diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index f7797616a..b7ec922db 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -269,6 +269,30 @@ describe('loadConfig accounts', () => { }); }); +describe('saveProviderConfiguration', () => { + it('atomically persists providers and accounts without exposing their secrets', () => { + 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( + vault, + { codex: { enabled: true, activeAccountId: 'acc_1', apiKey: 'sk-provider' } }, + [validAccount], + ); + + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ + hostname: '127.0.0.1', + providers: { codex: { enabled: true, activeAccountId: 'acc_1' } }, + accounts: [{ ...validAccount, credential: { type: 'api-key' } }], + }); + expect(vault.refs.get('provider:codex')).toBe('sk-provider'); + expect(vault.refs.get('account:acc_1')).toBe('sk-test'); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); +}); + describe('loadConfig custom MCP servers', () => { const validServer = { id: 'custom-1', diff --git a/apps/daemon/src/provider-store.ts b/apps/daemon/src/provider-store.ts index 8b039e3ca..1ad94eaa2 100644 --- a/apps/daemon/src/provider-store.ts +++ b/apps/daemon/src/provider-store.ts @@ -1,4 +1,5 @@ import type { ProviderConfigStore } from '@linkcode/engine'; +import { accountBinding } from '@linkcode/engine'; import type { Accounts, CustomMcpServer, ProvidersConfig } from '@linkcode/schema'; import { saveCustomMcpServers, saveProviderConfiguration } from './config'; import type { SecretVault } from './secrets'; @@ -33,5 +34,11 @@ export function createProviderConfigStore( saveCustomMcpServers(vault, next, customMcpServers); customMcpServers = next; }, + createAndBindAccount(agent, account) { + const next = accountBinding(providers, accounts, agent, account); + saveProviderConfiguration(vault, next.providers, next.accounts); + providers = next.providers; + accounts = next.accounts; + }, }; } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index b0af66d38..671db1750 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -86,9 +86,7 @@ export function DesktopShell({ NewSessionBranchPickerComponent, onDownloadAgent, onContinueUnverified, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, + onOpenProviderSettings, conversation, respondingRequestIds, responseErrors, @@ -438,9 +436,7 @@ export function DesktopShell({ topContent={} onContinueUnverified={onContinueUnverified} onDownloadAgent={onDownloadAgent} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} + onOpenProviderSettings={onOpenProviderSettings} onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onPickDirectory={pickDirectory} @@ -459,9 +455,7 @@ export function DesktopShell({ attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} + 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..4505e2a15 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={() => { + useProvidersSettingsStore.getState().startAdd(); + 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..be25a4893 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().startAdd(); + void navigate('/settings/providers'); + }} onOpenAutomations={() => { void navigate('/automations'); }} diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 5ee06cf8c..bca672987 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -1,4 +1,8 @@ import type { + Account, + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentEvent, AgentHistoryId, @@ -415,6 +419,9 @@ export class LinkCodeClient { case 'skill.updated': this.pending.resolve('skillSetEnabled', p.replyTo, p.skill); 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; @@ -817,6 +824,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); + } + /** Masked custom MCP servers (env/header keys only — the daemon never returns values). */ getCustomMcpServers(): Promise { return this.control.getCustomMcpServers(); @@ -1104,6 +1116,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 2163f6170..f5e52873c 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,4 +1,8 @@ import type { + Account, + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentHistoryId, AgentHistoryListOptions, @@ -547,6 +551,26 @@ 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 { + 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 36862705d..3512122b3 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, @@ -99,6 +100,7 @@ export interface PendingValueMap { historyRead: AgentHistoryReadResult; configGet: ProvidersConfig; accountsGet: Accounts; + accountModels: AccountModel[]; customMcpGet: CustomMcpServerPublic[]; pluginList: PluginList; pluginMutation: PluginMutation; @@ -159,6 +161,7 @@ export class PendingRegistry { historyRead: new Map(), configGet: new Map(), accountsGet: new Map(), + accountModels: new Map(), customMcpGet: new Map(), pluginList: new Map(), pluginMutation: new Map(), diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 5569b1d2a..a031785e6 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -9,6 +9,10 @@ import type { } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { + Account, + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentHistoryId, AgentHistoryListResult, @@ -258,6 +262,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()); @@ -268,6 +276,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)); + } + /** Masked custom MCP servers (data plane) — env/header keys only, never a secret value. */ getCustomMcpServers(): RequestResult { return toResult(this.raw.getCustomMcpServers()); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index 600351e3e..694677bad 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -6,6 +6,10 @@ import type { SessionStartResult, } from '@linkcode/client-core'; import type { + Account, + AccountEndpoint, + AccountModel, + AccountSecret, Accounts, AgentHistoryId, AgentHistoryListResult, @@ -239,6 +243,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(); } @@ -247,6 +257,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); +} + /** Masked custom MCP servers — env/header keys only, never a secret value. */ export function getCustomMcpServers(options?: Options): RequestResult { return resolveClient(options).getCustomMcpServers(); 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 365e3ac2a..ac8de7d8e 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'; @@ -198,6 +203,80 @@ 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('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' } }); + + 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', () => { + 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' }, + }); }); }); diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index 04ee345a7..1a7fa4514 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?.trim()) 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,14 +179,18 @@ 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 - ? loginCue(loginActivity[kind]) + case 'available': { + const currentLogin = loginActivity[kind]; + 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) + ? 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. @@ -188,19 +220,22 @@ 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(); - // 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 @@ -251,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; @@ -282,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 @@ -292,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)) { @@ -300,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' }); }); } @@ -336,6 +395,7 @@ export function useAgentRuntimeOnboarding(): { acknowledged, loginActivity, providers, + accounts, ), download, acknowledgeUnverified, diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 1f9d65982..1c1ae7773 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -408,6 +408,23 @@ export class DevMockHost { } 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 'plugin.list.get': await wait(CONTROL_LATENCY_MS); this.send({ 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..8d0fef32c --- /dev/null +++ b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx @@ -0,0 +1,167 @@ +// @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 ' }, + }); + 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' })); + 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..203cf9270 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,10 @@ 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 }; + const loginInProgress = + cue.state === 'needs-login' && (cue.phase === 'opening' || cue.phase === 'awaiting-code'); return (
@@ -283,29 +296,46 @@ function OauthCreateForm({ 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/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/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 489c7e433..31893341a 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -134,6 +134,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/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 56f5652f4..eed2644b0 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,6 +1,13 @@ import { z } from 'zod'; -import { AccountsSchema } from '../model/account'; +import { + AccountEndpointSchema, + AccountModelSchema, + AccountSchema, + AccountSecretSchema, + AccountsSchema, +} from '../model/account'; import { CustomMcpServerPatchOpSchema, CustomMcpServerPublicSchema } from '../model/custom-mcp'; +import { AgentKindSchema } from '../model/primitives'; import { ProvidersConfigSchema } from '../model/provider-config'; import { WireRequestIdSchema } from './request'; @@ -25,4 +32,24 @@ export const configWireVariants = [ /** Patch ops against the stored custom MCP servers; omitted when untouched. */ customMcpServers: z.array(CustomMcpServerPatchOpSchema).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. */ + 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 6db686bee..e97c325cd 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 67 as const; +export const WIRE_PROTOCOL_VERSION = 68 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ 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..358031270 --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -0,0 +1,107 @@ +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 { 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. */ +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}`; +} + +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 () => { + 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 = createHarness(); + 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 = createHarness(); + 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..5d7ab4541 --- /dev/null +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -0,0 +1,220 @@ +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, + 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) }; +} + +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('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', + '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 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' }, 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 request = vi + .fn() + .mockResolvedValue(jsonResponse([{ id: 'gpt-5' }, { id: 'gpt-5' }, { id: 'gpt-5-mini' }])); + await expect( + 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 request = vi.fn().mockResolvedValue({ + status: 401, + statusText: 'Unauthorized', + body: '{"error":"invalid api key"}', + }); + await expect( + probeEndpointModels(openai, { type: 'api-key', key: 'bad' }, request), + ).rejects.toThrow(REJECTION_PATTERN); + }); + + it('rejects a response that is not a model list', async () => { + const request = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + await expect( + probeEndpointModels(openai, { type: 'api-key', key: 'k' }, request), + ).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()); + }); + } + }); +}); 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/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts new file mode 100644 index 000000000..02a3a859f --- /dev/null +++ b/packages/host/engine/src/agent/model-probe.ts @@ -0,0 +1,290 @@ +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'; + +/** + * 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'; +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(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 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( + 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; +} + +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, + request: ModelListRequest = requestPublicModelList, +): Promise { + 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}` : ''}`); + } + 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) { + 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/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 0d634a92e..7cc61f939 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, CustomMcpServer, ProviderConfig, ProvidersConfig, @@ -20,6 +21,7 @@ export interface ProviderConfigStore { /** LinkCode-owned custom MCP servers (full plaintext — masking is the data plane's job). */ getCustomMcpServers(): CustomMcpServer[]; setCustomMcpServers(next: CustomMcpServer[]): void | Promise; + createAndBindAccount(agent: AgentKind, account: Account): void | Promise; } export class InMemoryProviderConfigStore implements ProviderConfigStore { @@ -47,6 +49,28 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { setCustomMcpServers(next: CustomMcpServer[]): void { this.customMcpServers = 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 bfdbd2c05..5fc22b664 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -3,10 +3,13 @@ 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 { CustomMcpServerService } from './custom-mcp-service'; 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'; import type { AgentRuntimeService } from './runtime-service'; @@ -19,6 +22,8 @@ type AgentRequest = Extract< | 'agent.catalog' | 'config.get' | 'config.set' + | 'config.account.create-and-bind' + | 'config.probe-models' | 'agent-login.start' | 'agent-login.submit-code' | 'agent-login.cancel'; @@ -35,6 +40,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 { @@ -128,6 +134,40 @@ 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 this.probeModels(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/deps.ts b/packages/host/engine/src/deps.ts index da2f9e6f8..3fdce4a71 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'; @@ -39,6 +40,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 340e1ba6c..b10921e63 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -232,6 +232,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( logins, responder, factory, + deps.modelProbe, ); const browserRequests = new BrowserRequestHandler(transport, browserBroker); const pluginRequests = new PluginRequestHandler(transport, plugins, responder); diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 7d4e4f7c0..599f7511e 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 23fd9ea69..c9b328514 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -77,7 +77,9 @@ export class WireRequestRouter { case 'agent-runtime.list': case 'agent.catalog': case 'config.get': - case 'config.set': { + case 'config.set': + case 'config.account.create-and-bind': + case 'config.probe-models': { return this.handlers.agent.handle(p); } case 'asset.list': diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 4509a1fe2..d0cdfb2d5 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -298,6 +298,7 @@ export const en = { loginReopenUrl: "Didn't open? Open the sign-in page", loginCancel: 'Cancel', loginFailedTitle: '{agent} sign-in failed', + goToSettings: 'Go to settings', }, composer: { placeholder: 'Describe what you want {agent} to do, or @-reference a file / terminal output…', @@ -1034,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 491332181..89ffbe684 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -289,6 +289,7 @@ export const zhCN = { loginReopenUrl: '没有自动打开?点此打开登录页', loginCancel: '取消', loginFailedTitle: '{agent} 登录失败', + goToSettings: '前往设置', }, composer: { placeholder: '描述想让 {agent} 做什么,或用 @ 引用文件 / 终端输出…', @@ -1012,8 +1013,6 @@ export const zhCN = { 'openai-chat': 'Codex / OpenCode 原生;Claude Code 经本地转换', 'openai-responses': 'Codex 原生', }, - oauthLoggedOutHint: '未检测到 CLI 登录——仍可添加,会话启动时会提示登录。', - oauthUnprobedHint: '登录状态未知——以 CLI 自身的登录态为准。', oauthEditHint: '订阅凭证跟随 CLI 登录;这里只能修改账号名称。', form: { label: '名称', 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..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 @@ -41,3 +41,46 @@ describe('AgentLoginCard awaiting phase per kind (CODE-174)', () => { expect(screen.getByRole('button', { name: 'loginCancel' })).toBeTruthy(); }); }); + +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( + , + ); + const button = screen.getByRole('button', { name: 'goToSettings' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + button.click(); + expect(onOpenProviderSettings).toHaveBeenCalledWith('claude-code'); + }); + + it('replaces retry with Go to settings after a failed workbench login', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'goToSettings' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'retry' })).toBeNull(); + }); + + it('keeps direct subscription login available inside settings', () => { + render( + , + ); + 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 474159296..d22a0b737 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, + onOpenProviderSettings, }: { 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 Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); @@ -59,6 +62,7 @@ export function AgentOnboardingCard({ onLogin={onLogin} onSubmitLoginCode={onSubmitLoginCode} onCancelLogin={onCancelLogin} + onOpenProviderSettings={onOpenProviderSettings} /> ); } @@ -163,12 +167,14 @@ function AgentLoginCard({ onLogin, onSubmitLoginCode, onCancelLogin, + onOpenProviderSettings, }: { kind: AgentKind; cue: Extract; onLogin?: (kind: AgentKind) => void; onSubmitLoginCode?: (kind: AgentKind, code: string) => void; onCancelLogin?: (kind: AgentKind) => void; + onOpenProviderSettings?: (kind: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); const agent = AGENT_LABELS[kind]; @@ -263,13 +269,19 @@ function AgentLoginCard({ {t('loginFailedTitle', { agent })} {cue.error && {cue.error}} - {onLogin && ( - - - - )} + ) : ( + onLogin && ( + + ) + )} + ); } @@ -279,13 +291,19 @@ function AgentLoginCard({ {t('needsLoginTitle', { agent })} {t('needsLoginBody', { agent })} - {onLogin && ( - - - - )} + ) : ( + onLogin && ( + + ) + )} + ); } diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 504e34ef5..60ec2714c 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -43,12 +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 Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; disabled?: boolean; isRunning: boolean; className?: string; @@ -90,9 +86,7 @@ export function ConversationSurface({ respondingRequestIds, responseErrors, runtimeCues, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, + onOpenProviderSettings, disabled = false, isRunning, className, @@ -157,9 +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 d47e18b4b..b443959ea 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -103,12 +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 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; @@ -155,9 +151,7 @@ export function NewSessionSurface({ onMentionQueryChange, onDownloadAgent, onContinueUnverified, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, + onOpenProviderSettings, onSubmit, onPickDirectory, onRegisterWorkspace, @@ -333,11 +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 e7274bd6f..20c6c9038 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -65,12 +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 Providers settings at the signed-out agent's setup flow. */ + onOpenProviderSettings?: (kind: AgentKind) => void; conversation: ConversationViewModel; respondingRequestIds: ReadonlySet; responseErrors?: ReadonlyMap; @@ -134,9 +130,7 @@ export function ShellFrame({ NewSessionBranchPickerComponent, onDownloadAgent, onContinueUnverified, - onLoginAgent, - onSubmitLoginCode, - onCancelLogin, + onOpenProviderSettings, conversation, respondingRequestIds, responseErrors, @@ -227,9 +221,7 @@ export function ShellFrame({ mentionItems={mentionItems} onContinueUnverified={onContinueUnverified} onDownloadAgent={onDownloadAgent} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} + onOpenProviderSettings={onOpenProviderSettings} onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onRegisterWorkspace={onRegisterWorkspace} @@ -248,9 +240,7 @@ export function ShellFrame({ isRunning={isRunning} cwd={active?.cwd} runtimeCues={runtimeCues} - onLoginAgent={onLoginAgent} - onSubmitLoginCode={onSubmitLoginCode} - onCancelLogin={onCancelLogin} + onOpenProviderSettings={onOpenProviderSettings} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent}