From 0fd423dea1d1b35504a94a4800b685bd1e681f76 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 07:56:58 -0800 Subject: [PATCH 1/5] feat(cloud): S0 Orca Cloud agent accounts + onboarding Agent row (G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main: - agent-credentials client for /v1/agent-credentials, /v1/agent-sources and the Codex broker routes; replies and errors are codes only. The shared control-plane request keeps the server's error name (HostedSandboxRefusalError) so newer routes (broker_in_progress, rate_limited, no_golden_image) map to codes. - claude setup-token runner (pseudo-terminal in production, fake child in tests): captures the sk-ant-oat01 token in memory, never logs it, PUTs it from main with the local Claude account email as label. Capture failure or no claude offers "Paste the token instead"; a pasted value goes renderer -> main once. - Codex broker driver: device code, poll until active, cancel, code expiry. - orcaCloudAgents IPC + preload bridge: states and codes only. Renderer: - Settings > Agent accounts > Orca Cloud (S0, spec §5): not set up, setting up (browser / preparing / device code / paste / saving), state 7 Source choice, active, expiring, expired/revoked, kill switch, signed out, load and save errors; the mandatory policy line; Source via SettingsSegmentedControl. - The composer's Agent row (cloud-start G) replaces ComposerCloudAgentHint: from your Orca account / team key (collapsed), Set up once -> S0, kill switch info, signed-out copy; hidden for members blocked by billing; never blocks. - Telemetry cloud_first_start_agent_coverage (once per install). - Fixtures: settings/agent-accounts/OrcaCloudAccounts:*, new-workspace/OrcaCloudAgentSetupRow:*. --- .../hosted-sandbox/hosted-sandbox-request.ts | 44 ++- .../agent-credentials-client.test.ts | 135 +++++++++ .../agent-credentials-client.ts | 120 ++++++++ .../claude-setup-token-pty.ts | 49 ++++ .../claude-setup-token-runner.test.ts | 144 ++++++++++ .../claude-setup-token-runner.ts | 184 ++++++++++++ .../codex-broker-driver.test.ts | 79 ++++++ .../orca-cloud-agents/codex-broker-driver.ts | 144 ++++++++++ .../orca-cloud-agents-ipc.ts | 128 +++++++++ .../orca-cloud-agents-service.test.ts | 179 ++++++++++++ .../orca-cloud-agents-service.ts | 263 ++++++++++++++++++ src/main/startup/main-window-core-services.ts | 2 + src/preload/api-types.ts | 3 + src/preload/api/orca-cloud-agents-bridge.ts | 50 ++++ src/preload/index.ts | 2 + .../CloudAgentSetup.fixture.tsx | 10 - .../ComposerCloudAgentHint.tsx | 59 ---- .../NewWorkspaceComposerProjectSection.tsx | 56 ++-- .../OrcaCloudAgentSetupRow.fixture.tsx | 33 +++ .../new-workspace/OrcaCloudAgentSetupRow.tsx | 198 +++++++++++++ .../agent-accounts/OrcaCloudAccountRow.tsx | 171 ++++++++++++ .../OrcaCloudAccounts.fixture.tsx | 170 +++++++++++ .../agent-accounts/OrcaCloudAccounts.test.tsx | 137 +++++++++ .../agent-accounts/OrcaCloudAccounts.tsx | 159 +++++++++++ .../OrcaCloudAccountsSection.tsx | 90 ++++++ .../agent-accounts/OrcaCloudActiveRow.tsx | 219 +++++++++++++++ .../agent-accounts/OrcaCloudSetupRow.tsx | 252 +++++++++++++++++ .../agent-accounts/agent-accounts-search.ts | 19 ++ .../orca-cloud-account-row-shell.tsx | 112 ++++++++ .../orca-cloud-accounts-copy.ts | 155 +++++++++++ .../orca-cloud-accounts-types.ts | 35 +++ .../settings-capability-section-renderers.tsx | 21 ++ .../settings/page/settings-page-renderer.tsx | 2 + .../quick-creation-execution.ts | 5 + ...settings-navigation-capability-sections.ts | 18 ++ src/renderer/src/i18n/locales/en.json | 134 ++++++++- .../cloud-first-start-agent-coverage.test.ts | 36 +++ .../cloud-first-start-agent-coverage.ts | 52 ++++ .../orca-cloud-agent-accounts-model.test.ts | 137 +++++++++ .../orca-cloud-agent-accounts-model.ts | 140 ++++++++++ .../orca-cloud-agent-accounts-store.ts | 150 ++++++++++ .../orca-cloud-agent-setup-row.test.ts | 82 ++++++ .../orca-cloud/orca-cloud-agent-setup-row.ts | 82 ++++++ .../src/lib/settings-navigation-types.ts | 1 + .../orca-cloud-agents-wire.ts | 149 ++++++++++ .../telemetry-cloud-setup-event-schemas.ts | 11 + src/shared/telemetry-event-registry.ts | 4 +- 47 files changed, 4324 insertions(+), 101 deletions(-) create mode 100644 src/main/orca-cloud-agents/agent-credentials-client.test.ts create mode 100644 src/main/orca-cloud-agents/agent-credentials-client.ts create mode 100644 src/main/orca-cloud-agents/claude-setup-token-pty.ts create mode 100644 src/main/orca-cloud-agents/claude-setup-token-runner.test.ts create mode 100644 src/main/orca-cloud-agents/claude-setup-token-runner.ts create mode 100644 src/main/orca-cloud-agents/codex-broker-driver.test.ts create mode 100644 src/main/orca-cloud-agents/codex-broker-driver.ts create mode 100644 src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts create mode 100644 src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts create mode 100644 src/main/orca-cloud-agents/orca-cloud-agents-service.ts create mode 100644 src/preload/api/orca-cloud-agents-bridge.ts delete mode 100644 src/renderer/src/components/cloud-agent-setup/ComposerCloudAgentHint.tsx create mode 100644 src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx create mode 100644 src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountRow.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.fixture.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.test.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudActiveRow.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/agent-accounts-search.ts create mode 100644 src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-copy.ts create mode 100644 src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-types.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.ts create mode 100644 src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.ts create mode 100644 src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-store.ts create mode 100644 src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.ts create mode 100644 src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts diff --git a/src/main/hosted-sandbox/hosted-sandbox-request.ts b/src/main/hosted-sandbox/hosted-sandbox-request.ts index 8fecc70ed76..b895f5fd831 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-request.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-request.ts @@ -115,6 +115,37 @@ const STATUS_CODES: Record = { internal_error: 'unavailable' } +/** + * A refusal the control plane answered with an error body. Routes newer than the shared code list + * (e.g. the Codex broker's `broker_in_progress`) keep their own name here, so their clients can map it. + */ +export class HostedSandboxRefusalError extends EnvironmentProviderError { + readonly serverError: string | null + readonly httpStatus: number + + constructor( + code: EnvironmentProviderErrorCode, + message: string, + reason: string | null, + serverError: string | null, + httpStatus: number + ) { + super(code, message, reason) + this.name = 'HostedSandboxRefusalError' + this.serverError = serverError + this.httpStatus = httpStatus + } +} + +const SERVER_NAME = /^[a-z][a-z0-9_]{0,63}$/ + +/** The `error` name of any error body, if it looks like a name (never free text). */ +function serverErrorName(payload: unknown): string | null { + const error = + typeof payload === 'object' && payload !== null ? (payload as { error?: unknown }).error : null + return typeof error === 'string' && SERVER_NAME.test(error) ? error : null +} + function errorFromResponse(status: number, payload: unknown): EnvironmentProviderError { const parsed = HostedSandboxErrorResponseSchema.safeParse(payload) if (parsed.success && parsed.data.error === 'entitlement_denied') { @@ -125,18 +156,23 @@ function errorFromResponse(status: number, payload: unknown): EnvironmentProvide } if (parsed.success) { const code = STATUS_CODES[parsed.data.error] ?? 'unavailable' - return new EnvironmentProviderError( + return new HostedSandboxRefusalError( code, `Orca Cloud refused the request (${parsed.data.error}).`, - parsed.data.reason ?? null + parsed.data.reason ?? null, + parsed.data.error, + status ) } if (status >= 300 && status < 400) { return new EnvironmentProviderError('invalid_response', 'Orca Cloud answered with a redirect.') } - return new EnvironmentProviderError( + return new HostedSandboxRefusalError( status === 401 || status === 403 ? 'unauthorized' : 'unavailable', - `Orca Cloud request failed (HTTP ${status}).` + `Orca Cloud request failed (HTTP ${status}).`, + null, + serverErrorName(payload), + status ) } diff --git a/src/main/orca-cloud-agents/agent-credentials-client.test.ts b/src/main/orca-cloud-agents/agent-credentials-client.test.ts new file mode 100644 index 00000000000..176c9f855ff --- /dev/null +++ b/src/main/orca-cloud-agents/agent-credentials-client.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + agentCredentialsFailureCode, + createAgentCredentialsClient +} from './agent-credentials-client' + +const TOKEN = `sk-ant-oat01-${'Q'.repeat(80)}` +const VIEW = { + claude: { state: 'active', label: 'a@acme.dev', expiresAt: 1_800_000_000_000 }, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } +} + +type Seen = { method: string; url: string; body: string | null } + +function fakeControlPlane(respond: (seen: Seen) => Response) { + const seen: Seen[] = [] + const fetchImpl: typeof fetch = async (input, init) => { + const request = { + method: init?.method ?? 'GET', + url: String(input), + body: typeof init?.body === 'string' ? init.body : null + } + seen.push(request) + return respond(request) + } + const client = createAgentCredentialsClient({ + baseUrl: 'https://control.test', + getAccessToken: async () => 'tok', + fetch: fetchImpl + }) + return { client, seen } +} + +async function failure(run: () => Promise): Promise { + try { + await run() + } catch (error) { + return error + } + throw new Error('expected a failure') +} + +describe('createAgentCredentialsClient', () => { + it('reads the credentials and stores a Claude token with its label', async () => { + const { client, seen } = fakeControlPlane(() => Response.json(VIEW)) + expect((await client.credentials()).claude.state).toBe('active') + await client.putClaude(TOKEN, 'a@acme.dev') + expect(seen[1]).toEqual({ + method: 'PUT', + url: 'https://control.test/v1/agent-credentials/claude', + body: JSON.stringify({ token: TOKEN, label: 'a@acme.dev' }) + }) + }) + + it('reads an unknown credential state as none instead of failing', async () => { + const { client } = fakeControlPlane(() => + Response.json({ ...VIEW, codex: { state: 'paused' } }) + ) + expect((await client.credentials()).codex.state).toBe('none') + }) + + it('maps server refusals to codes, and no error carries the token', async () => { + const answers: [number, unknown][] = [ + [400, { error: 'invalid_request', reason: 'invalid_token' }], + [404, { error: 'not_found' }], + [409, { error: 'broker_in_progress' }], + [429, { error: 'rate_limited' }], + [502, { error: 'broker_unavailable' }], + [503, { error: 'no_golden_image' }], + [503, { error: 'not_configured' }], + [401, { error: 'unauthorized' }] + ] + const { client } = fakeControlPlane(() => { + const [status, body] = answers.shift()! + return Response.json(body, { status }) + }) + const codes: string[] = [] + for (let i = 0; i < 8; i++) { + const error = await failure(() => + i < 2 ? client.putClaude(TOKEN) : client.startCodexBroker() + ) + expect(JSON.stringify(error)).not.toContain(TOKEN) + expect(String((error as Error).message)).not.toContain(TOKEN) + codes.push(agentCredentialsFailureCode(error)) + } + expect(codes).toEqual([ + 'invalid_token', + 'not_found', + 'broker_in_progress', + 'rate_limited', + 'broker_unavailable', + 'no_golden_image', + 'not_configured', + 'signed_out' + ]) + }) + + it('drives the Codex broker routes and refuses an odd broker id', async () => { + const replies = [ + { + brokerId: 'brk_1', + verificationUrl: 'https://auth.openai.com/codex/device', + userCode: 'ABCD-1234', + expiresAt: 5 + }, + { status: 'pending' }, + { status: 'failed' } + ] + const { client, seen } = fakeControlPlane(() => Response.json(replies.shift())) + expect((await client.startCodexBroker()).userCode).toBe('ABCD-1234') + expect((await client.codexBrokerStatus('brk_1')).status).toBe('pending') + await client.cancelCodexBroker('brk_1') + expect(seen.map((s) => `${s.method} ${new URL(s.url).pathname}`)).toEqual([ + 'POST /v1/agent-credentials/codex/broker', + 'GET /v1/agent-credentials/codex/broker/brk_1', + 'DELETE /v1/agent-credentials/codex/broker/brk_1' + ]) + await expect(client.codexBrokerStatus('../secrets')).rejects.toThrow() + }) + + it('writes the default Source', async () => { + const { client, seen } = fakeControlPlane(() => + Response.json({ + defaults: { claude: 'sign-in' }, + coveredByKey: { claude: true, codex: false } + }) + ) + await client.setDefaultSource('claude', 'sign-in') + expect(seen[0]).toMatchObject({ + method: 'PUT', + body: JSON.stringify({ agent: 'claude', source: 'sign-in' }) + }) + }) +}) diff --git a/src/main/orca-cloud-agents/agent-credentials-client.ts b/src/main/orca-cloud-agents/agent-credentials-client.ts new file mode 100644 index 00000000000..d90ee028d63 --- /dev/null +++ b/src/main/orca-cloud-agents/agent-credentials-client.ts @@ -0,0 +1,120 @@ +// Orca account agent credentials on the sandbox control plane (contract D11 + §2). Write-only for the +// Claude token: the PUT carries it, no response or error does, and nothing here logs a body. +import { z } from 'zod' +import { + AgentCredentialsResponseSchema, + AgentSourcesResponseSchema, + CodexBrokerStartResponseSchema, + CodexBrokerStatusResponseSchema, + type AgentCredentialsView, + type AgentSource, + type AgentSourcesView, + type CodexBrokerStart, + type CodexBrokerStatus, + type OrcaCloudAccountAgent, + type OrcaCloudAgentsErrorCode +} from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { + HostedSandboxRefusalError, + createHostedSandboxRequest, + type HostedSandboxControlPlaneClientOptions +} from '../hosted-sandbox/hosted-sandbox-request' + +export type AgentCredentialsClient = { + credentials(signal?: AbortSignal): Promise + sources(signal?: AbortSignal): Promise + /** Main only, right after setup-token on this computer or a paste. */ + putClaude(token: string, label?: string): Promise + remove(agent: OrcaCloudAccountAgent): Promise + setDefaultSource(agent: OrcaCloudAccountAgent, source: AgentSource): Promise + startCodexBroker(): Promise + codexBrokerStatus(brokerId: string): Promise + cancelCodexBroker(brokerId: string): Promise +} + +const CancelResponse = z.looseObject({}) +const BROKER_ID = /^[A-Za-z0-9_.:-]{1,128}$/ + +function brokerPath(brokerId: string): string { + if (!BROKER_ID.test(brokerId)) { + throw new Error('Invalid broker id.') + } + return `v1/agent-credentials/codex/broker/${encodeURIComponent(brokerId)}` +} + +export function createAgentCredentialsClient( + options: HostedSandboxControlPlaneClientOptions +): AgentCredentialsClient { + const call = createHostedSandboxRequest(options) + return { + credentials: (signal) => + call('GET', 'v1/agent-credentials', AgentCredentialsResponseSchema, { + kind: 'control', + signal + }), + sources: (signal) => + call('GET', 'v1/agent-sources', AgentSourcesResponseSchema, { kind: 'control', signal }), + putClaude: (token, label) => + call('PUT', 'v1/agent-credentials/claude', AgentCredentialsResponseSchema, { + body: label ? { token, label } : { token }, + kind: 'control' + }), + remove: (agent) => + call('DELETE', `v1/agent-credentials/${agent}`, AgentCredentialsResponseSchema, { + kind: 'control' + }), + setDefaultSource: (agent, source) => + call('PUT', 'v1/agent-sources', AgentSourcesResponseSchema, { + body: { agent, source }, + kind: 'control' + }), + startCodexBroker: () => + call('POST', 'v1/agent-credentials/codex/broker', CodexBrokerStartResponseSchema, { + body: {}, + // The broker boots a sandbox before it can print a device code. + kind: 'boot' + }), + codexBrokerStatus: async (brokerId) => + call('GET', brokerPath(brokerId), CodexBrokerStatusResponseSchema, { kind: 'control' }), + cancelCodexBroker: async (brokerId) => { + await call('DELETE', brokerPath(brokerId), CancelResponse, { kind: 'control' }) + } + } +} + +const SERVER_CODES: Partial> = { + broker_in_progress: 'broker_in_progress', + rate_limited: 'rate_limited', + broker_unavailable: 'broker_unavailable', + no_golden_image: 'no_golden_image', + not_configured: 'not_configured' +} + +/** The code the renderer turns into copy. Never the error's message. */ +export function agentCredentialsFailureCode(error: unknown): OrcaCloudAgentsErrorCode { + if (!isEnvironmentProviderError(error)) { + return 'unknown' + } + const serverError = error instanceof HostedSandboxRefusalError ? error.serverError : null + const named = serverError ? SERVER_CODES[serverError] : undefined + if (named) { + return named + } + switch (error.code) { + case 'unauthorized': + return 'signed_out' + case 'not_found': + // The credential routes answer 404 while the provider is off (flag or kill switch). + return 'not_found' + case 'invalid_request': + return error.reason === 'invalid_token' ? 'invalid_token' : 'unknown' + case 'timeout': + return 'timeout' + case 'conflict': + case 'unavailable': + case 'aborted': + case 'invalid_response': + return 'unavailable' + } +} diff --git a/src/main/orca-cloud-agents/claude-setup-token-pty.ts b/src/main/orca-cloud-agents/claude-setup-token-pty.ts new file mode 100644 index 00000000000..7a48a2faf00 --- /dev/null +++ b/src/main/orca-cloud-agents/claude-setup-token-pty.ts @@ -0,0 +1,49 @@ +// Production spawn for the setup-token runner: `claude setup-token` in a pseudo-terminal, because +// Claude's sign-in UI needs a TTY. Wide columns keep the printed token on one line. +import { stat } from 'node:fs/promises' +import { isAbsolute } from 'node:path' +import { resolveClaudeCommand, withCliRuntimeOnPath } from '../codex-cli/command' +import { stripInheritedClaudeSessionEnv } from '../../shared/agent-launch/startup/claude-parent-session-env' +import type { SetupTokenProcess, SpawnSetupToken } from './claude-setup-token-runner' + +const COLUMNS = 1000 + +function enoent(): Error { + return Object.assign(new Error('claude is not installed.'), { code: 'ENOENT' }) +} + +export const spawnClaudeSetupTokenPty: SpawnSetupToken = async () => { + const command = resolveClaudeCommand() + if (isAbsolute(command) && !(await stat(command).catch(() => null))) { + throw enoent() + } + const env: NodeJS.ProcessEnv = { ...process.env } + stripInheritedClaudeSessionEnv(env) + const childEnv = withCliRuntimeOnPath(command, { ...env, TERM: 'xterm-256color' }) + // Windows shims (.cmd) need the command interpreter; ConPTY can't start them directly. + const viaShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command) + const file = viaShell ? (process.env.ComSpec ?? 'cmd.exe') : command + const args = viaShell ? ['/d', '/s', '/c', `"${command}" setup-token`] : ['setup-token'] + const pty = await import('node-pty') + let child: ReturnType + try { + child = pty.spawn(file, args, { + name: 'xterm-256color', + cols: COLUMNS, + rows: 40, + env: childEnv as Record + }) + } catch (error) { + throw (error as { code?: unknown })?.code === 'ENOENT' ? enoent() : error + } + const process_: SetupTokenProcess = { + onData: (listener) => { + child.onData(listener) + }, + onExit: (listener) => { + child.onExit(({ exitCode }) => listener(exitCode)) + }, + kill: () => child.kill() + } + return process_ +} diff --git a/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts b/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts new file mode 100644 index 00000000000..b52c6abf63d --- /dev/null +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + findAuthorizeUrl, + findSetupToken, + runClaudeSetupToken, + type SetupTokenProcess +} from './claude-setup-token-runner' + +const TOKEN = `sk-ant-oat01-${'Ab3_-x'.repeat(16)}` +const AUTHORIZE = + 'https://claude.ai/oauth/authorize?code=true&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A5555%2Fcallback' + +type FakeChild = SetupTokenProcess & { + emit: (chunk: string) => void + exit: (code: number | null) => void + killed: boolean +} + +function fakeChild(): FakeChild { + let onData: (chunk: string) => void = () => {} + let onExit: (code: number | null) => void = () => {} + const child: FakeChild = { + killed: false, + onData: (listener) => { + onData = listener + }, + onExit: (listener) => { + onExit = listener + }, + kill: () => { + child.killed = true + }, + emit: (chunk) => onData(chunk), + exit: (code) => onExit(code) + } + return child +} + +async function started(child: FakeChild) { + const controller = new AbortController() + const onAuthorizeUrl = vi.fn() + const outcome = runClaudeSetupToken({ + spawn: async () => child, + signal: controller.signal, + timeoutMs: 1_000, + onAuthorizeUrl + }) + // Let the spawn promise settle so listeners are attached. + await Promise.resolve() + await Promise.resolve() + return { outcome, controller, onAuthorizeUrl } +} + +describe('findSetupToken', () => { + it('reads a token once a non-token character ends it', () => { + expect(findSetupToken(`Your OAuth token:\n\n${TOKEN}\n\nStore it`, false)).toBe(TOKEN) + expect(findSetupToken(`Your OAuth token:\n${TOKEN}`, false)).toBeNull() + expect(findSetupToken(`Your OAuth token:\n${TOKEN}`, true)).toBe(TOKEN) + }) + + it('treats terminal styling as a separator, so a styled word after it is not absorbed', () => { + expect(findSetupToken(`\u001b[1m${TOKEN}\u001b[22mStore this token`, false)).toBe(TOKEN) + }) + + it('ignores anything that is not a setup-token', () => { + expect(findSetupToken('sk-ant-oat01-short ', false)).toBeNull() + expect(findSetupToken(`sk-ant-api03-${'a'.repeat(40)} `, false)).toBeNull() + }) +}) + +describe('findAuthorizeUrl', () => { + it("finds Claude's sign-in page and nothing else", () => { + expect(findAuthorizeUrl(`Browser didn't open? Use the url below:\n${AUTHORIZE}\n`)).toBe( + new URL(AUTHORIZE).toString() + ) + expect(findAuthorizeUrl('see https://evil.example/oauth/authorize?x=1 \n')).toBeNull() + }) +}) + +describe('runClaudeSetupToken', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('captures the token across chunks and reports the sign-in link once', async () => { + const child = fakeChild() + const { outcome, onAuthorizeUrl } = await started(child) + child.emit(`Opening browser…\n${AUTHORIZE}\n`) + child.emit(`${AUTHORIZE}\n`) + child.emit(`\u001b[32m✓\u001b[0m Your token:\n${TOKEN.slice(0, 30)}`) + child.emit(`${TOKEN.slice(30)}\n`) + await expect(outcome).resolves.toEqual({ ok: true, token: TOKEN }) + expect(onAuthorizeUrl).toHaveBeenCalledTimes(1) + }) + + it('takes a token printed right before exit', async () => { + const child = fakeChild() + const { outcome } = await started(child) + child.emit(TOKEN) + child.exit(0) + await expect(outcome).resolves.toEqual({ ok: true, token: TOKEN }) + }) + + it('offers the paste fallback when setup-token ends without a token', async () => { + const child = fakeChild() + const { outcome } = await started(child) + child.emit('Something went wrong\n') + child.exit(1) + await expect(outcome).resolves.toEqual({ ok: false, error: 'capture_failed' }) + }) + + it('reports a missing claude as no_cli', async () => { + const outcome = await runClaudeSetupToken({ + spawn: async () => { + throw Object.assign(new Error('nope'), { code: 'ENOENT' }) + }, + signal: new AbortController().signal + }) + expect(outcome).toEqual({ ok: false, error: 'no_cli' }) + }) + + it('stops on a denied sign-in', async () => { + const child = fakeChild() + const { outcome } = await started(child) + child.emit('Error: access_denied\n') + await expect(outcome).resolves.toEqual({ ok: false, error: 'denied' }) + expect(child.killed).toBe(true) + }) + + it('kills the child on cancel and on timeout', async () => { + const cancelled = fakeChild() + const first = await started(cancelled) + first.controller.abort() + await expect(first.outcome).resolves.toEqual({ ok: false, error: 'cancelled' }) + expect(cancelled.killed).toBe(true) + + vi.useFakeTimers() + const slow = fakeChild() + const second = await started(slow) + vi.advanceTimersByTime(1_001) + await expect(second.outcome).resolves.toEqual({ ok: false, error: 'timed_out' }) + expect(slow.killed).toBe(true) + }) +}) diff --git a/src/main/orca-cloud-agents/claude-setup-token-runner.ts b/src/main/orca-cloud-agents/claude-setup-token-runner.ts new file mode 100644 index 00000000000..0132bbc7000 --- /dev/null +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.ts @@ -0,0 +1,184 @@ +// Runs the unmodified `claude setup-token` on this computer (spec §5 state 6) and reads the +// long-lived token it prints. The token stays in this module's caller: it is never logged, never put +// in an error, and never returned to the renderer. Output is scanned in memory and dropped. +import { CLAUDE_SETUP_TOKEN_PATTERN } from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' + +/** The child, as the runner needs it. Production is a pseudo-terminal (Claude's UI needs a TTY). */ +export type SetupTokenProcess = { + onData(listener: (chunk: string) => void): void + onExit(listener: (exitCode: number | null) => void): void + kill(): void +} + +/** Rejects with `{ code: 'ENOENT' }` when there is no `claude` to run. */ +export type SpawnSetupToken = () => Promise + +export type ClaudeSetupTokenFailure = + | 'no_cli' + | 'denied' + | 'timed_out' + | 'capture_failed' + | 'cancelled' + +export type ClaudeSetupTokenOutcome = + | { ok: true; token: string } + | { ok: false; error: ClaudeSetupTokenFailure } + +// Setup-token waits for a browser approval, like `claude auth login`. +const DEFAULT_TIMEOUT_MS = 600_000 +// Output is kept only as long as a token or a link could span chunks. +const MAX_SCAN_CHARS = 16_000 +// After the token prints, setup-token exits on its own; this is the backstop. +const EXIT_GRACE_MS = 3_000 +// Terminal escapes become a separator, so a styled word right after the token can't extend it. +const TERMINAL_ESCAPE = + // eslint-disable-next-line no-control-regex + /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]|[\u0000-\u0008\u000b-\u001f\u007f]/g +const TOKEN_CANDIDATE = /sk-ant-oat01-[A-Za-z0-9_-]+/g +const TOKEN_CHAR = /[A-Za-z0-9_-]/ +const DENIED = + /\baccess_denied\b|authorization (?:request )?(?:was )?denied|sign-?in (?:was )?denied|login (?:was )?denied/i +const AUTHORIZE_HOSTS = new Set(['claude.ai', 'claude.com', 'console.anthropic.com']) +const LINK = /https:\/\/[^\s"'<>]+(?=\s)/g + +function plainText(raw: string): string { + return raw.replace(TERMINAL_ESCAPE, '\n') +} + +/** + * The setup-token in `text`, once it is complete: followed by a non-token character, or at the end + * when `final` (the process exited). Exported for tests; callers never log its result. + */ +export function findSetupToken(text: string, final: boolean): string | null { + const plain = plainText(text) + let found: string | null = null + for (const match of plain.matchAll(TOKEN_CANDIDATE)) { + const end = (match.index ?? 0) + match[0].length + const terminated = end < plain.length && !TOKEN_CHAR.test(plain[end]) + if ((terminated || final) && CLAUDE_SETUP_TOKEN_PATTERN.test(match[0])) { + // The last complete one wins: a redraw repeats the same token. + found = match[0] + } + } + return found +} + +/** Claude's sign-in page, for "Open again". Only its own OAuth hosts. */ +export function findAuthorizeUrl(text: string): string | null { + for (const match of plainText(text).matchAll(LINK)) { + try { + const url = new URL(match[0]) + if (AUTHORIZE_HOSTS.has(url.hostname) && url.pathname.includes('oauth')) { + return url.toString() + } + } catch { + // Not a link; keep scanning. + } + } + return null +} + +export function runClaudeSetupToken(options: { + spawn: SpawnSetupToken + signal: AbortSignal + timeoutMs?: number + onAuthorizeUrl?: (url: string) => void +}): Promise { + const { spawn, signal, onAuthorizeUrl } = options + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + return new Promise((resolve) => { + if (signal.aborted) { + resolve({ ok: false, error: 'cancelled' }) + return + } + let child: SetupTokenProcess | null = null + let settled = false + let exited = false + let scanned = '' + let linkSeen = false + let timer: ReturnType | null = null + let graceTimer: ReturnType | null = null + + const kill = (): void => { + if (child && !exited) { + try { + child.kill() + } catch { + // Already gone. + } + } + } + const finish = (outcome: ClaudeSetupTokenOutcome, stop: 'kill' | 'grace' | 'none'): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + signal.removeEventListener('abort', onAbort) + // Nothing of the output outlives the run. + scanned = '' + if (stop === 'kill') { + kill() + } else if (stop === 'grace' && !exited) { + graceTimer = setTimeout(kill, EXIT_GRACE_MS) + graceTimer.unref?.() + } + resolve(outcome) + } + function onAbort(): void { + finish({ ok: false, error: 'cancelled' }, 'kill') + } + signal.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout(() => finish({ ok: false, error: 'timed_out' }, 'kill'), timeoutMs) + timer.unref?.() + + spawn().then( + (process) => { + child = process + if (settled) { + kill() + return + } + process.onData((chunk) => { + if (settled) { + return + } + scanned = `${scanned}${chunk}`.slice(-MAX_SCAN_CHARS) + const token = findSetupToken(scanned, false) + if (token) { + finish({ ok: true, token }, 'grace') + return + } + if (DENIED.test(plainText(scanned))) { + finish({ ok: false, error: 'denied' }, 'kill') + return + } + if (!linkSeen) { + const url = findAuthorizeUrl(scanned) + if (url) { + linkSeen = true + onAuthorizeUrl?.(url) + } + } + }) + process.onExit(() => { + exited = true + if (graceTimer) { + clearTimeout(graceTimer) + } + const token = settled ? null : findSetupToken(scanned, true) + finish(token ? { ok: true, token } : { ok: false, error: 'capture_failed' }, 'none') + }) + }, + (error: unknown) => { + const missing = + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === 'ENOENT' + finish({ ok: false, error: missing ? 'no_cli' : 'capture_failed' }, 'none') + } + ) + }) +} diff --git a/src/main/orca-cloud-agents/codex-broker-driver.test.ts b/src/main/orca-cloud-agents/codex-broker-driver.test.ts new file mode 100644 index 00000000000..9b34cef2612 --- /dev/null +++ b/src/main/orca-cloud-agents/codex-broker-driver.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAgentSetup } from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { startCodexBrokerSession } from './codex-broker-driver' + +const START = { + brokerId: 'brk_1', + verificationUrl: 'https://auth.openai.com/codex/device', + userCode: 'ABCD-1234', + expiresAt: 1_000 +} + +function harness(statuses: { status: 'pending' | 'active' | 'failed'; error?: string }[]) { + let clock = 0 + const updates: OrcaCloudAgentSetup[] = [] + const client = { + startCodexBroker: vi.fn(async () => START), + codexBrokerStatus: vi.fn(async () => statuses.shift() ?? { status: 'pending' as const }), + cancelCodexBroker: vi.fn(async () => {}) + } + const session = startCodexBrokerSession({ + client, + onUpdate: (setup) => updates.push(setup), + now: () => clock, + sleep: async () => { + clock += 100 + } + }) + return { session, updates, client } +} + +describe('startCodexBrokerSession', () => { + it('shows the device code, then reports saved once the broker is active', async () => { + const { session, updates } = harness([{ status: 'pending' }, { status: 'active' }]) + await session.done + expect(updates.map((u) => u.phase)).toEqual(['preparing', 'device', 'saved']) + expect(updates[1]).toMatchObject({ userCode: 'ABCD-1234' }) + }) + + it('reports why the broker failed', async () => { + const { session, updates } = harness([{ status: 'failed', error: 'not_a_chatgpt_login' }]) + await session.done + expect(updates.at(-1)).toEqual({ phase: 'failed', error: 'not_a_chatgpt_login' }) + }) + + it('cancels the broker when the code expires', async () => { + const { session, updates, client } = harness( + Array.from({ length: 20 }, () => ({ status: 'pending' as const })) + ) + await session.done + expect(updates.at(-1)).toEqual({ phase: 'failed', error: 'code_expired' }) + expect(client.cancelCodexBroker).toHaveBeenCalledWith('brk_1') + }) + + it('stops polling and cancels the broker on cancel', async () => { + const { session, client, updates } = harness([]) + await Promise.resolve() + await Promise.resolve() + await session.cancel() + await session.done + expect(client.cancelCodexBroker).toHaveBeenCalledWith('brk_1') + expect(updates.map((u) => u.phase)).not.toContain('saved') + }) + + it('maps a refused start to a code', async () => { + const updates: OrcaCloudAgentSetup[] = [] + const session = startCodexBrokerSession({ + client: { + startCodexBroker: async () => { + throw new Error('boom') + }, + codexBrokerStatus: vi.fn(), + cancelCodexBroker: vi.fn() + }, + onUpdate: (setup) => updates.push(setup) + }) + await session.done + expect(updates.at(-1)).toEqual({ phase: 'failed', error: 'unknown' }) + }) +}) diff --git a/src/main/orca-cloud-agents/codex-broker-driver.ts b/src/main/orca-cloud-agents/codex-broker-driver.ts new file mode 100644 index 00000000000..f6e2b43991a --- /dev/null +++ b/src/main/orca-cloud-agents/codex-broker-driver.ts @@ -0,0 +1,144 @@ +// Drives the Codex broker login (contract D11): start a broker, show its device code, poll until the +// control plane holds the ChatGPT login, then hand back. The login itself never touches this computer. +import type { + CodexBrokerStart, + CodexBrokerStatus, + OrcaCloudAgentSetup +} from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + agentCredentialsFailureCode, + type AgentCredentialsClient +} from './agent-credentials-client' + +export type CodexBrokerDriverDeps = { + client: Pick< + AgentCredentialsClient, + 'startCodexBroker' | 'codexBrokerStatus' | 'cancelCodexBroker' + > + onUpdate: (setup: OrcaCloudAgentSetup) => void + now?: () => number + sleep?: (ms: number, signal: AbortSignal) => Promise + pollMs?: number +} + +const POLL_MS = 3_000 +// A transient poll failure is retried; this many in a row ends the setup. +const MAX_POLL_FAILURES = 5 +const BROKER_FAILURES = new Set(['not_a_chatgpt_login', 'store_failed', 'cancelled']) + +function defaultSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +export type CodexBrokerSession = { + /** Resolves when the setup ends (saved, failed or cancelled). */ + done: Promise + /** Stops polling and cancels the broker on the control plane. */ + cancel: () => Promise +} + +export function startCodexBrokerSession(deps: CodexBrokerDriverDeps): CodexBrokerSession { + const now = deps.now ?? Date.now + const sleep = deps.sleep ?? defaultSleep + const pollMs = deps.pollMs ?? POLL_MS + const controller = new AbortController() + let brokerId: string | null = null + const startedAt = now() + + const run = async (): Promise => { + deps.onUpdate({ phase: 'preparing', startedAt }) + let started: CodexBrokerStart + try { + started = await deps.client.startCodexBroker() + } catch (error) { + if (!controller.signal.aborted) { + deps.onUpdate({ phase: 'failed', error: agentCredentialsFailureCode(error) }) + } + return + } + brokerId = started.brokerId + if (controller.signal.aborted) { + // Cancelled while the broker was starting: stop it too. + await deps.client.cancelCodexBroker(started.brokerId).catch(() => undefined) + return + } + deps.onUpdate({ + phase: 'device', + startedAt, + userCode: started.userCode, + verificationUrl: started.verificationUrl, + expiresAt: started.expiresAt + }) + let failures = 0 + while (!controller.signal.aborted) { + await sleep(pollMs, controller.signal) + if (controller.signal.aborted) { + return + } + if (now() >= started.expiresAt) { + await deps.client.cancelCodexBroker(started.brokerId).catch(() => undefined) + deps.onUpdate({ phase: 'failed', error: 'code_expired' }) + return + } + let status: CodexBrokerStatus + try { + status = await deps.client.codexBrokerStatus(started.brokerId) + failures = 0 + } catch (error) { + failures += 1 + if (failures >= MAX_POLL_FAILURES && !controller.signal.aborted) { + deps.onUpdate({ phase: 'failed', error: agentCredentialsFailureCode(error) }) + return + } + continue + } + if (controller.signal.aborted) { + return + } + if (status.status === 'active') { + deps.onUpdate({ phase: 'saved' }) + return + } + if (status.status === 'failed') { + const error = status.error ?? '' + deps.onUpdate( + error === 'cancelled' + ? { phase: 'idle' } + : { + phase: 'failed', + error: BROKER_FAILURES.has(error) + ? (error as 'not_a_chatgpt_login' | 'store_failed') + : 'broker_unavailable' + } + ) + return + } + } + } + + const done = run() + return { + done, + cancel: async () => { + if (controller.signal.aborted) { + return + } + controller.abort() + if (brokerId) { + await deps.client.cancelCodexBroker(brokerId).catch(() => undefined) + } + } + } +} diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts new file mode 100644 index 00000000000..f7fe6c563f7 --- /dev/null +++ b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts @@ -0,0 +1,128 @@ +// Renderer bridge for the one-time Orca Cloud agent setup (spec §5 S0). Replies are states and codes: +// the Claude setup-token is captured and sent from main, and a pasted token goes renderer -> main once. +import { BrowserWindow, app, ipcMain, shell } from 'electron' +import { z } from 'zod' +import { + ORCA_CLOUD_ACCOUNT_AGENTS, + type OrcaCloudAgentSetups +} from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { withCloudBuildDefaults } from '../cloud-build-defaults' +import { getHostedSandboxConfig } from '../hosted-sandbox/hosted-sandbox-config' +import { claudeStateFile, readClaudeFolderLogin } from '../claude-accounts/claude-account-folder' +import { createAgentCredentialsClient } from './agent-credentials-client' +import { spawnClaudeSetupTokenPty } from './claude-setup-token-pty' +import { + createOrcaCloudAgentsService, + type OrcaCloudAgentsService +} from './orca-cloud-agents-service' + +export const ORCA_CLOUD_AGENTS_SETUP_CHANGED = 'orcaCloudAgents:setupChanged' + +const Agent = z.enum(ORCA_CLOUD_ACCOUNT_AGENTS) +const AgentRequest = z.object({ agent: Agent }) +// Bounded well above a real token; the service checks the exact shape. +const PasteRequest = z.object({ token: z.string().min(1).max(1024) }) +const SourceRequest = z.object({ agent: Agent, source: z.enum(['sign-in', 'key']).nullable() }) + +const CHANNELS = [ + 'orcaCloudAgents:status', + 'orcaCloudAgents:setups', + 'orcaCloudAgents:start', + 'orcaCloudAgents:cancel', + 'orcaCloudAgents:reopen', + 'orcaCloudAgents:usePaste', + 'orcaCloudAgents:pasteClaudeToken', + 'orcaCloudAgents:acknowledge', + 'orcaCloudAgents:remove', + 'orcaCloudAgents:setDefaultSource' +] as const + +function broadcast(setups: OrcaCloudAgentSetups): void { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(ORCA_CLOUD_AGENTS_SETUP_CHANGED, setups) + } + } +} + +function readClaudeEmail(): string | null { + try { + return readClaudeFolderLogin(claudeStateFile(process.env.CLAUDE_CONFIG_DIR))?.email ?? null + } catch { + return null + } +} + +// One per app: a setup running when a window reattaches keeps going instead of being orphaned. +let shared: OrcaCloudAgentsService | null = null + +function serviceFor(userDataPath: string): OrcaCloudAgentsService { + if (shared) { + return shared + } + const config = getHostedSandboxConfig( + userDataPath, + withCloudBuildDefaults(process.env), + app.isPackaged + ) + shared = createOrcaCloudAgentsService({ + client: config + ? createAgentCredentialsClient({ + baseUrl: config.controlPlaneUrl, + getAccessToken: config.getAccessToken + }) + : null, + spawnSetupToken: spawnClaudeSetupTokenPty, + readClaudeEmail, + openExternal: (url) => shell.openExternal(url), + broadcast + }) + return shared +} + +export function registerOrcaCloudAgentsHandlers(userDataPath: string): void { + const service = serviceFor(userDataPath) + for (const channel of CHANNELS) { + ipcMain.removeHandler(channel) + } + // A malformed request is a renderer bug, not something the user typed. + const invalid = { ok: false, error: 'unknown' } as const + + ipcMain.handle('orcaCloudAgents:status', () => service.status()) + ipcMain.handle('orcaCloudAgents:setups', () => service.setups()) + ipcMain.handle('orcaCloudAgents:start', (_event, raw: unknown) => { + const request = AgentRequest.safeParse(raw) + return request.success ? service.start(request.data.agent) : invalid + }) + ipcMain.handle('orcaCloudAgents:cancel', async (_event, raw: unknown) => { + const request = AgentRequest.safeParse(raw) + if (request.success) { + await service.cancel(request.data.agent) + } + }) + ipcMain.handle('orcaCloudAgents:reopen', (_event, raw: unknown) => { + const request = AgentRequest.safeParse(raw) + return request.success ? service.reopen(request.data.agent) : false + }) + ipcMain.handle('orcaCloudAgents:usePaste', () => service.usePaste()) + ipcMain.handle('orcaCloudAgents:pasteClaudeToken', (_event, raw: unknown) => { + const request = PasteRequest.safeParse(raw) + return request.success ? service.pasteClaudeToken(request.data.token) : invalid + }) + ipcMain.handle('orcaCloudAgents:acknowledge', (_event, raw: unknown) => { + const request = AgentRequest.safeParse(raw) + if (request.success) { + service.acknowledge(request.data.agent) + } + }) + ipcMain.handle('orcaCloudAgents:remove', (_event, raw: unknown) => { + const request = AgentRequest.safeParse(raw) + return request.success ? service.remove(request.data.agent) : invalid + }) + ipcMain.handle('orcaCloudAgents:setDefaultSource', (_event, raw: unknown) => { + const request = SourceRequest.safeParse(raw) + return request.success + ? service.setDefaultSource(request.data.agent, request.data.source) + : invalid + }) +} diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts new file mode 100644 index 00000000000..a5ef9448ba9 --- /dev/null +++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentCredentialsView, + OrcaCloudAgentSetups +} from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import type { AgentCredentialsClient } from './agent-credentials-client' +import type { SetupTokenProcess } from './claude-setup-token-runner' +import { createOrcaCloudAgentsService, isProviderSignInUrl } from './orca-cloud-agents-service' + +const TOKEN = `sk-ant-oat01-${'Zz9_'.repeat(25)}` +const AUTHORIZE = 'https://claude.ai/oauth/authorize?code=true&state=s1' +const VIEW: AgentCredentialsView = { + claude: { state: 'active', label: 'a@acme.dev' }, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } +} + +function fakeChild() { + let onData: (chunk: string) => void = () => {} + let onExit: (code: number | null) => void = () => {} + const child: SetupTokenProcess & { emit(c: string): void; exit(): void } = { + onData: (l) => { + onData = l + }, + onExit: (l) => { + onExit = l + }, + kill: vi.fn(), + emit: (c) => onData(c), + exit: () => onExit(0) + } + return child +} + +function fakeClient(overrides: Partial = {}): AgentCredentialsClient { + return { + credentials: vi.fn(async () => VIEW), + sources: vi.fn(async () => ({ + defaults: {}, + coveredByKey: { claude: false, codex: false } + })), + putClaude: vi.fn(async () => VIEW), + remove: vi.fn(async () => VIEW), + setDefaultSource: vi.fn(async () => ({ + defaults: { claude: 'sign-in' as const }, + coveredByKey: { claude: true, codex: false } + })), + startCodexBroker: vi.fn(), + codexBrokerStatus: vi.fn(), + cancelCodexBroker: vi.fn(async () => {}), + ...overrides + } +} + +const tick = async (): Promise => { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +describe('orca cloud agents service', () => { + const logged: unknown[][] = [] + beforeEach(() => { + logged.length = 0 + for (const level of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, level).mockImplementation((...args: unknown[]) => { + logged.push(args) + }) + } + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + function setup(client = fakeClient()) { + const child = fakeChild() + const broadcasts: OrcaCloudAgentSetups[] = [] + const openExternal = vi.fn(async () => {}) + const service = createOrcaCloudAgentsService({ + client, + spawnSetupToken: async () => child, + readClaudeEmail: () => 'a@acme.dev', + openExternal, + broadcast: (setups) => broadcasts.push(setups) + }) + return { service, child, broadcasts, openExternal, client } + } + + /** Nothing the renderer or a log can see may contain the token. */ + function expectNoToken(...surfaces: unknown[]): void { + for (const surface of [...surfaces, logged]) { + expect(JSON.stringify(surface)).not.toContain(TOKEN) + expect(JSON.stringify(surface)).not.toContain('sk-ant-oat01') + } + } + + it('captures the setup-token in main and stores it with the local account as label', async () => { + const { service, child, broadcasts, client, openExternal } = setup() + const reply = service.start('claude') + await tick() + child.emit(`Opening browser\n${AUTHORIZE}\n`) + expect(service.setups().claude).toMatchObject({ phase: 'browser', canReopen: true }) + expect(await service.reopen('claude')).toBe(true) + expect(openExternal).toHaveBeenCalledWith(AUTHORIZE) + child.emit(`Your OAuth token (valid for 1 year):\n\n${TOKEN}\n\nStore this token`) + await tick() + expect(client.putClaude).toHaveBeenCalledWith(TOKEN, 'a@acme.dev') + expect(service.setups().claude).toEqual({ phase: 'saved' }) + expectNoToken(reply, broadcasts, service.setups(), await service.status()) + }) + + it('offers the paste fallback when capture fails, and stores a pasted token without echoing it', async () => { + const { service, child, broadcasts, client } = setup() + service.start('claude') + await tick() + child.emit('unexpected output\n') + child.exit() + await tick() + expect(service.setups().claude).toEqual({ phase: 'paste', reason: 'capture_failed' }) + const bad = await service.pasteClaudeToken('not a token') + expect(bad).toEqual({ ok: false, error: 'invalid_token' }) + expect(client.putClaude).not.toHaveBeenCalled() + const good = await service.pasteClaudeToken(` ${TOKEN}\n`) + expect(good).toEqual({ ok: true, value: true }) + expect(client.putClaude).toHaveBeenCalledWith(TOKEN, undefined) + expectNoToken(bad, good, broadcasts) + }) + + it('keeps the token out of logs, replies and broadcasts when the control plane refuses it', async () => { + const client = fakeClient({ + putClaude: vi.fn(async () => { + throw new EnvironmentProviderError( + 'invalid_request', + 'Orca Cloud refused the request (invalid_request).', + 'invalid_token' + ) + }) + }) + const { service, broadcasts } = setup(client) + service.usePaste() + const reply = await service.pasteClaudeToken(TOKEN) + expect(reply).toEqual({ ok: false, error: 'invalid_token' }) + expect(service.setups().claude).toEqual({ phase: 'paste', reason: 'chosen' }) + expect(logged.length).toBeGreaterThan(0) + expectNoToken(reply, broadcasts) + }) + + it('cancels a running setup-token and ignores its late output', async () => { + const { service, child, client } = setup() + service.start('claude') + await tick() + await service.cancel('claude') + expect(child.kill).toHaveBeenCalled() + child.emit(`${TOKEN}\n`) + await tick() + expect(client.putClaude).not.toHaveBeenCalled() + expect(service.setups().claude).toEqual({ phase: 'idle' }) + }) + + it('reports not_configured without a control plane', () => { + const service = createOrcaCloudAgentsService({ + client: null, + spawnSetupToken: async () => fakeChild(), + readClaudeEmail: () => null, + openExternal: vi.fn(), + broadcast: vi.fn() + }) + expect(service.start('claude')).toEqual({ ok: false, error: 'not_configured' }) + }) + + it('opens only the provider sign-in pages', () => { + expect(isProviderSignInUrl('claude', AUTHORIZE)).toBe(true) + expect(isProviderSignInUrl('claude', 'https://claude.ai.evil.example/oauth')).toBe(false) + expect(isProviderSignInUrl('codex', 'https://auth.openai.com/codex/device')).toBe(true) + expect(isProviderSignInUrl('codex', 'http://auth.openai.com/codex/device')).toBe(false) + expect(isProviderSignInUrl('codex', 'https://openai.com.evil.example/')).toBe(false) + }) +}) diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-service.ts b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts new file mode 100644 index 00000000000..af630e0f102 --- /dev/null +++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts @@ -0,0 +1,263 @@ +// The one-time Orca Cloud agent setup on this computer (spec §5 S0). Holds each agent's setup step, +// runs Claude's setup-token or the Codex broker, and stores the result in the Orca account. Replies +// and broadcasts carry states and codes only: no token, no CLI output, no server message. +import { + CLAUDE_SETUP_TOKEN_PATTERN, + IDLE_ORCA_CLOUD_AGENT_SETUPS, + type AgentCredentialsView, + type AgentSource, + type AgentSourcesView, + type OrcaCloudAccountAgent, + type OrcaCloudAgentSetup, + type OrcaCloudAgentSetups, + type OrcaCloudAgentsResult, + type OrcaCloudAgentsStatus +} from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + agentCredentialsFailureCode, + type AgentCredentialsClient +} from './agent-credentials-client' +import { runClaudeSetupToken, type SpawnSetupToken } from './claude-setup-token-runner' +import { startCodexBrokerSession, type CodexBrokerSession } from './codex-broker-driver' + +export type OrcaCloudAgentsServiceDeps = { + client: AgentCredentialsClient | null + spawnSetupToken: SpawnSetupToken + /** The Claude account signed in on this computer, used as the stored credential's label. */ + readClaudeEmail: () => string | null + openExternal: (url: string) => Promise + broadcast: (setups: OrcaCloudAgentSetups) => void + now?: () => number + codexPollMs?: number +} + +export type OrcaCloudAgentsService = ReturnType + +const CLAUDE_REOPEN_HOSTS = new Set(['claude.ai', 'claude.com', 'console.anthropic.com']) + +/** Only the provider's own sign-in pages ever open from here. */ +export function isProviderSignInUrl(agent: OrcaCloudAccountAgent, value: string): boolean { + let url: URL + try { + url = new URL(value) + } catch { + return false + } + if (url.protocol !== 'https:' || url.username || url.password) { + return false + } + if (agent === 'claude') { + return CLAUDE_REOPEN_HOSTS.has(url.hostname) + } + return ( + url.hostname === 'openai.com' || + url.hostname.endsWith('.openai.com') || + url.hostname === 'chatgpt.com' + ) +} + +export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { + const now = deps.now ?? Date.now + let setups: OrcaCloudAgentSetups = { ...IDLE_ORCA_CLOUD_AGENT_SETUPS } + let claudeRun: AbortController | null = null + let claudeUrl: string | null = null + let codexSession: CodexBrokerSession | null = null + let codexRun: { live: boolean } | null = null + + const update = (agent: OrcaCloudAccountAgent, setup: OrcaCloudAgentSetup): void => { + setups = { ...setups, [agent]: setup } + deps.broadcast(setups) + } + + async function attempt( + run: (client: AgentCredentialsClient) => Promise + ): Promise> { + if (!deps.client) { + return { ok: false, error: 'not_configured' } + } + try { + return { ok: true, value: await run(deps.client) } + } catch (error) { + const code = agentCredentialsFailureCode(error) + // The code only: a message could quote a request. + console.warn(`[orca-cloud-agents] request failed: ${code}`) + return { ok: false, error: code } + } + } + + /** Stores a Claude token in the Orca account; the token goes out once and is dropped here. */ + async function storeClaude( + token: string, + label: string | null + ): Promise> { + update('claude', { phase: 'saving' }) + const result = await attempt(async (client) => { + await client.putClaude(token, label ?? undefined) + return true as const + }) + update('claude', result.ok ? { phase: 'saved' } : { phase: 'failed', error: result.error }) + return result + } + + function startClaude(): void { + if (claudeRun) { + return + } + const controller = new AbortController() + claudeRun = controller + claudeUrl = null + const startedAt = now() + update('claude', { phase: 'browser', startedAt, canReopen: false }) + void runClaudeSetupToken({ + spawn: deps.spawnSetupToken, + signal: controller.signal, + onAuthorizeUrl: (url) => { + if (claudeRun === controller && isProviderSignInUrl('claude', url)) { + claudeUrl = url + update('claude', { phase: 'browser', startedAt, canReopen: true }) + } + } + }).then(async (outcome) => { + if (claudeRun !== controller) { + return + } + claudeRun = null + claudeUrl = null + if (outcome.ok) { + await storeClaude(outcome.token, deps.readClaudeEmail()) + return + } + switch (outcome.error) { + case 'cancelled': + update('claude', { phase: 'idle' }) + return + case 'no_cli': + case 'capture_failed': + update('claude', { phase: 'paste', reason: outcome.error }) + return + case 'denied': + case 'timed_out': + update('claude', { phase: 'failed', error: outcome.error }) + } + }) + } + + function startCodex(): void { + if (codexSession) { + return + } + // Updates from a cancelled run are dropped (the driver reports its first step synchronously). + const run = { live: true } + codexRun = run + const session = startCodexBrokerSession({ + client: deps.client!, + now, + ...(deps.codexPollMs ? { pollMs: deps.codexPollMs } : {}), + onUpdate: (setup) => { + if (run.live) { + update('codex', setup) + } + } + }) + codexSession = session + void session.done.finally(() => { + if (codexSession === session) { + codexSession = null + } + }) + } + + return { + setups: (): OrcaCloudAgentSetups => setups, + + status: (): Promise> => + attempt(async (client) => { + const [credentials, sources] = await Promise.all([client.credentials(), client.sources()]) + return { credentials, sources } + }), + + start: (agent: OrcaCloudAccountAgent): OrcaCloudAgentsResult => { + if (!deps.client) { + return { ok: false, error: 'not_configured' } + } + if (agent === 'claude') { + startClaude() + } else { + startCodex() + } + return { ok: true, value: true } + }, + + cancel: async (agent: OrcaCloudAccountAgent): Promise => { + if (agent === 'claude') { + claudeRun?.abort() + claudeRun = null + claudeUrl = null + update('claude', { phase: 'idle' }) + return + } + const session = codexSession + codexSession = null + if (codexRun) { + codexRun.live = false + codexRun = null + } + update('codex', { phase: 'idle' }) + await session?.cancel() + }, + + /** "Open again" (Claude) / "Open page" (Codex): the provider's page, never anything else. */ + reopen: async (agent: OrcaCloudAccountAgent): Promise => { + const current = setups[agent] + const url = + agent === 'claude' ? claudeUrl : current.phase === 'device' ? current.verificationUrl : null + if (!url || !isProviderSignInUrl(agent, url)) { + return false + } + await deps.openExternal(url) + return true + }, + + /** "Paste the token instead": stop setup-token and show the masked field. */ + usePaste: (): void => { + claudeRun?.abort() + claudeRun = null + claudeUrl = null + update('claude', { phase: 'paste', reason: 'chosen' }) + }, + + /** The renderer sends the pasted value once; the reply never includes it. */ + pasteClaudeToken: async (raw: string): Promise> => { + const token = raw.trim() + if (!CLAUDE_SETUP_TOKEN_PATTERN.test(token)) { + return { ok: false, error: 'invalid_token' } + } + claudeRun?.abort() + claudeRun = null + // A pasted token may come from another account, so no label: the server names it. + const result = await storeClaude(token, null) + if (!result.ok && result.error === 'invalid_token') { + // Keep the field open so the person can paste again. + update('claude', { phase: 'paste', reason: 'chosen' }) + } + return result + }, + + /** Back to idle once the renderer has read the saved credential. */ + acknowledge: (agent: OrcaCloudAccountAgent): void => { + const phase = setups[agent].phase + if (phase === 'saved' || phase === 'failed') { + update(agent, { phase: 'idle' }) + } + }, + + remove: (agent: OrcaCloudAccountAgent): Promise> => + attempt((client) => client.remove(agent)), + + setDefaultSource: ( + agent: OrcaCloudAccountAgent, + source: AgentSource + ): Promise> => + attempt((client) => client.setDefaultSource(agent, source)) + } +} diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 09a0be7de7d..36c8cd04c86 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -3,6 +3,7 @@ import { registerAgentApiKeyHandlers } from '../agent-api-keys/agent-api-key-ipc import { registerCloudWorkspacesHandlers } from '../ipc/cloud-workspaces' import { registerCloudApiHandlers } from '../cloud-api/cloud-api-ipc' import { registerCloudSecretsHandlers } from '../cloud-secrets/cloud-secrets-ipc' +import { registerOrcaCloudAgentsHandlers } from '../orca-cloud-agents/orca-cloud-agents-ipc' import { registerCoreHandlers } from '../ipc/register-core-handlers/register-core-handlers' import { attachMainWindowServices } from '../window/attach-main-window-services' import { initTccPromptNotice } from '../macos-permissions/macos-tcc-prompt-notice' @@ -73,6 +74,7 @@ export function attachMainWindowCoreServices( registerCloudWorkspacesHandlers() registerCloudApiHandlers() registerCloudSecretsHandlers(app.getPath('userData')) + registerOrcaCloudAgentsHandlers(app.getPath('userData')) registerCoreHandlers( store, runtime, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e4979e756be..9f3d5acfb6b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,6 +1,7 @@ import type { AgentApiKeysApi } from './api/agent-api-keys-bridge' import type { CloudApiBridge } from './api/cloud-api-bridge' import type { CloudSecretsApi } from './api/cloud-secrets-bridge' +import type { OrcaCloudAgentsApi } from './api/orca-cloud-agents-bridge' import type { ClaudeAccountsApi, CodexAccountsApi, @@ -189,6 +190,8 @@ export type PreloadApi = { cloudApi?: CloudApiBridge /** Desktop only: Orca Cloud secrets (personal, team, repo) injected into cloud workspaces. */ cloudSecrets?: CloudSecretsApi + /** Orca Cloud agent accounts (S0): desktop only, states and codes only. */ + orcaCloudAgents?: OrcaCloudAgentsApi /** COMPAT(legacy-relay-bridge): desktop-only, revisit 2027-01-15. */ legacyRelayBridge?: LegacyRelayBridgeApi automations: AutomationsApi diff --git a/src/preload/api/orca-cloud-agents-bridge.ts b/src/preload/api/orca-cloud-agents-bridge.ts new file mode 100644 index 00000000000..42726defe87 --- /dev/null +++ b/src/preload/api/orca-cloud-agents-bridge.ts @@ -0,0 +1,50 @@ +import { ipcRenderer } from 'electron' +import type { + AgentCredentialsView, + AgentSource, + AgentSourcesView, + OrcaCloudAccountAgent, + OrcaCloudAgentSetups, + OrcaCloudAgentsResult, + OrcaCloudAgentsStatus +} from '../../shared/orca-cloud-agents/orca-cloud-agents-wire' + +/** Desktop only. States and codes come back; a credential never does. */ +export type OrcaCloudAgentsApi = { + status: () => Promise> + setups: () => Promise + start: (request: { agent: OrcaCloudAccountAgent }) => Promise> + cancel: (request: { agent: OrcaCloudAccountAgent }) => Promise + reopen: (request: { agent: OrcaCloudAccountAgent }) => Promise + usePaste: () => Promise + /** The pasted value goes to main once; the reply is a code only. */ + pasteClaudeToken: (request: { token: string }) => Promise> + acknowledge: (request: { agent: OrcaCloudAccountAgent }) => Promise + remove: (request: { + agent: OrcaCloudAccountAgent + }) => Promise> + setDefaultSource: (request: { + agent: OrcaCloudAccountAgent + source: AgentSource + }) => Promise> + onSetupChanged: (listener: (setups: OrcaCloudAgentSetups) => void) => () => void +} + +export const orcaCloudAgentsApi: OrcaCloudAgentsApi = { + status: () => ipcRenderer.invoke('orcaCloudAgents:status'), + setups: () => ipcRenderer.invoke('orcaCloudAgents:setups'), + start: (request) => ipcRenderer.invoke('orcaCloudAgents:start', request), + cancel: (request) => ipcRenderer.invoke('orcaCloudAgents:cancel', request), + reopen: (request) => ipcRenderer.invoke('orcaCloudAgents:reopen', request), + usePaste: () => ipcRenderer.invoke('orcaCloudAgents:usePaste'), + pasteClaudeToken: (request) => ipcRenderer.invoke('orcaCloudAgents:pasteClaudeToken', request), + acknowledge: (request) => ipcRenderer.invoke('orcaCloudAgents:acknowledge', request), + remove: (request) => ipcRenderer.invoke('orcaCloudAgents:remove', request), + setDefaultSource: (request) => ipcRenderer.invoke('orcaCloudAgents:setDefaultSource', request), + onSetupChanged: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, setups: OrcaCloudAgentSetups): void => + listener(setups) + ipcRenderer.on('orcaCloudAgents:setupChanged', handler) + return () => ipcRenderer.removeListener('orcaCloudAgents:setupChanged', handler) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 96045137488..ab54d7c8281 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -95,6 +95,7 @@ import { runtimeDownloadsApi } from './api/runtime-downloads-bridge' import { agentApiKeysApi } from './api/agent-api-keys-bridge' import { cloudApiBridge } from './api/cloud-api-bridge' import { cloudSecretsApi } from './api/cloud-secrets-bridge' +import { orcaCloudAgentsApi } from './api/orca-cloud-agents-bridge' import { legacyRelayBridgeApi } from './api/legacy-relay-bridge-api' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -210,6 +211,7 @@ const api = { agentApiKeys: agentApiKeysApi, cloudApi: cloudApiBridge, cloudSecrets: cloudSecretsApi, + orcaCloudAgents: orcaCloudAgentsApi, legacyRelayBridge: legacyRelayBridgeApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx b/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx index b12fe900ec7..e83b8aa2745 100644 --- a/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx +++ b/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx @@ -5,7 +5,6 @@ import { VmBuildSheet } from '../worktree-creation/VmBuildSheet' import type { CloudAgentReadiness } from './cloud-agent-readiness' import type { CloudAgentSetupFailure, CloudAgentSetupOption } from './cloud-agent-setup-choices' import { CloudAgentSetupSavedNotice, CloudAgentSetupView } from './CloudAgentSetupView' -import { ComposerCloudAgentHintView } from './ComposerCloudAgentHint' import { VmBuildCloudAgentStepView, type VmBuildCloudAgentStepMode, @@ -268,12 +267,3 @@ export const dialogSaved = defineFixture({ ) }) - -export const composerHint = defineFixture({ - width: 520, - render: () => ( -
- -
- ) -}) diff --git a/src/renderer/src/components/cloud-agent-setup/ComposerCloudAgentHint.tsx b/src/renderer/src/components/cloud-agent-setup/ComposerCloudAgentHint.tsx deleted file mode 100644 index 9225d9381f0..00000000000 --- a/src/renderer/src/components/cloud-agent-setup/ComposerCloudAgentHint.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState } from 'react' -import { KeyRound } from 'lucide-react' -import { useAppStore } from '@/store' -import { useTranslate } from '@/i18n/use-translate' -import { Button } from '../ui/button' -import { isCloudAgentReady } from './cloud-agent-readiness' -import { CloudAgentSetupDialog } from './CloudAgentSetupDialog' -import { useCloudAgentSetup } from './use-cloud-agent-setup' - -export function ComposerCloudAgentHintView({ - onSetUp, - onLater -}: { - onSetUp: () => void - onLater: () => void -}): React.JSX.Element { - const t = useTranslate() - return ( -
- -

- {t( - 'cloudAgentSetup.composerHint', - 'Claude and Codex need access in Orca Cloud. Add it now, or sign in once the workspace starts.' - )} -

- - -
- ) -} - -/** - * "Add an agent" the moment Orca Cloud is picked for the first time, before anything is created, so a - * warm sandbox that starts in seconds cannot outrun it. Quiet once agents are covered or it was handled. - */ -export function ComposerCloudAgentHint(): React.JSX.Element | null { - const done = useAppStore((s) => s.cloudAgentSetupDone) - const markDone = useAppStore((s) => s.markCloudAgentSetupDone) - const [open, setOpen] = useState(false) - const { model } = useCloudAgentSetup({ repo: null, enabled: !done }) - if (done || model.status === 'loading' || isCloudAgentReady(model.readiness)) { - return null - } - return ( - <> - setOpen(true)} onLater={markDone} /> - - - ) -} diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx index 96a52552e66..543614599b7 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx @@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' -import { ComposerCloudAgentHint } from '../cloud-agent-setup/ComposerCloudAgentHint' +import { OrcaCloudAgentSetupRow } from './OrcaCloudAgentSetupRow' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import { getSelectedNewWorkspaceProjectOptionId } from '@/lib/new-workspace-project-options' @@ -18,7 +18,7 @@ import { EMPTY_PROJECT_OPTIONS } from './new-workspace-composer-card-props' import { OrcaCloudSelectionHint } from './OrcaCloudSelectionHint' import { isOrcaCloudRecipe, isOrcaCloudSelection } from './run-target-options' import { useOrcaCloudRunTargetAccess } from './orca-cloud-billing-cache' -import { orcaCloudCanStart, type OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access' +import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access' type NewWorkspaceComposerProjectSectionProps = Pick< NewWorkspaceComposerCardProps, @@ -40,25 +40,26 @@ type NewWorkspaceComposerProjectSectionProps = Pick< | 'selectedEphemeralVmRecipeId' | 'ephemeralVmRecipeError' | 'orcaCloudAvailability' -> & { - disabled?: boolean - projectDescriptionId: string - onAddProject: () => void - focusNameInput: () => void - shouldShowRunTargetPicker: boolean - projectHostSetupOptions: NewWorkspaceComposerCardProps['projectHostSetupOptions'] - ephemeralVmRecipes: EphemeralVmRecipeOption[] - handleProjectHostSetupChange: (setupId: string) => void - handleAddSshHost: () => void - handleAddRemoteServer: () => void - handleConnectRunTargetHost: (option: NeedsProjectHostOption) => Promise - handleSetLocation: (option: NeedsProjectHostOption) => void - sshStatusLabel: string - connectButtonLabel: string - selectedProjectName: string - /** Fixtures pin Orca Cloud entitlement; the app reads it from billing. */ - orcaCloudAccess?: OrcaCloudRunTargetAccess -} +> & + Partial> & { + disabled?: boolean + projectDescriptionId: string + onAddProject: () => void + focusNameInput: () => void + shouldShowRunTargetPicker: boolean + projectHostSetupOptions: NewWorkspaceComposerCardProps['projectHostSetupOptions'] + ephemeralVmRecipes: EphemeralVmRecipeOption[] + handleProjectHostSetupChange: (setupId: string) => void + handleAddSshHost: () => void + handleAddRemoteServer: () => void + handleConnectRunTargetHost: (option: NeedsProjectHostOption) => Promise + handleSetLocation: (option: NeedsProjectHostOption) => void + sshStatusLabel: string + connectButtonLabel: string + selectedProjectName: string + /** Fixtures pin Orca Cloud entitlement; the app reads it from billing. */ + orcaCloudAccess?: OrcaCloudRunTargetAccess + } export function NewWorkspaceComposerProjectSection({ disabled = false, @@ -94,7 +95,8 @@ export function NewWorkspaceComposerProjectSection({ sshStatusLabel, connectButtonLabel, selectedProjectName, - orcaCloudAccess + orcaCloudAccess, + quickAgent = null }: NewWorkspaceComposerProjectSectionProps): React.JSX.Element { const t = useTranslate() const orcaCloudOffered = shouldShowRunTargetPicker && ephemeralVmRecipes.some(isOrcaCloudRecipe) @@ -199,10 +201,12 @@ export function NewWorkspaceComposerProjectSection({ ) : null} - {/* Agent access is moot while Orca Cloud can't start (no plan, cap, payment, signed out). */} - {selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID && - orcaCloudCanStart(orcaCloudAccess ?? fetchedOrcaCloudAccess) ? ( - + {/* Agent row (cloud-start G): links to the one-time setup; never blocks Start. */} + {selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? ( + ) : null} {!shouldShowRunTargetPicker && ephemeralVmRecipeError ? (

{ephemeralVmRecipeError}

diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx new file mode 100644 index 00000000000..8071c9797e7 --- /dev/null +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx @@ -0,0 +1,33 @@ +// Cloud-start G: the Agent row in the "Set up Orca Cloud" panel, every state. +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { OrcaCloudAgentSetupRowModel } from '@/lib/orca-cloud/orca-cloud-agent-setup-row' +import { OrcaCloudAgentSetupRowView } from './OrcaCloudAgentSetupRow' + +function fixture(...rows: OrcaCloudAgentSetupRowModel[]) { + return defineFixture({ + width: 520, + render: () => ( +
+ {rows.map((row, index) => ( + + ))} +
+ ) + }) +} + +export const fromAccount = fixture({ kind: 'covered', agent: 'claude', via: 'account' }) +export const teamKey = fixture({ kind: 'covered', agent: 'codex', via: 'team' }) +export const notSetUp = fixture({ kind: 'notSetUp', agent: 'claude' }) +export const expired = fixture({ kind: 'expired', agent: 'claude' }) +export const killSwitch = fixture({ kind: 'killSwitch', agent: 'claude' }) +export const signedOut = fixture({ kind: 'signedOut', agent: 'claude' }) +export const loading = fixture({ kind: 'loading', agent: 'claude' }) +export const error = fixture({ kind: 'error', agent: 'codex' }) +export const allStates = fixture( + { kind: 'covered', agent: 'claude', via: 'account' }, + { kind: 'covered', agent: 'codex', via: 'team' }, + { kind: 'notSetUp', agent: 'claude' }, + { kind: 'killSwitch', agent: 'claude' }, + { kind: 'signedOut', agent: 'claude' } +) diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx new file mode 100644 index 00000000000..13454eec128 --- /dev/null +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx @@ -0,0 +1,198 @@ +// The "Set up Orca Cloud" panel's Agent row (cloud-start G). It replaces the old "Add an agent / +// Later" hint: it says how the composer's agent will sign in, links to the one-time setup in +// Settings → Agent accounts, and never blocks Start or signs in per sandbox. +import { useEffect } from 'react' +import { CircleCheck, CircleDashed, Info, TriangleAlert } from 'lucide-react' +import { useAppStore } from '@/store/index' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + orcaCloudAgentSetupRow, + type OrcaCloudAgentSetupRowModel +} from '@/lib/orca-cloud/orca-cloud-agent-setup-row' +import { + reloadOrcaCloudAgentAccounts, + useOrcaCloudAgentAccounts +} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' +import { noteComposerAgentCoverage } from '@/lib/orca-cloud/cloud-first-start-agent-coverage' +import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access' +import { agentName, keySourceLabel } from '../settings/agent-accounts/orca-cloud-accounts-copy' + +/** Settings → Agent accounts, scrolled to the Orca Cloud section (S0). The composer stays open. */ +export function openOrcaCloudAgentAccounts(): void { + useAppStore + .getState() + .openSettingsTarget({ pane: 'agent-accounts', repoId: null, sectionId: 'orca-cloud' }) +} + +function coveredLabel( + row: Extract, + t: Translate +): string { + const agent = agentName(row.agent, t) + return row.via === 'account' + ? t('orcaCloudAgentRow.fromAccount', '{{agent}} · from your Orca account', { agent }) + : t('orcaCloudAgentRow.viaKey', '{{agent}} · {{key}}', { + agent, + key: keySourceLabel(row.via, t) + }) +} + +function rowCopy( + row: Exclude, + t: Translate +): { detail: string; status?: string; action?: { label: string; onClick: () => void } } { + const agent = agentName(row.agent, t) + switch (row.kind) { + case 'notSetUp': + return { + detail: t( + 'orcaCloudAgentRow.notSetUp', + '{{agent}} isn’t set up for Orca Cloud yet. Set it up once in Settings → Agent accounts.', + { agent } + ), + action: { + label: t('orcaCloudAgentRow.setUpOnce', 'Set up once'), + onClick: openOrcaCloudAgentAccounts + } + } + case 'expired': + return { + detail: t( + 'orcaCloudAgentRow.expired', + 'Your {{agent}} sign-in for Orca Cloud expired. Renew it once in Settings → Agent accounts.', + { agent } + ), + action: { + label: t('orcaCloudAgentRow.renew', 'Renew'), + onClick: openOrcaCloudAgentAccounts + } + } + case 'killSwitch': + return { + detail: t( + 'orcaCloudAgentRow.killSwitch', + '{{agent}} signs in for each cloud workspace while Orca account sign-in is paused.', + { agent } + ) + } + case 'signedOut': + return { + detail: t( + 'orcaCloudAgentRow.signedOut', + '{{agent}}: set up once in Settings → Agent accounts after you sign in', + { agent } + ), + status: t('orcaCloudAgentRow.optional', 'Optional') + } + case 'error': + return { + detail: t( + 'orcaCloudAgentRow.error', + 'Couldn’t check whether {{agent}} is set up for Orca Cloud. You can still start.', + { agent } + ), + action: { + label: t('orcaCloudAgentRow.tryAgain', 'Try again'), + onClick: () => void reloadOrcaCloudAgentAccounts() + } + } + } +} + +function RowIcon({ kind }: { kind: OrcaCloudAgentSetupRowModel['kind'] }): React.JSX.Element { + const base = 'size-4 shrink-0' + switch (kind) { + case 'expired': + case 'error': + return + case 'killSwitch': + return + default: + return + } +} + +export function OrcaCloudAgentSetupRowView({ + row +}: { + row: OrcaCloudAgentSetupRowModel +}): React.JSX.Element | null { + const t = useTranslate() + const label = t('orcaCloudAgentRow.label', 'Agent') + if (row.kind === 'hidden') { + return null + } + if (row.kind === 'covered') { + // Collapsed: one quiet line, like the returning-user composer (cloud-start E). + const text = coveredLabel(row, t) + return ( +

+ + {label} + {text} +

+ ) + } + if (row.kind === 'loading') { + return ( +
+ + {t('orcaCloudAgentRow.checking', 'Checking {{agent}}', { + agent: agentName(row.agent, t) + })} + + + +
+ ) + } + const copy = rowCopy(row, t) + return ( +
+
+ + {label} + {copy.action ? ( + + ) : copy.status ? ( + {copy.status} + ) : null} +
+ {/* Indented to the label: icon (16px) + gap (12px). */} +

{copy.detail}

+
+ ) +} + +export function OrcaCloudAgentSetupRow({ + agent, + access, + githubBlocked = false +}: { + agent: string | null + access: OrcaCloudRunTargetAccess + githubBlocked?: boolean +}): React.JSX.Element | null { + const accounts = useOrcaCloudAgentAccounts() + const row = orcaCloudAgentSetupRow({ agent, access, githubBlocked, accounts }) + useEffect(() => { + noteComposerAgentCoverage(row) + }) + return +} diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountRow.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountRow.tsx new file mode 100644 index 00000000000..392b985f1cf --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountRow.tsx @@ -0,0 +1,171 @@ +// One S0 row per agent: picks the state's row and keeps the row's own Remove / save-error state. +import type React from 'react' +import { useState } from 'react' +import { useTranslate } from '@/i18n/use-translate' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import type { + OrcaCloudAccountAgent, + OrcaCloudAgentsErrorCode +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { RowShell } from './orca-cloud-account-row-shell' +import { agentName, keyPhrase, productName, setupFailureCopy } from './orca-cloud-accounts-copy' +import type { OrcaCloudAccountsActions } from './orca-cloud-accounts-types' +import { ActiveRow, ChoiceRow } from './OrcaCloudActiveRow' +import { SetupRow } from './OrcaCloudSetupRow' + +export function AccountRow({ + agent, + row, + actions, + now, + initialConfirmRemove, + initialRowError +}: { + agent: OrcaCloudAccountAgent + row: OrcaCloudAccountRow + actions: OrcaCloudAccountsActions + now: number + initialConfirmRemove: boolean + initialRowError: OrcaCloudAgentsErrorCode | null +}): React.JSX.Element { + const t = useTranslate() + const [confirmRemove, setConfirmRemove] = useState(initialConfirmRemove) + const [rowError, setRowError] = useState(initialRowError) + const product = productName(agent, t) + const useAccount = + agent === 'claude' + ? t('orcaCloudAccounts.useClaude', 'Use my Claude account') + : t('orcaCloudAccounts.useChatGPT', 'Use my ChatGPT account') + let content: React.JSX.Element + switch (row.kind) { + case 'notSetUp': + content = ( + actions.start(agent)}> + {useAccount} + + } + /> + ) + break + case 'setup': + content = + break + case 'failed': { + const copy = setupFailureCopy(agent, row.error, t) + content = ( + actions.start(agent)}> + {t('orcaCloudAccounts.tryAgain', 'Try again')} + + } + /> + ) + break + } + case 'choice': + content = ( + + ) + break + case 'active': + content = ( + + ) + break + case 'expired': + content = ( + actions.start(agent)}> + {t('orcaCloudAccounts.renew', 'Renew')} + + } + /> + ) + break + case 'killSwitch': + content = ( + + ) + break + } + if (!rowError) { + return content + } + return ( + <> + {content} +
  • + setRowError(null)} + /> +
  • + + ) +} + +/** One polite announcement per setup step ("Claude: finish in your browser"). */ diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.fixture.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.fixture.tsx new file mode 100644 index 00000000000..00294948e04 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.fixture.tsx @@ -0,0 +1,170 @@ +// S0 states (agent-accounts spec §5): settings/agent-accounts/OrcaCloudAccounts:. +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import type { OrcaCloudAgentAccountsLoad } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' +import type { + OrcaCloudAccountAgent, + OrcaCloudAgentsErrorCode, + OrcaCloudAgentsStatus +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { OrcaCloudAccountsView, type OrcaCloudAccountsActions } from './OrcaCloudAccounts' + +const NOW = Date.UTC(2026, 10, 10, 18, 0, 0) +const YEAR = 365 * 24 * 60 * 60 * 1000 +const noop = (): void => {} +const ACTIONS: OrcaCloudAccountsActions = { + start: noop, + cancel: noop, + reopen: noop, + usePaste: noop, + pasteToken: async () => null, + remove: async () => null, + setSource: async () => null, + copyCode: noop, + retryLoad: noop, + signIn: noop +} +const STATUS: OrcaCloudAgentsStatus = { + credentials: { + claude: { state: 'none' }, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } + }, + sources: { defaults: {}, coveredByKey: { claude: false, codex: false } } +} +const READY: OrcaCloudAgentAccountsLoad = { kind: 'ready', status: STATUS } + +function fixture( + rows: Record | null, + load: OrcaCloudAgentAccountsLoad = READY, + extra: { + confirmRemove?: OrcaCloudAccountAgent + rowError?: { agent: OrcaCloudAccountAgent; error: OrcaCloudAgentsErrorCode } + } = {} +) { + return defineFixture({ + width: 760, + render: () => ( +
    + +
    + ) + }) +} + +const CLAUDE_ACTIVE: OrcaCloudAccountRow = { + kind: 'active', + label: 'a@acme.dev', + expiresAt: NOW + YEAR, + expiring: false, + source: 'sign-in', + keyKind: null, + covered: false +} +const CODEX_TEAM: OrcaCloudAccountRow = { kind: 'notSetUp', keyKind: 'team' } + +export const notSetUp = fixture({ + claude: { kind: 'notSetUp', keyKind: null }, + codex: CODEX_TEAM +}) + +export const loading = fixture(null, { kind: 'loading' }) + +export const settingUpClaude = fixture({ + claude: { + kind: 'setup', + setup: { phase: 'browser', startedAt: NOW - 14_000, canReopen: true } + }, + codex: CODEX_TEAM +}) + +export const preparingCodex = fixture({ + claude: CLAUDE_ACTIVE, + codex: { kind: 'setup', setup: { phase: 'preparing', startedAt: NOW - 4_000 } } +}) + +export const settingUpCodexDevice = fixture({ + claude: CLAUDE_ACTIVE, + codex: { + kind: 'setup', + setup: { + phase: 'device', + startedAt: NOW - 38_000, + userCode: 'K7QD-9WFM', + verificationUrl: 'https://auth.openai.com/codex/device', + expiresAt: NOW + 14 * 60_000 + } + } +}) + +export const pasteToken = fixture({ + claude: { kind: 'setup', setup: { phase: 'paste', reason: 'capture_failed' } }, + codex: CODEX_TEAM +}) + +export const pasteTokenNoCli = fixture({ + claude: { kind: 'setup', setup: { phase: 'paste', reason: 'no_cli' } }, + codex: CODEX_TEAM +}) + +export const saving = fixture({ + claude: { kind: 'setup', setup: { phase: 'saving' } }, + codex: CODEX_TEAM +}) + +export const teamKeyChoice = fixture({ + claude: { kind: 'choice', keyKind: 'team' }, + codex: CODEX_TEAM +}) + +export const active = fixture({ + claude: CLAUDE_ACTIVE, + codex: { + kind: 'active', + label: 'a@acme.dev', + expiresAt: null, + expiring: false, + source: 'key', + keyKind: 'team', + covered: true + } +}) + +export const expiring = fixture({ + claude: { ...CLAUDE_ACTIVE, expiresAt: NOW + 12 * 24 * 60 * 60 * 1000, expiring: true }, + codex: CODEX_TEAM +}) + +export const expired = fixture({ + claude: { kind: 'expired', keyKind: 'team', covered: true }, + codex: { kind: 'expired', keyKind: null, covered: false } +}) + +export const removeConfirm = fixture({ claude: CLAUDE_ACTIVE, codex: CODEX_TEAM }, READY, { + confirmRemove: 'claude' +}) + +export const killSwitch = fixture({ + claude: CLAUDE_ACTIVE, + codex: { kind: 'killSwitch' } +}) + +export const setupFailed = fixture({ + claude: { kind: 'failed', error: 'denied' }, + codex: { kind: 'failed', error: 'code_expired' } +}) + +export const signedOutOfOrca = fixture(null, { kind: 'failed', error: 'signed_out' }) + +export const error = fixture({ claude: CLAUDE_ACTIVE, codex: CODEX_TEAM }, READY, { + rowError: { agent: 'claude', error: 'unavailable' } +}) + +export const loadError = fixture(null, { kind: 'failed', error: 'unavailable' }) diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.test.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.test.tsx new file mode 100644 index 00000000000..39a96d1d33e --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import type { OrcaCloudAgentsStatus } from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { OrcaCloudAccountsView, type OrcaCloudAccountsActions } from './OrcaCloudAccounts' + +const STATUS: OrcaCloudAgentsStatus = { + credentials: { + claude: { state: 'none' }, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } + }, + sources: { defaults: {}, coveredByKey: { claude: false, codex: false } } +} +const TOKEN = `sk-ant-oat01-${'x'.repeat(60)}` + +function actions(overrides: Partial = {}): OrcaCloudAccountsActions { + return { + start: vi.fn(), + cancel: vi.fn(), + reopen: vi.fn(), + usePaste: vi.fn(), + pasteToken: vi.fn(async () => null), + remove: vi.fn(async () => null), + setSource: vi.fn(async () => null), + copyCode: vi.fn(), + retryLoad: vi.fn(), + signIn: vi.fn(), + ...overrides + } +} + +function view( + rows: Record<'claude' | 'codex', OrcaCloudAccountRow>, + handlers = actions() +): OrcaCloudAccountsActions { + render( + + ) + return handlers +} + +const NOT_SET_UP: OrcaCloudAccountRow = { kind: 'notSetUp', keyKind: null } + +describe('OrcaCloudAccountsView', () => { + afterEach(cleanup) + + it('always shows the policy line and starts setup per agent', () => { + const handlers = view({ claude: NOT_SET_UP, codex: { kind: 'notSetUp', keyKind: 'team' } }) + expect( + screen.getByText( + 'Your Claude and ChatGPT accounts are used only in your own workspaces. Teammates never use them.' + ) + ).toBeTruthy() + expect(screen.getByText('Not set up · using the team key')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Use my ChatGPT account' })) + expect(handlers.start).toHaveBeenCalledWith('codex') + expect(screen.getAllByRole('heading', { level: 4 }).map((h) => h.textContent)).toEqual([ + 'Claude', + 'Codex' + ]) + }) + + it('sends a pasted token once and clears the field whatever the answer', async () => { + const pasteToken = vi.fn(async () => 'invalid_token' as const) + view( + { + claude: { kind: 'setup', setup: { phase: 'paste', reason: 'chosen' } }, + codex: NOT_SET_UP + }, + actions({ pasteToken }) + ) + const field = document.querySelector('input[type="password"]')! + fireEvent.change(field, { target: { value: TOKEN } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(pasteToken).toHaveBeenCalledTimes(1)) + expect(pasteToken).toHaveBeenCalledWith(TOKEN) + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('isn’t valid')) + expect(field.value).toBe('') + expect(document.body.innerHTML).not.toContain(TOKEN) + }) + + it('answers the one-time Source question (state 7)', async () => { + const setSource = vi.fn(async () => null) + view({ claude: { kind: 'choice', keyKind: 'team' }, codex: NOT_SET_UP }, actions({ setSource })) + fireEvent.click(screen.getByRole('button', { name: 'Keep the team key' })) + await waitFor(() => expect(setSource).toHaveBeenCalledWith('claude', 'key')) + }) + + it('confirms before removing, and shows a save error without losing the row', async () => { + const remove = vi.fn(async () => 'unavailable' as const) + view( + { + claude: { + kind: 'active', + label: 'a@acme.dev', + expiresAt: null, + expiring: false, + source: 'sign-in', + keyKind: null, + covered: false + }, + codex: NOT_SET_UP + }, + actions({ remove }) + ) + fireEvent.click(screen.getByRole('button', { name: 'Remove…' })) + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + await waitFor(() => expect(remove).toHaveBeenCalledWith('claude')) + await waitFor(() => + expect(screen.getByText('Couldn’t save to your Orca account.')).toBeTruthy() + ) + }) + + it('shows the kill switch as info with no action, and signed out with Sign in', () => { + view({ claude: { kind: 'killSwitch' }, codex: NOT_SET_UP }) + expect(screen.getByText('Signs in for each cloud workspace')).toBeTruthy() + cleanup() + const handlers = actions() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })) + expect(handlers.signIn).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx new file mode 100644 index 00000000000..ade8fbfe232 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx @@ -0,0 +1,159 @@ +// S0 · Settings › Agent accounts › Orca Cloud (agent-accounts spec §5): the one place a Claude or +// ChatGPT account is connected to Orca Cloud, once, on this computer. Rows show states only; the +// setup-token is captured in main and a pasted one goes to main once and never comes back. +import type React from 'react' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { Skeleton } from '@/components/ui/skeleton' +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import { + ORCA_CLOUD_ACCOUNT_AGENTS, + type OrcaCloudAccountAgent +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { SettingsGroupHeader } from '../section-layout/SettingsGroup' +import { agentName } from './orca-cloud-accounts-copy' +import type { OrcaCloudAccountsViewProps } from './orca-cloud-accounts-types' +import { AccountRow } from './OrcaCloudAccountRow' + +export type { + OrcaCloudAccountsActions, + OrcaCloudAccountsViewProps +} from './orca-cloud-accounts-types' + +export const ORCA_CLOUD_ACCOUNTS_SECTION_ID = 'orca-cloud' + +function setupAnnouncement( + rows: Record, + t: Translate +): string { + const parts: string[] = [] + for (const agent of ORCA_CLOUD_ACCOUNT_AGENTS) { + const row = rows[agent] + const name = agentName(agent, t) + if (row.kind === 'setup') { + const phase = row.setup.phase + const step = + phase === 'browser' + ? t('orcaCloudAccounts.announce.browser', 'finish in your browser') + : phase === 'preparing' + ? t('orcaCloudAccounts.announce.preparing', 'getting sign-in ready') + : phase === 'device' + ? t('orcaCloudAccounts.announce.device', 'enter the code on the sign-in page') + : phase === 'paste' + ? t('orcaCloudAccounts.announce.paste', 'paste the token') + : t('orcaCloudAccounts.announce.saving', 'saving') + parts.push(`${name}: ${step}`) + } else if (row.kind === 'active' || row.kind === 'choice') { + parts.push(`${name}: ${t('orcaCloudAccounts.announce.saved', 'set up')}`) + } + } + return parts.join('. ') +} + +function LoadingRows(): React.JSX.Element { + const t = useTranslate() + return ( +
      + {ORCA_CLOUD_ACCOUNT_AGENTS.map((agent) => ( +
    • +

      {agentName(agent, t)}

      +
      + + +
      +
    • + ))} +
    • {t('orcaCloudAccounts.loading', 'Loading agent accounts…')}
    • +
    + ) +} + +export function OrcaCloudAccountsView({ + load, + rows, + actions, + now, + initialConfirmRemove, + initialRowError +}: OrcaCloudAccountsViewProps): React.JSX.Element | null { + const t = useTranslate() + const announcement = rows ? setupAnnouncement(rows, t) : '' + if (load.kind === 'unavailable') { + return null + } + const signedOut = load.kind === 'failed' && load.error === 'signed_out' + let body: React.JSX.Element + if (load.kind === 'loading' || (load.kind === 'ready' && !rows)) { + body = + } else if (signedOut) { + body = ( + + {t('orcaCloudAccounts.signIn', 'Sign in')} + + } + /> + ) + } else if (load.kind === 'failed' || !rows) { + body = ( + + ) + } else { + body = ( +
      + {ORCA_CLOUD_ACCOUNT_AGENTS.map((agent) => ( + + ))} +
    + ) + } + return ( +
    + +
    + {body} +
    + {/* Mandatory (spec §5 item 5): the A1 mitigation depends on this line. */} +

    + {t( + 'orcaCloudAccounts.policy', + 'Your Claude and ChatGPT accounts are used only in your own workspaces. Teammates never use them.' + )} +

    +

    + {announcement} +

    +
    + ) +} diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx new file mode 100644 index 00000000000..4dd103da391 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx @@ -0,0 +1,90 @@ +// Wires S0 to main: the setup steps run there, and this only reads states and sends intents. +import { useMemo } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store/index' +import { useTranslate } from '@/i18n/use-translate' +import { useWorktreeCreationClock } from '@/components/worktree-creation/worktree-creation-clock' +import { orcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import { + applyOrcaCloudAgentCredentials, + applyOrcaCloudAgentSources, + reloadOrcaCloudAgentAccounts, + useOrcaCloudAgentAccounts +} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' +import { ORCA_CLOUD_ACCOUNT_AGENTS } from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { OrcaCloudAccountsView, type OrcaCloudAccountsActions } from './OrcaCloudAccounts' + +export function OrcaCloudAccountsSection(): React.JSX.Element | null { + const t = useTranslate() + const { load, setups, keyKinds } = useOrcaCloudAgentAccounts() + const running = ORCA_CLOUD_ACCOUNT_AGENTS.some((agent) => + ['browser', 'preparing', 'device'].includes(setups[agent].phase) + ) + const now = useWorktreeCreationClock(running) + const rows = useMemo( + () => + load.kind === 'ready' + ? { + claude: orcaCloudAccountRow('claude', load.status, setups.claude, keyKinds.claude), + codex: orcaCloudAccountRow('codex', load.status, setups.codex, keyKinds.codex) + } + : null, + [load, setups, keyKinds] + ) + const actions = useMemo(() => { + const api = window.api.orcaCloudAgents + return { + start: (agent) => void api?.start({ agent }), + cancel: (agent) => void api?.cancel({ agent }), + reopen: (agent) => void api?.reopen({ agent }), + usePaste: () => void api?.usePaste(), + pasteToken: async (token) => { + if (!api) { + return 'not_configured' + } + const result = await api.pasteClaudeToken({ token }) + return result.ok ? null : result.error + }, + remove: async (agent) => { + if (!api) { + return 'not_configured' + } + const result = await api.remove({ agent }) + if (result.ok) { + applyOrcaCloudAgentCredentials(result.value) + return null + } + // Already gone on the server: show what's there now. + if (result.error === 'not_found') { + void reloadOrcaCloudAgentAccounts() + return null + } + return result.error + }, + setSource: async (agent, source) => { + if (!api) { + return 'not_configured' + } + const result = await api.setDefaultSource({ agent, source }) + if (result.ok) { + applyOrcaCloudAgentSources(result.value) + return null + } + return result.error + }, + copyCode: (code) => { + void window.api.ui + .writeClipboardText(code) + .then(() => toast.success(t('orcaCloudAccounts.codeCopied', 'Code copied'))) + .catch(() => {}) + }, + retryLoad: () => void reloadOrcaCloudAgentAccounts(), + signIn: () => + void useAppStore + .getState() + .connectCurrentOrcaProfile() + .then(() => reloadOrcaCloudAgentAccounts()) + } + }, [t]) + return +} diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudActiveRow.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudActiveRow.tsx new file mode 100644 index 00000000000..51e95ce65d7 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudActiveRow.tsx @@ -0,0 +1,219 @@ +// S0 states 2 and 7: the saved account (Source, Remove…) and the one-time Source question. +import type React from 'react' +import { useState } from 'react' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import type { + AgentKeyKind, + OrcaCloudAccountAgent, + OrcaCloudAgentsErrorCode +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { SettingsSegmentedControl } from '../form-controls/SettingsSegmentedControl' +import { formatDate, RowShell } from './orca-cloud-account-row-shell' +import { + agentName, + keepKeyLabel, + keyOptionLabel, + keyPhrase, + productName +} from './orca-cloud-accounts-copy' +import type { OrcaCloudAccountsActions } from './orca-cloud-accounts-types' + +export function ActiveRow({ + agent, + row, + actions, + confirmRemove, + setConfirmRemove, + setRowError +}: { + agent: OrcaCloudAccountAgent + row: Extract + actions: OrcaCloudAccountsActions + confirmRemove: boolean + setConfirmRemove: (open: boolean) => void + setRowError: (error: OrcaCloudAgentsErrorCode | null) => void +}): React.JSX.Element { + const t = useTranslate() + const [busy, setBusy] = useState(false) + const product = productName(agent, t) + const who = + row.label ?? + t('orcaCloudAccounts.yourAccount', 'Your {{product}} account', { + product + }) + const run = async (work: () => Promise): Promise => { + setBusy(true) + const error = await work() + setBusy(false) + setRowError(error) + } + if (confirmRemove) { + return ( + + + + + } + /> + ) + } + const renews = + row.expiresAt !== null + ? t('orcaCloudAccounts.renewsBy', 'Renews by {{date}}.', { date: formatDate(row.expiresAt) }) + : null + const kept = t('orcaCloudAccounts.activeDetail', 'Kept in your Orca account, never in a sandbox.') + return ( + + {kept} + {renews ? ( + {renews} + ) : null} + + } + actions={ + <> + {row.covered && row.keyKind ? ( + { + if (!busy && source !== row.source) { + void run(() => actions.setSource(agent, source)) + } + }} + options={[ + { + value: 'sign-in', + label: t('orcaCloudAccounts.source.account', 'Your account'), + disabled: busy + }, + { value: 'key', label: keyOptionLabel(row.keyKind, t), disabled: busy } + ]} + /> + ) : null} + + + } + /> + ) +} + +export function ChoiceRow({ + agent, + keyKind, + actions, + setRowError +}: { + agent: OrcaCloudAccountAgent + keyKind: AgentKeyKind + actions: OrcaCloudAccountsActions + setRowError: (error: OrcaCloudAgentsErrorCode | null) => void +}): React.JSX.Element { + const t = useTranslate() + const [busy, setBusy] = useState(false) + const answer = async (source: 'sign-in' | 'key'): Promise => { + setBusy(true) + setRowError(await actions.setSource(agent, source)) + setBusy(false) + } + return ( + + + + + } + /> + ) +} diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx new file mode 100644 index 00000000000..7874464e747 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx @@ -0,0 +1,252 @@ +// S0 state 6: the setup running on this computer (browser, broker, device code, paste, saving). +import type React from 'react' +import { useId, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { Button } from '@/components/ui/button' +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import type { + OrcaCloudAccountAgent, + OrcaCloudAgentsErrorCode +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { SecretValueInput } from '../cloud-secrets/SecretValueInput' +import { ElapsedSuffix, RowShell } from './orca-cloud-account-row-shell' +import { setupFailureCopy } from './orca-cloud-accounts-copy' +import type { OrcaCloudAccountsActions } from './orca-cloud-accounts-types' + +function PasteTokenForm({ + reason, + onPaste, + onCancel +}: { + reason: 'capture_failed' | 'no_cli' | 'chosen' + onPaste: (token: string) => Promise + onCancel: () => void +}): React.JSX.Element { + const t = useTranslate() + const id = useId() + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const helpId = `${id}-help` + const submit = async (event: React.FormEvent): Promise => { + event.preventDefault() + if (saving || !value.trim()) { + return + } + setSaving(true) + const result = await onPaste(value) + setSaving(false) + // The field is cleared either way: a rejected value isn't kept around in the renderer. + setValue('') + setError(result) + } + return ( +
    void submit(event)}> + +
    +
    + { + setValue(next) + setError(null) + }} + placeholder={t('orcaCloudAccounts.paste.placeholder', 'sk-ant-oat01-…')} + invalid={error !== null} + autoFocus={reason === 'chosen'} + disabled={saving} + describedBy={helpId} + /> +
    + + +
    + {error ? ( +

    + {setupFailureCopy('claude', error, t).title} +

    + ) : ( +

    + {t('orcaCloudAccounts.paste.help', 'Write-only. You won’t see it again after saving.')} +

    + )} +
    + ) +} + +function pasteReasonCopy(reason: 'capture_failed' | 'no_cli' | 'chosen', t: Translate): string { + switch (reason) { + case 'capture_failed': + return t( + 'orcaCloudAccounts.paste.captureFailed', + 'Orca couldn’t read the token from claude setup-token. Run it in a terminal, then paste the token it prints.' + ) + case 'no_cli': + return t( + 'orcaCloudAccounts.paste.noCli', + 'The claude command isn’t on this computer. Run claude setup-token anywhere, then paste the token.' + ) + case 'chosen': + return t( + 'orcaCloudAccounts.paste.chosen', + 'Run claude setup-token anywhere, then paste the token it prints.' + ) + } +} + +function deviceHost(url: string): string { + try { + return new URL(url).hostname + } catch { + return url + } +} + +export function SetupRow({ + agent, + row, + actions, + now +}: { + agent: OrcaCloudAccountAgent + row: Extract + actions: OrcaCloudAccountsActions + now: number +}): React.JSX.Element { + const t = useTranslate() + const cancel = ( + + ) + const setup = row.setup + switch (setup.phase) { + case 'browser': + return ( + + {t('orcaCloudAccounts.settingUp', 'Finish in your browser')} + + + } + detail={t( + 'orcaCloudAccounts.settingUpClaudeDetail', + 'Orca runs claude setup-token on this computer, then saves the result to your Orca account.' + )} + actions={ + <> + {setup.canReopen ? ( + + ) : null} + {cancel} + + } + /> + ) + case 'preparing': + return ( + + {t('orcaCloudAccounts.preparing', 'Getting ChatGPT sign-in ready')} + + + } + detail={t( + 'orcaCloudAccounts.preparingDetail', + 'Codex signs in once in a short-lived sandbox. Orca then keeps the sign-in in your Orca account.' + )} + actions={cancel} + /> + ) + case 'device': + return ( + + {t('orcaCloudAccounts.deviceEnter', 'Enter')}{' '} + + {setup.userCode} + {' '} + {t('orcaCloudAccounts.deviceOn', 'on {{host}}', { + host: deviceHost(setup.verificationUrl) + })} + + + } + detail={t( + 'orcaCloudAccounts.deviceGuard', + 'Only continue if you started this sign-in in Orca.' + )} + actions={ + <> + + + {cancel} + + } + /> + ) + case 'paste': + return ( + + actions.cancel(agent)} + /> + + ) + case 'saving': + return ( + + ) + } +} diff --git a/src/renderer/src/components/settings/agent-accounts/agent-accounts-search.ts b/src/renderer/src/components/settings/agent-accounts/agent-accounts-search.ts new file mode 100644 index 00000000000..a97b7a736bd --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/agent-accounts-search.ts @@ -0,0 +1,19 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translateSearchKeyword } from '../search/settings-search-keywords' + +export const getAgentAccountsSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('settings.agentAccounts.searchTitle', 'Agent accounts'), + description: translate( + 'settings.agentAccounts.searchDescription', + 'Use your Claude and ChatGPT accounts in every cloud workspace.' + ), + keywords: [ + ...translateSearchKeyword('settings.agentAccounts.search.subscription', 'subscription'), + ...translateSearchKeyword('settings.agentAccounts.search.signIn', 'sign in'), + ...translateSearchKeyword('settings.agentAccounts.search.chatgpt', 'chatgpt'), + ...translateSearchKeyword('settings.agentAccounts.search.setupToken', 'setup-token') + ] + } +]) diff --git a/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx b/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx new file mode 100644 index 00000000000..a6e5dd2caeb --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx @@ -0,0 +1,112 @@ +// The S0 row frame (spec §5): agent heading, status glyph, two text lines and one action slot. +import type React from 'react' +import { CircleAlert, CircleCheck, Info, Loader2, TriangleAlert } from 'lucide-react' +import { getIntlLocale } from '@/i18n/i18n' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import { formatElapsedTime } from '@/components/worktree-creation/worktree-creation-clock' +import type { OrcaCloudAccountAgent } from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { agentName } from './orca-cloud-accounts-copy' + +export function formatDate(value: number): string { + return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: 'medium' }).format(value) +} + +export function StatusIcon({ + tone +}: { + tone: 'done' | 'busy' | 'warning' | 'info' | 'none' | 'error' +}): React.JSX.Element { + const base = 'mt-0.5 size-4 shrink-0' + switch (tone) { + case 'done': + return + case 'busy': + // Reduced motion stops the spin; the words carry the state. + return ( + + ) + case 'warning': + return + case 'error': + return + case 'info': + return + case 'none': + return + } +} + +export function RowShell({ + agent, + tone, + title, + detail, + detailTone = 'muted', + actions, + children +}: { + agent: OrcaCloudAccountAgent + tone: React.ComponentProps['tone'] + title: React.ReactNode + detail?: React.ReactNode + detailTone?: 'muted' | 'warning' + actions?: React.ReactNode + children?: React.ReactNode +}): React.JSX.Element { + const t = useTranslate() + return ( +
  • +

    {agentName(agent, t)}

    +
    +
    +
    + +
    +
    {title}
    + {detail ? ( +

    + {detail} +

    + ) : null} +
    +
    + {/* One action slot per row: focus stays here through every state. */} + {actions ? ( +
    + {actions} +
    + ) : null} +
    + {/* Aligned with the text: icon (16px) + gap (8px). */} + {children ?
    {children}
    : null} +
    +
  • + ) +} + +function useElapsed(startedAt: number, now: number): string { + return formatElapsedTime(Math.max(0, now - startedAt)) +} + +export function ElapsedSuffix({ + startedAt, + now +}: { + startedAt: number + now: number +}): React.JSX.Element { + const elapsed = useElapsed(startedAt, now) + return · {elapsed} +} diff --git a/src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-copy.ts b/src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-copy.ts new file mode 100644 index 00000000000..63b22c649c7 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-copy.ts @@ -0,0 +1,155 @@ +// Copy for S0 (agent-accounts spec §5, §10) and the composer's Agent row (cloud-start G). +// Catalog names only ("Claude", "Codex"); the vendor account for Codex is a ChatGPT account. +import type { Translate } from '@/i18n/use-translate' +import type { + AgentKeyKind, + OrcaCloudAccountAgent, + OrcaCloudAgentSetupFailure +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' + +export function agentName(agent: OrcaCloudAccountAgent, t: Translate): string { + return agent === 'claude' + ? t('orcaCloudAccounts.agent.claude', 'Claude') + : t('orcaCloudAccounts.agent.codex', 'Codex') +} + +/** The vendor account behind the agent: Claude for Claude, ChatGPT for Codex. */ +export function productName(agent: OrcaCloudAccountAgent, t: Translate): string { + return agent === 'claude' + ? t('orcaCloudAccounts.product.claude', 'Claude') + : t('orcaCloudAccounts.product.codex', 'ChatGPT') +} + +/** "team key" / "repository key" / "your API key", lower case for use mid-sentence. */ +export function keyPhrase(kind: AgentKeyKind, t: Translate): string { + switch (kind) { + case 'team': + return t('orcaCloudAccounts.key.team', 'the team key') + case 'repository': + return t('orcaCloudAccounts.key.repository', 'the repository key') + case 'personal': + return t('orcaCloudAccounts.key.personal', 'your API key') + } +} + +/** The Source option for the key, sentence case. */ +export function keyOptionLabel(kind: AgentKeyKind, t: Translate): string { + switch (kind) { + case 'team': + return t('orcaCloudAccounts.source.teamKey', 'Team key') + case 'repository': + return t('orcaCloudAccounts.source.repositoryKey', 'Repository key') + case 'personal': + return t('orcaCloudAccounts.source.ownKey', 'Your API key') + } +} + +/** "Keep the team key" for state 7. */ +export function keepKeyLabel(kind: AgentKeyKind, t: Translate): string { + switch (kind) { + case 'team': + return t('orcaCloudAccounts.keepTeam', 'Keep the team key') + case 'repository': + return t('orcaCloudAccounts.keepRepository', 'Keep the repository key') + case 'personal': + return t('orcaCloudAccounts.keepOwn', 'Keep your API key') + } +} + +/** "Claude · team key" in the composer's Agent row (glossary "Agent source"). */ +export function keySourceLabel(kind: AgentKeyKind, t: Translate): string { + switch (kind) { + case 'team': + return t('orcaCloudAccounts.via.team', 'team key') + case 'repository': + return t('orcaCloudAccounts.via.repository', 'repository key') + case 'personal': + return t('orcaCloudAccounts.via.personal', 'your API key') + } +} + +export function setupFailureCopy( + agent: OrcaCloudAccountAgent, + error: OrcaCloudAgentSetupFailure, + t: Translate +): { title: string; detail: string | null } { + const product = productName(agent, t) + const nothingChanged = t('orcaCloudAccounts.error.nothingChanged', 'Nothing was changed.') + switch (error) { + case 'denied': + return { + title: t('orcaCloudAccounts.error.denied', 'The sign-in was declined in your browser.'), + detail: nothingChanged + } + case 'timed_out': + return { + title: t('orcaCloudAccounts.error.timedOut', 'The sign-in took too long to finish.'), + detail: nothingChanged + } + case 'code_expired': + return { + title: t('orcaCloudAccounts.error.codeExpired', 'The code expired · start again'), + detail: null + } + case 'not_a_chatgpt_login': + return { + title: t( + 'orcaCloudAccounts.error.notChatGpt', + 'That sign-in wasn’t a ChatGPT account. Use the ChatGPT account you use with Codex.' + ), + detail: nothingChanged + } + case 'broker_in_progress': + return { + title: t( + 'orcaCloudAccounts.error.brokerBusy', + 'A ChatGPT sign-in is already running. Finish it, or try again in a minute.' + ), + detail: null + } + case 'rate_limited': + return { + title: t( + 'orcaCloudAccounts.error.rateLimited', + 'Too many sign-in attempts. Try again in a few minutes.' + ), + detail: nothingChanged + } + case 'invalid_token': + return { + title: t( + 'orcaCloudAccounts.error.invalidToken', + 'This token isn’t valid · run claude setup-token again' + ), + detail: null + } + case 'not_found': + case 'not_enabled': + return { + title: t( + 'orcaCloudAccounts.error.notEnabled', + 'Orca can’t use your {{product}} account in cloud workspaces right now.', + { product } + ), + detail: nothingChanged + } + case 'signed_out': + return { + title: t('orcaCloudAccounts.error.signedOut', 'Sign in to Orca again, then try again.'), + detail: nothingChanged + } + case 'no_cli': + case 'capture_failed': + case 'store_failed': + case 'broker_unavailable': + case 'no_golden_image': + case 'not_configured': + case 'timeout': + case 'unavailable': + case 'unknown': + return { + title: t('orcaCloudAccounts.error.saveFailed', 'Couldn’t save to your Orca account.'), + detail: nothingChanged + } + } +} diff --git a/src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-types.ts b/src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-types.ts new file mode 100644 index 00000000000..89e4559bdf0 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/orca-cloud-accounts-types.ts @@ -0,0 +1,35 @@ +// Contracts between S0's container and its rows. +import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model' +import type { OrcaCloudAgentAccountsLoad } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' +import type { + OrcaCloudAccountAgent, + OrcaCloudAgentsErrorCode +} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' + +export type OrcaCloudAccountsActions = { + start: (agent: OrcaCloudAccountAgent) => void + cancel: (agent: OrcaCloudAccountAgent) => void + reopen: (agent: OrcaCloudAccountAgent) => void + usePaste: () => void + /** Resolves with a code when the token wasn't stored; the value is never echoed back. */ + pasteToken: (token: string) => Promise + remove: (agent: OrcaCloudAccountAgent) => Promise + setSource: ( + agent: OrcaCloudAccountAgent, + source: 'sign-in' | 'key' + ) => Promise + copyCode: (code: string) => void + retryLoad: () => void + signIn: () => void +} + +export type OrcaCloudAccountsViewProps = { + load: OrcaCloudAgentAccountsLoad + rows: Record | null + actions: OrcaCloudAccountsActions + /** The clock for elapsed times; fixtures pin it. */ + now: number + /** Fixtures: open a row's Remove confirmation or a row-level save error. */ + initialConfirmRemove?: OrcaCloudAccountAgent + initialRowError?: { agent: OrcaCloudAccountAgent; error: OrcaCloudAgentsErrorCode } +} diff --git a/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx index 708791c1cc5..342b47ef2c4 100644 --- a/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx @@ -2,6 +2,7 @@ import { AccountsPane } from '../accounts/AccountsPane' import { getProviderAccountsOwnerKey } from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client' import { AgentsPane } from '../agents/AgentsPane' import { AgentApiKeysSection } from '../agent-api-keys/AgentApiKeysSection' +import { OrcaCloudAccountsSection } from '../agent-accounts/OrcaCloudAccountsSection' import { AgentApiKeysCloudDescription } from '../agent-api-keys/AgentApiKeysCloudDescription' import { AgentDefaultSurfaceSection } from '../agents/surfaces/AgentDefaultSurfaceSection' import { ComputerUsePane } from '../computer-use/ComputerUsePane' @@ -110,6 +111,26 @@ export function renderAccountsSettingsSection( ) } +export function renderAgentAccountsSettingsSection( + context: SettingsRenderContext, + t: Translate = translate +): React.JSX.Element | null { + const { model, navigation, view } = context + return model.showDesktopOnlySettings && model.orcaCloudApiAvailable ? ( + + {view.isSectionMounted('agent-accounts') ? : null} + + ) : null +} + export function renderAgentApiKeysSettingsSection( context: SettingsRenderContext, t: Translate = translate diff --git a/src/renderer/src/components/settings/page/settings-page-renderer.tsx b/src/renderer/src/components/settings/page/settings-page-renderer.tsx index 05b5fb4cb7f..76336d37683 100644 --- a/src/renderer/src/components/settings/page/settings-page-renderer.tsx +++ b/src/renderer/src/components/settings/page/settings-page-renderer.tsx @@ -12,6 +12,7 @@ import type { SettingsInteractionController } from './use-settings-interaction-c import type { SettingsRenderContext } from './settings-render-context' import { renderAccountsSettingsSection, + renderAgentAccountsSettingsSection, renderAgentApiKeysSettingsSection, renderAgentsSettingsSection, renderDesktopCapabilitySettingsSections, @@ -170,6 +171,7 @@ export function renderSettingsPage( > {renderAgentsSettingsSection(context, t)} {renderAccountsSettingsSection(context, t)} + {renderAgentAccountsSettingsSection(context, t)} {renderAgentApiKeysSettingsSection(context, t)} {renderOrchestrationSettingsSection(context, t)} {renderLinearSettingsSection(context, t)} diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 961cc647dad..aae782b4242 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -14,6 +14,8 @@ import { prepareQuickComposerStartup } from './quick-startup-plan' import { buildQuickCreationRequest, type QuickSubmitOptions } from './quick-creation-request' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' import { resolveAgentSessionLaunchRoute } from '@/lib/agent-session/launch/routing/agent-session-launch-plan' +import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' +import { reportCloudStartAgentCoverage } from '@/lib/orca-cloud/cloud-first-start-agent-coverage' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -128,6 +130,9 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { ...(selectedRecipe?.checkoutMode ? { checkoutMode: selectedRecipe.checkoutMode } : {}), ...(selectedRecipe?.provider ? { provider: selectedRecipe.provider } : {}) } + if (activeEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID) { + reportCloudStartAgentCoverage(agent) + } } const promptDelivery = quickDraftPrompt ? 'draft' : 'auto-submit' diff --git a/src/renderer/src/hooks/settings-navigation-capability-sections.ts b/src/renderer/src/hooks/settings-navigation-capability-sections.ts index 6f041632f0b..2a25a7a13aa 100644 --- a/src/renderer/src/hooks/settings-navigation-capability-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-capability-sections.ts @@ -2,6 +2,7 @@ import { LinearIcon } from '@/components/icons/LinearIcon' import { getAccountsPaneSearchEntries } from '@/components/settings/accounts/accounts-search' import { getAgentsPaneSearchEntries } from '@/components/settings/agents/agents-search' import { getAgentApiKeysSearchEntries } from '@/components/settings/agent-api-keys/agent-api-keys-search' +import { getAgentAccountsSearchEntries } from '@/components/settings/agent-accounts/agent-accounts-search' import { getComputerUsePaneSearchEntries } from '@/components/settings/computer-use/computer-use-search' import { getGeneralPaneSearchEntries } from '@/components/settings/general/general-search' import { getIntegrationsPaneSearchEntries } from '@/components/settings/integrations/integrations-search' @@ -15,6 +16,7 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' import type { SettingsNavSection } from '@/lib/settings-navigation-types' import { + BadgeCheck, Blocks, Bot, CircleUserRound, @@ -68,6 +70,22 @@ export function buildCapabilitySettingsSections( group: 'capabilities', badge: t('auto.hooks.useSettingsNavigationMetadata.7c79d3b7bf', 'Optional') }, + // S0: connecting a Claude or ChatGPT account to Orca Cloud runs on this computer (desktop only). + ...(showDesktopOnlySettings && isOrcaCloudApiAvailable + ? [ + { + id: 'agent-accounts', + title: t('settings.agentAccounts.title', 'Agent accounts'), + description: t( + 'settings.agentAccounts.description', + 'Use your Claude and ChatGPT accounts in every cloud workspace. Set up once on this computer.' + ), + icon: BadgeCheck, + searchEntries: getAgentAccountsSearchEntries(), + group: 'capabilities' as const + } + ] + : []), // Desktop-owned vault: a web client can neither read nor fill it. ...(showDesktopOnlySettings ? [ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a1df391d241..9886ef074f7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1398,6 +1398,18 @@ }, "orcaAccount": { "descriptionWithCloud": "Sign-in, plan, team and Orca Cloud settings." + }, + "agentAccounts": { + "searchTitle": "Agent accounts", + "searchDescription": "Use your Claude and ChatGPT accounts in every cloud workspace.", + "search": { + "subscription": "subscription", + "signIn": "sign in", + "chatgpt": "chatgpt", + "setupToken": "setup-token" + }, + "title": "Agent accounts", + "description": "Use your Claude and ChatGPT accounts in every cloud workspace. Set up once on this computer." } }, "menu": { @@ -26852,7 +26864,6 @@ "gotIt": "Got it", "save": "Save", "done": "Done", - "composerHint": "Claude and Codex need access in Orca Cloud. Add it now, or sign in once the workspace starts.", "later": "Later", "skippedTitle": "Agent setup skipped", "signInPending": "Agent sign-in", @@ -27434,5 +27445,126 @@ } }, "promiseAccount": "Keys are sealed on this computer. When you’re signed in to Orca, they’re also saved in your Orca account for cloud workspaces. On SSH hosts and paired machines, Orca passes a key for that run only and never writes it there." + }, + "orcaCloudAgentRow": { + "fromAccount": "{{agent}} · from your Orca account", + "viaKey": "{{agent}} · {{key}}", + "notSetUp": "{{agent}} isn’t set up for Orca Cloud yet. Set it up once in Settings → Agent accounts.", + "setUpOnce": "Set up once", + "expired": "Your {{agent}} sign-in for Orca Cloud expired. Renew it once in Settings → Agent accounts.", + "renew": "Renew", + "killSwitch": "{{agent}} signs in for each cloud workspace while Orca account sign-in is paused.", + "signedOut": "{{agent}}: set up once in Settings → Agent accounts after you sign in", + "optional": "Optional", + "error": "Couldn’t check whether {{agent}} is set up for Orca Cloud. You can still start.", + "tryAgain": "Try again", + "label": "Agent", + "checking": "Checking {{agent}}" + }, + "orcaCloudAccounts": { + "paste": { + "label": "Claude setup token", + "placeholder": "sk-ant-oat01-…", + "save": "Save", + "help": "Write-only. You won’t see it again after saving.", + "captureFailed": "Orca couldn’t read the token from claude setup-token. Run it in a terminal, then paste the token it prints.", + "noCli": "The claude command isn’t on this computer. Run claude setup-token anywhere, then paste the token.", + "chosen": "Run claude setup-token anywhere, then paste the token it prints." + }, + "cancel": "Cancel", + "settingUp": "Finish in your browser", + "settingUpClaudeDetail": "Orca runs claude setup-token on this computer, then saves the result to your Orca account.", + "openAgain": "Open again", + "preparing": "Getting ChatGPT sign-in ready", + "preparingDetail": "Codex signs in once in a short-lived sandbox. Orca then keeps the sign-in in your Orca account.", + "deviceEnter": "Enter", + "deviceOn": "on {{host}}", + "deviceGuard": "Only continue if you started this sign-in in Orca.", + "copyCode": "Copy code", + "openPage": "Open page", + "pasteInstead": "Paste the token instead", + "saving": "Saving to your Orca account…", + "yourAccount": "Your {{product}} account", + "removeConfirm": "Remove your {{product}} account from Orca? Cloud workspaces use the team key, or ask you to sign in.", + "remove": "Remove", + "renewsBy": "Renews by {{date}}.", + "activeDetail": "Kept in your Orca account, never in a sandbox.", + "active": "{{email}} · from your Orca account", + "activeKeyFirst": "{{email}} · saved, using {{key}}", + "sourceLabel": "Source for {{agent}}", + "source": { + "account": "Your account", + "teamKey": "Team key", + "repositoryKey": "Repository key", + "ownKey": "Your API key" + }, + "removeEllipsis": "Remove…", + "choice": "Saved. Use your {{product}} account instead of {{key}} in your cloud workspaces?", + "choiceDetail": "Your account’s usage limits apply instead of the team’s bill. You can change this any time.", + "choiceDetailKey": "Your account’s usage limits apply instead of the key’s billing. You can change this any time.", + "useMine": "Use my account", + "useClaude": "Use my Claude account", + "useChatGPT": "Use my ChatGPT account", + "notSetUpKey": "Not set up · using {{key}}", + "notSetUp": "Not set up", + "notSetUpDetail": "One sign-in on this computer. Then every cloud workspace can use it.", + "tryAgain": "Try again", + "expired": "Your {{agent}} sign-in expired", + "expiredDetail": "Cloud workspaces use {{key}} until you renew. Nothing else changed.", + "expiredDetailNoKey": "Cloud workspaces ask you to sign in until you renew.", + "renew": "Renew", + "killSwitch": "Signs in for each cloud workspace", + "killSwitchDetail": "Orca can’t use your {{product}} account in cloud workspaces right now. Each new cloud workspace asks once until it’s back.", + "error": { + "saveFailed": "Couldn’t save to your Orca account.", + "nothingChanged": "Nothing was changed.", + "denied": "The sign-in was declined in your browser.", + "timedOut": "The sign-in took too long to finish.", + "codeExpired": "The code expired · start again", + "notChatGpt": "That sign-in wasn’t a ChatGPT account. Use the ChatGPT account you use with Codex.", + "brokerBusy": "A ChatGPT sign-in is already running. Finish it, or try again in a minute.", + "rateLimited": "Too many sign-in attempts. Try again in a few minutes.", + "invalidToken": "This token isn’t valid · run claude setup-token again", + "notEnabled": "Orca can’t use your {{product}} account in cloud workspaces right now.", + "signedOut": "Sign in to Orca again, then try again." + }, + "dismiss": "Dismiss", + "announce": { + "browser": "finish in your browser", + "preparing": "getting sign-in ready", + "device": "enter the code on the sign-in page", + "paste": "paste the token", + "saving": "saving", + "saved": "set up" + }, + "loading": "Loading agent accounts…", + "signedOut": "Sign in to Orca to use your accounts in cloud workspaces.", + "signIn": "Sign in", + "loadFailed": "Couldn’t load your agent accounts.", + "title": "Orca Cloud", + "help": "Set up once on this computer. Every cloud workspace uses it, including new, cloned and restarted ones.", + "policy": "Your Claude and ChatGPT accounts are used only in your own workspaces. Teammates never use them.", + "codeCopied": "Code copied", + "agent": { + "claude": "Claude", + "codex": "Codex" + }, + "product": { + "claude": "Claude", + "codex": "ChatGPT" + }, + "key": { + "team": "the team key", + "repository": "the repository key", + "personal": "your API key" + }, + "keepTeam": "Keep the team key", + "keepRepository": "Keep the repository key", + "keepOwn": "Keep your API key", + "via": { + "team": "team key", + "repository": "repository key", + "personal": "your API key" + } } } diff --git a/src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.test.ts b/src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.test.ts new file mode 100644 index 00000000000..711196d462e --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const track = vi.fn() +vi.mock('@/lib/telemetry', () => ({ track: (...args: unknown[]) => track(...args) })) + +const { noteComposerAgentCoverage, reportCloudStartAgentCoverage, resetCloudStartAgentCoverage } = + await import('./cloud-first-start-agent-coverage') + +describe('first cloud start agent coverage', () => { + beforeEach(() => { + track.mockClear() + window.localStorage.clear() + resetCloudStartAgentCoverage() + }) + + it('reports the first cloud start once, with what the Agent row showed', () => { + noteComposerAgentCoverage({ kind: 'covered', agent: 'claude', via: 'team' }) + reportCloudStartAgentCoverage('claude') + reportCloudStartAgentCoverage('claude') + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith('cloud_first_start_agent_coverage', { + agent: 'claude', + coverage: 'key' + }) + }) + + it('reports unknown when the row showed another agent, and buckets other agents', () => { + noteComposerAgentCoverage({ kind: 'notSetUp', agent: 'claude' }) + reportCloudStartAgentCoverage('cursor') + expect(track).toHaveBeenCalledWith('cloud_first_start_agent_coverage', { + agent: 'other', + coverage: 'unknown' + }) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.ts b/src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.ts new file mode 100644 index 00000000000..26929f99b55 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-first-start-agent-coverage.ts @@ -0,0 +1,52 @@ +// Cloud-start telemetry: the share of first cloud starts where the agent was already covered. +// Enum-only, once per install; the composer's Agent row notes what it showed, the start reports it. +import { track } from '@/lib/telemetry' +import type { OrcaCloudAgentSetupRowModel } from './orca-cloud-agent-setup-row' + +const REPORTED_KEY = 'orca.cloud.firstStartAgentCoverageReported' + +type Coverage = 'account' | 'key' | 'none' | 'unknown' +let latest: { agent: string; coverage: Coverage } | null = null + +function coverageOf(row: OrcaCloudAgentSetupRowModel): Coverage { + switch (row.kind) { + case 'covered': + return row.via === 'account' ? 'account' : 'key' + case 'notSetUp': + case 'expired': + case 'killSwitch': + case 'signedOut': + return 'none' + case 'hidden': + case 'loading': + case 'error': + return 'unknown' + } +} + +/** The Agent row calls this with what it currently shows. */ +export function noteComposerAgentCoverage(row: OrcaCloudAgentSetupRowModel): void { + latest = row.kind === 'hidden' ? null : { agent: row.agent, coverage: coverageOf(row) } +} + +/** Call when a cloud workspace start is submitted; reports only the first one on this install. */ +export function reportCloudStartAgentCoverage(agent: string | null): void { + try { + if (window.localStorage.getItem(REPORTED_KEY)) { + return + } + window.localStorage.setItem(REPORTED_KEY, '1') + } catch { + return + } + const known = agent === 'claude' || agent === 'codex' ? agent : 'other' + track('cloud_first_start_agent_coverage', { + agent: known, + coverage: latest && latest.agent === agent ? latest.coverage : 'unknown' + }) +} + +/** Test seam. */ +export function resetCloudStartAgentCoverage(): void { + latest = null +} diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.test.ts new file mode 100644 index 00000000000..b538b2dc674 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + agentKeyKinds, + orcaCloudAccountRow, + orcaCloudAgentCoverage +} from './orca-cloud-agent-accounts-model' + +function status(overrides: { + claude?: OrcaCloudAgentsStatus['credentials']['claude'] + enabled?: boolean + covered?: boolean + defaultSource?: 'sign-in' | 'key' | null +}): OrcaCloudAgentsStatus { + return { + credentials: { + claude: overrides.claude ?? { state: 'none' }, + codex: { state: 'none' }, + providerEnabled: { claude: overrides.enabled ?? true, codex: true } + }, + sources: { + defaults: { claude: overrides.defaultSource ?? null }, + coveredByKey: { claude: overrides.covered ?? false, codex: false } + } + } +} + +const IDLE = { phase: 'idle' } as const +const ACTIVE = { state: 'active', label: 'a@acme.dev', expiresAt: 10 } as const + +describe('agentKeyKinds', () => { + it('names the winning key: team, then repository, then personal', () => { + expect( + agentKeyKinds([ + { name: 'ANTHROPIC_API_KEY', scope: 'personal' }, + { name: 'AI_GATEWAY_API_KEY', scope: 'org' }, + { name: 'OPENAI_API_KEY', scope: 'repo' }, + { name: 'DATABASE_URL', scope: 'org' } + ]) + ).toEqual({ claude: 'team', codex: 'team' }) + expect(agentKeyKinds([{ name: 'OPENAI_API_KEY', scope: 'personal' }])).toEqual({ + claude: null, + codex: 'personal' + }) + }) +}) + +describe('orcaCloudAccountRow', () => { + it('walks the S0 states', () => { + expect(orcaCloudAccountRow('claude', status({}), IDLE, null)).toEqual({ + kind: 'notSetUp', + keyKind: null + }) + expect(orcaCloudAccountRow('claude', status({ covered: true }), IDLE, 'team')).toEqual({ + kind: 'notSetUp', + keyKind: 'team' + }) + // State 7: set up while a key covers it, and no default chosen yet. + expect( + orcaCloudAccountRow('claude', status({ claude: ACTIVE, covered: true }), IDLE, 'team').kind + ).toBe('choice') + expect( + orcaCloudAccountRow( + 'claude', + status({ claude: ACTIVE, covered: true, defaultSource: 'key' }), + IDLE, + 'team' + ) + ).toMatchObject({ kind: 'active', source: 'key', covered: true }) + expect(orcaCloudAccountRow('claude', status({ claude: ACTIVE }), IDLE, null)).toMatchObject({ + kind: 'active', + source: 'sign-in', + label: 'a@acme.dev', + expiring: false + }) + expect( + orcaCloudAccountRow('claude', status({ claude: { state: 'expiring' } }), IDLE, null) + ).toMatchObject({ kind: 'active', expiring: true, label: null }) + expect( + orcaCloudAccountRow('claude', status({ claude: { state: 'revoked' } }), IDLE, null) + ).toEqual({ kind: 'expired', keyKind: null, covered: false }) + }) + + it('puts the kill switch first, then a running or failed setup', () => { + expect( + orcaCloudAccountRow('claude', status({ claude: ACTIVE, enabled: false }), IDLE, null) + ).toEqual({ kind: 'killSwitch' }) + const browser = { phase: 'browser', startedAt: 1, canReopen: false } as const + expect(orcaCloudAccountRow('claude', status({}), browser, null)).toEqual({ + kind: 'setup', + setup: browser + }) + expect( + orcaCloudAccountRow('claude', status({}), { phase: 'failed', error: 'denied' }, null) + ).toEqual({ kind: 'failed', error: 'denied' }) + }) +}) + +describe('orcaCloudAgentCoverage', () => { + it('says how a new cloud workspace authenticates', () => { + expect(orcaCloudAgentCoverage('claude', status({ claude: ACTIVE }), null)).toEqual({ + kind: 'account' + }) + expect( + orcaCloudAgentCoverage('claude', status({ claude: ACTIVE, covered: true }), 'team') + ).toEqual({ kind: 'key', keyKind: 'team' }) + expect( + orcaCloudAgentCoverage( + 'claude', + { + ...status({ claude: ACTIVE, covered: true }), + sources: { + defaults: { claude: 'sign-in' }, + effective: { claude: { source: 'sign-in' } }, + coveredByKey: { claude: true, codex: false } + } + }, + 'team' + ) + ).toEqual({ kind: 'account' }) + expect(orcaCloudAgentCoverage('claude', status({}), null)).toEqual({ kind: 'notSetUp' }) + expect( + orcaCloudAgentCoverage('claude', status({ claude: { state: 'expired' } }), null) + ).toEqual({ kind: 'expired' }) + // Kill switch: a key still covers it; otherwise each workspace signs in. + expect( + orcaCloudAgentCoverage('claude', status({ claude: ACTIVE, enabled: false }), null) + ).toEqual({ kind: 'killSwitch' }) + expect( + orcaCloudAgentCoverage( + 'claude', + status({ claude: ACTIVE, enabled: false, covered: true }), + 'personal' + ) + ).toEqual({ kind: 'key', keyKind: 'personal' }) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.ts new file mode 100644 index 00000000000..d2ec91ffc68 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-model.ts @@ -0,0 +1,140 @@ +// What S0 (Settings › Agent accounts › Orca Cloud) and the composer's Agent row show for each agent, +// derived from GET /v1/agent-credentials + /v1/agent-sources and the setup running on this computer. +import type { CloudEffectiveSecret } from '../../../../shared/cloud-secrets/cloud-secrets-wire' +import { + isUsableAgentCredential, + type AgentKeyKind, + type OrcaCloudAccountAgent, + type OrcaCloudAgentSetup, + type OrcaCloudAgentSetupFailure, + type OrcaCloudAgentsStatus +} from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' + +/** The names that make a key cover an agent, as the control plane counts them (coveredByKey). */ +const KEY_NAMES: Record = { + claude: [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'AWS_BEARER_TOKEN_BEDROCK', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'AI_GATEWAY_API_KEY' + ], + codex: ['OPENAI_API_KEY', 'AI_GATEWAY_API_KEY'] +} + +/** Which key wins for each agent (Team > Repository > Personal), for "team key" / "your API key". */ +export function agentKeyKinds( + effective: readonly Pick[] +): Record { + const kindFor = (agent: OrcaCloudAccountAgent): AgentKeyKind | null => { + const scopes = new Set( + effective.filter((secret) => KEY_NAMES[agent].includes(secret.name)).map((s) => s.scope) + ) + return scopes.has('org') + ? 'team' + : scopes.has('repo') + ? 'repository' + : scopes.has('personal') + ? 'personal' + : null + } + return { claude: kindFor('claude'), codex: kindFor('codex') } +} + +/** One S0 row (spec §5 states 2–9). `keyKind` is the key a row falls back to, when one covers it. */ +export type OrcaCloudAccountRow = + | { kind: 'notSetUp'; keyKind: AgentKeyKind | null } + | { + kind: 'setup' + setup: Exclude + } + | { kind: 'failed'; error: OrcaCloudAgentSetupFailure } + /** State 7: set up, and a key already covers the agent, and no default Source was chosen yet. */ + | { kind: 'choice'; keyKind: AgentKeyKind | null } + | { + kind: 'active' + label: string | null + expiresAt: number | null + expiring: boolean + source: 'sign-in' | 'key' + keyKind: AgentKeyKind | null + covered: boolean + } + | { kind: 'expired'; keyKind: AgentKeyKind | null; covered: boolean } + /** §5c: the provider's Orca-account path is off. Info only; the stored credential is kept. */ + | { kind: 'killSwitch' } + +export function orcaCloudAccountRow( + agent: OrcaCloudAccountAgent, + status: OrcaCloudAgentsStatus, + setup: OrcaCloudAgentSetup, + keyKind: AgentKeyKind | null +): OrcaCloudAccountRow { + const credential = status.credentials[agent] + const covered = status.sources.coveredByKey[agent] + const fallbackKey = covered ? (keyKind ?? 'team') : null + if (!status.credentials.providerEnabled[agent]) { + return { kind: 'killSwitch' } + } + if (setup.phase === 'failed') { + return { kind: 'failed', error: setup.error } + } + if (setup.phase !== 'idle' && setup.phase !== 'saved') { + return { kind: 'setup', setup } + } + if (credential.state === 'none') { + return { kind: 'notSetUp', keyKind: fallbackKey } + } + if (!isUsableAgentCredential(credential.state)) { + return { kind: 'expired', keyKind: fallbackKey, covered } + } + const chosen = status.sources.defaults[agent] ?? null + if (covered && chosen === null) { + return { kind: 'choice', keyKind: fallbackKey } + } + return { + kind: 'active', + label: credential.label?.trim() || null, + expiresAt: credential.expiresAt ?? null, + expiring: credential.state === 'expiring', + // With no key covering the agent, a usable account is the source whatever was chosen. + source: covered && chosen === 'key' ? 'key' : 'sign-in', + keyKind: fallbackKey, + covered + } +} + +/** How a new cloud workspace authenticates this agent, for the composer's Agent row (G). */ +export type OrcaCloudAgentCoverage = + | { kind: 'account' } + | { kind: 'key'; keyKind: AgentKeyKind } + | { kind: 'notSetUp' } + | { kind: 'expired' } + /** §5c without a key: each new cloud workspace asks once. */ + | { kind: 'killSwitch' } + +export function orcaCloudAgentCoverage( + agent: OrcaCloudAccountAgent, + status: OrcaCloudAgentsStatus, + keyKind: AgentKeyKind | null +): OrcaCloudAgentCoverage { + const credential = status.credentials[agent] + const enabled = status.credentials.providerEnabled[agent] + const covered = status.sources.coveredByKey[agent] + const usable = enabled && isUsableAgentCredential(credential.state) + const source = status.sources.effective?.[agent]?.source ?? status.sources.defaults[agent] ?? null + const key: OrcaCloudAgentCoverage = { kind: 'key', keyKind: keyKind ?? 'team' } + if (usable && (source === 'sign-in' || !covered)) { + return { kind: 'account' } + } + if (covered) { + return key + } + if (!enabled) { + return { kind: 'killSwitch' } + } + return credential.state === 'expired' || credential.state === 'revoked' + ? { kind: 'expired' } + : { kind: 'notSetUp' } +} diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-store.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-store.ts new file mode 100644 index 00000000000..d55ead579fe --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-accounts-store.ts @@ -0,0 +1,150 @@ +// One read of the Orca Cloud agent accounts per renderer, shared by S0 and the composer's Agent row, +// plus the setup step main reports. Holds states and codes only; main keeps every credential. +import { useEffect, useSyncExternalStore } from 'react' +import { + IDLE_ORCA_CLOUD_AGENT_SETUPS, + ORCA_CLOUD_ACCOUNT_AGENTS, + type AgentCredentialsView, + type AgentKeyKind, + type AgentSourcesView, + type OrcaCloudAccountAgent, + type OrcaCloudAgentSetups, + type OrcaCloudAgentsErrorCode, + type OrcaCloudAgentsStatus +} from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { agentKeyKinds } from './orca-cloud-agent-accounts-model' + +export type OrcaCloudAgentAccountsLoad = + | { kind: 'loading' } + | { kind: 'ready'; status: OrcaCloudAgentsStatus } + | { kind: 'failed'; error: OrcaCloudAgentsErrorCode } + /** No control plane in this build. */ + | { kind: 'unavailable' } + +export type OrcaCloudAgentAccountsState = { + load: OrcaCloudAgentAccountsLoad + setups: OrcaCloudAgentSetups + keyKinds: Record +} + +const NO_KEYS: OrcaCloudAgentAccountsState['keyKinds'] = { claude: null, codex: null } + +let state: OrcaCloudAgentAccountsState = { + load: { kind: 'loading' }, + setups: IDLE_ORCA_CLOUD_AGENT_SETUPS, + keyKinds: NO_KEYS +} +const listeners = new Set<() => void>() +let started = false +let generation = 0 +// A reload is only needed when the last read is older than this, or something changed it. +const FRESH_MS = 30_000 +let loadedAt = 0 + +function set(next: Partial): void { + state = { ...state, ...next } + for (const listener of listeners) { + listener() + } +} + +function bridge(): NonNullable | null { + return typeof window === 'undefined' ? null : (window.api?.orcaCloudAgents ?? null) +} + +/** Re-reads the credentials and sources (and which key covers each agent). */ +export async function reloadOrcaCloudAgentAccounts(): Promise { + const api = bridge() + if (!api) { + set({ load: { kind: 'unavailable' } }) + return + } + const mine = ++generation + if (state.load.kind !== 'ready') { + set({ load: { kind: 'loading' } }) + } + const [result, effective] = await Promise.all([ + api.status().catch(() => ({ ok: false as const, error: 'unknown' as const })), + window.api.cloudSecrets?.effective({}).catch(() => null) ?? Promise.resolve(null) + ]) + if (mine !== generation) { + return + } + loadedAt = Date.now() + set({ + load: result.ok + ? { kind: 'ready', status: result.value } + : result.error === 'not_configured' + ? { kind: 'unavailable' } + : { kind: 'failed', error: result.error }, + keyKinds: effective?.ok ? agentKeyKinds(effective.value.secrets) : state.keyKinds + }) +} + +function start(): void { + if (started) { + return + } + const api = bridge() + if (!api) { + state = { ...state, load: { kind: 'unavailable' } } + return + } + started = true + void api.setups().then( + (setups) => set({ setups }), + () => {} + ) + api.onSetupChanged((setups) => { + set({ setups }) + // A finished setup changes the stored credential: read it, then let main go back to idle. + for (const agent of ORCA_CLOUD_ACCOUNT_AGENTS) { + if (setups[agent].phase === 'saved') { + void reloadOrcaCloudAgentAccounts().then(() => api.acknowledge({ agent })) + } + } + }) +} + +function subscribe(listener: () => void): () => void { + start() + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** S0 and the Agent row; reads once on mount when the last read is stale. */ +export function useOrcaCloudAgentAccounts(enabled = true): OrcaCloudAgentAccountsState { + const snapshot = useSyncExternalStore( + subscribe, + () => state, + () => state + ) + useEffect(() => { + if (enabled && (state.load.kind !== 'ready' || Date.now() - loadedAt > FRESH_MS)) { + void reloadOrcaCloudAgentAccounts() + } + }, [enabled]) + return snapshot +} + +/** A write's reply is the new view: apply it without another read. */ +export function applyOrcaCloudAgentCredentials(credentials: AgentCredentialsView): void { + if (state.load.kind === 'ready') { + set({ load: { kind: 'ready', status: { ...state.load.status, credentials } } }) + } + // The implicit Source depends on the credential, so the sources view moves too. + void reloadOrcaCloudAgentAccounts() +} + +export function applyOrcaCloudAgentSources(sources: AgentSourcesView): void { + if (state.load.kind === 'ready') { + set({ load: { kind: 'ready', status: { ...state.load.status, sources } } }) + } +} + +/** Fixtures and tests only. */ +export function setOrcaCloudAgentAccountsStateForTest(next: OrcaCloudAgentAccountsState): void { + started = true + loadedAt = Date.now() + set(next) +} diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.test.ts new file mode 100644 index 00000000000..3171af4a537 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { orcaCloudAgentSetupRow } from './orca-cloud-agent-setup-row' +import type { OrcaCloudAgentAccountsLoad } from './orca-cloud-agent-accounts-store' + +const READY: OrcaCloudAgentsStatus = { + credentials: { + claude: { state: 'active', label: 'a@acme.dev' }, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } + }, + sources: { defaults: {}, coveredByKey: { claude: false, codex: true } } +} +const ENTITLED = { state: 'entitled', remainingSeconds: 3600 } as const + +function row( + agent: string | null, + load: OrcaCloudAgentAccountsLoad = { kind: 'ready', status: READY }, + access: Parameters[0]['access'] = ENTITLED +) { + return orcaCloudAgentSetupRow({ + agent, + access, + accounts: { load, keyKinds: { claude: null, codex: 'team' } } + }) +} + +describe('orcaCloudAgentSetupRow', () => { + it('reads "from your Orca account" or "team key" when the agent is covered', () => { + expect(row('claude')).toEqual({ kind: 'covered', agent: 'claude', via: 'account' }) + expect(row('codex')).toEqual({ kind: 'covered', agent: 'codex', via: 'team' }) + }) + + it('links to S0 when nothing covers the agent', () => { + const none: OrcaCloudAgentsStatus = { + ...READY, + credentials: { ...READY.credentials, claude: { state: 'none' } } + } + expect(row('claude', { kind: 'ready', status: none })).toEqual({ + kind: 'notSetUp', + agent: 'claude' + }) + }) + + it('is info only under the kill switch', () => { + const paused: OrcaCloudAgentsStatus = { + ...READY, + credentials: { ...READY.credentials, providerEnabled: { claude: false, codex: true } } + } + expect(row('claude', { kind: 'ready', status: paused }).kind).toBe('killSwitch') + }) + + it('is hidden for agents without an account path, members blocked by billing, and GitHub blocks', () => { + expect(row('cursor').kind).toBe('hidden') + expect(row(null).kind).toBe('hidden') + expect( + row('claude', undefined, { state: 'no-plan', lapsed: false, canManageBilling: false }).kind + ).toBe('hidden') + expect( + row('claude', undefined, { state: 'no-plan', lapsed: false, canManageBilling: true }).kind + ).toBe('covered') + expect( + orcaCloudAgentSetupRow({ + agent: 'claude', + access: ENTITLED, + githubBlocked: true, + accounts: { + load: { kind: 'ready', status: READY }, + keyKinds: { claude: null, codex: null } + } + }).kind + ).toBe('hidden') + }) + + it('has a signed-out, loading and error state, none of which block Start', () => { + expect(row('claude', undefined, { state: 'signed-out' }).kind).toBe('signedOut') + expect(row('claude', { kind: 'failed', error: 'signed_out' }).kind).toBe('signedOut') + expect(row('claude', { kind: 'loading' }).kind).toBe('loading') + expect(row('claude', { kind: 'failed', error: 'unavailable' }).kind).toBe('error') + expect(row('claude', { kind: 'unavailable' }).kind).toBe('hidden') + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.ts new file mode 100644 index 00000000000..067069b7fc8 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-setup-row.ts @@ -0,0 +1,82 @@ +// The "Set up Orca Cloud" panel's Agent row (cloud-start onboarding G, agent-accounts v4). It reads +// the Orca account's credentials and sources, links to S0 when nothing is set up, and never blocks Start. +import type { OrcaCloudRunTargetAccess } from '@/components/new-workspace/orca-cloud-run-target-access' +import { + isOrcaCloudAccountAgent, + type AgentKeyKind, + type OrcaCloudAccountAgent +} from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { orcaCloudAgentCoverage } from './orca-cloud-agent-accounts-model' +import type { OrcaCloudAgentAccountsState } from './orca-cloud-agent-accounts-store' + +export type OrcaCloudAgentSetupRowModel = + | { kind: 'hidden' } + | { kind: 'loading'; agent: OrcaCloudAccountAgent } + /** Collapsed to one line: nothing to do. */ + | { kind: 'covered'; agent: OrcaCloudAccountAgent; via: 'account' | AgentKeyKind } + | { kind: 'notSetUp'; agent: OrcaCloudAccountAgent } + | { kind: 'expired'; agent: OrcaCloudAccountAgent } + /** §5c, info only. */ + | { kind: 'killSwitch'; agent: OrcaCloudAccountAgent } + | { kind: 'signedOut'; agent: OrcaCloudAccountAgent } + | { kind: 'error'; agent: OrcaCloudAccountAgent } + +/** A member can't fix billing, so nothing in this row helps yet (cloud-start C1). */ +function billingBlocksMember(access: OrcaCloudRunTargetAccess): boolean { + switch (access.state) { + case 'no-plan': + case 'out-of-hours': + case 'payment-issue': + return !access.canManageBilling + case 'loading': + case 'unknown': + case 'signed-out': + case 'entitled': + return false + } +} + +export function orcaCloudAgentSetupRow({ + agent, + access, + githubBlocked = false, + accounts +}: { + /** The composer's agent; only agents with an account path (Claude, Codex) get a row. */ + agent: string | null + access: OrcaCloudRunTargetAccess + /** GitHub blocks Start (no remote): the panel shows that instead. */ + githubBlocked?: boolean + accounts: Pick +}): OrcaCloudAgentSetupRowModel { + if (!isOrcaCloudAccountAgent(agent) || githubBlocked || billingBlocksMember(access)) { + return { kind: 'hidden' } + } + if (access.state === 'signed-out') { + return { kind: 'signedOut', agent } + } + const { load } = accounts + switch (load.kind) { + case 'unavailable': + return { kind: 'hidden' } + case 'loading': + return { kind: 'loading', agent } + case 'failed': + return load.error === 'signed_out' ? { kind: 'signedOut', agent } : { kind: 'error', agent } + case 'ready': + break + } + const coverage = orcaCloudAgentCoverage(agent, load.status, accounts.keyKinds[agent]) + switch (coverage.kind) { + case 'account': + return { kind: 'covered', agent, via: 'account' } + case 'key': + return { kind: 'covered', agent, via: coverage.keyKind } + case 'notSetUp': + return { kind: 'notSetUp', agent } + case 'expired': + return { kind: 'expired', agent } + case 'killSwitch': + return { kind: 'killSwitch', agent } + } +} diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index fe2c5ee41a5..85988b08629 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -17,6 +17,7 @@ const SETTINGS_NAV_TARGETS = [ 'general', 'integrations', 'accounts', + 'agent-accounts', 'agent-api-keys', 'browser', 'git', diff --git a/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts b/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts new file mode 100644 index 00000000000..9cc6d622efc --- /dev/null +++ b/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts @@ -0,0 +1,149 @@ +// Orca Cloud agent accounts (agent-accounts spec §5 S0, contract D11): wire shapes of +// /v1/agent-credentials and /v1/agent-sources, and the states main reports to the renderer. +// Nothing here carries a credential: responses and IPC replies are states, labels and codes only. +import { z } from 'zod' + +export const ORCA_CLOUD_ACCOUNT_AGENTS = ['claude', 'codex'] as const +export type OrcaCloudAccountAgent = (typeof ORCA_CLOUD_ACCOUNT_AGENTS)[number] + +export function isOrcaCloudAccountAgent(value: unknown): value is OrcaCloudAccountAgent { + return value === 'claude' || value === 'codex' +} + +/** A Claude setup-token, exactly as the control plane accepts it (PUT /v1/agent-credentials/claude). */ +export const CLAUDE_SETUP_TOKEN_PATTERN = /^sk-ant-oat01-[A-Za-z0-9_-]{20,500}$/ + +export const AGENT_CREDENTIAL_STATES = ['none', 'active', 'expiring', 'expired', 'revoked'] as const +export type AgentCredentialState = (typeof AGENT_CREDENTIAL_STATES)[number] + +/** Active or expiring: the stored credential still works for a request now. */ +export function isUsableAgentCredential(state: AgentCredentialState): boolean { + return state === 'active' || state === 'expiring' +} + +const CredentialView = z.object({ + // An unknown future state reads as none rather than failing the whole view. + state: z.enum(AGENT_CREDENTIAL_STATES).catch('none'), + label: z.string().max(200).optional(), + expiresAt: z.number().finite().optional() +}) +export type AgentCredentialView = z.infer + +export const AgentCredentialsResponseSchema = z.object({ + claude: CredentialView, + codex: CredentialView, + /** false is the per-provider kill switch (spec §5c). */ + providerEnabled: z.object({ claude: z.boolean(), codex: z.boolean() }) +}) +export type AgentCredentialsView = z.infer + +const Source = z.enum(['sign-in', 'key']).nullable() +export type AgentSource = z.infer +const PerAgentSource = z.object({ claude: Source.optional(), codex: Source.optional() }) + +export const AgentSourcesResponseSchema = z.looseObject({ + /** The default Source ("New cloud workspaces"); null = never chosen. */ + defaults: PerAgentSource, + effective: z + .object({ + claude: z.looseObject({ source: Source }).optional(), + codex: z.looseObject({ source: Source }).optional() + }) + .optional(), + /** Some agent key the caller's workspaces would get covers the agent. */ + coveredByKey: z.object({ claude: z.boolean(), codex: z.boolean() }), + sharedWorkspace: z.boolean().optional() +}) +export type AgentSourcesView = z.infer + +export const CodexBrokerStartResponseSchema = z.object({ + brokerId: z.string().min(1).max(128), + verificationUrl: z.string().url().max(512), + userCode: z.string().min(1).max(64), + expiresAt: z.number().finite() +}) +export type CodexBrokerStart = z.infer + +export const CodexBrokerStatusResponseSchema = z.object({ + status: z.enum(['pending', 'active', 'failed']), + error: z.string().max(64).optional() +}) +export type CodexBrokerStatus = z.infer + +/** Which kind of key covers an agent, for "using the team key" / "using your API key". */ +export type AgentKeyKind = 'team' | 'repository' | 'personal' + +/** One read for S0 and the composer's Agent row. */ +export type OrcaCloudAgentsStatus = { + credentials: AgentCredentialsView + sources: AgentSourcesView +} + +/** Codes the renderer turns into copy; never a server message, which could quote input. */ +export const ORCA_CLOUD_AGENTS_ERROR_CODES = [ + 'signed_out', + 'not_configured', + /** The provider's flag is off or its kill switch is on (404 from the credential routes). */ + 'not_enabled', + 'invalid_token', + 'not_found', + 'broker_in_progress', + 'rate_limited', + 'broker_unavailable', + 'no_golden_image', + 'timeout', + 'unavailable', + 'unknown' +] as const +export type OrcaCloudAgentsErrorCode = (typeof ORCA_CLOUD_AGENTS_ERROR_CODES)[number] + +export type OrcaCloudAgentsResult = + | { ok: true; value: T } + | { ok: false; error: OrcaCloudAgentsErrorCode } + +/** Why a setup on this computer stopped (the runner and the broker driver). */ +export const ORCA_CLOUD_AGENT_SETUP_FAILURES = [ + /** `claude` isn't installed here: the row offers the paste fallback. */ + 'no_cli', + 'denied', + 'timed_out', + /** setup-token finished but Orca couldn't read a token from it: the row offers the paste fallback. */ + 'capture_failed', + /** Codex: the device code expired before it was approved. */ + 'code_expired', + /** Codex: the login wasn't a ChatGPT account. */ + 'not_a_chatgpt_login', + 'store_failed' +] as const +export type OrcaCloudAgentSetupFailure = + | (typeof ORCA_CLOUD_AGENT_SETUP_FAILURES)[number] + | OrcaCloudAgentsErrorCode + +/** The one setup step running on this computer for an agent (spec §5 state 6). */ +export type OrcaCloudAgentSetup = + | { phase: 'idle' } + /** Claude: setup-token is running and the browser is open ("Finish in your browser"). */ + | { phase: 'browser'; startedAt: number; canReopen: boolean } + /** Codex: the broker is starting. */ + | { phase: 'preparing'; startedAt: number } + /** Codex: enter the code on the provider's page. The code is meant to be shown; it isn't a secret. */ + | { + phase: 'device' + startedAt: number + userCode: string + verificationUrl: string + expiresAt: number + } + /** Claude: paste a setup-token instead (capture failed, no CLI, or chosen). */ + | { phase: 'paste'; reason: 'capture_failed' | 'no_cli' | 'chosen' } + | { phase: 'saving' } + /** Just stored; the renderer re-reads the credentials (and may ask the state-7 Source question). */ + | { phase: 'saved' } + | { phase: 'failed'; error: OrcaCloudAgentSetupFailure } + +export type OrcaCloudAgentSetups = Record + +export const IDLE_ORCA_CLOUD_AGENT_SETUPS: OrcaCloudAgentSetups = { + claude: { phase: 'idle' }, + codex: { phase: 'idle' } +} diff --git a/src/shared/telemetry-cloud-setup-event-schemas.ts b/src/shared/telemetry-cloud-setup-event-schemas.ts index 824486e95ca..943896df232 100644 --- a/src/shared/telemetry-cloud-setup-event-schemas.ts +++ b/src/shared/telemetry-cloud-setup-event-schemas.ts @@ -50,3 +50,14 @@ export const cloudSetupFirstSandboxSchema = z /** Trial → plan conversion: a setup session's plan step finished. */ export const cloudSetupPlanChosenSchema = z.object({ after_trial: z.boolean() }).strict() + +/** Cloud-start G: how the composer's agent was covered at this install's first cloud start. */ +export const CLOUD_START_AGENT_VALUES = ['claude', 'codex', 'other'] as const +export const CLOUD_START_AGENT_COVERAGE_VALUES = ['account', 'key', 'none', 'unknown'] as const + +export const cloudFirstStartAgentCoverageSchema = z + .object({ + agent: z.enum(CLOUD_START_AGENT_VALUES), + coverage: z.enum(CLOUD_START_AGENT_COVERAGE_VALUES) + }) + .strict() diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 509c1a33d15..adfab31bf68 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -130,6 +130,7 @@ import { } from './telemetry-wsl-move-event-schemas' import { + cloudFirstStartAgentCoverageSchema, cloudSetupFirstSandboxSchema, cloudSetupPlanChosenSchema, cloudSetupStepDoneSchema, @@ -271,5 +272,6 @@ export const eventSchemas = { cloud_setup_step_done: cloudSetupStepDoneSchema, cloud_setup_step_failed: cloudSetupStepFailedSchema, cloud_setup_first_sandbox: cloudSetupFirstSandboxSchema, - cloud_setup_plan_chosen: cloudSetupPlanChosenSchema + cloud_setup_plan_chosen: cloudSetupPlanChosenSchema, + cloud_first_start_agent_coverage: cloudFirstStartAgentCoverageSchema } as const From a3d362a49a3d7f72b389e67e7a721c326a81e2c2 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 08:05:45 -0800 Subject: [PATCH 2/5] fix(cloud): S0 security review: Windows quoting, repaint-safe token capture, broker id, identity binding - setup-token on Windows goes through cmd with the shared quoting as one verbatim ConPTY line; the child env is buildClaudeChildProcessEnv (no inherited auth) and runs in the home directory. - Token capture drops styling escapes, breaks on cursor moves, waits 1 s for a repaint and keeps the longest complete copy, so a split copy is never stored. - Broker ids must start with a letter or digit (no '.'/'..' path tricks), checked at parse time too; server error names are looked up with a Map. - A setup belongs to the Orca identity it started under: a changed identity drops the captured token / cancels the broker and resets the rows. - A token being stored can't be cancelled or raced by a paste or a new start. - The Codex driver only shows OpenAI sign-in pages and caps the wait at 20 min. - The paste field clears in a finally; a rejected IPC reads as unknown. --- .../agent-credentials-client.test.ts | 10 ++ .../agent-credentials-client.ts | 19 +-- .../claude-setup-token-pty.ts | 31 ++++- .../claude-setup-token-runner.test.ts | 45 ++++++- .../claude-setup-token-runner.ts | 52 +++++-- .../codex-broker-driver.test.ts | 28 +++- .../orca-cloud-agents/codex-broker-driver.ts | 30 ++++- .../orca-cloud-agents-ipc.ts | 25 +++- .../orca-cloud-agents-service.test.ts | 43 +++++- .../orca-cloud-agents-service.ts | 127 +++++++++++++----- .../agent-accounts/OrcaCloudAccounts.tsx | 2 +- .../OrcaCloudAccountsSection.tsx | 4 +- .../agent-accounts/OrcaCloudSetupRow.tsx | 14 +- .../orca-cloud-account-row-shell.tsx | 2 +- .../orca-cloud-agents-wire.ts | 3 +- 15 files changed, 357 insertions(+), 78 deletions(-) diff --git a/src/main/orca-cloud-agents/agent-credentials-client.test.ts b/src/main/orca-cloud-agents/agent-credentials-client.test.ts index 176c9f855ff..f2d08b840d4 100644 --- a/src/main/orca-cloud-agents/agent-credentials-client.test.ts +++ b/src/main/orca-cloud-agents/agent-credentials-client.test.ts @@ -117,6 +117,16 @@ describe('createAgentCredentialsClient', () => { 'DELETE /v1/agent-credentials/codex/broker/brk_1' ]) await expect(client.codexBrokerStatus('../secrets')).rejects.toThrow() + await expect(client.cancelCodexBroker('..')).rejects.toThrow() + }) + + it('never maps an inherited property name to a code', async () => { + const { client } = fakeControlPlane(() => + Response.json({ error: 'constructor' }, { status: 500 }) + ) + expect(agentCredentialsFailureCode(await failure(() => client.credentials()))).toBe( + 'unavailable' + ) }) it('writes the default Source', async () => { diff --git a/src/main/orca-cloud-agents/agent-credentials-client.ts b/src/main/orca-cloud-agents/agent-credentials-client.ts index d90ee028d63..20a1ebc981d 100644 --- a/src/main/orca-cloud-agents/agent-credentials-client.ts +++ b/src/main/orca-cloud-agents/agent-credentials-client.ts @@ -34,7 +34,8 @@ export type AgentCredentialsClient = { } const CancelResponse = z.looseObject({}) -const BROKER_ID = /^[A-Za-z0-9_.:-]{1,128}$/ +// Leading letter or digit: '.' and '..' would resolve to another path. +const BROKER_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/ function brokerPath(brokerId: string): string { if (!BROKER_ID.test(brokerId)) { @@ -83,13 +84,13 @@ export function createAgentCredentialsClient( } } -const SERVER_CODES: Partial> = { - broker_in_progress: 'broker_in_progress', - rate_limited: 'rate_limited', - broker_unavailable: 'broker_unavailable', - no_golden_image: 'no_golden_image', - not_configured: 'not_configured' -} +const SERVER_CODES = new Map([ + ['broker_in_progress', 'broker_in_progress'], + ['rate_limited', 'rate_limited'], + ['broker_unavailable', 'broker_unavailable'], + ['no_golden_image', 'no_golden_image'], + ['not_configured', 'not_configured'] +]) /** The code the renderer turns into copy. Never the error's message. */ export function agentCredentialsFailureCode(error: unknown): OrcaCloudAgentsErrorCode { @@ -97,7 +98,7 @@ export function agentCredentialsFailureCode(error: unknown): OrcaCloudAgentsErro return 'unknown' } const serverError = error instanceof HostedSandboxRefusalError ? error.serverError : null - const named = serverError ? SERVER_CODES[serverError] : undefined + const named = serverError ? SERVER_CODES.get(serverError) : undefined if (named) { return named } diff --git a/src/main/orca-cloud-agents/claude-setup-token-pty.ts b/src/main/orca-cloud-agents/claude-setup-token-pty.ts index 7a48a2faf00..6a69ee74bd6 100644 --- a/src/main/orca-cloud-agents/claude-setup-token-pty.ts +++ b/src/main/orca-cloud-agents/claude-setup-token-pty.ts @@ -1,8 +1,11 @@ // Production spawn for the setup-token runner: `claude setup-token` in a pseudo-terminal, because // Claude's sign-in UI needs a TTY. Wide columns keep the printed token on one line. import { stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { isAbsolute } from 'node:path' import { resolveClaudeCommand, withCliRuntimeOnPath } from '../codex-cli/command' +import { buildClaudeChildProcessEnv } from '../claude/claude-child-process-environment' +import { buildWindowsCommandInvocation } from '../claude-accounts/windows-command-invocation' import { stripInheritedClaudeSessionEnv } from '../../shared/agent-launch/startup/claude-parent-session-env' import type { SetupTokenProcess, SpawnSetupToken } from './claude-setup-token-runner' @@ -12,18 +15,31 @@ function enoent(): Error { return Object.assign(new Error('claude is not installed.'), { code: 'ENOENT' }) } +/** + * What to run. Windows shims (.cmd) go through the command interpreter with the same quoting as + * Orca's other Claude commands, handed to ConPTY as one verbatim command line. + */ +export function setupTokenInvocation( + command: string, + platform: NodeJS.Platform = process.platform +): { file: string; args: string[] | string } { + if (platform === 'win32' && /\.(cmd|bat)$/i.test(command)) { + const invocation = buildWindowsCommandInvocation(command, ['setup-token']) + return { file: invocation.command, args: invocation.args.join(' ') } + } + return { file: command, args: ['setup-token'] } +} + export const spawnClaudeSetupTokenPty: SpawnSetupToken = async () => { const command = resolveClaudeCommand() if (isAbsolute(command) && !(await stat(command).catch(() => null))) { throw enoent() } - const env: NodeJS.ProcessEnv = { ...process.env } + // Like Orca's other Claude children: no inherited auth overrides, no parent session stamps. + const env: NodeJS.ProcessEnv = buildClaudeChildProcessEnv() stripInheritedClaudeSessionEnv(env) const childEnv = withCliRuntimeOnPath(command, { ...env, TERM: 'xterm-256color' }) - // Windows shims (.cmd) need the command interpreter; ConPTY can't start them directly. - const viaShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command) - const file = viaShell ? (process.env.ComSpec ?? 'cmd.exe') : command - const args = viaShell ? ['/d', '/s', '/c', `"${command}" setup-token`] : ['setup-token'] + const { file, args } = setupTokenInvocation(command) const pty = await import('node-pty') let child: ReturnType try { @@ -31,12 +47,13 @@ export const spawnClaudeSetupTokenPty: SpawnSetupToken = async () => { name: 'xterm-256color', cols: COLUMNS, rows: 40, + cwd: homedir(), env: childEnv as Record }) } catch (error) { throw (error as { code?: unknown })?.code === 'ENOENT' ? enoent() : error } - const process_: SetupTokenProcess = { + const handle: SetupTokenProcess = { onData: (listener) => { child.onData(listener) }, @@ -45,5 +62,5 @@ export const spawnClaudeSetupTokenPty: SpawnSetupToken = async () => { }, kill: () => child.kill() } - return process_ + return handle } diff --git a/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts b/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts index b52c6abf63d..2d6de9c7c02 100644 --- a/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts @@ -42,7 +42,7 @@ async function started(child: FakeChild) { const outcome = runClaudeSetupToken({ spawn: async () => child, signal: controller.signal, - timeoutMs: 1_000, + timeoutMs: 10_000, onAuthorizeUrl }) // Let the spawn promise settle so listeners are attached. @@ -58,8 +58,15 @@ describe('findSetupToken', () => { expect(findSetupToken(`Your OAuth token:\n${TOKEN}`, true)).toBe(TOKEN) }) - it('treats terminal styling as a separator, so a styled word after it is not absorbed', () => { - expect(findSetupToken(`\u001b[1m${TOKEN}\u001b[22mStore this token`, false)).toBe(TOKEN) + it('drops styling inside the token but breaks on cursor moves around it', () => { + const styled = `${TOKEN.slice(0, 30)}\u001b[?25l\u001b[1m${TOKEN.slice(30)}\u001b[22m` + expect(findSetupToken(`${styled}\n`, false)).toBe(TOKEN) + expect(findSetupToken(`${TOKEN}\u001b[7;1HStore this token`, false)).toBe(TOKEN) + }) + + it('prefers the whole token over a copy a repaint cut short', () => { + const cut = `${TOKEN.slice(0, 40)}\u001b[3;1H${TOKEN.slice(40)}` + expect(findSetupToken(`${cut}\n${TOKEN}\n`, false)).toBe(TOKEN) }) it('ignores anything that is not a setup-token', () => { @@ -83,14 +90,30 @@ describe('runClaudeSetupToken', () => { }) it('captures the token across chunks and reports the sign-in link once', async () => { + vi.useFakeTimers() const child = fakeChild() const { outcome, onAuthorizeUrl } = await started(child) child.emit(`Opening browser…\n${AUTHORIZE}\n`) child.emit(`${AUTHORIZE}\n`) child.emit(`\u001b[32m✓\u001b[0m Your token:\n${TOKEN.slice(0, 30)}`) child.emit(`${TOKEN.slice(30)}\n`) + await vi.advanceTimersByTimeAsync(1_000) await expect(outcome).resolves.toEqual({ ok: true, token: TOKEN }) expect(onAuthorizeUrl).toHaveBeenCalledTimes(1) + // The child exits on its own; the backstop kills it only if it doesn't. + expect(child.killed).toBe(false) + await vi.advanceTimersByTimeAsync(3_000) + expect(child.killed).toBe(true) + }) + + it('waits for a repaint before settling on a token', async () => { + vi.useFakeTimers() + const child = fakeChild() + const { outcome } = await started(child) + child.emit(`${TOKEN.slice(0, 40)}\u001b[3;1H${TOKEN.slice(40)}\n`) + child.emit(`\u001b[2J${TOKEN}\n`) + await vi.advanceTimersByTimeAsync(1_000) + await expect(outcome).resolves.toEqual({ ok: true, token: TOKEN }) }) it('takes a token printed right before exit', async () => { @@ -137,8 +160,22 @@ describe('runClaudeSetupToken', () => { vi.useFakeTimers() const slow = fakeChild() const second = await started(slow) - vi.advanceTimersByTime(1_001) + vi.advanceTimersByTime(10_001) await expect(second.outcome).resolves.toEqual({ ok: false, error: 'timed_out' }) expect(slow.killed).toBe(true) }) }) + +describe('setupTokenInvocation', () => { + it('runs a Windows shim through cmd with the shared quoting, as one verbatim line', async () => { + const { setupTokenInvocation } = await import('./claude-setup-token-pty') + const shim = 'C:\\Users\\Ada Lovelace\\AppData\\Roaming\\npm\\claude.cmd' + const windows = setupTokenInvocation(shim, 'win32') + expect(typeof windows.args).toBe('string') + expect(windows.args).toBe(`/d /v:off /s /c ""${shim}" "setup-token""`) + expect(setupTokenInvocation('/usr/local/bin/claude', 'darwin')).toEqual({ + file: '/usr/local/bin/claude', + args: ['setup-token'] + }) + }) +}) diff --git a/src/main/orca-cloud-agents/claude-setup-token-runner.ts b/src/main/orca-cloud-agents/claude-setup-token-runner.ts index 0132bbc7000..d88467ef74d 100644 --- a/src/main/orca-cloud-agents/claude-setup-token-runner.ts +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.ts @@ -30,7 +30,13 @@ const DEFAULT_TIMEOUT_MS = 600_000 const MAX_SCAN_CHARS = 16_000 // After the token prints, setup-token exits on its own; this is the backstop. const EXIT_GRACE_MS = 3_000 -// Terminal escapes become a separator, so a styled word right after the token can't extend it. +// Redraws can repeat the token; the longest complete copy seen in this window wins. +const SETTLE_MS = 1_000 +// Styling and cursor show/hide never break a line, so they vanish; every other terminal escape +// (cursor moves, erases) and control character becomes a line break, so text from another spot on +// the screen can't extend the token. +// eslint-disable-next-line no-control-regex +const INLINE_ESCAPE = /\u001b\[[0-9;:?]*[mhl]/g const TERMINAL_ESCAPE = // eslint-disable-next-line no-control-regex /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]|[\u0000-\u0008\u000b-\u001f\u007f]/g @@ -42,12 +48,13 @@ const AUTHORIZE_HOSTS = new Set(['claude.ai', 'claude.com', 'console.anthropic.c const LINK = /https:\/\/[^\s"'<>]+(?=\s)/g function plainText(raw: string): string { - return raw.replace(TERMINAL_ESCAPE, '\n') + return raw.replace(INLINE_ESCAPE, '').replace(TERMINAL_ESCAPE, '\n') } /** - * The setup-token in `text`, once it is complete: followed by a non-token character, or at the end - * when `final` (the process exited). Exported for tests; callers never log its result. + * The setup-token in `text`, once complete: followed by a non-token character, or at the end when + * `final` (the process exited). With several copies (a redraw), the longest wins, so a copy cut by + * a repaint never beats the whole one. Exported for tests; callers never log its result. */ export function findSetupToken(text: string, final: boolean): string | null { const plain = plainText(text) @@ -55,8 +62,11 @@ export function findSetupToken(text: string, final: boolean): string | null { for (const match of plain.matchAll(TOKEN_CANDIDATE)) { const end = (match.index ?? 0) + match[0].length const terminated = end < plain.length && !TOKEN_CHAR.test(plain[end]) - if ((terminated || final) && CLAUDE_SETUP_TOKEN_PATTERN.test(match[0])) { - // The last complete one wins: a redraw repeats the same token. + if ( + (terminated || final) && + CLAUDE_SETUP_TOKEN_PATTERN.test(match[0]) && + match[0].length > (found?.length ?? 0) + ) { found = match[0] } } @@ -96,8 +106,15 @@ export function runClaudeSetupToken(options: { let exited = false let scanned = '' let linkSeen = false + let best: string | null = null let timer: ReturnType | null = null + let settleTimer: ReturnType | null = null let graceTimer: ReturnType | null = null + const remember = (token: string | null): void => { + if (token && token.length > (best?.length ?? 0)) { + best = token + } + } const kill = (): void => { if (child && !exited) { @@ -116,9 +133,13 @@ export function runClaudeSetupToken(options: { if (timer) { clearTimeout(timer) } + if (settleTimer) { + clearTimeout(settleTimer) + } signal.removeEventListener('abort', onAbort) // Nothing of the output outlives the run. scanned = '' + best = null if (stop === 'kill') { kill() } else if (stop === 'grace' && !exited) { @@ -146,9 +167,17 @@ export function runClaudeSetupToken(options: { return } scanned = `${scanned}${chunk}`.slice(-MAX_SCAN_CHARS) - const token = findSetupToken(scanned, false) - if (token) { - finish({ ok: true, token }, 'grace') + remember(findSetupToken(scanned, false)) + if (best && !settleTimer) { + settleTimer = setTimeout(() => { + const token = best + if (token) { + finish({ ok: true, token }, 'grace') + } + }, SETTLE_MS) + settleTimer.unref?.() + } + if (best) { return } if (DENIED.test(plainText(scanned))) { @@ -168,7 +197,10 @@ export function runClaudeSetupToken(options: { if (graceTimer) { clearTimeout(graceTimer) } - const token = settled ? null : findSetupToken(scanned, true) + if (!settled) { + remember(findSetupToken(scanned, true)) + } + const token = best finish(token ? { ok: true, token } : { ok: false, error: 'capture_failed' }, 'none') }) }, diff --git a/src/main/orca-cloud-agents/codex-broker-driver.test.ts b/src/main/orca-cloud-agents/codex-broker-driver.test.ts index 9b34cef2612..774be4bacc9 100644 --- a/src/main/orca-cloud-agents/codex-broker-driver.test.ts +++ b/src/main/orca-cloud-agents/codex-broker-driver.test.ts @@ -9,11 +9,14 @@ const START = { expiresAt: 1_000 } -function harness(statuses: { status: 'pending' | 'active' | 'failed'; error?: string }[]) { +function harness( + statuses: { status: 'pending' | 'active' | 'failed'; error?: string }[], + start = START +) { let clock = 0 const updates: OrcaCloudAgentSetup[] = [] const client = { - startCodexBroker: vi.fn(async () => START), + startCodexBroker: vi.fn(async () => start), codexBrokerStatus: vi.fn(async () => statuses.shift() ?? { status: 'pending' as const }), cancelCodexBroker: vi.fn(async () => {}) } @@ -61,6 +64,27 @@ describe('startCodexBrokerSession', () => { expect(updates.map((u) => u.phase)).not.toContain('saved') }) + it('never shows a sign-in page that is not OpenAI', async () => { + const { session, updates, client } = harness([], { + ...START, + verificationUrl: 'https://evil.example/device' + }) + await session.done + expect(updates.map((u) => u.phase)).toEqual(['preparing', 'failed']) + expect(client.cancelCodexBroker).toHaveBeenCalledWith('brk_1') + }) + + it('caps the wait even if the broker says the code lasts for days', async () => { + const { session, updates } = harness( + Array.from({ length: 30 }, () => ({ status: 'pending' as const })), + { ...START, expiresAt: Number.MAX_SAFE_INTEGER } + ) + // The fake clock moves 100 ms per poll, so cap it via a short wait in this test. + await Promise.race([session.done, new Promise((resolve) => setTimeout(resolve, 50))]) + await session.cancel() + expect(updates[1]).toMatchObject({ phase: 'device', expiresAt: 20 * 60_000 }) + }) + it('maps a refused start to a code', async () => { const updates: OrcaCloudAgentSetup[] = [] const session = startCodexBrokerSession({ diff --git a/src/main/orca-cloud-agents/codex-broker-driver.ts b/src/main/orca-cloud-agents/codex-broker-driver.ts index f6e2b43991a..25251afb3a2 100644 --- a/src/main/orca-cloud-agents/codex-broker-driver.ts +++ b/src/main/orca-cloud-agents/codex-broker-driver.ts @@ -10,6 +10,24 @@ import { type AgentCredentialsClient } from './agent-credentials-client' +/** The broker's sign-in page must be OpenAI's; anything else is never shown or opened. */ +export function isCodexSignInUrl(value: string): boolean { + let url: URL + try { + url = new URL(value) + } catch { + return false + } + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + (url.hostname === 'openai.com' || + url.hostname.endsWith('.openai.com') || + url.hostname === 'chatgpt.com') + ) +} + export type CodexBrokerDriverDeps = { client: Pick< AgentCredentialsClient, @@ -22,6 +40,8 @@ export type CodexBrokerDriverDeps = { } const POLL_MS = 3_000 +// Whatever the broker says, a device code is never waited on for longer than this. +const MAX_WAIT_MS = 20 * 60_000 // A transient poll failure is retried; this many in a row ends the setup. const MAX_POLL_FAILURES = 5 const BROKER_FAILURES = new Set(['not_a_chatgpt_login', 'store_failed', 'cancelled']) @@ -74,12 +94,18 @@ export function startCodexBrokerSession(deps: CodexBrokerDriverDeps): CodexBroke await deps.client.cancelCodexBroker(started.brokerId).catch(() => undefined) return } + if (!isCodexSignInUrl(started.verificationUrl)) { + await deps.client.cancelCodexBroker(started.brokerId).catch(() => undefined) + deps.onUpdate({ phase: 'failed', error: 'broker_unavailable' }) + return + } + const deadline = Math.min(started.expiresAt, startedAt + MAX_WAIT_MS) deps.onUpdate({ phase: 'device', startedAt, userCode: started.userCode, verificationUrl: started.verificationUrl, - expiresAt: started.expiresAt + expiresAt: deadline }) let failures = 0 while (!controller.signal.aborted) { @@ -87,7 +113,7 @@ export function startCodexBrokerSession(deps: CodexBrokerDriverDeps): CodexBroke if (controller.signal.aborted) { return } - if (now() >= started.expiresAt) { + if (now() >= deadline) { await deps.client.cancelCodexBroker(started.brokerId).catch(() => undefined) deps.onUpdate({ phase: 'failed', error: 'code_expired' }) return diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts index f7fe6c563f7..e4db0f700cd 100644 --- a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts +++ b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts @@ -9,6 +9,8 @@ import { import { withCloudBuildDefaults } from '../cloud-build-defaults' import { getHostedSandboxConfig } from '../hosted-sandbox/hosted-sandbox-config' import { claudeStateFile, readClaudeFolderLogin } from '../claude-accounts/claude-account-folder' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { getOrcaProfileIndexGeneration } from '../orca-profiles/profile-index-generation' import { createAgentCredentialsClient } from './agent-credentials-client' import { spawnClaudeSetupTokenPty } from './claude-setup-token-pty' import { @@ -53,6 +55,26 @@ function readClaudeEmail(): string | null { } } +/** The signed-in Orca user, re-read only when the profile index changes (sign-in, sign-out, switch). */ +function orcaIdentityReader(userDataPath: string): () => string | null { + let cached: { generation: number; identity: string | null } | null = null + return () => { + const generation = getOrcaProfileIndexGeneration() + if (cached?.generation !== generation) { + let identity: string | null = null + try { + const active = ensureActiveOrcaProfile(userDataPath) + const userId = active.profile.cloud?.userId + identity = userId ? `${active.profile.id}:${userId}` : null + } catch { + identity = null + } + cached = { generation, identity } + } + return cached.identity + } +} + // One per app: a setup running when a window reattaches keeps going instead of being orphaned. let shared: OrcaCloudAgentsService | null = null @@ -75,7 +97,8 @@ function serviceFor(userDataPath: string): OrcaCloudAgentsService { spawnSetupToken: spawnClaudeSetupTokenPty, readClaudeEmail, openExternal: (url) => shell.openExternal(url), - broadcast + broadcast, + identity: orcaIdentityReader(userDataPath) }) return shared } diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts index a5ef9448ba9..72142dd61bb 100644 --- a/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts +++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts @@ -73,7 +73,7 @@ describe('orca cloud agents service', () => { vi.restoreAllMocks() }) - function setup(client = fakeClient()) { + function setup(client = fakeClient(), identity: () => string | null = () => 'p1:u1') { const child = fakeChild() const broadcasts: OrcaCloudAgentSetups[] = [] const openExternal = vi.fn(async () => {}) @@ -82,7 +82,8 @@ describe('orca cloud agents service', () => { spawnSetupToken: async () => child, readClaudeEmail: () => 'a@acme.dev', openExternal, - broadcast: (setups) => broadcasts.push(setups) + broadcast: (setups) => broadcasts.push(setups), + identity }) return { service, child, broadcasts, openExternal, client } } @@ -104,6 +105,7 @@ describe('orca cloud agents service', () => { expect(await service.reopen('claude')).toBe(true) expect(openExternal).toHaveBeenCalledWith(AUTHORIZE) child.emit(`Your OAuth token (valid for 1 year):\n\n${TOKEN}\n\nStore this token`) + child.exit() await tick() expect(client.putClaude).toHaveBeenCalledWith(TOKEN, 'a@acme.dev') expect(service.setups().claude).toEqual({ phase: 'saved' }) @@ -158,6 +160,43 @@ describe('orca cloud agents service', () => { expect(service.setups().claude).toEqual({ phase: 'idle' }) }) + it('drops a captured token when the Orca account changed during setup', async () => { + let who = 'p1:u1' + const { service, child, client, broadcasts } = setup(fakeClient(), () => who) + service.start('claude') + await tick() + who = 'p1:u2' + child.emit(`${TOKEN}\n`) + child.exit() + await tick() + expect(client.putClaude).not.toHaveBeenCalled() + expect(broadcasts.at(-1)?.claude).toEqual({ phase: 'failed', error: 'signed_out' }) + // The next read under the new account starts clean. + expect(service.setups().claude).toEqual({ phase: 'idle' }) + }) + + it('does not let cancel or a paste race a token that is being stored', async () => { + let release: () => void = () => {} + const client = fakeClient({ + putClaude: vi.fn( + () => + new Promise((resolve) => { + release = () => resolve(VIEW) + }) + ) + }) + const { service } = setup(client) + const first = service.pasteClaudeToken(TOKEN) + await tick() + expect(service.setups().claude).toEqual({ phase: 'saving' }) + await service.cancel('claude') + expect(await service.pasteClaudeToken(TOKEN)).toEqual({ ok: false, error: 'unavailable' }) + expect(service.setups().claude).toEqual({ phase: 'saving' }) + release() + expect(await first).toEqual({ ok: true, value: true }) + expect(client.putClaude).toHaveBeenCalledTimes(1) + }) + it('reports not_configured without a control plane', () => { const service = createOrcaCloudAgentsService({ client: null, diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-service.ts b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts index af630e0f102..e37e4dc648f 100644 --- a/src/main/orca-cloud-agents/orca-cloud-agents-service.ts +++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts @@ -18,7 +18,11 @@ import { type AgentCredentialsClient } from './agent-credentials-client' import { runClaudeSetupToken, type SpawnSetupToken } from './claude-setup-token-runner' -import { startCodexBrokerSession, type CodexBrokerSession } from './codex-broker-driver' +import { + isCodexSignInUrl, + startCodexBrokerSession, + type CodexBrokerSession +} from './codex-broker-driver' export type OrcaCloudAgentsServiceDeps = { client: AgentCredentialsClient | null @@ -27,6 +31,11 @@ export type OrcaCloudAgentsServiceDeps = { readClaudeEmail: () => string | null openExternal: (url: string) => Promise broadcast: (setups: OrcaCloudAgentSetups) => void + /** + * Who is signed in to Orca (null when signed out). A setup belongs to the identity it started + * under: if that changes, its result is dropped instead of landing in another Orca account. + */ + identity?: () => string | null now?: () => number codexPollMs?: number } @@ -49,11 +58,7 @@ export function isProviderSignInUrl(agent: OrcaCloudAccountAgent, value: string) if (agent === 'claude') { return CLAUDE_REOPEN_HOSTS.has(url.hostname) } - return ( - url.hostname === 'openai.com' || - url.hostname.endsWith('.openai.com') || - url.hostname === 'chatgpt.com' - ) + return isCodexSignInUrl(value) } export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { @@ -63,10 +68,49 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { let claudeUrl: string | null = null let codexSession: CodexBrokerSession | null = null let codexRun: { live: boolean } | null = null + // A captured or pasted token is being stored: it can't be cancelled or raced by another one. + let claudeSaving = false + const identity = deps.identity ?? (() => null) + let setupsIdentity = identity() const update = (agent: OrcaCloudAccountAgent, setup: OrcaCloudAgentSetup): void => { setups = { ...setups, [agent]: setup } - deps.broadcast(setups) + try { + deps.broadcast(setups) + } catch { + // A closing window must not break the setup itself. + } + } + + function stopClaudeRun(): void { + claudeRun?.abort() + claudeRun = null + claudeUrl = null + } + + function stopCodexRun(): CodexBrokerSession | null { + const session = codexSession + codexSession = null + if (codexRun) { + codexRun.live = false + codexRun = null + } + return session + } + + /** Signing out or switching Orca accounts ends any setup that started under the old one. */ + function followIdentity(): void { + const current = identity() + if (current === setupsIdentity) { + return + } + setupsIdentity = current + stopClaudeRun() + void stopCodexRun()?.cancel() + if (!claudeSaving) { + update('claude', { phase: 'idle' }) + } + update('codex', { phase: 'idle' }) } async function attempt( @@ -88,21 +132,29 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { /** Stores a Claude token in the Orca account; the token goes out once and is dropped here. */ async function storeClaude( token: string, - label: string | null + label: string | null, + owner: string | null ): Promise> { + if (identity() !== owner) { + update('claude', { phase: 'failed', error: 'signed_out' }) + return { ok: false, error: 'signed_out' } + } + claudeSaving = true update('claude', { phase: 'saving' }) const result = await attempt(async (client) => { await client.putClaude(token, label ?? undefined) return true as const }) + claudeSaving = false update('claude', result.ok ? { phase: 'saved' } : { phase: 'failed', error: result.error }) return result } function startClaude(): void { - if (claudeRun) { + if (claudeRun || claudeSaving) { return } + const owner = identity() const controller = new AbortController() claudeRun = controller claudeUrl = null @@ -124,7 +176,7 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { claudeRun = null claudeUrl = null if (outcome.ok) { - await storeClaude(outcome.token, deps.readClaudeEmail()) + await storeClaude(outcome.token, deps.readClaudeEmail(), owner) return } switch (outcome.error) { @@ -149,14 +201,21 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { // Updates from a cancelled run are dropped (the driver reports its first step synchronously). const run = { live: true } codexRun = run + const owner = identity() const session = startCodexBrokerSession({ client: deps.client!, now, ...(deps.codexPollMs ? { pollMs: deps.codexPollMs } : {}), onUpdate: (setup) => { - if (run.live) { - update('codex', setup) + if (!run.live) { + return } + if (identity() !== owner) { + void stopCodexRun()?.cancel() + update('codex', { phase: 'failed', error: 'signed_out' }) + return + } + update('codex', setup) } }) codexSession = session @@ -168,18 +227,24 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { } return { - setups: (): OrcaCloudAgentSetups => setups, + setups: (): OrcaCloudAgentSetups => { + followIdentity() + return setups + }, - status: (): Promise> => - attempt(async (client) => { + status: (): Promise> => { + followIdentity() + return attempt(async (client) => { const [credentials, sources] = await Promise.all([client.credentials(), client.sources()]) return { credentials, sources } - }), + }) + }, start: (agent: OrcaCloudAccountAgent): OrcaCloudAgentsResult => { if (!deps.client) { return { ok: false, error: 'not_configured' } } + followIdentity() if (agent === 'claude') { startClaude() } else { @@ -190,18 +255,15 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { cancel: async (agent: OrcaCloudAccountAgent): Promise => { if (agent === 'claude') { - claudeRun?.abort() - claudeRun = null - claudeUrl = null + // A token already on its way to the Orca account finishes; the row shows the result. + if (claudeSaving) { + return + } + stopClaudeRun() update('claude', { phase: 'idle' }) return } - const session = codexSession - codexSession = null - if (codexRun) { - codexRun.live = false - codexRun = null - } + const session = stopCodexRun() update('codex', { phase: 'idle' }) await session?.cancel() }, @@ -220,9 +282,10 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { /** "Paste the token instead": stop setup-token and show the masked field. */ usePaste: (): void => { - claudeRun?.abort() - claudeRun = null - claudeUrl = null + if (claudeSaving) { + return + } + stopClaudeRun() update('claude', { phase: 'paste', reason: 'chosen' }) }, @@ -232,10 +295,12 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) { if (!CLAUDE_SETUP_TOKEN_PATTERN.test(token)) { return { ok: false, error: 'invalid_token' } } - claudeRun?.abort() - claudeRun = null + if (claudeSaving) { + return { ok: false, error: 'unavailable' } + } + stopClaudeRun() // A pasted token may come from another account, so no label: the server names it. - const result = await storeClaude(token, null) + const result = await storeClaude(token, null, identity()) if (!result.ok && result.error === 'invalid_token') { // Keep the field open so the person can paste again. update('claude', { phase: 'paste', reason: 'chosen' }) diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx index ade8fbfe232..f7e68e2ae8e 100644 --- a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx @@ -58,7 +58,7 @@ function LoadingRows(): React.JSX.Element { {ORCA_CLOUD_ACCOUNT_AGENTS.map((agent) => (
  • {agentName(agent, t)}

    diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx index 4dd103da391..731dc3391e6 100644 --- a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccountsSection.tsx @@ -42,8 +42,8 @@ export function OrcaCloudAccountsSection(): React.JSX.Element | null { if (!api) { return 'not_configured' } - const result = await api.pasteClaudeToken({ token }) - return result.ok ? null : result.error + const result = await api.pasteClaudeToken({ token }).catch(() => null) + return result === null ? 'unknown' : result.ok ? null : result.error }, remove: async (agent) => { if (!api) { diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx index 7874464e747..d7a4a5cfd82 100644 --- a/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx @@ -35,11 +35,15 @@ function PasteTokenForm({ return } setSaving(true) - const result = await onPaste(value) - setSaving(false) - // The field is cleared either way: a rejected value isn't kept around in the renderer. - setValue('') - setError(result) + let result: OrcaCloudAgentsErrorCode | null = 'unknown' + try { + result = await onPaste(value) + } finally { + setSaving(false) + // Cleared whatever happens: a value that wasn't stored isn't kept around in the renderer. + setValue('') + setError(result) + } } return (
    void submit(event)}> diff --git a/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx b/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx index a6e5dd2caeb..ba2bd2214c3 100644 --- a/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx +++ b/src/renderer/src/components/settings/agent-accounts/orca-cloud-account-row-shell.tsx @@ -61,7 +61,7 @@ export function RowShell({ return (
  • {agentName(agent, t)}

    diff --git a/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts b/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts index 9cc6d622efc..500b6ad958a 100644 --- a/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts +++ b/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts @@ -57,7 +57,8 @@ export const AgentSourcesResponseSchema = z.looseObject({ export type AgentSourcesView = z.infer export const CodexBrokerStartResponseSchema = z.object({ - brokerId: z.string().min(1).max(128), + // Used in a request path: a leading letter or digit, so never '.' or '..'. + brokerId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/), verificationUrl: z.string().url().max(512), userCode: z.string().min(1).max(64), expiresAt: z.number().finite() From f498cea0feaebea8e5520d267319d526b4c0da32 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 08:11:24 -0800 Subject: [PATCH 3/5] feat(cloud): the setup panel's Agent row (G) reads the S0 model; panel G fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged Set up Orca Cloud panel's 'agent' step now comes from the same row model as the standalone row: done with 'Claude · from your Orca account' / 'Codex · team key', Set up once / Renew to S0, the kill-switch info line, signed-out copy, loading; hidden when nothing there helps. Drops the panel's placeholder copy keys. --- .../run-on/CloudStartAgentRow.fixture.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx new file mode 100644 index 00000000000..35823b4b3f3 --- /dev/null +++ b/src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx @@ -0,0 +1,33 @@ +// Cloud-start G inside the "Set up Orca Cloud" panel: the Agent row from the Orca account's setup. +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { translate } from '@/i18n/i18n' +import type { OrcaCloudAgentSetupRowModel } from '@/lib/orca-cloud/orca-cloud-agent-setup-row' +import { OrcaCloudSetupPanel } from './OrcaCloudSetupPanel' +import { cloudStartAgentStep } from './cloud-start-agent-step' +import type { CloudSetupStep } from './cloud-start-model' + +const BASE: CloudSetupStep[] = [ + { id: 'account', state: 'done', detail: 'neil@acme.dev' }, + { id: 'github', state: 'needed', need: 'github', canFix: true } +] + +function panel(row: OrcaCloudAgentSetupRowModel) { + return defineFixture({ + width: 560, + render: () => { + const agent = cloudStartAgentStep({ id: 'agent', state: 'optional' }, row, translate) + return ( +
    + +
    + ) + } + }) +} + +export const gFromAccount = panel({ kind: 'covered', agent: 'claude', via: 'account' }) +export const gTeamKey = panel({ kind: 'covered', agent: 'codex', via: 'team' }) +export const gNotSetUp = panel({ kind: 'notSetUp', agent: 'claude' }) +export const gKillSwitch = panel({ kind: 'killSwitch', agent: 'claude' }) +export const gSignedOut = panel({ kind: 'signedOut', agent: 'claude' }) +export const gLoading = panel({ kind: 'loading', agent: 'claude' }) From e20737161875172b6ae5d4863810d11c3e5370a9 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 08:14:31 -0800 Subject: [PATCH 4/5] chore(cloud): S0 lint gates: no casts, exhaustive switches, fake-clock broker test --- src/main/hosted-sandbox/hosted-sandbox-request.ts | 2 +- .../agent-credentials-client.test.ts | 2 +- .../orca-cloud-agents/claude-setup-token-pty.ts | 15 ++++++++++++--- .../claude-setup-token-runner.ts | 4 +--- .../orca-cloud-agents/codex-broker-driver.test.ts | 14 ++++++++------ src/main/orca-cloud-agents/codex-broker-driver.ts | 8 ++++---- .../OrcaCloudAgentSetupRow.fixture.tsx | 7 +++++-- .../new-workspace/OrcaCloudAgentSetupRow.tsx | 6 +++++- 8 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/main/hosted-sandbox/hosted-sandbox-request.ts b/src/main/hosted-sandbox/hosted-sandbox-request.ts index b895f5fd831..b4023df9b2a 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-request.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-request.ts @@ -142,7 +142,7 @@ const SERVER_NAME = /^[a-z][a-z0-9_]{0,63}$/ /** The `error` name of any error body, if it looks like a name (never free text). */ function serverErrorName(payload: unknown): string | null { const error = - typeof payload === 'object' && payload !== null ? (payload as { error?: unknown }).error : null + typeof payload === 'object' && payload !== null && 'error' in payload ? payload.error : null return typeof error === 'string' && SERVER_NAME.test(error) ? error : null } diff --git a/src/main/orca-cloud-agents/agent-credentials-client.test.ts b/src/main/orca-cloud-agents/agent-credentials-client.test.ts index f2d08b840d4..136a74f6c3c 100644 --- a/src/main/orca-cloud-agents/agent-credentials-client.test.ts +++ b/src/main/orca-cloud-agents/agent-credentials-client.test.ts @@ -81,7 +81,7 @@ describe('createAgentCredentialsClient', () => { i < 2 ? client.putClaude(TOKEN) : client.startCodexBroker() ) expect(JSON.stringify(error)).not.toContain(TOKEN) - expect(String((error as Error).message)).not.toContain(TOKEN) + expect(error instanceof Error ? error.message : '').not.toContain(TOKEN) codes.push(agentCredentialsFailureCode(error)) } expect(codes).toEqual([ diff --git a/src/main/orca-cloud-agents/claude-setup-token-pty.ts b/src/main/orca-cloud-agents/claude-setup-token-pty.ts index 6a69ee74bd6..8657255a2ff 100644 --- a/src/main/orca-cloud-agents/claude-setup-token-pty.ts +++ b/src/main/orca-cloud-agents/claude-setup-token-pty.ts @@ -38,7 +38,14 @@ export const spawnClaudeSetupTokenPty: SpawnSetupToken = async () => { // Like Orca's other Claude children: no inherited auth overrides, no parent session stamps. const env: NodeJS.ProcessEnv = buildClaudeChildProcessEnv() stripInheritedClaudeSessionEnv(env) - const childEnv = withCliRuntimeOnPath(command, { ...env, TERM: 'xterm-256color' }) + const childEnv: Record = {} + for (const [key, value] of Object.entries( + withCliRuntimeOnPath(command, { ...env, TERM: 'xterm-256color' }) + )) { + if (value !== undefined) { + childEnv[key] = value + } + } const { file, args } = setupTokenInvocation(command) const pty = await import('node-pty') let child: ReturnType @@ -48,10 +55,12 @@ export const spawnClaudeSetupTokenPty: SpawnSetupToken = async () => { cols: COLUMNS, rows: 40, cwd: homedir(), - env: childEnv as Record + env: childEnv }) } catch (error) { - throw (error as { code?: unknown })?.code === 'ENOENT' ? enoent() : error + const missing = + typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' + throw missing ? enoent() : error } const handle: SetupTokenProcess = { onData: (listener) => { diff --git a/src/main/orca-cloud-agents/claude-setup-token-runner.ts b/src/main/orca-cloud-agents/claude-setup-token-runner.ts index d88467ef74d..1f24ce5761a 100644 --- a/src/main/orca-cloud-agents/claude-setup-token-runner.ts +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.ts @@ -206,9 +206,7 @@ export function runClaudeSetupToken(options: { }, (error: unknown) => { const missing = - typeof error === 'object' && - error !== null && - (error as { code?: unknown }).code === 'ENOENT' + typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' finish({ ok: false, error: missing ? 'no_cli' : 'capture_failed' }, 'none') } ) diff --git a/src/main/orca-cloud-agents/codex-broker-driver.test.ts b/src/main/orca-cloud-agents/codex-broker-driver.test.ts index 774be4bacc9..9078792928b 100644 --- a/src/main/orca-cloud-agents/codex-broker-driver.test.ts +++ b/src/main/orca-cloud-agents/codex-broker-driver.test.ts @@ -11,7 +11,8 @@ const START = { function harness( statuses: { status: 'pending' | 'active' | 'failed'; error?: string }[], - start = START + start = START, + stepMs = 100 ) { let clock = 0 const updates: OrcaCloudAgentSetup[] = [] @@ -25,7 +26,7 @@ function harness( onUpdate: (setup) => updates.push(setup), now: () => clock, sleep: async () => { - clock += 100 + clock += stepMs } }) return { session, updates, client } @@ -75,14 +76,15 @@ describe('startCodexBrokerSession', () => { }) it('caps the wait even if the broker says the code lasts for days', async () => { + // Each fake poll moves the clock a minute: the 20-minute cap ends it, not the broker's date. const { session, updates } = harness( Array.from({ length: 30 }, () => ({ status: 'pending' as const })), - { ...START, expiresAt: Number.MAX_SAFE_INTEGER } + { ...START, expiresAt: Number.MAX_SAFE_INTEGER }, + 60_000 ) - // The fake clock moves 100 ms per poll, so cap it via a short wait in this test. - await Promise.race([session.done, new Promise((resolve) => setTimeout(resolve, 50))]) - await session.cancel() + await session.done expect(updates[1]).toMatchObject({ phase: 'device', expiresAt: 20 * 60_000 }) + expect(updates.at(-1)).toEqual({ phase: 'failed', error: 'code_expired' }) }) it('maps a refused start to a code', async () => { diff --git a/src/main/orca-cloud-agents/codex-broker-driver.ts b/src/main/orca-cloud-agents/codex-broker-driver.ts index 25251afb3a2..a37ec5c6c66 100644 --- a/src/main/orca-cloud-agents/codex-broker-driver.ts +++ b/src/main/orca-cloud-agents/codex-broker-driver.ts @@ -44,7 +44,9 @@ const POLL_MS = 3_000 const MAX_WAIT_MS = 20 * 60_000 // A transient poll failure is retried; this many in a row ends the setup. const MAX_POLL_FAILURES = 5 -const BROKER_FAILURES = new Set(['not_a_chatgpt_login', 'store_failed', 'cancelled']) +function isBrokerFailure(error: string): error is 'not_a_chatgpt_login' | 'store_failed' { + return error === 'not_a_chatgpt_login' || error === 'store_failed' +} function defaultSleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { @@ -144,9 +146,7 @@ export function startCodexBrokerSession(deps: CodexBrokerDriverDeps): CodexBroke ? { phase: 'idle' } : { phase: 'failed', - error: BROKER_FAILURES.has(error) - ? (error as 'not_a_chatgpt_login' | 'store_failed') - : 'broker_unavailable' + error: isBrokerFailure(error) ? error : 'broker_unavailable' } ) return diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx index 8071c9797e7..8bda93b1503 100644 --- a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx @@ -8,8 +8,11 @@ function fixture(...rows: OrcaCloudAgentSetupRowModel[]) { width: 520, render: () => (
    - {rows.map((row, index) => ( - + {rows.map((row) => ( + ))}
    ) diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx index 9ba64db519f..b53bee011cf 100644 --- a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx @@ -110,7 +110,11 @@ function RowIcon({ kind }: { kind: OrcaCloudAgentSetupRowModel['kind'] }): React return case 'killSwitch': return - default: + case 'covered': + case 'hidden': + case 'loading': + case 'notSetUp': + case 'signedOut': return } } From f196d901a0f6c2ebf606e6a6f4d586c4395178b4 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 08:21:41 -0800 Subject: [PATCH 5/5] fix(cloud): design-squad on #282: one 'Copy code and open' for the Codex device code; panel row actions are outline xs buttons --- .../new-workspace/run-on/OrcaCloudSetupPanel.tsx | 6 ++++-- .../settings/agent-accounts/OrcaCloudSetupRow.tsx | 12 ++++++------ src/renderer/src/i18n/locales/en.json | 5 ++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx b/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx index 2db0a75bea0..852a524db46 100644 --- a/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx +++ b/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx @@ -260,10 +260,12 @@ export function OrcaCloudSetupPanel({ {note} ) : null} {step.action ? ( + // One shared in-row action ("Set up once", "How to push"): it must read as a button. - {cancel} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 894cdef1467..64343197cac 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27615,8 +27615,6 @@ "deviceEnter": "Enter", "deviceOn": "on {{host}}", "deviceGuard": "Only continue if you started this sign-in in Orca.", - "copyCode": "Copy code", - "openPage": "Open page", "pasteInstead": "Paste the token instead", "saving": "Saving to your Orca account…", "yourAccount": "Your {{product}} account", @@ -27700,6 +27698,7 @@ "team": "team key", "repository": "repository key", "personal": "your API key" - } + }, + "copyAndOpen": "Copy code and open" } }