From 270acd3bfa413d056f0b930a0fda81195c75aa30 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 31 Jul 2026 01:42:00 +0800 Subject: [PATCH 01/12] feat(schema,engine): probe an endpoint's model list for an unsaved account Adds config.probe-models: the daemon reads the endpoint's own model list with a secret the client has not persisted yet, so the account forms can offer a real model picker instead of a free-text field. Bumps the wire protocol version. --- packages/client/core/src/client.ts | 11 +++ .../client/core/src/client/control-channel.ts | 14 +++ .../core/src/client/pending-registry.ts | 3 + packages/client/sdk/src/client.ts | 11 +++ packages/client/sdk/src/operations.ts | 9 ++ .../src/settings/providers/capability.ts | 6 ++ .../foundation/schema/src/model/account.ts | 19 +++- packages/foundation/schema/src/wire/config.ts | 21 +++- .../foundation/schema/src/wire/message.ts | 2 +- .../src/__tests__/engine-model-probe.test.ts | 95 +++++++++++++++++++ .../engine/src/__tests__/model-probe.test.ts | 72 ++++++++++++++ packages/host/engine/src/agent/model-probe.ts | 77 +++++++++++++++ .../host/engine/src/agent/request-handler.ts | 28 ++++++ .../host/engine/src/wire/request-router.ts | 3 +- 14 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 packages/host/engine/src/__tests__/engine-model-probe.test.ts create mode 100644 packages/host/engine/src/__tests__/model-probe.test.ts create mode 100644 packages/host/engine/src/agent/model-probe.ts diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index f53a597bb..4025c10bf 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -1,4 +1,7 @@ import type { + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentEvent, AgentHistoryId, @@ -352,6 +355,9 @@ export class LinkCodeClient { this.pending.resolve('configGet', p.replyTo, p.providers); this.pending.resolve('accountsGet', p.replyTo, p.accounts); break; + case 'config.probe-models.result': + this.pending.resolve('accountModels', p.replyTo, p.models); + break; case 'agent-runtime.listed': this.pending.resolve('agentRuntimeList', p.replyTo, p.runtimes); break; @@ -712,6 +718,11 @@ export class LinkCodeClient { return this.control.getAccounts(); } + /** Model list an endpoint serves, read daemon-side with a not-yet-saved secret. */ + probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { + return this.control.probeAccountModels(endpoint, secret); + } + listAgentRuntimes(): Promise { return this.control.listAgentRuntimes(); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index dc2974c7d..c84d3ab2e 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,4 +1,7 @@ import type { + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentHistoryId, AgentHistoryListOptions, @@ -407,6 +410,17 @@ export class ControlChannel { })); } + /** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer + * the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */ + probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { + return this.sendCorrelated('accountModels', (clientReqId) => ({ + kind: 'config.probe-models', + clientReqId, + endpoint, + secret, + })); + } + /** Persist the daemon-owned global account pool (data plane). Preserves the provider config. */ setAccounts(accounts: Accounts): Promise { return this.sendCorrelated('ack', (clientReqId) => ({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index f24adf327..4c57454cd 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -1,4 +1,5 @@ import type { + AccountModel, Accounts, AgentHistoryListResult, AgentHistoryReadResult, @@ -71,6 +72,7 @@ export interface PendingValueMap { historyRead: AgentHistoryReadResult; configGet: ProvidersConfig; accountsGet: Accounts; + accountModels: AccountModel[]; agentRuntimeList: AgentRuntimes; agentCatalog: AgentStartCatalog; assetList: ManagedAssetStatus[]; @@ -124,6 +126,7 @@ export class PendingRegistry { historyRead: new Map(), configGet: new Map(), accountsGet: new Map(), + accountModels: new Map(), agentRuntimeList: new Map(), agentCatalog: new Map(), assetList: new Map(), diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index e14289a60..64705ea9c 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -6,6 +6,9 @@ import type { } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentHistoryId, AgentHistoryListResult, @@ -219,6 +222,14 @@ export class LinkCodeSdkClient { return toResult(this.raw.setAccounts(accounts)); } + /** Enumerate what an endpoint serves, using a secret that is not saved yet. */ + probeAccountModels( + endpoint: AccountEndpoint, + secret: AccountSecret, + ): RequestResult { + return toResult(this.raw.probeAccountModels(endpoint, secret)); + } + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ listAgentRuntimes(): RequestResult { return toResult(this.raw.listAgentRuntimes()); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index 35cc4c1ce..6a1c5a4fd 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -1,5 +1,8 @@ import type { HistoryListClientOptions, HistoryReadClientOptions } from '@linkcode/client-core'; import type { + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentHistoryId, AgentHistoryListResult, @@ -184,6 +187,12 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe return resolveClient(options).setAccounts(options.accounts); } +export function probeAccountModels( + options: Options<{ endpoint: AccountEndpoint; secret: AccountSecret }>, +): RequestResult { + return resolveClient(options).probeAccountModels(options.endpoint, options.secret); +} + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ export function listAgentRuntimes(options?: Options): RequestResult { return resolveClient(options).listAgentRuntimes(); diff --git a/packages/client/workbench/src/settings/providers/capability.ts b/packages/client/workbench/src/settings/providers/capability.ts index 6e571c925..ff92867a2 100644 --- a/packages/client/workbench/src/settings/providers/capability.ts +++ b/packages/client/workbench/src/settings/providers/capability.ts @@ -25,6 +25,12 @@ const TRANSLATABLE: ReadonlyArray<{ agent: AgentKind; upstream: AccountProtocol { agent: 'claude-code', upstream: 'openai-chat' }, ]; +/** The protocol an agent speaks natively — what an API-key login should offer it by default. + * grok-build accepts no custom endpoint at all, so its fallback is only a form default. */ +export function nativeAccountProtocol(kind: AgentKind): AccountProtocol { + return AGENT_NATIVE_PROTOCOLS[kind][0] ?? 'openai-chat'; +} + export type BindingTier = 'native' | 'translate' | 'unavailable'; export type BindingUnavailableReason = diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index f50d99352..3a7ef456f 100644 --- a/packages/foundation/schema/src/model/account.ts +++ b/packages/foundation/schema/src/model/account.ts @@ -12,12 +12,19 @@ import { AgentKindSchema, TimestampSchema } from './primitives'; export const AccountProtocolSchema = z.enum(['anthropic', 'openai-chat', 'openai-responses']); export type AccountProtocol = z.infer; -/** How an account's secret authenticates. */ -export const AccountCredentialSchema = z.discriminatedUnion('type', [ +/** The secret shapes LinkCode itself holds — the only ones that can authenticate a direct request + * to an endpoint (an `oauth` account has no secret here, so it cannot). */ +export const AccountSecretSchema = z.discriminatedUnion('type', [ /** `x-api-key`-style provider key. */ z.object({ type: z.literal('api-key'), key: z.string().min(1) }), /** Bearer token (e.g. `ANTHROPIC_AUTH_TOKEN`, or a gateway token). */ z.object({ type: z.literal('auth-token'), token: z.string().min(1) }), +]); +export type AccountSecret = z.infer; + +/** How an account's secret authenticates. */ +export const AccountCredentialSchema = z.discriminatedUnion('type', [ + ...AccountSecretSchema.options, /** Delegates to the agent CLI's own login store — LinkCode stores no secret. An OAuth login is * specific to one CLI, so the account names its agent. */ z.object({ type: z.literal('oauth'), agent: AgentKindSchema }), @@ -50,6 +57,14 @@ export const AccountSchema = z.object({ }); export type Account = z.infer; +/** A model an endpoint advertises on its own model list, as read by the daemon's probe. `label` is + * the provider's display name when it ships one; relays usually ship the bare id only. */ +export const AccountModelSchema = z.object({ + id: z.string().min(1), + label: z.string().optional(), +}); +export type AccountModel = z.infer; + /** The global account pool, keyed by position; account ids are unique within it. */ export const AccountsSchema = z.array(AccountSchema); export type Accounts = z.infer; diff --git a/packages/foundation/schema/src/wire/config.ts b/packages/foundation/schema/src/wire/config.ts index 845b72e4e..df7e86a15 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; -import { AccountsSchema } from '../model/account'; +import { + AccountEndpointSchema, + AccountModelSchema, + AccountSecretSchema, + AccountsSchema, +} from '../model/account'; import { ProvidersConfigSchema } from '../model/provider-config'; import { WireRequestIdSchema } from './request'; @@ -22,4 +27,18 @@ export const configWireVariants = [ /** The global account pool; omitted by a client editing only provider settings. */ accounts: AccountsSchema.optional(), }), + /** Enumerate what an endpoint serves, before the account is saved: the daemon reads the + * endpoint's own model list with the given secret. The client cannot do this itself — the + * renderer's CSP blocks remote fetches, and only the daemon may hold the secret. */ + z.object({ + kind: z.literal('config.probe-models'), + clientReqId: WireRequestIdSchema, + endpoint: AccountEndpointSchema, + secret: AccountSecretSchema, + }), + z.object({ + kind: z.literal('config.probe-models.result'), + replyTo: WireRequestIdSchema, + models: z.array(AccountModelSchema), + }), ] as const; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index bc78deaed..d2b84c54d 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WirePayloadSchema } from './payload'; */ // Bump on every wire schema change; mismatched peers silently discard all frames (Invariant 1). -export const WIRE_PROTOCOL_VERSION = 63 as const; +export const WIRE_PROTOCOL_VERSION = 64 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts new file mode 100644 index 000000000..f95a59574 --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -0,0 +1,95 @@ +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { WirePayload } from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSessionHarness } from './fixtures/session-harness'; + +/** The probe is a real HTTP round-trip, so its reply lands after `inject`'s task settle. */ +function replyFor(sent: WirePayload[], clientReqId: string): Promise { + return vi.waitFor(() => + nullthrow( + sent.find((p) => 'replyTo' in p && p.replyTo === clientReqId), + `no reply for ${clientReqId}`, + ), + ); +} + +/** A stand-in relay: answers one model-list route and 401s everything else. */ +function startRelay(handler: (url: string) => { status: number; body: string }): Promise { + const server = createServer((req, res) => { + const { status, body } = handler(req.url ?? ''); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(body); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +function baseUrl(server: Server): string { + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +let relay: Server | undefined; + +afterEach(async () => { + const server = relay; + if (server) { + await new Promise((resolve) => { + server.close(resolve); + }); + } + relay = undefined; +}); + +describe('config.probe-models', () => { + it('answers with the models the endpoint serves for an unsaved secret', async () => { + const seen: string[] = []; + relay = await startRelay((url) => { + seen.push(url); + return url === '/v1/models' + ? { status: 200, body: JSON.stringify({ data: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }] }) } + : { status: 404, body: '{}' }; + }); + const h = createSessionHarness(); + await h.engine.start(); + + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-1', + endpoint: { baseUrl: `${baseUrl(relay)}/v1`, protocol: 'openai-chat' }, + secret: { type: 'api-key', key: 'sk-test' }, + }); + + await expect(replyFor(h.sent, 'probe-1')).resolves.toEqual({ + kind: 'config.probe-models.result', + replyTo: 'probe-1', + models: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }], + }); + expect(seen).toEqual(['/v1/models']); + }); + + it("relays the endpoint's own rejection to the client", async () => { + relay = await startRelay(() => ({ + status: 401, + body: JSON.stringify({ error: 'invalid api key' }), + })); + const h = createSessionHarness(); + await h.engine.start(); + + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-2', + endpoint: { baseUrl: baseUrl(relay), protocol: 'anthropic' }, + secret: { type: 'api-key', key: 'bad' }, + }); + + const failed = await replyFor(h.sent, 'probe-2'); + if (failed.kind !== 'request.failed') throw new Error('no request.failed for probe-2'); + expect(failed.message).toContain('401'); + expect(failed.message).toContain('invalid api key'); + }); +}); diff --git a/packages/host/engine/src/__tests__/model-probe.test.ts b/packages/host/engine/src/__tests__/model-probe.test.ts new file mode 100644 index 000000000..7126b7504 --- /dev/null +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -0,0 +1,72 @@ +import type { AccountEndpoint } from '@linkcode/schema'; +import { describe, expect, it, vi } from 'vitest'; +import { modelListHeaders, modelListUrl, probeEndpointModels } from '../agent/model-probe'; + +const anthropic: AccountEndpoint = { baseUrl: 'https://relay.test/', protocol: 'anthropic' }; +const openai: AccountEndpoint = { baseUrl: 'https://relay.test/v1', protocol: 'openai-chat' }; +const REJECTION_PATTERN = /401.*invalid api key/; +const NOT_A_LIST_PATTERN = /did not answer a model list/; + +function jsonResponse(body: unknown): Response { + return Response.json(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('endpoint model list addressing', () => { + it('appends /v1 for Anthropic-shaped base URLs and only /models for OpenAI-shaped ones', () => { + expect(modelListUrl(anthropic)).toBe('https://relay.test/v1/models?limit=1000'); + expect(modelListUrl(openai)).toBe('https://relay.test/v1/models'); + }); + + it('authenticates per protocol and credential shape', () => { + expect(modelListHeaders(anthropic, { type: 'api-key', key: 'k' })).toMatchObject({ + 'x-api-key': 'k', + 'anthropic-version': '2023-06-01', + }); + expect(modelListHeaders(anthropic, { type: 'auth-token', token: 't' })).toMatchObject({ + authorization: 'Bearer t', + 'anthropic-version': '2023-06-01', + }); + expect(modelListHeaders(openai, { type: 'api-key', key: 'k' }).authorization).toBe('Bearer k'); + }); +}); + +describe('probeEndpointModels', () => { + it("reads the vendors' {data} envelope, keeping display names and order", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + data: [{ id: 'claude-opus-4', display_name: 'Claude Opus 4' }, { id: 'claude-haiku-4' }], + }), + ); + await expect( + probeEndpointModels(anthropic, { type: 'api-key', key: 'k' }, fetchImpl), + ).resolves.toEqual([{ id: 'claude-opus-4', label: 'Claude Opus 4' }, { id: 'claude-haiku-4' }]); + }); + + it('accepts a bare array and drops duplicate ids', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse([{ id: 'gpt-5' }, { id: 'gpt-5' }, { id: 'gpt-5-mini' }])); + await expect( + probeEndpointModels(openai, { type: 'api-key', key: 'k' }, fetchImpl), + ).resolves.toEqual([{ id: 'gpt-5' }, { id: 'gpt-5-mini' }]); + }); + + it("surfaces the endpoint's own rejection so the user can act on it", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response('{"error":"invalid api key"}', { status: 401 })); + await expect( + probeEndpointModels(openai, { type: 'api-key', key: 'bad' }, fetchImpl), + ).rejects.toThrow(REJECTION_PATTERN); + }); + + it('rejects a response that is not a model list', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + await expect( + probeEndpointModels(openai, { type: 'api-key', key: 'k' }, fetchImpl), + ).rejects.toThrow(NOT_A_LIST_PATTERN); + }); +}); diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts new file mode 100644 index 000000000..6e88f5780 --- /dev/null +++ b/packages/host/engine/src/agent/model-probe.ts @@ -0,0 +1,77 @@ +import type { AccountEndpoint, AccountModel, AccountSecret } from '@linkcode/schema'; +import { z } from 'zod'; + +/** + * Reads what an endpoint (gateway / relay / direct vendor API) actually serves, so the account + * forms can offer a real model list instead of a free-text field. The daemon owns this call: it + * holds the secret, and the renderer's CSP blocks remote fetches anyway. + * + * Endpoint facts (2026-07): OpenAI-shaped `baseUrl`s already end in `/v1`, Anthropic-shaped ones + * are the bare origin — hence the two different model-list paths below. Both answer + * `{data:[{id,…}]}`; Anthropic adds `display_name` and paginates (one 1000-entry page is every + * model any vendor currently serves). + */ + +const PROBE_TIMEOUT_MS = 10000; +const ANTHROPIC_VERSION = '2023-06-01'; +/** Trim of an error body kept for the user-facing message — enough to read a vendor's reason. */ +const ERROR_BODY_LIMIT = 200; +const TRAILING_SLASH_PATTERN = /\/+$/; + +/** Tolerant of a relay that answers a bare array instead of the vendors' `{data}` envelope. */ +const ModelListSchema = z.union([ + z.object({ data: z.array(z.object({ id: z.string(), display_name: z.string().optional() })) }), + z.array(z.object({ id: z.string(), display_name: z.string().optional() })), +]); + +export function modelListUrl(endpoint: AccountEndpoint): string { + const base = endpoint.baseUrl.replace(TRAILING_SLASH_PATTERN, ''); + return endpoint.protocol === 'anthropic' ? `${base}/v1/models?limit=1000` : `${base}/models`; +} + +export function modelListHeaders( + endpoint: AccountEndpoint, + secret: AccountSecret, +): Record { + const headers: Record = { accept: 'application/json' }; + if (endpoint.protocol === 'anthropic') { + headers['anthropic-version'] = ANTHROPIC_VERSION; + // An Anthropic-shaped gateway authenticates with whichever header its own credential is. + if (secret.type === 'api-key') headers['x-api-key'] = secret.key; + else headers.authorization = `Bearer ${secret.token}`; + return headers; + } + headers.authorization = `Bearer ${secret.type === 'api-key' ? secret.key : secret.token}`; + return headers; +} + +/** Model ids the endpoint advertises, deduped, in the order it listed them. Rejects with a + * user-facing message (status + the vendor's own reason) — the dialog shows it verbatim. */ +export async function probeEndpointModels( + endpoint: AccountEndpoint, + secret: AccountSecret, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = modelListUrl(endpoint); + const response = await fetchImpl(url, { + headers: modelListHeaders(endpoint, secret), + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (!response.ok) { + const body = (await response.text().catch(() => '')).trim().slice(0, ERROR_BODY_LIMIT); + throw new Error(`${response.status} ${response.statusText}${body ? ` — ${body}` : ''}`); + } + const parsed = ModelListSchema.safeParse(await response.json()); + if (!parsed.success) throw new Error(`${url} did not answer a model list`); + const entries = Array.isArray(parsed.data) ? parsed.data : parsed.data.data; + const byId = new Map(); + for (const entry of entries) { + if (entry.id && !byId.has(entry.id)) { + byId.set(entry.id, { + id: entry.id, + ...(entry.display_name && { label: entry.display_name }), + }); + } + } + return [...byId.values()]; +} diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index b62f37afd..2d64c0386 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -3,9 +3,11 @@ import type { WirePayload } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; +import { extractErrorMessage } from 'foxts/extract-error-message'; import { OperationError, RequestError } from '../failure'; import type { WireResponder } from '../wire/responder'; import type { AgentLoginService } from './login-service'; +import { probeEndpointModels } from './model-probe'; import type { ProviderConfigStore } from './provider-config'; import { applyProviderDefaults } from './provider-config'; import type { AgentRuntimeService } from './runtime-service'; @@ -18,6 +20,7 @@ type AgentRequest = Extract< | 'agent.catalog' | 'config.get' | 'config.set' + | 'config.probe-models' | 'agent-login.start' | 'agent-login.submit-code' | 'agent-login.cancel'; @@ -123,6 +126,31 @@ export class AgentRequestHandler { ), ); } + case 'config.probe-models': + return this.responder.reply( + payload.clientReqId, + Effect.tryPromise({ + try: async () => { + const models = await probeEndpointModels(payload.endpoint, payload.secret); + this.transport.send( + createWireMessage({ + kind: 'config.probe-models.result', + replyTo: payload.clientReqId, + models, + }), + ); + }, + // The vendor's own reason (bad key, unreachable host) is the whole value of the probe, + // so it rides publicMessage — the only field that reaches the client. + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'config.probe-models', + publicMessage: `Model detection failed: ${extractErrorMessage(cause, false) ?? 'unknown error'}`, + cause, + }), + }), + ); case 'agent-login.start': { const logins = this.logins; if (logins) { diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index eba04eda3..790dfbfea 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -73,7 +73,8 @@ export class WireRequestRouter { case 'agent-runtime.list': case 'agent.catalog': case 'config.get': - case 'config.set': { + case 'config.set': + case 'config.probe-models': { return this.handlers.agent.handle(p); } case 'asset.list': From b9c7bd375363f4a22740ca908bfc96e848be1bba Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 31 Jul 2026 01:42:25 +0800 Subject: [PATCH 02/12] feat(workbench,ui): offer an API-key login on the signed-out agent card The signed-out onboarding card gains an "API key" path next to the OAuth button: a dialog collects a relay key plus its base URL, detects the endpoint's models, saves it as a pool account and binds it as the agent's active one. A bound key account now clears the login cue, the way the legacy per-agent key already did. --- .../src/renderer/src/shell/desktop-shell.tsx | 3 + .../__tests__/onboarding.test.ts | 42 ++- .../agent-runtime/api-key-login/dialog.tsx | 317 ++++++++++++++++++ .../src/agent-runtime/api-key-login/store.ts | 19 ++ .../workbench/src/agent-runtime/onboarding.ts | 43 ++- .../workbench/src/surface/workbench.tsx | 143 ++++---- packages/presentation/i18n/src/locales/en.ts | 29 ++ .../presentation/i18n/src/locales/zh-cn.ts | 29 ++ .../__tests__/agent-onboarding-card.test.tsx | 40 +++ .../ui/src/shell/agent-onboarding-card.tsx | 32 +- .../ui/src/shell/conversation-surface.tsx | 4 + .../ui/src/shell/new-session-surface.tsx | 4 + .../presentation/ui/src/shell/shell-frame.tsx | 5 + 13 files changed, 627 insertions(+), 83 deletions(-) create mode 100644 packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx create mode 100644 packages/client/workbench/src/agent-runtime/api-key-login/store.ts diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 816b72e2b..57ef63d59 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -82,6 +82,7 @@ export function DesktopShell({ onLoginAgent, onSubmitLoginCode, onCancelLogin, + onUseApiKey, conversation, respondingRequestIds, responseErrors, @@ -416,6 +417,7 @@ export function DesktopShell({ onLoginAgent={onLoginAgent} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} + onUseApiKey={onUseApiKey} onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onPickDirectory={pickDirectory} @@ -437,6 +439,7 @@ export function DesktopShell({ onLoginAgent={onLoginAgent} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} + onUseApiKey={onUseApiKey} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent} diff --git a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts index 365e3ac2a..0d8efec20 100644 --- a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts @@ -1,4 +1,9 @@ -import type { AgentRuntimes, ManagedAssetStatus } from '@linkcode/schema'; +import type { + Accounts, + AgentRuntimes, + ManagedAssetStatus, + ProvidersConfig, +} from '@linkcode/schema'; import { managedAgentAssetId, managedToolAssetId } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import type { AssetActivityMap } from '../onboarding'; @@ -199,6 +204,41 @@ describe('deriveAgentRuntimeCues', () => { 'claude-code': { state: 'needs-login', phase: 'idle' }, }); }); + + it('suppresses the login cue for a bound key account, but not for a bound oauth one', () => { + const runtimes: AgentRuntimes = { + 'claude-code': { status: 'available', source: 'detected', auth: { loggedIn: false } }, + }; + const providers: ProvidersConfig = { + 'claude-code': { enabled: true, activeAccountId: 'acc_1' }, + }; + const relay: Accounts = [ + { + id: 'acc_1', + label: 'relay', + credential: { type: 'api-key', key: 'sk-x' }, + endpoint: { baseUrl: 'https://relay.test', protocol: 'anthropic' }, + createdAt: 1, + }, + ]; + expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, relay)).toEqual({}); + // An oauth account carries no secret — it delegates back to the signed-out CLI login. + const delegated: Accounts = [ + { + id: 'acc_1', + label: 'Claude', + credential: { type: 'oauth', agent: 'claude-code' }, + createdAt: 1, + }, + ]; + expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, delegated)).toEqual({ + 'claude-code': { state: 'needs-login', phase: 'idle' }, + }); + // A stale binding (account deleted) leaves nothing injected either. + expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, [])).toEqual({ + 'claude-code': { state: 'needs-login', phase: 'idle' }, + }); + }); }); describe('dropConfirmedInstalls', () => { diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx new file mode 100644 index 000000000..c630c0119 --- /dev/null +++ b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx @@ -0,0 +1,317 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { Account, AccountModel, AccountSecret, AgentKind } from '@linkcode/schema'; +import { AccountProtocolSchema } from '@linkcode/schema'; +import { + getAccounts, + getProviderConfig, + probeAccountModels, + setAccounts, + setProviderConfig, +} from '@linkcode/sdk'; +import { AGENT_LABELS } from '@linkcode/ui'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Form } from 'coss-ui/components/form'; +import { Input } from 'coss-ui/components/input'; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { Loader2Icon } from 'lucide-react'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { z } from 'zod'; +import { rhfErrorsToFormErrors } from '../../lib/form'; +import { useData, useMutation } from '../../runtime/tayori'; +import { nativeAccountProtocol } from '../../settings/providers/capability'; +import { withBinding } from '../../settings/providers/view'; +import { useAgentApiKeyLoginStore } from './store'; + +const DraftSchema = z.object({ + label: z.string().trim().min(1), + baseUrl: z.url(), + protocol: AccountProtocolSchema, + credentialType: z.enum(['api-key', 'auth-token']), + secret: z.string().trim().min(1), + model: z.string().trim(), +}); +type Draft = z.infer; + +function draftSecret(draft: Pick): AccountSecret { + return draft.credentialType === 'auth-token' + ? { type: 'auth-token', token: draft.secret.trim() } + : { type: 'api-key', key: draft.secret.trim() }; +} + +/** Account constructor at module scope: `Date.now` may not run in a component body. */ +function accountFromDraft(draft: Draft): Account { + return { + id: `acc_${crypto.randomUUID()}`, + label: draft.label.trim(), + credential: draftSecret(draft), + endpoint: { baseUrl: draft.baseUrl.trim(), protocol: draft.protocol }, + ...(draft.model.trim() && { model: draft.model.trim() }), + createdAt: Date.now(), + }; +} + +/** + * The API-key alternative to a CLI's OAuth login, reachable from the signed-out onboarding card: + * a relay/gateway key plus its base URL, saved as a normal pool account and bound as the agent's + * active one — the same contract session start already injects. Mounted once beside the shell; the + * card only opens it (see {@link useAgentApiKeyLoginStore}). + */ +export function AgentApiKeyLoginDialog(): React.ReactNode { + const kind = useAgentApiKeyLoginStore((state) => state.kind); + const close = useAgentApiKeyLoginStore((state) => state.close); + const t = useTranslations('workbench.apiKeyLogin'); + + return ( + !open && close()}> + + + {t('title', { agent: kind ? AGENT_LABELS[kind] : '' })} + {t('hint')} + + + {/* Keyed per agent so switching agents starts from that agent's own defaults. */} + {kind !== null && } + + + + ); +} + +function ApiKeyLoginForm({ + kind, + onDone, +}: { + kind: AgentKind; + onDone: () => void; +}): React.ReactNode { + const t = useTranslations('workbench.apiKeyLogin'); + const { data: accounts, mutate: mutateAccounts } = useData(getAccounts, {}); + const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); + const saveAccounts = useMutation(setAccounts); + const saveProviders = useMutation(setProviderConfig); + const probe = useMutation(probeAccountModels); + // Detection result and its failure are transient UI state, not part of the submitted draft. + const [detected, setDetected] = useState(null); + const [detectError, setDetectError] = useState(null); + + const { + control, + register, + handleSubmit, + getValues, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(DraftSchema), + defaultValues: { + label: t('defaultLabel', { agent: AGENT_LABELS[kind] }), + baseUrl: '', + protocol: nativeAccountProtocol(kind), + credentialType: 'api-key', + secret: '', + model: '', + }, + }); + + const onSubmit = handleSubmit(async (draft) => { + try { + const account = accountFromDraft(draft); + await saveAccounts.trigger({ accounts: [...(accounts ?? []), account] }); + await saveProviders.trigger({ providers: withBinding(providers ?? {}, kind, account.id) }); + await Promise.all([mutateAccounts(), mutateProviders()]); + onDone(); + } catch (error) { + setError('root', { message: extractErrorMessage(error, false) ?? t('saveFailed') }); + } + }); + + // Imperative read: detection is an action on the current values, not a field subscription. + async function detect(): Promise { + const draft = getValues(); + setDetectError(null); + const endpoint = z + .object({ baseUrl: z.url(), protocol: AccountProtocolSchema }) + .safeParse({ baseUrl: draft.baseUrl.trim(), protocol: draft.protocol }); + if (!endpoint.success || draft.secret.trim() === '') { + setDetectError(t('detectNeedsEndpoint')); + return; + } + try { + const models = await probe.trigger({ endpoint: endpoint.data, secret: draftSecret(draft) }); + setDetected(models); + if (models.length === 0) setDetectError(t('detectEmpty')); + } catch (error) { + setDetected(null); + setDetectError(extractErrorMessage(error, false) ?? t('detectFailed')); + } + } + + const protocolItems = AccountProtocolSchema.options.map((protocol) => ({ + value: protocol, + label: t(`protocol.${protocol}`), + })); + const credentialItems = [ + { value: 'api-key', label: t('credentialApiKey') }, + { value: 'auth-token', label: t('credentialAuthToken') }, + ]; + + return ( +
+ + {t('labelField')} + + + + + {t('baseUrl')} + + + +
+
+ + {t('credentialType')} + ( + + )} + /> + +
+
+ + {t('protocol.field')} + ( + + )} + /> + +
+
+ + {t('secret')} + + + + + {t('model')} +
+ {detected && detected.length > 0 ? ( + ( + ({ + value: model.id, + label: model.label ?? model.id, + }))} + value={field.value} + onValueChange={field.onChange} + /> + )} + /> + ) : ( + + )} + +
+ {detectError !== null && {detectError}} + {detected && detected.length > 0 && detectError === null && ( + + {t('detectCount', { count: detected.length })} + + )} +
+
+ + +
+
+ ); +} + +function DraftSelect({ + items, + value, + onValueChange, +}: { + items: Array<{ value: string; label: string }>; + value: string; + onValueChange: (value: string) => void; +}): React.ReactNode { + return ( + + ); +} diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts new file mode 100644 index 000000000..218a157c2 --- /dev/null +++ b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts @@ -0,0 +1,19 @@ +import type { AgentKind } from '@linkcode/schema'; +import { create } from 'zustand'; + +/** + * Which agent's API-key login dialog is open. Module-scope, not component state: the onboarding + * card that opens it lives several prop hops below the workbench, and the dialog is mounted beside + * the shell rather than inside the card. Not persisted. + */ +interface AgentApiKeyLoginState { + kind: AgentKind | null; + open: (kind: AgentKind) => void; + close: () => void; +} + +export const useAgentApiKeyLoginStore = create()((set) => ({ + kind: null, + open: (kind) => set({ kind }), + close: () => set({ kind: null }), +})); diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index 04ee345a7..967841c9e 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -1,5 +1,6 @@ import { useLinkCodeClient } from '@linkcode/client-core'; import type { + Accounts, AgentKind, AgentRuntimeAvailability, AgentRuntimes, @@ -9,7 +10,7 @@ import type { ProvidersConfig, } from '@linkcode/schema'; import { managedAgentAssetId, managedAssetIdEquals, managedAssetKey } from '@linkcode/schema'; -import { ensureAsset, getProviderConfig } from '@linkcode/sdk'; +import { ensureAsset, getAccounts, getProviderConfig } from '@linkcode/sdk'; import type { AgentRuntimeCue, AgentRuntimeCues } from '@linkcode/ui'; import { noop } from 'foxact/noop'; import { useEffect } from 'foxact/use-abortable-effect'; @@ -100,18 +101,45 @@ export function deriveAgentRuntimeCues( acknowledged: Partial>, loginActivity: LoginActivityMap = {}, providers: ProvidersConfig = {}, + accounts: Accounts = [], ): AgentRuntimeCues { const cues: AgentRuntimeCues = {}; if (!runtimes) return cues; for (const [kind, runtime] of Object.entries(runtimes) as Array< [AgentKind, AgentRuntimeAvailability] >) { - const cue = deriveCue(kind, runtime, assets, activity, acknowledged, loginActivity, providers); + const cue = deriveCue( + kind, + runtime, + assets, + activity, + acknowledged, + loginActivity, + providers, + accounts, + ); if (cue) cues[kind] = cue; } return cues; } +/** + * Whether LinkCode injects a secret for this agent at spawn, which makes a signed-out CLI runnable + * (`applyProviderDefaults`): the bound account's own key/token, or the legacy per-agent api key. An + * `oauth` account delegates back to the CLI's login store, so it does not count. + */ +export function hasInjectedCredential( + kind: AgentKind, + providers: ProvidersConfig, + accounts: Accounts, +): boolean { + if (providers[kind]?.apiKey !== undefined) return true; + const boundId = providers[kind]?.activeAccountId; + if (boundId === undefined) return false; + const bound = accounts.find((account) => account.id === boundId); + return bound?.credential.type === 'api-key' || bound?.credential.type === 'auth-token'; +} + /** The login cue for a signed-out runtime, its phase driven by any in-flight login activity. */ function loginCue(activity: LoginActivity | undefined): AgentRuntimeCue { if (activity?.kind === 'opening') return { state: 'needs-login', phase: 'opening' }; @@ -151,12 +179,13 @@ function deriveCue( acknowledged: Partial>, loginActivity: LoginActivityMap, providers: ProvidersConfig, + accounts: Accounts, ): AgentRuntimeCue | undefined { switch (runtime.status) { case 'available': - // `auth` absent means unprobed or a fail-open probe — don't block. A configured API key is - // injected at spawn (applyProviderDefaults), making a signed-out CLI runnable — no cue then. - return runtime.auth?.loggedIn === false && !providers[kind]?.apiKey + // `auth` absent means unprobed or a fail-open probe — don't block. An injected credential + // makes a signed-out CLI runnable, so it clears the cue (see hasInjectedCredential). + return runtime.auth?.loggedIn === false && !hasInjectedCredential(kind, providers, accounts) ? loginCue(loginActivity[kind]) : undefined; case 'out-of-range': { @@ -199,8 +228,9 @@ export function useAgentRuntimeOnboarding(): { const client = useLinkCodeClient(); const { data: runtimes } = useAgentRuntimes(); const { data: assets } = useAssets(); - // A saved API key makes a signed-out CLI runnable, so it suppresses the login cue. + // An injected credential makes a signed-out CLI runnable, so it suppresses the login cue. const { data: providers } = useData(getProviderConfig, {}); + const { data: accounts } = useData(getAccounts, {}); const [activity, setActivity] = useState({}); const [loginActivity, setLoginActivity] = useState({}); // The in-flight loginId per kind (+ a cancel flag so a user-aborted login settles to idle, not @@ -336,6 +366,7 @@ export function useAgentRuntimeOnboarding(): { acknowledged, loginActivity, providers, + accounts, ), download, acknowledgeUnverified, diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index f711bfc49..6b766d2e2 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -46,6 +46,8 @@ import { useSet } from 'foxact/use-set'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; +import { AgentApiKeyLoginDialog } from '../agent-runtime/api-key-login/dialog'; +import { useAgentApiKeyLoginStore } from '../agent-runtime/api-key-login/store'; import { useAgentRuntimeOnboarding } from '../agent-runtime/onboarding'; import { captureProductEvent } from '../analytics/product-analytics'; import { useFileMentionSource } from '../files/mentions'; @@ -270,6 +272,7 @@ function WorkbenchSessionSurface({ (state) => state.branchesByWorkspace, ); const onboarding = useAgentRuntimeOnboarding(); + const openApiKeyLogin = useAgentApiKeyLoginStore((state) => state.open); const rememberNewSessionDefaults = useNewSessionDefaultsStore((state) => state.remember); const rememberSelection = useNewSessionDefaultsStore((state) => state.rememberSelection); const [previewExpandedKeys, addPreviewExpanded, removePreviewExpanded] = useSet(); @@ -611,73 +614,77 @@ function WorkbenchSessionSurface({ } return ( - + <> + + + ); } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 0738d605d..a6422f77c 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -290,6 +290,35 @@ export const en = { loginReopenUrl: "Didn't open? Open the sign-in page", loginCancel: 'Cancel', loginFailedTitle: '{agent} sign-in failed', + loginWithApiKey: 'Use an API key', + }, + apiKeyLogin: { + title: 'Sign in to {agent} with an API key', + hint: "Paste a relay or gateway API key and its base URL. It is saved as a pool account and bound as this agent's active one.", + defaultLabel: '{agent} relay', + labelField: 'Name', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'https://…', + credentialType: 'Credential type', + credentialApiKey: 'API key (x-api-key)', + credentialAuthToken: 'Auth token (Bearer)', + secret: 'Secret', + protocol: { + field: 'Protocol', + anthropic: 'Anthropic', + 'openai-chat': 'OpenAI Chat', + 'openai-responses': 'OpenAI Responses', + }, + model: 'Default model', + modelPlaceholder: "Leave empty to use the agent's default", + detect: 'Detect models', + detectCount: '{count} models detected', + detectEmpty: 'The endpoint returned no models', + detectNeedsEndpoint: 'Enter a valid base URL and secret first', + detectFailed: 'Detection failed', + saveFailed: 'Could not save', + cancel: 'Cancel', + save: 'Save and use', }, composer: { placeholder: 'Describe what you want {agent} to do, or @-reference a file / terminal output…', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 59d961420..f7b3ee076 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -281,6 +281,35 @@ export const zhCN = { loginReopenUrl: '没有自动打开?点此打开登录页', loginCancel: '取消', loginFailedTitle: '{agent} 登录失败', + loginWithApiKey: '用 API Key 登录', + }, + apiKeyLogin: { + title: '用 API Key 登录 {agent}', + hint: '填入中转站或网关的 API Key 与 Base URL。保存后会作为账号存入账号池,并绑定为该 agent 的活跃账号。', + defaultLabel: '{agent} 中转站', + labelField: '名称', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'https://…', + credentialType: '凭据类型', + credentialApiKey: 'API Key(x-api-key)', + credentialAuthToken: 'Auth Token(Bearer)', + secret: '密钥', + protocol: { + field: '接口协议', + anthropic: 'Anthropic', + 'openai-chat': 'OpenAI Chat', + 'openai-responses': 'OpenAI Responses', + }, + model: '默认模型', + modelPlaceholder: '留空则用 agent 默认模型', + detect: '检测模型', + detectCount: '检测到 {count} 个模型', + detectEmpty: '该端点没有返回任何模型', + detectNeedsEndpoint: '请先填写有效的 Base URL 与密钥', + detectFailed: '检测失败', + saveFailed: '保存失败', + cancel: '取消', + save: '保存并使用', }, composer: { placeholder: '描述想让 {agent} 做什么,或用 @ 引用文件 / 终端输出…', diff --git a/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx b/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx index 8c044132b..8b523d083 100644 --- a/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx @@ -41,3 +41,43 @@ describe('AgentLoginCard awaiting phase per kind (CODE-174)', () => { expect(screen.getByRole('button', { name: 'loginCancel' })).toBeTruthy(); }); }); + +describe('API-key alternative to the OAuth login', () => { + it('offers it next to the sign-in button and reports the agent', () => { + const onUseApiKey = vi.fn(); + render( + , + ); + screen.getByRole('button', { name: 'loginWithApiKey' }).click(); + expect(onUseApiKey).toHaveBeenCalledWith('claude-code'); + }); + + it('offers it as the recovery after a failed login', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'retry' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'loginWithApiKey' })).toBeTruthy(); + }); + + it('stays hidden when the host provides no handler', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: 'loginWithApiKey' })).toBeNull(); + }); +}); diff --git a/packages/presentation/ui/src/shell/agent-onboarding-card.tsx b/packages/presentation/ui/src/shell/agent-onboarding-card.tsx index 474159296..6fd8a85a0 100644 --- a/packages/presentation/ui/src/shell/agent-onboarding-card.tsx +++ b/packages/presentation/ui/src/shell/agent-onboarding-card.tsx @@ -40,6 +40,7 @@ export function AgentOnboardingCard({ onLogin, onSubmitLoginCode, onCancelLogin, + onUseApiKey, }: { kind: AgentKind; cue: AgentRuntimeCue; @@ -48,6 +49,8 @@ export function AgentOnboardingCard({ onLogin?: (kind: AgentKind) => void; onSubmitLoginCode?: (kind: AgentKind, code: string) => void; onCancelLogin?: (kind: AgentKind) => void; + /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ + onUseApiKey?: (kind: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); @@ -59,6 +62,7 @@ export function AgentOnboardingCard({ onLogin={onLogin} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} + onUseApiKey={onUseApiKey} /> ); } @@ -163,12 +167,14 @@ function AgentLoginCard({ onLogin, onSubmitLoginCode, onCancelLogin, + onUseApiKey, }: { kind: AgentKind; cue: Extract; onLogin?: (kind: AgentKind) => void; onSubmitLoginCode?: (kind: AgentKind, code: string) => void; onCancelLogin?: (kind: AgentKind) => void; + onUseApiKey?: (kind: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); const agent = AGENT_LABELS[kind]; @@ -263,13 +269,18 @@ function AgentLoginCard({ {t('loginFailedTitle', { agent })} {cue.error && {cue.error}} - {onLogin && ( - + + {onLogin && ( - - )} + )} + {onUseApiKey && ( + + )} + ); } @@ -279,13 +290,18 @@ function AgentLoginCard({ {t('needsLoginTitle', { agent })} {t('needsLoginBody', { agent })} - {onLogin && ( - + + {onLogin && ( - - )} + )} + {onUseApiKey && ( + + )} + ); } diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 46ec48001..e63d9d0a3 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -48,6 +48,8 @@ export interface ConversationSurfaceProps { onSubmitLoginCode?: (kind: AgentKind, code: string) => void; /** Aborts an in-flight login. */ onCancelLogin?: (kind: AgentKind) => void; + /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ + onUseApiKey?: (kind: AgentKind) => void; disabled?: boolean; isRunning: boolean; className?: string; @@ -90,6 +92,7 @@ export function ConversationSurface({ onLoginAgent, onSubmitLoginCode, onCancelLogin, + onUseApiKey, disabled = false, isRunning, className, @@ -153,6 +156,7 @@ export function ConversationSurface({ onCancelLogin={onCancelLogin} onLogin={onLoginAgent} onSubmitLoginCode={onSubmitLoginCode} + onUseApiKey={onUseApiKey} /> diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 3fd75c6cc..836cf4cbc 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -109,6 +109,8 @@ export interface NewSessionSurfaceProps { onSubmitLoginCode?: (kind: AgentKind, code: string) => void; /** Aborts an in-flight login. */ onCancelLogin?: (kind: AgentKind) => void; + /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ + onUseApiKey?: (kind: AgentKind) => void; /** Starts the session and sends the prompt. A rejection keeps the page up — the caller's error * banner reports the failure, same contract as the conversation composer. */ onSubmit: (submission: NewSessionSubmission) => Promise; @@ -158,6 +160,7 @@ export function NewSessionSurface({ onLoginAgent, onSubmitLoginCode, onCancelLogin, + onUseApiKey, onSubmit, onPickDirectory, onRegisterWorkspace, @@ -320,6 +323,7 @@ export function NewSessionSurface({ onDownload={onDownloadAgent} onLogin={onLoginAgent} onSubmitLoginCode={onSubmitLoginCode} + onUseApiKey={onUseApiKey} /> diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 134908d82..ad99cb62a 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -71,6 +71,8 @@ export interface ShellFrameProps onSubmitLoginCode?: (kind: AgentKind, code: string) => void; /** Aborts an in-flight login. */ onCancelLogin?: (kind: AgentKind) => void; + /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ + onUseApiKey?: (kind: AgentKind) => void; conversation: ConversationViewModel; respondingRequestIds: ReadonlySet; responseErrors?: ReadonlyMap; @@ -135,6 +137,7 @@ export function ShellFrame({ onLoginAgent, onSubmitLoginCode, onCancelLogin, + onUseApiKey, conversation, respondingRequestIds, responseErrors, @@ -227,6 +230,7 @@ export function ShellFrame({ onLoginAgent={onLoginAgent} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} + onUseApiKey={onUseApiKey} onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onRegisterWorkspace={onRegisterWorkspace} @@ -248,6 +252,7 @@ export function ShellFrame({ onLoginAgent={onLoginAgent} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} + onUseApiKey={onUseApiKey} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent} From 718371d57e15116a50adef064b5cd4946ec73dc4 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 31 Jul 2026 04:42:34 +0000 Subject: [PATCH 03/12] fix(engine): harden model endpoint probing --- .../engine/src/__tests__/model-probe.test.ts | 32 +-- packages/host/engine/src/agent/model-probe.ts | 243 ++++++++++++++++-- 2 files changed, 244 insertions(+), 31 deletions(-) diff --git a/packages/host/engine/src/__tests__/model-probe.test.ts b/packages/host/engine/src/__tests__/model-probe.test.ts index 7126b7504..f283c7955 100644 --- a/packages/host/engine/src/__tests__/model-probe.test.ts +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -1,5 +1,6 @@ import type { AccountEndpoint } from '@linkcode/schema'; import { describe, expect, it, vi } from 'vitest'; +import type { ModelListRequest } from '../agent/model-probe'; import { modelListHeaders, modelListUrl, probeEndpointModels } from '../agent/model-probe'; const anthropic: AccountEndpoint = { baseUrl: 'https://relay.test/', protocol: 'anthropic' }; @@ -7,11 +8,8 @@ const openai: AccountEndpoint = { baseUrl: 'https://relay.test/v1', protocol: 'o const REJECTION_PATTERN = /401.*invalid api key/; const NOT_A_LIST_PATTERN = /did not answer a model list/; -function jsonResponse(body: unknown): Response { - return Response.json(body, { - status: 200, - headers: { 'content-type': 'application/json' }, - }); +function jsonResponse(body: unknown): Awaited> { + return { status: 200, statusText: 'OK', body: JSON.stringify(body) }; } describe('endpoint model list addressing', () => { @@ -35,38 +33,40 @@ describe('endpoint model list addressing', () => { describe('probeEndpointModels', () => { it("reads the vendors' {data} envelope, keeping display names and order", async () => { - const fetchImpl = vi.fn().mockResolvedValue( + const request = vi.fn().mockResolvedValue( jsonResponse({ data: [{ id: 'claude-opus-4', display_name: 'Claude Opus 4' }, { id: 'claude-haiku-4' }], }), ); await expect( - probeEndpointModels(anthropic, { type: 'api-key', key: 'k' }, fetchImpl), + probeEndpointModels(anthropic, { type: 'api-key', key: 'k' }, request), ).resolves.toEqual([{ id: 'claude-opus-4', label: 'Claude Opus 4' }, { id: 'claude-haiku-4' }]); }); it('accepts a bare array and drops duplicate ids', async () => { - const fetchImpl = vi - .fn() + const request = vi + .fn() .mockResolvedValue(jsonResponse([{ id: 'gpt-5' }, { id: 'gpt-5' }, { id: 'gpt-5-mini' }])); await expect( - probeEndpointModels(openai, { type: 'api-key', key: 'k' }, fetchImpl), + probeEndpointModels(openai, { type: 'api-key', key: 'k' }, request), ).resolves.toEqual([{ id: 'gpt-5' }, { id: 'gpt-5-mini' }]); }); it("surfaces the endpoint's own rejection so the user can act on it", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('{"error":"invalid api key"}', { status: 401 })); + const request = vi.fn().mockResolvedValue({ + status: 401, + statusText: 'Unauthorized', + body: '{"error":"invalid api key"}', + }); await expect( - probeEndpointModels(openai, { type: 'api-key', key: 'bad' }, fetchImpl), + probeEndpointModels(openai, { type: 'api-key', key: 'bad' }, request), ).rejects.toThrow(REJECTION_PATTERN); }); it('rejects a response that is not a model list', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + const request = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); await expect( - probeEndpointModels(openai, { type: 'api-key', key: 'k' }, fetchImpl), + probeEndpointModels(openai, { type: 'api-key', key: 'k' }, request), ).rejects.toThrow(NOT_A_LIST_PATTERN); }); }); diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts index 6e88f5780..02a3a859f 100644 --- a/packages/host/engine/src/agent/model-probe.ts +++ b/packages/host/engine/src/agent/model-probe.ts @@ -1,4 +1,10 @@ +import type { LookupAddress } from 'node:dns'; +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { BlockList, isIP } from 'node:net'; import type { AccountEndpoint, AccountModel, AccountSecret } from '@linkcode/schema'; +import { extractErrorMessage } from 'foxts/extract-error-message'; import { z } from 'zod'; /** @@ -14,19 +20,83 @@ import { z } from 'zod'; const PROBE_TIMEOUT_MS = 10000; const ANTHROPIC_VERSION = '2023-06-01'; -/** Trim of an error body kept for the user-facing message — enough to read a vendor's reason. */ const ERROR_BODY_LIMIT = 200; +const RESPONSE_BODY_LIMIT = 1024 * 1024; +const MODEL_LIMIT = 10000; const TRAILING_SLASH_PATTERN = /\/+$/; +const BLOCKED_IPV4 = new BlockList(); +const BLOCKED_IPV6 = new BlockList(); + +for (const [network, prefix] of [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +] as const) { + BLOCKED_IPV4.addSubnet(network, prefix, 'ipv4'); +} +for (const [network, prefix] of [ + ['::', 128], + ['::1', 128], + ['::', 96], + ['::ffff:0:0', 96], + ['::ffff:0:0:0', 96], + ['64:ff9b::', 96], + ['64:ff9b:1::', 48], + ['100::', 64], + ['2001:db8::', 32], + ['2001::', 32], + ['2002::', 16], + ['fc00::', 7], + ['fec0::', 10], + ['fe80::', 10], + ['ff00::', 8], +] as const) { + BLOCKED_IPV6.addSubnet(network, prefix, 'ipv6'); +} /** Tolerant of a relay that answers a bare array instead of the vendors' `{data}` envelope. */ +const ModelEntrySchema = z.object({ + id: z.string().min(1).max(512), + display_name: z.string().max(1024).optional(), +}); const ModelListSchema = z.union([ - z.object({ data: z.array(z.object({ id: z.string(), display_name: z.string().optional() })) }), - z.array(z.object({ id: z.string(), display_name: z.string().optional() })), + z.object({ data: z.array(ModelEntrySchema).max(MODEL_LIMIT) }), + z.array(ModelEntrySchema).max(MODEL_LIMIT), ]); +interface ModelListResponse { + status: number; + statusText: string; + body: string; +} + +export type ModelListRequest = ( + url: URL, + headers: Record, + signal?: AbortSignal, +) => Promise; +export type ModelProbe = typeof probeEndpointModels; + export function modelListUrl(endpoint: AccountEndpoint): string { - const base = endpoint.baseUrl.replace(TRAILING_SLASH_PATTERN, ''); - return endpoint.protocol === 'anthropic' ? `${base}/v1/models?limit=1000` : `${base}/models`; + const url = new URL(endpoint.baseUrl); + if (url.username || url.password || url.search || url.hash) { + throw new Error('Model detection endpoint cannot contain credentials, a query, or a fragment'); + } + const basePath = url.pathname.replace(TRAILING_SLASH_PATTERN, ''); + url.pathname = endpoint.protocol === 'anthropic' ? `${basePath}/v1/models` : `${basePath}/models`; + if (endpoint.protocol === 'anthropic') url.searchParams.set('limit', '1000'); + return url.href; } export function modelListHeaders( @@ -45,24 +115,167 @@ export function modelListHeaders( return headers; } +function normalizedHostname(url: URL): string { + return url.hostname[0] === '[' ? url.hostname.slice(1, -1) : url.hostname; +} + +function isBlockedAddress(address: LookupAddress): boolean { + return address.family === 4 + ? BLOCKED_IPV4.check(address.address, 'ipv4') + : BLOCKED_IPV6.check(address.address, 'ipv6'); +} + +export async function resolvePublicEndpoint( + url: URL, + lookup: typeof dnsLookup = dnsLookup, +): Promise { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Model detection requires an HTTP(S) endpoint'); + } + const hostname = normalizedHostname(url); + const family = isIP(hostname); + const addresses = family + ? [{ address: hostname, family }] + : await lookup(hostname, { all: true, verbatim: true }); + if (addresses.length === 0 || addresses.some(isBlockedAddress)) { + throw new Error('Model detection cannot access private or non-routable addresses'); + } + return addresses[0]; +} + +export async function requestPublicModelList( + url: URL, + headers: Record, + signal?: AbortSignal, +): Promise { + const address = await withAbort(resolvePublicEndpoint(url), signal); + return requestModelListAtAddress(url, headers, address, signal); +} + +function withAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortReason(signal)); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + void promise + .then((value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }) + .catch((error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(normalizeError(error, 'Model endpoint resolution failed')); + }); + }); +} + +function normalizeError(error: unknown, fallback: string): Error { + return new Error(extractErrorMessage(error, false) ?? fallback); +} + +function abortReason(signal: AbortSignal): Error { + return normalizeError(signal.reason, 'Model detection aborted'); +} + +export function requestModelListAtAddress( + url: URL, + headers: Record, + address: LookupAddress, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let responseEnded = false; + const settleError = (error: unknown) => { + if (settled) return; + settled = true; + reject(normalizeError(error, 'Model-list request failed')); + }; + const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)( + { + hostname: address.address, + family: address.family, + port: url.port, + path: `${url.pathname}${url.search}`, + method: 'GET', + headers: { ...headers, host: url.host }, + ...(url.protocol === 'https:' && { servername: normalizedHostname(url) }), + signal, + }, + (response) => { + const status = response.statusCode ?? 0; + const limit = status >= 200 && status < 300 ? RESPONSE_BODY_LIMIT : ERROR_BODY_LIMIT; + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > limit) { + const error = new Error(`Model-list response exceeded ${limit} bytes`); + settleError(error); + response.destroy(error); + request.destroy(error); + return; + } + chunks.push(chunk); + }); + response.on('end', () => { + if (settled) return; + settled = true; + responseEnded = true; + resolve({ + status, + statusText: response.statusMessage ?? '', + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + response.on('aborted', () => settleError(new Error('Model-list response was interrupted'))); + response.on('error', settleError); + response.on('close', () => { + if (!responseEnded && !response.complete) { + settleError(new Error('Model-list response was interrupted')); + } + }); + }, + ); + request.on('error', (error) => settleError(signal?.aborted ? abortReason(signal) : error)); + request.end(); + }); +} + /** Model ids the endpoint advertises, deduped, in the order it listed them. Rejects with a * user-facing message (status + the vendor's own reason) — the dialog shows it verbatim. */ export async function probeEndpointModels( endpoint: AccountEndpoint, secret: AccountSecret, - fetchImpl: typeof fetch = fetch, + request: ModelListRequest = requestPublicModelList, ): Promise { - const url = modelListUrl(endpoint); - const response = await fetchImpl(url, { - headers: modelListHeaders(endpoint, secret), - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - }); - if (!response.ok) { - const body = (await response.text().catch(() => '')).trim().slice(0, ERROR_BODY_LIMIT); + const url = new URL(modelListUrl(endpoint)); + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new Error('Model detection timed out')); + }, PROBE_TIMEOUT_MS); + let response: ModelListResponse; + try { + response = await request(url, modelListHeaders(endpoint, secret), controller.signal); + } finally { + clearTimeout(timeout); + } + if (response.status < 200 || response.status >= 300) { + const body = response.body.trim().slice(0, ERROR_BODY_LIMIT); throw new Error(`${response.status} ${response.statusText}${body ? ` — ${body}` : ''}`); } - const parsed = ModelListSchema.safeParse(await response.json()); - if (!parsed.success) throw new Error(`${url} did not answer a model list`); + let json: unknown; + try { + json = JSON.parse(response.body); + } catch { + throw new Error(`${url.href} did not answer a model list`); + } + const parsed = ModelListSchema.safeParse(json); + if (!parsed.success) throw new Error(`${url.href} did not answer a model list`); const entries = Array.isArray(parsed.data) ? parsed.data : parsed.data.data; const byId = new Map(); for (const entry of entries) { From 1c990520a7f8ed4a9d3e04219806e12d6c5717d1 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 31 Jul 2026 04:42:49 +0000 Subject: [PATCH 04/12] test(engine): cover model probe boundaries --- .../engine/src/__tests__/model-probe.test.ts | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/host/engine/src/__tests__/model-probe.test.ts b/packages/host/engine/src/__tests__/model-probe.test.ts index f283c7955..5d7ab4541 100644 --- a/packages/host/engine/src/__tests__/model-probe.test.ts +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -1,12 +1,25 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; import type { AccountEndpoint } from '@linkcode/schema'; import { describe, expect, it, vi } from 'vitest'; import type { ModelListRequest } from '../agent/model-probe'; -import { modelListHeaders, modelListUrl, probeEndpointModels } from '../agent/model-probe'; +import { + modelListHeaders, + modelListUrl, + probeEndpointModels, + requestModelListAtAddress, + resolvePublicEndpoint, +} from '../agent/model-probe'; const anthropic: AccountEndpoint = { baseUrl: 'https://relay.test/', protocol: 'anthropic' }; const openai: AccountEndpoint = { baseUrl: 'https://relay.test/v1', protocol: 'openai-chat' }; const REJECTION_PATTERN = /401.*invalid api key/; const NOT_A_LIST_PATTERN = /did not answer a model list/; +const HTTP_PATTERN = /HTTP/; +const PRIVATE_PATTERN = /private/; +const EXCEEDED_PATTERN = /exceeded/; +const TIMED_OUT_PATTERN = /timed out/; +const INVALID_ENDPOINT_PATTERN = /cannot contain/; function jsonResponse(body: unknown): Awaited> { return { status: 200, statusText: 'OK', body: JSON.stringify(body) }; @@ -18,6 +31,15 @@ describe('endpoint model list addressing', () => { expect(modelListUrl(openai)).toBe('https://relay.test/v1/models'); }); + it('rejects URL components that string-appending could misaddress', () => { + expect(() => + modelListUrl({ baseUrl: 'https://relay.test/v1?tenant=x', protocol: 'openai-chat' }), + ).toThrow(INVALID_ENDPOINT_PATTERN); + expect(() => + modelListUrl({ baseUrl: 'https://user:secret@relay.test/v1', protocol: 'openai-chat' }), + ).toThrow(INVALID_ENDPOINT_PATTERN); + }); + it('authenticates per protocol and credential shape', () => { expect(modelListHeaders(anthropic, { type: 'api-key', key: 'k' })).toMatchObject({ 'x-api-key': 'k', @@ -70,3 +92,129 @@ describe('probeEndpointModels', () => { ).rejects.toThrow(NOT_A_LIST_PATTERN); }); }); + +describe('model probe network boundary', () => { + it('rejects unsupported schemes and private destinations', async () => { + await expect(resolvePublicEndpoint(new URL('file:///tmp/models'))).rejects.toThrow( + HTTP_PATTERN, + ); + await expect(resolvePublicEndpoint(new URL('http://127.0.0.1/models'))).rejects.toThrow( + PRIVATE_PATTERN, + ); + await expect(resolvePublicEndpoint(new URL('http://[::1]/models'))).rejects.toThrow( + PRIVATE_PATTERN, + ); + await expect(resolvePublicEndpoint(new URL('http://[fec0::1]/models'))).rejects.toThrow( + PRIVATE_PATTERN, + ); + await expect(resolvePublicEndpoint(new URL('http://[64:ff9b::7f00:1]/models'))).rejects.toThrow( + PRIVATE_PATTERN, + ); + }); + + it('enforces an absolute deadline', async () => { + vi.useFakeTimers(); + try { + const request = vi.fn( + (_url, _headers, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error(String(signal.reason))), { + once: true, + }); + }), + ); + const result = probeEndpointModels(openai, { type: 'api-key', key: 'k' }, request); + const expectation = expect(result).rejects.toThrow(TIMED_OUT_PATTERN); + await vi.advanceTimersByTimeAsync(10000); + await expectation; + } finally { + vi.useRealTimers(); + } + }); + + it('does not follow a redirect carrying an x-api-key', async () => { + let redirectedRequests = 0; + const redirected = createServer((_request, response) => { + redirectedRequests += 1; + response.end('{}'); + }); + await new Promise((resolve) => { + redirected.listen(0, '127.0.0.1', resolve); + }); + const redirectedPort = (redirected.address() as AddressInfo).port; + const source = createServer((_request, response) => { + response.writeHead(302, { location: `http://127.0.0.1:${redirectedPort}/steal` }); + response.end(); + }); + await new Promise((resolve) => { + source.listen(0, '127.0.0.1', resolve); + }); + const sourcePort = (source.address() as AddressInfo).port; + try { + const result = await requestModelListAtAddress( + new URL(`http://relay.test:${sourcePort}/models`), + { 'x-api-key': 'secret' }, + { address: '127.0.0.1', family: 4 }, + ); + expect(result.status).toBe(302); + expect(redirectedRequests).toBe(0); + } finally { + await Promise.all( + [source, redirected].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + } + }); + + it('rejects an oversized successful response', async () => { + const server = createServer((_request, response) => { + response.end('x'.repeat(1024 * 1024 + 1)); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const port = (server.address() as AddressInfo).port; + try { + await expect( + requestModelListAtAddress( + new URL(`http://relay.test:${port}/models`), + {}, + { address: '127.0.0.1', family: 4 }, + ), + ).rejects.toThrow(EXCEEDED_PATTERN); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it('rejects a response that closes before its declared body completes', async () => { + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-length': '10' }); + response.write('x'); + response.destroy(); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const port = (server.address() as AddressInfo).port; + try { + await expect( + requestModelListAtAddress( + new URL(`http://relay.test:${port}/models`), + {}, + { address: '127.0.0.1', family: 4 }, + ), + ).rejects.toThrow(); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); +}); From f0d8fbe6a846a450bd42d51fe4f62e8e649333c9 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 31 Jul 2026 04:43:14 +0000 Subject: [PATCH 05/12] fix(config): save agent accounts atomically --- apps/daemon/src/__tests__/config.test.ts | 26 +++++- apps/daemon/src/config.ts | 53 +++++++++++- apps/daemon/src/provider-store.ts | 9 +- packages/client/core/src/client.ts | 5 ++ .../client/core/src/client/control-channel.ts | 10 +++ packages/client/sdk/src/client.ts | 5 ++ packages/client/sdk/src/operations.ts | 7 ++ .../__tests__/onboarding.test.ts | 10 +++ .../agent-runtime/api-key-login/dialog.tsx | 84 ++++++++++++++----- .../src/agent-runtime/api-key-login/store.ts | 6 +- .../workbench/src/agent-runtime/onboarding.ts | 2 +- .../workbench/src/mock/dev-mock-host.ts | 17 ++++ .../integration/dev-mock-transport.test.ts | 16 ++++ packages/foundation/schema/src/wire/config.ts | 8 ++ .../foundation/schema/src/wire/message.ts | 2 +- .../src/__tests__/engine-model-probe.test.ts | 16 +++- .../src/__tests__/provider-config.test.ts | 41 ++++++++- .../host/engine/src/agent/provider-config.ts | 24 ++++++ .../host/engine/src/agent/request-handler.ts | 14 +++- packages/host/engine/src/deps.ts | 2 + packages/host/engine/src/engine.ts | 1 + packages/host/engine/src/index.ts | 2 +- .../host/engine/src/wire/request-router.ts | 1 + 23 files changed, 326 insertions(+), 35 deletions(-) diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 69368a1da..1cea7c9b7 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { Account } from '@linkcode/schema'; import { DAEMON_DEFAULT_PORT, DAEMON_PORT_HUNT_SPAN, daemonBasePort } from '@linkcode/schema'; import { noop } from 'foxts/noop'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -10,6 +11,7 @@ import { hqCredentialsPath, loadConfig, runtimeFilePath, + saveProviderConfiguration, } from '../config'; import { logger } from '../logger'; import { daemonChannel, telemetryConfigCachePath } from '../paths'; @@ -49,7 +51,7 @@ const validAccount = { label: 'Personal key', credential: { type: 'api-key', key: 'sk-test' }, createdAt: 0, -}; +} satisfies Account; describe('loadConfig providers', () => { it('keeps valid provider entries and drops an invalid one, logging the error', () => { @@ -241,3 +243,23 @@ describe('loadConfig accounts', () => { expect(errorSpy).not.toHaveBeenCalled(); }); }); + +describe('saveProviderConfiguration', () => { + it('atomically persists providers and accounts while preserving unrelated fields', () => { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + const path = join(dir, 'config.json'); + writeFileSync(path, JSON.stringify({ hostname: '127.0.0.1' })); + + saveProviderConfiguration({ codex: { enabled: true, activeAccountId: 'acc_1' } }, [ + validAccount, + ]); + + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ + hostname: '127.0.0.1', + providers: { codex: { enabled: true, activeAccountId: 'acc_1' } }, + accounts: [validAccount], + }); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); +}); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index ea3681f6b..5ecfdb5d3 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -1,4 +1,14 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { daemonRuntimeFilePath } from '@linkcode/common/node'; @@ -195,12 +205,21 @@ export function saveAccounts(accounts: Accounts): void { writeConfigField('accounts', accounts); } +export function saveProviderConfiguration(providers: ProvidersConfig, accounts: Accounts): void { + writeConfigFields({ providers, accounts }); +} + /** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */ function writeConfigField( key: 'providers' | 'accounts' | 'simulatorConsent', value: unknown, ): void { + writeConfigFields({ [key]: value }); +} + +function writeConfigFields(fields: Partial>): void { const path = configPath(); + const directory = dirname(path); let file: Record = {}; try { const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); @@ -208,9 +227,35 @@ function writeConfigField( } catch { // Start from an empty document if the file is missing or malformed. } - file[key] = value; - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + Object.assign(file, fields); + mkdirSync(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + let descriptor: number | undefined; + try { + descriptor = openSync(temporaryPath, 'wx', 0o600); + writeFileSync(descriptor, `${JSON.stringify(file, null, 2)}\n`); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + renameSync(temporaryPath, path); + try { + const directoryDescriptor = openSync(directory, 'r'); + try { + fsyncSync(directoryDescriptor); + } finally { + closeSync(directoryDescriptor); + } + } catch { + // Directory fsync is unsupported on some platforms; rename still preserves atomicity. + } + } finally { + if (descriptor !== undefined) closeSync(descriptor); + try { + unlinkSync(temporaryPath); + } catch { + // The temporary file is absent after a successful rename or an early open failure. + } + } } function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig { diff --git a/apps/daemon/src/provider-store.ts b/apps/daemon/src/provider-store.ts index 0475ac5dd..411980848 100644 --- a/apps/daemon/src/provider-store.ts +++ b/apps/daemon/src/provider-store.ts @@ -1,6 +1,7 @@ import type { ProviderConfigStore } from '@linkcode/engine'; +import { accountBinding } from '@linkcode/engine'; import type { Accounts, ProvidersConfig } from '@linkcode/schema'; -import { saveAccounts, saveProviders } from './config'; +import { saveAccounts, saveProviderConfiguration, saveProviders } from './config'; /** * Daemon-backed data-plane config store: in-memory providers + account pool seeded at boot, each @@ -24,5 +25,11 @@ export function createProviderConfigStore( accounts = next; saveAccounts(next); }, + createAndBindAccount(agent, account) { + const next = accountBinding(providers, accounts, agent, account); + saveProviderConfiguration(next.providers, next.accounts); + providers = next.providers; + accounts = next.accounts; + }, }; } diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 4025c10bf..43833fd2d 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -1,4 +1,5 @@ import type { + Account, AccountEndpoint, AccountModel, AccountSecret, @@ -954,6 +955,10 @@ export class LinkCodeClient { return this.control.setProviderConfig(providers); } + createAndBindAccount(agent: AgentKind, account: Account): Promise { + return this.control.createAndBindAccount(agent, account); + } + setAccounts(accounts: Accounts): Promise { return this.control.setAccounts(accounts); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index c84d3ab2e..c351922ca 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,4 +1,5 @@ import type { + Account, AccountEndpoint, AccountModel, AccountSecret, @@ -410,6 +411,15 @@ export class ControlChannel { })); } + createAndBindAccount(agent: AgentKind, account: Account): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'config.account.create-and-bind', + clientReqId, + agent, + account, + })); + } + /** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer * the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */ probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 64705ea9c..434130083 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -6,6 +6,7 @@ import type { } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { + Account, AccountEndpoint, AccountModel, AccountSecret, @@ -212,6 +213,10 @@ export class LinkCodeSdkClient { return toResult(this.raw.setProviderConfig(providers)); } + createAndBindAccount(agent: AgentKind, account: Account): RequestResult<{ ok: true }> { + return toResult(this.raw.createAndBindAccount(agent, account)); + } + /** Read the daemon-owned global account pool (data plane). */ getAccounts(): RequestResult { return toResult(this.raw.getAccounts()); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index 6a1c5a4fd..6a02a327b 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -1,5 +1,6 @@ import type { HistoryListClientOptions, HistoryReadClientOptions } from '@linkcode/client-core'; import type { + Account, AccountEndpoint, AccountModel, AccountSecret, @@ -179,6 +180,12 @@ export function setProviderConfig( return resolveClient(options).setProviderConfig(options.providers); } +export function createAndBindAccount( + options: Options<{ agent: AgentKind; account: Account }>, +): RequestResult<{ ok: true }> { + return resolveClient(options).createAndBindAccount(options.agent, options.account); +} + export function getAccounts(options?: Options): RequestResult { return resolveClient(options).getAccounts(); } diff --git a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts index 0d8efec20..244daf105 100644 --- a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts @@ -203,6 +203,16 @@ describe('deriveAgentRuntimeCues', () => { expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {})).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' }, }); + expect( + deriveAgentRuntimeCues( + runtimes, + ASSETS, + {}, + {}, + {}, + { 'claude-code': { enabled: true, apiKey: ' ' } }, + ), + ).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' } }); }); it('suppresses the login cue for a bound key account, but not for a bound oauth one', () => { diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx index c630c0119..9bb5910e1 100644 --- a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx +++ b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx @@ -2,11 +2,10 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { Account, AccountModel, AccountSecret, AgentKind } from '@linkcode/schema'; import { AccountProtocolSchema } from '@linkcode/schema'; import { + createAndBindAccount, getAccounts, getProviderConfig, probeAccountModels, - setAccounts, - setProviderConfig, } from '@linkcode/sdk'; import { AGENT_LABELS } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; @@ -30,14 +29,13 @@ import { } from 'coss-ui/components/select'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { Loader2Icon } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslations } from 'use-intl'; import { z } from 'zod'; import { rhfErrorsToFormErrors } from '../../lib/form'; import { useData, useMutation } from '../../runtime/tayori'; import { nativeAccountProtocol } from '../../settings/providers/capability'; -import { withBinding } from '../../settings/providers/view'; import { useAgentApiKeyLoginStore } from './store'; const DraftSchema = z.object({ @@ -56,10 +54,21 @@ function draftSecret(draft: Pick): AccountSe : { type: 'api-key', key: draft.secret.trim() }; } +function detectionKey( + draft: Pick, +): string { + return JSON.stringify([ + draft.baseUrl.trim(), + draft.credentialType, + draft.protocol, + draft.secret.trim(), + ]); +} + /** Account constructor at module scope: `Date.now` may not run in a component body. */ -function accountFromDraft(draft: Draft): Account { +function accountFromDraft(id: string, draft: Draft): Account { return { - id: `acc_${crypto.randomUUID()}`, + id, label: draft.label.trim(), credential: draftSecret(draft), endpoint: { baseUrl: draft.baseUrl.trim(), protocol: draft.protocol }, @@ -76,6 +85,7 @@ function accountFromDraft(draft: Draft): Account { */ export function AgentApiKeyLoginDialog(): React.ReactNode { const kind = useAgentApiKeyLoginStore((state) => state.kind); + const accountId = useAgentApiKeyLoginStore((state) => state.accountId); const close = useAgentApiKeyLoginStore((state) => state.close); const t = useTranslations('workbench.apiKeyLogin'); @@ -88,7 +98,9 @@ export function AgentApiKeyLoginDialog(): React.ReactNode { {/* Keyed per agent so switching agents starts from that agent's own defaults. */} - {kind !== null && } + {kind !== null && accountId !== null && ( + + )} @@ -97,20 +109,22 @@ export function AgentApiKeyLoginDialog(): React.ReactNode { function ApiKeyLoginForm({ kind, + accountId, onDone, }: { kind: AgentKind; + accountId: string; onDone: () => void; }): React.ReactNode { const t = useTranslations('workbench.apiKeyLogin'); - const { data: accounts, mutate: mutateAccounts } = useData(getAccounts, {}); - const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); - const saveAccounts = useMutation(setAccounts); - const saveProviders = useMutation(setProviderConfig); + const { mutate: mutateAccounts } = useData(getAccounts, {}); + const { mutate: mutateProviders } = useData(getProviderConfig, {}); + const save = useMutation(createAndBindAccount); const probe = useMutation(probeAccountModels); // Detection result and its failure are transient UI state, not part of the submitted draft. const [detected, setDetected] = useState(null); const [detectError, setDetectError] = useState(null); + const probeGenerationRef = useRef(0); const { control, @@ -118,6 +132,7 @@ function ApiKeyLoginForm({ handleSubmit, getValues, setError, + setValue, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(DraftSchema), @@ -130,12 +145,13 @@ function ApiKeyLoginForm({ model: '', }, }); + const baseUrlField = register('baseUrl'); + const secretField = register('secret'); const onSubmit = handleSubmit(async (draft) => { try { - const account = accountFromDraft(draft); - await saveAccounts.trigger({ accounts: [...(accounts ?? []), account] }); - await saveProviders.trigger({ providers: withBinding(providers ?? {}, kind, account.id) }); + const account = accountFromDraft(accountId, draft); + await save.trigger({ agent: kind, account }); await Promise.all([mutateAccounts(), mutateProviders()]); onDone(); } catch (error) { @@ -143,9 +159,18 @@ function ApiKeyLoginForm({ } }); + function invalidateDetection(): void { + probeGenerationRef.current += 1; + if (detected !== null) setValue('model', ''); + setDetected(null); + setDetectError(null); + } + // Imperative read: detection is an action on the current values, not a field subscription. async function detect(): Promise { + const generation = ++probeGenerationRef.current; const draft = getValues(); + const key = detectionKey(draft); setDetectError(null); const endpoint = z .object({ baseUrl: z.url(), protocol: AccountProtocolSchema }) @@ -156,9 +181,11 @@ function ApiKeyLoginForm({ } try { const models = await probe.trigger({ endpoint: endpoint.data, secret: draftSecret(draft) }); + if (generation !== probeGenerationRef.current || key !== detectionKey(getValues())) return; setDetected(models); if (models.length === 0) setDetectError(t('detectEmpty')); } catch (error) { + if (generation !== probeGenerationRef.current || key !== detectionKey(getValues())) return; setDetected(null); setDetectError(extractErrorMessage(error, false) ?? t('detectFailed')); } @@ -190,7 +217,11 @@ function ApiKeyLoginForm({ className="w-full" autoComplete="off" placeholder={t('baseUrlPlaceholder')} - {...register('baseUrl')} + {...baseUrlField} + onChange={(event) => { + void baseUrlField.onChange(event); + invalidateDetection(); + }} /> @@ -205,7 +236,10 @@ function ApiKeyLoginForm({ { + field.onChange(value); + invalidateDetection(); + }} /> )} /> @@ -221,7 +255,10 @@ function ApiKeyLoginForm({ { + field.onChange(value); + invalidateDetection(); + }} /> )} /> @@ -230,7 +267,16 @@ function ApiKeyLoginForm({ {t('secret')} - + { + void secretField.onChange(event); + invalidateDetection(); + }} + /> @@ -283,7 +329,7 @@ function ApiKeyLoginForm({ - diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts index 218a157c2..7d40cd51a 100644 --- a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts +++ b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts @@ -8,12 +8,14 @@ import { create } from 'zustand'; */ interface AgentApiKeyLoginState { kind: AgentKind | null; + accountId: string | null; open: (kind: AgentKind) => void; close: () => void; } export const useAgentApiKeyLoginStore = create()((set) => ({ kind: null, - open: (kind) => set({ kind }), - close: () => set({ kind: null }), + accountId: null, + open: (kind) => set({ kind, accountId: `acc_${crypto.randomUUID()}` }), + close: () => set({ kind: null, accountId: null }), })); diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index 967841c9e..b23d8f89a 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -133,7 +133,7 @@ export function hasInjectedCredential( providers: ProvidersConfig, accounts: Accounts, ): boolean { - if (providers[kind]?.apiKey !== undefined) return true; + if (providers[kind]?.apiKey?.trim()) return true; const boundId = providers[kind]?.activeAccountId; if (boundId === undefined) return false; const bound = accounts.find((account) => account.id === boundId); diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index da0e1e57b..f36165be5 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -362,6 +362,23 @@ export class DevMockHost { if (p.accounts !== undefined) this.accounts = structuredClone(p.accounts); this.sendSuccess(p.clientReqId); break; + case 'config.account.create-and-bind': { + await wait(CONTROL_LATENCY_MS); + const account = structuredClone(p.account); + const exists = this.accounts.some((candidate) => candidate.id === account.id); + this.accounts = exists + ? this.accounts.map((candidate) => (candidate.id === account.id ? account : candidate)) + : [...this.accounts, account]; + this.providers = { + ...this.providers, + [p.agent]: { + ...(this.providers[p.agent] ?? { enabled: true }), + activeAccountId: account.id, + }, + }; + this.sendSuccess(p.clientReqId); + break; + } case 'workspace.list': await wait(CONTROL_LATENCY_MS); this.send({ diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index a60368cf0..971371b75 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -132,6 +132,22 @@ describe('dev mock transport', () => { // Independent fields: writing accounts preserved the provider config. expect(await client.getProviderConfig()).toEqual(providers); + const boundAccount = { + id: 'acc_2', + label: 'Relay', + credential: { type: 'api-key', key: 'sk-relay' }, + createdAt: 1, + } satisfies Accounts[number]; + await client.createAndBindAccount('codex', boundAccount); + await client.createAndBindAccount('codex', { ...boundAccount, label: 'Updated relay' }); + expect(await client.getAccounts()).toEqual([ + accounts[0], + { ...boundAccount, label: 'Updated relay' }, + ]); + expect(await client.getProviderConfig()).toEqual({ + codex: { enabled: true, defaultModel: 'mock-model', activeAccountId: 'acc_2' }, + }); + client.dispose(); }); diff --git a/packages/foundation/schema/src/wire/config.ts b/packages/foundation/schema/src/wire/config.ts index df7e86a15..bc953acf5 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { AccountEndpointSchema, AccountModelSchema, + AccountSchema, AccountSecretSchema, AccountsSchema, } from '../model/account'; +import { AgentKindSchema } from '../model/primitives'; import { ProvidersConfigSchema } from '../model/provider-config'; import { WireRequestIdSchema } from './request'; @@ -27,6 +29,12 @@ export const configWireVariants = [ /** The global account pool; omitted by a client editing only provider settings. */ accounts: AccountsSchema.optional(), }), + z.object({ + kind: z.literal('config.account.create-and-bind'), + clientReqId: WireRequestIdSchema, + agent: AgentKindSchema, + account: AccountSchema, + }), /** Enumerate what an endpoint serves, before the account is saved: the daemon reads the * endpoint's own model list with the given secret. The client cannot do this itself — the * renderer's CSP blocks remote fetches, and only the daemon may hold the secret. */ diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index d2b84c54d..80d5e61d4 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WirePayloadSchema } from './payload'; */ // Bump on every wire schema change; mismatched peers silently discard all frames (Invariant 1). -export const WIRE_PROTOCOL_VERSION = 64 as const; +export const WIRE_PROTOCOL_VERSION = 65 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index f95a59574..358031270 100644 --- a/packages/host/engine/src/__tests__/engine-model-probe.test.ts +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -4,6 +4,7 @@ import type { AddressInfo } from 'node:net'; import type { WirePayload } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { probeEndpointModels, requestModelListAtAddress } from '../agent/model-probe'; import { createSessionHarness } from './fixtures/session-harness'; /** The probe is a real HTTP round-trip, so its reply lands after `inject`'s task settle. */ @@ -33,6 +34,17 @@ function baseUrl(server: Server): string { return `http://127.0.0.1:${port}`; } +const localModelProbe: typeof probeEndpointModels = (endpoint, secret) => + probeEndpointModels(endpoint, secret, (url, headers) => + requestModelListAtAddress(url, headers, { address: '127.0.0.1', family: 4 }), + ); + +function createHarness() { + return createSessionHarness(undefined, undefined, undefined, undefined, undefined, undefined, { + modelProbe: localModelProbe, + }); +} + let relay: Server | undefined; afterEach(async () => { @@ -54,7 +66,7 @@ describe('config.probe-models', () => { ? { status: 200, body: JSON.stringify({ data: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }] }) } : { status: 404, body: '{}' }; }); - const h = createSessionHarness(); + const h = createHarness(); await h.engine.start(); await h.inject({ @@ -77,7 +89,7 @@ describe('config.probe-models', () => { status: 401, body: JSON.stringify({ error: 'invalid api key' }), })); - const h = createSessionHarness(); + const h = createHarness(); await h.engine.start(); await h.inject({ diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index afa1c0845..e26263cba 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -1,6 +1,6 @@ import type { Account, ProvidersConfig, StartOptions } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { applyProviderDefaults } from '../agent/provider-config'; +import { accountBinding, applyProviderDefaults } from '../agent/provider-config'; const baseOpts: StartOptions = { kind: 'codex', cwd: '/repo' }; @@ -110,3 +110,42 @@ describe('applyProviderDefaults account pool', () => { expect(applyProviderDefaults(baseOpts, providers, [oauth]).config).toEqual({}); }); }); + +describe('accountBinding', () => { + const account: Account = { + id: 'acc_1', + label: 'Relay', + credential: { type: 'api-key', key: 'sk-new' }, + createdAt: 1, + }; + + it('preserves unrelated providers and accounts while binding the selected agent', () => { + const providers: ProvidersConfig = { + codex: { enabled: true, defaultModel: 'gpt-5' }, + opencode: { enabled: false, activeAccountId: 'acc_2' }, + }; + const other: Account = { + id: 'acc_2', + label: 'Other', + credential: { type: 'api-key', key: 'sk-other' }, + createdAt: 0, + }; + + expect(accountBinding(providers, [other], 'codex', account)).toEqual({ + providers: { + codex: { enabled: true, defaultModel: 'gpt-5', activeAccountId: 'acc_1' }, + opencode: { enabled: false, activeAccountId: 'acc_2' }, + }, + accounts: [other, account], + }); + }); + + it('upserts by account id so retrying the same request is idempotent', () => { + const first = accountBinding({}, [], 'codex', account); + const updated = { ...account, label: 'Updated relay' }; + const retry = accountBinding(first.providers, first.accounts, 'codex', updated); + + expect(retry.accounts).toEqual([updated]); + expect(retry.providers.codex?.activeAccountId).toBe(account.id); + }); +}); diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 4db3696b0..948c911a2 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -1,6 +1,7 @@ import type { Account, Accounts, + AgentKind, ProviderConfig, ProvidersConfig, StartOptions, @@ -17,6 +18,7 @@ export interface ProviderConfigStore { /** The global account pool bound by `providers[kind].activeAccountId`. */ getAccounts(): Accounts; setAccounts(next: Accounts): void | Promise; + createAndBindAccount(agent: AgentKind, account: Account): void | Promise; } export class InMemoryProviderConfigStore implements ProviderConfigStore { @@ -38,6 +40,28 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { setAccounts(next: Accounts): void { this.accounts = next; } + + createAndBindAccount(agent: AgentKind, account: Account): void { + const next = accountBinding(this.providers, this.accounts, agent, account); + this.providers = next.providers; + this.accounts = next.accounts; + } +} + +export function accountBinding( + providers: ProvidersConfig, + accounts: Accounts, + agent: AgentKind, + account: Account, +): { providers: ProvidersConfig; accounts: Accounts } { + const entry = providers[agent] ?? { enabled: true }; + const exists = accounts.some((candidate) => candidate.id === account.id); + return { + providers: { ...providers, [agent]: { ...entry, activeAccountId: account.id } }, + accounts: exists + ? accounts.map((candidate) => (candidate.id === account.id ? account : candidate)) + : [...accounts, account], + }; } /** diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 2d64c0386..e97b8e831 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -7,6 +7,7 @@ import { extractErrorMessage } from 'foxts/extract-error-message'; import { OperationError, RequestError } from '../failure'; import type { WireResponder } from '../wire/responder'; import type { AgentLoginService } from './login-service'; +import type { ModelProbe } from './model-probe'; import { probeEndpointModels } from './model-probe'; import type { ProviderConfigStore } from './provider-config'; import { applyProviderDefaults } from './provider-config'; @@ -20,6 +21,7 @@ type AgentRequest = Extract< | 'agent.catalog' | 'config.get' | 'config.set' + | 'config.account.create-and-bind' | 'config.probe-models' | 'agent-login.start' | 'agent-login.submit-code' @@ -36,6 +38,7 @@ export class AgentRequestHandler { private readonly logins: AgentLoginService | undefined, private readonly responder: WireResponder, private readonly factory: AdapterFactory, + private readonly probeModels: ModelProbe = probeEndpointModels, ) {} handle(payload: AgentRequest): Effect.Effect { @@ -126,12 +129,21 @@ export class AgentRequestHandler { ), ); } + case 'config.account.create-and-bind': + return this.responder.reply( + payload.clientReqId, + updateProviderConfig('config.account.create-and-bind', () => + this.providers.createAndBindAccount(payload.agent, payload.account), + ).pipe( + Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), + ), + ); case 'config.probe-models': return this.responder.reply( payload.clientReqId, Effect.tryPromise({ try: async () => { - const models = await probeEndpointModels(payload.endpoint, payload.secret); + const models = await this.probeModels(payload.endpoint, payload.secret); this.transport.send( createWireMessage({ kind: 'config.probe-models.result', diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index d7dc6f8c7..55090cde7 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -1,6 +1,7 @@ import type { AdapterFactory, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; import type { AgentRuntimes } from '@linkcode/schema'; import type { LoginBinaryResolver } from './agent/login-service'; +import type { ModelProbe } from './agent/model-probe'; import type { ProviderConfigStore } from './agent/provider-config'; import type { TranslatorService } from './agent/translator'; import type { AssetService } from './asset/service'; @@ -35,6 +36,7 @@ export interface EngineDeps { * The default is volatile and never asks anyone, so an embedding without one is not gated. */ simulatorConsent?: SimulatorConsentService; providerStore?: ProviderConfigStore; + modelProbe?: ModelProbe; git?: GitService; fileSuggest?: FileSuggestService; workspaceStore?: WorkspaceStore; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 003e2383e..4af2dfe81 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -208,6 +208,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( logins, responder, factory, + deps.modelProbe, ); const browserRequests = new BrowserRequestHandler(transport, browserBroker); const requests = new WireRequestRouter(transport, { diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 74a6c998b..6e96b01e6 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -4,7 +4,7 @@ * feature implementations stay package-internal. */ -export type { ProviderConfigStore } from './agent/provider-config'; +export { accountBinding, type ProviderConfigStore } from './agent/provider-config'; export type { TranslatorService, TranslatorUpstream } from './agent/translator'; export type { AssetService } from './asset/service'; export type { LoopStore, ScheduleStore } from './automation'; diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index 790dfbfea..0ef08b122 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -74,6 +74,7 @@ export class WireRequestRouter { case 'agent.catalog': case 'config.get': case 'config.set': + case 'config.account.create-and-bind': case 'config.probe-models': { return this.handlers.agent.handle(p); } From f1839d151f765f4e0f2909cf1033ef362d4f48c1 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 12:21:22 +0000 Subject: [PATCH 06/12] fix(workbench): route signed-out setup through providers Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- .../src/renderer/src/shell/desktop-shell.tsx | 15 +-- .../src/shell/desktop-workbench-shell.tsx | 8 +- .../webview/src/shell/web-workbench-shell.tsx | 5 + .../agent-runtime/api-key-login/dialog.tsx | 6 +- .../src/agent-runtime/api-key-login/store.ts | 5 +- .../workbench/src/agent-runtime/onboarding.ts | 10 +- .../settings/providers/providers-settings.tsx | 121 +++++++++++++++++- .../workbench/src/settings/providers/store.ts | 7 + .../workbench/src/surface/workbench.tsx | 3 - packages/presentation/i18n/src/locales/en.ts | 14 +- .../presentation/i18n/src/locales/zh-cn.ts | 14 +- .../__tests__/agent-onboarding-card.test.tsx | 27 ++-- .../__tests__/conversation-surface.test.tsx | 6 +- .../__tests__/new-session-surface.test.tsx | 21 +++ .../ui/src/shell/agent-onboarding-card.tsx | 50 ++++---- .../ui/src/shell/conversation-surface.tsx | 20 +-- .../ui/src/shell/new-session-surface.tsx | 20 +-- .../presentation/ui/src/shell/shell-frame.tsx | 25 +--- 18 files changed, 251 insertions(+), 126 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 336a53c43..671db1750 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -86,10 +86,7 @@ export function DesktopShell({ NewSessionBranchPickerComponent, onDownloadAgent, onContinueUnverified, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, - onUseApiKey, + onOpenProviderSettings, conversation, respondingRequestIds, responseErrors, @@ -439,10 +436,7 @@ export function DesktopShell({ topContent={} onContinueUnverified={onContinueUnverified} onDownloadAgent={onDownloadAgent} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} - onUseApiKey={onUseApiKey} + onOpenProviderSettings={onOpenProviderSettings} onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onPickDirectory={pickDirectory} @@ -461,10 +455,7 @@ export function DesktopShell({ attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} - onUseApiKey={onUseApiKey} + onOpenProviderSettings={onOpenProviderSettings} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent} diff --git a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx index 9e621fd07..8d1f004e2 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx @@ -1,5 +1,5 @@ import type { WorkbenchShellProps } from '@linkcode/workbench'; -import { useNavigationHistoryStore } from '@linkcode/workbench'; +import { useNavigationHistoryStore, useProvidersSettingsStore } from '@linkcode/workbench'; import { systemBridge } from '@renderer/ipc'; import { openDesktopSettings, useDesktopSettingsStore } from '../settings/store'; import { DesktopShell } from './desktop-shell'; @@ -8,13 +8,17 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps) const theme = useDesktopSettingsStore((state) => state.theme); return ( openDesktopSettings()} + onOpenProviderSettings={(kind) => { + useProvidersSettingsStore.getState().startAgentSetup(kind); + openDesktopSettings('providers'); + }} onOpenAutomations={() => useNavigationHistoryStore.getState().openOverlay('automations')} onImportHistory={() => openDesktopSettings('history-import')} themeType={theme} - {...props} /> ); } diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index 6d938b6ee..4be6a6e8e 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -4,6 +4,7 @@ import { getResourcesPanelPresentation, RESOURCES_FLOATING_COLUMN_WIDTH, RESOURCES_FLOATING_MIN_WORKSPACE_WIDTH, + useProvidersSettingsStore, useResourcesPanelStore, WorkspaceServicesMenu, } from '@linkcode/workbench'; @@ -58,6 +59,10 @@ export function WebWorkbenchShell({ { + useProvidersSettingsStore.getState().startAgentSetup(kind); + void navigate('/settings/providers'); + }} onOpenAutomations={() => { void navigate('/automations'); }} diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx index 9bb5910e1..a90f316c9 100644 --- a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx +++ b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx @@ -78,10 +78,8 @@ function accountFromDraft(id: string, draft: Draft): Account { } /** - * The API-key alternative to a CLI's OAuth login, reachable from the signed-out onboarding card: - * a relay/gateway key plus its base URL, saved as a normal pool account and bound as the agent's - * active one — the same contract session start already injects. Mounted once beside the shell; the - * card only opens it (see {@link useAgentApiKeyLoginStore}). + * The API-key branch of the Providers settings setup flow: a relay/gateway key plus its base URL, + * saved as a normal pool account and bound as the agent's active one. */ export function AgentApiKeyLoginDialog(): React.ReactNode { const kind = useAgentApiKeyLoginStore((state) => state.kind); diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts index 7d40cd51a..4be4cfac9 100644 --- a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts +++ b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts @@ -2,9 +2,8 @@ import type { AgentKind } from '@linkcode/schema'; import { create } from 'zustand'; /** - * Which agent's API-key login dialog is open. Module-scope, not component state: the onboarding - * card that opens it lives several prop hops below the workbench, and the dialog is mounted beside - * the shell rather than inside the card. Not persisted. + * Which agent's API-key setup dialog is open. Module-scope because the Providers method picker and + * the API-key dialog are sibling overlays. Not persisted. */ interface AgentApiKeyLoginState { kind: AgentKind | null; diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index b23d8f89a..8f2bbe03d 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -221,7 +221,7 @@ export function useAgentRuntimeOnboarding(): { cues: AgentRuntimeCues; download: (kind: AgentKind) => void; acknowledgeUnverified: (kind: AgentKind) => void; - login: (kind: AgentKind) => void; + login: (kind: AgentKind, onSuccess?: () => void) => void; submitLoginCode: (kind: AgentKind, code: string) => void; cancelLogin: (kind: AgentKind) => void; } { @@ -312,7 +312,7 @@ export function useAgentRuntimeOnboarding(): { }); } - function login(kind: AgentKind): void { + function login(kind: AgentKind, onSuccess?: () => void): void { const entry = { loginId: undefined as string | undefined, cancelled: false }; activeLoginsRef.current.set(kind, entry); setLogin(kind, { kind: 'opening' }); @@ -333,8 +333,10 @@ export function useAgentRuntimeOnboarding(): { activeLoginsRef.current.delete(kind); // Success: the re-probe push flips the runtime to logged-in and drops the cue. Cancel: // back to the idle login button. Failure: show the error with a retry. - if (ok || entry.cancelled) setLogin(kind, undefined); - else setLogin(kind, { kind: 'failed', error: error ?? 'login failed' }); + if (ok || entry.cancelled) { + setLogin(kind, undefined); + if (ok) onSuccess?.(); + } else setLogin(kind, { kind: 'failed', error: error ?? 'login failed' }); }, }); }) diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 219a143c7..218ea3611 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -1,16 +1,22 @@ import type { Account, AgentKind, ProvidersConfig } from '@linkcode/schema'; import { getAccounts, getProviderConfig, setAccounts, setProviderConfig } from '@linkcode/sdk'; -import { AccountDetail, AccountList } from '@linkcode/ui'; +import { AccountDetail, AccountList, AGENT_LABELS, AgentOnboardingCard } from '@linkcode/ui'; +import { Button } from 'coss-ui/components/button'; import { Dialog, + DialogDescription, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from 'coss-ui/components/dialog'; import { Skeleton } from 'coss-ui/components/skeleton'; +import { ChevronLeftIcon, KeyRoundIcon, LogInIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; +import { AgentApiKeyLoginDialog } from '../../agent-runtime/api-key-login/dialog'; +import { useAgentApiKeyLoginStore } from '../../agent-runtime/api-key-login/store'; import { useAgentRuntimes } from '../../agent-runtime/hooks'; +import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; import { serviceById } from './catalog'; @@ -37,6 +43,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { } = useData(getAccounts, {}); const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); const { data: runtimes } = useAgentRuntimes(); + const onboarding = useAgentRuntimeOnboarding(); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); @@ -45,6 +52,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { const startEdit = useProvidersSettingsStore((state) => state.startEdit); const backToAccount = useProvidersSettingsStore((state) => state.backToAccount); const startAdd = useProvidersSettingsStore((state) => state.startAdd); + const startAgentSetup = useProvidersSettingsStore((state) => state.startAgentSetup); + const startAgentLogin = useProvidersSettingsStore((state) => state.startAgentLogin); const pickService = useProvidersSettingsStore((state) => state.pickService); const backToCatalog = useProvidersSettingsStore((state) => state.backToCatalog); const closeDialog = useProvidersSettingsStore((state) => state.closeDialog); @@ -104,6 +113,21 @@ export function ProvidersSettingsPanel(): React.ReactNode { closeDialog(); }; + const handleSubscriptionLogin = (kind: AgentKind): void => { + startAgentLogin(kind); + onboarding.login(kind, closeDialog); + }; + + const handleCancelLogin = (kind: AgentKind): void => { + onboarding.cancelLogin(kind); + startAgentSetup(kind); + }; + + const handleApiKeySetup = (kind: AgentKind): void => { + closeDialog(); + useAgentApiKeyLoginStore.getState().open(kind); + }; + const dialogOpen = view.kind !== 'browse'; return ( @@ -121,14 +145,53 @@ export function ProvidersSettingsPanel(): React.ReactNode { open={dialogOpen} disablePointerDismissal={saveAccounts.isMutating} onOpenChange={(open) => { - if (!open && !saveAccounts.isMutating) closeDialog(); + if (!open && !saveAccounts.isMutating) { + if (view.kind === 'agent-login') onboarding.cancelLogin(view.agent); + closeDialog(); + } }} > - {view.kind === 'add-catalog' ? ( + {view.kind === 'agent-setup' ? ( + + ) : view.kind === 'agent-login' ? ( + <> + + + {t('setup.subscriptionTitle', { agent: AGENT_LABELS[view.agent] })} + + + +
+ +
+ +
+ + ) : view.kind === 'add-catalog' ? ( <> {t('chooseService')} @@ -188,6 +251,58 @@ export function ProvidersSettingsPanel(): React.ReactNode { )}
+ ); } + +function AgentProviderSetupChoices({ + kind, + onLogin, + onApiKey, +}: { + kind: AgentKind; + onLogin: (kind: AgentKind) => void; + onApiKey: (kind: AgentKind) => void; +}): React.ReactNode { + const t = useTranslations('settings.providers'); + const agent = AGENT_LABELS[kind]; + return ( + <> + + {t('setup.title', { agent })} + {t('setup.hint')} + + +
+ + +
+
+ + ); +} diff --git a/packages/client/workbench/src/settings/providers/store.ts b/packages/client/workbench/src/settings/providers/store.ts index 0717df192..bc9e6b054 100644 --- a/packages/client/workbench/src/settings/providers/store.ts +++ b/packages/client/workbench/src/settings/providers/store.ts @@ -1,3 +1,4 @@ +import type { AgentKind } from '@linkcode/schema'; import { create } from 'zustand'; /** @@ -9,6 +10,8 @@ export type ProvidersSettingsView = | { kind: 'browse' } | { kind: 'add-catalog' } | { kind: 'add-form'; service: string } + | { kind: 'agent-setup'; agent: AgentKind } + | { kind: 'agent-login'; agent: AgentKind } | { kind: 'account'; accountId: string; editing: boolean }; interface ProvidersSettingsState { @@ -18,6 +21,8 @@ interface ProvidersSettingsState { startEdit: () => void; backToAccount: () => void; startAdd: () => void; + startAgentSetup: (agent: AgentKind) => void; + startAgentLogin: (agent: AgentKind) => void; pickService: (service: string) => void; backToCatalog: () => void; closeDialog: () => void; @@ -39,6 +44,8 @@ export const useProvidersSettingsStore = create()((set) : { view: state.view }, ), startAdd: () => set({ view: { kind: 'add-catalog' } }), + startAgentSetup: (agent) => set({ view: { kind: 'agent-setup', agent } }), + startAgentLogin: (agent) => set({ view: { kind: 'agent-login', agent } }), pickService: (service) => set({ view: { kind: 'add-form', service } }), backToCatalog: () => set({ view: { kind: 'add-catalog' } }), closeDialog: () => set({ view: { kind: 'browse' } }), diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 6f0798f0b..6f1895e32 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -640,9 +640,6 @@ function WorkbenchSessionSurface({ runtimeCues={onboarding.cues} onDownloadAgent={onboarding.download} onContinueUnverified={onboarding.acknowledgeUnverified} - onLoginAgent={onboarding.login} - onSubmitLoginCode={onboarding.submitLoginCode} - onCancelLogin={onboarding.cancelLogin} conversation={conversation} respondingRequestIds={respondingRequestIds} responseErrors={visibleResponseErrors} diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 881870bd7..c623af4df 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -298,10 +298,10 @@ export const en = { loginReopenUrl: "Didn't open? Open the sign-in page", loginCancel: 'Cancel', loginFailedTitle: '{agent} sign-in failed', - loginWithApiKey: 'Use an API key', + goToSettings: 'Go to settings', }, apiKeyLogin: { - title: 'Sign in to {agent} with an API key', + title: 'Add an API key for {agent}', hint: "Paste a relay or gateway API key and its base URL. It is saved as a pool account and bound as this agent's active one.", defaultLabel: '{agent} relay', labelField: 'Name', @@ -1025,6 +1025,16 @@ export const en = { removeHint: 'This account is not connected to any agent.', cancel: 'Cancel', chooseService: 'Choose a service', + setup: { + title: 'Add a provider for {agent}', + hint: 'Choose how you want to use this agent.', + subscription: 'Login with subscription', + subscriptionHint: 'Use your existing {agent} subscription.', + subscriptionTitle: 'Login to {agent} with subscription', + apiKey: 'Add API key', + apiKeyHint: 'Use a direct API, relay, or gateway endpoint.', + chooseMethod: 'Choose another method', + }, group: { subscription: 'Subscriptions', direct: 'Direct APIs', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 50225a51e..9dac914a9 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -289,10 +289,10 @@ export const zhCN = { loginReopenUrl: '没有自动打开?点此打开登录页', loginCancel: '取消', loginFailedTitle: '{agent} 登录失败', - loginWithApiKey: '用 API Key 登录', + goToSettings: '前往设置', }, apiKeyLogin: { - title: '用 API Key 登录 {agent}', + title: '为 {agent} 添加 API Key', hint: '填入中转站或网关的 API Key 与 Base URL。保存后会作为账号存入账号池,并绑定为该 agent 的活跃账号。', defaultLabel: '{agent} 中转站', labelField: '名称', @@ -1003,6 +1003,16 @@ export const zhCN = { removeHint: '此账号尚未接入任何智能体。', cancel: '取消', chooseService: '选择服务商', + setup: { + title: '为 {agent} 添加 Provider', + hint: '选择你想使用此智能体的方式。', + subscription: '使用订阅登录', + subscriptionHint: '使用现有的 {agent} 订阅账号。', + subscriptionTitle: '使用订阅登录 {agent}', + apiKey: '添加 API Key', + apiKeyHint: '使用 API 直连、中转站或网关端点。', + chooseMethod: '选择其他方式', + }, group: { subscription: '订阅', direct: 'API 直连', diff --git a/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx b/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx index 8b523d083..b9072c6de 100644 --- a/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/agent-onboarding-card.test.tsx @@ -42,35 +42,37 @@ describe('AgentLoginCard awaiting phase per kind (CODE-174)', () => { }); }); -describe('API-key alternative to the OAuth login', () => { - it('offers it next to the sign-in button and reports the agent', () => { - const onUseApiKey = vi.fn(); +describe('Providers settings handoff', () => { + it('shows only Go to settings for the idle signed-out flow and reports the agent', () => { + const onOpenProviderSettings = vi.fn(); render( , ); - screen.getByRole('button', { name: 'loginWithApiKey' }).click(); - expect(onUseApiKey).toHaveBeenCalledWith('claude-code'); + const button = screen.getByRole('button', { name: 'goToSettings' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + button.click(); + expect(onOpenProviderSettings).toHaveBeenCalledWith('claude-code'); }); - it('offers it as the recovery after a failed login', () => { + it('replaces retry with Go to settings after a failed workbench login', () => { render( , ); - expect(screen.getByRole('button', { name: 'retry' })).toBeTruthy(); - expect(screen.getByRole('button', { name: 'loginWithApiKey' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'goToSettings' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'retry' })).toBeNull(); }); - it('stays hidden when the host provides no handler', () => { + it('keeps direct subscription login available inside settings', () => { render( { onLogin={vi.fn()} />, ); - expect(screen.queryByRole('button', { name: 'loginWithApiKey' })).toBeNull(); + expect(screen.getByRole('button', { name: 'login' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'goToSettings' })).toBeNull(); }); }); diff --git a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx index e79ce31aa..638ac6bec 100644 --- a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx @@ -82,7 +82,7 @@ function surface( respondingRequestIds={new Set()} isRunning={false} runtimeCues={runtimeCues} - onLoginAgent={vi.fn()} + onOpenProviderSettings={vi.fn()} onRespondPermission={vi.fn()} onRespondQuestion={vi.fn()} /> @@ -161,9 +161,9 @@ describe('ConversationSurface prompt card', () => { describe('ConversationSurface needs-login recovery (CODE-172)', () => { it('renders the sign-in card and blocks send for a needs-login cue', () => { render(surface({ 'claude-code': { state: 'needs-login', phase: 'idle' } })); - // The AgentLoginCard idle phase: title + sign-in button (mocked i18n returns raw keys). + // The AgentLoginCard idle phase: title + settings button (mocked i18n returns raw keys). expect(screen.getByText('needsLoginTitle')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'login' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'goToSettings' })).toBeTruthy(); // Send stays gated by sendBlocked even once text is present (an empty composer disables the // button on its own, which would mask a missing sendBlocked wiring). typeInComposer('hello'); diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 943ad4a71..6599da5b6 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -101,6 +101,27 @@ function BranchPickerTest({ onSelect }: { onSelect: (branch: string) => void }) } describe('NewSessionSurface', () => { + it('hands a signed-out provider to settings instead of logging in inline', () => { + const onOpenProviderSettings = vi.fn(); + render( + , + ); + + screen.getByRole('button', { name: 'goToSettings' }).click(); + expect(onOpenProviderSettings).toHaveBeenCalledWith('codex'); + expect(screen.queryByRole('button', { name: 'login' })).toBeNull(); + }); + it.each([ { chatWorkspace: CHAT_WORKSPACE, initialWorkspaceId: CHAT_WORKSPACE.workspaceId }, { chatWorkspace: null, initialWorkspaceId: null }, diff --git a/packages/presentation/ui/src/shell/agent-onboarding-card.tsx b/packages/presentation/ui/src/shell/agent-onboarding-card.tsx index 6fd8a85a0..d22a0b737 100644 --- a/packages/presentation/ui/src/shell/agent-onboarding-card.tsx +++ b/packages/presentation/ui/src/shell/agent-onboarding-card.tsx @@ -40,7 +40,7 @@ export function AgentOnboardingCard({ onLogin, onSubmitLoginCode, onCancelLogin, - onUseApiKey, + onOpenProviderSettings, }: { kind: AgentKind; cue: AgentRuntimeCue; @@ -49,8 +49,8 @@ export function AgentOnboardingCard({ onLogin?: (kind: AgentKind) => void; onSubmitLoginCode?: (kind: AgentKind, code: string) => void; onCancelLogin?: (kind: AgentKind) => void; - /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ - onUseApiKey?: (kind: AgentKind) => void; + /** Opens Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); @@ -62,7 +62,7 @@ export function AgentOnboardingCard({ onLogin={onLogin} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} - onUseApiKey={onUseApiKey} + onOpenProviderSettings={onOpenProviderSettings} /> ); } @@ -167,14 +167,14 @@ function AgentLoginCard({ onLogin, onSubmitLoginCode, onCancelLogin, - onUseApiKey, + onOpenProviderSettings, }: { kind: AgentKind; cue: Extract; onLogin?: (kind: AgentKind) => void; onSubmitLoginCode?: (kind: AgentKind, code: string) => void; onCancelLogin?: (kind: AgentKind) => void; - onUseApiKey?: (kind: AgentKind) => void; + onOpenProviderSettings?: (kind: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); const agent = AGENT_LABELS[kind]; @@ -269,16 +269,17 @@ function AgentLoginCard({ {t('loginFailedTitle', { agent })} {cue.error && {cue.error}} - - {onLogin && ( - - )} - {onUseApiKey && ( - + ) : ( + onLogin && ( + + ) )} @@ -290,16 +291,17 @@ function AgentLoginCard({ {t('needsLoginTitle', { agent })} {t('needsLoginBody', { agent })} - - {onLogin && ( - - )} - {onUseApiKey && ( - + ) : ( + onLogin && ( + + ) )} diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 2fc06beb4..60ec2714c 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -43,14 +43,8 @@ export interface ConversationSurfaceProps { * surfaces here — the sign-in recovery after an auth-failed turn. Install/version cues never * block a session that is already running. */ runtimeCues?: AgentRuntimeCues; - /** Starts (or retries) the interactive login for the signed-out agent. */ - onLoginAgent?: (kind: AgentKind) => void; - /** Submits the authorization code pasted from the browser during a login. */ - onSubmitLoginCode?: (kind: AgentKind, code: string) => void; - /** Aborts an in-flight login. */ - onCancelLogin?: (kind: AgentKind) => void; - /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ - onUseApiKey?: (kind: AgentKind) => void; + /** Opens Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; disabled?: boolean; isRunning: boolean; className?: string; @@ -92,10 +86,7 @@ export function ConversationSurface({ respondingRequestIds, responseErrors, runtimeCues, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, - onUseApiKey, + onOpenProviderSettings, disabled = false, isRunning, className, @@ -160,10 +151,7 @@ export function ConversationSurface({ diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index dc00d5a56..b443959ea 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -103,14 +103,8 @@ export interface NewSessionSurfaceProps { onDownloadAgent?: (kind: AgentKind) => void; /** Accepts an out-of-range detected version — the workbench remembers the (agent, version) pick. */ onContinueUnverified?: (kind: AgentKind) => void; - /** Starts (or retries) the interactive login for a signed-out agent. */ - onLoginAgent?: (kind: AgentKind) => void; - /** Submits the authorization code pasted from the browser during a login. */ - onSubmitLoginCode?: (kind: AgentKind, code: string) => void; - /** Aborts an in-flight login. */ - onCancelLogin?: (kind: AgentKind) => void; - /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ - onUseApiKey?: (kind: AgentKind) => void; + /** Opens Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; /** Starts the session and sends the prompt. A rejection keeps the page up — the caller's error * banner reports the failure, same contract as the conversation composer. */ onSubmit: (submission: NewSessionSubmission) => Promise; @@ -157,10 +151,7 @@ export function NewSessionSurface({ onMentionQueryChange, onDownloadAgent, onContinueUnverified, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, - onUseApiKey, + onOpenProviderSettings, onSubmit, onPickDirectory, onRegisterWorkspace, @@ -336,12 +327,9 @@ export function NewSessionSurface({ diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index c8065bb24..20c6c9038 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -65,14 +65,8 @@ export interface ShellFrameProps onDownloadAgent?: (kind: AgentKind) => void; /** Accepts an out-of-range detected version for the current pick. */ onContinueUnverified?: (kind: AgentKind) => void; - /** Starts (or retries) the interactive login for a signed-out agent. */ - onLoginAgent?: (kind: AgentKind) => void; - /** Submits the authorization code pasted from the browser during a login. */ - onSubmitLoginCode?: (kind: AgentKind, code: string) => void; - /** Aborts an in-flight login. */ - onCancelLogin?: (kind: AgentKind) => void; - /** Opens the API-key / relay-endpoint alternative to the CLI's OAuth login. */ - onUseApiKey?: (kind: AgentKind) => void; + /** Opens Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; conversation: ConversationViewModel; respondingRequestIds: ReadonlySet; responseErrors?: ReadonlyMap; @@ -136,10 +130,7 @@ export function ShellFrame({ NewSessionBranchPickerComponent, onDownloadAgent, onContinueUnverified, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, - onUseApiKey, + onOpenProviderSettings, conversation, respondingRequestIds, responseErrors, @@ -230,10 +221,7 @@ export function ShellFrame({ mentionItems={mentionItems} onContinueUnverified={onContinueUnverified} onDownloadAgent={onDownloadAgent} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} - onUseApiKey={onUseApiKey} + onOpenProviderSettings={onOpenProviderSettings} onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onRegisterWorkspace={onRegisterWorkspace} @@ -252,10 +240,7 @@ export function ShellFrame({ isRunning={isRunning} cwd={active?.cwd} runtimeCues={runtimeCues} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} - onUseApiKey={onUseApiKey} + onOpenProviderSettings={onOpenProviderSettings} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent} From 6a43d517e5b3f7fb988abfadb06b60c1b26a63b9 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 12:43:39 +0000 Subject: [PATCH 07/12] fix(settings): open provider service catalog directly Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- .../src/shell/desktop-workbench-shell.tsx | 4 +- .../webview/src/shell/web-workbench-shell.tsx | 4 +- .../workbench/src/agent-runtime/onboarding.ts | 10 +- .../settings/providers/providers-settings.tsx | 121 +----------------- .../workbench/src/settings/providers/store.ts | 7 - 5 files changed, 11 insertions(+), 135 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx index 8d1f004e2..4505e2a15 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx @@ -12,8 +12,8 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps) systemBridge={systemBridge} header={header} onOpenSettings={() => openDesktopSettings()} - onOpenProviderSettings={(kind) => { - useProvidersSettingsStore.getState().startAgentSetup(kind); + onOpenProviderSettings={() => { + useProvidersSettingsStore.getState().startAdd(); openDesktopSettings('providers'); }} onOpenAutomations={() => useNavigationHistoryStore.getState().openOverlay('automations')} diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index 4be6a6e8e..be25a4893 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -59,8 +59,8 @@ export function WebWorkbenchShell({ { - useProvidersSettingsStore.getState().startAgentSetup(kind); + onOpenProviderSettings={() => { + useProvidersSettingsStore.getState().startAdd(); void navigate('/settings/providers'); }} onOpenAutomations={() => { diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index 8f2bbe03d..b23d8f89a 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -221,7 +221,7 @@ export function useAgentRuntimeOnboarding(): { cues: AgentRuntimeCues; download: (kind: AgentKind) => void; acknowledgeUnverified: (kind: AgentKind) => void; - login: (kind: AgentKind, onSuccess?: () => void) => void; + login: (kind: AgentKind) => void; submitLoginCode: (kind: AgentKind, code: string) => void; cancelLogin: (kind: AgentKind) => void; } { @@ -312,7 +312,7 @@ export function useAgentRuntimeOnboarding(): { }); } - function login(kind: AgentKind, onSuccess?: () => void): void { + function login(kind: AgentKind): void { const entry = { loginId: undefined as string | undefined, cancelled: false }; activeLoginsRef.current.set(kind, entry); setLogin(kind, { kind: 'opening' }); @@ -333,10 +333,8 @@ export function useAgentRuntimeOnboarding(): { activeLoginsRef.current.delete(kind); // Success: the re-probe push flips the runtime to logged-in and drops the cue. Cancel: // back to the idle login button. Failure: show the error with a retry. - if (ok || entry.cancelled) { - setLogin(kind, undefined); - if (ok) onSuccess?.(); - } else setLogin(kind, { kind: 'failed', error: error ?? 'login failed' }); + if (ok || entry.cancelled) setLogin(kind, undefined); + else setLogin(kind, { kind: 'failed', error: error ?? 'login failed' }); }, }); }) diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 218ea3611..219a143c7 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -1,22 +1,16 @@ import type { Account, AgentKind, ProvidersConfig } from '@linkcode/schema'; import { getAccounts, getProviderConfig, setAccounts, setProviderConfig } from '@linkcode/sdk'; -import { AccountDetail, AccountList, AGENT_LABELS, AgentOnboardingCard } from '@linkcode/ui'; -import { Button } from 'coss-ui/components/button'; +import { AccountDetail, AccountList } from '@linkcode/ui'; import { Dialog, - DialogDescription, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from 'coss-ui/components/dialog'; import { Skeleton } from 'coss-ui/components/skeleton'; -import { ChevronLeftIcon, KeyRoundIcon, LogInIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; -import { AgentApiKeyLoginDialog } from '../../agent-runtime/api-key-login/dialog'; -import { useAgentApiKeyLoginStore } from '../../agent-runtime/api-key-login/store'; import { useAgentRuntimes } from '../../agent-runtime/hooks'; -import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; import { serviceById } from './catalog'; @@ -43,7 +37,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { } = useData(getAccounts, {}); const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); const { data: runtimes } = useAgentRuntimes(); - const onboarding = useAgentRuntimeOnboarding(); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); @@ -52,8 +45,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { const startEdit = useProvidersSettingsStore((state) => state.startEdit); const backToAccount = useProvidersSettingsStore((state) => state.backToAccount); const startAdd = useProvidersSettingsStore((state) => state.startAdd); - const startAgentSetup = useProvidersSettingsStore((state) => state.startAgentSetup); - const startAgentLogin = useProvidersSettingsStore((state) => state.startAgentLogin); const pickService = useProvidersSettingsStore((state) => state.pickService); const backToCatalog = useProvidersSettingsStore((state) => state.backToCatalog); const closeDialog = useProvidersSettingsStore((state) => state.closeDialog); @@ -113,21 +104,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { closeDialog(); }; - const handleSubscriptionLogin = (kind: AgentKind): void => { - startAgentLogin(kind); - onboarding.login(kind, closeDialog); - }; - - const handleCancelLogin = (kind: AgentKind): void => { - onboarding.cancelLogin(kind); - startAgentSetup(kind); - }; - - const handleApiKeySetup = (kind: AgentKind): void => { - closeDialog(); - useAgentApiKeyLoginStore.getState().open(kind); - }; - const dialogOpen = view.kind !== 'browse'; return ( @@ -145,53 +121,14 @@ export function ProvidersSettingsPanel(): React.ReactNode { open={dialogOpen} disablePointerDismissal={saveAccounts.isMutating} onOpenChange={(open) => { - if (!open && !saveAccounts.isMutating) { - if (view.kind === 'agent-login') onboarding.cancelLogin(view.agent); - closeDialog(); - } + if (!open && !saveAccounts.isMutating) closeDialog(); }} > - {view.kind === 'agent-setup' ? ( - - ) : view.kind === 'agent-login' ? ( - <> - - - {t('setup.subscriptionTitle', { agent: AGENT_LABELS[view.agent] })} - - - -
- -
- -
- - ) : view.kind === 'add-catalog' ? ( + {view.kind === 'add-catalog' ? ( <> {t('chooseService')} @@ -251,58 +188,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { )}
- ); } - -function AgentProviderSetupChoices({ - kind, - onLogin, - onApiKey, -}: { - kind: AgentKind; - onLogin: (kind: AgentKind) => void; - onApiKey: (kind: AgentKind) => void; -}): React.ReactNode { - const t = useTranslations('settings.providers'); - const agent = AGENT_LABELS[kind]; - return ( - <> - - {t('setup.title', { agent })} - {t('setup.hint')} - - -
- - -
-
- - ); -} diff --git a/packages/client/workbench/src/settings/providers/store.ts b/packages/client/workbench/src/settings/providers/store.ts index bc9e6b054..0717df192 100644 --- a/packages/client/workbench/src/settings/providers/store.ts +++ b/packages/client/workbench/src/settings/providers/store.ts @@ -1,4 +1,3 @@ -import type { AgentKind } from '@linkcode/schema'; import { create } from 'zustand'; /** @@ -10,8 +9,6 @@ export type ProvidersSettingsView = | { kind: 'browse' } | { kind: 'add-catalog' } | { kind: 'add-form'; service: string } - | { kind: 'agent-setup'; agent: AgentKind } - | { kind: 'agent-login'; agent: AgentKind } | { kind: 'account'; accountId: string; editing: boolean }; interface ProvidersSettingsState { @@ -21,8 +18,6 @@ interface ProvidersSettingsState { startEdit: () => void; backToAccount: () => void; startAdd: () => void; - startAgentSetup: (agent: AgentKind) => void; - startAgentLogin: (agent: AgentKind) => void; pickService: (service: string) => void; backToCatalog: () => void; closeDialog: () => void; @@ -44,8 +39,6 @@ export const useProvidersSettingsStore = create()((set) : { view: state.view }, ), startAdd: () => set({ view: { kind: 'add-catalog' } }), - startAgentSetup: (agent) => set({ view: { kind: 'agent-setup', agent } }), - startAgentLogin: (agent) => set({ view: { kind: 'agent-login', agent } }), pickService: (service) => set({ view: { kind: 'add-form', service } }), backToCatalog: () => set({ view: { kind: 'add-catalog' } }), closeDialog: () => set({ view: { kind: 'browse' } }), From 9cc79cd1ab9923ba75381394008c8f406617b638 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 12:44:00 +0000 Subject: [PATCH 08/12] refactor(workbench): remove obsolete API key login dialog Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- .../agent-runtime/api-key-login/dialog.tsx | 361 ------------------ .../src/agent-runtime/api-key-login/store.ts | 20 - .../src/settings/providers/capability.ts | 6 - 3 files changed, 387 deletions(-) delete mode 100644 packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx delete mode 100644 packages/client/workbench/src/agent-runtime/api-key-login/store.ts diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx b/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx deleted file mode 100644 index a90f316c9..000000000 --- a/packages/client/workbench/src/agent-runtime/api-key-login/dialog.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import type { Account, AccountModel, AccountSecret, AgentKind } from '@linkcode/schema'; -import { AccountProtocolSchema } from '@linkcode/schema'; -import { - createAndBindAccount, - getAccounts, - getProviderConfig, - probeAccountModels, -} from '@linkcode/sdk'; -import { AGENT_LABELS } from '@linkcode/ui'; -import { Button } from 'coss-ui/components/button'; -import { - Dialog, - DialogDescription, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from 'coss-ui/components/dialog'; -import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; -import { Form } from 'coss-ui/components/form'; -import { Input } from 'coss-ui/components/input'; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from 'coss-ui/components/select'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -import { Loader2Icon } from 'lucide-react'; -import { useRef, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { useTranslations } from 'use-intl'; -import { z } from 'zod'; -import { rhfErrorsToFormErrors } from '../../lib/form'; -import { useData, useMutation } from '../../runtime/tayori'; -import { nativeAccountProtocol } from '../../settings/providers/capability'; -import { useAgentApiKeyLoginStore } from './store'; - -const DraftSchema = z.object({ - label: z.string().trim().min(1), - baseUrl: z.url(), - protocol: AccountProtocolSchema, - credentialType: z.enum(['api-key', 'auth-token']), - secret: z.string().trim().min(1), - model: z.string().trim(), -}); -type Draft = z.infer; - -function draftSecret(draft: Pick): AccountSecret { - return draft.credentialType === 'auth-token' - ? { type: 'auth-token', token: draft.secret.trim() } - : { type: 'api-key', key: draft.secret.trim() }; -} - -function detectionKey( - draft: Pick, -): string { - return JSON.stringify([ - draft.baseUrl.trim(), - draft.credentialType, - draft.protocol, - draft.secret.trim(), - ]); -} - -/** Account constructor at module scope: `Date.now` may not run in a component body. */ -function accountFromDraft(id: string, draft: Draft): Account { - return { - id, - label: draft.label.trim(), - credential: draftSecret(draft), - endpoint: { baseUrl: draft.baseUrl.trim(), protocol: draft.protocol }, - ...(draft.model.trim() && { model: draft.model.trim() }), - createdAt: Date.now(), - }; -} - -/** - * The API-key branch of the Providers settings setup flow: a relay/gateway key plus its base URL, - * saved as a normal pool account and bound as the agent's active one. - */ -export function AgentApiKeyLoginDialog(): React.ReactNode { - const kind = useAgentApiKeyLoginStore((state) => state.kind); - const accountId = useAgentApiKeyLoginStore((state) => state.accountId); - const close = useAgentApiKeyLoginStore((state) => state.close); - const t = useTranslations('workbench.apiKeyLogin'); - - return ( - !open && close()}> - - - {t('title', { agent: kind ? AGENT_LABELS[kind] : '' })} - {t('hint')} - - - {/* Keyed per agent so switching agents starts from that agent's own defaults. */} - {kind !== null && accountId !== null && ( - - )} - - - - ); -} - -function ApiKeyLoginForm({ - kind, - accountId, - onDone, -}: { - kind: AgentKind; - accountId: string; - onDone: () => void; -}): React.ReactNode { - const t = useTranslations('workbench.apiKeyLogin'); - const { mutate: mutateAccounts } = useData(getAccounts, {}); - const { mutate: mutateProviders } = useData(getProviderConfig, {}); - const save = useMutation(createAndBindAccount); - const probe = useMutation(probeAccountModels); - // Detection result and its failure are transient UI state, not part of the submitted draft. - const [detected, setDetected] = useState(null); - const [detectError, setDetectError] = useState(null); - const probeGenerationRef = useRef(0); - - const { - control, - register, - handleSubmit, - getValues, - setError, - setValue, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(DraftSchema), - defaultValues: { - label: t('defaultLabel', { agent: AGENT_LABELS[kind] }), - baseUrl: '', - protocol: nativeAccountProtocol(kind), - credentialType: 'api-key', - secret: '', - model: '', - }, - }); - const baseUrlField = register('baseUrl'); - const secretField = register('secret'); - - const onSubmit = handleSubmit(async (draft) => { - try { - const account = accountFromDraft(accountId, draft); - await save.trigger({ agent: kind, account }); - await Promise.all([mutateAccounts(), mutateProviders()]); - onDone(); - } catch (error) { - setError('root', { message: extractErrorMessage(error, false) ?? t('saveFailed') }); - } - }); - - function invalidateDetection(): void { - probeGenerationRef.current += 1; - if (detected !== null) setValue('model', ''); - setDetected(null); - setDetectError(null); - } - - // Imperative read: detection is an action on the current values, not a field subscription. - async function detect(): Promise { - const generation = ++probeGenerationRef.current; - const draft = getValues(); - const key = detectionKey(draft); - setDetectError(null); - const endpoint = z - .object({ baseUrl: z.url(), protocol: AccountProtocolSchema }) - .safeParse({ baseUrl: draft.baseUrl.trim(), protocol: draft.protocol }); - if (!endpoint.success || draft.secret.trim() === '') { - setDetectError(t('detectNeedsEndpoint')); - return; - } - try { - const models = await probe.trigger({ endpoint: endpoint.data, secret: draftSecret(draft) }); - if (generation !== probeGenerationRef.current || key !== detectionKey(getValues())) return; - setDetected(models); - if (models.length === 0) setDetectError(t('detectEmpty')); - } catch (error) { - if (generation !== probeGenerationRef.current || key !== detectionKey(getValues())) return; - setDetected(null); - setDetectError(extractErrorMessage(error, false) ?? t('detectFailed')); - } - } - - const protocolItems = AccountProtocolSchema.options.map((protocol) => ({ - value: protocol, - label: t(`protocol.${protocol}`), - })); - const credentialItems = [ - { value: 'api-key', label: t('credentialApiKey') }, - { value: 'auth-token', label: t('credentialAuthToken') }, - ]; - - return ( -
- - {t('labelField')} - - - - - {t('baseUrl')} - { - void baseUrlField.onChange(event); - invalidateDetection(); - }} - /> - - -
-
- - {t('credentialType')} - ( - { - field.onChange(value); - invalidateDetection(); - }} - /> - )} - /> - -
-
- - {t('protocol.field')} - ( - { - field.onChange(value); - invalidateDetection(); - }} - /> - )} - /> - -
-
- - {t('secret')} - { - void secretField.onChange(event); - invalidateDetection(); - }} - /> - - - - {t('model')} -
- {detected && detected.length > 0 ? ( - ( - ({ - value: model.id, - label: model.label ?? model.id, - }))} - value={field.value} - onValueChange={field.onChange} - /> - )} - /> - ) : ( - - )} - -
- {detectError !== null && {detectError}} - {detected && detected.length > 0 && detectError === null && ( - - {t('detectCount', { count: detected.length })} - - )} -
-
- - -
-
- ); -} - -function DraftSelect({ - items, - value, - onValueChange, -}: { - items: Array<{ value: string; label: string }>; - value: string; - onValueChange: (value: string) => void; -}): React.ReactNode { - return ( - - ); -} diff --git a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts b/packages/client/workbench/src/agent-runtime/api-key-login/store.ts deleted file mode 100644 index 4be4cfac9..000000000 --- a/packages/client/workbench/src/agent-runtime/api-key-login/store.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AgentKind } from '@linkcode/schema'; -import { create } from 'zustand'; - -/** - * Which agent's API-key setup dialog is open. Module-scope because the Providers method picker and - * the API-key dialog are sibling overlays. Not persisted. - */ -interface AgentApiKeyLoginState { - kind: AgentKind | null; - accountId: string | null; - open: (kind: AgentKind) => void; - close: () => void; -} - -export const useAgentApiKeyLoginStore = create()((set) => ({ - kind: null, - accountId: null, - open: (kind) => set({ kind, accountId: `acc_${crypto.randomUUID()}` }), - close: () => set({ kind: null, accountId: null }), -})); diff --git a/packages/client/workbench/src/settings/providers/capability.ts b/packages/client/workbench/src/settings/providers/capability.ts index ff92867a2..6e571c925 100644 --- a/packages/client/workbench/src/settings/providers/capability.ts +++ b/packages/client/workbench/src/settings/providers/capability.ts @@ -25,12 +25,6 @@ const TRANSLATABLE: ReadonlyArray<{ agent: AgentKind; upstream: AccountProtocol { agent: 'claude-code', upstream: 'openai-chat' }, ]; -/** The protocol an agent speaks natively — what an API-key login should offer it by default. - * grok-build accepts no custom endpoint at all, so its fallback is only a form default. */ -export function nativeAccountProtocol(kind: AgentKind): AccountProtocol { - return AGENT_NATIVE_PROTOCOLS[kind][0] ?? 'openai-chat'; -} - export type BindingTier = 'native' | 'translate' | 'unavailable'; export type BindingUnavailableReason = From 808dee0ca296d8df7774dd59a04b0544485e25d8 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 12:44:14 +0000 Subject: [PATCH 09/12] chore(i18n): remove obsolete provider setup copy Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- packages/presentation/i18n/src/locales/en.ts | 38 ------------------- .../presentation/i18n/src/locales/zh-cn.ts | 38 ------------------- 2 files changed, 76 deletions(-) diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index c623af4df..986a182b2 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -300,34 +300,6 @@ export const en = { loginFailedTitle: '{agent} sign-in failed', goToSettings: 'Go to settings', }, - apiKeyLogin: { - title: 'Add an API key for {agent}', - hint: "Paste a relay or gateway API key and its base URL. It is saved as a pool account and bound as this agent's active one.", - defaultLabel: '{agent} relay', - labelField: 'Name', - baseUrl: 'Base URL', - baseUrlPlaceholder: 'https://…', - credentialType: 'Credential type', - credentialApiKey: 'API key (x-api-key)', - credentialAuthToken: 'Auth token (Bearer)', - secret: 'Secret', - protocol: { - field: 'Protocol', - anthropic: 'Anthropic', - 'openai-chat': 'OpenAI Chat', - 'openai-responses': 'OpenAI Responses', - }, - model: 'Default model', - modelPlaceholder: "Leave empty to use the agent's default", - detect: 'Detect models', - detectCount: '{count} models detected', - detectEmpty: 'The endpoint returned no models', - detectNeedsEndpoint: 'Enter a valid base URL and secret first', - detectFailed: 'Detection failed', - saveFailed: 'Could not save', - cancel: 'Cancel', - save: 'Save and use', - }, composer: { placeholder: 'Describe what you want {agent} to do, or @-reference a file / terminal output…', placeholderDisconnected: 'Create or pick a thread first', @@ -1025,16 +997,6 @@ export const en = { removeHint: 'This account is not connected to any agent.', cancel: 'Cancel', chooseService: 'Choose a service', - setup: { - title: 'Add a provider for {agent}', - hint: 'Choose how you want to use this agent.', - subscription: 'Login with subscription', - subscriptionHint: 'Use your existing {agent} subscription.', - subscriptionTitle: 'Login to {agent} with subscription', - apiKey: 'Add API key', - apiKeyHint: 'Use a direct API, relay, or gateway endpoint.', - chooseMethod: 'Choose another method', - }, group: { subscription: 'Subscriptions', direct: 'Direct APIs', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 9dac914a9..ba9ac5378 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -291,34 +291,6 @@ export const zhCN = { loginFailedTitle: '{agent} 登录失败', goToSettings: '前往设置', }, - apiKeyLogin: { - title: '为 {agent} 添加 API Key', - hint: '填入中转站或网关的 API Key 与 Base URL。保存后会作为账号存入账号池,并绑定为该 agent 的活跃账号。', - defaultLabel: '{agent} 中转站', - labelField: '名称', - baseUrl: 'Base URL', - baseUrlPlaceholder: 'https://…', - credentialType: '凭据类型', - credentialApiKey: 'API Key(x-api-key)', - credentialAuthToken: 'Auth Token(Bearer)', - secret: '密钥', - protocol: { - field: '接口协议', - anthropic: 'Anthropic', - 'openai-chat': 'OpenAI Chat', - 'openai-responses': 'OpenAI Responses', - }, - model: '默认模型', - modelPlaceholder: '留空则用 agent 默认模型', - detect: '检测模型', - detectCount: '检测到 {count} 个模型', - detectEmpty: '该端点没有返回任何模型', - detectNeedsEndpoint: '请先填写有效的 Base URL 与密钥', - detectFailed: '检测失败', - saveFailed: '保存失败', - cancel: '取消', - save: '保存并使用', - }, composer: { placeholder: '描述想让 {agent} 做什么,或用 @ 引用文件 / 终端输出…', placeholderDisconnected: '请先创建或选择线程', @@ -1003,16 +975,6 @@ export const zhCN = { removeHint: '此账号尚未接入任何智能体。', cancel: '取消', chooseService: '选择服务商', - setup: { - title: '为 {agent} 添加 Provider', - hint: '选择你想使用此智能体的方式。', - subscription: '使用订阅登录', - subscriptionHint: '使用现有的 {agent} 订阅账号。', - subscriptionTitle: '使用订阅登录 {agent}', - apiKey: '添加 API Key', - apiKeyHint: '使用 API 直连、中转站或网关端点。', - chooseMethod: '选择其他方式', - }, group: { subscription: '订阅', direct: 'API 直连', From 3cbd825e8128f7913f79015e4be9c9b6d788d149 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 13:14:23 +0000 Subject: [PATCH 10/12] fix(workbench): support completable agent logins Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- .../__tests__/onboarding-hook.test.ts | 160 ++++++++++++++++++ .../__tests__/onboarding.test.ts | 16 ++ .../workbench/src/agent-runtime/onboarding.ts | 49 ++++-- 3 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 packages/client/workbench/src/agent-runtime/__tests__/onboarding-hook.test.ts diff --git a/packages/client/workbench/src/agent-runtime/__tests__/onboarding-hook.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding-hook.test.ts new file mode 100644 index 000000000..52fefe1d7 --- /dev/null +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding-hook.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { nullthrow } from 'foxact/nullthrow'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAgentRuntimeOnboarding } from '../onboarding'; + +interface LoginHandlers { + onUrl: (url: string) => void; + onSettled: (result: { ok: boolean; error?: string }) => void; +} + +const mocks = vi.hoisted(() => ({ + client: { + subscribeAssetProgress: vi.fn(() => vi.fn()), + subscribeAssetSettled: vi.fn(() => vi.fn()), + subscribeAgentRuntimesChanged: vi.fn(() => vi.fn()), + startAgentLogin: vi.fn(), + subscribeAgentLogin: vi.fn(), + submitLoginCode: vi.fn(), + cancelAgentLogin: vi.fn(), + }, + runtimes: {}, + ensureAsset: vi.fn(), + acknowledge: vi.fn(), +})); + +vi.mock('@linkcode/client-core', () => ({ + useLinkCodeClient: () => mocks.client, +})); + +vi.mock('../../assets/hooks', () => ({ + useAssets: () => ({ data: [] }), +})); + +vi.mock('../hooks', () => ({ + useAgentRuntimes: () => ({ data: mocks.runtimes }), +})); + +vi.mock('../../runtime/tayori', () => ({ + useData: (operation: { name: string }) => ({ + data: operation.name === 'getAccounts' ? [] : {}, + }), + useMutation: () => ({ trigger: mocks.ensureAsset }), +})); + +vi.mock('../unverified-store', () => ({ + useUnverifiedRuntimesStore: ( + selector: (state: { + acknowledged: Record; + acknowledge: (kind: string, version: string) => void; + }) => unknown, + ) => selector({ acknowledged: {}, acknowledge: mocks.acknowledge }), +})); + +let loginHandlers: LoginHandlers | undefined; + +function handlers(): LoginHandlers { + return nullthrow(loginHandlers, 'login handlers were not registered'); +} + +const ignoreLoginId = (_loginId: string): void => undefined; + +beforeEach(() => { + vi.clearAllMocks(); + loginHandlers = undefined; + mocks.runtimes = { + 'claude-code': { + status: 'available', + source: 'detected', + auth: { loggedIn: false }, + }, + codex: { status: 'available', source: 'detected', auth: { loggedIn: false } }, + }; + mocks.client.startAgentLogin.mockResolvedValue('login-1'); + mocks.client.subscribeAgentLogin.mockImplementation((_loginId: string, next: LoginHandlers) => { + loginHandlers = next; + return vi.fn(); + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('useAgentRuntimeOnboarding login lifecycle', () => { + it('opens the Codex authorization URL and calls success only after login settles', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + const onSuccess = vi.fn(); + const { result } = renderHook(() => useAgentRuntimeOnboarding()); + + act(() => result.current.login('codex', onSuccess)); + await waitFor(() => + expect(mocks.client.subscribeAgentLogin).toHaveBeenCalledWith('login-1', expect.any(Object)), + ); + + act(() => handlers().onUrl('https://auth.openai.test/authorize')); + expect(open).toHaveBeenCalledWith( + 'https://auth.openai.test/authorize', + '_blank', + 'noopener,noreferrer', + ); + expect(result.current.cues.codex).toEqual({ + state: 'needs-login', + phase: 'awaiting-code', + url: 'https://auth.openai.test/authorize', + }); + expect(onSuccess).not.toHaveBeenCalled(); + + act(() => handlers().onSettled({ ok: true })); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it('keeps Claude on its self-opened browser flow and forwards the pasted code', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + const { result } = renderHook(() => useAgentRuntimeOnboarding()); + + act(() => result.current.login('claude-code')); + await waitFor(() => expect(mocks.client.subscribeAgentLogin).toHaveBeenCalled()); + act(() => handlers().onUrl('https://claude.ai/oauth/authorize')); + + expect(open).not.toHaveBeenCalled(); + act(() => result.current.submitLoginCode('claude-code', 'authorization-code')); + expect(mocks.client.submitLoginCode).toHaveBeenCalledWith('login-1', 'authorization-code'); + }); + + it('does not call success after failure or cancellation', async () => { + const onFailureSuccess = vi.fn(); + const first = renderHook(() => useAgentRuntimeOnboarding()); + act(() => first.result.current.login('codex', onFailureSuccess)); + await waitFor(() => expect(mocks.client.subscribeAgentLogin).toHaveBeenCalled()); + act(() => handlers().onSettled({ ok: false, error: 'denied' })); + expect(onFailureSuccess).not.toHaveBeenCalled(); + expect(first.result.current.cues.codex).toEqual({ + state: 'needs-login', + phase: 'failed', + error: 'denied', + }); + first.unmount(); + + let resolveStart = ignoreLoginId; + const pendingStart = new Promise((resolve) => { + resolveStart = resolve; + }); + mocks.client.startAgentLogin.mockReturnValue(pendingStart); + const onCancelledSuccess = vi.fn(); + const second = renderHook(() => useAgentRuntimeOnboarding()); + act(() => second.result.current.login('claude-code', onCancelledSuccess)); + act(() => second.result.current.cancelLogin('claude-code')); + await act(async () => { + resolveStart('login-2'); + await pendingStart; + }); + + expect(mocks.client.cancelAgentLogin).toHaveBeenCalledWith('login-2'); + act(() => handlers().onSettled({ ok: true })); + expect(onCancelledSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts index 244daf105..a72ebabb6 100644 --- a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts @@ -215,6 +215,22 @@ describe('deriveAgentRuntimeCues', () => { ).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' } }); }); + it('surfaces an explicitly started login even when an injected key suppresses the idle cue', () => { + const runtimes: AgentRuntimes = { + codex: { status: 'available', source: 'detected', auth: { loggedIn: false } }, + }; + expect( + deriveAgentRuntimeCues( + runtimes, + ASSETS, + {}, + {}, + { codex: { kind: 'opening' } }, + { codex: { enabled: true, apiKey: 'sk-x' } }, + ), + ).toEqual({ codex: { state: 'needs-login', phase: 'opening' } }); + }); + it('suppresses the login cue for a bound key account, but not for a bound oauth one', () => { const runtimes: AgentRuntimes = { 'claude-code': { status: 'available', source: 'detected', auth: { loggedIn: false } }, diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index b23d8f89a..fb60f2f6e 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -182,12 +182,15 @@ function deriveCue( accounts: Accounts, ): AgentRuntimeCue | undefined { switch (runtime.status) { - case 'available': + case 'available': { + const currentLogin = loginActivity[kind]; + if (currentLogin) return loginCue(currentLogin); // `auth` absent means unprobed or a fail-open probe — don't block. An injected credential // makes a signed-out CLI runnable, so it clears the cue (see hasInjectedCredential). return runtime.auth?.loggedIn === false && !hasInjectedCredential(kind, providers, accounts) - ? loginCue(loginActivity[kind]) + ? loginCue(undefined) : undefined; + } case 'out-of-range': { // "Download paired version" is the same install as the missing flow — activity must win over // the probed status here too, or progress never shows and a settled install stays blocked. @@ -217,14 +220,16 @@ function deriveCue( * The derived cue map plus the onboarding actions, per agent kind. Progress streams in via the * asset broadcasts; snapshots revalidate through the push-aware useAssets/useAgentRuntimes. */ -export function useAgentRuntimeOnboarding(): { +export interface AgentRuntimeOnboarding { cues: AgentRuntimeCues; download: (kind: AgentKind) => void; acknowledgeUnverified: (kind: AgentKind) => void; - login: (kind: AgentKind) => void; + login: (kind: AgentKind, onSuccess?: () => void) => void; submitLoginCode: (kind: AgentKind, code: string) => void; cancelLogin: (kind: AgentKind) => void; -} { +} + +export function useAgentRuntimeOnboarding(): AgentRuntimeOnboarding { const client = useLinkCodeClient(); const { data: runtimes } = useAgentRuntimes(); const { data: assets } = useAssets(); @@ -281,6 +286,17 @@ export function useAgentRuntimeOnboarding(): { [client], ); + useEffect( + () => () => { + for (const entry of activeLoginsRef.current.values()) { + entry.cancelled = true; + if (entry.loginId) client.cancelAgentLogin(entry.loginId); + } + activeLoginsRef.current.clear(); + }, + [client], + ); + function download(kind: AgentKind): void { const id = AGENT_ASSET_IDS[kind]; if (!id) return; @@ -312,8 +328,13 @@ export function useAgentRuntimeOnboarding(): { }); } - function login(kind: AgentKind): void { - const entry = { loginId: undefined as string | undefined, cancelled: false }; + function login(kind: AgentKind, onSuccess?: () => void): void { + const previous = activeLoginsRef.current.get(kind); + if (previous) { + previous.cancelled = true; + if (previous.loginId) client.cancelAgentLogin(previous.loginId); + } + const entry = { loginId: undefined as string | undefined, cancelled: false, onSuccess }; activeLoginsRef.current.set(kind, entry); setLogin(kind, { kind: 'opening' }); client @@ -322,6 +343,7 @@ export function useAgentRuntimeOnboarding(): { entry.loginId = loginId; client.subscribeAgentLogin(loginId, { onUrl(url) { + if (activeLoginsRef.current.get(kind) !== entry || entry.cancelled) return; // Desktop routes `_blank` to the system browser; the card keeps `url` as a fallback // link. Self-opening CLIs already launched their own tab — don't race it in a second. if (!SELF_OPENING_LOGIN_KINDS.has(kind)) { @@ -330,17 +352,24 @@ export function useAgentRuntimeOnboarding(): { setLogin(kind, { kind: 'awaiting-code', url }); }, onSettled({ ok, error }) { + if (activeLoginsRef.current.get(kind) !== entry) return; activeLoginsRef.current.delete(kind); // Success: the re-probe push flips the runtime to logged-in and drops the cue. Cancel: // back to the idle login button. Failure: show the error with a retry. - if (ok || entry.cancelled) setLogin(kind, undefined); - else setLogin(kind, { kind: 'failed', error: error ?? 'login failed' }); + if (entry.cancelled) setLogin(kind, undefined); + else if (ok) { + setLogin(kind, undefined); + entry.onSuccess?.(); + } else setLogin(kind, { kind: 'failed', error: error ?? 'login failed' }); }, }); + if (entry.cancelled) client.cancelAgentLogin(loginId); }) .catch((err: unknown) => { + if (activeLoginsRef.current.get(kind) !== entry) return; activeLoginsRef.current.delete(kind); - setLogin(kind, { kind: 'failed', error: extractErrorMessage(err) ?? 'login failed' }); + if (entry.cancelled) setLogin(kind, undefined); + else setLogin(kind, { kind: 'failed', error: extractErrorMessage(err) ?? 'login failed' }); }); } From 8f6a3abba0a337900586c83499b126a5c5a13220 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 13:14:57 +0000 Subject: [PATCH 11/12] fix(providers): authenticate subscription accounts Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- .../providers/__tests__/add-flow.test.tsx | 166 ++++++++++++++++++ .../src/settings/providers/add-flow.tsx | 75 +++++--- .../settings/providers/providers-settings.tsx | 45 ++++- packages/presentation/i18n/src/locales/en.ts | 3 - .../presentation/i18n/src/locales/zh-cn.ts | 2 - 5 files changed, 253 insertions(+), 38 deletions(-) create mode 100644 packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx diff --git a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx new file mode 100644 index 000000000..17a3e4e5a --- /dev/null +++ b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import type { AgentRuntimes } from '@linkcode/schema'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AgentRuntimeOnboarding } from '../../../agent-runtime/onboarding'; +import { AddAccountForm } from '../add-flow'; + +function translateKey(key: string): string { + return key; +} + +vi.mock('use-intl', () => ({ + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +function onboarding(overrides: Partial = {}): AgentRuntimeOnboarding { + return { + cues: {}, + download: vi.fn(), + acknowledgeUnverified: vi.fn(), + login: vi.fn(), + submitLoginCode: vi.fn(), + cancelLogin: vi.fn(), + ...overrides, + }; +} + +function signedOutRuntimes(): AgentRuntimes { + return { + 'claude-code': { + status: 'available', + source: 'detected', + auth: { loggedIn: false }, + }, + codex: { status: 'available', source: 'detected', auth: { loggedIn: false } }, + }; +} + +describe('subscription account creation', () => { + it('starts Claude login and creates the account only from the success callback', () => { + const login = vi.fn(); + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'login' })); + expect(login).toHaveBeenCalledWith('claude-code', expect.any(Function)); + expect(onSubmit).not.toHaveBeenCalled(); + + const onSuccess = login.mock.calls[0]?.[1] as (() => void) | undefined; + act(() => onSuccess?.()); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + service: 'claude-sub', + credential: { type: 'oauth', agent: 'claude-code' }, + }), + ); + }); + + it('forwards Claude authorization codes and cancellation to the shared state machine', () => { + const submitLoginCode = vi.fn(); + const cancelLogin = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('loginCodePlaceholder'), { + target: { value: ' pasted-code ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'loginSubmit' })); + expect(submitLoginCode).toHaveBeenCalledWith('claude-code', 'pasted-code'); + fireEvent.click(screen.getByRole('button', { name: 'loginCancel' })); + expect(cancelLogin).toHaveBeenCalledWith('claude-code'); + }); + + it('adds an already authenticated ChatGPT subscription without restarting login', () => { + const login = vi.fn(); + const onSubmit = vi.fn(); + const runtimes: AgentRuntimes = { + codex: { + status: 'available', + source: 'detected', + auth: { loggedIn: true, email: 'user@example.com' }, + }, + }; + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'form.submit' })); + expect(login).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + service: 'chatgpt-sub', + credential: { type: 'oauth', agent: 'codex' }, + }), + ); + }); +}); + +describe('non-subscription account creation', () => { + it('keeps direct API services on the existing key form', async () => { + const login = vi.fn(); + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('sk-ant-…'), { target: { value: 'sk-ant-test' } }); + fireEvent.click(screen.getByRole('button', { name: 'form.submit' })); + expect(login).not.toHaveBeenCalled(); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-ant-test' }, + }), + ), + ); + }); +}); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index e1dda8787..84880984d 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { Account, AccountProtocol, AgentRuntimes } from '@linkcode/schema'; -import { ServiceIcon } from '@linkcode/ui'; +import { AgentOnboardingCard, ServiceIcon } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; import { Field, FieldLabel } from 'coss-ui/components/field'; import { Input } from 'coss-ui/components/input'; @@ -18,6 +18,7 @@ import type { Control, FieldValues, Path } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form'; import { useTranslations } from 'use-intl'; import { z } from 'zod'; +import type { AgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import type { ServiceDescriptor, ServiceGroup, ServiceVariant } from './catalog'; import { fillTemplate, SERVICE_CATALOG, serviceById, templatePlaceholders } from './catalog'; @@ -144,12 +145,14 @@ export function ServiceCatalogView({ export function AddAccountForm({ serviceId, runtimes, + onboarding, busy, onBack, onSubmit, }: { serviceId: string; runtimes: AgentRuntimes | undefined; + onboarding: AgentRuntimeOnboarding; busy: boolean; onBack: () => void; onSubmit: (account: Account) => void; @@ -170,7 +173,13 @@ export function AddAccountForm({

{t(`serviceName.${service.id}`)}

{service.kind === 'oauth' ? ( - + ) : service.kind === 'endpoint' ? ( ) : ( @@ -257,17 +266,17 @@ function OauthEditForm({ ); } -/** A subscription account delegates to the agent CLI's own login — no secret to collect. A - * signed-out CLI is still accepted; sessions surface the login error, and in-app login lives on - * the workbench onboarding card. */ +/** Subscription accounts are persisted only after the agent CLI login succeeds. */ function OauthCreateForm({ service, runtimes, + onboarding, busy, onSubmit, }: { service: Extract; runtimes: AgentRuntimes | undefined; + onboarding: AgentRuntimeOnboarding; busy: boolean; onSubmit: (account: Account) => void; }): React.ReactNode { @@ -275,6 +284,8 @@ function OauthCreateForm({ const serviceName = t(`serviceName.${service.id}`); const [label, setLabel] = useState(serviceName); const auth = runtimes?.[service.agent]?.auth; + const loggedIn = auth?.loggedIn === true; + const cue = onboarding.cues[service.agent] ?? { state: 'needs-login', phase: 'idle' as const }; return (
@@ -287,25 +298,41 @@ function OauthCreateForm({ onChange={(event) => setLabel(event.target.value)} /> -

- {auth - ? auth.loggedIn - ? [t('loggedIn'), auth.email, auth.method, auth.subscriptionType] - .filter(Boolean) - .join(' · ') - : t('oauthLoggedOutHint') - : t('oauthUnprobedHint')} -

-
- -
+ {loggedIn ? ( + <> +

+ {[t('loggedIn'), auth.email, auth.method, auth.subscriptionType] + .filter(Boolean) + .join(' · ')} +

+
+ +
+ + ) : ( + { + onboarding.login(kind, () => onSubmit(oauthAccount(service, label))); + } + } + onSubmitLoginCode={onboarding.submitLoginCode} + onCancelLogin={onboarding.cancelLogin} + /> + )}
); } diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 219a143c7..be5df091a 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -1,5 +1,11 @@ import type { Account, AgentKind, ProvidersConfig } from '@linkcode/schema'; -import { getAccounts, getProviderConfig, setAccounts, setProviderConfig } from '@linkcode/sdk'; +import { + createAndBindAccount, + getAccounts, + getProviderConfig, + setAccounts, + setProviderConfig, +} from '@linkcode/sdk'; import { AccountDetail, AccountList } from '@linkcode/ui'; import { Dialog, @@ -11,6 +17,7 @@ import { import { Skeleton } from 'coss-ui/components/skeleton'; import { useTranslations } from 'use-intl'; import { useAgentRuntimes } from '../../agent-runtime/hooks'; +import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; import { serviceById } from './catalog'; @@ -37,6 +44,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { } = useData(getAccounts, {}); const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); const { data: runtimes } = useAgentRuntimes(); + const onboarding = useAgentRuntimeOnboarding(); + const bindAccount = useMutation(createAndBindAccount); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); @@ -52,7 +61,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { const pool = accounts ?? []; const accountsById = new Map(pool.map((account) => [account.id, account])); const selected = view.kind === 'account' ? accountsById.get(view.accountId) : undefined; - const busy = saveAccounts.isMutating || saveProviders.isMutating; + const busy = bindAccount.isMutating || saveAccounts.isMutating || saveProviders.isMutating; const selectedDetail = selected === undefined ? undefined @@ -73,8 +82,13 @@ export function ProvidersSettingsPanel(): React.ReactNode { }; const handleAdd = async (account: Account): Promise => { - await saveAccounts.trigger({ accounts: [...pool, account] }); - await mutateAccounts(); + if (account.credential.type === 'oauth') { + await bindAccount.trigger({ agent: account.credential.agent, account }); + await Promise.all([mutateAccounts(), mutateProviders()]); + } else { + await saveAccounts.trigger({ accounts: [...pool, account] }); + await mutateAccounts(); + } closeDialog(); }; @@ -106,6 +120,12 @@ export function ProvidersSettingsPanel(): React.ReactNode { const dialogOpen = view.kind !== 'browse'; + const cancelSubscriptionLogin = (): void => { + if (view.kind !== 'add-form') return; + const service = serviceById(view.service); + if (service?.kind === 'oauth') onboarding.cancelLogin(service.agent); + }; + return (
{/* The page title is rendered by the settings shell; this is the lead subtitle. */} @@ -119,14 +139,17 @@ export function ProvidersSettingsPanel(): React.ReactNode { /> { - if (!open && !saveAccounts.isMutating) closeDialog(); + if (!open && !busy) { + cancelSubscriptionLogin(); + closeDialog(); + } }} > {view.kind === 'add-catalog' ? ( <> @@ -147,8 +170,12 @@ export function ProvidersSettingsPanel(): React.ReactNode { { + cancelSubscriptionLogin(); + backToCatalog(); + }} onSubmit={(account) => { void handleAdd(account); }} diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 986a182b2..d0cdfb2d5 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1035,9 +1035,6 @@ export const en = { 'openai-chat': 'Native for Codex / OpenCode; Claude Code via local translation', 'openai-responses': 'Native for Codex', }, - oauthLoggedOutHint: - 'No CLI login detected — you can still add it; sessions will prompt for login.', - oauthUnprobedHint: 'Login state unknown — the CLI’s own login decides.', oauthEditHint: 'Subscription credentials follow the CLI login; only the account name can be changed here.', form: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index ba9ac5378..89ffbe684 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1013,8 +1013,6 @@ export const zhCN = { 'openai-chat': 'Codex / OpenCode 原生;Claude Code 经本地转换', 'openai-responses': 'Codex 原生', }, - oauthLoggedOutHint: '未检测到 CLI 登录——仍可添加,会话启动时会提示登录。', - oauthUnprobedHint: '登录状态未知——以 CLI 自身的登录态为准。', oauthEditHint: '订阅凭证跟随 CLI 登录;这里只能修改账号名称。', form: { label: '名称', From 9f88a5c0929a72b504552ba6a225b6c993554d9f Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 1 Aug 2026 13:23:37 +0000 Subject: [PATCH 12/12] fix(workbench): preserve subscription login state Amp-Thread-ID: https://ampcode.com/threads/T-019fbd22-48e7-74bc-9a4f-4680fd919ef5 --- .../src/agent-runtime/__tests__/onboarding.test.ts | 13 +++++++++++++ .../workbench/src/agent-runtime/onboarding.ts | 2 +- .../settings/providers/__tests__/add-flow.test.tsx | 1 + .../workbench/src/settings/providers/add-flow.tsx | 3 +++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts index a72ebabb6..ac8de7d8e 100644 --- a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts @@ -229,6 +229,19 @@ describe('deriveAgentRuntimeCues', () => { { codex: { enabled: true, apiKey: 'sk-x' } }, ), ).toEqual({ codex: { state: 'needs-login', phase: 'opening' } }); + + const loggedIn: AgentRuntimes = { + codex: { status: 'available', source: 'detected', auth: { loggedIn: true } }, + }; + expect( + deriveAgentRuntimeCues( + loggedIn, + ASSETS, + {}, + {}, + { codex: { kind: 'failed', error: 'stale failure' } }, + ), + ).toEqual({}); }); it('suppresses the login cue for a bound key account, but not for a bound oauth one', () => { diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index fb60f2f6e..1a7fa4514 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -184,7 +184,7 @@ function deriveCue( switch (runtime.status) { case 'available': { const currentLogin = loginActivity[kind]; - if (currentLogin) return loginCue(currentLogin); + if (currentLogin && runtime.auth?.loggedIn !== true) return loginCue(currentLogin); // `auth` absent means unprobed or a fail-open probe — don't block. An injected credential // makes a signed-out CLI runnable, so it clears the cue (see hasInjectedCredential). return runtime.auth?.loggedIn === false && !hasInjectedCredential(kind, providers, accounts) diff --git a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx index 17a3e4e5a..8d0fef32c 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx +++ b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx @@ -98,6 +98,7 @@ describe('subscription account creation', () => { fireEvent.change(screen.getByPlaceholderText('loginCodePlaceholder'), { target: { value: ' pasted-code ' }, }); + expect(screen.getByRole('textbox', { name: 'form.label' })).toHaveProperty('disabled', true); fireEvent.click(screen.getByRole('button', { name: 'loginSubmit' })); expect(submitLoginCode).toHaveBeenCalledWith('claude-code', 'pasted-code'); fireEvent.click(screen.getByRole('button', { name: 'loginCancel' })); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index 84880984d..203cf9270 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -286,6 +286,8 @@ function OauthCreateForm({ const auth = runtimes?.[service.agent]?.auth; const loggedIn = auth?.loggedIn === true; const cue = onboarding.cues[service.agent] ?? { state: 'needs-login', phase: 'idle' as const }; + const loginInProgress = + cue.state === 'needs-login' && (cue.phase === 'opening' || cue.phase === 'awaiting-code'); return (
@@ -294,6 +296,7 @@ function OauthCreateForm({ setLabel(event.target.value)} />