Merge pull request #282 from nwparker/cloud/agents-s0

feat(cloud): S0 Orca Cloud agent accounts + onboarding Agent row (G)
This commit is contained in:
Neil
2026-11-10 08:30:08 -08:00
committed by GitHub
53 changed files with 4787 additions and 140 deletions
@@ -115,6 +115,37 @@ const STATUS_CODES: Record<string, EnvironmentProviderErrorCode> = {
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
)
}
@@ -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<unknown>): Promise<unknown> {
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' })
})
})
})
@@ -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<AgentCredentialsView>
sources(signal?: AbortSignal): Promise<AgentSourcesView>
/** Main only, right after setup-token on this computer or a paste. */
putClaude(token: string, label?: string): Promise<AgentCredentialsView>
remove(agent: OrcaCloudAccountAgent): Promise<AgentCredentialsView>
setDefaultSource(agent: OrcaCloudAccountAgent, source: AgentSource): Promise<AgentSourcesView>
startCodexBroker(): Promise<CodexBrokerStart>
codexBrokerStatus(brokerId: string): Promise<CodexBrokerStatus>
cancelCodexBroker(brokerId: string): Promise<void>
}
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<string, OrcaCloudAgentsErrorCode>([
['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'
}
}
@@ -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<string, string> = {}
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<typeof pty.spawn>
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
}
@@ -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']
})
})
})
@@ -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<SetupTokenProcess>
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<ClaudeSetupTokenOutcome> {
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<typeof setTimeout> | null = null
let settleTimer: ReturnType<typeof setTimeout> | null = null
let graceTimer: ReturnType<typeof setTimeout> | 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')
}
)
})
}
@@ -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' })
})
})
@@ -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<void>
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<void> {
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<void>
/** Stops polling and cancels the broker on the control plane. */
cancel: () => Promise<void>
}
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<void> => {
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)
}
}
}
}
@@ -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
})
}
@@ -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> = {}): 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<void> => {
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<AgentCredentialsView>((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)
})
})
@@ -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<void>
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<typeof createOrcaCloudAgentsService>
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<T>(
run: (client: AgentCredentialsClient) => Promise<T>
): Promise<OrcaCloudAgentsResult<T>> {
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<OrcaCloudAgentsResult<true>> {
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<OrcaCloudAgentsResult<OrcaCloudAgentsStatus>> => {
followIdentity()
return attempt(async (client) => {
const [credentials, sources] = await Promise.all([client.credentials(), client.sources()])
return { credentials, sources }
})
},
start: (agent: OrcaCloudAccountAgent): OrcaCloudAgentsResult<true> => {
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<void> => {
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<boolean> => {
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<OrcaCloudAgentsResult<true>> => {
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<OrcaCloudAgentsResult<AgentCredentialsView>> =>
attempt((client) => client.remove(agent)),
setDefaultSource: (
agent: OrcaCloudAccountAgent,
source: AgentSource
): Promise<OrcaCloudAgentsResult<AgentSourcesView>> =>
attempt((client) => client.setDefaultSource(agent, source))
}
}
@@ -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,
+3
View File
@@ -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
@@ -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<OrcaCloudAgentsResult<OrcaCloudAgentsStatus>>
setups: () => Promise<OrcaCloudAgentSetups>
start: (request: { agent: OrcaCloudAccountAgent }) => Promise<OrcaCloudAgentsResult<true>>
cancel: (request: { agent: OrcaCloudAccountAgent }) => Promise<void>
reopen: (request: { agent: OrcaCloudAccountAgent }) => Promise<boolean>
usePaste: () => Promise<void>
/** The pasted value goes to main once; the reply is a code only. */
pasteClaudeToken: (request: { token: string }) => Promise<OrcaCloudAgentsResult<true>>
acknowledge: (request: { agent: OrcaCloudAccountAgent }) => Promise<void>
remove: (request: {
agent: OrcaCloudAccountAgent
}) => Promise<OrcaCloudAgentsResult<AgentCredentialsView>>
setDefaultSource: (request: {
agent: OrcaCloudAccountAgent
source: AgentSource
}) => Promise<OrcaCloudAgentsResult<AgentSourcesView>>
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)
}
}
+2
View File
@@ -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,
@@ -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({
</Dialogish>
)
})
export const composerHint = defineFixture({
width: 520,
render: () => (
<div className="bg-background p-4">
<ComposerCloudAgentHintView onSetUp={noop} onLater={noop} />
</div>
)
})
@@ -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 (
<div
role="status"
className="flex items-center gap-2.5 rounded-md border border-border/70 bg-muted/35 px-3 py-2"
data-composer-cloud-agent-hint=""
>
<KeyRound aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{t(
'cloudAgentSetup.composerHint',
'Claude and Codex need access in Orca Cloud. Add it now, or sign in once the workspace starts.'
)}
</p>
<Button type="button" variant="outline" size="xs" onClick={onSetUp}>
{t('cloudAgentSetup.title', 'Add an agent')}
</Button>
<Button type="button" variant="ghost" size="xs" onClick={onLater}>
{t('cloudAgentSetup.later', 'Later')}
</Button>
</div>
)
}
/**
* "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 (
<>
<ComposerCloudAgentHintView onSetUp={() => setOpen(true)} onLater={markDone} />
<CloudAgentSetupDialog open={open} onOpenChange={setOpen} onSaved={markDone} />
</>
)
}
@@ -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<void>
handleSetLocation: (option: NeedsProjectHostOption) => void
sshStatusLabel: string
connectButtonLabel: string
selectedProjectName: string
/** Fixtures pin Orca Cloud entitlement; the app reads it from billing. */
orcaCloudAccess?: OrcaCloudRunTargetAccess
}
> &
Partial<Pick<NewWorkspaceComposerCardProps, 'quickAgent'>> & {
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<void>
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}
</div>
{cloudStart?.panelVisible && selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? (
// The panel's own Agent row (G) comes from the same model as the standalone one below.
<OrcaCloudSetupPanel steps={cloudStart.steps} allSet={cloudStart.allSet} />
) : // Agent access is moot while Orca Cloud can't start (no plan, cap, payment, signed out).
selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID &&
orcaCloudCanStart(orcaCloudAccess ?? fetchedOrcaCloudAccess) ? (
<ComposerCloudAgentHint />
) : // Agent row (cloud-start G) without the panel: links to the one-time setup; never blocks Start.
selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? (
<OrcaCloudAgentSetupRow
agent={quickAgent}
access={orcaCloudAccess ?? fetchedOrcaCloudAccess}
/>
) : null}
{!shouldShowRunTargetPicker && ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-2xs text-destructive">{ephemeralVmRecipeError}</p>
@@ -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: () => (
<div className="space-y-2 bg-background p-4">
{rows.map((row) => (
<OrcaCloudAgentSetupRowView
key={`${row.kind}:${row.kind === 'hidden' ? '' : row.agent}:${row.kind === 'covered' ? row.via : ''}`}
row={row}
/>
))}
</div>
)
})
}
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' }
)
@@ -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<OrcaCloudAgentSetupRowModel, { kind: 'covered' }>,
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<OrcaCloudAgentSetupRowModel, { kind: 'hidden' | 'covered' | 'loading' }>,
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 <TriangleAlert aria-hidden className={`${base} text-status-warning-text`} />
case 'killSwitch':
return <Info aria-hidden className={`${base} text-muted-foreground`} />
case 'covered':
case 'hidden':
case 'loading':
case 'notSetUp':
case 'signedOut':
return <CircleDashed aria-hidden className={`${base} text-muted-foreground`} />
}
}
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 (
<p
data-orca-cloud-agent-row="covered"
className="flex min-h-6 items-center gap-2 px-1 text-xs text-muted-foreground"
>
<CircleCheck aria-hidden className="size-3.5 shrink-0" />
<span className="font-medium text-foreground">{label}</span>
<span className="min-w-0 truncate">{text}</span>
</p>
)
}
if (row.kind === 'loading') {
return (
<div
data-orca-cloud-agent-row="loading"
aria-busy="true"
className="flex min-h-[3.25rem] flex-col justify-center gap-1.5 rounded-md border border-border px-3 py-2.5"
>
<span className="sr-only">
{t('orcaCloudAgentRow.checking', 'Checking {{agent}}', {
agent: agentName(row.agent, t)
})}
</span>
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-64" />
</div>
)
}
const copy = orcaCloudAgentRowCopy(row, t)
return (
<div
role="group"
aria-label={[label, copy.detail, copy.status].filter(Boolean).join(', ')}
data-orca-cloud-agent-row={row.kind}
className="flex flex-col items-start gap-0.5 rounded-md border border-border px-3 py-2.5 text-sm"
>
<div className="flex min-h-8 w-full items-center gap-3">
<RowIcon kind={row.kind} />
<span className="min-w-0 flex-1 text-foreground">{label}</span>
{copy.action ? (
<Button type="button" variant="outline" size="sm" onClick={copy.action.onClick}>
{copy.action.label}
</Button>
) : copy.status ? (
<span className="shrink-0 text-muted-foreground">{copy.status}</span>
) : null}
</div>
{/* Indented to the label: icon (16px) + gap (12px). */}
<p className="w-full min-w-0 pl-7 text-muted-foreground">{copy.detail}</p>
</div>
)
}
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 <OrcaCloudAgentSetupRowView row={row} />
}
@@ -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 (
<div className="bg-background p-4">
<OrcaCloudSetupPanel steps={agent ? [...BASE, agent] : BASE} />
</div>
)
}
})
}
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' })
@@ -260,10 +260,12 @@ export function OrcaCloudSetupPanel({
<span className="block text-[11px] text-muted-foreground">{note}</span>
) : null}
{step.action ? (
// One shared in-row action ("Set up once", "How to push"): it must read as a button.
<Button
type="button"
variant="link"
size="statusAction"
variant="outline"
size="xs"
className="mt-1"
onClick={step.action.onClick}
>
{step.action.label}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { translate } from '@/i18n/i18n'
import { cloudStartAgentStep } from './cloud-start-agent-step'
import type { CloudSetupStep } from './cloud-start-model'
const STEP: CloudSetupStep = { id: 'agent', state: 'optional' }
describe('cloudStartAgentStep', () => {
it('is done and reads the source when the agent is covered', () => {
expect(
cloudStartAgentStep(STEP, { kind: 'covered', agent: 'claude', via: 'account' }, translate)
).toEqual({ id: 'agent', state: 'done', detail: 'Claude · from your Orca account' })
expect(
cloudStartAgentStep(STEP, { kind: 'covered', agent: 'codex', via: 'team' }, translate)
).toMatchObject({ state: 'done', detail: 'Codex · team key' })
})
it('offers Set up once when nothing covers it, and never blocks Start', () => {
const step = cloudStartAgentStep(STEP, { kind: 'notSetUp', agent: 'claude' }, translate)
expect(step).toMatchObject({
state: 'optional',
detail:
'Claude isn’t set up for Orca Cloud yet. Set it up once in Settings → Agent accounts.',
action: { label: 'Set up once' }
})
expect(step?.blocksStart).toBeUndefined()
})
it('is info only under the kill switch, has no action signed out, and hides when asked', () => {
expect(
cloudStartAgentStep(STEP, { kind: 'killSwitch', agent: 'claude' }, translate)
).toMatchObject({
detail: 'Claude signs in for each cloud workspace while Orca account sign-in is paused.'
})
expect(
cloudStartAgentStep(STEP, { kind: 'killSwitch', agent: 'claude' }, translate)?.action
).toBeUndefined()
const signedOut = cloudStartAgentStep(STEP, { kind: 'signedOut', agent: 'claude' }, translate)
expect(signedOut?.action).toBeUndefined()
expect(signedOut?.status).toBe('Optional')
expect(cloudStartAgentStep(STEP, { kind: 'hidden' }, translate)).toBeNull()
expect(cloudStartAgentStep(STEP, { kind: 'loading', agent: 'claude' }, translate)?.state).toBe(
'loading'
)
})
})
@@ -0,0 +1,37 @@
// The setup panel's Agent row (cloud-start G) from the Orca account's agent credentials and sources:
// "from your Orca account" / "team key" when covered, "Set up once" (S0) otherwise. Never blocks Start.
import type { Translate } from '@/i18n/use-translate'
import type { OrcaCloudAgentSetupRowModel } from '@/lib/orca-cloud/orca-cloud-agent-setup-row'
import { orcaCloudAgentCoveredLabel, orcaCloudAgentRowCopy } from '../OrcaCloudAgentSetupRow'
import type { CloudSetupStep } from './cloud-start-model'
/** Null hides the row (an agent without an account path, or nothing here helps yet). */
export function cloudStartAgentStep(
step: CloudSetupStep,
row: OrcaCloudAgentSetupRowModel,
t: Translate
): CloudSetupStep | null {
switch (row.kind) {
case 'hidden':
return null
case 'loading':
return { ...step, state: 'loading' }
case 'covered':
// Covered: the row is done, so the panel collapses once the required rows are too.
return { ...step, state: 'done', detail: orcaCloudAgentCoveredLabel(row, t) }
case 'notSetUp':
case 'expired':
case 'killSwitch':
case 'signedOut':
case 'error': {
const copy = orcaCloudAgentRowCopy(row, t)
return {
...step,
state: 'optional',
detail: copy.detail,
...(copy.status ? { status: copy.status } : {}),
...(copy.action ? { action: copy.action } : {})
}
}
}
}
@@ -16,7 +16,7 @@ import {
} from './cloud-start-model'
import type { CloudStartView } from './use-cloud-start-composer'
import { getCloudPrimaryLabel } from './cloud-start-actions'
import { decorateCloudSetupStep } from './use-cloud-start-steps'
import { decorateCloudSetupSteps } from './use-cloud-start-steps'
import type { OrcaCloudGitHubRepository } from '@/lib/orca-cloud/use-orca-cloud-github-repositories'
const projects: NewWorkspaceProjectOption[] = [
@@ -86,8 +86,10 @@ export function ComposerPreview({
const [host, setHost] = useState('local')
const [recipe, setRecipe] = useState<string | null>(cloudSelected ? 'cloud' : null)
const signedOut = availability === 'needs-sign-in' || access.state === 'signed-out'
const steps = (pinnedSteps ?? deriveCloudSetupSteps({ availability, access, github })).map(
(step) => decorateCloudSetupStep(step, 'claude', signedOut)
// G: Claude not set up yet, so the Agent row offers the one-time setup (or waits for sign-in).
const steps = decorateCloudSetupSteps(
pinnedSteps ?? deriveCloudSetupSteps({ availability, access, github }),
signedOut ? { kind: 'signedOut', agent: 'claude' } : { kind: 'notSetUp', agent: 'claude' }
)
const selected = recipe === 'cloud'
const panelVisible = selected && (isCloudSetupPanelVisible(steps) || allSet)
@@ -2,7 +2,6 @@
// repository check (D1–D3) and the agent row, or the setup session's own rows once one is open.
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { translate } from '@/i18n/i18n'
import { getAgentCatalog } from '@/lib/agent-catalog/agent-catalog'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
import { orcaCloudGitHubSetupRow } from '@/lib/orca-cloud/orca-cloud-github-setup-row'
import { useOrcaCloudRepoAccess } from '@/lib/orca-cloud/use-orca-cloud-repo-access'
@@ -14,37 +13,32 @@ import {
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import { deriveCloudSetupSteps, type CloudSetupStep } from './cloud-start-model'
import { cloudStartAgentStep } from './cloud-start-agent-step'
import {
orcaCloudAgentSetupRow,
type OrcaCloudAgentSetupRowModel
} from '@/lib/orca-cloud/orca-cloud-agent-setup-row'
import { useOrcaCloudAgentAccounts } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
import { noteComposerAgentCoverage } from '@/lib/orca-cloud/cloud-first-start-agent-coverage'
import { toGitHubSetupStep } from './cloud-start-github-step'
function agentStep(step: CloudSetupStep, agent: string | null, signedOut: boolean): CloudSetupStep {
if (step.id !== 'agent' || !agent) {
return step
/** Composer-only wording on derived rows: the Agent row (G) from the Orca account's agent setup. */
export function decorateCloudSetupSteps(
steps: readonly CloudSetupStep[],
agentRow: OrcaCloudAgentSetupRowModel
): CloudSetupStep[] {
const out: CloudSetupStep[] = []
for (const step of steps) {
if (step.id !== 'agent') {
out.push(step)
continue
}
const decorated = cloudStartAgentStep(step, agentRow, translate)
if (decorated) {
out.push(decorated)
}
}
const name = getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent
// Agent accounts v4: sign in once via the Orca account (S0), never per sandbox (G).
return {
...step,
detail: signedOut
? translate(
'components.newWorkspace.cloudSetup.agentSetUpAfterSignIn',
'{{agent}}: set up once in Settings → Agent accounts after you sign in',
{ agent: name }
)
: translate(
'components.newWorkspace.cloudSetup.agentSetUpOnce',
'{{agent}}: set up once in Settings → Agent accounts',
{ agent: name }
)
}
}
/** Composer-only wording on derived rows: the agent's name. */
export function decorateCloudSetupStep(
step: CloudSetupStep,
agent: string | null,
signedOut: boolean
): CloudSetupStep {
return agentStep(step, agent, signedOut)
return out
}
export function useCloudStartSteps({
@@ -102,13 +96,29 @@ export function useCloudStartSteps({
useSyncExternalStore(subscribeCloudSetupSessionRevision, getCloudSetupSessionRevision)
const session = readCloudSetupSession(setupSessionId)
const signedOut = availability === 'needs-sign-in' || access.state === 'signed-out'
const accounts = useOrcaCloudAgentAccounts(cloudSelected && !signedOut)
const agentRow = useMemo(
() =>
orcaCloudAgentSetupRow({
agent,
access: signedOut ? { state: 'signed-out' } : access,
accounts
}),
[access, accounts, agent, signedOut]
)
useEffect(() => {
if (cloudSelected) {
noteComposerAgentCoverage(agentRow)
}
}, [agentRow, cloudSelected])
const steps = useMemo(
() =>
(
decorateCloudSetupSteps(
session?.steps ??
deriveCloudSetupSteps({ availability, access, github: githubOverride ?? github })
).map((step) => decorateCloudSetupStep(step, agent, signedOut)),
[access, agent, availability, github, githubOverride, session?.steps, signedOut]
deriveCloudSetupSteps({ availability, access, github: githubOverride ?? github }),
agentRow
),
[access, agentRow, availability, github, githubOverride, session?.steps]
)
return { steps, session, openInstall }
}
@@ -0,0 +1,171 @@
// One S0 row per agent: picks the state's row and keeps the row's own Remove / save-error state.
import type React from 'react'
import { useState } from 'react'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import type { OrcaCloudAccountRow } from '@/lib/orca-cloud/orca-cloud-agent-accounts-model'
import type {
OrcaCloudAccountAgent,
OrcaCloudAgentsErrorCode
} from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import { RowShell } from './orca-cloud-account-row-shell'
import { agentName, keyPhrase, productName, setupFailureCopy } from './orca-cloud-accounts-copy'
import type { OrcaCloudAccountsActions } from './orca-cloud-accounts-types'
import { ActiveRow, ChoiceRow } from './OrcaCloudActiveRow'
import { SetupRow } from './OrcaCloudSetupRow'
export function AccountRow({
agent,
row,
actions,
now,
initialConfirmRemove,
initialRowError
}: {
agent: OrcaCloudAccountAgent
row: OrcaCloudAccountRow
actions: OrcaCloudAccountsActions
now: number
initialConfirmRemove: boolean
initialRowError: OrcaCloudAgentsErrorCode | null
}): React.JSX.Element {
const t = useTranslate()
const [confirmRemove, setConfirmRemove] = useState(initialConfirmRemove)
const [rowError, setRowError] = useState<OrcaCloudAgentsErrorCode | null>(initialRowError)
const product = productName(agent, t)
const useAccount =
agent === 'claude'
? t('orcaCloudAccounts.useClaude', 'Use my Claude account')
: t('orcaCloudAccounts.useChatGPT', 'Use my ChatGPT account')
let content: React.JSX.Element
switch (row.kind) {
case 'notSetUp':
content = (
<RowShell
agent={agent}
tone="none"
title={
row.keyKind
? t('orcaCloudAccounts.notSetUpKey', 'Not set up · using {{key}}', {
key: keyPhrase(row.keyKind, t)
})
: t('orcaCloudAccounts.notSetUp', 'Not set up')
}
detail={t(
'orcaCloudAccounts.notSetUpDetail',
'One sign-in on this computer. Then every cloud workspace can use it.'
)}
actions={
<Button type="button" variant="outline" size="sm" onClick={() => actions.start(agent)}>
{useAccount}
</Button>
}
/>
)
break
case 'setup':
content = <SetupRow agent={agent} row={row} actions={actions} now={now} />
break
case 'failed': {
const copy = setupFailureCopy(agent, row.error, t)
content = (
<RowShell
agent={agent}
tone="error"
title={copy.title}
detail={copy.detail}
actions={
<Button type="button" variant="outline" size="sm" onClick={() => actions.start(agent)}>
{t('orcaCloudAccounts.tryAgain', 'Try again')}
</Button>
}
/>
)
break
}
case 'choice':
content = (
<ChoiceRow
agent={agent}
keyKind={row.keyKind ?? 'team'}
actions={actions}
setRowError={setRowError}
/>
)
break
case 'active':
content = (
<ActiveRow
agent={agent}
row={row}
actions={actions}
confirmRemove={confirmRemove}
setConfirmRemove={setConfirmRemove}
setRowError={setRowError}
/>
)
break
case 'expired':
content = (
<RowShell
agent={agent}
tone="warning"
title={t('orcaCloudAccounts.expired', 'Your {{agent}} sign-in expired', {
agent: agentName(agent, t)
})}
detail={
row.covered
? t(
'orcaCloudAccounts.expiredDetail',
'Cloud workspaces use {{key}} until you renew. Nothing else changed.',
{ key: keyPhrase(row.keyKind ?? 'team', t) }
)
: t(
'orcaCloudAccounts.expiredDetailNoKey',
'Cloud workspaces ask you to sign in until you renew.'
)
}
actions={
<Button type="button" variant="outline" size="sm" onClick={() => actions.start(agent)}>
{t('orcaCloudAccounts.renew', 'Renew')}
</Button>
}
/>
)
break
case 'killSwitch':
content = (
<RowShell
agent={agent}
tone="info"
title={t('orcaCloudAccounts.killSwitch', 'Signs in for each cloud workspace')}
detail={t(
'orcaCloudAccounts.killSwitchDetail',
'Orca can’t use your {{product}} account in cloud workspaces right now. Each new cloud workspace asks once until it’s back.',
{ product }
)}
/>
)
break
}
if (!rowError) {
return content
}
return (
<>
{content}
<li className="pb-3">
<InlineError
density="compact"
title={t('orcaCloudAccounts.error.saveFailed', 'Couldn’t save to your Orca account.')}
detail={t('orcaCloudAccounts.error.nothingChanged', 'Nothing was changed.')}
retryLabel={t('orcaCloudAccounts.dismiss', 'Dismiss')}
onRetry={() => setRowError(null)}
/>
</li>
</>
)
}
/** One polite announcement per setup step ("Claude: finish in your browser"). */
@@ -0,0 +1,170 @@
// S0 states (agent-accounts spec §5): settings/agent-accounts/OrcaCloudAccounts:<state>.
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<OrcaCloudAccountAgent, OrcaCloudAccountRow> | null,
load: OrcaCloudAgentAccountsLoad = READY,
extra: {
confirmRemove?: OrcaCloudAccountAgent
rowError?: { agent: OrcaCloudAccountAgent; error: OrcaCloudAgentsErrorCode }
} = {}
) {
return defineFixture({
width: 760,
render: () => (
<div className="bg-background p-6">
<OrcaCloudAccountsView
load={load}
rows={rows}
actions={ACTIONS}
now={NOW}
initialConfirmRemove={extra.confirmRemove}
initialRowError={extra.rowError}
/>
</div>
)
})
}
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' })
@@ -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> = {}): 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(
<OrcaCloudAccountsView
load={{ kind: 'ready', status: STATUS }}
rows={rows}
actions={handlers}
now={0}
/>
)
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<HTMLInputElement>('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(
<OrcaCloudAccountsView
load={{ kind: 'failed', error: 'signed_out' }}
rows={null}
actions={handlers}
now={0}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(handlers.signIn).toHaveBeenCalled()
})
})
@@ -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<OrcaCloudAccountAgent, OrcaCloudAccountRow>,
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 (
<ul aria-busy="true" className="px-5">
{ORCA_CLOUD_ACCOUNT_AGENTS.map((agent) => (
<li
key={agent}
className="grid min-h-16 grid-cols-[5rem_minmax(0,1fr)] items-center gap-x-4 border-t border-border py-3 first:border-t-0"
>
<h4 className="text-sm font-medium text-foreground">{agentName(agent, t)}</h4>
<div className="space-y-1.5">
<Skeleton className="h-3.5 w-56" />
<Skeleton className="h-3 w-80" />
</div>
</li>
))}
<li className="sr-only">{t('orcaCloudAccounts.loading', 'Loading agent accounts…')}</li>
</ul>
)
}
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 = <LoadingRows />
} else if (signedOut) {
body = (
<InlineError
tone="neutral"
className="m-4"
title={t(
'orcaCloudAccounts.signedOut',
'Sign in to Orca to use your accounts in cloud workspaces.'
)}
action={
<Button type="button" variant="outline" size="xs" onClick={actions.signIn}>
{t('orcaCloudAccounts.signIn', 'Sign in')}
</Button>
}
/>
)
} else if (load.kind === 'failed' || !rows) {
body = (
<InlineError
className="m-4"
title={t('orcaCloudAccounts.loadFailed', 'Couldn’t load your agent accounts.')}
detail={t('orcaCloudAccounts.error.nothingChanged', 'Nothing was changed.')}
retryLabel={t('orcaCloudAccounts.tryAgain', 'Try again')}
onRetry={actions.retryLoad}
/>
)
} else {
body = (
<ul className="px-5">
{ORCA_CLOUD_ACCOUNT_AGENTS.map((agent) => (
<AccountRow
key={agent}
agent={agent}
row={rows[agent]}
actions={actions}
now={now}
initialConfirmRemove={initialConfirmRemove === agent}
initialRowError={initialRowError?.agent === agent ? initialRowError.error : null}
/>
))}
</ul>
)
}
return (
<section id={ORCA_CLOUD_ACCOUNTS_SECTION_ID} className="space-y-3">
<SettingsGroupHeader
title={t('orcaCloudAccounts.title', 'Orca Cloud')}
description={t(
'orcaCloudAccounts.help',
'Set up once on this computer. Every cloud workspace uses it, including new, cloned and restarted ones.'
)}
/>
<div className="overflow-hidden rounded-xl border border-border bg-card/50 shadow-xs">
{body}
</div>
{/* Mandatory (spec §5 item 5): the A1 mitigation depends on this line. */}
<p className="px-1 text-xs text-muted-foreground">
{t(
'orcaCloudAccounts.policy',
'Your Claude and ChatGPT accounts are used only in your own workspaces. Teammates never use them.'
)}
</p>
<p aria-live="polite" className="sr-only">
{announcement}
</p>
</section>
)
}
@@ -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<OrcaCloudAccountsActions>(() => {
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 <OrcaCloudAccountsView load={load} rows={rows} actions={actions} now={now} />
}
@@ -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<OrcaCloudAccountRow, { kind: 'active' }>
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<OrcaCloudAgentsErrorCode | null>): Promise<void> => {
setBusy(true)
const error = await work()
setBusy(false)
setRowError(error)
}
if (confirmRemove) {
return (
<RowShell
agent={agent}
tone="warning"
title={t(
'orcaCloudAccounts.removeConfirm',
'Remove your {{product}} account from Orca? Cloud workspaces use the team key, or ask you to sign in.',
{ product }
)}
actions={
<>
<Button
type="button"
variant="destructive"
size="sm"
disabled={busy}
aria-busy={busy || undefined}
autoFocus
onClick={() =>
void run(async () => {
const error = await actions.remove(agent)
if (!error) {
setConfirmRemove(false)
}
return error
})
}
>
{t('orcaCloudAccounts.remove', 'Remove')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={busy}
onClick={() => setConfirmRemove(false)}
>
{t('orcaCloudAccounts.cancel', 'Cancel')}
</Button>
</>
}
/>
)
}
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 (
<RowShell
agent={agent}
tone={row.expiring ? 'warning' : 'done'}
title={
row.source === 'sign-in'
? t('orcaCloudAccounts.active', '{{email}} · from your Orca account', { email: who })
: t('orcaCloudAccounts.activeKeyFirst', '{{email}} · saved, using {{key}}', {
email: who,
key: keyPhrase(row.keyKind ?? 'team', t)
})
}
detail={
<>
{kept}
{renews ? (
<span className={cn(row.expiring && 'text-status-warning-text')}> {renews}</span>
) : null}
</>
}
actions={
<>
{row.covered && row.keyKind ? (
<SettingsSegmentedControl
size="sm"
activation="manual"
ariaLabel={t('orcaCloudAccounts.sourceLabel', 'Source for {{agent}}', {
agent: agentName(agent, t)
})}
value={row.source}
onChange={(source) => {
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}
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirmRemove(true)}>
{t('orcaCloudAccounts.removeEllipsis', 'Remove…')}
</Button>
</>
}
/>
)
}
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<void> => {
setBusy(true)
setRowError(await actions.setSource(agent, source))
setBusy(false)
}
return (
<RowShell
agent={agent}
tone="done"
title={t(
'orcaCloudAccounts.choice',
'Saved. Use your {{product}} account instead of {{key}} in your cloud workspaces?',
{ product: productName(agent, t), key: keyPhrase(keyKind, t) }
)}
detail={
keyKind === 'team'
? t(
'orcaCloudAccounts.choiceDetail',
'Your account’s usage limits apply instead of the team’s bill. You can change this any time.'
)
: t(
'orcaCloudAccounts.choiceDetailKey',
'Your account’s usage limits apply instead of the key’s billing. You can change this any time.'
)
}
actions={
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => void answer('sign-in')}
>
{t('orcaCloudAccounts.useMine', 'Use my account')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void answer('key')}
>
{keepKeyLabel(keyKind, t)}
</Button>
</>
}
/>
)
}
@@ -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<OrcaCloudAgentsErrorCode | null>
onCancel: () => void
}): React.JSX.Element {
const t = useTranslate()
const id = useId()
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<OrcaCloudAgentsErrorCode | null>(null)
const helpId = `${id}-help`
const submit = async (event: React.FormEvent): Promise<void> => {
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 (
<form className="space-y-1.5" onSubmit={(event) => void submit(event)}>
<label htmlFor={id} className="sr-only">
{t('orcaCloudAccounts.paste.label', 'Claude setup token')}
</label>
<div className="flex items-center gap-1.5">
<div className="min-w-0 flex-1">
<SecretValueInput
id={id}
value={value}
onChange={(next) => {
setValue(next)
setError(null)
}}
placeholder={t('orcaCloudAccounts.paste.placeholder', 'sk-ant-oat01-…')}
invalid={error !== null}
autoFocus={reason === 'chosen'}
disabled={saving}
describedBy={helpId}
/>
</div>
<Button
type="submit"
variant="outline"
size="sm"
disabled={saving || !value.trim()}
aria-busy={saving || undefined}
>
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
{t('orcaCloudAccounts.paste.save', 'Save')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onCancel} disabled={saving}>
{t('orcaCloudAccounts.cancel', 'Cancel')}
</Button>
</div>
{error ? (
<p role="alert" className="text-xs text-destructive">
{setupFailureCopy('claude', error, t).title}
</p>
) : (
<p id={helpId} className="text-xs text-muted-foreground">
{t('orcaCloudAccounts.paste.help', 'Write-only. You won’t see it again after saving.')}
</p>
)}
</form>
)
}
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<OrcaCloudAccountRow, { kind: 'setup' }>
actions: OrcaCloudAccountsActions
now: number
}): React.JSX.Element {
const t = useTranslate()
const cancel = (
<Button type="button" variant="ghost" size="sm" onClick={() => actions.cancel(agent)}>
{t('orcaCloudAccounts.cancel', 'Cancel')}
</Button>
)
const setup = row.setup
switch (setup.phase) {
case 'browser':
return (
<RowShell
agent={agent}
tone="busy"
title={
<>
{t('orcaCloudAccounts.settingUp', 'Finish in your browser')}
<ElapsedSuffix startedAt={setup.startedAt} now={now} />
</>
}
detail={t(
'orcaCloudAccounts.settingUpClaudeDetail',
'Orca runs claude setup-token on this computer, then saves the result to your Orca account.'
)}
actions={
<>
{setup.canReopen ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => actions.reopen(agent)}
>
{t('orcaCloudAccounts.openAgain', 'Open again')}
</Button>
) : null}
{cancel}
</>
}
/>
)
case 'preparing':
return (
<RowShell
agent={agent}
tone="busy"
title={
<>
{t('orcaCloudAccounts.preparing', 'Getting ChatGPT sign-in ready')}
<ElapsedSuffix startedAt={setup.startedAt} now={now} />
</>
}
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 (
<RowShell
agent={agent}
tone="busy"
title={
<>
{t('orcaCloudAccounts.deviceEnter', 'Enter')}{' '}
<code className="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-sm tracking-wider text-foreground">
{setup.userCode}
</code>{' '}
{t('orcaCloudAccounts.deviceOn', 'on {{host}}', {
host: deviceHost(setup.verificationUrl)
})}
<ElapsedSuffix startedAt={setup.startedAt} now={now} />
</>
}
detail={t(
'orcaCloudAccounts.deviceGuard',
'Only continue if you started this sign-in in Orca.'
)}
actions={
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
actions.copyCode(setup.userCode)
actions.reopen(agent)
}}
>
{t('orcaCloudAccounts.copyAndOpen', 'Copy code and open')}
</Button>
{cancel}
</>
}
/>
)
case 'paste':
return (
<RowShell
agent={agent}
tone="none"
title={t('orcaCloudAccounts.pasteInstead', 'Paste the token instead')}
detail={pasteReasonCopy(setup.reason, t)}
>
<PasteTokenForm
reason={setup.reason}
onPaste={actions.pasteToken}
onCancel={() => actions.cancel(agent)}
/>
</RowShell>
)
case 'saving':
return (
<RowShell
agent={agent}
tone="busy"
title={t('orcaCloudAccounts.saving', 'Saving to your Orca account…')}
/>
)
}
}
@@ -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')
]
}
])
@@ -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 <CircleCheck aria-hidden className={cn(base, 'text-muted-foreground')} />
case 'busy':
// Reduced motion stops the spin; the words carry the state.
return (
<Loader2
aria-hidden
className={cn(base, 'text-muted-foreground motion-safe:animate-spin')}
/>
)
case 'warning':
return <TriangleAlert aria-hidden className={cn(base, 'text-status-warning-text')} />
case 'error':
return <CircleAlert aria-hidden className={cn(base, 'text-destructive')} />
case 'info':
return <Info aria-hidden className={cn(base, 'text-muted-foreground')} />
case 'none':
return <span aria-hidden className={base} />
}
}
export function RowShell({
agent,
tone,
title,
detail,
detailTone = 'muted',
actions,
children
}: {
agent: OrcaCloudAccountAgent
tone: React.ComponentProps<typeof StatusIcon>['tone']
title: React.ReactNode
detail?: React.ReactNode
detailTone?: 'muted' | 'warning'
actions?: React.ReactNode
children?: React.ReactNode
}): React.JSX.Element {
const t = useTranslate()
return (
<li
data-orca-cloud-account={agent}
className="grid min-h-16 grid-cols-[5rem_minmax(0,1fr)] items-center gap-x-4 border-t border-border py-3 first:border-t-0"
>
<h4 className="text-sm font-medium text-foreground">{agentName(agent, t)}</h4>
<div className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<StatusIcon tone={tone} />
<div className="min-w-0 space-y-0.5">
<div className="text-sm text-foreground">{title}</div>
{detail ? (
<p
className={cn(
'text-xs',
detailTone === 'warning' ? 'text-status-warning-text' : 'text-muted-foreground'
)}
>
{detail}
</p>
) : null}
</div>
</div>
{/* One action slot per row: focus stays here through every state. */}
{actions ? (
<div className="flex shrink-0 items-center gap-1.5" data-row-actions="">
{actions}
</div>
) : null}
</div>
{/* Aligned with the text: icon (16px) + gap (8px). */}
{children ? <div className="pl-6">{children}</div> : null}
</div>
</li>
)
}
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 <span className="text-muted-foreground tabular-nums"> · {elapsed}</span>
}
@@ -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
}
}
}
@@ -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<OrcaCloudAgentsErrorCode | null>
remove: (agent: OrcaCloudAccountAgent) => Promise<OrcaCloudAgentsErrorCode | null>
setSource: (
agent: OrcaCloudAccountAgent,
source: 'sign-in' | 'key'
) => Promise<OrcaCloudAgentsErrorCode | null>
copyCode: (code: string) => void
retryLoad: () => void
signIn: () => void
}
export type OrcaCloudAccountsViewProps = {
load: OrcaCloudAgentAccountsLoad
rows: Record<OrcaCloudAccountAgent, OrcaCloudAccountRow> | 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 }
}
@@ -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 ? (
<SettingsSection
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.'
)}
searchEntries={navigation.getSectionSearchEntries('agent-accounts')}
>
{view.isSectionMounted('agent-accounts') ? <OrcaCloudAccountsSection /> : null}
</SettingsSection>
) : null
}
export function renderAgentApiKeysSettingsSection(
context: SettingsRenderContext,
t: Translate = translate
@@ -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)}
@@ -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'
@@ -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
? [
+132 -3
View File
@@ -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"
}
}
@@ -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'
})
})
})
@@ -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
}
@@ -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' })
})
})
@@ -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<OrcaCloudAccountAgent, readonly string[]> = {
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<CloudEffectiveSecret, 'name' | 'scope'>[]
): Record<OrcaCloudAccountAgent, AgentKeyKind | null> {
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<OrcaCloudAgentSetup, { phase: 'idle' | 'saved' | 'failed' }>
}
| { 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' }
}
@@ -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<OrcaCloudAccountAgent, AgentKeyKind | null>
}
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<OrcaCloudAgentAccountsState>): void {
state = { ...state, ...next }
for (const listener of listeners) {
listener()
}
}
function bridge(): NonNullable<typeof window.api.orcaCloudAgents> | 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<void> {
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)
}
@@ -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<typeof orcaCloudAgentSetupRow>[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')
})
})
@@ -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<OrcaCloudAgentAccountsState, 'load' | 'keyKinds'>
}): 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 }
}
}
@@ -17,6 +17,7 @@ const SETTINGS_NAV_TARGETS = [
'general',
'integrations',
'accounts',
'agent-accounts',
'agent-api-keys',
'browser',
'git',
@@ -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<typeof CredentialView>
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<typeof AgentCredentialsResponseSchema>
const Source = z.enum(['sign-in', 'key']).nullable()
export type AgentSource = z.infer<typeof Source>
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<typeof AgentSourcesResponseSchema>
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<typeof CodexBrokerStartResponseSchema>
export const CodexBrokerStatusResponseSchema = z.object({
status: z.enum(['pending', 'active', 'failed']),
error: z.string().max(64).optional()
})
export type CodexBrokerStatus = z.infer<typeof CodexBrokerStatusResponseSchema>
/** 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<T> =
| { 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<OrcaCloudAccountAgent, OrcaCloudAgentSetup>
export const IDLE_ORCA_CLOUD_AGENT_SETUPS: OrcaCloudAgentSetups = {
claude: { phase: 'idle' },
codex: { phase: 'idle' }
}
@@ -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()
+3 -1
View File
@@ -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