mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user