diff --git a/src/main/hosted-sandbox/hosted-sandbox-request.ts b/src/main/hosted-sandbox/hosted-sandbox-request.ts index 8fecc70ed76..b4023df9b2a 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 && 'error' in payload ? payload.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..136a74f6c3c --- /dev/null +++ b/src/main/orca-cloud-agents/agent-credentials-client.test.ts @@ -0,0 +1,145 @@ +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(error instanceof Error ? 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() + 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 () => { + 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..20a1ebc981d --- /dev/null +++ b/src/main/orca-cloud-agents/agent-credentials-client.ts @@ -0,0 +1,121 @@ +// 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({}) +// 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)) { + 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 = 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 { + if (!isEnvironmentProviderError(error)) { + return 'unknown' + } + const serverError = error instanceof HostedSandboxRefusalError ? error.serverError : null + const named = serverError ? SERVER_CODES.get(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..8657255a2ff --- /dev/null +++ b/src/main/orca-cloud-agents/claude-setup-token-pty.ts @@ -0,0 +1,75 @@ +// 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' + +const COLUMNS = 1000 + +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() + } + // Like Orca's other Claude children: no inherited auth overrides, no parent session stamps. + const env: NodeJS.ProcessEnv = buildClaudeChildProcessEnv() + stripInheritedClaudeSessionEnv(env) + 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 + try { + child = pty.spawn(file, args, { + name: 'xterm-256color', + cols: COLUMNS, + rows: 40, + cwd: homedir(), + env: childEnv + }) + } catch (error) { + const missing = + typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' + throw missing ? enoent() : error + } + const handle: SetupTokenProcess = { + onData: (listener) => { + child.onData(listener) + }, + onExit: (listener) => { + child.onExit(({ exitCode }) => listener(exitCode)) + }, + kill: () => child.kill() + } + 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 new file mode 100644 index 00000000000..2d6de9c7c02 --- /dev/null +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.test.ts @@ -0,0 +1,181 @@ +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: 10_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('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', () => { + 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 () => { + 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 () => { + 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(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 new file mode 100644 index 00000000000..1f24ce5761a --- /dev/null +++ b/src/main/orca-cloud-agents/claude-setup-token-runner.ts @@ -0,0 +1,214 @@ +// 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 +// 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 +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(INLINE_ESCAPE, '').replace(TERMINAL_ESCAPE, '\n') +} + +/** + * 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) + 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]) && + match[0].length > (found?.length ?? 0) + ) { + 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 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) { + try { + child.kill() + } catch { + // Already gone. + } + } + } + const finish = (outcome: ClaudeSetupTokenOutcome, stop: 'kill' | 'grace' | 'none'): void => { + if (settled) { + return + } + settled = true + 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) { + 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) + 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))) { + 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) + } + if (!settled) { + remember(findSetupToken(scanned, true)) + } + const token = best + finish(token ? { ok: true, token } : { ok: false, error: 'capture_failed' }, 'none') + }) + }, + (error: unknown) => { + const missing = + 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 new file mode 100644 index 00000000000..9078792928b --- /dev/null +++ b/src/main/orca-cloud-agents/codex-broker-driver.test.ts @@ -0,0 +1,105 @@ +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 }[], + start = START, + stepMs = 100 +) { + 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 += stepMs + } + }) + 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('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 () => { + // 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 }, + 60_000 + ) + 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 () => { + 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..a37ec5c6c66 --- /dev/null +++ b/src/main/orca-cloud-agents/codex-broker-driver.ts @@ -0,0 +1,170 @@ +// 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' + +/** 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, + 'startCodexBroker' | 'codexBrokerStatus' | 'cancelCodexBroker' + > + onUpdate: (setup: OrcaCloudAgentSetup) => void + now?: () => number + sleep?: (ms: number, signal: AbortSignal) => Promise + pollMs?: number +} + +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 +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) => { + 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 + } + 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: deadline + }) + let failures = 0 + while (!controller.signal.aborted) { + await sleep(pollMs, controller.signal) + if (controller.signal.aborted) { + return + } + if (now() >= deadline) { + 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: isBrokerFailure(error) ? error : '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..e4db0f700cd --- /dev/null +++ b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts @@ -0,0 +1,151 @@ +// 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 { 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 { + 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 + } +} + +/** 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 + +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, + identity: orcaIdentityReader(userDataPath) + }) + 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..72142dd61bb --- /dev/null +++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts @@ -0,0 +1,218 @@ +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(), identity: () => string | null = () => 'p1:u1') { + 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), + identity + }) + 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`) + child.exit() + 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('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, + 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..e37e4dc648f --- /dev/null +++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts @@ -0,0 +1,328 @@ +// 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 { + isCodexSignInUrl, + 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 + /** + * 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 +} + +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 isCodexSignInUrl(value) +} + +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 + // 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 } + 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( + 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, + 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 || claudeSaving) { + return + } + const owner = identity() + 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(), owner) + 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 owner = identity() + const session = startCodexBrokerSession({ + client: deps.client!, + now, + ...(deps.codexPollMs ? { pollMs: deps.codexPollMs } : {}), + onUpdate: (setup) => { + if (!run.live) { + return + } + if (identity() !== owner) { + void stopCodexRun()?.cancel() + update('codex', { phase: 'failed', error: 'signed_out' }) + return + } + update('codex', setup) + } + }) + codexSession = session + void session.done.finally(() => { + if (codexSession === session) { + codexSession = null + } + }) + } + + return { + setups: (): OrcaCloudAgentSetups => { + followIdentity() + return setups + }, + + 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 { + startCodex() + } + return { ok: true, value: true } + }, + + cancel: async (agent: OrcaCloudAccountAgent): Promise => { + if (agent === 'claude') { + // 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 = stopCodexRun() + 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 => { + if (claudeSaving) { + return + } + stopClaudeRun() + 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' } + } + 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, identity()) + 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 6e10d81777e..63cb1e6042e 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' @@ -21,7 +21,7 @@ import { RunOnHintLine } from './run-on/RunOnHintLine' import { OrcaCloudSetupPanel } from './run-on/OrcaCloudSetupPanel' 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, @@ -44,25 +44,26 @@ type NewWorkspaceComposerProjectSectionProps = Pick< | 'ephemeralVmRecipeError' | 'orcaCloudAvailability' | 'cloudStart' -> & { - 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, @@ -99,6 +100,7 @@ export function NewWorkspaceComposerProjectSection({ connectButtonLabel, selectedProjectName, orcaCloudAccess, + quickAgent = null, cloudStart = null }: NewWorkspaceComposerProjectSectionProps): React.JSX.Element { const cloudOnly = @@ -246,11 +248,14 @@ export function NewWorkspaceComposerProjectSection({ ) : null} {cloudStart?.panelVisible && selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? ( + // The panel's own Agent row (G) comes from the same model as the standalone one below. - ) : // 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) without the panel: 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..8bda93b1503 --- /dev/null +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.fixture.tsx @@ -0,0 +1,36 @@ +// 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) => ( + + ))} +
+ ) + }) +} + +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..b53bee011cf --- /dev/null +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx @@ -0,0 +1,203 @@ +// 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' }) +} + +export function orcaCloudAgentCoveredLabel( + 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) + }) +} + +/** The row's words and action, shared with the setup panel's Agent step. */ +export function orcaCloudAgentRowCopy( + 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 + case 'covered': + case 'hidden': + case 'loading': + case 'notSetUp': + case 'signedOut': + 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 = orcaCloudAgentCoveredLabel(row, t) + return ( +

+ + {label} + {text} +

+ ) + } + if (row.kind === 'loading') { + return ( +
+ + {t('orcaCloudAgentRow.checking', 'Checking {{agent}}', { + agent: agentName(row.agent, t) + })} + + + +
+ ) + } + const copy = orcaCloudAgentRowCopy(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/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' }) 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. + } + /> + ) + 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..f7e68e2ae8e --- /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..731dc3391e6 --- /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 }).catch(() => null) + return result === null ? 'unknown' : 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..5a381a4019f --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx @@ -0,0 +1,256 @@ +// 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) + 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)}> + +
    +
    + { + 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..ba2bd2214c3 --- /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 76c25a65590..bb2bf16b956 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": { @@ -23097,8 +23109,6 @@ "allSet": "All set", "title": "Set up Orca Cloud", "starting": "Starting your cloud workspace…", - "agentSetUpAfterSignIn": "{{agent}}: set up once in Settings → Agent accounts after you sign in", - "agentSetUpOnce": "{{agent}}: set up once in Settings → Agent accounts", "accountNeededTeam": "Sign in or create one. We’ll make a team for you.", "trial": "Trial", "trialNoCard": "No card needed. Nothing is charged until you choose a plan.", @@ -26987,7 +26997,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", @@ -27572,5 +27581,125 @@ } }, "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.", + "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" + }, + "copyAndOpen": "Copy code and open" } } 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..500b6ad958a --- /dev/null +++ b/src/shared/orca-cloud-agents/orca-cloud-agents-wire.ts @@ -0,0 +1,150 @@ +// 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({ + // 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() +}) +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