diff --git a/cloud/apps/acceptance/desktop/run-desktop.mjs b/cloud/apps/acceptance/desktop/run-desktop.mjs index 20b215c2aa6..8ce06a5a754 100644 --- a/cloud/apps/acceptance/desktop/run-desktop.mjs +++ b/cloud/apps/acceptance/desktop/run-desktop.mjs @@ -1029,6 +1029,12 @@ async function main() { results.notes.push('test repo not clonable here; used a local repo with the same origin URL') } created.tmp = tmp + if (process.env.W9D_ONLY === 'run-actions' || process.env.W9D_ONLY === 'run-ports2') { + writeFileSync(path.join(repoDir, 'orca.yaml'), runActionsYaml(tmp)) + execFileSync('git', ['-C', repoDir, 'add', 'orca.yaml'], { stdio: 'ignore' }) + execFileSync('git', ['-C', repoDir, '-c', 'user.email=w9@orca.dev', '-c', 'user.name=W9', 'commit', '-qm', 'orca.yaml run actions'], { stdio: 'ignore' }) + results.app.repoDir = repoDir + } const setup = await runStep('setup', 'Launch dev Orca on a fresh profile (signed out), add repo', async (step) => { // #127 keys the sandbox token on the signed-in identity, so no sign-in-then-relaunch dance: the @@ -1914,12 +1920,600 @@ async function asleepMovedStep() { }) } +// ---------- run actions + per-workspace ports (#187/#216) ---------- + +/** Setup fails while /fail-setup exists; web serves on $ORCA_PORT; echo-loop is nonconcurrent. */ +function runActionsYaml(tmp) { + const marker = path.join(tmp, 'fail-setup') + return [ + 'scripts:', + ` setup: "test ! -e '${marker}' || exit 3; echo W9-SETUP-OK"`, + ' autoRunAfterSetup: web', + ' run:', + ' web:', + ' command: "python3 -m http.server $ORCA_PORT"', + ' default: true', + ' echo-loop:', + ' command: "while true; do echo loop-$ORCA_PORT; sleep 1; done"', + ' runMode: nonconcurrent', + '' + ].join('\n') +} + +const runRecords = () => page.evaluate(() => { try { return JSON.parse(localStorage.getItem('orca.runActions.v1') ?? '{}') } catch { return {} } }) +const runOf = async (worktreeId, actionId) => (await runRecords())?.[worktreeId]?.[actionId] ?? null + +/** Approve the orca.yaml trust prompt when it is up; counts prompts per kind. */ +async function approveTrust(step) { + const m = await store(() => { + const s = window.__store.getState() + return s.activeModal === 'confirm-orca-yaml-hooks' ? { kind: s.modalData?.scriptKind ?? null } : null + }).catch(() => null) + if (!m) return false + step.trustPrompts = [...(step.trustPrompts ?? []), { kind: m.kind, at: new Date().toISOString() }] + results.trustPrompts = [...(results.trustPrompts ?? []), { step: step.id, kind: m.kind }] + await shot(step, `trust-${m.kind}`) + await page.getByRole('button', { name: /^Run hooks$/ }).first().click({ timeout: 5000 }) + await sleep(500) + return true +} +async function waitFor(step, fn, timeoutMs) { + const t0 = Date.now() + while (Date.now() - t0 < timeoutMs) { + await approveTrust(step) + const v = await fn() + if (v) return { v, ms: Date.now() - t0 } + await sleep(200) + } + return { v: null, ms: Date.now() - t0, timedOut: true } +} +const httpOk = (port) => fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) }).then((r) => r.status, () => null) + +async function worktreeByName(name) { + return store((n) => Object.values(window.__store.getState().worktreesByRepo ?? {}).flat().find((w) => w.displayName === n) ?? null, name) +} +/** Create a local worktree from the composer (Blank Terminal); approve setup trust when asked. */ +async function createLocalWorktree(step, name) { + const d = await openComposer(step, `${name}-composer`) + await d.getByRole('combobox', { name: 'Run on' }).click() + const targets = page.getByRole('listbox', { name: 'Run targets' }) + await targets.waitFor() + await targets.getByRole('option', { name: /^Local Mac/ }).first().click() + await sleep(400) + if (!(await selectBlankAgent(d))) throw new Error('could not choose Blank Terminal') + await fillName(d, name) + const t0 = Date.now() + await d.getByRole('button', { name: /^Create worktree/ }).click() + const w = await waitFor(step, () => worktreeByName(name), 60_000) + if (!w.v) throw new Error(`no worktree ${name}`) + created.localPaths.push(w.v.path) + return { name, id: w.v.id, path: w.v.path, createdAt: t0 } +} +/** Open the workspace and read its env from a fresh plain terminal. */ +async function terminalEnv(step, wsRow, kind = 'local') { + const row = await rowOf(wsRow, kind) + await (row ?? page.getByText(wsRow, { exact: true }).first()).click() + await sleep(800) + await openNewTerminalTab() + await waitShellPrompt(60_000) + await focusTerminal() + await page.keyboard.type('echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n') + const r = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)\s*$/m) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 20_000) + return r.v +} +/** The Run button scoped to the visible tab strip; its chevron menu. */ +const runButton = () => page.getByTestId('run-action-button').filter({ visible: true }).first() +async function runMenu(step) { + await page.keyboard.press('Escape').catch(() => {}) + await runButton().getByRole('button', { name: 'Run actions' }).click() + await page.getByRole('menuitem').first().waitFor({ timeout: 5000 }) + await sleep(200) + step.lastRunMenu = (await page.getByRole('menu').filter({ visible: true }).last().innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 400) +} +/** Click the menu item `item` listed under the action `actionName` (menu order: header, its items). */ +async function runMenuItem(step, actionName, item) { + await runMenu(step) + const found = await page.evaluate(({ actionName, item }) => { + const menu = [...document.querySelectorAll('[role="menu"]')].filter((m) => m.getBoundingClientRect().width > 0).pop() + if (!menu) return 'no menu' + let under = false + for (const el of menu.querySelectorAll('*')) { + const isItem = el.getAttribute('role') === 'menuitem' + const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() + if (!isItem && el.children.length <= 3 && text.startsWith(actionName) && !text.startsWith(`${actionName} `.repeat(2))) under = true + else if (!isItem && under && el.children.length <= 3 && /^(Web|Echo loop|Env)\b/.test(text) && !text.startsWith(actionName)) under = false + if (under && isItem && text === item) { + el.setAttribute('data-w9d-run-item', '1') + return 'ok' + } + } + return 'not found' + }, { actionName, item }) + if (found !== 'ok') throw new Error(`menu item "${item}" under ${actionName}: ${found}; menu ${step.lastRunMenu}`) + await page.locator('[data-w9d-run-item="1"]').first().click() +} + +/** #233: nonconcurrent stops only the same action elsewhere; cloud port block is 3000 and stable. */ +async function runPorts2Flow() { + const A = `${wsName}-a` + const B = `${wsName}-b` + const wsA = {} + const wsB = {} + await runStep('run.nonconcurrent', 'echo-loop (nonconcurrent) started in B stops echo-loop in A; concurrent web keeps running and serving in A and B (#233)', async (step) => { + Object.assign(wsA, await createLocalWorktree(step, A)) + await waitFor(step, async () => (await runOf(wsA.id, 'web'))?.status === 'running', 90_000) + Object.assign(wsB, await createLocalWorktree(step, B)) + await waitFor(step, async () => (await runOf(wsB.id, 'web'))?.status === 'running', 90_000) + const envA = await terminalEnv(step, A) + const envB = await terminalEnv(step, B) + wsA.port = Number(envA?.ORCA_PORT) + wsB.port = Number(envB?.ORCA_PORT) + step.ports = { A: wsA.port, B: wsB.port } + step.before = { webA: (await runOf(wsA.id, 'web'))?.status, webB: (await runOf(wsB.id, 'web'))?.status, serveA: await httpOk(wsA.port), serveB: await httpOk(wsB.port) } + await (await rowOf(A, 'local')).click() + await sleep(800) + await runMenuItem(step, 'Echo loop', 'Run') + const aOn = await waitFor(step, async () => (await runOf(wsA.id, 'echo-loop'))?.status === 'running', 20_000) + await (await rowOf(B, 'local')).click() + await sleep(800) + const t0 = Date.now() + await runMenuItem(step, 'Echo loop', 'Run') + const res = await waitFor(step, async () => { + const a = await runOf(wsA.id, 'echo-loop') + const b = await runOf(wsB.id, 'echo-loop') + return b?.status === 'running' && (!a || a.status === 'stopped' || a.status === 'exited') ? true : null + }, 20_000) + step.timings.bStartToAStoppedMs = res.v ? Date.now() - t0 : null + await sleep(3000) + step.after = { + echoA: (await runOf(wsA.id, 'echo-loop'))?.status ?? 'gone', + echoB: (await runOf(wsB.id, 'echo-loop'))?.status ?? 'gone', + webA: (await runOf(wsA.id, 'web'))?.status ?? 'gone', + webB: (await runOf(wsB.id, 'web'))?.status ?? 'gone', + serveA: await httpOk(wsA.port), + serveB: await httpOk(wsB.port) + } + await runMenu(step) + await shot(step, 'b-menu') + await page.keyboard.press('Escape') + const webKept = step.after.webA === 'running' && step.after.webB === 'running' && step.after.serveA === 200 && step.after.serveB === 200 + step.status = aOn.v && res.v && webKept ? 'pass' : 'fail' + step.detail = `ports A ${wsA.port}, B ${wsB.port}; before ${JSON.stringify(step.before)}; echo-loop in A running: ${Boolean(aOn.v)}; started in B -> A's echo-loop stopped in ${step.timings.bStartToAStoppedMs}ms; after ${JSON.stringify(step.after)} (web kept running and serving in both: ${webKept})` + }) + + // ---- cloud: ORCA_PORT=3000 for the first workspace's terminal and run action, also after Sleep -> wake. + await store(() => window.__store.getState().connectCurrentOrcaProfile()) + await sleep(1500) + let cws = null + await runStep('run.ports', 'Cloud: the first workspace terminal and run action see ORCA_PORT=3000, still 3000 after Sleep -> wake; a second worktree on the same VM gets another block (if the UI allows)', async (step) => { + const sub = {} + step.substeps = sub + const w = await createExtraWorkspace(step, `${wsName}-cl`, { blankAgent: true }) + const wt = await worktreeByName(w.name) + cws = { ...w, id: wt?.id } + step.sandboxId = w.sandboxId + const repoPath = await store((id) => { const s = window.__store.getState(); const r = s.repos.find((x) => (s.worktreesByRepo?.[x.id] ?? []).some((t) => t.id === id)); return r?.path ?? null }, cws.id) + step.cloudRepoPath = repoPath + // orca.yaml in the VM's primary checkout (committed there, so worktrees made from it have it too). + const yaml = [ + 'scripts:', + ' setup: "echo SETUP P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT > /tmp/w9-setup-env-$(basename $PWD)"', + ' run:', + ' env:', + ' command: "echo RUNENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT; sleep 600"', + ' default: true', + '' + ].join('\n') + const b64 = Buffer.from(yaml).toString('base64') + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + const rp = repoPath ?? '/vercel/sandbox/project' + await page.keyboard.type(`clear; echo ${b64} | base64 -d > ${rp}/orca.yaml && git -C ${rp} add orca.yaml && git -C ${rp} -c user.email=w9@orca.dev -c user.name=W9 commit -qm w9-orca-yaml && cp ${rp}/orca.yaml ./orca.yaml && echo YAMLOK; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n`) + // The VM's port registry and orcad build, to explain which workspace holds the launcher's block. + await sleep(1500) + await page.keyboard.type('clear; echo W9REG; cat /vercel/.orcad-data/workspace-port-blocks.json; echo; echo W9REGEND\n') + const reg = await waitFor(step, async () => (await visibleTerminals()).join('\n').match(/W9REG\s*\n([\s\S]*?)\n\s*W9REGEND/)?.[1]?.replace(/\s+/g, ' ') ?? null, 15_000) + step.vmRegistry = reg.v + await focusTerminal() + await page.keyboard.type('clear; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n') + const env1 = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)\s*$/m) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.terminalEnv = env1.v + sub.terminal3000 = env1.v?.ORCA_PORT === '3000' && env1.v?.ORCA_PORT_COUNT === '10' && env1.v?.CONDUCTOR_PORT === '3000' + // Run action env. + await (await rowOf(A, 'local')).click() + await sleep(800) + await (await rowOf(cws.name)).click() + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await waitFor(step, async () => /Env/.test(await runButton().innerText().catch(() => '')), 30_000) + const readRunEnv = async () => { + const r = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/RUNENV P=(\d*) C=(\d*) K=(\d*)/) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + return r.v + } + await runButton().getByRole('button', { name: /^Run Env/ }).click() + await waitFor(step, async () => (await runOf(cws.id, 'env'))?.status === 'running', 60_000) + step.runActionEnv = await readRunEnv() + sub.runAction3000 = step.runActionEnv?.ORCA_PORT === '3000' + await shot(step, 'run-env') + step.setupEnvFirst = 'not testable: the first workspace is created from the GitHub test repo, which has no orca.yaml, so no setup runs for it' + // Sleep -> wake, then terminal + run action again. + await openRowMenu(cws.name) + await page.getByRole('menuitem', { name: /^(Suspend VM|Sleep)$/ }).first().click() + await page.getByRole('dialog').getByRole('button', { name: /^(Sleep|Suspend)/ }).click({ timeout: 1500 }).catch(() => {}) + const asleep = await waitSandboxStatus(cws.sandboxId, ['suspended', 'sleeping', 'stopped'], 180_000) + step.timings.sleepMs = asleep.ms + await sleep(2000) + const tabBeforeSleep = (await runOf(cws.id, 'env'))?.tabId + const t0 = Date.now() + await (await rowOf(cws.name)).click() + // Back = relaunched in a new tab after the wake. + const back = await waitFor(step, async () => { const r = await runOf(cws.id, 'env'); return r?.status === 'running' && r.tabId !== tabBeforeSleep }, 180_000) + step.timings.wakeClickToRunActionBackMs = back.v ? Date.now() - t0 : null + await page.getByText('Run: Env', { exact: true }).first().click().catch(() => {}) + await sleep(1000) + step.runActionEnvAfterWake = await readRunEnv() + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + await page.keyboard.type('clear; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n') + const env2 = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)\s*$/m) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.terminalEnvAfterWake = env2.v + sub.stillAfterWake = env2.v?.ORCA_PORT === '3000' && step.runActionEnvAfterWake?.ORCA_PORT === '3000' + await shot(step, 'after-wake') + // A second worktree on the same VM: offered by the composer? + const d = await openComposer(step, 'second-worktree-composer') + await d.getByRole('combobox', { name: 'Run on' }).click() + const targets = page.getByRole('listbox', { name: 'Run targets' }) + await targets.waitFor() + step.runTargetsWithCloud = (await targets.getByRole('option').allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim()) + await shot(step, 'run-targets') + // Only a target for the existing VM counts (not "Orca Cloud", which starts a new sandbox). + const sameVm = targets.getByRole('option').filter({ hasText: new RegExp(`${cws.name}|sandbox ·|environment`, 'i') }).filter({ hasNotText: /Orca Cloud|Local Mac|Connect/ }).first() + if (await sameVm.isVisible().catch(() => false)) { + step.secondWorktree = 'offered' + await sameVm.click() + await selectBlankAgent(d) + const n2 = `${wsName}-cl2` + await fillName(d, n2) + await d.getByRole('button', { name: /^Create/ }).click() + const w2 = await waitFor(step, () => worktreeByName(n2), 120_000) + if (w2.v) { + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + await page.keyboard.type('clear; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT; cat /tmp/w9-setup-env-* 2>/dev/null\n') + const env3 = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)/) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.secondWorktreeEnv = env3.v + step.setupEnvLines = ((await visibleTerminals()).join('\n').match(/SETUP P=\S* C=\S* K=\S*/g) ?? []) + sub.secondWorktreeOtherBlock = Boolean(env3.v?.ORCA_PORT) && env3.v.ORCA_PORT !== '3000' + } else { + step.secondWorktree = 'offered, but no worktree appeared within 120s' + } + } else { + step.secondWorktree = 'not testable: the composer offers no target for an existing cloud VM (only a new Orca Cloud sandbox)' + await page.keyboard.press('Escape').catch(() => {}) + await page.keyboard.press('Escape').catch(() => {}) + } + step.status = Object.values(sub).every(Boolean) ? 'pass' : 'fail' + step.detail = `cloud first workspace: terminal ${JSON.stringify(step.terminalEnv)}, run action ${JSON.stringify(step.runActionEnv)}; Sleep -> ${asleep.status} ${asleep.ms}ms; click to wake -> run action back ${step.timings.wakeClickToRunActionBackMs}ms, then terminal ${JSON.stringify(step.terminalEnvAfterWake)}, run action ${JSON.stringify(step.runActionEnvAfterWake)}; setup: ${step.setupEnvFirst}; second worktree on the same VM: ${step.secondWorktree}${step.secondWorktreeEnv ? ` -> ${JSON.stringify(step.secondWorktreeEnv)}, setup env ${JSON.stringify(step.setupEnvLines)}` : ''}; run targets ${JSON.stringify(step.runTargetsWithCloud)}; ${JSON.stringify(sub)}` + }) + + if (cws) { + await runStep('life.destroy', 'Delete the cloud workspace', async (step) => { + step.sandboxId = cws.sandboxId + await page.keyboard.press('Escape').catch(() => {}) + const o = await uiDelete(step, cws.name, 'delete', 120_000) + const w = await waitSandboxStatus(cws.sandboxId, ['destroyed'], 120_000) + step.status = o.kind === 'removed' && w.status === 'destroyed' ? 'pass' : 'fail' + step.detail = `UI Delete -> ${o.kind} in ${o.ms}ms; sandbox -> ${w.status}` + }) + } + for (const n of [A, B]) { + if (await rowShown(n, 'local')) { + await openRowMenu(n, 'local').catch(() => {}) + await page.getByRole('menuitem', { name: /^Delete/ }).click().catch(() => {}) + await page.getByRole('dialog').getByRole('button', { name: /^(Force )?Delete( Workspace)?$/ }).first().click().catch(() => {}) + await waitRowGone(n, 'local', 30_000).catch(() => {}) + } + } +} + +async function runActionsFlow() { + const A = `${wsName}-a` + const B = `${wsName}-b` + const C = `${wsName}-c` + const wsA = {} + const wsB = {} + const portsBefore = {} + await runStep('local.run-actions', 'Run actions: autoRunAfterSetup, Run button, trust once, Stop/Restart, ⌥⌘↩; no auto-run after a failed setup', async (step) => { + const sub = {} + step.substeps = sub + // A: setup succeeds -> web auto-runs. + Object.assign(wsA, await createLocalWorktree(step, A)) + const auto = await waitFor(step, async () => { + const r = await runOf(wsA.id, 'web') + return r?.status === 'running' ? r : null + }, 90_000) + sub.autoRunAfterSetup = Boolean(auto.v) + step.timings.createToAutoRunMs = auto.v ? Date.now() - wsA.createdAt : null + await shot(step, 'a-autorun') + const env = await terminalEnv(step, A) + wsA.port = Number(env?.ORCA_PORT) + const serve = await waitFor(step, async () => (await httpOk(wsA.port)) === 200, 30_000) + sub.serverOnOrcaPort = Boolean(serve.v) + step.webPortA = wsA.port + const tabs = await store((id) => (window.__store.getState().tabsByWorktree?.[id] ?? []).map((t) => t.customTitle ?? t.title), wsA.id) + step.tabsA = tabs + sub.runTab = tabs.some((t) => /^Run: Web$/.test(t ?? '')) + sub.buttonShowsAction = /Web/.test(await runButton().innerText().catch(() => '')) + await shot(step, 'a-run-button') + // Stop. + const before = await runOf(wsA.id, 'web') + const t0 = Date.now() + await runMenuItem(step, 'Web', 'Stop') + const stopped = await waitFor(step, async () => (await runOf(wsA.id, 'web'))?.status === 'stopped', 15_000) + step.timings.stopToStoppedMs = stopped.v ? Date.now() - t0 : null + await runMenu(step) + sub.stoppedLabel = /Stopped/.test(step.lastRunMenu) + await shot(step, 'a-stopped-menu') + await page.keyboard.press('Escape') + const freed = await waitFor(step, async () => (await httpOk(wsA.port)) === null, 10_000) + sub.stopFreesPort = Boolean(freed.v) + sub.stop = Boolean(stopped.v) + // ⌥⌘↩ starts it again. + await focusTerminal() + const t1 = Date.now() + await page.keyboard.press('Meta+Alt+Enter') + const started = await waitFor(step, async () => (await runOf(wsA.id, 'web'))?.status === 'running' && (await httpOk(wsA.port)) === 200, 30_000) + step.timings.shortcutStartToServingMs = started.v ? Date.now() - t1 : null + sub.shortcutStarts = Boolean(started.v) + // ⌥⌘↩ while running restarts (new tab). + const tabBefore = (await runOf(wsA.id, 'web'))?.tabId + const t2 = Date.now() + await page.keyboard.press('Meta+Alt+Enter') + const restarted = await waitFor(step, async () => { + const r = await runOf(wsA.id, 'web') + return r?.status === 'running' && r.tabId !== tabBefore && (await httpOk(wsA.port)) === 200 + }, 30_000) + step.timings.shortcutRestartToServingMs = restarted.v ? Date.now() - t2 : null + sub.shortcutRestarts = Boolean(restarted.v) + // Menu Restart replaces the tab. + const tabBefore2 = (await runOf(wsA.id, 'web'))?.tabId + const t3 = Date.now() + await runMenuItem(step, 'Web', 'Restart') + const rr = await waitFor(step, async () => { + const r = await runOf(wsA.id, 'web') + return r?.status === 'running' && r.tabId !== tabBefore2 && (await httpOk(wsA.port)) === 200 + }, 30_000) + step.timings.menuRestartToServingMs = rr.v ? Date.now() - t3 : null + const runTabs = (await store((id) => (window.__store.getState().tabsByWorktree?.[id] ?? []).map((t) => t.customTitle ?? t.title), wsA.id)).filter((t) => /^Run: Web$/.test(t ?? '')) + sub.restartReplacesTab = Boolean(rr.v) && runTabs.length === 1 + step.runWebTabsAfterRestart = runTabs.length + await shot(step, 'a-restarted') + // C: setup fails -> no auto-run. + writeFileSync(path.join(created.tmp, 'fail-setup'), 'x') + const wsC = await createLocalWorktree(step, C) + const setupTab = await waitFor(step, async () => (await store((id) => (window.__store.getState().tabsByWorktree?.[id] ?? []).some((t) => /Setup/.test(t.customTitle ?? t.title ?? '')), wsC.id)) || null, 30_000) + await sleep(15_000) + await approveTrust(step) + const cRun = await runOf(wsC.id, 'web') + sub.noAutoRunAfterFailedSetup = Boolean(setupTab.v) && !cRun + step.failedSetup = { setupTabSeen: Boolean(setupTab.v), runRecord: cRun } + await shot(step, 'c-setup-failed') + rmSync(path.join(created.tmp, 'fail-setup'), { force: true }) + step.wsC = wsC + // Trust prompt for run actions shown exactly once in this run so far. + const runPrompts = (results.trustPrompts ?? []).filter((x) => x.kind === 'run') + sub.trustOnce = runPrompts.length === 1 + step.trustPromptsByKind = (results.trustPrompts ?? []).reduce((a, x) => ({ ...a, [x.kind]: (a[x.kind] ?? 0) + 1 }), {}) + step.status = Object.values(sub).every(Boolean) ? 'pass' : 'fail' + step.detail = `substeps ${JSON.stringify(sub)}; trust prompts ${JSON.stringify(step.trustPromptsByKind)}; create->auto-run ${step.timings.createToAutoRunMs}ms; Stop->"Stopped" ${step.timings.stopToStoppedMs}ms; ⌥⌘↩ start->serving ${step.timings.shortcutStartToServingMs}ms, restart->serving ${step.timings.shortcutRestartToServingMs}ms; menu Restart->serving ${step.timings.menuRestartToServingMs}ms (Run: Web tabs after: ${step.runWebTabsAfterRestart}); failed setup: ${JSON.stringify(step.failedSetup)}` + }) + + await runStep('run.nonconcurrent', 'echo-loop (nonconcurrent): starting it in workspace B stops it in workspace A', async (step) => { + if (!wsA.id) throw new Error('no workspace A') + Object.assign(wsB, await createLocalWorktree(step, B)) + await waitFor(step, async () => (await runOf(wsB.id, 'web'))?.status === 'running', 60_000) + // Start echo-loop in A. + await (await rowOf(A, 'local')).click() + await sleep(800) + await runMenuItem(step, 'Echo loop', 'Run') + const aOn = await waitFor(step, async () => (await runOf(wsA.id, 'echo-loop'))?.status === 'running', 20_000) + // Start echo-loop in B. + await (await rowOf(B, 'local')).click() + await sleep(800) + const t0 = Date.now() + await runMenuItem(step, 'Echo loop', 'Run') + const res = await waitFor(step, async () => { + const a = await runOf(wsA.id, 'echo-loop') + const b = await runOf(wsB.id, 'echo-loop') + return b?.status === 'running' && (!a || a.status === 'stopped' || a.status === 'exited') ? { a: a?.status ?? 'gone', b: b.status } : null + }, 20_000) + step.timings.bStartToAStoppedMs = res.v ? Date.now() - t0 : null + step.after = { a: (await runOf(wsA.id, 'echo-loop'))?.status ?? 'gone', b: (await runOf(wsB.id, 'echo-loop'))?.status ?? 'gone', webA: (await runOf(wsA.id, 'web'))?.status, webB: (await runOf(wsB.id, 'web'))?.status } + await runMenu(step) + await shot(step, 'b-menu') + await page.keyboard.press('Escape') + step.status = aOn.v && res.v ? 'pass' : 'fail' + step.detail = `A echo-loop running: ${Boolean(aOn.v)}; after starting it in B (${step.timings.bStartToAStoppedMs}ms): ${JSON.stringify(step.after)}. Note: a nonconcurrent start stops every other run of the repo in any workspace, so web (concurrent) in A and in B was stopped too` + }) + + await runStep('run.ports', 'ORCA_PORT/ORCA_PORT_COUNT/CONDUCTOR_PORT in every terminal; distinct blocks per workspace; stable across an app restart; cloud: served in the VM and auto-forwarded here', async (step) => { + const sub = {} + step.substeps = sub + const envA = await terminalEnv(step, A) + const envB = await terminalEnv(step, B) + step.env = { A: envA, B: envB } + sub.envVars = [envA, envB].every((e) => e && e.ORCA_PORT && e.ORCA_PORT_COUNT === '10' && e.CONDUCTOR_PORT === e.ORCA_PORT) + sub.distinctBlocks = envA?.ORCA_PORT !== envB?.ORCA_PORT && Math.abs(Number(envA?.ORCA_PORT) - Number(envB?.ORCA_PORT)) >= 10 + // The nonconcurrent start above stopped web everywhere; start it again in B from the Run button. + // echo-loop is B's primary now, so start Web from the menu ("Run again" after a past run). + await runMenuItem(step, 'Web', 'Run again').catch(async () => { + await page.keyboard.press('Escape').catch(() => {}) + await runMenuItem(step, 'Web', 'Run') + }) + const served = await waitFor(step, async () => (await runOf(wsB.id, 'web'))?.status === 'running' && (await httpOk(Number(envB?.ORCA_PORT))) === 200, 30_000) + step.timings.runButtonToServingMs = served.v ? served.ms : null + sub.localServe = Boolean(served.v) + const regFile = path.join(results.app.userData, 'workspace-port-blocks.json') + step.registry = existsSync(regFile) ? JSON.parse(readFileSync(regFile, 'utf8')) : null + portsBefore.A = envA?.ORCA_PORT + portsBefore.B = envB?.ORCA_PORT + // App restart, same profile. + const t0 = Date.now() + await stopApp() + await launchApp(results.app.userData, 'restart') + await installToastRecorder() + await page.getByText(A, { exact: true }).first().waitFor({ timeout: 30_000 }) + await sleep(2000) + step.timings.relaunchMs = Date.now() - t0 + const envA2 = await terminalEnv(step, A) + const envB2 = await terminalEnv(step, B) + step.envAfterRestart = { A: envA2, B: envB2 } + sub.stableAfterRestart = envA2?.ORCA_PORT === envA?.ORCA_PORT && envB2?.ORCA_PORT === envB?.ORCA_PORT + step.runsAfterRestart = { webA: (await runOf(wsA.id, 'web'))?.status ?? null, webB: (await runOf(wsB.id, 'web'))?.status ?? null, serveA: await httpOk(Number(envA?.ORCA_PORT)), serveB: await httpOk(Number(envB?.ORCA_PORT)) } + await shot(step, 'after-restart') + step.localSubstepsPass = Object.values(sub).every(Boolean) + step.status = step.localSubstepsPass ? 'pass' : 'fail' + step.detail = `local: ${JSON.stringify(sub)}; A ${JSON.stringify(envA)}, B ${JSON.stringify(envB)}; after app restart (${step.timings.relaunchMs}ms) A ${envA2?.ORCA_PORT}, B ${envB2?.ORCA_PORT}; runs after restart ${JSON.stringify(step.runsAfterRestart)}` + }) + + // ---- cloud: the same web action in a VM workspace, forwarded here; Sleep/wake and Restart bring it back. + await store(() => window.__store.getState().connectCurrentOrcaProfile()) + await sleep(1500) + let cws = null + let cloudPort = null + const cloudWebRunning = async () => (await runOf(cws.id, 'web'))?.status === 'running' + await runStep('run.ports.cloud', 'Cloud workspace: the web action listens on $ORCA_PORT in the VM and is reachable here through the auto-forward', async (step) => { + const w = await createExtraWorkspace(step, `${wsName}-cl`, { blankAgent: true }) + const wt = await worktreeByName(w.name) + cws = { ...w, id: wt?.id, path: wt?.path } + step.sandboxId = w.sandboxId + // The run actions come from the repo's primary checkout orca.yaml; put it there in the VM. + const repo = await store((id) => { const s = window.__store.getState(); const r = s.repos.find((x) => (s.worktreesByRepo?.[x.id] ?? []).some((t) => t.id === id)); return r ? { id: r.id, path: r.path } : null }, cws.id) + step.cloudRepo = repo + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + const yaml = runActionsYaml('/nonexistent-w9').replace(/^ setup:.*\n/m, '').replace(/^ autoRunAfterSetup:.*\n/m, '') + const b64 = Buffer.from(yaml).toString('base64') + await page.keyboard.type(`echo ${b64} | base64 -d > ${repo?.path ?? '/vercel/sandbox/project'}/orca.yaml && cp ${repo?.path ?? '/vercel/sandbox/project'}/orca.yaml ./orca.yaml && echo YAMLOK; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n`) + const env = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)\s*$/m) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.vmEnv = env.v + cloudPort = Number(env.v?.ORCA_PORT) + // Re-read run actions: switch away and back, plus a window focus event. + await (await rowOf(A, 'local')).click() + await sleep(800) + await (await rowOf(cws.name)).click() + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + const btn = await waitFor(step, async () => /Web/.test(await runButton().innerText().catch(() => '')), 30_000) + step.runButtonShown = Boolean(btn.v) + const t0 = Date.now() + await runButton().getByRole('button', { name: /^Run Web/ }).click() + const running = await waitFor(step, cloudWebRunning, 60_000) + step.timings.clickToRunningMs = running.v ? Date.now() - t0 : null + const fwd = await waitFor(step, async () => (await httpOk(cloudPort)) === 200, 60_000) + step.timings.clickToForwardedServingMs = fwd.v ? Date.now() - t0 : null + step.forwardToast = (await toasts()).find((x) => /Forwarded/.test(x)) ?? null + await shot(step, 'cloud-running') + step.status = env.v && env.v.ORCA_PORT_COUNT === '10' && env.v.CONDUCTOR_PORT === env.v.ORCA_PORT && running.v && fwd.v ? 'pass' : 'fail' + // Fold the cloud half into run.ports (one catalog row). + const rp = results.steps.find((x) => x.id === 'run.ports') + if (rp) { + rp.cloud = { status: step.status, vmEnv: env.v, timings: step.timings } + rp.status = rp.localSubstepsPass && step.status === 'pass' ? 'pass' : 'fail' + rp.screenshots.push(...step.screenshots.filter((x) => /cloud-running/.test(x))) + } + step.detail = `VM terminal ${JSON.stringify(env.v)}; Run Web -> running ${step.timings.clickToRunningMs}ms, reachable on this Mac at localhost:${cloudPort} ${step.timings.clickToForwardedServingMs}ms after the click (${step.forwardToast ? step.forwardToast.split('\n')[0] : 'no forward toast seen'}); repo ${JSON.stringify(step.cloudRepo)}` + if (rp) rp.detail += `; cloud: ${step.detail}` + }) + + await runStep('run.cloud-resume', 'Cloud: Sleep then click to wake brings the action back; Restart sandbox (rollover) brings it back', async (step) => { + if (!cws?.id) throw new Error('no cloud workspace') + step.sandboxId = cws.sandboxId + const sub = {} + step.substeps = sub + // Sleep. + await openRowMenu(cws.name) + await page.getByRole('menuitem', { name: /^(Suspend VM|Sleep)$/ }).first().click() + await page.getByRole('dialog').getByRole('button', { name: /^(Sleep|Suspend)/ }).click({ timeout: 1500 }).catch(() => {}) + const asleep = await waitSandboxStatus(cws.sandboxId, ['suspended', 'sleeping', 'stopped'], 180_000) + step.timings.sleepMs = asleep.ms + await sleep(2000) + const t0 = Date.now() + await (await rowOf(cws.name)).click() + const back = await waitFor(step, async () => (await cloudWebRunning()) && (await httpOk(cloudPort)) === 200, 180_000) + step.timings.wakeClickToServingMs = back.v ? Date.now() - t0 : null + sub.wakeResume = Boolean(back.v) + await shot(step, 'after-wake') + // Restart sandbox (rollover) from the row menu. + await openRowMenu(cws.name) + const t1 = Date.now() + await page.getByRole('menuitem', { name: /^Restart sandbox$/ }).click() + const confirm = page.getByRole('button', { name: /^Restart sandbox$/ }).filter({ visible: true }) + if (await confirm.first().isVisible({ timeout: 2500 }).catch(() => false)) await confirm.first().click() + // Wait for the restart to take effect (server goes away), then for it to come back. + await waitFor(step, async () => (await httpOk(cloudPort)) === null, 60_000) + const back2 = await waitFor(step, async () => (await cloudWebRunning()) && (await httpOk(cloudPort)) === 200, 180_000) + step.timings.restartClickToServingMs = back2.v ? Date.now() - t1 : null + sub.restartResume = Boolean(back2.v) + await shot(step, 'after-restart') + step.status = Object.values(sub).every(Boolean) ? 'pass' : 'fail' + step.detail = `Sleep -> ${asleep.status} in ${asleep.ms}ms; click to wake -> action serving again through the forward ${step.timings.wakeClickToServingMs}ms after the click; Restart sandbox -> serving again ${step.timings.restartClickToServingMs}ms after the click; ${JSON.stringify(sub)}` + }) + + if (cws) { + await runStep('life.destroy', 'Delete the cloud workspace', async (step) => { + step.sandboxId = cws.sandboxId + const o = await uiDelete(step, cws.name, 'delete', 120_000) + const w = await waitSandboxStatus(cws.sandboxId, ['destroyed'], 120_000) + step.status = o.kind === 'removed' && w.status === 'destroyed' ? 'pass' : 'fail' + step.detail = `UI Delete -> ${o.kind} in ${o.ms}ms; sandbox -> ${w.status}` + }) + } + // Stop local servers so nothing keeps listening after the run. + for (const n of [A, B, C]) { + if (await rowShown(n, 'local')) { + await openRowMenu(n, 'local').catch(() => {}) + await page.getByRole('menuitem', { name: /^Delete/ }).click().catch(() => {}) + await page.getByRole('dialog').getByRole('button', { name: /^(Force )?Delete( Workspace)?$/ }).first().click().catch(() => {}) + await waitRowGone(n, 'local', 30_000).catch(() => {}) + } + } +} + /** * W9D_MODE=targeted: picker sign-in (twice per process: fresh profile, then after a sign-out), * create + keystroke, Move to this computer (Archive afterwards on) then Delete within seconds, * and a normal Delete. */ async function targetedFlow() { + if (process.env.W9D_ONLY === 'run-ports2') { + await runPorts2Flow() + return + } + if (process.env.W9D_ONLY === 'run-actions') { + await runActionsFlow() + return + } if (process.env.W9D_ONLY === 'ui-audit') { await uiAuditFlow() return diff --git a/cloud/apps/acceptance/findings.json b/cloud/apps/acceptance/findings.json index 7d7520c0200..366d33600b2 100644 --- a/cloud/apps/acceptance/findings.json +++ b/cloud/apps/acceptance/findings.json @@ -1,6 +1,9 @@ { "findings": [ "Wake delivery (target p95 < 6 s) — MET in pdx1: after core U17/U18 (default region pdx1, Standard 8 vCPU/16 GB, golden 63b6999), n=20, persistent connection. Wake to echo p50 5.04 s, p95 5.85 s, p99 5.94 s; 0/20 slow hosts. Pinned to iad1 at 8/16: p50 5.31, p95 10.59, p99 11.80 s, with 4/20 slow hosts. BEFORE (iad1 4/8): 5.28 / 8.99 / 9.40 s. Region, not size, removes the tail. From this Mac the first terminal list (0.18 vs 0.44 s) and echo (0.07 vs 0.15 s) are also faster in pdx1. Create p50: pdx1 2.03 s, iad1-pinned 4.26 s (pinned creates seem to skip the warm pool).", + "run.ports (cloud) passes on a #233 golden (iad1 d323e4a): first workspace 3000/10/3000, a second worktree on the same VM 26600/10/26600, both unchanged after sleep/wake, both persisted in the VM registry. pdx1 still runs 2d99d9b (pre-#233) and gives a hash block until its golden rotates. Desktop/golden version skew is a launch risk to watch. run.nonconcurrent passes with #233 (only the same action stops, 471 ms).", + "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control is covered by life.read-asleep-access. The phone view was checked in W9's own headless simulator run: “Latest from this workspace · As of just now” showed the marker branch and NOTES.md, and Vercel stayed stopped.", + "Read-asleep access, triggered by W9 itself: the operator members/removed route revokes the member; their pre-removal token and a fresh token both get 401, and their wrapped keys go 1 to 0, while the other teammate still reads. Making the workspace private gives that teammate 404 and 0 keys. Phone protocol: device token plus mobile reader key reads a sleeping private workspace in 125 ms, and unpairing revokes access.", "Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html): five rows are capped at partial because they passed on a weaker bar than the Conductor column: real-model agent run, subscription sign-in, default machine size, and the download and remote-desktop UIs. Four rows Conductor has that our bar left out are added as not landed: local core workflow, Run actions with per-workspace ports, read-while-asleep from any device, and per-turn code checkpoints. Our 'pass' counts measure the team's own bar. Every cloud result is on dev Workers, Stripe TEST mode and dev/staging tokens; nothing is shipped (mvp-tracker §5 launch items are all open).", "Agents and machine size (2026-11-10): image.agents: Claude Code, Codex, Cursor, OpenCode, Gemini and Copilot all answer --version on a fresh VM. agent.keys-swap: stand-in Gemini/Cursor keys exist on the VM only as placeholders; the provider sees the swapped key (Gemini 400 API_KEY_INVALID, Cursor 401); a foreign key or a disallowed path is refused (403); the stand-in values appear in 0 files and 0 process environments. sub.size cap lifted: Standard is now 8 vCPU / 16 GB (core U18, #194), and machineSize large maps to standard.", "Audit caps lifted by real-UI runs (m4air, 855d8ed34b6, desktop-20261109-234651): acc.download, file-tree Download: 20 MiB file in 2.1 s and a folder as zip in 0.6 s, with real progress and success toasts and matching sha256. Caveat: only the macOS Save panel answer was stubbed in main, because CDP cannot drive a native panel. acc.remote-desktop: Open remote desktop tab connected in 3.4 s; noVNC canvas streams non-blank pixels, and a red xterm launched in the VM appeared on the canvas. local.core-workflow passes (local worktree in 0.6 s, terminal edit, diff in Source Control, stage, commit).", diff --git a/cloud/apps/acceptance/mobile/cdp.mjs b/cloud/apps/acceptance/mobile/cdp.mjs new file mode 100644 index 00000000000..33083975541 --- /dev/null +++ b/cloud/apps/acceptance/mobile/cdp.mjs @@ -0,0 +1,101 @@ +// Headless helpers over Metro's CDP for the Orca mobile dev client on a simulator (no GUI). +// Adapted from W8/gap5's /tmp/w8/cdp-*.mjs. Port: W9M_METRO_PORT (default 8131). +// node cdp.mjs identity-install stores {seedB64, enrollment} as the app's Orca Cloud identity +// node cdp.mjs identity-clear removes the stored identity (the app makes a fresh, unenrolled key) +// node cdp.mjs texts rendered text strings containing (read-only) +// node cdp.mjs eval '' +// Hermes's CDP does not await promises: the result is parked on a global and read back. +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { resolve, dirname } from 'node:path' + +const here = dirname(fileURLToPath(import.meta.url)) +const require = createRequire(resolve(here, '../../../../mobile/package.json')) +const WebSocket = require('ws') +const PORT = process.env.W9M_METRO_PORT ?? '8131' +const KEY = 'orca.cloud-device.v1' +const [mode, arg] = process.argv.slice(2) + +async function session() { + const targets = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + const target = targets.find((t) => t.appId === 'com.stably.orca.mobile') ?? targets[0] + if (!target) { + throw new Error('no CDP target (is the app running?)') + } + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((ok, fail) => { + ws.on('open', ok) + ws.on('error', fail) + }) + let n = 0 + const pending = new Map() + ws.on('message', (data) => { + const msg = JSON.parse(String(data)) + pending.get(msg.id)?.(msg) + }) + const evaluate = (expression) => + new Promise((ok) => { + const id = ++n + pending.set(id, ok) + ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true } })) + }).then((m) => m.result?.result?.value ?? m.result?.exceptionDetails?.text ?? null) + return { evaluate, close: () => ws.close() } +} + +const timer = setTimeout(() => { + console.log('"cdp timeout"') + process.exit(2) +}, 20000) +const s = await session() +try { + if (mode === 'identity-install' || mode === 'identity-clear') { + const value = mode === 'identity-install' ? readFileSync(arg, 'utf8') : null + await s.evaluate(`(function () { + globalThis.__w9Identity = { pending: true } + var m = globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.ExpoSecureStore + if (!m) { globalThis.__w9Identity = { error: 'no ExpoSecureStore' }; return } + var options = { keychainAccessible: m.WHEN_UNLOCKED_THIS_DEVICE_ONLY } + var work = ${JSON.stringify(value)} === null + ? m.deleteValueWithKeyAsync(${JSON.stringify(KEY)}, options) + : m.setValueWithKeyAsync(${JSON.stringify(value)}, ${JSON.stringify(KEY)}, options) + work.then(function () { globalThis.__w9Identity = { ok: true } }, function (e) { globalThis.__w9Identity = { error: String(e) } }) + })()`) + await new Promise((r) => setTimeout(r, 1500)) + console.log(await s.evaluate('JSON.stringify(globalThis.__w9Identity)')) + } else if (mode === 'texts') { + console.log( + await s.evaluate(`(() => { + const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (!hook) return '"no hook"' + const out = new Set() + const take = (v) => { + if (typeof v === 'string' && v.includes(${JSON.stringify(arg ?? '')})) out.add(v) + else if (Array.isArray(v)) v.forEach(take) + } + for (const id of hook.renderers.keys()) { + const roots = hook.getFiberRoots(id) + roots && roots.forEach((r) => { + const stack = [r.current] + while (stack.length) { + const f = stack.pop() + if (!f) continue + if (f.memoizedProps) { take(f.memoizedProps.children); take(f.memoizedProps.text); take(f.memoizedProps.accessibilityLabel) } + if (f.child) stack.push(f.child) + if (f.sibling) stack.push(f.sibling) + } + }) + } + return JSON.stringify([...out].slice(0, 80)) + })()`) + ) + } else if (mode === 'eval') { + console.log(JSON.stringify(await s.evaluate(arg))) + } else { + console.log('usage: cdp.mjs identity-install | identity-clear | texts | eval ') + } +} finally { + s.close() + clearTimeout(timer) +} +process.exit(0) diff --git a/cloud/apps/acceptance/mobile/read-asleep-sim.ts b/cloud/apps/acceptance/mobile/read-asleep-sim.ts new file mode 100644 index 00000000000..d20f8b10c6a --- /dev/null +++ b/cloud/apps/acceptance/mobile/read-asleep-sim.ts @@ -0,0 +1,135 @@ +// W9 simulator run for life.read-asleep-phone: the Orca mobile app shows a SLEEPING workspace's +// "Latest from this workspace" without waking it. Driven in phases so the sim steps (CDP identity +// install, deep link, screenshot) run between them; see run-read-asleep-sim.sh. +// tsx mobile/read-asleep-sim.ts enroll device session (Ed25519 phone key) for the owner; writes the identity file (0600) +// tsx mobile/read-asleep-sim.ts readerkey waits for the app's own mobile reader key (GET /v1/reader-keys) +// tsx mobile/read-asleep-sim.ts workspace creates the workspace, writes NOTES.md + a commit with a marker, sleeps it +// tsx mobile/read-asleep-sim.ts provider Vercel GET of the sandbox (no-wake check) +// tsx mobile/read-asleep-sim.ts cleanup destroys the sandbox, revokes the device session + reader key, Vercel 404 check +// State (ids, timings; no secrets) lives in $W9M_DIR/state.json; the phone seed only in identity.json (0600). +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto' +import { control, vercel, vercelRest } from '../src/clients' +import { config } from '../src/env' +import type { Identity } from '../src/tokens' + +const DIR = process.env.W9M_DIR ?? '/tmp/w9m' +const STATE = join(DIR, 'state.json') +const owner: Identity = { sub: process.env.W9M_OWNER ?? 'w9-mobile' } +const state: Record = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {} +const save = () => writeFileSync(STATE, JSON.stringify(state, null, 2)) +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const providerStatus = async (id: string) => { + const r = await vercelRest('GET', `/v2/sandboxes/orca-dev-${id}`, { projectId: config.vercelProjectId }) + return { http: r.status, sandbox: r.json?.sandbox?.status ?? null, session: r.json?.session?.status ?? null } +} + +const phase = process.argv[2] +if (phase === 'enroll') { + // The app's identity is a 32-byte Ed25519 seed (tweetnacl fromSeed == RFC 8032 private key). + const seed = randomBytes(32) + const der = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]) + const pub = Buffer.from(createPublicKey(createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })).export({ format: 'jwk' }).x!, 'base64') + const t0 = performance.now() + const r = await control.as(owner, 'POST', '/v1/device-sessions', { label: 'W9 eval iPhone (sim)', platform: 'ios', publicKeyB64: pub.toString('base64') }) + const ds = r.body?.deviceSession?.id + console.log('enroll', r.status, ds ?? JSON.stringify(r.body).slice(0, 200)) + if (!ds) process.exit(1) + writeFileSync( + join(DIR, 'identity.json'), + JSON.stringify({ seedB64: seed.toString('base64'), enrollment: { controlPlaneUrl: config.controlPlaneUrl, deviceSessionId: ds, label: 'W9 eval iPhone (sim)', enrolledAt: Date.now() } }), + { mode: 0o600 } + ) + Object.assign(state, { owner: `dev:${owner.sub}`, deviceSessionId: ds, enrollMs: Math.round(performance.now() - t0) }) + save() +} else if (phase === 'readerkey') { + for (let i = 0; i < 40; i++) { + const r = await control.as(owner, 'GET', '/v1/reader-keys') + const keys: any[] = r.body?.keys ?? r.body?.readerKeys ?? [] + const mobile = keys.find((k) => (k.deviceKind ?? k.kind) === 'mobile') + if (mobile) { + state.readerKeyId = mobile.id + save() + console.log('reader key', mobile.id, mobile.deviceKind ?? mobile.kind, mobile.deviceLabel ?? mobile.label) + process.exit(0) + } + if (i === 0) console.log('reader-keys', r.status, `${keys.length} keys`) + await sleep(3000) + } + console.log('no mobile reader key registered by the app') + process.exit(1) +} else if (phase === 'workspace') { + const marker = `W9MOB${randomBytes(3).toString('hex').toUpperCase()}` + const t0 = performance.now() + const created = await control.as(owner, 'POST', '/v1/sandboxes', { + workspaceId: `w9-mobile-${marker.toLowerCase()}`, + machineSize: 'standard', + title: `W9 phone read ${marker}`, + repo: { url: 'https://github.com/octocat/Hello-World' } + }) + const id = created.body?.sandbox?.id + console.log('create', created.status, created.ms, 'ms', id ?? JSON.stringify(created.body).slice(0, 200)) + if (!id) process.exit(1) + Object.assign(state, { marker, sandboxId: id, createMs: created.ms }) + save() + const root = created.body.connection.projectRoot + const script = [ + `cd ${root}`, + `git checkout -q -b w9-${marker}`, + `printf '# Notes\\n\\nW9 phone read-asleep marker: ${marker}\\n' > NOTES.md`, + `git add NOTES.md`, + `git -c user.name='W9 eval' -c user.email='w9@example.invalid' commit -q -m 'Add NOTES.md (${marker})'`, + `printf 'Edited after the commit: ${marker}\\n' >> NOTES.md`, + `git log --oneline -1; git status --short; git branch --show-current` + ].join(' && ') + const ex: any = await vercel().runCommand(created.body.sandbox.session!.id, { command: 'bash', args: ['-c', script], timeoutMs: 60_000 }) + const out = String(ex?.stdout ?? ex?.output ?? JSON.stringify(ex)).trim() + console.log('vm:', out.slice(0, 400)) + state.vmOutput = out.slice(0, 400) + const s0 = performance.now() + const sus = await control.as(owner, 'POST', `/v1/sandboxes/${id}/suspend`) + state.suspendStatus = sus.status + state.suspendMs = Math.round(performance.now() - s0) + state.readSnapshotAt = sus.body?.sandbox?.readSnapshotAt ?? null + state.afterSuspend = await providerStatus(id) + state.workspaceSetupMs = Math.round(performance.now() - t0) + save() + console.log('suspend', sus.status, state.suspendMs, 'ms; provider', JSON.stringify(state.afterSuspend)) + const g = await control.as(owner, 'GET', `/v1/sandboxes/${id}`) + console.log('control-plane view', g.body?.sandbox?.status, 'readSnapshotAt', g.body?.sandbox?.readSnapshotAt ?? g.body?.readSnapshotAt ?? '?') +} else if (phase === 'provider') { + const p = await providerStatus(state.sandboxId) + const g = await control.as(owner, 'GET', `/v1/sandboxes/${state.sandboxId}`) + const label = process.argv[3] ?? 'check' + state.provider = { ...(state.provider ?? {}), [label]: { ...p, controlPlane: g.body?.sandbox?.status, at: new Date().toISOString() } } + save() + console.log(label, JSON.stringify(state.provider[label])) +} else if (phase === 'cleanup') { + const out: Record = {} + if (state.sandboxId) { + const d = await control.as(owner, 'DELETE', `/v1/sandboxes/${state.sandboxId}`) + out.destroy = d.status + for (let i = 0; i < 20; i++) { + const p = await providerStatus(state.sandboxId) + out.vercelAfterDestroy = p.http + if (p.http === 404) break + await sleep(3000) + } + } + if (state.deviceSessionId) { + out.revokeDeviceSession = (await control.as(owner, 'DELETE', `/v1/device-sessions/${state.deviceSessionId}`)).status + } + if (state.readerKeyId) { + out.readerKeyDelete = (await control.as(owner, 'DELETE', `/v1/reader-keys/${state.readerKeyId}`)).status + } + const keys = await control.as(owner, 'GET', '/v1/reader-keys') + out.liveReaderKeysLeft = (keys.body?.keys ?? keys.body?.readerKeys ?? []).length + const list = await control.as(owner, 'GET', '/v1/sandboxes') + out.liveSandboxesLeft = (list.body?.sandboxes ?? []).filter((s: any) => !['destroyed', 'deleted'].includes(s.status)).length + state.cleanup = out + save() + console.log(JSON.stringify(out)) +} else { + console.log('phases: enroll | readerkey | workspace | provider [label] | cleanup') +} diff --git a/cloud/apps/acceptance/src/catalog.ts b/cloud/apps/acceptance/src/catalog.ts index cad54adbc80..2ac4c349064 100644 --- a/cloud/apps/acceptance/src/catalog.ts +++ b/cloud/apps/acceptance/src/catalog.ts @@ -40,8 +40,14 @@ export const CATALOG: Feature[] = [ { id: 'billing.entitlement-gate', section: S.acct, feature: 'Account without a plan is refused a sandbox (fail closed)', conductor: 'Pro/org required', owner: 'core', priority: 'P0' }, // Conductor's local core workflow (added after the independent audit; the cloud matrix left it out) { id: 'local.core-workflow', section: S.acct, feature: 'Local core workflow: create a local worktree, run an agent, see the diff, commit (desktop)', conductor: 'Core local app (free)', owner: 'w9-eval (m4air desktop run)', priority: 'P0' }, - { id: 'local.run-actions', section: S.acct, feature: 'Run actions: named run scripts (several, run_mode, auto-run after setup) with a per-workspace port block; Conductor import keeps them', conductor: '0.70 multiple run scripts, CONDUCTOR_PORT 10-port block', owner: 'gap-run-actions', priority: 'P0' }, + { id: 'local.run-actions', section: S.acct, feature: 'Run actions: orca.yaml scripts.run on the Run button, "Run: " terminal, trust prompt once, Stop (Ctrl+C → Stopped), Restart, ⌥⌘↩, autoRunAfterSetup only after a successful setup', conductor: '0.70 multiple run scripts, ⌘R, auto-run after setup', owner: 'gap-run-actions', priority: 'P0' }, + { id: 'run.ports', section: S.acct, feature: 'Per-workspace port block: ORCA_PORT / ORCA_PORT_COUNT=10 / CONDUCTOR_PORT, distinct per workspace, stable across restarts; Vite via Run listens on $ORCA_PORT locally and in cloud (auto-forward)', conductor: 'CONDUCTOR_PORT 10-port block', owner: 'gap-run-actions', priority: 'P0' }, + { id: 'run.nonconcurrent', section: S.acct, feature: 'runMode nonconcurrent stops the same action in another workspace first', conductor: 'run_mode', owner: 'gap-run-actions', priority: 'P1' }, + { id: 'run.cloud-resume', section: S.life, feature: 'A running action comes back after wake and after rollover in a cloud workspace', conductor: 'Restart run scripts after wake (manual)', owner: 'gap-run-actions', priority: 'P0', better: 'Comes back by itself' }, + { id: 'run.conductor-import', section: S.acct, feature: 'Conductor importer keeps run / run_mode / auto_run_after_setup (settings.toml, settings.local.toml, conductor.json)', conductor: 'n/a (migration)', owner: 'gap-run-actions', priority: 'P0' }, { id: 'life.read-asleep', section: S.life, feature: 'Chats, PR comments and check logs readable while asleep from any device (no wake)', conductor: '0.80/0.85, server-held', owner: 'gap-read-asleep', priority: 'P0' }, + { id: 'life.read-asleep-access', section: S.life, feature: 'Read-while-asleep access control: removed member loses access at once (keys deleted), private workspace is owner-only', conductor: 'Org access to server-held chats', owner: 'gap-read-asleep', priority: 'P0', better: 'End-to-end encrypted; server holds only ciphertext' }, + { id: 'life.read-asleep-phone', section: S.life, feature: 'Phone shows "Latest from this workspace" for a sleeping workspace without waking it', conductor: 'iOS app reads server-held chats', owner: 'gap-read-asleep', priority: 'P1' }, { id: 'local.checkpoints', section: S.acct, feature: 'Per-turn code checkpoints: revert restores files and chat together', conductor: '0.19 (Claude), 0.44 (Codex) checkpoints', owner: 'gap-checkpoints', priority: 'P0' }, // 1. Substrate { id: 'sub.create', section: S.sub, feature: 'Hosted microVM per workspace, one call / one click', conductor: '0.78 GA', owner: 'core', priority: 'P0' }, diff --git a/cloud/apps/acceptance/src/checks/billing.ts b/cloud/apps/acceptance/src/checks/billing.ts index 43e7be21b75..b245d691073 100644 --- a/cloud/apps/acceptance/src/checks/billing.ts +++ b/cloud/apps/acceptance/src/checks/billing.ts @@ -36,7 +36,7 @@ function d1(dir: string, binding: string, sql: string): any[] { return (JSON.parse(out.slice(out.indexOf('['))) as Array<{ results: any[] }>)[0]?.results ?? [] } const d1Billing = (sql: string) => d1(BILLING_DIR, 'BILLING_DB', sql) -const d1Control = (sql: string) => d1(new URL('../../../sandbox-control/', import.meta.url).pathname, 'SANDBOX_DB', sql) +export const d1Control = (sql: string) => d1(new URL('../../../sandbox-control/', import.meta.url).pathname, 'SANDBOX_DB', sql) async function advanceClock(clock: string, to: number): Promise { await stripe('POST', `/v1/test_helpers/test_clocks/${clock}/advance`, { frozen_time: Math.floor(to) }) diff --git a/cloud/apps/acceptance/src/checks/lifecycle.ts b/cloud/apps/acceptance/src/checks/lifecycle.ts index 75b36de0f89..ddb423f80c7 100644 --- a/cloud/apps/acceptance/src/checks/lifecycle.ts +++ b/cloud/apps/acceptance/src/checks/lifecycle.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { control } from '../clients' import { config } from '../env' import { log, type Check, type Ctx } from '../context' -import { orca, openTerminal } from '../orca-cli' +import { orca, openTerminal, typeAndSee } from '../orca-cli' import { sandboxToken } from '../tokens' const ARCHIVE = { 'x-orca-sandbox-features': 'archive' } @@ -318,6 +318,55 @@ export const lifecycleChecks: Record = { } }, + + 'run.ports': async (ctx) => { + // Cloud half of per-workspace ports (#216/#233), headless over the pairing: the VM's first workspace + // gets the launcher block (3000), a second worktree on the same VM gets its own registry block, both + // are persisted and survive sleep/wake. The local half (distinct blocks, stable across app restart) + // was proven in the real app on m4air (desktop-20261110-033734). + const box = await ctx.sandboxes.create(ctx.solo, { workspaceId: `w9-ports-${ctx.run.file.runId}`, repo: { url: config.testRepo }, ...(process.env.ORCA_ACCEPTANCE_REGION ? { region: process.env.ORCA_ACCEPTANCE_REGION } : {}) }) + if (box.status !== 200) { + return { status: 'fail', detail: `create ${box.status}` } + } + const id = box.body.sandbox.id + let code = box.body.connection.pairingCode as string + const root = box.body.connection.projectRoot as string + try { + const first = await openTerminal(code, root) + const worktree: string | undefined = first.term.json?.result?.terminal?.worktreeId + const repoId = worktree?.split('::')[0] + const second = await orca(code, ['worktree', 'create', '--repo', `id:${repoId}`, '--name', 'w9-second']) + const secondPath: string | undefined = second.json?.result?.worktree?.path + const portsIn = async (path: string, label: string) => { + const t = await orca(code, ['terminal', 'create', '--worktree', `path:${path}`, '--title', label]) + const handle = t.json?.result?.terminal?.handle + if (!handle) { + return `no-terminal(${t.stderr.slice(0, 80)})` + } + const seen = await typeAndSee(code, handle, `echo PORTS_\${ORCA_PORT}_\${ORCA_PORT_COUNT}_\${CONDUCTOR_PORT}_END`, '_END') + return /PORTS_(\d+)_(\d+)_(\d+)_END/.exec(seen.output)?.slice(1).join('/') ?? `unseen(${seen.error})` + } + const a1 = await portsIn(root, 'first') + const b1 = secondPath ? await portsIn(secondPath, 'second') : `no second worktree (${second.stderr.slice(0, 120) || JSON.stringify(second.json?.error ?? '').slice(0, 120)})` + const registry = (await ctx.exec((await ctx.sandboxes.get(id)).body.sandbox.session!.id, 'cat /vercel/.orcad-data/workspace-port-blocks.json 2>/dev/null')).stdout.trim() + await ctx.sandboxes.suspend(id) + const w = await ctx.sandboxes.resume(id) + code = w.body.connection.pairingCode + const a2 = await portsIn(root, 'first-after') + const b2 = secondPath ? await portsIn(secondPath, 'second-after') : 'n/a' + const aPort = Number(a1.split('/')[0]) + const bPort = Number(b1.split('/')[0]) + const ok = + aPort === 3000 && a1.endsWith('/10/3000') && bPort >= 20000 && bPort <= 31999 && bPort !== aPort && a1 === a2 && b1 === b2 + return { + status: ok ? 'pass' : 'fail', + detail: `cloud VM ports (ORCA_PORT/COUNT/CONDUCTOR_PORT): first workspace ${a1}, second worktree ${b1}; after sleep/wake ${a2} and ${b2}; registry ${registry.slice(0, 200)}. Local blocks (distinct, stable across restart) proven on m4air desktop-20261110-033734.`, + evidence: [log('port registry', registry)] + } + } finally { + await ctx.sandboxes.destroy(id) + } + }, 'acc.ssh': async (ctx) => { const b = await refresh(ctx) const { publicKey } = generateKeyPairSync('ed25519') diff --git a/cloud/apps/acceptance/src/checks/platform.ts b/cloud/apps/acceptance/src/checks/platform.ts index 644f87bd324..1779c162630 100644 --- a/cloud/apps/acceptance/src/checks/platform.ts +++ b/cloud/apps/acceptance/src/checks/platform.ts @@ -5,7 +5,8 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SignJWT } from 'jose' -import { control } from '../clients' +import { control, vercelRest } from '../clients' +import { d1Control } from './billing' import { config, secrets } from '../env' import { log, type Check, type Ctx } from '../context' import { orca } from '../orca-cli' @@ -119,6 +120,24 @@ function openGrant(sealed: any, grantId: string, pair: ReturnType): any { + const aesOpen = (key: Buffer, nonceB64: string, ctB64: string, aad: string) => { + const ct = Buffer.from(ctB64, 'base64') + const d = createDecipheriv('aes-256-gcm', key, Buffer.from(nonceB64, 'base64')) + d.setAAD(Buffer.from(aad)) + d.setAuthTag(ct.subarray(ct.length - 16)) + return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]) + } + const r = s.recipient + const eph = Buffer.from(r.ephemeralPublicKeyB64, 'base64') + const ephKey = createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: eph.toString('base64url') }, format: 'jwk' }) + const shared = diffieHellman({ privateKey: pair.privateKey, publicKey: ephKey }) + const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.concat([eph, pair.publicRaw]), Buffer.from('orca-cloud-read-wrap-v1'), 32)) + const dek = aesOpen(kek, r.nonceB64, r.wrappedKeyB64, `orca-cloud-read-wrap-v1:${s.workspaceId}:${s.snapshotId}:${r.keyId}`) + return JSON.parse(aesOpen(dek, s.nonceB64, s.ciphertextB64, `orca-cloud-read-v1:${s.workspaceId}:${s.snapshotId}`).toString('utf8')) +} + let teamWs: Promise<{ shared: string; priv: string }> | null = null function teamWorkspaces(ctx: Ctx) { teamWs ??= (async () => { @@ -340,6 +359,170 @@ export const platformChecks: Record = { return { status: ok ? 'pass' : 'fail', detail: `cost in list: compute ${w?.cost?.computeMs} ms, $${((w?.cost?.totalUsdMicros ?? 0) / 1e6).toFixed(4)}` } }, + + 'life.read-asleep': async (ctx) => { + // Gap 5: a teammate's device reads a SLEEPING team workspace (git state, agents, chats) without + // waking it; end-to-end encrypted to the device's X25519 reader key. Also checks that an outsider + // is refused and that the control-plane DB holds no plaintext (a marker file name never appears). + const tag = ctx.run.file.runId.replace(/[^0-9]/g, '').slice(-8) + const marker = `w9readmarker${tag}` + const pair = x25519Pair() + const reg = await control.as(ctx.member, 'PUT', '/v1/reader-keys', { publicKeyB64: pair.publicRaw.toString('base64'), deviceLabel: 'w9 reader', deviceKind: 'desktop' }) + const keyId = reg.body?.key?.id ?? reg.body?.readerKey?.id ?? reg.body?.id + if (!keyId) { + return { status: 'fail', detail: `reader key ${reg.status} ${JSON.stringify(reg.body).slice(0, 200)}` } + } + const box = await ctx.sandboxes.create(ctx.owner, { workspaceId: `w9-read-${tag}`, title: 'W9 read asleep', visibility: 'team', repo: { url: config.testRepo }, useEnvironment: false }) + try { + if (box.status !== 200) { + return { status: 'fail', detail: `create ${box.status} ${JSON.stringify(box.body).slice(0, 200)}` } + } + const id = box.body.sandbox.id + await ctx.exec(box.body.sandbox.session!.id, `cd ${box.body.connection.projectRoot} && echo hi > ${marker}.txt`) + const sus = await ctx.sandboxes.suspend(id) + const provider = async () => (await vercelRest('GET', `/v2/sandboxes/orca-dev-${id}`, { projectId: config.vercelProjectId })).json + const before = await provider() + const samples: number[] = [] + let payload: any = null + let err = '' + for (let i = 0; i < 20; i++) { + const t0 = performance.now() + const r = await control.as(ctx.member, 'GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`) + const s = r.body?.snapshot + if (!s) { + err = `${r.status} ${JSON.stringify(r.body).slice(0, 160)}` + break + } + payload = openReadSnapshot(s, pair) + samples.push(Math.round(performance.now() - t0)) + } + const after = await provider() + const stillStopped = !['running', 'pending'].includes(after?.session?.status ?? after?.sandbox?.status ?? '') + const outsider = await control.as(ctx.outsider, 'GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`) + const text = JSON.stringify(payload ?? '') + const sawMarker = text.includes(marker) + const dbRows = d1Control(`SELECT COUNT(*) AS n FROM read_snapshots WHERE sandbox_id = '${id}' AND (ciphertext LIKE '%${marker}%')`) + const plaintextInDb = Number(dbRows[0]?.n ?? 0) > 0 + if (samples.length) { + ctx.run.bench('read.asleep', samples, 'teammate fetch + decrypt of a sleeping workspace snapshot, from this Mac') + } + const sorted = [...samples].sort((a, b) => a - b) + const ok = sus.status === 200 && samples.length === 20 && payload?.workspaceId === id && sawMarker && stillStopped && outsider.status >= 400 && !plaintextInDb + return { + status: ok ? 'pass' : 'fail', + detail: err + ? `read-snapshot: ${err}` + : `teammate decrypted the sleeping workspace's snapshot (sections: ${Object.keys(payload ?? {}).join(', ')}; git shows the new file: ${sawMarker}); 20 reads p50 ${sorted[9]} ms, max ${sorted.at(-1)} ms; Vercel session ${before?.session?.status ?? before?.sandbox?.status} -> ${after?.session?.status ?? after?.sandbox?.status} (no wake: ${stillStopped}); outsider -> ${outsider.status}; plaintext marker in D1: ${plaintextInDb}`, + ...(sorted.length ? { timingsMs: { readP50: sorted[9]!, readMax: sorted.at(-1)! } } : {}) + } + } finally { + await control.as(ctx.member, 'DELETE', `/v1/reader-keys/${keyId}`) + if (box.status === 200) { + await ctx.sandboxes.destroy(box.body.sandbox.id) + } + } + }, + + 'life.read-asleep-access': async (ctx) => { + // Access control for reading a sleeping workspace, triggered by W9 itself on dev: the operator + // member-removed route (what auth calls) revokes a member at once; making the workspace private + // prunes the other teammate. Checks the API answer and the wrapped keys left in D1. + const tag = ctx.run.file.runId.replace(/[^0-9]/g, '').slice(-8) + const org = `w9-rm-${tag}` + const owner = { sub: `w9-rm-owner-${tag}`, org, orgRole: 'owner' as const } + const m1 = { sub: `w9-rm-m1-${tag}`, org, orgRole: 'member' as const } + const m2 = { sub: `w9-rm-m2-${tag}`, org, orgRole: 'member' as const } + const k1 = x25519Pair() + const k2 = x25519Pair() + const reg = async (who: typeof m1, k: ReturnType) => { + const r = await control.as(who, 'PUT', '/v1/reader-keys', { publicKeyB64: k.publicRaw.toString('base64'), deviceLabel: 'w9', deviceKind: 'desktop' }) + return (r.body?.key?.id ?? r.body?.readerKey?.id ?? r.body?.id) as string + } + const id1 = await reg(m1, k1) + const id2 = await reg(m2, k2) + const box = await ctx.sandboxes.create(owner, { workspaceId: `w9-rm-${tag}`, title: 'W9 access', visibility: 'team', useEnvironment: false }) + try { + if (box.status !== 200 || !id1 || !id2) { + return { status: 'fail', detail: `setup: create ${box.status}, keys ${id1}/${id2}` } + } + const id = box.body.sandbox.id + await ctx.sandboxes.suspend(id) + const read = async (who: typeof m1, keyId: string, token?: string) => + token ? control.raw('GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`, token) : control.as(who, 'GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`) + const wrappedFor = (keyId: string) => Number(d1Control(`SELECT COUNT(*) AS n FROM read_snapshot_keys WHERE key_id = '${keyId}'`)[0]?.n ?? 0) + const before1 = (await read(m1, id1)).status + const before2 = (await read(m2, id2)).status + const m1Token = await sandboxToken(m1) // minted before the removal, like a device's live token + const wrapped1Before = wrappedFor(id1) + const removed = await control.operator('POST', '/v1/internal/members/removed', { accountId: `dev:org:${org}`, userId: m1.sub }) + const after1Old = (await read(m1, id1, m1Token)).status + const after1New = (await read(m1, id1)).status + const wrapped1After = wrappedFor(id1) + const after2 = (await read(m2, id2)).status + // Private: only the owner may read; the remaining teammate is pruned at once. + const priv = await control.as(owner, 'PATCH', `/v1/team/workspaces/${id}`, { visibility: 'private' }) + const after2Private = (await read(m2, id2)).status + const wrapped2After = wrappedFor(id2) + const ok = + before1 === 200 && before2 === 200 && wrapped1Before > 0 && removed.status === 200 && after1Old === 401 && after1New >= 400 && + wrapped1After === 0 && after2 === 200 && priv.status === 200 && after2Private >= 400 && wrapped2After === 0 + return { + status: ok ? 'pass' : 'fail', + detail: `team workspace asleep: m1 ${before1}, m2 ${before2}; operator members/removed(m1) -> ${removed.status} ${JSON.stringify(removed.body).slice(0, 80)}; m1 with pre-removal token -> ${after1Old}, fresh token -> ${after1New}; m1 wrapped keys ${wrapped1Before} -> ${wrapped1After}; m2 still reads -> ${after2}; made private (${priv.status}) -> m2 ${after2Private}, m2 wrapped keys -> ${wrapped2After}` + } + } finally { + await control.as(m1, 'DELETE', `/v1/reader-keys/${id1}`).catch(() => null) + await control.as(m2, 'DELETE', `/v1/reader-keys/${id2}`).catch(() => null) + if (box.status === 200) { + await ctx.sandboxes.destroy(box.body.sandbox.id) + } + } + }, + + 'life.read-asleep-phone': async (ctx) => { + // The phone's path, headless: a device session (Ed25519, like the app enrolls), a mobile reader + // key registered WITH the device token, then the sleeping workspace's snapshot fetched with the + // device token and decrypted. The phone UI rendering is a separate simulator run. + const tag = ctx.run.file.runId.replace(/[^0-9]/g, '').slice(-8) + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const pub = Buffer.from(publicKey.export({ format: 'jwk' }).x!, 'base64') + const enroll = await control.as(ctx.owner, 'POST', '/v1/device-sessions', { label: 'w9 phone', platform: 'ios', publicKeyB64: pub.toString('base64') }) + const ds = enroll.body?.deviceSession?.id + if (!ds) { + return { status: 'fail', detail: `enroll ${enroll.status} ${JSON.stringify(enroll.body).slice(0, 160)}` } + } + const deviceToken = async () => + new SignJWT({}).setProtectedHeader({ alg: 'EdDSA', kid: ds }).setIssuer('orca-device').setSubject(ds).setAudience('orca-sandbox').setIssuedAt().setExpirationTime('4m').sign(createPrivateKey(privateKey.export({ format: 'pem', type: 'pkcs8' }))) + const pair = x25519Pair() + const reg = await control.raw('PUT', '/v1/reader-keys', await deviceToken(), { publicKeyB64: pair.publicRaw.toString('base64'), deviceLabel: 'w9 phone', deviceKind: 'mobile' }) + const keyId = (reg.body?.key?.id ?? reg.body?.readerKey?.id ?? reg.body?.id) as string | undefined + const box = await ctx.sandboxes.create(ctx.owner, { workspaceId: `w9-phone-${tag}`, title: 'W9 phone read', visibility: 'private', useEnvironment: false }) + try { + if (!keyId || box.status !== 200) { + return { status: 'fail', detail: `reader key ${reg.status} ${JSON.stringify(reg.body).slice(0, 120)}; create ${box.status}` } + } + const id = box.body.sandbox.id + await ctx.sandboxes.suspend(id) + const t0 = performance.now() + const r = await control.raw('GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`, await deviceToken()) + const payload = r.body?.snapshot ? openReadSnapshot(r.body.snapshot, pair) : null + const ms = Math.round(performance.now() - t0) + const after = (await vercelRest('GET', `/v2/sandboxes/orca-dev-${id}`, { projectId: config.vercelProjectId })).json + const stopped = !['running', 'pending'].includes(after?.session?.status ?? after?.sandbox?.status ?? '') + await control.as(ctx.owner, 'DELETE', `/v1/device-sessions/${ds}`) + const revoked = await control.raw('GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`, await deviceToken()) + const ok = reg.status < 300 && payload?.workspaceId === id && stopped && revoked.status >= 400 + return { + status: ok ? 'pass' : 'fail', + detail: `phone path headless: device session ${enroll.status}; mobile reader key registered with the device token -> ${reg.status}; private sleeping workspace read with the device token + decrypted in ${ms} ms (sections ${Object.keys(payload ?? {}).join(', ')}); no wake=${stopped}; after unpairing the phone -> ${revoked.status}. The phone UI ("Latest from this workspace") is the simulator run.`, + timingsMs: { phoneRead: ms } + } + } finally { + if (box.status === 200) { + await ctx.sandboxes.destroy(box.body.sandbox.id) + } + } + }, // ---------------- W5 ---------------- 'api.rest': async (ctx) => { const created = await api(ctx, 'POST', '/v1/workspaces', { name: `w9-api-${tag(ctx)}`, repo: { url: config.testRepo } }) diff --git a/cloud/apps/acceptance/src/golden-status.ts b/cloud/apps/acceptance/src/golden-status.ts index fc9cc9b6cb0..ac01e674d6a 100644 --- a/cloud/apps/acceptance/src/golden-status.ts +++ b/cloud/apps/acceptance/src/golden-status.ts @@ -32,7 +32,18 @@ function contains(commit: string, ancestor: string): boolean { } for (;;) { - const a = active() + let a: ReturnType = null + try { + a = active() + } catch (error) { + // Transient Cloudflare API/auth errors: keep waiting rather than dying. + console.log(JSON.stringify({ at: new Date().toISOString(), error: String(error).split('\n')[0]!.slice(0, 160) })) + if (!wait) { + break + } + await new Promise((r) => setTimeout(r, 120_000)) + continue + } const ok = a?.commit && required ? contains(a.commit, required) : null console.log(JSON.stringify({ at: new Date().toISOString(), ...a, containsRequired: ok })) if (!wait || ok) { diff --git a/cloud/apps/acceptance/src/import-desktop.ts b/cloud/apps/acceptance/src/import-desktop.ts index 637987035b8..8d03f348017 100644 --- a/cloud/apps/acceptance/src/import-desktop.ts +++ b/cloud/apps/acceptance/src/import-desktop.ts @@ -27,7 +27,11 @@ const MAP: Record = { 'life.auto-resume': 'life.auto-resume', 'acc.download': 'acc.download', 'acc.remote-desktop': 'acc.remote-desktop', - 'local.core-workflow': 'local.core-workflow' + 'local.core-workflow': 'local.core-workflow', + 'local.run-actions': 'local.run-actions', + 'run.ports': 'run.ports', + 'run.nonconcurrent': 'run.nonconcurrent', + 'run.cloud-resume': 'run.cloud-resume' } const checks: CheckResult[] = [] const benchmarks: RunFile['benchmarks'] = [] diff --git a/cloud/apps/billing/scripts/e2e-test-mode.ts b/cloud/apps/billing/scripts/e2e-test-mode.ts index 7290c4b1cfe..40e06ebea7f 100644 --- a/cloud/apps/billing/scripts/e2e-test-mode.ts +++ b/cloud/apps/billing/scripts/e2e-test-mode.ts @@ -5,22 +5,24 @@ // tsx scripts/e2e-test-mode.ts run # drive the flows below // // Env for `run`: BILLING_URL, STRIPE_SECRET_KEY (sk_test_/rk_test_ only), CONTROL_PLANE_TOKEN, -// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON), E2E_ISSUER, +// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON; an optional +// STRIPE_PRICE_OVERAGE_PREVIOUS, an older metered overage price, checks opt-in re-prices it), E2E_ISSUER, // E2E_D1_ARGS (wrangler d1 execute target, e.g. "BILLING_DB --local"). // // E2E_MEMBERSHIP_TOKEN (optional) enables the peak-seat checks; E2E_GRACE_SECONDS (default 96 h) // is the Worker's payment grace (short: watched expiring; long: past_due_since backdated in D1). // -// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in -> usage past the -// included hours -> meter events in Stripe -> spend cap blocks starts -> peak seats (add charged -// now, removal deferred, re-add free) -> annual plan + overage -> seat true-down + test-clock +// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in (per-minute price) -> +// 75/90/100% warnings -> usage past the included hours -> meter events in Stripe -> spend cap +// blocks starts and puts running sandboxes to sleep -> peak seats (add charged +// now, removal deferred, re-add free) -> annual plan + overage (an older overage item is re-priced, not doubled) -> seat true-down + test-clock // renewal -> invoice.payment_failed -> grace -> suspend -> pay -> resume -> cancel -> 3DS Checkout // (challenge passed; 3DS-then-decline and insufficient funds create nothing) -> cleanup. // Prints PASS/FAIL per check and the ids. import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose' -import { DEFAULT_PLANS } from '../src/plans' +import { DEFAULT_PLANS, OVERAGE_SECONDS_PER_BILLED_UNIT, capSeconds, overageUnitAmountDecimal } from '../src/plans' import { encodeForm } from '../src/stripe' const HOUR = 3600 @@ -250,13 +252,26 @@ const subA = await stripe<{ items: { data: Array<{ price: { id: string } }> } }> check('overage opt-in adds the metered item ($25 default cap)', optIn.status === 200 && optIn.body.overage?.active === true && optIn.body.overage?.spendCapCents === 2500 && subA.items.data.some((item) => item.price.id === catalog.STRIPE_PRICE_OVERAGE), optIn.body) -// ---- 3. Usage past included hours -> meter events -------------------------------------------- +const overagePrice = await stripe<{ unit_amount_decimal: string; transform_quantity: { divide_by: number; round: string }; recurring: { usage_type: string; meter: string } }>( + 'GET', `/v1/prices/${catalog.STRIPE_PRICE_OVERAGE}`) +check(`overage price is billed by the minute (${overageUnitAmountDecimal(DEFAULT_PLANS.team.overageCentsPerHour)} cents per minute, divide_by 60, round up)`, + overagePrice.unit_amount_decimal === overageUnitAmountDecimal(DEFAULT_PLANS.team.overageCentsPerHour) && + overagePrice.transform_quantity?.divide_by === OVERAGE_SECONDS_PER_BILLED_UNIT && overagePrice.transform_quantity.round === 'up' && + overagePrice.recurring.usage_type === 'metered' && overagePrice.recurring.meter === catalog.meter, overagePrice) + +// ---- 3. Usage past included hours -> warnings -> meter events -------------------------------- const meterStart = Math.floor(Date.now() / 1000 / 60) * 60 - 2 * 24 * HOUR -for (let i = 0; i < 5; i++) { +for (let i = 0; i < 4; i++) { await reportUsage(accountA, 24) } +// 96 of 120 h (80%), then 108 h (90%), then 120 h: alerts at 75%, 90% and 100%. +check('75% included-hours warning', (await entitlement(orgA)).usageWarningPercent === 75) +await reportUsage(accountA, 12) +check('90% included-hours warning', (await entitlement(orgA)).usageWarningPercent === 90) +await reportUsage(accountA, 12) const crossing = await reportUsage(accountA, 4) -check('usage past 120 h: 4 h overage recorded, starts still allowed', crossing.overageSeconds === 4 * HOUR && crossing.canStartSandbox === true, crossing) +check('usage past 120 h: 4 h overage recorded, starts still allowed, nothing put to sleep', crossing.overageSeconds === 4 * HOUR && + crossing.canStartSandbox === true && crossing.suspendRunning === false, crossing) const warned = await entitlement(orgA) check('100% included-hours warning', warned.usageWarningPercent === 100, warned.usageWarningPercent) @@ -264,13 +279,17 @@ check('100% included-hours warning', warned.usageWarningPercent === 100, warned. await reportUsage(accountA, 20) await reportUsage(accountA, 20) const capped = await startCheck(accountA) -check(`spend cap ($25 = ${(2500 / DEFAULT_PLANS.team.overageCentsPerHour).toFixed(1)} overage h) reached -> new starts blocked`, capped.allow === false && capped.reason === 'overage_cap_reached', capped) +check(`spend cap ($25 = ${(2500 / DEFAULT_PLANS.team.overageCentsPerHour).toFixed(1)} overage h) reached -> new starts blocked, running sandboxes put to sleep`, + capped.allow === false && capped.reason === 'overage_cap_reached' && capped.suspendRunning === true, capped) const spent = await entitlement(orgA) -check('metered spend stops at the cap', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490, spent.overage) +check('metered spend stops at the cap (whole minutes, never over it)', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490 && + spent.suspendRunning === true && spent.payment?.suspendRunning === false, { overage: spent.overage, suspendRunning: spent.suspendRunning }) +const capSuspensions = await billing('GET', '/v1/internal/suspensions', null).then((r) => r.body) +check('a spend cap is not a payment suspension (no data-retention clock)', !capSuspensions.accounts?.some((a: any) => a.accountId === accountA), capSuspensions) const meterEnd = Math.ceil(Date.now() / 1000 / 60) * 60 + 60 // The $25 default cap in overage seconds at the plan's overage rate (single source: plans.ts). -const CAP_SECONDS = Math.floor((2500 / DEFAULT_PLANS.team.overageCentsPerHour) * HOUR) +const CAP_SECONDS = capSeconds(DEFAULT_PLANS.team, 2500) const summaries = await until( 'meter event summaries', () => stripe<{ data: Array<{ aggregated_value: number }> }>('GET', `/v1/billing/meters/${catalog.meter}/event_summaries`, { @@ -328,8 +347,12 @@ ids.annualCustomer = customerB.id linkAccount(`org:${orgB}`, customerB.id) await stripe('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerB.id }) .then((pm: any) => stripe('POST', `/v1/customers/${customerB.id}`, { invoice_settings: { default_payment_method: pm.id } })) -const annualSub = await stripe<{ id: string }>('POST', '/v1/subscriptions', { - customer: customerB.id, items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }], metadata: { account_id: `org:${orgB}` } +// An older overage price already on the subscription (opted in before the price changed). +const previousOverage = catalog.STRIPE_PRICE_OVERAGE_PREVIOUS +const annualSub = await stripe<{ id: string; items: { data: Array<{ id: string; price: { id: string } }> } }>('POST', '/v1/subscriptions', { + customer: customerB.id, + items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }, ...(previousOverage ? [{ price: previousOverage }] : [])], + metadata: { account_id: `org:${orgB}` } }) ids.annualSubscription = annualSub.id const annualEnt = await until('annual active', () => entitlement(orgB), (e) => e.status === 'active') @@ -337,6 +360,13 @@ check('annual Team: 3 seats, 180 h monthly allowance window', annualEnt.interval annualEnt.usageWindow && annualEnt.usageWindow.end - annualEnt.usageWindow.start <= 31 * 24 * HOUR * 1000, annualEnt) const annualOptIn = await billing('POST', '/v1/billing/overage', orgB, { enabled: true }) check('annual plan accepts the monthly metered overage item', annualOptIn.status === 200 && annualOptIn.body.overage?.active === true, annualOptIn) +if (previousOverage) { + const oldItem = annualSub.items.data.find((item) => item.price.id === previousOverage)?.id + const items = (await stripe<{ items: { data: Array<{ id: string; price: { id: string } }> } }>('GET', `/v1/subscriptions/${annualSub.id}`)).items.data + const metered = items.filter((item) => item.price.id !== catalog.STRIPE_PRICE_TEAM_ANNUAL) + check('opt-in moves the older overage item to the current price (no second item)', metered.length === 1 && + metered[0]!.id === oldItem && metered[0]!.price.id === catalog.STRIPE_PRICE_OVERAGE, metered) +} // ---- 7. Test clock: renewal, then invoice.payment_failed -------------------------------------- const orgC = `e2e-${runId}-c` diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts index b5d5846a691..4b4c5efbee0 100644 --- a/cloud/apps/billing/scripts/stripe-test-setup.ts +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -8,7 +8,7 @@ // Prices (cents) default to the agreed Team pricing; override with // STRIPE_SETUP_PRICES='{"teamMonthly":5000,"teamAnnual":48000,"overagePerHour":100}' // Idempotency keys include the prices, so a re-run within 24h returns the same objects. -import { DEFAULT_PLANS } from '../src/plans' +import { DEFAULT_PLANS, OVERAGE_SECONDS_PER_BILLED_UNIT, overageUnitAmountDecimal } from '../src/plans' import { encodeForm } from '../src/stripe' const key = process.env.STRIPE_SECRET_KEY ?? '' @@ -82,20 +82,22 @@ const meter = await post( 'meter' ) const overageProduct = await post('/v1/products', { name: 'Orca sandbox overage', unit_label: 'std hour' }, 'product:overage') -// Per sandbox hour (one size, 1x; see MACHINE_SIZE_WEIGHT); the meter counts seconds and the -// invoice divides by 3600, rounding up. +// Per sandbox hour (one size, 1x; see MACHINE_SIZE_WEIGHT), billed by the minute: the meter counts +// seconds, the invoice divides the period's total by 60 (rounding up) and charges the hourly rate +// / 60 per minute (unit_amount_decimal, 12 decimals; overageUnitAmountDecimal in plans.ts). const overage = await post( '/v1/prices', { product: overageProduct.id, currency: 'usd', - unit_amount: prices.overagePerHour, + unit_amount_decimal: overageUnitAmountDecimal(prices.overagePerHour), recurring: { interval: 'month', usage_type: 'metered', meter: meter.id }, - transform_quantity: { divide_by: 3600, round: 'up' }, - lookup_key: 'orca_sandbox_overage_hourly', - transfer_lookup_key: true + transform_quantity: { divide_by: OVERAGE_SECONDS_PER_BILLED_UNIT, round: 'up' }, + lookup_key: 'orca_sandbox_overage_per_minute', + transfer_lookup_key: true, + nickname: 'Sandbox overage, billed by the minute' }, - `price:overage:${suffix}` + `price:overage:minute:${suffix}` ) const portal = await post( diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts index 5b7663d3094..3fc2808cb62 100644 --- a/cloud/apps/billing/src/entitlements.ts +++ b/cloud/apps/billing/src/entitlements.ts @@ -6,6 +6,7 @@ import { allowanceWindow, capSeconds, includedSeconds, + overageCents, planCatalog, type BillingInterval, type MachineSize, @@ -44,7 +45,7 @@ export type Entitlement = { includedSeconds: number usedSeconds: number remainingIncludedSeconds: number - /** Highest configured warning level reached (e.g. 80, 100), or null. */ + /** Highest configured warning level reached (75, 90 or 100), or null. */ usageWarningPercent: number | null overageSeconds: number overage: { @@ -64,6 +65,12 @@ export type Entitlement = { } /** Failed payment: new starts stop at once; running sandboxes must be suspended after the grace. */ payment: { pastDueSince: number | null; graceEndsAt: number | null; suspendRunning: boolean } + /** + * The account's running sandboxes must go to sleep now (snapshot kept, never deleted): payment + * grace expired, or the account is at its limit (out of included hours with overage off, or at + * its overage spend cap). The control plane acts on it from the start check and usage reports. + */ + suspendRunning: boolean canStartSandbox: boolean reason: DenyReason | null } @@ -110,6 +117,7 @@ export async function computeEntitlement( idleSleepMinutes: team.idleSleepMinutes }, payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, + suspendRunning: false, canStartSandbox: false, reason: 'no_subscription' } @@ -166,7 +174,8 @@ export async function computeEntitlement( overage: { ...overageBase, active: overageActive, - spentCents: Math.round((metered / 3600) * team.overageCentsPerHour) + // What Stripe will invoice: whole minutes at the per-minute price. + spentCents: overageCents(team, metered) }, limits: { maxConcurrentSandboxes: team.concurrentSandboxesPerSeat * Math.max(1, subscription.seats), @@ -175,6 +184,7 @@ export async function computeEntitlement( idleSleepMinutes: team.idleSleepMinutes }, payment: { pastDueSince, graceEndsAt, suspendRunning: graceExpired }, + suspendRunning: graceExpired || reason === 'included_hours_exhausted' || reason === 'overage_cap_reached', canStartSandbox: reason === null, reason } @@ -189,7 +199,7 @@ export function checkStart( entitlement: Entitlement, request: { machineSize: MachineSize; runningSandboxes: number; runningSandboxesForUser?: number } ): { allow: boolean; reason: DenyReason | null; suspendRunning: boolean } { - const suspendRunning = entitlement.payment.suspendRunning + const suspendRunning = entitlement.suspendRunning if (!entitlement.canStartSandbox) { return { allow: false, reason: entitlement.reason, suspendRunning } } diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 23d596c93d2..1d7b46f876c 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -192,7 +192,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo ...result, canStartSandbox: entitlement.canStartSandbox, reason: entitlement.reason, - suspendRunning: entitlement.payment.suspendRunning + suspendRunning: entitlement.suspendRunning }) } diff --git a/cloud/apps/billing/src/overage.ts b/cloud/apps/billing/src/overage.ts index 69c03b17de5..590e00f1d1f 100644 --- a/cloud/apps/billing/src/overage.ts +++ b/cloud/apps/billing/src/overage.ts @@ -1,13 +1,15 @@ // Overage is opt-in per account (off by default) with a monthly spend cap admins can raise. -// Opting in adds the metered overage price to the Stripe subscription (once); opting out stops +// Opting in puts exactly one metered overage item, at STRIPE_PRICE_OVERAGE, on the Stripe +// subscription: an item still on an older overage price is moved to the current one rather than a +// second added (two items on the same meter would bill the same seconds twice). Opting out stops // metering immediately but leaves the item, so usage already reported is still invoiced. import { z } from 'zod' import type { BillingCaller } from './auth' import { OPEN_STATUSES } from './checkout' import type { Env } from './env' -import { planCatalog } from './plans' -import type { StripeClient } from './stripe' -import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription } from './store' +import { planCatalog, planForPrice } from './plans' +import type { StripeClient, StripeSubscription } from './stripe' +import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription, type SubscriptionRow } from './store' export const OverageRequestSchema = z .object({ @@ -37,10 +39,11 @@ export async function updateOverage( if (!subscription || !OPEN_STATUSES.has(subscription.status)) { return { ok: false, status: 409, error: 'no_subscription' } } - if (request.enabled && !subscription.overage_item_id) { - // Idempotent per subscription+price, so a retried opt-in cannot add a second item. - const item = await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE }) - await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: item.id, updated_at: now }) + if (request.enabled) { + const itemId = await ensureOverageItem(env, stripe, subscription) + if (itemId !== subscription.overage_item_id) { + await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: itemId, updated_at: now }) + } } // An omitted cap keeps the current one; null restores the plan default. const spendCapCents = @@ -50,3 +53,37 @@ export async function updateOverage( await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now) return { ok: true } } + +/** A metered item that is not a seat price and not the current overage price: an older overage price. */ +function isStaleOverageItem(env: Env, item: StripeSubscription['items']['data'][number]): boolean { + return ( + item.price.id !== env.STRIPE_PRICE_OVERAGE && + planForPrice(env, item.price.id) === null && + item.price.recurring?.usage_type === 'metered' + ) +} + +/** The subscription's one overage item at the current price, creating or re-pricing it as needed. */ +async function ensureOverageItem(env: Env, stripe: StripeClient, subscription: SubscriptionRow): Promise { + // Stripe is the source of truth for the items: the stored id can be missing or on an old price. + const live = await stripe.retrieveSubscription(subscription.stripe_subscription_id) + const current = live.items.data.find((item) => item.price.id === env.STRIPE_PRICE_OVERAGE) + const stale = live.items.data.filter((item) => isStaleOverageItem(env, item)) + if (current) { + // Never leave a second metered item on the same meter. + for (const item of stale) { + await stripe.deleteSubscriptionItem(item.id) + } + return current.id + } + const [replace, ...extra] = stale + if (replace) { + await stripe.setSubscriptionItemPrice({ item: replace.id, price: env.STRIPE_PRICE_OVERAGE }) + for (const item of extra) { + await stripe.deleteSubscriptionItem(item.id) + } + return replace.id + } + // Idempotent per subscription+price, so a retried opt-in cannot add a second item. + return (await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE })).id +} diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts index e02a322e4f6..0b4182e49c1 100644 --- a/cloud/apps/billing/src/plans.ts +++ b/cloud/apps/billing/src/plans.ts @@ -74,11 +74,13 @@ export const DEFAULT_PLANS: PlanCatalog = { // Same constant the control plane enforces and serves (GET /v1/config); never a literal here. idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES, // Shared with the control plane's workspace cost; entitlements, the spend-cap math, the desktop and - // the e2e read it from here. STRIPE_PRICE_OVERAGE must be a metered price with this unit_amount. + // the e2e read it from here. STRIPE_PRICE_OVERAGE must be the metered per-minute price for this rate + // (unit_amount_decimal = overageUnitAmountDecimal(rate), transform_quantity divide_by 60, round up). overageCentsPerHour: ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR, defaultSpendCapCents: 2500, maxSpendCapCents: 500_000, - warnAtPercent: [80, 100], + // Owners and admins are alerted at 75%, 90% and 100% of the team's sandbox hours. + warnAtPercent: [75, 90, 100], paymentGraceHours: 96, seatTrueDownHours: 6 }, @@ -138,7 +140,8 @@ export function publicOffers(catalog: PlanCatalog) { concurrentSandboxesPerSeat: team.concurrentSandboxesPerSeat, idleSleepMinutes: team.idleSleepMinutes, overageCentsPerHour: team.overageCentsPerHour, - defaultSpendCapCents: team.defaultSpendCapCents + defaultSpendCapCents: team.defaultSpendCapCents, + maxSpendCapCents: team.maxSpendCapCents }, enterprise: catalog.enterprise } @@ -163,9 +166,31 @@ export function includedSeconds(team: TeamPlan, seats: number): number { return Math.round(team.includedHoursPerSeat * 3600) * Math.max(1, seats) } -/** The spend cap expressed as overage seconds (what the meter counts). */ +// Overage is billed by the minute: the meter counts seconds, and the Stripe price divides the +// period's total by 60 (rounding up) and charges overageCentsPerHour / 60 per minute. Metering in +// seconds keeps every segment exact; only the invoice rounds, once per period, to a whole minute. +export const OVERAGE_SECONDS_PER_BILLED_UNIT = 60 +/** Stripe allows at most 12 decimal places in unit_amount_decimal. */ +const STRIPE_DECIMAL_PLACES = 12 + +/** The per-minute Stripe price (cents, as Stripe's unit_amount_decimal string) for an hourly rate. */ +export function overageUnitAmountDecimal(centsPerHour: number): string { + return (centsPerHour / (3600 / OVERAGE_SECONDS_PER_BILLED_UNIT)).toFixed(STRIPE_DECIMAL_PLACES) +} + +/** What Stripe invoices for `seconds` of overage: whole minutes (rounded up) at the per-minute rate. */ +export function overageCents(team: TeamPlan, seconds: number): number { + const minutes = Math.ceil(Math.max(0, seconds) / OVERAGE_SECONDS_PER_BILLED_UNIT) + return Math.round(minutes * Number(overageUnitAmountDecimal(team.overageCentsPerHour))) +} + +/** + * The spend cap expressed as overage seconds (what the meter counts): the whole minutes the cap + * pays for, so the invoice never goes over the cap after Stripe rounds up to the minute. + */ export function capSeconds(team: TeamPlan, spendCapCents: number): number { - return Math.floor((spendCapCents / team.overageCentsPerHour) * 3600) + const minutes = Math.floor((spendCapCents * (3600 / OVERAGE_SECONDS_PER_BILLED_UNIT)) / team.overageCentsPerHour) + return minutes * OVERAGE_SECONDS_PER_BILLED_UNIT } function addUtcMonths(ms: number, months: number): number { diff --git a/cloud/apps/billing/src/stripe.ts b/cloud/apps/billing/src/stripe.ts index e76f8ad1f7e..8d493a6fd58 100644 --- a/cloud/apps/billing/src/stripe.ts +++ b/cloud/apps/billing/src/stripe.ts @@ -73,7 +73,7 @@ export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) { const base = env.STRIPE_API_BASE ?? 'https://api.stripe.com' const version = env.STRIPE_API_VERSION ?? DEFAULT_STRIPE_API_VERSION - async function request(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) { + async function request(method: 'GET' | 'POST' | 'DELETE', path: string, params?: FormRecord, idempotencyKey?: string) { assertKeyMode(env) const headers: Record = { authorization: `Bearer ${env.STRIPE_SECRET_KEY}`, @@ -152,6 +152,23 @@ export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) { `overage-item:${args.subscription}:${args.price}` ) }, + /** Moves an item to another price in place (an older overage price to the current one). */ + setSubscriptionItemPrice(args: { item: string; price: string }) { + return request<{ id: string; price: { id: string } }>( + 'POST', + `/v1/subscription_items/${encodeURIComponent(args.item)}`, + { price: args.price, proration_behavior: 'none' }, + `overage-item-price:${args.item}:${args.price}` + ) + }, + /** DELETE is idempotent in Stripe, so no idempotency key (Stripe ignores one there). */ + deleteSubscriptionItem(item: string) { + return request<{ id: string; deleted: boolean }>( + 'DELETE', + `/v1/subscription_items/${encodeURIComponent(item)}`, + { proration_behavior: 'none' } + ) + }, setSubscriptionItemQuantity(args: { item: string quantity: number @@ -202,7 +219,7 @@ export type StripeSubscription = { quantity?: number current_period_start?: number current_period_end?: number - price: { id: string } + price: { id: string; recurring?: { usage_type?: string } | null } }> } } diff --git a/cloud/apps/billing/src/usage.ts b/cloud/apps/billing/src/usage.ts index 2591a9698b7..015e0b6fc71 100644 --- a/cloud/apps/billing/src/usage.ts +++ b/cloud/apps/billing/src/usage.ts @@ -1,8 +1,9 @@ // Sandbox usage ingestion (control plane -> billing) and the Stripe meter outbox. // Only overage reaches Stripe, and only for accounts that opted in, up to their monthly spend cap: // included hours are tracked here (pooled across seats, monthly even on annual plans, which tiered -// Stripe prices cannot express); the overage price is a flat per-hour metered price on a -// "standard seconds" meter with transform_quantity divide_by=3600. +// Stripe prices cannot express); the overage price is a flat per-hour rate billed by the minute: a +// metered per-minute price on a "standard seconds" meter with transform_quantity divide_by=60. +// Metering stays in seconds (exact per segment); Stripe rounds the period total up to a minute. import { z } from 'zod' import type { Env } from './env' import { MACHINE_SIZE_WEIGHT, MACHINE_SIZES, capSeconds, includedSeconds, planCatalog } from './plans' diff --git a/cloud/apps/billing/test/billing-config.test.ts b/cloud/apps/billing/test/billing-config.test.ts index 31a2e2b15a9..1b5f3835a5e 100644 --- a/cloud/apps/billing/test/billing-config.test.ts +++ b/cloud/apps/billing/test/billing-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { allowanceWindow, capSeconds, DEFAULT_PLANS } from '../src/plans' +import { allowanceWindow, capSeconds, DEFAULT_PLANS, overageCents, overageUnitAmountDecimal } from '../src/plans' +import { ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire' import { createHarness } from './harness' const HOUR = 3600 @@ -42,7 +43,8 @@ describe('config: tax and plan catalog', () => { concurrentSandboxesPerSeat: 3, idleSleepMinutes: 15, overageCentsPerHour: 100, - defaultSpendCapCents: 5000 + defaultSpendCapCents: 5000, + maxSpendCapCents: 500_000 }, enterprise: { label: 'Enterprise', priceLabel: 'Custom', contactUrl: 'https://example.test/sales' } }) @@ -84,4 +86,43 @@ describe('allowance windows and caps', () => { expect(DEFAULT_PLANS.team.overageCentsPerHour).toBe(100) expect(capSeconds(DEFAULT_PLANS.team, 2500)).toBe(25 * HOUR) }) + + it('alerts at 75, 90 and 100% of the included hours', () => { + expect(DEFAULT_PLANS.team.warnAtPercent).toEqual([75, 90, 100]) + }) +}) + +describe('per-minute overage price', () => { + const team = DEFAULT_PLANS.team + + it('derives the Stripe per-minute unit_amount_decimal from the shared hourly rate', () => { + expect(team.overageCentsPerHour).toBe(ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR) + // $1.00 per hour = 1.666666666667 cents per minute (Stripe's 12-decimal maximum). + expect(overageUnitAmountDecimal(100)).toBe('1.666666666667') + expect(overageUnitAmountDecimal(75)).toBe('1.250000000000') + expect(overageUnitAmountDecimal(team.overageCentsPerHour).split('.')[1]).toHaveLength(12) + }) + + it('charges whole minutes, rounding up, like the price (transform_quantity divide_by 60, round up)', () => { + expect(overageCents(team, 0)).toBe(0) + expect(overageCents(team, 1)).toBe(2) // one started minute: 1.67 cents + expect(overageCents(team, 60)).toBe(2) + expect(overageCents(team, 61)).toBe(3) // two minutes: 3.33 cents + expect(overageCents(team, 30 * 60)).toBe(50) + expect(overageCents(team, HOUR)).toBe(100) + expect(overageCents(team, 4 * HOUR)).toBe(400) + }) + + it('caps on whole minutes, so the invoice never passes the spend cap', () => { + for (const cap of [0, 1, 99, 100, 101, 2500, 2501, 12_345, 500_000]) { + const seconds = capSeconds(team, cap) + expect(seconds % 60).toBe(0) + expect(overageCents(team, seconds)).toBeLessThanOrEqual(cap) + // ...and one more minute would pass it. + expect(overageCents(team, seconds + 60)).toBeGreaterThan(cap) + } + // $25.01 doesn't pay for a 1,501st minute ($25.0167); $25.02 does. + expect(capSeconds(team, 2501)).toBe(25 * HOUR) + expect(capSeconds(team, 2502)).toBe(25 * HOUR + 60) + }) }) diff --git a/cloud/apps/billing/test/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts index baeb58d52bb..7638d4f5d6c 100644 --- a/cloud/apps/billing/test/billing-flow.test.ts +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake' -import { createHarness } from './harness' +import { CONTROL_TOKEN, createHarness } from './harness' const HOUR = 3600 const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR @@ -148,7 +148,7 @@ describe('webhooks', () => { it('tracks seat and interval changes made in the portal', async () => { const { h, auth, subscription } = await subscribedTeam() subscription.items.data[0]!.quantity = 5 - subscription.items.data[0]!.price = { id: 'price_team_year' } + subscription.items.data[0]!.price = { id: 'price_team_year', recurring: { usage_type: 'licensed' } } subscription.items.data[0]!.current_period_end = PERIOD_START + 365 * 24 * HOUR await h.webhook(stripeEvent('customer.subscription.updated', subscription)) const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) @@ -224,10 +224,24 @@ describe('entitlements, overage and usage', () => { expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50 per seat per month', annualPriceLabel: '$480 per seat, billed yearly' }) expect(entitlement.body.offers.enterprise).toMatchObject({ label: 'Enterprise', priceLabel: 'Custom' }) - for (let i = 0; i < 4; i++) { - await h.controlPlane('/v1/internal/usage', usage(`w-${i}`, 22)) + // Alerts at 75%, 90% and 100% of the 120 pooled hours (the highest level reached). Segments + // are at most a day, so the hours arrive in pieces: 10 so far. + const warning = async () => (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent + const add = async (id: string, hours: number[]) => { + for (const [i, value] of hours.entries()) { + await h.controlPlane('/v1/internal/usage', usage(`${id}-${i}`, value)) + } } - expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent).toBe(80) + await add('a', [20, 20, 20, 19]) // 89 h + expect(await warning()).toBe(null) + await add('b', [1]) // 90 h = 75% + expect(await warning()).toBe(75) + await add('c', [17]) // 107 h + expect(await warning()).toBe(75) + await add('d', [1]) // 108 h = 90% + expect(await warning()).toBe(90) + await add('e', [12]) // 120 h + expect(await warning()).toBe(100) }) it('is idempotent per usageId and counts every size 1x (legacy large is the same 8 vCPU / 16 GB box)', async () => { @@ -293,6 +307,109 @@ describe('entitlements, overage and usage', () => { expect((await check()).body).toMatchObject({ allow: true }) }) + it('puts running sandboxes to sleep (suspendRunning) when out of hours with overage off', async () => { + const { h, auth } = await subscribedTeam() + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + for (let i = 0; i < 4; i++) { + expect((await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))).body).toMatchObject({ suspendRunning: false }) + } + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + // The report that uses the last included hour tells the control plane to put the rest to sleep. + expect((await h.controlPlane('/v1/internal/usage', usage('u-4', 24))).body).toMatchObject({ + canStartSandbox: false, + reason: 'included_hours_exhausted', + suspendRunning: true + }) + expect((await check()).body).toMatchObject({ allow: false, reason: 'included_hours_exhausted', suspendRunning: true }) + const entitlement = (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body + // Not a payment problem: the payment fields (and the desktop's payment notice) stay clear. + expect(entitlement).toMatchObject({ suspendRunning: true, payment: { pastDueSince: null, suspendRunning: false } }) + // A cap is not a cancellation: no payment suspension, so no data-retention clock. + expect((await h.call('GET', '/v1/internal/suspensions', { headers: { authorization: `Bearer ${CONTROL_TOKEN}` } })).body).toEqual({ accounts: [] }) + + // Turning overage on lifts it. + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + }) + + it('puts running sandboxes to sleep at the spend cap, and lifts it when the cap is raised', async () => { + const { h, auth } = await subscribedTeam({ overage: true, spendCapCents: 300 }) + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + for (let i = 0; i < 5; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + expect((await h.controlPlane('/v1/internal/usage', usage('o-1', 2))).body).toMatchObject({ suspendRunning: false }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + expect((await h.controlPlane('/v1/internal/usage', usage('o-2', 1))).body).toMatchObject({ + reason: 'overage_cap_reached', + suspendRunning: true + }) + expect((await check()).body).toMatchObject({ allow: false, reason: 'overage_cap_reached', suspendRunning: true }) + const entitlement = (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body + expect(entitlement).toMatchObject({ suspendRunning: true, payment: { suspendRunning: false }, overage: { spentCents: 300 } }) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 2500 }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + }) + + it('on opt-in, moves an item on an older overage price to the current price instead of adding a second', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + // Opted in back when overage was $0.75 an hour, then opted out (the item stays). + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + overagePrice: 'price_overage_075_hourly', + quantity: 2, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + const oldItem = subscription.items.data[1]!.id + // Off by default: no overage, and the old item is not treated as the current one. + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage).toMatchObject({ optedIn: false, active: false }) + + expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(200) + expect(subscription.items.data.map((item) => [item.id, item.price.id])).toEqual([ + [subscription.items.data[0]!.id, 'price_team_month'], + [oldItem, 'price_overage'] + ]) + expect(h.stripe.calls.some((call) => call.path === '/v1/subscription_items')).toBe(false) + const swap = h.stripe.calls.find((call) => call.path === `/v1/subscription_items/${oldItem}`)! + expect(swap.form.get('price')).toBe('price_overage') + expect(swap.form.get('proration_behavior')).toBe('none') + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage).toMatchObject({ optedIn: true, active: true }) + // The item's webhook keeps the id, and a second opt-in changes nothing. + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 5000 }, headers: auth }) + expect(subscription.items.data).toHaveLength(2) + expect(h.raw.prepare('SELECT overage_item_id FROM subscriptions').get()).toEqual({ overage_item_id: oldItem }) + }) + + it('on opt-in, removes a stale overage item that sits next to the current one', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + overagePrice: ['price_overage_075_hourly', 'price_overage'], + quantity: 2, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect(subscription.items.data.map((item) => item.price.id)).toEqual(['price_team_month', 'price_overage']) + expect(h.stripe.calls.filter((call) => call.method === 'DELETE')).toHaveLength(1) + }) + it('opting out stops metering immediately and blocks starts past included hours', async () => { const { h, auth } = await subscribedTeam({ overage: true }) await h.call('POST', '/v1/billing/overage', { body: { enabled: false }, headers: auth }) diff --git a/cloud/apps/billing/test/stripe-fake.ts b/cloud/apps/billing/test/stripe-fake.ts index ae46ba2dd52..8c6209990b0 100644 --- a/cloud/apps/billing/test/stripe-fake.ts +++ b/cloud/apps/billing/test/stripe-fake.ts @@ -11,7 +11,15 @@ export type FakeSubscription = { cancel_at_period_end: boolean livemode: boolean metadata: Record - items: { object: 'list'; data: Array<{ id: string; quantity?: number; current_period_start: number; current_period_end: number; price: { id: string } }> } + items: { object: 'list'; data: FakeItem[] } +} + +type FakeItem = { + id: string + quantity?: number + current_period_start: number + current_period_end: number + price: { id: string; recurring: { usage_type: 'licensed' | 'metered' } } } export type RecordedCall = { method: string; path: string; form: URLSearchParams; headers: Headers } @@ -88,6 +96,10 @@ export function createFakeStripe() { const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length)) for (const subscription of subscriptions.values()) { const item = subscription.items.data.find((entry) => entry.id === itemId) + if (item && form.has('price')) { + item.price = { id: form.get('price')!, recurring: { usage_type: 'metered' } } + return respond({ id: item.id, price: item.price }) + } if (item) { item.quantity = Number(form.get('quantity')) return respond({ id: item.id, quantity: item.quantity }) @@ -95,15 +107,26 @@ export function createFakeStripe() { } return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 }) } + if (method === 'DELETE' && url.pathname.startsWith('/v1/subscription_items/')) { + const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length)) + for (const subscription of subscriptions.values()) { + const index = subscription.items.data.findIndex((entry) => entry.id === itemId) + if (index >= 0) { + subscription.items.data.splice(index, 1) + return Response.json({ id: itemId, object: 'subscription_item', deleted: true }) + } + } + return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 }) + } if (method === 'POST' && url.pathname === '/v1/subscription_items') { const subscription = subscriptions.get(form.get('subscription') ?? '') if (!subscription) { return Response.json({ error: { code: 'resource_missing', message: 'No such subscription' } }, { status: 404 }) } const base = subscription.items.data[0]! - const item = { + const item: FakeItem = { id: `si_test_${nextId++}`, - price: { id: form.get('price') ?? '' }, + price: { id: form.get('price') ?? '', recurring: { usage_type: 'metered' } }, current_period_start: base.current_period_start, current_period_end: base.current_period_end } @@ -135,7 +158,8 @@ export function createFakeStripe() { customer: string accountId: string basePrice: string - overagePrice?: string + /** Metered overage items already on the subscription (e.g. an older overage price). */ + overagePrice?: string | string[] quantity?: number status?: string periodStart: number @@ -145,10 +169,10 @@ export function createFakeStripe() { const id = `sub_test_${nextId++}` const period = { current_period_start: args.periodStart, current_period_end: args.periodEnd } const data: FakeSubscription['items']['data'] = [ - { id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice }, ...period } + { id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice, recurring: { usage_type: 'licensed' } }, ...period } ] - if (args.overagePrice) { - data.push({ id: `si_test_${nextId++}`, price: { id: args.overagePrice }, ...period }) + for (const price of [args.overagePrice ?? []].flat()) { + data.push({ id: `si_test_${nextId++}`, price: { id: price, recurring: { usage_type: 'metered' } }, ...period }) } const subscription: FakeSubscription = { id, diff --git a/cloud/apps/billing/wrangler.dev.jsonc b/cloud/apps/billing/wrangler.dev.jsonc index d8f90af061e..9eab816db28 100644 --- a/cloud/apps/billing/wrangler.dev.jsonc +++ b/cloud/apps/billing/wrangler.dev.jsonc @@ -27,7 +27,8 @@ // Stripe TEST MODE objects in acct_1Or7xeKb73TzRkjk (scripts/stripe-test-setup.ts). "STRIPE_PRICE_TEAM_MONTHLY": "price_1UZmKpKb73TzRkjk33aiB2th", "STRIPE_PRICE_TEAM_ANNUAL": "price_1UZmKpKb73TzRkjkS7qv85NI", - "STRIPE_PRICE_OVERAGE": "price_1UaBI9Kb73TzRkjkdxshOPZZ", + // $1.00 per sandbox hour, billed by the minute (unit_amount_decimal 1.666666666667, divide_by 60). + "STRIPE_PRICE_OVERAGE": "price_1UaDDzKb73TzRkjkznGWUEoC", "STRIPE_PORTAL_CONFIGURATION": "bpc_1UZmKvKb73TzRkjk59rIuHbx", "BILLING_RETURN_URL": "https://orca-billing-dev.lovecast.workers.dev/return", "BILLING_JWT_AUDIENCE": "orca-billing", diff --git a/cloud/apps/sandbox-control/image/golden-image.test.ts b/cloud/apps/sandbox-control/image/golden-image.test.ts index 8c251997304..54514ac6c9c 100644 --- a/cloud/apps/sandbox-control/image/golden-image.test.ts +++ b/cloud/apps/sandbox-control/image/golden-image.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -160,6 +160,15 @@ describe('Worker-embedded image scripts', () => { const generated = readFileSync(fileURLToPath(new URL('./vm-scripts.generated.ts', import.meta.url).href), 'utf8') expect(generated).toBe(renderVmScriptsModule()) }) + + it('installs every VM script and makes zsh read the session env hook once', () => { + const provision = readFileSync(fileURLToPath(new URL('./provision-golden.sh', import.meta.url).href), 'utf8') + const loop = provision.match(/^for name in (.+); do$/m)?.[1].split(' ') ?? [] + expect(new Set(loop)).toEqual(new Set(VM_SCRIPTS)) + expect(provision).toContain( + "grep -q zz-orca-session-env /etc/zsh/zshenv ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv" + ) + }) }) describe('agents the image ships', () => { @@ -223,3 +232,30 @@ describe('copilot wrapper', () => { expect(run({ GH_TOKEN: 'orca-ph-sbx1', COPILOT_PROVIDER_BASE_URL: 'https://llm.example.com/v1' })).toBe('orca-ph-sbx1||-i a b') }) }) + +describe('first-run seeding (orca-sandbox-env)', () => { + const env = readFileSync(fileURLToPath(new URL('./vm/orca-sandbox-env', import.meta.url).href), 'utf8') + const start = env.indexOf(`"$node_bin" -e '`) + `"$node_bin" -e '`.length + const script = env.slice(start, env.indexOf(`' || echo 'agent first-run state not seeded'`)) + const seed = (home: string) => + spawnSync(process.execPath, ['-e', script], { env: { HOME: home, ORCA_PROJECT: '/vercel/sandbox/project' }, encoding: 'utf8' }) + const opencodeConfig = (home: string) => + JSON.parse(readFileSync(join(home, '.config/opencode/opencode.json'), 'utf8')) as { disabled_providers?: string[] } + + it('keeps OpenCode off the providers that cannot work in Orca Cloud, so it shows "Connect a provider"', () => { + const home = mkdtempSync(join(tmpdir(), 'orca-seed-')) + expect(seed(home).status).toBe(0) + expect(opencodeConfig(home).disabled_providers).toEqual(['github-copilot', 'opencode']) + }) + + it('leaves a provider the user connected in OpenCode, and the user own list, alone', () => { + const home = mkdtempSync(join(tmpdir(), 'orca-seed-')) + seed(home) + spawnSync('mkdir', ['-p', join(home, '.local/share/opencode')]) + writeFileSync(join(home, '.local/share/opencode/auth.json'), JSON.stringify({ 'github-copilot': { type: 'oauth' } })) + const config = opencodeConfig(home) + writeFileSync(join(home, '.config/opencode/opencode.json'), JSON.stringify({ ...config, disabled_providers: [...(config.disabled_providers ?? []), 'groq'] })) + seed(home) + expect(opencodeConfig(home).disabled_providers).toEqual(['groq', 'opencode']) + }) +}) diff --git a/cloud/apps/sandbox-control/image/golden-image.ts b/cloud/apps/sandbox-control/image/golden-image.ts index 7c1093fe0b8..94e2f51c1a2 100644 --- a/cloud/apps/sandbox-control/image/golden-image.ts +++ b/cloud/apps/sandbox-control/image/golden-image.ts @@ -9,6 +9,7 @@ export const VM_SCRIPTS = [ 'orca-sandbox-drain', 'orca-sandbox-env', 'orca-session-env.sh', + 'orca-env', 'orca-egress-probe', 'orca-image-wipe', 'orca-share-proxy', diff --git a/cloud/apps/sandbox-control/image/provision-golden.sh b/cloud/apps/sandbox-control/image/provision-golden.sh index d462140756b..2a3469f9204 100755 --- a/cloud/apps/sandbox-control/image/provision-golden.sh +++ b/cloud/apps/sandbox-control/image/provision-golden.sh @@ -209,7 +209,7 @@ if [ -n "${NOVNC_VERSION:-}" ]; then fi log 'Orca entry points' -for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do +for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-env orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do var="ORCA_VM_SCRIPT_${name//[-.]/_}" printf '%s' "${!var}" | base64 -d >"$ORCA_HOME/bin/$name" chmod 0755 "$ORCA_HOME/bin/$name" @@ -238,6 +238,14 @@ if [ -f /etc/bash.bashrc ]; then grep -q zz-orca-session-env /etc/bash.bashrc || echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null fi +if [ -f /etc/bash.bashrc ]; then + # Ubuntu: zsh reads /etc/zsh/zshenv for every shell, so new zsh shells match bash (`orca-env` covers + # shells that were already open). + sudo install -d -m 0755 /etc/zsh + sudo touch /etc/zsh/zshenv + grep -q zz-orca-session-env /etc/zsh/zshenv || + echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv >/dev/null +fi sudo chown -R root:root "$ORCA_HOME" log 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)' diff --git a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts index 4e14637a96f..f62bfcd76fd 100644 --- a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts +++ b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts @@ -2,8 +2,8 @@ import type { BuildInputs, GoldenImageManifest } from './golden-image' export const MANIFEST: GoldenImageManifest = {"imageVersion":"2026-11-09.1","image":"vercel/sandbox/node:24","region":"iad1","vcpus":4,"orcad":{"bundleUrl":null,"sha256":null},"gh":{"version":"2.103.3","sha256":"4c806978ffce1cec70853302a56d70ac6da4f19bae7adca4e1a8ab00633dd91d"},"chrome":{"version":"157.0.8103.38-1","sha256":"a62b277dc03da2cff50cc04589deb66f6875be339c066d46adb49b49eedb629d"},"agentCliPackages":["@anthropic-ai/claude-code@2.1.319","@openai/codex@0.166.0","opencode-ai@1.19.5","@google/gemini-cli@0.67.0","@github/copilot@1.0.108"],"cursorAgent":{"version":"2026.11.05-86047df","sha256":"170b77786cbf4c93064d64fff12b71a178b968730561c64afc2e8e752535442e"},"desktop":{"novnc":{"version":"1.6.0","sha256":"5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa"},"websockify":{"version":"0.13.0","sha256":"b6413e364efd04f3c92ec8c17747e3c4adc20157c2ef1c5d019a26d944a46df8"}}} -export const PROVISION_SCRIPT: string = "#!/usr/bin/env bash\n# Runs once inside the build sandbox (as the sandbox user, with sudo). Installs the pinned orcad\n# bundle, agent CLIs and Orca's VM entry points into /opt/orca, then smoke-tests orcad.\n# Inputs (env, from image/golden-image.manifest.json via build-golden-image.ts):\n# ORCAD_BUNDLE_URL ORCAD_BUNDLE_SHA256 [GH_VERSION GH_SHA256] [CHROME_VERSION CHROME_SHA256]\n# AGENT_NPM_PACKAGES (space-separated\n# name@version), [CURSOR_AGENT_VERSION CURSOR_AGENT_SHA256], [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\nset -euo pipefail\nORCA_HOME=/opt/orca\nlog() { printf '[golden] %s\\n' \"$*\" >&2; }\n\nsudo mkdir -p \"$ORCA_HOME/bin\" \"$ORCA_HOME/runtime\" \"$ORCA_HOME/tools\"\nsudo chown -R \"$(id -u):$(id -g)\" \"$ORCA_HOME\"\n\nlog 'system packages'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n # Sandbox egress is TLS-only: plain-http apt mirrors time out at runtime (W2, wake on SSH).\n sudo sed -i 's#http://#https://#g' /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true\n sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq\n # Developer basics agents expect (Conductor parity): rg, tmux, vim, git-lfs alongside the build tools.\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n ca-certificates curl git git-lfs jq ripgrep tmux vim-nox less openssh-client openssh-server build-essential python3 \\\n unzip zip util-linux procps >/dev/null\n git lfs install --system --skip-repo >/dev/null\n # sshd is started on demand by the wake-on-SSH bridge (key-only, loopback); never at boot.\n sudo systemctl disable ssh.service ssh.socket >/dev/null 2>&1 || true\n # A locked password field (\"!\") makes sshd refuse even key logins; '*' = no password, not locked.\n sudo usermod -p '*' \"$(id -un)\"\nelif command -v dnf >/dev/null; then\n sudo dnf install -y -q ca-certificates curl-minimal git git-lfs jq tmux vim-enhanced gcc gcc-c++ make python3 \\\n unzip util-linux procps-ng >/dev/null\nelse\n log 'no supported package manager'; exit 1\nfi\n\n# Managed images have no /vercel/sandbox; the project lives at /vercel/sandbox/project on every image.\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" /vercel/sandbox\n\nfetch_verified() { # url sha256 dest\n curl -fsSL --retry 3 -o \"$3\" \"$1\"\n echo \"$2 $3\" | sha256sum -c --quiet -\n}\n\nlog 'orcad bundle'\nbundle=\"$(mktemp -d)/orcad.tgz\"\nfetch_verified \"$ORCAD_BUNDLE_URL\" \"$ORCAD_BUNDLE_SHA256\" \"$bundle\"\ntar -xzf \"$bundle\" -C \"$ORCA_HOME/runtime\"\nrm -rf \"$(dirname \"$bundle\")\"\n# Release tarballs (orca-server-linux-*) may nest the server; normalize to runtime/orcad + runtime/runtimes.\nif [ ! -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\" ]; then\n server=\"$(find \"$ORCA_HOME/runtime\" -name orcad-server.js -type f | head -n 1)\"\n [ -n \"$server\" ] || { log 'bundle has no orcad-server.js'; exit 1; }\n root=\"$(dirname \"$(dirname \"$server\")\")\"\n ln -sfn \"$(dirname \"$server\")\" \"$ORCA_HOME/runtime/orcad\"\n [ -d \"$ORCA_HOME/runtime/runtimes\" ] || ln -sfn \"$root/runtimes\" \"$ORCA_HOME/runtime/runtimes\"\nfi\ntest -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ntest -x \"$node\"\n\nif [ -n \"${GH_VERSION:-}\" ]; then\n log \"gh $GH_VERSION\"\n fetch_verified \"https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz\" \\\n \"$GH_SHA256\" /tmp/gh.tgz\n tar -xzf /tmp/gh.tgz -C /tmp\n mkdir -p \"$ORCA_HOME/tools/bin\"\n install -m 0755 \"/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh\" \"$ORCA_HOME/tools/bin/gh\"\n rm -rf /tmp/gh.tgz \"/tmp/gh_${GH_VERSION}_linux_amd64\"\nfi\n\nif [ -n \"${CHROME_VERSION:-}\" ] && command -v apt-get >/dev/null; then\n log \"google-chrome $CHROME_VERSION (headless browser for agents and Orca browser automation)\"\n deb=\"$(mktemp -d)/chrome.deb\"\n fetch_verified \"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\" \\\n \"$CHROME_SHA256\" \"$deb\"\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \"$deb\" >/dev/null\n rm -rf \"$(dirname \"$deb\")\"\n # The .deb adds Google's apt source; the image stays pinned, so drop it.\n sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome\n google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom 'data:text/html,

ok

' 2>/dev/null | grep -q '

ok

'\nfi\n\nlog 'agent CLIs'\n# The image's system npm installs them; agents later run on whatever node orcad puts on PATH.\n# shellcheck disable=SC2086 # word splitting is the package list\nnpm install --global --no-fund --no-audit --prefix \"$ORCA_HOME/tools\" $AGENT_NPM_PACKAGES\n# opencode-ai installs every linux-x64 build variant (~205 MB each) and hardlinks the one its\n# postinstall picked into bin/opencode.exe, a standalone binary: drop the variants it did not pick.\nopencode_pkg=\"$ORCA_HOME/tools/lib/node_modules/opencode-ai\"\nif [ -x \"$opencode_pkg/bin/opencode.exe\" ]; then\n picked=\"$(stat -c %i \"$opencode_pkg/bin/opencode.exe\")\"\n for variant in \"$opencode_pkg\"/node_modules/opencode-*; do\n [ -d \"$variant\" ] || continue\n [ \"$(stat -c %i \"$variant/bin/opencode\" 2>/dev/null)\" = \"$picked\" ] || rm -rf \"$variant\"\n done\n \"$opencode_pkg/bin/opencode.exe\" --version >/dev/null\nfi\n\n# Copilot CLI prefers GH_TOKEN/GITHUB_TOKEN over the user's own /login. In Orca Cloud those hold the\n# workspace's GitHub App placeholder, which Copilot can't use (\"Resource not accessible by\n# integration\"), so a signed-in user could never get past it. The wrapper drops them for Copilot\n# only (COPILOT_GITHUB_TOKEN, a /login and BYOK still work), unless BYOK is set up, which needs no\n# GitHub sign-in and keeps them so `gh` in Copilot's shell still works.\ncopilot_loader=\"$ORCA_HOME/tools/lib/node_modules/@github/copilot/npm-loader.js\"\nif [ -f \"$copilot_loader\" ]; then\n rm -f \"$ORCA_HOME/tools/bin/copilot\"\n cat >\"$ORCA_HOME/tools/bin/copilot\" </dev/null; fi\ndone\n\nif [ -n \"${CURSOR_AGENT_VERSION:-}\" ]; then\n # Cursor's agent CLI is a pinned tarball (its own node + bundle), not an npm package. The image\n # copy lives under /opt/orca (root-owned) and its release channel is set to \"static\" at boot\n # (orca-sandbox-env), so it never replaces itself mid-session.\n log \"cursor-agent $CURSOR_AGENT_VERSION\"\n tgz=\"$(mktemp -d)/cursor-agent.tgz\"\n fetch_verified \"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/x64/agent-cli-package.tar.gz\" \\\n \"$CURSOR_AGENT_SHA256\" \"$tgz\"\n rm -rf \"$ORCA_HOME/tools/cursor-agent\"\n mkdir -p \"$ORCA_HOME/tools/cursor-agent\" \"$ORCA_HOME/tools/bin\"\n tar --strip-components=1 -xzf \"$tgz\" -C \"$ORCA_HOME/tools/cursor-agent\"\n rm -rf \"$(dirname \"$tgz\")\"\n ln -sfn ../cursor-agent/cursor-agent \"$ORCA_HOME/tools/bin/cursor-agent\"\n \"$ORCA_HOME/tools/bin/cursor-agent\" --version >/dev/null\nfi\n\nlog 'openssh server (binaries only)'\n# For `orca ssh`: an unprivileged sshd on loopback (see vm/orca-sshd). Only the server binaries,\n# kept out of /usr: the packages would pull in systemd units, SELinux policy (~38 MB on AL2023) and\n# system host keys a user-run sshd never uses. Its libraries are already in the base image, except\n# tcp-wrappers on Ubuntu.\nmkdir -p \"$ORCA_HOME/openssh\"\npkgs=\"$(mktemp -d)\"\nif command -v dnf >/dev/null; then\n sudo dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null\n sudo dnf install -y -q --downloadonly --downloaddir=\"$pkgs\" openssh-server >/dev/null\n (cd \"$pkgs\" && rpm2cpio openssh-server-[0-9]*.rpm | cpio -idm --quiet)\n sudo dnf clean all -q >/dev/null 2>&1 || true\n libexec=\"$pkgs/usr/libexec/openssh\"\nelse\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libwrap0 >/dev/null\n (cd \"$pkgs\" && apt-get download -qq openssh-server openssh-sftp-server >/dev/null)\n for deb in \"$pkgs\"/*.deb; do dpkg-deb -x \"$deb\" \"$pkgs\"; done\n libexec=\"$pkgs/usr/lib/openssh\"\nfi\ninstall -m 0755 \"$pkgs/usr/sbin/sshd\" \"$libexec/sshd-session\" \"$libexec/sftp-server\" \"$ORCA_HOME/openssh/\"\n# OpenSSH 10 splits authentication into its own binary.\nif [ -x \"$libexec/sshd-auth\" ]; then install -m 0755 \"$libexec/sshd-auth\" \"$ORCA_HOME/openssh/\"; fi\nsudo rm -rf \"$pkgs\"\n\"$ORCA_HOME/openssh/sshd\" -V 2>&1 | head -n 1 >&2 || true\n# sshd refuses a locked account ('!') even for key logins; '*' unlocks it with no usable password.\nsudo usermod -p '*' \"$(id -un)\"\n\ninstall_desktop() {\n # Remote desktop (~80 MB): Xvnc + openbox + xterm from the distro, noVNC + websockify pinned\n # tarballs. Only orca-desktop-start runs it, on demand; nothing starts at boot. Every step says\n # `|| return 1`: errexit does not apply inside an `if` condition.\n command -v apt-get >/dev/null || { log 'remote desktop: apt only'; return 1; }\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n tigervnc-standalone-server tigervnc-tools openbox xterm x11-xserver-utils >/dev/null || return 1\n local tmp dir=\"$ORCA_HOME/desktop\"\n tmp=\"$(mktemp -d)\" || return 1\n fetch_verified \"https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz\" \\\n \"$NOVNC_SHA256\" \"$tmp/novnc.tgz\" || return 1\n fetch_verified \"https://github.com/novnc/websockify/archive/refs/tags/v${WEBSOCKIFY_VERSION}.tar.gz\" \\\n \"$WEBSOCKIFY_SHA256\" \"$tmp/websockify.tgz\" || return 1\n rm -rf \"$dir\" && mkdir -p \"$dir/novnc\" \"$dir/websockify\" || return 1\n tar -xzf \"$tmp/novnc.tgz\" -C \"$dir/novnc\" --strip-components 1 || return 1\n tar -xzf \"$tmp/websockify.tgz\" -C \"$dir/websockify\" --strip-components 1 || return 1\n # Orca tab fit: title \"Remote desktop\" (Xvnc's -desktop name), no noVNC favicon, control bar on\n # the right (away from Orca's sidebar edge); a stale per-boot password ends the session with one\n # plain message instead of a reconnect loop. \"Unencrypted\" is noVNC's view of plain ws:// to\n # localhost; the hop to the sandbox rides the end-to-end encrypted runtime connection.\n sed -i -e 's/document.title = e.detail.name + \" - \" + PAGE_TITLE;/document.title = e.detail.name;/' \\\n -e \"s/WebUtil.readSetting('controlbar_pos') === 'right'/WebUtil.readSetting('controlbar_pos', 'right') === 'right'/\" \\\n -e 's/securityFailed(e) {/securityFailed(e) { UI.inhibitReconnect = true; UI.showStatus(\"This remote desktop session ended. Reopen it from the workspace menu.\", \"error\"); return;/' \\\n -e 's/_(\"Connected (unencrypted) to \")/_(\"Connected to \")/' \\\n \"$dir/novnc/app/ui.js\" || return 1\n sed -i '/rel=\"icon\"/d; /rel=\"apple-touch-icon\"/d' \"$dir/novnc/vnc.html\" || return 1\n rm -rf \"$tmp\"\n PYTHONPATH=\"$dir/websockify\" python3 -P -c 'import websockify' >/dev/null || return 1\n command -v Xvnc >/dev/null && test -s \"$dir/novnc/vnc.html\"\n}\ndesktop=false\nif [ -n \"${NOVNC_VERSION:-}\" ]; then\n log 'remote desktop'\n # Optional: a failure leaves an image without the desktop, never a failed build.\n if install_desktop; then\n desktop=true\n else\n log 'remote desktop install failed; continuing without it'\n rm -rf \"$ORCA_HOME/desktop\"\n fi\nfi\n\nlog 'Orca entry points'\nfor name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do\n var=\"ORCA_VM_SCRIPT_${name//[-.]/_}\"\n printf '%s' \"${!var}\" | base64 -d >\"$ORCA_HOME/bin/$name\"\n chmod 0755 \"$ORCA_HOME/bin/$name\"\ndone\n# The orca CLI (agents drive worktrees, terminals and the browser through it) talks to this VM's orcad.\ncli=\"$(find \"$ORCA_HOME/runtime\" -name orca-cli.cjs -type f | head -n 1)\"\nif [ -n \"$cli\" ]; then\n printf '#!/usr/bin/env bash\\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\\nexec \"%s\" \"%s\" \"$@\"\\n' \"$node\" \"$cli\" >\"$ORCA_HOME/bin/orca\"\n chmod 0755 \"$ORCA_HOME/bin/orca\"\nelse\n log 'bundle has no orca CLI (orca-cli.cjs): agents will not have `orca` on PATH'\nfi\n# The image's node/npm/npx live only on the build command's PATH; append them so agents (codex's\n# `env node` shebang) and stdio MCP servers launched with npx resolve them in Orca's login shells.\nsystem_node=\"$(command -v npx || command -v node || true)\"\nif [ -z \"$system_node\" ]; then\n echo 'no node/npx on the build PATH' >&2\n exit 1\nfi\nprintf 'export PATH=%s/tools/bin:%s/bin:$PATH:%s\\n' \"$ORCA_HOME\" \"$ORCA_HOME\" \"$(dirname \"$system_node\")\" |\n sudo tee /etc/profile.d/orca.sh >/dev/null\n# Every new shell (login or not: /etc/bashrc sources profile.d) re-reads the session env file.\nsudo install -m 0644 \"$ORCA_HOME/bin/orca-session-env.sh\" /etc/profile.d/zz-orca-session-env.sh\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu's bash.bashrc does not source profile.d for non-login interactive shells (AL2023's bashrc does).\n grep -q zz-orca-session-env /etc/bash.bashrc ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null\nfi\nsudo chown -R root:root \"$ORCA_HOME\"\n\nlog 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)'\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/compile-cache\"\n# The runtime is root-owned, so its startup-qualified marker lives here. The smoke run leaves one,\n# so every boot from the image skips orcad's disposable startup preflight (~0.6 s of wake).\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/startup-qualification\"\nsmoke_data=\"$(mktemp -d)\"\n# orcad-boot also writes orcad's full code cache as the smoke run exits (~0.3 s more off every boot).\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/bin/orcad-boot\" \\\n --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" --write-code-cache \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6799 --json >/tmp/orcad-smoke.out 2>/tmp/orcad-smoke.err &\nsmoke_pid=$!\nready=0\nfor _ in $(seq 1 600); do\n if grep -q '\"orca_server_ready\"' /tmp/orcad-smoke.out 2>/dev/null; then ready=1; break; fi\n sleep 0.1\ndone\n# orcad writes the marker just after readiness; best effort, a boot without one only re-qualifies.\nfor _ in $(seq 1 30); do\n [ \"$ready\" = 1 ] && [ ! -s \"$ORCA_HOME/startup-qualification/.orcad-startup-qualified\" ] || break\n sleep 0.1\ndone\nkill \"$smoke_pid\" 2>/dev/null || true\nwait \"$smoke_pid\" 2>/dev/null || true\nrm -rf \"$smoke_data\" /tmp/orcad-smoke.out\nif [ \"$ready\" != 1 ]; then\n tail -n 40 /tmp/orcad-smoke.err >&2; exit 1\nfi\n# Best effort: without it every boot loads orcad as before.\nif [ -s \"$ORCA_HOME/compile-cache/orcad-server.v8\" ]; then\n log \"orcad code cache: $(stat -c %s \"$ORCA_HOME/compile-cache/orcad-server.v8\") bytes\"\nelse\n log 'orcad code cache was not written; boots compile orcad without it'\n grep 'orcad-boot' /tmp/orcad-smoke.err >&2 || true\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini cursor-agent copilot gh; do\n if [ -x \"$ORCA_HOME/tools/bin/$bin\" ]; then\n v=\"$(\"$ORCA_HOME/tools/bin/$bin\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$bin\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\nfor sys in rg vim git-lfs google-chrome; do\n if command -v \"$sys\" >/dev/null; then\n v=\"$(\"$sys\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$sys\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\ncommand -v tmux >/dev/null && versions=\"$(jq -c --arg v \"$(tmux -V)\" '. + {tmux: $v}' <<<\"$versions\")\"\n[ -x \"$ORCA_HOME/bin/orca\" ] && versions=\"$(jq -c '. + {orca: \"bundled\"}' <<<\"$versions\")\"\njq -c --arg node \"$(\"$node\" --version)\" --arg disk \"$(df -h / | awk 'NR==2{print $2}')\" \\\n --argjson desktop \"$desktop\" '{node: $node, disk: $disk, tools: ., desktop: $desktop}' <<<\"$versions\"\n" -export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.onorca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.01\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\ndir=\"/dev/shm/orca-env-$(id -u)\"\nif [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop both managed blocks\n awk '/^# >>> orca-cloud managed/{skip=1} !skip{print} /^# <<< orca-cloud managed/{skip=0}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n' || echo 'agent first-run state not seeded' >&2\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.onorca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at)\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const project = worktrees.find((w) => w.path === PROJECT) || worktrees[0] || null\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of listSessions()) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=/usr/bin/git\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x /opt/orca/bin/orca-git-as ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec /opt/orca/bin/orca-git-as \"$@\" 2> >(grep -v '^orca-git-as: no teammate identity' >&2)\n fi\n ;;\nesac\nexec \"$real\" \"$@\"\n"} +export const PROVISION_SCRIPT: string = "#!/usr/bin/env bash\n# Runs once inside the build sandbox (as the sandbox user, with sudo). Installs the pinned orcad\n# bundle, agent CLIs and Orca's VM entry points into /opt/orca, then smoke-tests orcad.\n# Inputs (env, from image/golden-image.manifest.json via build-golden-image.ts):\n# ORCAD_BUNDLE_URL ORCAD_BUNDLE_SHA256 [GH_VERSION GH_SHA256] [CHROME_VERSION CHROME_SHA256]\n# AGENT_NPM_PACKAGES (space-separated\n# name@version), [CURSOR_AGENT_VERSION CURSOR_AGENT_SHA256], [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\nset -euo pipefail\nORCA_HOME=/opt/orca\nlog() { printf '[golden] %s\\n' \"$*\" >&2; }\n\nsudo mkdir -p \"$ORCA_HOME/bin\" \"$ORCA_HOME/runtime\" \"$ORCA_HOME/tools\"\nsudo chown -R \"$(id -u):$(id -g)\" \"$ORCA_HOME\"\n\nlog 'system packages'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n # Sandbox egress is TLS-only: plain-http apt mirrors time out at runtime (W2, wake on SSH).\n sudo sed -i 's#http://#https://#g' /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true\n sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq\n # Developer basics agents expect (Conductor parity): rg, tmux, vim, git-lfs alongside the build tools.\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n ca-certificates curl git git-lfs jq ripgrep tmux vim-nox less openssh-client openssh-server build-essential python3 \\\n unzip zip util-linux procps >/dev/null\n git lfs install --system --skip-repo >/dev/null\n # sshd is started on demand by the wake-on-SSH bridge (key-only, loopback); never at boot.\n sudo systemctl disable ssh.service ssh.socket >/dev/null 2>&1 || true\n # A locked password field (\"!\") makes sshd refuse even key logins; '*' = no password, not locked.\n sudo usermod -p '*' \"$(id -un)\"\nelif command -v dnf >/dev/null; then\n sudo dnf install -y -q ca-certificates curl-minimal git git-lfs jq tmux vim-enhanced gcc gcc-c++ make python3 \\\n unzip util-linux procps-ng >/dev/null\nelse\n log 'no supported package manager'; exit 1\nfi\n\n# Managed images have no /vercel/sandbox; the project lives at /vercel/sandbox/project on every image.\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" /vercel/sandbox\n\nfetch_verified() { # url sha256 dest\n curl -fsSL --retry 3 -o \"$3\" \"$1\"\n echo \"$2 $3\" | sha256sum -c --quiet -\n}\n\nlog 'orcad bundle'\nbundle=\"$(mktemp -d)/orcad.tgz\"\nfetch_verified \"$ORCAD_BUNDLE_URL\" \"$ORCAD_BUNDLE_SHA256\" \"$bundle\"\ntar -xzf \"$bundle\" -C \"$ORCA_HOME/runtime\"\nrm -rf \"$(dirname \"$bundle\")\"\n# Release tarballs (orca-server-linux-*) may nest the server; normalize to runtime/orcad + runtime/runtimes.\nif [ ! -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\" ]; then\n server=\"$(find \"$ORCA_HOME/runtime\" -name orcad-server.js -type f | head -n 1)\"\n [ -n \"$server\" ] || { log 'bundle has no orcad-server.js'; exit 1; }\n root=\"$(dirname \"$(dirname \"$server\")\")\"\n ln -sfn \"$(dirname \"$server\")\" \"$ORCA_HOME/runtime/orcad\"\n [ -d \"$ORCA_HOME/runtime/runtimes\" ] || ln -sfn \"$root/runtimes\" \"$ORCA_HOME/runtime/runtimes\"\nfi\ntest -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ntest -x \"$node\"\n\nif [ -n \"${GH_VERSION:-}\" ]; then\n log \"gh $GH_VERSION\"\n fetch_verified \"https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz\" \\\n \"$GH_SHA256\" /tmp/gh.tgz\n tar -xzf /tmp/gh.tgz -C /tmp\n mkdir -p \"$ORCA_HOME/tools/bin\"\n install -m 0755 \"/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh\" \"$ORCA_HOME/tools/bin/gh\"\n rm -rf /tmp/gh.tgz \"/tmp/gh_${GH_VERSION}_linux_amd64\"\nfi\n\nif [ -n \"${CHROME_VERSION:-}\" ] && command -v apt-get >/dev/null; then\n log \"google-chrome $CHROME_VERSION (headless browser for agents and Orca browser automation)\"\n deb=\"$(mktemp -d)/chrome.deb\"\n fetch_verified \"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\" \\\n \"$CHROME_SHA256\" \"$deb\"\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \"$deb\" >/dev/null\n rm -rf \"$(dirname \"$deb\")\"\n # The .deb adds Google's apt source; the image stays pinned, so drop it.\n sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome\n google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom 'data:text/html,

ok

' 2>/dev/null | grep -q '

ok

'\nfi\n\nlog 'agent CLIs'\n# The image's system npm installs them; agents later run on whatever node orcad puts on PATH.\n# shellcheck disable=SC2086 # word splitting is the package list\nnpm install --global --no-fund --no-audit --prefix \"$ORCA_HOME/tools\" $AGENT_NPM_PACKAGES\n# opencode-ai installs every linux-x64 build variant (~205 MB each) and hardlinks the one its\n# postinstall picked into bin/opencode.exe, a standalone binary: drop the variants it did not pick.\nopencode_pkg=\"$ORCA_HOME/tools/lib/node_modules/opencode-ai\"\nif [ -x \"$opencode_pkg/bin/opencode.exe\" ]; then\n picked=\"$(stat -c %i \"$opencode_pkg/bin/opencode.exe\")\"\n for variant in \"$opencode_pkg\"/node_modules/opencode-*; do\n [ -d \"$variant\" ] || continue\n [ \"$(stat -c %i \"$variant/bin/opencode\" 2>/dev/null)\" = \"$picked\" ] || rm -rf \"$variant\"\n done\n \"$opencode_pkg/bin/opencode.exe\" --version >/dev/null\nfi\n\n# Copilot CLI prefers GH_TOKEN/GITHUB_TOKEN over the user's own /login. In Orca Cloud those hold the\n# workspace's GitHub App placeholder, which Copilot can't use (\"Resource not accessible by\n# integration\"), so a signed-in user could never get past it. The wrapper drops them for Copilot\n# only (COPILOT_GITHUB_TOKEN, a /login and BYOK still work), unless BYOK is set up, which needs no\n# GitHub sign-in and keeps them so `gh` in Copilot's shell still works.\ncopilot_loader=\"$ORCA_HOME/tools/lib/node_modules/@github/copilot/npm-loader.js\"\nif [ -f \"$copilot_loader\" ]; then\n rm -f \"$ORCA_HOME/tools/bin/copilot\"\n cat >\"$ORCA_HOME/tools/bin/copilot\" </dev/null; fi\ndone\n\nif [ -n \"${CURSOR_AGENT_VERSION:-}\" ]; then\n # Cursor's agent CLI is a pinned tarball (its own node + bundle), not an npm package. The image\n # copy lives under /opt/orca (root-owned) and its release channel is set to \"static\" at boot\n # (orca-sandbox-env), so it never replaces itself mid-session.\n log \"cursor-agent $CURSOR_AGENT_VERSION\"\n tgz=\"$(mktemp -d)/cursor-agent.tgz\"\n fetch_verified \"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/x64/agent-cli-package.tar.gz\" \\\n \"$CURSOR_AGENT_SHA256\" \"$tgz\"\n rm -rf \"$ORCA_HOME/tools/cursor-agent\"\n mkdir -p \"$ORCA_HOME/tools/cursor-agent\" \"$ORCA_HOME/tools/bin\"\n tar --strip-components=1 -xzf \"$tgz\" -C \"$ORCA_HOME/tools/cursor-agent\"\n rm -rf \"$(dirname \"$tgz\")\"\n ln -sfn ../cursor-agent/cursor-agent \"$ORCA_HOME/tools/bin/cursor-agent\"\n \"$ORCA_HOME/tools/bin/cursor-agent\" --version >/dev/null\nfi\n\nlog 'openssh server (binaries only)'\n# For `orca ssh`: an unprivileged sshd on loopback (see vm/orca-sshd). Only the server binaries,\n# kept out of /usr: the packages would pull in systemd units, SELinux policy (~38 MB on AL2023) and\n# system host keys a user-run sshd never uses. Its libraries are already in the base image, except\n# tcp-wrappers on Ubuntu.\nmkdir -p \"$ORCA_HOME/openssh\"\npkgs=\"$(mktemp -d)\"\nif command -v dnf >/dev/null; then\n sudo dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null\n sudo dnf install -y -q --downloadonly --downloaddir=\"$pkgs\" openssh-server >/dev/null\n (cd \"$pkgs\" && rpm2cpio openssh-server-[0-9]*.rpm | cpio -idm --quiet)\n sudo dnf clean all -q >/dev/null 2>&1 || true\n libexec=\"$pkgs/usr/libexec/openssh\"\nelse\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libwrap0 >/dev/null\n (cd \"$pkgs\" && apt-get download -qq openssh-server openssh-sftp-server >/dev/null)\n for deb in \"$pkgs\"/*.deb; do dpkg-deb -x \"$deb\" \"$pkgs\"; done\n libexec=\"$pkgs/usr/lib/openssh\"\nfi\ninstall -m 0755 \"$pkgs/usr/sbin/sshd\" \"$libexec/sshd-session\" \"$libexec/sftp-server\" \"$ORCA_HOME/openssh/\"\n# OpenSSH 10 splits authentication into its own binary.\nif [ -x \"$libexec/sshd-auth\" ]; then install -m 0755 \"$libexec/sshd-auth\" \"$ORCA_HOME/openssh/\"; fi\nsudo rm -rf \"$pkgs\"\n\"$ORCA_HOME/openssh/sshd\" -V 2>&1 | head -n 1 >&2 || true\n# sshd refuses a locked account ('!') even for key logins; '*' unlocks it with no usable password.\nsudo usermod -p '*' \"$(id -un)\"\n\ninstall_desktop() {\n # Remote desktop (~80 MB): Xvnc + openbox + xterm from the distro, noVNC + websockify pinned\n # tarballs. Only orca-desktop-start runs it, on demand; nothing starts at boot. Every step says\n # `|| return 1`: errexit does not apply inside an `if` condition.\n command -v apt-get >/dev/null || { log 'remote desktop: apt only'; return 1; }\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n tigervnc-standalone-server tigervnc-tools openbox xterm x11-xserver-utils >/dev/null || return 1\n local tmp dir=\"$ORCA_HOME/desktop\"\n tmp=\"$(mktemp -d)\" || return 1\n fetch_verified \"https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz\" \\\n \"$NOVNC_SHA256\" \"$tmp/novnc.tgz\" || return 1\n fetch_verified \"https://github.com/novnc/websockify/archive/refs/tags/v${WEBSOCKIFY_VERSION}.tar.gz\" \\\n \"$WEBSOCKIFY_SHA256\" \"$tmp/websockify.tgz\" || return 1\n rm -rf \"$dir\" && mkdir -p \"$dir/novnc\" \"$dir/websockify\" || return 1\n tar -xzf \"$tmp/novnc.tgz\" -C \"$dir/novnc\" --strip-components 1 || return 1\n tar -xzf \"$tmp/websockify.tgz\" -C \"$dir/websockify\" --strip-components 1 || return 1\n # Orca tab fit: title \"Remote desktop\" (Xvnc's -desktop name), no noVNC favicon, control bar on\n # the right (away from Orca's sidebar edge); a stale per-boot password ends the session with one\n # plain message instead of a reconnect loop. \"Unencrypted\" is noVNC's view of plain ws:// to\n # localhost; the hop to the sandbox rides the end-to-end encrypted runtime connection.\n sed -i -e 's/document.title = e.detail.name + \" - \" + PAGE_TITLE;/document.title = e.detail.name;/' \\\n -e \"s/WebUtil.readSetting('controlbar_pos') === 'right'/WebUtil.readSetting('controlbar_pos', 'right') === 'right'/\" \\\n -e 's/securityFailed(e) {/securityFailed(e) { UI.inhibitReconnect = true; UI.showStatus(\"This remote desktop session ended. Reopen it from the workspace menu.\", \"error\"); return;/' \\\n -e 's/_(\"Connected (unencrypted) to \")/_(\"Connected to \")/' \\\n \"$dir/novnc/app/ui.js\" || return 1\n sed -i '/rel=\"icon\"/d; /rel=\"apple-touch-icon\"/d' \"$dir/novnc/vnc.html\" || return 1\n rm -rf \"$tmp\"\n PYTHONPATH=\"$dir/websockify\" python3 -P -c 'import websockify' >/dev/null || return 1\n command -v Xvnc >/dev/null && test -s \"$dir/novnc/vnc.html\"\n}\ndesktop=false\nif [ -n \"${NOVNC_VERSION:-}\" ]; then\n log 'remote desktop'\n # Optional: a failure leaves an image without the desktop, never a failed build.\n if install_desktop; then\n desktop=true\n else\n log 'remote desktop install failed; continuing without it'\n rm -rf \"$ORCA_HOME/desktop\"\n fi\nfi\n\nlog 'Orca entry points'\nfor name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-env orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do\n var=\"ORCA_VM_SCRIPT_${name//[-.]/_}\"\n printf '%s' \"${!var}\" | base64 -d >\"$ORCA_HOME/bin/$name\"\n chmod 0755 \"$ORCA_HOME/bin/$name\"\ndone\n# The orca CLI (agents drive worktrees, terminals and the browser through it) talks to this VM's orcad.\ncli=\"$(find \"$ORCA_HOME/runtime\" -name orca-cli.cjs -type f | head -n 1)\"\nif [ -n \"$cli\" ]; then\n printf '#!/usr/bin/env bash\\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\\nexec \"%s\" \"%s\" \"$@\"\\n' \"$node\" \"$cli\" >\"$ORCA_HOME/bin/orca\"\n chmod 0755 \"$ORCA_HOME/bin/orca\"\nelse\n log 'bundle has no orca CLI (orca-cli.cjs): agents will not have `orca` on PATH'\nfi\n# The image's node/npm/npx live only on the build command's PATH; append them so agents (codex's\n# `env node` shebang) and stdio MCP servers launched with npx resolve them in Orca's login shells.\nsystem_node=\"$(command -v npx || command -v node || true)\"\nif [ -z \"$system_node\" ]; then\n echo 'no node/npx on the build PATH' >&2\n exit 1\nfi\nprintf 'export PATH=%s/tools/bin:%s/bin:$PATH:%s\\n' \"$ORCA_HOME\" \"$ORCA_HOME\" \"$(dirname \"$system_node\")\" |\n sudo tee /etc/profile.d/orca.sh >/dev/null\n# Every new shell (login or not: /etc/bashrc sources profile.d) re-reads the session env file.\nsudo install -m 0644 \"$ORCA_HOME/bin/orca-session-env.sh\" /etc/profile.d/zz-orca-session-env.sh\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu's bash.bashrc does not source profile.d for non-login interactive shells (AL2023's bashrc does).\n grep -q zz-orca-session-env /etc/bash.bashrc ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null\nfi\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu: zsh reads /etc/zsh/zshenv for every shell, so new zsh shells match bash (`orca-env` covers\n # shells that were already open).\n sudo install -d -m 0755 /etc/zsh\n sudo touch /etc/zsh/zshenv\n grep -q zz-orca-session-env /etc/zsh/zshenv ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv >/dev/null\nfi\nsudo chown -R root:root \"$ORCA_HOME\"\n\nlog 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)'\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/compile-cache\"\n# The runtime is root-owned, so its startup-qualified marker lives here. The smoke run leaves one,\n# so every boot from the image skips orcad's disposable startup preflight (~0.6 s of wake).\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/startup-qualification\"\nsmoke_data=\"$(mktemp -d)\"\n# orcad-boot also writes orcad's full code cache as the smoke run exits (~0.3 s more off every boot).\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/bin/orcad-boot\" \\\n --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" --write-code-cache \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6799 --json >/tmp/orcad-smoke.out 2>/tmp/orcad-smoke.err &\nsmoke_pid=$!\nready=0\nfor _ in $(seq 1 600); do\n if grep -q '\"orca_server_ready\"' /tmp/orcad-smoke.out 2>/dev/null; then ready=1; break; fi\n sleep 0.1\ndone\n# orcad writes the marker just after readiness; best effort, a boot without one only re-qualifies.\nfor _ in $(seq 1 30); do\n [ \"$ready\" = 1 ] && [ ! -s \"$ORCA_HOME/startup-qualification/.orcad-startup-qualified\" ] || break\n sleep 0.1\ndone\nkill \"$smoke_pid\" 2>/dev/null || true\nwait \"$smoke_pid\" 2>/dev/null || true\nrm -rf \"$smoke_data\" /tmp/orcad-smoke.out\nif [ \"$ready\" != 1 ]; then\n tail -n 40 /tmp/orcad-smoke.err >&2; exit 1\nfi\n# Best effort: without it every boot loads orcad as before.\nif [ -s \"$ORCA_HOME/compile-cache/orcad-server.v8\" ]; then\n log \"orcad code cache: $(stat -c %s \"$ORCA_HOME/compile-cache/orcad-server.v8\") bytes\"\nelse\n log 'orcad code cache was not written; boots compile orcad without it'\n grep 'orcad-boot' /tmp/orcad-smoke.err >&2 || true\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini cursor-agent copilot gh; do\n if [ -x \"$ORCA_HOME/tools/bin/$bin\" ]; then\n v=\"$(\"$ORCA_HOME/tools/bin/$bin\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$bin\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\nfor sys in rg vim git-lfs google-chrome; do\n if command -v \"$sys\" >/dev/null; then\n v=\"$(\"$sys\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$sys\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\ncommand -v tmux >/dev/null && versions=\"$(jq -c --arg v \"$(tmux -V)\" '. + {tmux: $v}' <<<\"$versions\")\"\n[ -x \"$ORCA_HOME/bin/orca\" ] && versions=\"$(jq -c '. + {orca: \"bundled\"}' <<<\"$versions\")\"\njq -c --arg node \"$(\"$node\" --version)\" --arg disk \"$(df -h / | awk 'NR==2{print $2}')\" \\\n --argjson desktop \"$desktop\" '{node: $node, disk: $disk, tools: ., desktop: $desktop}' <<<\"$versions\"\n" +export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.onorca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.01\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\ndir=\"/dev/shm/orca-env-$(id -u)\"\nif [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop both managed blocks\n awk '/^# >>> orca-cloud managed/{skip=1} !skip{print} /^# <<< orca-cloud managed/{skip=0}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.onorca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at)\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const project = worktrees.find((w) => w.path === PROJECT) || worktrees[0] || null\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of listSessions()) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=/usr/bin/git\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x /opt/orca/bin/orca-git-as ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec /opt/orca/bin/orca-git-as \"$@\" 2> >(grep -v '^orca-git-as: no teammate identity' >&2)\n fi\n ;;\nesac\nexec \"$real\" \"$@\"\n"} export const SOURCE_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Golden build from source, run by the Worker inside the build sandbox: orcad + the orca CLI from one\n# commit of the Orca repo (cloud/integration carries host RPCs no upstream release has), then the same\n# provision-golden.sh every image uses. Inputs (env):\n# ORCA_SRC_SHA (40-hex) and either ORCA_SRC_DIR (source already uploaded) or ORCA_SRC_REPO\n# (https://github.com//) + ORCA_SRC_AUTH (an\n# `Authorization: Basic …` header value for a 1 h read-only installation token; never printed),\n# ORCA_PROVISION_SCRIPT (base64 of provision-golden.sh) + everything provision-golden.sh reads.\nset -euo pipefail\n# Vercel can report a detached build command as still running after it exits, so the outcome is also\n# a file the caller polls: /tmp/source-build.exit (exit code), /tmp/source-build.report (the\n# provision report line).\nrm -f /tmp/source-build.exit /tmp/source-build.report\ntrap 'echo $? >/tmp/source-build.exit' EXIT\nlog() { printf '[source-build] %s\\n' \"$*\" >&2; }\nstart=$(date +%s)\n[[ \"$ORCA_SRC_SHA\" =~ ^[0-9a-f]{40}$ ]] || { log 'ORCA_SRC_SHA must be a full commit sha'; exit 2; }\n\nlog 'build tools'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n sudo apt-get update -qq\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends build-essential python3 git ca-certificates >/dev/null\nelse\n sudo dnf install -y -q gcc gcc-c++ make tar gzip python3 git >/dev/null\nfi\nsudo corepack enable >/dev/null 2>&1 || true\n\nsrc=\"$HOME/orca-src\"\nif [ -n \"${ORCA_SRC_DIR:-}\" ]; then\n # Operator rotation (image/rotate-golden-from-source.ts) uploaded `git archive ` here already.\n log \"uploaded source for $ORCA_SRC_SHA\"\n src=\"$ORCA_SRC_DIR\"\n cd \"$src\" && test -f package.json\n git init -q && git add -A && git -c user.email=build@orca -c user.name=build commit -qm \"$ORCA_SRC_SHA\"\nelse\n log \"fetch $ORCA_SRC_SHA\"\n rm -rf \"$src\" && mkdir -p \"$src\" && cd \"$src\"\n git init -q\n # The header lives only in this one command's config, not in .git/config.\n git -c \"http.https://github.com/.extraheader=$ORCA_SRC_AUTH\" fetch -q --depth 1 \"$ORCA_SRC_REPO\" \"$ORCA_SRC_SHA\"\n unset ORCA_SRC_AUTH\n git -c advice.detachedHead=false checkout -q FETCH_HEAD\n test \"$(git rev-parse HEAD)\" = \"$ORCA_SRC_SHA\"\nfi\n\n# The image files come from this commit too (VM scripts, provision, manifest pins). The env the\n# caller passed is only a fallback for commits from before commit-provision-env.mjs existed.\nimage_dir=\"$src/cloud/apps/sandbox-control/image\"\nif [ -f \"$image_dir/commit-provision-env.mjs\" ]; then\n node \"$image_dir/commit-provision-env.mjs\" \"$image_dir\" >/tmp/commit-provision.env\n # shellcheck disable=SC1091\n . /tmp/commit-provision.env\n rm -f /tmp/commit-provision.env\n log \"image files from $ORCA_SRC_SHA\"\nfi\n\nlog 'build orcad + CLI'\nexport CI=1 ELECTRON_SKIP_BINARY_DOWNLOAD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1\ncorepack pnpm install --frozen-lockfile >/tmp/src-install.log 2>&1 || { tail -40 /tmp/src-install.log >&2; exit 1; }\ncorepack pnpm -C mobile install --frozen-lockfile >/tmp/src-mobile.log 2>&1 || { tail -40 /tmp/src-mobile.log >&2; exit 1; }\ncorepack pnpm build:mobile-web >/tmp/src-mobile-web.log 2>&1 || { tail -40 /tmp/src-mobile-web.log >&2; exit 1; }\ncorepack pnpm build:orcad >/tmp/src-orcad.log 2>&1 || { tail -60 /tmp/src-orcad.log >&2; exit 1; }\ncorepack pnpm build:cli >/tmp/src-cli.log 2>&1 || { tail -40 /tmp/src-cli.log >&2; exit 1; }\nmkdir -p out/orca-cli\nnode_modules/.bin/esbuild out/cli/index.js --bundle --platform=node --target=node22 --format=cjs \\\n --outfile=out/orca-cli/orca-cli.cjs --log-level=error\nprintf '%s\\n' \"$ORCA_SRC_SHA\" >out/orcad/SOURCE_COMMIT\ntar czf /tmp/orcad.tgz -C out orcad runtimes orca-cli\nlog \"built in $(( $(date +%s) - start ))s\"\n\nexport ORCAD_BUNDLE_URL=file:///tmp/orcad.tgz\nORCAD_BUNDLE_SHA256=\"$(sha256sum /tmp/orcad.tgz | cut -d' ' -f1)\"\nexport ORCAD_BUNDLE_SHA256\ncd \"$HOME\"\nprintf '%s' \"$ORCA_PROVISION_SCRIPT\" | base64 -d >/tmp/provision-golden.sh\nunset ORCA_PROVISION_SCRIPT\nbash /tmp/provision-golden.sh | tee /tmp/source-build.report\n# Build leftovers stay out of the image.\nrm -rf \"$src\" \"$HOME/.local/share/pnpm\" \"$HOME/.cache/pnpm\" /tmp/orcad.tgz /tmp/provision-golden.sh /tmp/src-*.log\nlog \"done in $(( $(date +%s) - start ))s\"\n" export const ENVIRONMENT_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Environment build (\"Cloud Computer\"), run by the control plane in a VM booted from the golden image.\n# Clones every repo, runs the org's install script once with `set -euo pipefail`, then records what it\n# built from (commit + lockfile blob ids per repo) so the control plane can tell when a rebuild is due.\n# Everything printed here streams live to the desktop build log.\n#\n# Env from the control plane (validated there): ORCA_BUILD_REPOS (`|||\n# ` lines: each private repo gets its own placeholder the egress swaps), ORCA_INSTALL_SCRIPT_B64, ORCA_LOCKFILES (space list), optional ORCA_GIT_EXTRAHEADER, plus\n# the org secrets the environment names. `::orca-step:: ` lines mark progress.\n# Exit codes: 10 clone, 20 install script, 30 recording the result.\nset -uo pipefail\numask 022\n# One stream: the provider replays stdout and stderr separately after the fact, so a failed build's\n# log would read out of order. `::orca-error::` lines carry Orca's own failure explanations.\nexec 2>&1\nOUT=/tmp/orca-build\nROOT=/vercel/sandbox/repos\nmkdir -p \"$OUT\" \"$ROOT\"\nstep() { printf '\\n::orca-step:: %s\\n' \"$1\"; }\nstarted=$(date +%s)\nexport PATH=\"/opt/orca/tools/bin:/opt/orca/bin:$PATH\"\nexport ORCA_REPOS_ROOT=\"$ROOT\" ORCA_ENVIRONMENT_BUILD=1 CI=1 GIT_TERMINAL_PROMPT=0\nrepos() { printf '%s\\n' \"$ORCA_BUILD_REPOS\"; }\n\n# Tell the script author (often an agent) what they are installing onto.\npm=$(for c in apt-get dnf yum apk; do command -v \"$c\" >/dev/null 2>&1 && { echo \"$c\"; break; }; done)\necho \"Base image: $(. /etc/os-release 2>/dev/null && echo \"$PRETTY_NAME\"), $(nproc) vCPU, package manager: ${pm:-none} (with sudo)\"\n\nstep clone\nfirst=\"\"\nwhile IFS='|' read -r url branch dir header; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n [ -n \"$first\" ] || first=\"$dest\"\n # The egress layer swaps the placeholder for a short-lived repo token; nothing real is on disk,\n # and the header is never written to the repo's config.\n git_auth=()\n [ -z \"${header:-}\" ] || git_auth=(-c \"http.https://github.com/.extraheader=$header\")\n echo \"Cloning $url${branch:+ ($branch)}\"\n rm -rf -- \"$dest\"\n # The clone's own error shows only when it really failed (an empty repo is recovered below).\n if ! git \"${git_auth[@]}\" clone --quiet ${branch:+--branch \"$branch\"} -- \"$url\" \"$dest\" 2>\"$OUT/clone.err\"; then\n refs=0\n git \"${git_auth[@]}\" ls-remote --exit-code -- \"$url\" >/dev/null 2>&1 || refs=$?\n if [ -n \"$branch\" ] && [ \"$refs\" -eq 2 ]; then\n # Reachable but no refs at all (ls-remote exits 2): a repo with no commits yet.\n echo \"$url has no commits yet; starting it empty on $branch.\"\n rm -rf -- \"$dest\"\n { git \"${git_auth[@]}\" clone --quiet -- \"$url\" \"$dest\" 2>/dev/null && git -C \"$dest\" checkout --quiet -b \"$branch\"; } || exit 10\n else\n cat \"$OUT/clone.err\"\n echo \"::orca-error:: Could not clone $url${branch:+ at $branch}. Check that the branch exists and Orca Cloud can reach the repository.\" \n exit 10\n fi\n fi\n git -C \"$dest\" log -1 --format=' at %h %s' 2>/dev/null || echo ' (no commits yet)'\ndone < <(repos)\n\nstep install\nprintf '%s' \"${ORCA_INSTALL_SCRIPT_B64:-}\" | base64 -d >\"$OUT/install.sh\"\nif [ -s \"$OUT/install.sh\" ]; then\n echo \"Running the install script in ${first:-$ROOT}\"\n (cd \"${first:-$ROOT}\" && exec bash -euo pipefail \"$OUT/install.sh\") /dev/null)\" || continue\n git -C \"$dest\" ls-files --others --ignored --exclude-standard --directory 2>/dev/null \\\n | sed 's#/$##' | grep -E \"$deps_re\" | head -n 500 >\"$common/orca-env-deps\" || true\n if [ -n \"${ORCA_DEPS_HOOK_B64:-}\" ]; then\n # A relative core.hooksPath (husky: .husky/_) names a gitignored folder a new worktree lacks; pin\n # it to the primary checkout so every worktree runs the same hooks, ours included.\n rel=\"$(git -C \"$dest\" config --get core.hooksPath 2>/dev/null || true)\"\n case \"$rel\" in '' | /*) ;; *) git -C \"$dest\" config core.hooksPath \"$dest/$rel\" ;; esac\n hooks=\"$(git -C \"$dest\" rev-parse --path-format=absolute --git-path hooks)\"\n mkdir -p \"$hooks\"\n if [ -e \"$hooks/post-checkout\" ] && ! grep -q 'orca-env-deps' \"$hooks/post-checkout\"; then\n mv -- \"$hooks/post-checkout\" \"$hooks/post-checkout.orca-chained\"\n fi\n printf '%s' \"$ORCA_DEPS_HOOK_B64\" | base64 -d >\"$hooks/post-checkout\" && chmod 0755 \"$hooks/post-checkout\"\n fi\n n=$(wc -l <\"$common/orca-env-deps\" | tr -d ' ')\n [ \"$n\" -gt 0 ] && echo \"$dir: $n installed folder(s) carried into new workspaces.\"\ndone < <(repos)\n{\n printf '{\"durationSec\":%d,\"repos\":[' \"$(( $(date +%s) - started ))\"\n sep=\"\"\n while IFS='|' read -r url branch dir _; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n commit=$(git -C \"$dest\" rev-parse --verify --quiet HEAD 2>/dev/null || true)\n head=$(git -C \"$dest\" symbolic-ref --quiet --short HEAD 2>/dev/null || true)\n printf '%s{\"url\":\"%s\",\"dir\":\"%s\",\"branch\":\"%s\",\"commit\":\"%s\",\"lockfiles\":{' \"$sep\" \"$url\" \"$dir\" \"$head\" \"$commit\"\n lsep=\"\"\n if [ -n \"$commit\" ]; then\n for f in ${ORCA_LOCKFILES:-}; do\n blob=$(git -C \"$dest\" rev-parse --verify --quiet \"HEAD:$f\" 2>/dev/null || true)\n if [ -n \"$blob\" ]; then printf '%s\"%s\":\"%s\"' \"$lsep\" \"$f\" \"$blob\"; lsep=\",\"; fi\n done\n fi\n printf '}}'\n sep=\",\"\n done < <(repos)\n printf '],\"diskUsedBytes\":%s,\"diskTotalBytes\":%s}\\n' \\\n \"$(df -B1 --output=used / | tail -n 1 | tr -d ' ')\" \"$(df -B1 --output=size / | tail -n 1 | tr -d ' ')\"\n} >\"$OUT/result.json\" || exit 30\n# Nothing from this VM's own session belongs in the image, only what the script installed.\nrm -rf -- \"$OUT/install.sh\" \"$HOME/.bash_history\" \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\"\necho \"Disk used: $(df -h --output=used / | tail -n 1 | tr -d ' ') of $(df -h --output=size / | tail -n 1 | tr -d ' ')\"\n" export const ENVIRONMENT_DEPS_HOOK: string = "#!/usr/bin/env bash\n# Installed by Orca environment builds as the repo's post-checkout hook. When git creates a new linked\n# worktree (`git worktree add` passes the null commit as the previous HEAD), give it the dependency\n# folders the build installed in the primary checkout, as hard links: near-instant, real paths (not\n# symlinks, so bundlers and native modules behave). Hard links share file contents: tools that rewrite\n# a file in place inside these folders change it for every worktree on this VM (one workspace per VM).\n# Any post-checkout hook the repo already had runs afterwards, unchanged.\nprev=\"${1:-}\"\nhere=\"$(dirname \"$0\")\"\nif [ \"$prev\" = \"0000000000000000000000000000000000000000\" ] && [ \"${ORCA_SKIP_DEPS_LINK:-}\" != 1 ]; then\n common=\"$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)\"\n top=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\n primary=\"$(dirname \"$common\")\"\n list=\"$common/orca-env-deps\"\n if [ -n \"$top\" ] && [ \"$top\" != \"$primary\" ] && [ -f \"$list\" ]; then\n started=$(date +%s%N)\n linked=0\n while IFS= read -r rel; do\n case \"$rel\" in '' | /* | *..*) continue ;; esac\n [ -e \"$primary/$rel\" ] && [ ! -e \"$top/$rel\" ] || continue\n mkdir -p \"$(dirname \"$top/$rel\")\"\n if [ -d \"$primary/$rel\" ]; then\n if ! cp -al -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null; then\n # A partial hard-link copy would make the fallback nest inside it: start clean.\n rm -rf -- \"$top/$rel\"\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || { rm -rf -- \"$top/$rel\"; continue; }\n fi\n else\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || continue\n fi\n linked=$((linked + 1))\n done <\"$list\"\n if [ \"$linked\" -gt 0 ]; then\n echo \"Orca: reused $linked installed dependency folder(s) from the environment build ($(( ($(date +%s%N) - started) / 1000000 )) ms).\" >&2\n fi\n fi\nfi\nif [ -x \"$here/post-checkout.orca-chained\" ]; then\n if head -n 1 \"$here/post-checkout.orca-chained\" | grep -Eq '^#!.*(ba|da|z)?sh'; then\n # Sourced, so $0 still names `post-checkout` (husky v9 finds the user's hook by basename \"$0\").\n ( . \"$here/post-checkout.orca-chained\" \"$@\" )\n exit $?\n fi\n exec \"$here/post-checkout.orca-chained\" \"$@\"\nfi\nexit 0\n" diff --git a/cloud/apps/sandbox-control/image/vm/orca-env b/cloud/apps/sandbox-control/image/vm/orca-env new file mode 100755 index 00000000000..88dea196c98 --- /dev/null +++ b/cloud/apps/sandbox-control/image/vm/orca-env @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets +# saved after the shell started): eval "$(orca-env)" or, in fish: orca-env --fish | source +# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and +# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone. +# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal. +set -euo pipefail +fish='' +case "${1-}" in + '') ;; + --fish) fish=1 ;; + -h | --help) + printf 'usage: eval "$(orca-env)" (fish: orca-env --fish | source)\n' + exit 0 + ;; + *) + printf 'orca-env: unknown option %s\n' "$1" >&2 + exit 2 + ;; +esac +# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names. +file="${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}" +set -f # names are split below, never globbed +# Not written yet (or not a cloud VM): nothing to change, like the shell hook. +[ -r "$file" ] || exit 0 + +# 'it'\''s' for POSIX shells; fish single quotes escape only \ and '. +quote() { + local v="$1" + if [ -n "$fish" ]; then + v="${v//\\/\\\\}" + v="${v//\'/\\\'}" + printf "'%s'" "$v" + else + printf "'%s'" "${v//\'/\'\\\'\'}" + fi +} + +# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test +# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes. +skip_name() { + case "$1" in + '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;; + LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;; + PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \ + BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \ + NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \ + CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \ + PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \ + GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \ + GH_TOKEN | GITHUB_TOKEN) return 0 ;; + # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the + # Orca-account placeholder (contract D10), never a stored value. + esac + return 1 +} + +names=' ' +while IFS= read -r line || [ -n "$line" ]; do + name="${line%%=*}" + skip_name "$name" && continue + # $(...) drops trailing newlines; the x keeps them. + value="$(printf '%s' "${line#*=}" | base64 -d 2>/dev/null; printf x)" + value="${value%x}" + if [ -n "$fish" ]; then + printf 'set -gx %s %s\n' "$name" "$(quote "$value")" + else + printf 'export %s=%s\n' "$name" "$(quote "$value")" + fi + names="$names$name " +done <"$file" +for name in ${ORCA_SESSION_ENV_NAMES-}; do + skip_name "$name" && continue + case "$names" in *" $name "*) continue ;; esac + if [ -n "$fish" ]; then + printf 'set -e %s\n' "$name" + else + printf 'unset %s\n' "$name" + fi +done +names="${names# }" +names="${names% }" +if [ -n "$fish" ]; then + printf 'set -gx ORCA_SESSION_ENV_NAMES %s\n' "$(quote "$names")" +else + printf 'export ORCA_SESSION_ENV_NAMES=%s\n' "$(quote "$names")" +fi diff --git a/cloud/apps/sandbox-control/image/vm/orca-sandbox-env b/cloud/apps/sandbox-control/image/vm/orca-sandbox-env index 3385bc365bb..4b863e42876 100644 --- a/cloud/apps/sandbox-control/image/vm/orca-sandbox-env +++ b/cloud/apps/sandbox-control/image/vm/orca-sandbox-env @@ -227,6 +227,32 @@ if (isObject(oc) && !jsoncHasModel) { else fs.rmSync(ocLedger, { force: true }) } } + +// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode +// picks one and fails ("Authorization header is badly formatted" / opencode.ai unreachable) instead +// of showing "Connect a provider". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the +// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the +// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json). +const ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, ".local", "share"), "opencode", "auth.json")) +const connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : []) +const ocDisabledLedger = path.join(ocDir, ".orca-managed-disabled-providers") +const ocNow = readStrict(ocFile) +if (isObject(ocNow) && !/"disabled_providers"\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, "opencode.jsonc"), "utf8") } catch { return "" } })())) { + let ours = [] + try { ours = fs.readFileSync(ocDisabledLedger, "utf8").split("\n").filter(Boolean) } catch {} + const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === "string" && !ours.includes(id)) + const managed = ["github-copilot", "opencode"].filter((id) => !connected.has(id) && !userList.includes(id)) + const next = [...userList, ...managed] + const before = JSON.stringify(ocNow.disabled_providers ?? []) + if (JSON.stringify(next) !== before) { + const updated = { $schema: "https://opencode.ai/config.json", ...ocNow } + if (next.length > 0) updated.disabled_providers = next + else delete updated.disabled_providers + write(ocFile, updated) + } + if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join("\n") + "\n", { mode: 0o600 }) + else fs.rmSync(ocDisabledLedger, { force: true }) +} ' || echo 'agent first-run state not seeded' >&2 fi printf '{"names":%d}\n' "$count" diff --git a/cloud/apps/sandbox-control/migrations/0042_session_cpu.sql b/cloud/apps/sandbox-control/migrations/0042_session_cpu.sql new file mode 100644 index 00000000000..ffb834b73c9 --- /dev/null +++ b/cloud/apps/sandbox-control/migrations/0042_session_cpu.sql @@ -0,0 +1,20 @@ +-- U22: each session's measured CPU, for internal fair-use review only. Customers keep flat +-- sandbox hours: nothing here feeds usage_outbox, billing or any customer-facing route. +-- One row per provider session (idempotent); active_cpu_ms stays NULL until Vercel reports it +-- after the VM stops, and a cron sweep retries the ones still missing. Additive. +CREATE TABLE session_cpu ( + session_id TEXT PRIMARY KEY, + sandbox_id TEXT NOT NULL, + account_id TEXT NOT NULL, + user_id TEXT, + vcpus INTEGER NOT NULL, + memory_mb INTEGER, + active_cpu_ms INTEGER, + wall_ms INTEGER NOT NULL, + started_at INTEGER, + ended_at INTEGER NOT NULL, + fetched_at INTEGER, + attempts INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX session_cpu_account_ended ON session_cpu (account_id, ended_at); +CREATE INDEX session_cpu_pending ON session_cpu (ended_at) WHERE active_cpu_ms IS NULL; diff --git a/cloud/apps/sandbox-control/migrations/0044_agent_credentials.sql b/cloud/apps/sandbox-control/migrations/0044_agent_credentials.sql new file mode 100644 index 00000000000..cca25d02e9c --- /dev/null +++ b/cloud/apps/sandbox-control/migrations/0044_agent_credentials.sql @@ -0,0 +1,19 @@ +-- Account credentials for agents, Personal only (agent-sources contract D11): the Claude setup-token or the +-- Codex broker's tokens (JSON), envelope-encrypted like secrets (AAD: scope 'account', user id, agent). Values +-- never leave the Worker except swapped into a request at egress (D10). Flags S1 (Claude) / S2 (Codex). +CREATE TABLE agent_credentials ( + user_id TEXT NOT NULL, + account_id TEXT NOT NULL, + agent TEXT NOT NULL CHECK (agent IN ('claude', 'codex')), + kek_id TEXT NOT NULL, + wrapped_dek TEXT NOT NULL, + ciphertext TEXT NOT NULL, + -- What the person sees ("ada@example.com"); never a token. + label TEXT, + expires_at INTEGER, + -- active | revoked (expired/expiring are derived from expires_at) + state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'revoked')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, agent) +); diff --git a/cloud/apps/sandbox-control/package.json b/cloud/apps/sandbox-control/package.json index bf63884437f..0b519f0dbdb 100644 --- a/cloud/apps/sandbox-control/package.json +++ b/cloud/apps/sandbox-control/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "wrangler dev --port 8791 --ip 127.0.0.1", + "dev": "wrangler dev -c wrangler.jsonc -c wrangler.build-runner.jsonc --port 8791 --ip 127.0.0.1", "db:migrate:local": "wrangler d1 migrations apply SANDBOX_DB --local", "golden-image": "tsx image/build-golden-image.ts", "typecheck": "tsc --noEmit", diff --git a/cloud/apps/sandbox-control/src/account-providers.ts b/cloud/apps/sandbox-control/src/account-providers.ts new file mode 100644 index 00000000000..d91956c246f --- /dev/null +++ b/cloud/apps/sandbox-control/src/account-providers.ts @@ -0,0 +1,52 @@ +// "Via your Orca account" (agent-sources contract D10/D13): an agent signs in once, account-wide, and every one +// of the person's own cloud workspaces uses that account through an egress placeholder. The sandbox never holds +// the real credential. The proxy picks per request, so renewals, Source changes and the kill switch apply to +// the next request. One provider per agent: Claude here (A1 spike: `CLAUDE_CODE_OAUTH_TOKEN=` +// is accepted and sent as `Authorization: Bearer`), Codex in codex-credentials.ts. +import type { CredentialAgent } from './agent-credential-names' +import { readUsableAgentCredential } from './agent-credentials' +import type { Keyring } from './secrets-crypto' +import type { SandboxRow } from './store' + +export type AccountProvider = { + agent: CredentialAgent + /** Egress hosts where the account placeholder is swapped (also allowlisted while the account applies). */ + hosts: readonly string[] + /** Env the session gets while the account applies: placeholders only. */ + sessionEnv(row: Pick): Record + /** Header the client sends the placeholder in. */ + header: string + placeholder(row: Pick): string + format(credential: string): string + /** Extra headers derived from the real credential (e.g. Codex's ChatGPT account id). */ + extraHeaders?(credential: string): Record + /** + * The provider answered 401 to this credential. Default (absent): mark it revoked. Codex instead + * kicks a single-flight lease refresh, which marks it expired/revoked itself if the refresh token is dead. + */ + onUnauthorized?(ctx: { db: D1Database; keyring: Keyring; userId: string; now: number; waitUntil: (work: Promise) => void }): Promise + /** The real value for this person, or null (none, expired, revoked). */ + credential(db: D1Database, keyring: Keyring, userId: string, now: number): Promise +} + +/** D13: a workspace shared with teammates never uses a personal account. */ +export function isSharedWorkspace(row: Pick): boolean { + return row.visibility === 'team' +} + +export const claudeAccount: AccountProvider = { + agent: 'claude', + // Messages plus the OAuth-side endpoints Claude Code calls with the same token (A1 spike). + hosts: ['api.anthropic.com'], + placeholder: (row) => `orca-ph-claude-account-${row.id}`, + sessionEnv: (row) => ({ CLAUDE_CODE_OAUTH_TOKEN: `orca-ph-claude-account-${row.id}` }), + header: 'authorization', + format: (credential) => `Bearer ${credential}`, + credential: (db, keyring, userId, now) => readUsableAgentCredential(db, keyring, userId, 'claude', now) +} + +export const ACCOUNT_PROVIDERS: AccountProvider[] = [claudeAccount] + +export function accountProvider(agent: CredentialAgent): AccountProvider | undefined { + return ACCOUNT_PROVIDERS.find((provider) => provider.agent === agent) +} diff --git a/cloud/apps/sandbox-control/src/agent-credentials.ts b/cloud/apps/sandbox-control/src/agent-credentials.ts new file mode 100644 index 00000000000..92e904f9170 --- /dev/null +++ b/cloud/apps/sandbox-control/src/agent-credentials.ts @@ -0,0 +1,169 @@ +// Account credentials for agents (agent-sources contract D11): one per person per agent, Personal only. +// GET /v1/agent-credentials -> {claude, codex: {state, label?, expiresAt?}, providerEnabled} +// PUT /v1/agent-credentials/claude {token, label?, expiresAt?} (flag S1; desktop main runs `claude setup-token`) +// DELETE /v1/agent-credentials/:agent +// Values are never returned or logged. They leave the Worker only when the egress proxy swaps them into a +// request from the person's own, unshared workspace (D10, D13). The Codex broker (flag S2) stores its tokens +// through putAgentCredential too (codex-broker.ts). +import { z } from 'zod' +import type { CredentialAgent } from './agent-credential-names' +import type { SandboxCaller } from './auth' +import type { Env } from './env' +import type { Keyring, SecretBinding } from './secrets-crypto' + +export type CredentialState = 'none' | 'active' | 'expiring' | 'expired' | 'revoked' +const EXPIRING_MS = 30 * 24 * 60 * 60_000 +/** `claude setup-token` issues a one-year token. */ +const CLAUDE_SETUP_TOKEN_TTL_MS = 365 * 24 * 60 * 60_000 + +type CredentialRow = { + user_id: string + account_id: string + agent: CredentialAgent + kek_id: string + wrapped_dek: string + ciphertext: string + label: string | null + expires_at: number | null + state: 'active' | 'revoked' + updated_at: number +} + +export class AgentCredentialError extends Error { + constructor( + readonly status: 400 | 403 | 404 | 503, + readonly code: string, + readonly reason?: string + ) { + super(reason ? `${code}:${reason}` : code) + } +} + +/** The AAD every account credential is sealed with; the Codex lease refresher must reuse it exactly. */ +export const credentialBinding = (userId: string, agent: CredentialAgent): SecretBinding => ({ scope: 'account', ownerId: userId, name: agent }) + +/** Flags S1/S2 turn a provider on; the *_DISABLED switch turns it off again (kill switch, operator-only). */ +export function providerEnabled(env: Pick): Record { + return { + claude: env.AGENT_ACCOUNTS_CLAUDE === 'on' && env.AGENT_ACCOUNTS_CLAUDE_DISABLED !== '1', + codex: env.AGENT_ACCOUNTS_CODEX === 'on' && env.AGENT_ACCOUNTS_CODEX_DISABLED !== '1' + } +} + +export function credentialState(row: Pick | null, now: number): CredentialState { + if (!row) { + return 'none' + } + if (row.state === 'revoked') { + return 'revoked' + } + if (row.expires_at !== null && row.expires_at <= now) { + return 'expired' + } + if (row.expires_at !== null && row.expires_at - now <= EXPIRING_MS) { + return 'expiring' + } + return 'active' +} + +/** Usable for a request now: active or expiring (still valid). */ +export function isUsable(state: CredentialState): boolean { + return state === 'active' || state === 'expiring' +} + +async function rowFor(db: D1Database, userId: string, agent: CredentialAgent): Promise { + return db.prepare('SELECT * FROM agent_credentials WHERE user_id = ? AND agent = ?').bind(userId, agent).first() +} + +export async function agentCredentialState(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise { + return credentialState(await rowFor(db, userId, agent), now) +} + +export async function putAgentCredential( + db: D1Database, + keyring: Keyring, + input: { userId: string; accountId: string; agent: CredentialAgent; value: string; label?: string | null; expiresAt?: number | null }, + now: number +): Promise { + const sealed = await keyring.seal(credentialBinding(input.userId, input.agent), input.value) + await db + .prepare( + `INSERT INTO agent_credentials (user_id, account_id, agent, kek_id, wrapped_dek, ciphertext, label, expires_at, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + ON CONFLICT (user_id, agent) DO UPDATE SET account_id = excluded.account_id, kek_id = excluded.kek_id, + wrapped_dek = excluded.wrapped_dek, ciphertext = excluded.ciphertext, label = excluded.label, + expires_at = excluded.expires_at, state = 'active', updated_at = excluded.updated_at` + ) + .bind(input.userId, input.accountId, input.agent, sealed.kekId, sealed.wrappedDek, sealed.ciphertext, input.label ?? null, input.expiresAt ?? null, now, now) + .run() +} + +/** The real value for egress, or null when there is none or it can't be used now. */ +export async function readUsableAgentCredential(db: D1Database, keyring: Keyring, userId: string, agent: CredentialAgent, now: number): Promise { + const row = await rowFor(db, userId, agent) + if (!isUsable(credentialState(row, now)) || !row) { + return null + } + try { + return await keyring.open(credentialBinding(userId, agent), { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }) + } catch { + console.log(JSON.stringify({ event: 'agent_credential_unreadable', agent })) + return null + } +} + +/** The provider said the credential is no longer valid (401 at egress): stop using it until renewed. */ +export async function markAgentCredentialRevoked(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise { + await db.prepare(`UPDATE agent_credentials SET state = 'revoked', updated_at = ? WHERE user_id = ? AND agent = ?`).bind(now, userId, agent).run() +} + +export async function deleteAgentCredential(db: D1Database, userId: string, agent: CredentialAgent): Promise { + const result = await db.prepare('DELETE FROM agent_credentials WHERE user_id = ? AND agent = ?').bind(userId, agent).run() + return result.meta.changes === 1 +} + +export async function agentCredentialsView(db: D1Database, env: Env, userId: string, now: number) { + const { results } = await db.prepare('SELECT * FROM agent_credentials WHERE user_id = ?').bind(userId).all() + const view = (agent: CredentialAgent) => { + const row = results.find((r) => r.agent === agent) ?? null + const state = credentialState(row, now) + return { state, ...(row?.label ? { label: row.label } : {}), ...(row?.expires_at ? { expiresAt: row.expires_at } : {}) } + } + return { claude: view('claude'), codex: view('codex'), providerEnabled: providerEnabled(env) } +} + +// A Claude setup-token: `sk-ant-oat01-` then URL-safe characters. Shape-checked, never logged. +const CLAUDE_TOKEN = /^sk-ant-oat01-[A-Za-z0-9_-]{20,500}$/ +export const PutClaudeSchema = z + .object({ + token: z.string().max(600), + label: z.string().max(200).optional(), + expiresAt: z.number().int().positive().optional() + }) + .strict() + +export async function putClaudeCredential(db: D1Database, keyring: Keyring, env: Env, caller: SandboxCaller, body: z.infer, now: number) { + if (env.AGENT_ACCOUNTS_CLAUDE !== 'on') { + throw new AgentCredentialError(404, 'not_found') + } + const token = body.token.trim() + if (!CLAUDE_TOKEN.test(token)) { + throw new AgentCredentialError(400, 'invalid_request', 'invalid_token') + } + // Personal only: a person's account is never stored for a team (D13 keeps it out of shared workspaces too). + await putAgentCredential( + db, + keyring, + { + userId: caller.userId, + accountId: caller.accountId, + agent: 'claude', + value: token, + label: body.label?.trim() || caller.name || null, + expiresAt: Math.min(body.expiresAt ?? now + CLAUDE_SETUP_TOKEN_TTL_MS, now + CLAUDE_SETUP_TOKEN_TTL_MS) + }, + now + ) + // Names only, never the value. + console.log(JSON.stringify({ event: 'agent_credential_written', agent: 'claude', by: caller.userId })) +} diff --git a/cloud/apps/sandbox-control/src/agent-sources.ts b/cloud/apps/sandbox-control/src/agent-sources.ts index 312b66bf722..5b465363efd 100644 --- a/cloud/apps/sandbox-control/src/agent-sources.ts +++ b/cloud/apps/sandbox-control/src/agent-sources.ts @@ -12,6 +12,8 @@ import type { SandboxCaller } from './auth' import { memberNamesFor } from './member-names' import { effectiveSecrets } from './secrets' import { getSandbox, type SandboxRow } from './store' +import { agentCredentialState, isUsable } from './agent-credentials' +import { accountProvider, isSharedWorkspace } from './account-providers' export type AgentSource = 'sign-in' | 'key' type OfferAnswer = 'unanswered' | 'accepted' | 'declined' @@ -97,7 +99,13 @@ async function ownedWorkspace(db: D1Database, caller: SandboxCaller, workspaceId return row } -export async function agentSourcesView(db: D1Database, caller: SandboxCaller, workspaceId: string | null) { +export async function agentSourcesView( + db: D1Database, + caller: SandboxCaller, + workspaceId: string | null, + enabled: Record = { claude: false, codex: false }, + now: number = Date.now() +) { const row = workspaceId ? await ownedWorkspace(db, caller, workspaceId) : null // The sandbox applies its owner's choice (shared sandboxes, v1): show the owner's, not the caller's. const subject = row ? row.user_id : caller.userId @@ -106,6 +114,11 @@ export async function agentSourcesView(db: D1Database, caller: SandboxCaller, wo const workspace = row ? byAgent(rows, row.id) : null const effective = await effectiveSecrets(db, caller, row?.repo_url ?? null) const covered = coveredByKey(new Set(effective.map((secret) => secret.name))) + // v4: with no key covering it, a set-up Orca account is the default (no question asked). + const usable = {} as Record + for (const agent of CREDENTIAL_AGENTS) { + usable[agent] = enabled[agent] && isUsable(await agentCredentialState(db, subject, agent, now)) + } const offerRows = rows.filter((r) => r.scope === OFFER_SCOPE) const signInOffer = Object.fromEntries( CREDENTIAL_AGENTS.map((agent) => [ @@ -122,7 +135,7 @@ export async function agentSourcesView(db: D1Database, caller: SandboxCaller, wo if (defaults[agent]) { return [agent, { source: defaults[agent], origin: 'default' }] } - return [agent, { source: covered[agent] ? 'key' : null, origin: 'implicit' }] + return [agent, { source: covered[agent] ? 'key' : usable[agent] ? 'sign-in' : null, origin: 'implicit' }] }) ) let owner: { userId: string; displayName: string | null; isCaller: boolean } | null = null @@ -130,7 +143,9 @@ export async function agentSourcesView(db: D1Database, caller: SandboxCaller, wo const names = await memberNamesFor(db, caller.accountId, [row.user_id]) owner = { userId: row.user_id, displayName: names.get(row.user_id) ?? null, isCaller: row.user_id === caller.userId } } - return { defaults, workspace, effective: resolved, coveredByKey: covered, signInOffer, owner } + // D13: personal accounts never apply in a workspace shared with teammates. + const sharedWorkspace = row ? isSharedWorkspace(row) : false + return { defaults, workspace, effective: resolved, coveredByKey: covered, signInOffer, owner, sharedWorkspace } } async function upsert(db: D1Database, caller: SandboxCaller, scope: string, agent: CredentialAgent, source: string | null, now: number) { @@ -181,3 +196,50 @@ export async function runningSandboxesForDefault(db: D1Database, userId: string, export async function deleteAgentSourcesForSandbox(db: D1Database, sandboxId: string): Promise { await db.prepare('DELETE FROM agent_sources WHERE scope = ?').bind(sandboxId).run() } + +/** + * How each agent authenticates in this session (D10): + * - 'account': the owner's Orca-account credential through an egress placeholder (keys for that agent dropped); + * - 'omit': kill-switch fallback (Source sign-in, provider off): keys dropped, the agent's own login in the sandbox; + * - 'keep': API keys as resolved (precedence), the default. + * Source: explicit (workspace, then default), else implicit: a covering key wins, else a usable account. + */ +export type AgentMode = 'account' | 'omit' | 'keep' +export async function agentModesFor( + db: D1Database, + row: Pick, + env: Readonly>, + options: { + providerEnabled: Record + accountsUsable: boolean + now: number + readable?: (agent: CredentialAgent) => Promise + } +): Promise> { + const rows = await rowsFor(db, row.user_id, [row.id, DEFAULT_SCOPE]) + const workspace = byAgent(rows, row.id) + const defaults = byAgent(rows, DEFAULT_SCOPE) + const covered = coveredByKey(new Set(Object.keys(env))) + const modes = {} as Record + for (const agent of CREDENTIAL_AGENTS) { + const explicit = workspace[agent] ?? defaults[agent] + const provider = accountProvider(agent) + const enabled = options.providerEnabled[agent] && provider !== undefined + const usable = enabled + ? isUsable(await agentCredentialState(db, row.user_id, agent, options.now)) && (options.readable ? await options.readable(agent) : true) + : false + const source = explicit ?? (covered[agent] ? 'key' : usable ? 'sign-in' : null) + if (source !== 'sign-in') { + modes[agent] = 'keep' + } else if (!enabled) { + // Kill switch (or no provider yet): the agent signs in inside the sandbox; keys stay out (Rule 3). + modes[agent] = 'omit' + } else if (usable && options.accountsUsable && !isSharedWorkspace(row)) { + modes[agent] = 'account' + } else { + // Expired/revoked, placeholders off, or a shared workspace (D13): the winning key, if any. + modes[agent] = 'keep' + } + } + return modes +} diff --git a/cloud/apps/sandbox-control/src/api/openapi.ts b/cloud/apps/sandbox-control/src/api/openapi.ts index b8dc9a45257..f18428d2ea7 100644 --- a/cloud/apps/sandbox-control/src/api/openapi.ts +++ b/cloud/apps/sandbox-control/src/api/openapi.ts @@ -69,6 +69,8 @@ const schemas: Record = { machineSize: { type: 'string' }, region: { type: 'string' }, repoUrl: { type: ['string', 'null'] }, + repoBranch: { type: ['string', 'null'], description: 'Branch the workspace was started on, when known' }, + lastActivityAt: { type: 'integer', description: 'Last agent work or input (falls back to the last state change)' }, session: { type: ['object', 'null'], properties: { startedAt: { type: 'integer' }, leaseExpiresAt: { type: 'integer' } } }, createdAt: { type: 'integer' } } diff --git a/cloud/apps/sandbox-control/src/api/workspaces.ts b/cloud/apps/sandbox-control/src/api/workspaces.ts index 324e4fb0719..944fbd2755c 100644 --- a/cloud/apps/sandbox-control/src/api/workspaces.ts +++ b/cloud/apps/sandbox-control/src/api/workspaces.ts @@ -51,6 +51,8 @@ export function workspaceView(row: WorkspaceRow) { machineSize: row.machine_size, region: row.region, repoUrl: row.repo_url, + repoBranch: row.repo_branch ?? null, + lastActivityAt: row.last_activity_at ?? row.updated_at, session, lastError: row.last_error, createdAt: row.created_at, diff --git a/cloud/apps/sandbox-control/src/build-runner.ts b/cloud/apps/sandbox-control/src/build-runner.ts new file mode 100644 index 00000000000..cd4ee88ba6a --- /dev/null +++ b/cloud/apps/sandbox-control/src/build-runner.ts @@ -0,0 +1,72 @@ +// Entry point of orca-sandbox-build-runner-* (wrangler.build-runner.dev.jsonc). The runner lives in its +// own Worker because Cloudflare generates no preview URLs for a Worker that implements a Durable Object: +// hosting it in the control plane made every `wrangler versions upload --preview-alias` URL 404. +// The runner only keeps the alarm; each step runs in the control plane (BuildTicker in index.ts) over +// an RPC service binding, so the build code, D1 and every secret stay in one Worker. + +/** The control plane's BuildTicker entrypoint, as the runner sees it. */ +export type BuildTickerApi = { + /** Advances the build one step; the delay (ms) until the next step, or null once it is final. */ + tickBuild(build: RunnerBuild): Promise +} + +export type RunnerBuild = { id: string; environmentId: string } + +export type BuildRunnerEnv = { + CONTROL: BuildTickerApi +} + +/** Retry delay after a step fails (control plane unreachable, provider error). */ +export const RUNNER_RETRY_MS = 15_000 + +/** + * One per environment build: wakes on an alarm, has the control plane advance the build one step + * (finalize, snapshot, smoke boot, billing all run there) and re-arms until the build is final. Builds + * therefore finish with no client watching, at most a few seconds after their VM command ends. The + * control plane's cron re-arms every unfinished build each minute, so a lost runner costs a minute. + */ +export class EnvironmentBuildRunner implements DurableObject { + constructor( + private readonly state: DurableObjectState, + private readonly env: BuildRunnerEnv + ) {} + + async fetch(request: Request): Promise { + const body = (await request.json().catch(() => null)) as Partial | null + if (!body?.id || !body.environmentId) { + return new Response('bad request', { status: 400 }) + } + await this.state.storage.put('build', { id: body.id, environmentId: body.environmentId }) + if ((await this.state.storage.getAlarm()) === null) { + await this.state.storage.setAlarm(Date.now() + 2_000) + } + return new Response(null, { status: 204 }) + } + + async alarm(): Promise { + const build = await this.state.storage.get('build') + if (!build) { + await this.state.storage.deleteAll() + return + } + let next: number | null + try { + next = await this.env.CONTROL.tickBuild({ id: build.id, environmentId: build.environmentId }) + } catch (error) { + console.log(JSON.stringify({ event: 'environment_build_tick_failed', buildId: build.id, error: String(error).slice(0, 200) })) + next = RUNNER_RETRY_MS + } + if (next === null) { + await this.state.storage.deleteAll() + return + } + await this.state.storage.setAlarm(Date.now() + next) + } +} + +export default { + // No public surface: the control plane reaches the runner through its Durable Object binding only. + fetch(): Response { + return new Response('not found', { status: 404 }) + } +} satisfies ExportedHandler diff --git a/cloud/apps/sandbox-control/src/egress-proxy.ts b/cloud/apps/sandbox-control/src/egress-proxy.ts index b4fa86ce19f..261d07d7f11 100644 --- a/cloud/apps/sandbox-control/src/egress-proxy.ts +++ b/cloud/apps/sandbox-control/src/egress-proxy.ts @@ -35,7 +35,12 @@ export const NO_CREDENTIALS: CredentialBroker = { authorizationFor: async () => export type ProxiedRoute = { header: string placeholders: readonly string[] - credential: () => Promise + /** The header value, or null (refused), or `{ deny }` to refuse with a specific reason. */ + credential: () => Promise + /** Extra headers set with the credential (e.g. an account id the client can't know). */ + extraHeaders?: () => Record + /** Sees the provider's status (e.g. a 401 revokes an account credential). Never sees the body. */ + onUpstreamStatus?: (status: number) => void /** Narrows where this credential may be used (member tokens: the workspace repository only). */ pathAllowed?: (method: string, path: string) => boolean /** The reply must be a JSON object; only these top-level fields pass (no refresh token for the VM). */ @@ -134,6 +139,9 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps) if (!credential) { return deny('credential_unavailable') } + if (typeof credential !== 'string') { + return deny(credential.deny) + } const headers = new Headers(request.headers) for (const name of DROP_HEADERS) { headers.delete(name) @@ -145,6 +153,9 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps) } } headers.set(route.header, credential) + for (const [name, value] of Object.entries(route.extraHeaders?.() ?? {})) { + headers.set(name, value) + } const hasBody = request.method !== 'GET' && request.method !== 'HEAD' const upstream = await (deps.fetch ?? fetch)(`https://${host}${path}`, { method: request.method, @@ -156,6 +167,7 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps) console.log( JSON.stringify({ event: 'egress_swap', sandboxId: row.id, host, credential: route.label ?? route.header, status: upstream.status }) ) + route.onUpstreamStatus?.(upstream.status) if (route.keepResponseFields) { return onlyFields(upstream, route.keepResponseFields) } diff --git a/cloud/apps/sandbox-control/src/env.ts b/cloud/apps/sandbox-control/src/env.ts index c304f060439..7fd55915db2 100644 --- a/cloud/apps/sandbox-control/src/env.ts +++ b/cloud/apps/sandbox-control/src/env.ts @@ -54,7 +54,7 @@ export type Env = { BILLING_URL?: string /** Service binding to the billing Worker; Workers on one account cannot reach each other over workers.dev. */ BILLING?: Fetcher - /** Durable Object that advances environment builds on alarms (EnvironmentBuildRunner in index.ts). */ + /** Durable Object that advances environment builds on alarms (EnvironmentBuildRunner, hosted by the build runner Worker: src/build-runner.ts). */ ENV_BUILD_RUNNER?: DurableObjectNamespace CONTROL_PLANE_TOKEN?: string /** `stub` allows every start and records nothing: local dev only, refused when SANDBOX_ENV=prod. */ @@ -90,6 +90,14 @@ export type Env = { SECRETS_EGRESS_PLACEHOLDERS?: string /** Per-team kill switch (comma account ids): those accounts get plain env injection. */ SECRETS_EGRESS_PLACEHOLDERS_OFF?: string + /** Flag S1 (`on`): Claude via the person's Orca account (setup-token store + egress swap, contract D10/D11). */ + AGENT_ACCOUNTS_CLAUDE?: string + /** `1`: kill switch for S1 (agents fall back to keys, or sign in inside the sandbox). */ + AGENT_ACCOUNTS_CLAUDE_DISABLED?: string + /** Flag S2 (`on`): Codex via the account broker. */ + AGENT_ACCOUNTS_CODEX?: string + /** `1`: kill switch for S2. */ + AGENT_ACCOUNTS_CODEX_DISABLED?: string /** App slug for the install URL (github.com/apps/). */ GITHUB_APP_SLUG?: string diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 569d71d39b5..ffc9a2ffbe9 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -36,6 +36,8 @@ import { ReadSnapshotRouteError, getReadSnapshot, handleReaderKeysRoute, pruneRe import { refreshStaleEnvironments } from './environment-golden-refresh' import { ORCA_CLOUD_IDLE_SLEEP_MINUTES } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire' import { z } from 'zod' +import { WorkerEntrypoint } from 'cloudflare:workers' +import type { RunnerBuild } from './build-runner' import { maintainPool, poolConfigFromEnv } from './pool' import { cachedCreateTiming } from './create-timing' import { creatorLeftAccount } from './member-changes' @@ -90,9 +92,20 @@ import { GuardrailError, consumeCreateToken, guardrailsFromEnv } from './guardra import { handleOperator, isOperator } from './operator' import { DEFAULT_RETENTION, type RetentionConfig } from './retention' import { reconcile } from './reconcile' -import { egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' +import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets' import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh' +import { accountProvider, isSharedWorkspace } from './account-providers' +import { + AgentCredentialError, + PutClaudeSchema, + agentCredentialsView, + deleteAgentCredential, + providerEnabled, + putClaudeCredential, + markAgentCredentialRevoked, + readUsableAgentCredential +} from './agent-credentials' import { AgentSourceError, PutOfferSchema, @@ -121,6 +134,7 @@ import type { GoldenImageProviderApi } from './golden-image-provider' import { CreateRequestSchema, LeaseRequestSchema, parseWakeWait, readyView, sandboxView } from './wire' import { ArchiveRequestSchema } from './sandbox-vm-inspect' import { + restoreArchivedToSleeping, archiveSandbox, readArchive, restartSandbox, @@ -159,12 +173,14 @@ import { isOrgAdmin, listWorkspaces, patchWorkspace, + assertCanManageWorkspace, reportPresence, requireVisible, resolveLink, workspaceViews } from './workspaces' import { rememberCallerName } from './member-names' +import { sweepSessionCpu } from './session-cpu' import type { SandboxCaller } from './auth' const MAX_JSON_BYTES = 16 * 1024 @@ -286,6 +302,69 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => })) ) } + // D10/D13: re-decided per request, so a Source change, renewal, revocation, kill switch or the workspace + // becoming shared applies to the next request with no restart. + const accountRoutes = (row: SandboxRow, host: string): ProxiedRoute[] => + egressAccountRoutes(row) + .filter((route) => route.host === host) + .flatMap(({ agent, owner }) => { + const provider = accountProvider(agent) + const keyring = keyringFor(env) + if (!provider || !keyring) { + return [] + } + let real: string | null = null + return [ + { + header: provider.header, + placeholders: [provider.format(provider.placeholder(row))], + label: `account:${agent}`, + credential: async () => { + const now = (deps.now ?? Date.now)() + // The session was set up for this owner; a workspace handed to someone else never uses + // the new owner's account until its env is rebuilt for them. + if (owner !== row.user_id) { + scheduleEnvRefresh(env, deps, row) + return { deny: 'account_owner_changed' } + } + if (isSharedWorkspace(row)) { + scheduleEnvRefresh(env, deps, row) + return { deny: 'account_not_shared' } + } + if (!providerEnabled(env)[agent]) { + scheduleEnvRefresh(env, deps, row) + return { deny: 'account_disabled' } + } + real = await cachedAccountCredential(env.SANDBOX_DB, await keyring, row.user_id, agent, now) + if (!real) { + scheduleEnvRefresh(env, deps, row) + return { deny: 'no_agent_credential' } + } + return provider.format(real) + }, + ...(provider.extraHeaders ? { extraHeaders: () => (real ? provider.extraHeaders!(real) : {}) } : {}), + onUpstreamStatus: (status) => { + if (status === 401) { + // The provider no longer accepts it (revoked at the source): stop using it until renewed. + const now = (deps.now ?? Date.now)() + const waitUntil = (work: Promise) => (deps.waitUntil ? deps.waitUntil(work.catch(() => undefined)) : void work.catch(() => undefined)) + const handled = provider.onUnauthorized + ? keyring.then((ring) => provider.onUnauthorized!({ db: env.SANDBOX_DB, keyring: ring, userId: row.user_id, now, waitUntil })) + : markAgentCredentialRevoked(env.SANDBOX_DB, row.user_id, agent, now) + const work = handled.then(() => { + forgetAccountCredential(row.user_id, agent) + scheduleEnvRefresh(env, deps, row) + }) + if (deps.waitUntil) { + deps.waitUntil(work.catch(() => undefined)) + } else { + void work.catch(() => undefined) + } + } + } + } + ] + }) return async (row, host) => { if (host === 'github.com') { return members.routes(row, host) @@ -299,7 +378,110 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => ...secretRoutes(row, host) ] } - return secretRoutes(row, host) + return [...accountRoutes(row, host), ...secretRoutes(row, host)] + } +} + +// Account credentials per person, decrypted at most once a minute per isolate; an edit or delete drops it. +const ACCOUNT_CACHE_MS = 10_000 +const accountCache = new Map }>() +function cachedAccountCredential(db: D1Database, keyring: Keyring, userId: string, agent: 'claude' | 'codex', now: number): Promise { + const key = `${userId}:${agent}` + const hit = accountCache.get(key) + if (hit && now - hit.at < ACCOUNT_CACHE_MS) { + return hit.value + } + const value = readUsableAgentCredential(db, keyring, userId, agent, now) + accountCache.set(key, { at: now, value }) + value.catch(() => accountCache.delete(key)) + return value +} +export function forgetAccountCredential(userId: string, agent: 'claude' | 'codex'): void { + accountCache.delete(`${userId}:${agent}`) +} + +/** Background env refresh for one sandbox (proxy-side self-healing when its account route stops applying). */ +const lastHealRefresh = new Map() +function scheduleEnvRefresh(env: Env, deps: Deps, row: SandboxRow): void { + const now = (deps.now ?? Date.now)() + if (now - (lastHealRefresh.get(row.id) ?? 0) < 60_000) { + return + } + lastHealRefresh.set(row.id, now) + if (lastHealRefresh.size > 1000) { + for (const [id, at] of lastHealRefresh) { + if (now - at >= 60_000) { + lastHealRefresh.delete(id) + } + } + } + const work = lifecycleDeps(env, deps).then((life) => (life ? refreshSandboxEnv(life, row) : false)) + if (deps.waitUntil) { + deps.waitUntil(work.catch(() => undefined)) + } else { + void work.catch(() => undefined) + } +} + +/** Running sandboxes of this person whose session env depends on their account credential. */ +async function refreshOwnersSandboxes(env: Env, deps: Deps, userId: string): Promise { + const { results } = await env.SANDBOX_DB.prepare(`SELECT * FROM sandboxes WHERE user_id = ? AND status = 'running' LIMIT 200`).bind(userId).all() + if (results.length === 0) { + return + } + const work = lifecycleDeps(env, deps).then(async (life) => { + if (life) { + for (const row of results) { + await refreshSandboxEnv(life, row).catch(() => false) + } + } + }) + if (deps.waitUntil) { + deps.waitUntil(work.catch(() => undefined)) + } else { + await work + } +} + +async function agentCredentialsRoute(request: Request, url: URL, env: Env, deps: Deps, caller: SandboxCaller): Promise { + const db = env.SANDBOX_DB + const now = (deps.now ?? Date.now)() + const noStore = (body: unknown) => Response.json(body, { headers: { 'cache-control': 'no-store' } }) + const keyring = keyringFor(env) + if (!keyring) { + return fail('not_configured', 503) + } + try { + if (request.method === 'GET' && url.pathname === '/v1/agent-credentials') { + return noStore(await agentCredentialsView(db, env, caller.userId, now)) + } + if (request.method === 'PUT' && url.pathname === '/v1/agent-credentials/claude') { + const body = PutClaudeSchema.safeParse(await readJson(request)) + if (!body.success) { + return fail('invalid_request', 400) + } + await putClaudeCredential(db, await keyring, env, caller, body.data, now) + forgetAccountCredential(caller.userId, 'claude') + await refreshOwnersSandboxes(env, deps, caller.userId) + return noStore(await agentCredentialsView(db, env, caller.userId, now)) + } + const del = /^\/v1\/agent-credentials\/(claude|codex)$/.exec(url.pathname) + if (request.method === 'DELETE' && del) { + const agent = del[1] as 'claude' | 'codex' + if (!(await deleteAgentCredential(db, caller.userId, agent))) { + return fail('not_found', 404) + } + forgetAccountCredential(caller.userId, agent) + console.log(JSON.stringify({ event: 'agent_credential_deleted', agent, by: caller.userId })) + await refreshOwnersSandboxes(env, deps, caller.userId) + return noStore(await agentCredentialsView(db, env, caller.userId, now)) + } + return fail('not_found', 404) + } catch (error) { + if (error instanceof AgentCredentialError) { + return fail(error.code, error.status, error.reason) + } + throw error } } @@ -590,7 +772,10 @@ async function goldenRegions( return { goldenSnapshotId: goldens[defaultRegion]!, goldenSnapshots: goldens, defaultRegion, fallbackRegions: usable.slice(1) } } -/** Arms the build's runner (one Durable Object per build); a no-op where the binding is absent. */ +/** + * Arms the build's runner: one Durable Object per build, hosted by the build runner Worker + * (src/build-runner.ts). A no-op where the binding is absent. + */ function buildScheduler(env: Env): BuildDeps['schedule'] { const runners = env.ENV_BUILD_RUNNER if (!runners) { @@ -757,7 +942,7 @@ async function secretsDeps(env: Env): Promise { return undefined } try { - return { keyring: await keyring, ...secretsEgressMode(env) } + return { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) } } catch { // A malformed KEK must not block sandbox starts; the secrets routes surface it as 503. console.log(JSON.stringify({ event: 'secrets_kek_invalid' })) @@ -1080,6 +1265,17 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) } return response } + if (url.pathname === '/v1/agent-credentials' || url.pathname.startsWith('/v1/agent-credentials/')) { + const caller = await authenticateDesktop(env, request, (deps.now ?? Date.now)()) + if (!caller) { + return fail('unauthorized', 401) + } + // Desktop main only: never a phone's device session (and the renderer never holds the token). + if (caller.deviceSessionId) { + return fail('forbidden', 403, 'device_session') + } + return await agentCredentialsRoute(request, url, env, deps, caller) + } if (url.pathname === '/v1/agent-sources' || url.pathname === '/v1/agent-sources/offer') { const caller = await authenticateDesktop(env, request, (deps.now ?? Date.now)()) if (!caller) { @@ -1425,13 +1621,53 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps, if (!body.success) { return fail('invalid_request', 400) } - const patched = await patchWorkspace(db, caller, row, body.data, now) + const { archived, ...rest } = body.data + // Archive/unarchive are lifecycle changes, not labels. The lifecycle runs FIRST and is what + // sets archived_at, so a failed or refused archive never labels a running sandbox archived. + let archivedNow = false + if (archived !== undefined) { + assertCanManageWorkspace(caller, row) + if (archived && row.archived_at === null) { + if (row.status !== 'running' && row.status !== 'suspended') { + // Starting, waking or going to sleep: nothing changes; the client asks to try again. + return fail('conflict', 409, `sandbox_${row.status}`) + } + await archiveSandbox(life, row, {}, workspaceConfig(env, deps)) + archivedNow = true + } else if (!archived && row.status === 'archived') { + await restoreArchivedToSleeping(life, row) + } else if (!archived && row.archived_at !== null) { + // A label-only archive from before archive stopped the sandbox: clear the label. + await patchWorkspace(db, caller, row, { archived: false }, now) + } + } + const current = (await getSandbox(env.SANDBOX_DB, row.id)) ?? row + let patched: Awaited> = + Object.keys(rest).length > 0 + ? await patchWorkspace(db, caller, current, rest, now) + : { row: current, revoke: 'none' } + if (archivedNow && patched.revoke === 'none') { + // Archiving disconnects teammates' devices, as before. + patched = { ...patched, revoke: 'non_owner' } + } if (patched.revoke !== 'none') { // Readers lose the sleeping copy at once, before (and regardless of) the VM-side revokes. await pruneReadSnapshotToOwner(db, patched.row) } const revoked = patched.revoke === 'none' ? null : await revokeForAccessChange(life, patched.row, patched.revoke, caller.userId) + if ( + patched.row.status === 'running' && + (patched.row.user_id !== current.user_id || patched.row.visibility !== current.visibility) + ) { + // A new owner or a share changes which account (if any) the agents use (D10/D13): rebuild the env now. + const work = refreshSandboxEnv(life, patched.row).catch(() => false) + if (deps.waitUntil) { + deps.waitUntil(work) + } else { + await work + } + } return json({ workspace: await view(patched.row), ...(revoked ? { revoked } : {}) }) } case 'POST wake': { @@ -1601,6 +1837,11 @@ export default { // Counts only; no ids, tokens or pairing material. const summary = await reconcile(life) console.log(JSON.stringify({ event: 'reconcile', ...summary })) + // Internal fair-use review (U22): retry sessions whose CPU the stop couldn't read yet. + const cpu = await sweepSessionCpu(life.db, life.provider, life.now()) + if (cpu.checked > 0) { + console.log(JSON.stringify({ event: 'session_cpu_sweep', ...cpu })) + } const retried = await retryPendingMessages(life).catch(() => -1) if (retried !== 0) { console.log(JSON.stringify({ event: 'message_retry', retried })) @@ -1639,47 +1880,28 @@ export async function refreshEnvironmentsOnGolden(env: Env, life: LifecycleDeps, } /** - * One per environment build: wakes on an alarm, advances the build one step (finalize, snapshot, - * smoke boot, billing all run here) and re-arms until the build is final. Builds therefore finish - * with no client watching, at most a few seconds after their VM command ends. + * RPC entrypoint for the build runner Worker (src/build-runner.ts), bound there as its CONTROL service. + * Each runner alarm lands here: the step runs in this Worker, with its D1, provider and secrets, and + * answers the delay until the next step (null: final, the runner stops). A failure throws back to the + * runner, which retries. Not reachable from the internet: only a service binding can name an entrypoint. */ -export class EnvironmentBuildRunner implements DurableObject { - constructor( - private readonly state: DurableObjectState, - private readonly env: Env - ) {} - - async fetch(request: Request): Promise { - const body = (await request.json().catch(() => null)) as { id?: string; environmentId?: string } | null - if (!body?.id || !body.environmentId) { - return new Response('bad request', { status: 400 }) - } - await this.state.storage.put('build', { id: body.id, environmentId: body.environmentId }) - if ((await this.state.storage.getAlarm()) === null) { - await this.state.storage.setAlarm(Date.now() + 2_000) - } - return new Response(null, { status: 204 }) +export class BuildTicker extends WorkerEntrypoint { + tickBuild(build: RunnerBuild): Promise { + return runBuildTick(this.env, build) } +} - async alarm(): Promise { - const build = await this.state.storage.get<{ id: string; environmentId: string }>('build') - const life = build ? await lifecycleDeps(this.env, {}) : null - if (!build || !life) { - await this.state.storage.deleteAll() - return - } - let next: number | null - try { - next = await tickBuild(buildDeps(life, {}, this.env), build.environmentId, build.id) - } catch (error) { - console.log(JSON.stringify({ event: 'environment_build_tick_failed', buildId: build.id, error: String(error).slice(0, 200) })) - next = 15_000 - } - if (next === null) { - await this.state.storage.deleteAll() - return - } - await this.state.storage.setAlarm(Date.now() + next) +/** One runner step; null when the control plane is unconfigured (the cron re-arms the runner later). */ +export async function runBuildTick(env: Env, build: RunnerBuild, deps: Deps = {}): Promise { + const life = await lifecycleDeps(env, deps) + if (!life) { + return null + } + try { + return await tickBuild(buildDeps(life, deps, env), build.environmentId, build.id) + } catch (error) { + console.log(JSON.stringify({ event: 'environment_build_tick_failed', buildId: build.id, error: String(error).slice(0, 200) })) + throw error } } @@ -1691,7 +1913,7 @@ async function agentSourcesRoute(request: Request, url: URL, env: Env, deps: Dep const noStore = (body: unknown) => Response.json(body, { headers: { 'cache-control': 'no-store' } }) try { if (request.method === 'GET' && url.pathname === '/v1/agent-sources') { - return noStore(await agentSourcesView(db, caller, url.searchParams.get('workspaceId'))) + return noStore(await agentSourcesView(db, caller, url.searchParams.get('workspaceId'), providerEnabled(env), now)) } if (request.method === 'PUT' && url.pathname === '/v1/agent-sources/offer') { const body = PutOfferSchema.safeParse(await readJson(request)) @@ -1699,7 +1921,7 @@ async function agentSourcesRoute(request: Request, url: URL, env: Env, deps: Dep return fail('invalid_request', 400) } await putOfferAnswer(db, caller, body.data, now) - return noStore(await agentSourcesView(db, caller, null)) + return noStore(await agentSourcesView(db, caller, null, providerEnabled(env), now)) } if (request.method === 'PUT' && url.pathname === '/v1/agent-sources') { const body = PutSourceSchema.safeParse(await readJson(request)) @@ -1723,7 +1945,7 @@ async function agentSourcesRoute(request: Request, url: URL, env: Env, deps: Dep await work } } - return noStore(await agentSourcesView(db, caller, body.data.workspaceId ?? null)) + return noStore(await agentSourcesView(db, caller, body.data.workspaceId ?? null, providerEnabled(env), now)) } return fail('not_found', 404) } catch (error) { diff --git a/cloud/apps/sandbox-control/src/lifecycle.ts b/cloud/apps/sandbox-control/src/lifecycle.ts index 53307250b1e..4e260c61d4c 100644 --- a/cloud/apps/sandbox-control/src/lifecycle.ts +++ b/cloud/apps/sandbox-control/src/lifecycle.ts @@ -1,5 +1,6 @@ // Sandbox lifecycle: one persistent provider sandbox per Orca workspace, with an Orca-owned lease, // a forced stop before the provider's 24h cap, usage metering and write-ahead rows. +import { accountProvider } from './account-providers' import { deleteAgentSourcesForSandbox } from './agent-sources' import type { EntitlementClient, MachineSize } from './entitlements' import { parseGitHubRepo, type GitHubAppMinter } from './github-app' @@ -18,7 +19,7 @@ import { memberPlaceholder, type MemberGitIdentity } from './github-user-tokens' import { connectedMembers, memberGitConfigs } from './member-git' import type { RetentionConfig } from './retention' import type { ProviderSandboxState, SandboxProviderApi } from './sandbox-provider' -import { egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' +import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env' import type { Keyring } from './secrets-crypto' import { captureReadSnapshot, deleteReadSnapshot } from './read-snapshots' @@ -43,6 +44,7 @@ import { type SandboxRow, type SessionFields } from './store' +import { recordSessionEnd } from './session-cpu' import { flushUsage, queueUsage } from './usage' import { SANDBOX_VCPUS } from './machine-size' import { isRegionalFailure } from './regions' @@ -70,6 +72,8 @@ export type LifecycleDeps = { keyring: Keyring egressPlaceholders: boolean | ReadonlySet egressPlaceholdersOff?: ReadonlySet + /** Flags S1/S2 minus the kill switch: which agents may use an Orca-account credential (D11). */ + accountProviders?: Record<'claude' | 'codex', boolean> } now: () => number /** A member's GitHub identity (user token) for git-as-sender; absent = app identity only. */ @@ -233,6 +237,13 @@ export function egressPolicy(deps: LifecycleDeps, row: SandboxRow, gitToken?: st }, // Model API keys swapped at egress (phase 2); each host also ends in the 403 deny rule, except a // host whose client sends its own short-lived token on later calls (Cursor). + // An agent's Orca-account placeholder (D10): forwarded, claimed only by that exact Bearer value. + ...egressAccountRoutes(row).flatMap(({ agent, host }): CredentialRoute[] => { + const provider = accountProvider(agent) + return provider + ? [{ host, header: provider.header, placeholders: [provider.format(provider.placeholder(row))], via: 'forward', forwardURL: proxyUrl }] + : [] + }), ...egressSecretHosts(row).flatMap((entry) => Object.keys(entry.headers).map( (header): CredentialRoute => ({ @@ -674,15 +685,21 @@ export async function stopForSuspend( if (stopped.snapshotId) { await recordSnapshots(deps.db, row.id, [stopped.snapshotId], deps.now()) } + await queueUsage(deps.db, row, deps.now()) + // Internal CPU review (U22): never throws, never touches the meter. + await recordSessionEnd(deps.db, row, deps.now(), stopped.usage, deps.now()) + } else { + await queueUsage(deps.db, row, deps.now()) } - await queueUsage(deps.db, row, deps.now()) await deps.hooks?.afterStop?.(row).catch(() => undefined) return claim(deps, row, { status: 'suspended', ...NO_SESSION }) } /** The provider ended the session on its own (lease lapsed): bill up to when it stopped. */ export async function markProviderStopped(deps: LifecycleDeps, row: SandboxRow): Promise { - await queueUsage(deps.db, row, Math.min(deps.now(), row.lease_expires_at ?? deps.now())) + const endedAt = Math.min(deps.now(), row.lease_expires_at ?? deps.now()) + await queueUsage(deps.db, row, endedAt) + await recordSessionEnd(deps.db, row, endedAt, null, deps.now()) await deps.hooks?.afterStop?.(row).catch(() => undefined) return claim(deps, row, { status: 'suspended', ...NO_SESSION }) } @@ -809,6 +826,8 @@ export async function destroySandbox(deps: LifecycleDeps, row: SandboxRow): Prom } let destroying = row.status === 'destroying' ? row : await claim(deps, row, { status: 'destroying' }) const settled = await queueUsage(deps.db, destroying, deps.now()) + // A live session ends with the delete (no-op once suspended: the row has no session then). + await recordSessionEnd(deps.db, destroying, deps.now(), null, deps.now()) if (settled !== destroying.usage_settled_at) { destroying = await claim(deps, destroying, { usage_settled_at: settled }) } diff --git a/cloud/apps/sandbox-control/src/operator.ts b/cloud/apps/sandbox-control/src/operator.ts index 1dfe2de47a7..a709679b12a 100644 --- a/cloud/apps/sandbox-control/src/operator.ts +++ b/cloud/apps/sandbox-control/src/operator.ts @@ -7,6 +7,8 @@ // DELETE /v1/internal/github/bindings {installationId, accountId?} // POST /v1/internal/members/role-changed {accountId, userId, role} re-cap their keys and MCP grants (auth calls this) // POST /v1/internal/members/removed {accountId, userId} revoke a departed member's keys, phones and workspace grants, disable their routines (auth calls this) +// GET /v1/internal/fair-use?days=30 per-account CPU utilisation + fair-use flag (U22; internal only, never billed) +// POST /v1/internal/session-cpu/sweep fetch CPU for recently stopped sessions now (the cron does this every 5 min) import { z } from 'zod' import { audit } from './api/audit' import { syncMemberRole } from './api/member-role-sync' @@ -18,6 +20,7 @@ import { guardrails, type LifecycleDeps } from './lifecycle' import { drainAll, housekeeping } from './retention' import { memberChangeTime, recordMemberChange } from './member-changes' import { removeMember } from './member-removal' +import { fairUseReport, FAIR_USE_WINDOW_DAYS, sweepSessionCpu } from './session-cpu' import { listByStatus } from './store' const KillSwitchBody = z.object({ enabled: z.boolean(), drain: z.boolean().optional() }).strict() @@ -141,6 +144,15 @@ export async function handleOperator( const removal = await removeMember(life, body.data.accountId, body.data.userId, now, body.data.changedAt) return Response.json({ revoked: removal.keysRevoked, ...removal }) } + case 'GET /v1/internal/fair-use': { + const days = Number(new URL(request.url).searchParams.get('days') ?? FAIR_USE_WINDOW_DAYS) + if (!Number.isInteger(days) || days < 1 || days > 90) { + return Response.json({ error: 'invalid_request' }, { status: 400 }) + } + return Response.json(await fairUseReport(life.db, now, days), { headers: { 'cache-control': 'no-store' } }) + } + case 'POST /v1/internal/session-cpu/sweep': + return Response.json({ sweep: await sweepSessionCpu(life.db, life.provider, now) }) default: return null } diff --git a/cloud/apps/sandbox-control/src/provider-env.ts b/cloud/apps/sandbox-control/src/provider-env.ts index 2c766282f92..cec8ecbf2aa 100644 --- a/cloud/apps/sandbox-control/src/provider-env.ts +++ b/cloud/apps/sandbox-control/src/provider-env.ts @@ -101,6 +101,11 @@ export type CredentialPolicy = { * headers that set state either); anything else is refused. */ keepResponseFields?: readonly string[] + /** + * The placeholder's shape when the client checks it (default `orca-ph--`). Copilot + * CLI ignores a COPILOT_GITHUB_TOKEN that doesn't look like a GitHub token. + */ + placeholderFormat?: (sandboxId: string) => string /** More ways the client may send the key in a header (gh-style `token `). */ altHeaderFormats?: Record string)[]> } @@ -169,6 +174,7 @@ const CREDENTIALS: readonly CredentialSpec[] = [ defaultHosts: ['api.github.com', ...COPILOT_API_HOSTS], headers: { authorization: bearer }, altHeaderFormats: { authorization: [(value: string) => `token ${value}`] }, + placeholderFormat: (sandboxId: string) => `github_pat_orcaph_${sandboxId}`, allowUnclaimedHosts: [...COPILOT_API_HOSTS], // On GitHub's API the user's token checks Copilot access and nothing else: the rest of the API // (repos, gists, ...) stays out of reach of whatever runs in the workspace. diff --git a/cloud/apps/sandbox-control/src/sandbox-provider.ts b/cloud/apps/sandbox-control/src/sandbox-provider.ts index 8d31727ab3a..9f9852141d6 100644 --- a/cloud/apps/sandbox-control/src/sandbox-provider.ts +++ b/cloud/apps/sandbox-control/src/sandbox-provider.ts @@ -27,6 +27,18 @@ export type ProviderSession = { timeoutMs: number } +/** A session's measured usage (internal fair-use review only; never metered or sent to billing). */ +export type ProviderSessionUsage = { + sessionId: string + status: ProviderSessionStatus + /** Vercel reports this only once the VM has stopped. */ + activeCpuMs: number | null + durationMs: number | null + vcpus: number | null + memoryMb: number | null + stoppedAt: number | null +} + export type ProviderRoute = { port: number; url: string } export type ProviderSandboxState = { @@ -87,7 +99,9 @@ export type SandboxProviderApi = { createSandbox(params: CreateSandboxParams): Promise /** `resume: true` boots a stopped persistent sandbox from its latest snapshot. Null when absent. */ getSandbox(name: string, options: { resume: boolean }): Promise - stopSession(sessionId: string): Promise<{ snapshotId: string | null }> + stopSession(sessionId: string): Promise<{ snapshotId: string | null; usage?: ProviderSessionUsage | null }> + /** A session's usage, stopped or not. Null when the provider no longer knows it. Optional: CPU review is best-effort. */ + getSessionUsage?(sessionId: string): Promise extendTimeout(sessionId: string, durationMs: number): Promise updateNetworkPolicy(sessionId: string, policy: NetworkPolicy): Promise /** Sets the full exposed-port list on the sandbox (live on a running session); returns its routes. */ diff --git a/cloud/apps/sandbox-control/src/secret-egress.ts b/cloud/apps/sandbox-control/src/secret-egress.ts index 19a9553b8b0..c5804f8a142 100644 --- a/cloud/apps/sandbox-control/src/secret-egress.ts +++ b/cloud/apps/sandbox-control/src/secret-egress.ts @@ -5,7 +5,7 @@ // Codex) hang under Vercel transform rules, so these hosts always take the forwardURL path through // /v1/egress/proxy, the same path api.github.com uses. Placeholders are not secrets: the proxy // authenticates the sandbox by Vercel's OIDC token, the placeholder only marks which header to swap. -import { SWAPPED_CREDENTIALS, credentialTarget, credentialTargets, type CredentialTarget, type ValueScopes } from './provider-env' +import { SWAPPED_CREDENTIALS, credentialShape, credentialTarget, credentialTargets, type CredentialTarget, type ValueScopes } from './provider-env' import type { SandboxRow } from './store' export type SecretEgressHost = CredentialTarget @@ -14,7 +14,8 @@ export type SecretEgressHost = CredentialTarget const LEGACY_HOSTS: Record = { 'api.anthropic.com': 'ANTHROPIC_API_KEY', 'api.openai.com': 'OPENAI_API_KEY' } export function secretPlaceholder(row: Pick, secret: string): string { - return `orca-ph-${secret.toLowerCase().replaceAll('_', '-')}-${row.id}` + const format = credentialShape(secret)?.placeholderFormat + return format ? format(row.id) : `orca-ph-${secret.toLowerCase().replaceAll('_', '-')}-${row.id}` } /** `SECRET@host` entries recorded on the row at boot (or refresh) as placeholder-routed. */ @@ -69,3 +70,23 @@ export function applySecretPlaceholders( } return { env: next, targets } } + +/** + * `account::@` entries: hosts where an agent's Orca-account placeholder is swapped (D10). + * The owner (URI-encoded user id) is recorded so a workspace handed to someone else never starts using + * the new owner's account behind the old session. + */ +export function encodeAccountRoute(agent: 'claude' | 'codex', ownerUserId: string, host: string): string { + return `account:${agent}:${encodeURIComponent(ownerUserId)}@${host}` +} + +export function egressAccountRoutes(row: Pick): { agent: 'claude' | 'codex'; owner: string; host: string }[] { + const out: { agent: 'claude' | 'codex'; owner: string; host: string }[] = [] + for (const entry of (row.egress_secret_hosts ?? '').split(',')) { + const match = /^account:(claude|codex):([^@,]+)@([a-z0-9.-]+)$/.exec(entry) + if (match) { + out.push({ agent: match[1] as 'claude' | 'codex', owner: decodeURIComponent(match[2]!), host: match[3]! }) + } + } + return out +} diff --git a/cloud/apps/sandbox-control/src/session-cpu.ts b/cloud/apps/sandbox-control/src/session-cpu.ts new file mode 100644 index 00000000000..4bae566095f --- /dev/null +++ b/cloud/apps/sandbox-control/src/session-cpu.ts @@ -0,0 +1,175 @@ +// U22: internal-only CPU tracking. Customers keep flat sandbox hours; this records each session's +// measured active CPU (Vercel reports it after the VM stops) so operators can spot fair-use outliers. +// Never touches usage_outbox, billing or a customer route, and never blocks a stop: every entry +// point swallows its own failures, and the cron sweep fills in what the stop couldn't read. +import { SANDBOX_VCPUS } from './machine-size' +import type { ProviderSessionUsage, SandboxProviderApi } from './sandbox-provider' +import type { SandboxRow } from './store' + +export const FAIR_USE_WINDOW_DAYS = 30 +/** Average active CPU over vCPU-time above this flags an account for review. */ +export const FAIR_USE_THRESHOLD = 0.6 +/** Sessions older than this without CPU are given up on (the provider may have forgotten them). */ +const SWEEP_WINDOW_MS = 7 * 24 * 60 * 60_000 +const SWEEP_MAX_ATTEMPTS = 12 +const SWEEP_BATCH = 25 + +/** + * Records a session that just ended; idempotent per session id. `usage` is whatever the stop + * answered (often no CPU yet). Never throws. + */ +export async function recordSessionEnd( + db: D1Database, + row: SandboxRow, + endedAt: number, + usage: ProviderSessionUsage | null | undefined, + now: number +): Promise { + try { + if (!row.session_id) { + return + } + const vcpus = positive(usage?.vcpus) ?? SANDBOX_VCPUS + const startedAt = row.session_started_at + const wallMs = positive(usage?.durationMs) ?? (startedAt !== null ? Math.max(0, endedAt - startedAt) : 0) + await db + .prepare( + `INSERT OR IGNORE INTO session_cpu + (session_id, sandbox_id, account_id, user_id, vcpus, memory_mb, wall_ms, started_at, ended_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind(row.session_id, row.id, row.account_id, row.user_id ?? null, vcpus, positive(usage?.memoryMb), wallMs, startedAt, endedAt) + .run() + if (usage) { + await applyUsage(db, row.session_id, usage, now) + } + } catch { + // Internal review data only: a D1 or shape problem must never fail the stop. + } +} + +/** Fills CPU on a pending row; first answer wins (re-fetching never rewrites a recorded value). */ +async function applyUsage(db: D1Database, sessionId: string, usage: ProviderSessionUsage, now: number): Promise { + if (usage.activeCpuMs === null || !Number.isFinite(usage.activeCpuMs) || usage.activeCpuMs < 0) { + return false + } + const result = await db + .prepare( + `UPDATE session_cpu SET active_cpu_ms = ?, fetched_at = ?, + wall_ms = COALESCE(?, wall_ms), vcpus = COALESCE(?, vcpus), memory_mb = COALESCE(?, memory_mb) + WHERE session_id = ? AND active_cpu_ms IS NULL` + ) + .bind( + Math.round(usage.activeCpuMs), + now, + positive(usage.durationMs), + positive(usage.vcpus), + positive(usage.memoryMb), + sessionId + ) + .run() + return (result.meta?.changes ?? 0) > 0 +} + +export type SessionCpuSweep = { checked: number; recorded: number; failed: number } + +/** Cron: asks the provider again for recent sessions still missing CPU. Never throws. */ +export async function sweepSessionCpu( + db: D1Database, + provider: Pick, + now: number +): Promise { + const summary: SessionCpuSweep = { checked: 0, recorded: 0, failed: 0 } + if (!provider.getSessionUsage) { + return summary + } + let pending: { session_id: string }[] + try { + pending = ( + await db + .prepare( + `SELECT session_id FROM session_cpu + WHERE active_cpu_ms IS NULL AND ended_at >= ? AND attempts < ? + ORDER BY ended_at DESC LIMIT ?` + ) + .bind(now - SWEEP_WINDOW_MS, SWEEP_MAX_ATTEMPTS, SWEEP_BATCH) + .all<{ session_id: string }>() + ).results + } catch { + return summary + } + for (const { session_id } of pending) { + summary.checked++ + try { + const usage = await provider.getSessionUsage(session_id) + if (usage && (await applyUsage(db, session_id, usage, now))) { + summary.recorded++ + continue + } + } catch { + summary.failed++ + } + await db + .prepare('UPDATE session_cpu SET attempts = attempts + 1 WHERE session_id = ?') + .bind(session_id) + .run() + .catch(() => undefined) + } + return summary +} + +export type FairUseAccount = { + accountId: string + sessions: number + activeCpuMs: number + vcpuMs: number + /** sum(active_cpu_ms) / sum(wall_ms × vcpus), 0..1 (null when there is no measured time). */ + cpuUtilization: number | null + fairUseFlag: boolean +} + +export type FairUseReport = { + windowDays: number + since: number + threshold: number + /** Ended sessions in the window still waiting for the provider's CPU figure (excluded from the math). */ + pendingSessions: number + accounts: FairUseAccount[] +} + +/** Operator-only. Per account over the window; highest utilisation first. */ +export async function fairUseReport(db: D1Database, now: number, windowDays = FAIR_USE_WINDOW_DAYS): Promise { + const since = now - windowDays * 24 * 60 * 60_000 + const { results } = await db + .prepare( + `SELECT account_id, COUNT(*) AS sessions, SUM(active_cpu_ms) AS active_cpu_ms, SUM(wall_ms * vcpus) AS vcpu_ms + FROM session_cpu WHERE ended_at >= ? AND active_cpu_ms IS NOT NULL + GROUP BY account_id` + ) + .bind(since) + .all<{ account_id: string; sessions: number; active_cpu_ms: number; vcpu_ms: number }>() + const pending = await db + .prepare('SELECT COUNT(*) AS n FROM session_cpu WHERE ended_at >= ? AND active_cpu_ms IS NULL') + .bind(since) + .first<{ n: number }>() + const accounts = results + .map((row): FairUseAccount => { + const vcpuMs = Number(row.vcpu_ms) || 0 + const activeCpuMs = Number(row.active_cpu_ms) || 0 + const ratio = vcpuMs > 0 ? activeCpuMs / vcpuMs : null + return { + accountId: row.account_id, + sessions: Number(row.sessions), + activeCpuMs, + vcpuMs, + cpuUtilization: ratio === null ? null : Math.round(ratio * 10_000) / 10_000, + fairUseFlag: ratio !== null && ratio > FAIR_USE_THRESHOLD + } + }) + .sort((a, b) => (b.cpuUtilization ?? -1) - (a.cpuUtilization ?? -1)) + return { windowDays, since, threshold: FAIR_USE_THRESHOLD, pendingSessions: Number(pending?.n ?? 0), accounts } +} + +function positive(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : null +} diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index f0aaca727a9..92d2a15c485 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -2,9 +2,11 @@ // presets filled in, and with egress placeholders on, model keys swapped for placeholders. The same // answer drives the egress allowlist and the proxy's credential routes, at boot and on refresh. import type { LifecycleDeps } from './lifecycle' -import { signInAgentsFor, withoutSignInCredentials } from './agent-sources' +import { agentModesFor, withoutSignInCredentials, type AgentMode } from './agent-sources' +import { accountProvider } from './account-providers' +import type { CredentialAgent } from './agent-credential-names' import { deriveProviderEnv, deriveProviderScopes, providerHosts } from './provider-env' -import { applySecretPlaceholders, encodeEgressSecretHosts } from './secret-egress' +import { applySecretPlaceholders, encodeAccountRoute, encodeEgressSecretHosts } from './secret-egress' import { resolveSandboxSecrets } from './secrets' import type { Keyring } from './secrets-crypto' import type { SandboxRow } from './store' @@ -27,11 +29,10 @@ export async function resolvedProviderEnv( keyring: Keyring, row: Pick ): Promise { - const [{ env: stored, scopes }, signIn] = await Promise.all([ - resolveSandboxSecrets(db, keyring, row), - signInAgentsFor(db, row) - ]) - const env = withoutSignInCredentials(deriveProviderEnv(stored), signIn) + const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row) + // The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time + // through the row's recorded routes (an 'account' agent has no key route left). + const env = deriveProviderEnv(stored) return { env, scopes: deriveProviderScopes(stored, env, scopes) } } @@ -66,6 +67,17 @@ export function forgetProviderEnv(sandboxId: string): void { } } +function egressSwapOn(deps: Pick, row: Pick): boolean { + const placeholders = deps.secrets?.egressPlaceholders + return ( + !deps.secrets?.egressPlaceholdersOff?.has(row.account_id) && + (placeholders === true || (placeholders instanceof Set && placeholders.has(row.account_id))) && + Boolean(deps.config.egressProxyUrl) + ) +} + +export type { AgentMode } + export async function sessionSecretEnv( deps: Pick, row: SandboxRow @@ -74,22 +86,40 @@ export async function sessionSecretEnv( return EMPTY } const { env: stored, scopes: storedScopes, skipped } = await resolveSandboxSecrets(deps.db, deps.secrets.keyring, row) - // Agents on "Your sign-in" (owner's choice): their credentials never reach this session. - const signIn = await signInAgentsFor(deps.db, row) - const env = withoutSignInCredentials(deriveProviderEnv(stored), signIn) + const swap = egressSwapOn(deps, row) + const derived = deriveProviderEnv(stored) + // D10: per agent, the owner's account (placeholder), the kill-switch fallback, or the keys. + const modes = await agentModesFor(deps.db, row, derived, { + providerEnabled: deps.secrets.accountProviders ?? { claude: false, codex: false }, + accountsUsable: swap, + now: Date.now(), + // Usable means decryptable now, not just stored: a token we can't open must not hold the session in account mode. + readable: async (agent) => { + const provider = accountProvider(agent) + return provider ? (await provider.credential(deps.db, deps.secrets!.keyring, row.user_id, Date.now())) !== null : false + } + }) + const dropped = new Set((Object.keys(modes) as CredentialAgent[]).filter((agent) => modes[agent] !== 'keep')) + const env = withoutSignInCredentials(derived, dropped) const scopes = deriveProviderScopes(stored, env, storedScopes) const egressAllowHosts = providerHosts(env).join(',') || null // Counts only. console.log(JSON.stringify({ event: 'session_secrets', sandboxId: row.id, injected: Object.keys(env).length, skipped })) - const placeholders = deps.secrets.egressPlaceholders - const swap = - !deps.secrets.egressPlaceholdersOff?.has(row.account_id) && - (placeholders === true || (placeholders instanceof Set && placeholders.has(row.account_id))) - if (!swap || !deps.config.egressProxyUrl) { + if (!swap) { return { env, egressSecretHosts: null, egressAllowHosts } } const swapped = applySecretPlaceholders(row, env, scopes) - return { env: swapped.env, egressSecretHosts: encodeEgressSecretHosts(swapped.targets), egressAllowHosts } + const accountEnv: Record = {} + const accountRoutes: string[] = [] + for (const agent of Object.keys(modes) as CredentialAgent[]) { + const provider = modes[agent] === 'account' ? accountProvider(agent) : undefined + if (provider) { + Object.assign(accountEnv, provider.sessionEnv(row)) + accountRoutes.push(...provider.hosts.map((host) => encodeAccountRoute(agent, row.user_id, host))) + } + } + const secretHosts = [encodeEgressSecretHosts(swapped.targets), ...accountRoutes].filter(Boolean).join(',') || null + return { env: { ...swapped.env, ...accountEnv }, egressSecretHosts: secretHosts, egressAllowHosts } } /** Provider hosts from the session's secrets plus the workspace's declared tool hosts (MCP). */ diff --git a/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts b/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts index e8715bcac9b..088c66da934 100644 --- a/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts +++ b/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts @@ -8,6 +8,7 @@ import { type CommandResult, type ProviderSandboxState, type ProviderSession, + type ProviderSessionUsage, type SandboxProviderApi } from './sandbox-provider' @@ -26,7 +27,13 @@ const Session = z.looseObject({ id: z.string(), status: Status, startedAt: z.number().optional(), - timeout: z.number() + timeout: z.number(), + // Usage fields (SDK 3.9.1). activeCpuDurationMs only appears once the VM has stopped. + vcpus: z.number().optional(), + memory: z.number().optional(), + duration: z.number().optional(), + stoppedAt: z.number().optional(), + activeCpuDurationMs: z.number().optional() }) const Sandbox = z.looseObject({ name: z.string(), @@ -104,6 +111,15 @@ export function createVercelSandboxApi( startedAt: session.startedAt ?? null, timeoutMs: session.timeout }) + const toUsage = (session: z.infer): ProviderSessionUsage => ({ + sessionId: session.id, + status: session.status, + activeCpuMs: session.activeCpuDurationMs ?? null, + durationMs: session.duration ?? null, + vcpus: session.vcpus ?? null, + memoryMb: session.memory ?? null, + stoppedAt: session.stoppedAt ?? null + }) const toState = (body: z.infer): ProviderSandboxState => ({ sandbox: { name: body.sandbox.name, @@ -163,8 +179,22 @@ export function createVercelSandboxApi( async stopSession(sessionId) { const response = await request('POST', `/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/stop`) - const body = await json(z.looseObject({ snapshot: Snapshot.optional() }), response) - return { snapshotId: body.snapshot?.id ?? null } + const body = await json(z.looseObject({ snapshot: Snapshot.optional(), session: z.unknown().optional() }), response) + // Usage is internal-only and best-effort: a session shape we can't read never fails the stop. + const session = Session.safeParse(body.session) + return { snapshotId: body.snapshot?.id ?? null, usage: session.success ? toUsage(session.data) : null } + }, + + async getSessionUsage(sessionId) { + try { + const response = await request('GET', `/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}`) + return toUsage((await json(z.looseObject({ session: Session }), response)).session) + } catch (error) { + if (error instanceof ProviderError && error.status === 404) { + return null + } + throw error + } }, async extendTimeout(sessionId, durationMs) { diff --git a/cloud/apps/sandbox-control/src/workspace-lifecycle.ts b/cloud/apps/sandbox-control/src/workspace-lifecycle.ts index bb2fb55ac34..e16b826a85c 100644 --- a/cloud/apps/sandbox-control/src/workspace-lifecycle.ts +++ b/cloud/apps/sandbox-control/src/workspace-lifecycle.ts @@ -332,6 +332,21 @@ export async function unarchiveSandbox(deps: LifecycleDeps, row: SandboxRow): Pr return resumeSandbox(deps, suspended) } +/** + * Unarchive without waking (the team list): the snapshot backstop is restored and the workspace is + * asleep again; opening it later runs the ordinary billed, capped wake. + */ +export async function restoreArchivedToSleeping(deps: LifecycleDeps, row: SandboxRow): Promise { + if (row.status !== 'archived') { + return row + } + await deps.provider.updateSnapshotExpiration( + row.provider_name, + deps.config.snapshotExpirationMs ?? DEFAULT_SNAPSHOT_EXPIRATION_MS + ) + return claim(deps, row, { status: 'suspended', archived_at: null, archive_expires_at: null }) +} + /** Cron: archives past their TTL are destroyed (snapshots included). */ export async function expiredArchives(db: D1Database, now: number): Promise { const { results } = await db diff --git a/cloud/apps/sandbox-control/src/workspaces.ts b/cloud/apps/sandbox-control/src/workspaces.ts index 7afd79be9b5..f29d0c52e5c 100644 --- a/cloud/apps/sandbox-control/src/workspaces.ts +++ b/cloud/apps/sandbox-control/src/workspaces.ts @@ -69,6 +69,13 @@ function canManage(caller: SandboxCaller, row: SandboxRow): boolean { return row.account_id === caller.accountId && (row.user_id === caller.userId || isOrgAdmin(caller)) } +/** Owner or a team admin; anyone else gets forbidden (or not_found when they can't see it). */ +export function assertCanManageWorkspace(caller: SandboxCaller, row: SandboxRow): void { + if (!canManage(caller, row)) { + throw new WorkspaceError(canSee(caller, row) ? 'forbidden' : 'not_found', 'owner_or_admin_only') + } +} + /** A row the caller may address by id; anything else is indistinguishable from a missing one. */ export async function requireVisible(db: D1Database, caller: SandboxCaller, id: string): Promise { const row = await getSandbox(db, id) @@ -285,6 +292,9 @@ export async function patchWorkspace( } else if (!canManage(caller, row)) { throw new WorkspaceError(canSee(caller, row) ? 'forbidden' : 'not_found', 'owner_or_admin_only') } + if (patch.archived === true) { + throw new WorkspaceError('invalid_request', 'archive_via_lifecycle') + } if (patch.visibility === 'team' && !row.account_id.includes('org:')) { // A personal account has no team to share with. throw new WorkspaceError('invalid_request', 'personal_account') @@ -303,7 +313,9 @@ export async function patchWorkspace( ...(patch.branch !== undefined ? { repo_branch: patch.branch } : {}), ...(patch.visibility !== undefined ? { visibility: patch.visibility } : {}), ...(patch.section !== undefined ? { section: patch.section } : {}), - ...(patch.archived !== undefined ? { archived_at: patch.archived ? (row.archived_at ?? now) : null } : {}), + // Archiving is a lifecycle change (archiveSandbox stops the VM, then sets archived_at); this edit + // can only clear a label left by an older label-only archive. + ...(patch.archived === false ? { archived_at: null } : {}), ...(patch.ownerUserId !== undefined ? { user_id: patch.ownerUserId } : {}), ...(patch.tokenUsdMicros !== undefined ? { token_usd_micros: patch.tokenUsdMicros } : {}) }) diff --git a/cloud/apps/sandbox-control/test/agent-accounts.test.ts b/cloud/apps/sandbox-control/test/agent-accounts.test.ts new file mode 100644 index 00000000000..efb68967bd4 --- /dev/null +++ b/cloud/apps/sandbox-control/test/agent-accounts.test.ts @@ -0,0 +1,201 @@ +import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ENV_COMMAND, START_COMMAND } from '../src/sandbox-vm-commands' +import { createHarness } from './harness' + +const KEK = Buffer.alloc(32, 7).toString('base64') +const PROXY_URL = 'https://sandbox.test/v1/egress/proxy' +const SETUP_TOKEN = 'sk-ant-oat01-FAKEsetupTOKEN_for_tests_only_000000000000' +const TEAM_KEY = 'sk-ant-api03-TEAM-fake-0000-aaaa' + +let logs: string[] = [] +beforeEach(() => { + logs = [] + for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { + logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')) + }) + } +}) +afterEach(() => { + for (const value of [SETUP_TOKEN, TEAM_KEY]) { + expect(logs.join('\n')).not.toContain(value) + } + vi.restoreAllMocks() +}) + +function decode(encoded: string | undefined): Record { + return Object.fromEntries( + (encoded ?? '') + .split('\n') + .filter(Boolean) + .map((line) => { + const at = line.indexOf('=') + return [line.slice(0, at), Buffer.from(line.slice(at + 1), 'base64').toString('utf8')] + }) + ) +} + +async function setup(overrides: Record = {}) { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] } + const upstream: { url: string; headers: Headers }[] = [] + const reply = { status: 200 } + const h = await createHarness( + { SECRETS_KEK: KEK, EGRESS_PROXY_URL: PROXY_URL, SECRETS_EGRESS_PLACEHOLDERS: 'on', AGENT_ACCOUNTS_CLAUDE: 'on', ...overrides }, + { + egressKeysForIssuer: () => createLocalJWKSet(jwks), + credentials: { authorizationFor: async () => null }, + egressFetch: async (input, init) => { + upstream.push({ url: String(input), headers: new Headers(init?.headers) }) + return new Response('{}', { status: reply.status }) + } + } + ) + const as = (sub: string, org?: string, role?: string) => h.token(sub, org, 'sandbox', role ? { orgRole: role } : {}) + const lastEnv = (command: string) => decode(h.vercel.commands.filter((c) => c.command === command).at(-1)?.env.ORCA_SECRET_ENV) + async function proxied(sandboxId: string, host: string, headers: Record) { + const oidc = await new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_name: `orca-test-${sandboxId}` }) + .setProtectedHeader({ alg: 'RS256', kid: 'v1' }) + .setIssuer('https://oidc.vercel.com/team_orca') + .setAudience(PROXY_URL) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey) + const response = await h.send( + new Request(`${PROXY_URL}/v1/messages`, { + method: 'POST', + body: '{}', + headers: { + 'vercel-forwarded-host': host, + 'vercel-forwarded-scheme': 'https', + 'vercel-forwarded-port': '443', + 'vercel-forwarded-path': '/v1/messages', + 'vercel-sandbox-oidc-token': oidc, + ...headers + } + }) + ) + return { status: response.status, body: await response.text() } + } + return { h, as, lastEnv, proxied, upstream, reply } +} + +describe('Orca-account credentials (D11)', () => { + it('stores a Claude setup-token Personal-only and never returns or logs it; flag off = no route', async () => { + const off = await setup({ AGENT_ACCOUNTS_CLAUDE: '' }) + const mia0 = await off.as('mia') + expect((await off.h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia0, body: { token: SETUP_TOKEN } })).status).toBe(404) + + const { h, as } = await setup() + const mia = await as('mia') + expect((await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: 'sk-ant-api03-not-a-setup-token' } })).body.reason).toBe('invalid_token') + const put = await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN, label: 'mia@example.com' } }) + expect(put.status).toBe(200) + expect(put.body.claude).toMatchObject({ state: 'active', label: 'mia@example.com' }) + expect(put.body.providerEnabled).toEqual({ claude: true, codex: false }) + expect(JSON.stringify(put.body)).not.toContain(SETUP_TOKEN) + // Someone else sees nothing of Mia's. + expect((await h.call('GET', '/v1/agent-credentials', { bearer: await as('bob') })).body.claude.state).toBe('none') + expect((await h.call('DELETE', '/v1/agent-credentials/claude', { bearer: mia })).body.claude.state).toBe('none') + }) +}) + +describe('egress-time Source (D10) and shared workspaces (D13)', () => { + it('a set-up account is the implicit Source with no key: placeholder in the VM, real token only at egress', async () => { + const { h, as, lastEnv, proxied, upstream } = await setup() + const mia = await as('mia') + await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + const env = lastEnv(START_COMMAND) + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe(`orca-ph-claude-account-${id}`) + expect(JSON.stringify(env)).not.toContain(SETUP_TOKEN) + const box = [...h.vercel.boxes.values()][0]! + expect(box.policy.allow['api.anthropic.com']!.at(-1)).toEqual({ response: { statusCode: 403, body: 'orca-egress-denied', contentType: 'text/plain' } }) + expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(200) + expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${SETUP_TOKEN}`) + const view = await h.call('GET', `/v1/agent-sources?workspaceId=${id}`, { bearer: mia }) + expect(view.body.effective.claude).toEqual({ source: 'sign-in', origin: 'implicit' }) + expect(view.body.sharedWorkspace).toBe(false) + }) + + it('a team key wins by default; choosing "Your account" swaps the key out live; deleting the account falls back', async () => { + const { h, as, lastEnv, proxied, upstream } = await setup() + const ada = await as('ada', 'acme', 'admin') + const mia = await as('mia', 'acme', 'member') + await h.call('PUT', '/v1/secrets/org/ANTHROPIC_API_KEY', { bearer: ada, body: { value: TEAM_KEY } }) + await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + expect(lastEnv(START_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + expect(lastEnv(START_COMMAND).ANTHROPIC_API_KEY).toMatch(/^orca-ph-anthropic-api-key-/) + await h.call('PUT', '/v1/agent-sources', { bearer: mia, body: { agent: 'claude', source: 'sign-in' } }) + let env = lastEnv(ENV_COMMAND) + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe(`orca-ph-claude-account-${id}`) + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(200) + expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${SETUP_TOKEN}`) + await h.call('DELETE', '/v1/agent-credentials/claude', { bearer: mia }) + // The proxy refuses at once; the session moves back to the team key. + expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(403) + env = lastEnv(ENV_COMMAND) + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + expect(env.ANTHROPIC_API_KEY).toMatch(/^orca-ph-anthropic-api-key-/) + }) + + it('never uses a personal account in a workspace shared with teammates', async () => { + const { h, as, lastEnv, proxied } = await setup() + const mia = await as('mia', 'acme', 'member') + await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + expect(lastEnv(START_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBe(`orca-ph-claude-account-${id}`) + // The workspace becomes shared: the very next request is refused, and the session refreshes off the account. + await h.env.SANDBOX_DB.prepare(`UPDATE sandboxes SET visibility = 'team' WHERE id = ?`).bind(id).run() + const refused = await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` }) + expect(refused.status).toBe(403) + expect(lastEnv(ENV_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + expect((await h.call('GET', `/v1/agent-sources?workspaceId=${id}`, { bearer: mia })).body.sharedWorkspace).toBe(true) + }) + + it('a workspace handed to a new owner never uses the new owner\'s account behind the old session', async () => { + const { h, as, proxied } = await setup() + const mia = await as('mia', 'acme', 'member') + const bob = await as('bob', 'acme', 'member') + await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) + await h.call('PUT', '/v1/agent-credentials/claude', { bearer: bob, body: { token: 'sk-ant-oat01-BOBsetupTOKEN_for_tests_only_111111111111' } }) + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + await h.env.SANDBOX_DB.prepare(`UPDATE sandboxes SET user_id = 'bob' WHERE id = ?`).bind(id).run() + const refused = await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` }) + expect(refused).toEqual({ status: 403, body: 'account_owner_changed' }) + }) + + it('a 401 from the provider marks the account revoked and the session falls off it', async () => { + const { h, as, proxied, reply, lastEnv } = await setup() + const mia = await as('mia') + await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + reply.status = 401 + expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(401) + expect((await h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.claude.state).toBe('revoked') + // Already off the account: no placeholder in new shells, and the proxy refuses with a clear reason. + await vi.waitFor(() => expect(lastEnv(ENV_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()) + expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(403) + }) + + it('kill switch: Source "sign-in" falls back to the agent signing in inside the sandbox (no key, no placeholder)', async () => { + const { h, as, lastEnv } = await setup({ AGENT_ACCOUNTS_CLAUDE_DISABLED: '1' }) + const ada = await as('ada', 'acme', 'admin') + const mia = await as('mia', 'acme', 'member') + await h.call('PUT', '/v1/secrets/org/ANTHROPIC_API_KEY', { bearer: ada, body: { value: TEAM_KEY } }) + await h.call('PUT', '/v1/agent-sources', { bearer: mia, body: { agent: 'claude', source: 'sign-in' } }) + await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const env = lastEnv(START_COMMAND) + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect((await h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.providerEnabled.claude).toBe(false) + }) +}) diff --git a/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts b/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts index 9eb50a5b13a..b342c939f58 100644 --- a/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts +++ b/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts @@ -196,7 +196,11 @@ describe('egress swap on a sandbox with the new keys', () => { const { h, id, send, upstream } = await setup() const mia = await h.token('mia', undefined, 'sandbox') expect((await h.call('PUT', '/v1/secrets/personal/COPILOT_GITHUB_TOKEN', { body: { value: COPILOT_KEY }, bearer: mia })).status).toBe(200) - const ph = `orca-ph-copilot-github-token-${id}` + // Copilot CLI ignores a token that doesn't look like a GitHub token, so the stand-in looks like one. + const ph = `github_pat_orcaph_${id}` + const vmEnv = h.vercel.commands.filter((c) => c.env.ORCA_SECRET_ENV).at(-1)!.env.ORCA_SECRET_ENV! + const line = vmEnv.split('\n').find((l) => l.startsWith('COPILOT_GITHUB_TOKEN='))! + expect(Buffer.from(line.slice(line.indexOf('=') + 1), 'base64').toString('utf8')).toBe(ph) const policy = [...h.vercel.boxes.values()][0]!.policy const values = policy.allow['api.github.com']!.flatMap((rule) => ('match' in rule && rule.match?.headers ? [rule.match.headers[0]!.value.exact] : [])) expect(values).toEqual(expect.arrayContaining([`Bearer ${ph}`, `token ${ph}`, `Bearer orca-ph-${id}`])) diff --git a/cloud/apps/sandbox-control/test/build-runner.test.ts b/cloud/apps/sandbox-control/test/build-runner.test.ts new file mode 100644 index 00000000000..fe303b125a3 --- /dev/null +++ b/cloud/apps/sandbox-control/test/build-runner.test.ts @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { EnvironmentBuildRunner, RUNNER_RETRY_MS, type BuildTickerApi, type RunnerBuild } from '../src/build-runner' +import { BuildTicker, runBuildTick } from '../src/index' +import type { Env } from '../src/env' +import { createHarness } from './harness' + +const RESULT = JSON.stringify({ + durationSec: 3, + repos: [{ url: 'https://github.com/acme/web.git', dir: 'acme/web', branch: 'main', commit: 'a'.repeat(40), lockfiles: {} }], + diskUsedBytes: 3_000_000_000, + diskTotalBytes: 34_000_000_000 +}) + +/** Durable Object storage as the runner uses it: one key and one alarm. */ +function fakeState() { + const data = new Map() + let alarm: number | null = null + const storage = { + get: async (key: string) => data.get(key), + put: async (key: string, value: unknown) => void data.set(key, value), + getAlarm: async () => alarm, + setAlarm: async (at: number) => void (alarm = at), + deleteAll: async () => { + data.clear() + alarm = null + } + } + return { state: { storage } as unknown as DurableObjectState, data, alarm: () => alarm } +} + +function runnerFor(control: BuildTickerApi) { + const fake = fakeState() + const runner = new EnvironmentBuildRunner(fake.state, { CONTROL: control }) + const start = (build: RunnerBuild) => runner.fetch(new Request('https://runner/start', { method: 'POST', body: JSON.stringify(build) })) + return { ...fake, runner, start } +} + +/** Reads a wrangler .jsonc (full-line comments only, as ours are written). */ +function wranglerConfig(file: string): any { + const text = readFileSync(`${import.meta.dirname}/../${file}`, 'utf8') + return JSON.parse( + text + .split('\n') + .filter((line) => !line.trim().startsWith('//')) + .join('\n') + ) +} + +describe('environment build runner Worker', () => { + it('finishes a build through runner alarms that step it in the control plane (nobody watching)', async () => { + let runner: ReturnType | null = null + const h = await createHarness({}, { scheduleBuild: async (build) => void (await runner!.start(build)) }) + // The runner's CONTROL binding: BuildTicker.tickBuild, i.e. runBuildTick with the control plane's deps. + const ticks: RunnerBuild[] = [] + runner = runnerFor({ + tickBuild: async (build) => { + ticks.push(build) + return runBuildTick(h.env, build, h.deps) + } + }) + h.vercel.buildResult = RESULT + const admin = await h.token('alice', 'acme', 'sandbox', { orgRole: 'admin' }) + const env = await h.call('POST', '/v1/environments', { bearer: admin, body: { name: 'Default', repos: [{ url: 'acme/web' }], installScript: 'echo hi' } }) + const envId = env.body.environment.id + const started = await h.call('POST', `/v1/environments/${envId}/builds`, { bearer: admin }) + expect(started.body.build.status).toBe('running') + const build = { id: started.body.build.id, environmentId: envId } + // Armed at start; the first alarm comes 2 s later. + expect(runner.data.get('build')).toEqual(build) + expect(runner.alarm()).not.toBeNull() + // While the VM command runs, each alarm re-arms on a short cadence. + await runner.runner.alarm() + expect(ticks).toEqual([build]) + expect(runner.alarm()).not.toBeNull() + const command = [...h.vercel.buildCommands.values()].at(-1)! + command.logs = `${JSON.stringify({ data: '::orca-step:: install\nhi\n', stream: 'stdout' })}\n` + command.exitCode = 0 + h.advance(3_000) + // The next alarm finalizes (snapshot, smoke boot, activation, billing) and the runner clears itself. + await runner.runner.alarm() + expect(runner.alarm()).toBeNull() + expect(runner.data.size).toBe(0) + const row = h.raw.prepare('SELECT status, usage_reported_at FROM environment_builds WHERE id = ?').get(build.id) as { + status: string + usage_reported_at: number | null + } + expect(row.status).toBe('succeeded') + expect(row.usage_reported_at).not.toBeNull() + const environment = (await h.call('GET', `/v1/environments/${envId}`, { bearer: admin })).body.environment + expect(environment.activeBuildId).toBe(build.id) + }) + + it('retries after a failed step (control plane unreachable) and keeps its build', async () => { + const r = runnerFor({ + tickBuild: async () => { + throw new Error('service binding down') + } + }) + await r.start({ id: 'b1', environmentId: 'e1' }) + const before = Date.now() + await r.runner.alarm() + expect(r.alarm()).toBeGreaterThanOrEqual(before + RUNNER_RETRY_MS) + expect(r.data.get('build')).toEqual({ id: 'b1', environmentId: 'e1' }) + }) + + it('stops when the control plane answers null, and refuses a start without a build', async () => { + const r = runnerFor({ tickBuild: async () => null }) + expect((await r.runner.fetch(new Request('https://runner/start', { method: 'POST', body: '{}' }))).status).toBe(400) + await r.start({ id: 'b1', environmentId: 'e1' }) + await r.runner.alarm() + expect(r.alarm()).toBeNull() + expect(r.data.size).toBe(0) + }) + + it('runBuildTick answers null for an unknown build and when the control plane is unconfigured', async () => { + const h = await createHarness() + expect(await runBuildTick(h.env, { id: 'nope', environmentId: 'nope' }, h.deps)).toBeNull() + const bare: Env = { ...h.env, VERCEL_TOKEN: undefined, VERCEL_TEAM_ID: undefined } + expect(await runBuildTick(bare, { id: 'nope', environmentId: 'nope' })).toBeNull() + // The RPC entrypoint the runner calls is that same step. + expect(await new BuildTicker({} as ExecutionContext, bare).tickBuild({ id: 'nope', environmentId: 'nope' })).toBeNull() + }) + + it('dev configs: the control plane implements no Durable Object (preview URLs) and binds the runner Worker', () => { + const control = wranglerConfig('wrangler.dev.jsonc') + const runner = wranglerConfig('wrangler.build-runner.dev.jsonc') + // A binding, when present, always points at the runner Worker. During the two-step rollout (error 10061: + // no binding may name a class in the deploy that deletes it) the control plane has none for one deploy. + const bindings: any[] = control.durable_objects?.bindings ?? [] + expect(bindings.every((b) => b.class_name === 'EnvironmentBuildRunner' && b.script_name === runner.name)).toBe(true) + // Every class the control plane ever created is deleted again by a later migration. + const created = control.migrations.flatMap((m: any) => m.new_sqlite_classes ?? []) + const deleted = control.migrations.flatMap((m: any) => m.deleted_classes ?? []) + expect(created.every((name: string) => deleted.includes(name))).toBe(true) + expect(control.preview_urls).toBe(true) + expect(runner.main).toBe('src/build-runner.ts') + expect(runner.migrations.flatMap((m: any) => m.new_sqlite_classes ?? [])).toEqual(['EnvironmentBuildRunner']) + expect(runner.services).toEqual([{ binding: 'CONTROL', service: control.name, entrypoint: 'BuildTicker' }]) + // The runner holds no data and no secrets: every step runs in the control plane. + expect(runner.d1_databases).toBeUndefined() + expect(runner.vars).toBeUndefined() + }) + + it('local configs pair the same way (pnpm dev runs both)', () => { + const control = wranglerConfig('wrangler.jsonc') + const runner = wranglerConfig('wrangler.build-runner.jsonc') + expect(control.durable_objects.bindings[0].script_name).toBe(runner.name) + expect(control.migrations).toBeUndefined() + expect(runner.services[0]).toEqual({ binding: 'CONTROL', service: control.name, entrypoint: 'BuildTicker' }) + }) +}) diff --git a/cloud/apps/sandbox-control/test/cloudflare-workers-stub.ts b/cloud/apps/sandbox-control/test/cloudflare-workers-stub.ts new file mode 100644 index 00000000000..4c40935c7f7 --- /dev/null +++ b/cloud/apps/sandbox-control/test/cloudflare-workers-stub.ts @@ -0,0 +1,8 @@ +// `cloudflare:workers` exists only in workerd; vitest (Node) aliases it here. Tests construct +// entrypoints directly with (ctx, env), as the runtime does. +export class WorkerEntrypoint { + constructor( + protected readonly ctx: unknown, + protected readonly env: Env + ) {} +} diff --git a/cloud/apps/sandbox-control/test/fake-vercel.ts b/cloud/apps/sandbox-control/test/fake-vercel.ts index abf4af00964..0162e3ed33f 100644 --- a/cloud/apps/sandbox-control/test/fake-vercel.ts +++ b/cloud/apps/sandbox-control/test/fake-vercel.ts @@ -83,6 +83,10 @@ export type FakeVercel = SandboxProviderApi & GoldenImageProviderApi & { snapshotExpirations: Map /** Simulates the provider ending a session at its timeout. */ expire(name: string): void + /** activeCpuDurationMs per session, as GET session reports it once stopped (stop answers without it). */ + sessionCpu: Map + /** GET session fails (provider outage) when set. */ + failSessionUsage: boolean } export function createFakeVercel(now: () => number): FakeVercel { @@ -122,6 +126,8 @@ export function createFakeVercel(now: () => number): FakeVercel { const buildCommands = new Map() const fake: FakeVercel = { + sessionCpu: new Map(), + failSessionUsage: false, boxes, snapshots, commands, @@ -222,7 +228,19 @@ export function createFakeVercel(now: () => number): FakeVercel { }, async stopSession(sessionId) { const box = bySession(sessionId) - return { snapshotId: box.status === 'running' ? stop(box) : null } + const startedAt = box.startedAt + const snapshotId = box.status === 'running' ? stop(box) : null + return { + snapshotId, + usage: { sessionId, status: 'stopped', activeCpuMs: null, durationMs: now() - startedAt, vcpus: box.params.vcpus, memoryMb: box.params.vcpus * 2048, stoppedAt: now() } + } + }, + async getSessionUsage(sessionId) { + if (fake.failSessionUsage) { + throw new ProviderError(503, null) + } + const cpu = fake.sessionCpu.get(sessionId) + return cpu === undefined ? null : { sessionId, status: 'stopped', activeCpuMs: cpu, durationMs: null, vcpus: null, memoryMb: null, stoppedAt: null } }, async extendTimeout(sessionId, durationMs) { const box = bySession(sessionId) diff --git a/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts b/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts index 7ccdbb818a9..d7a9045ca88 100644 --- a/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts +++ b/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts @@ -251,6 +251,21 @@ describe('sandbox control plane cron', () => { expect((await h.call('GET', `/v1/sandboxes/${a}`)).body.sandbox.status).toBe('suspended') }) + it('at the spend cap, puts every running sandbox of the account to sleep and deletes nothing', async () => { + const h = await createHarness() + const a = (await h.call('POST', '/v1/sandboxes', { body: create })).body.sandbox.id + const b = (await h.call('POST', '/v1/sandboxes', { body: { ...create, workspaceId: 'ws-2' } })).body.sandbox.id + h.billing.usageAnswer = { canStartSandbox: false, reason: 'overage_cap_reached', suspendRunning: true } + h.advance(10 * MIN) + await h.reconcile() + for (const id of [a, b]) { + expect((await h.call('GET', `/v1/sandboxes/${id}`)).body.sandbox.status).toBe('suspended') + } + // Asleep, not deleted: the VMs (and their disks) are kept, and no data-retention clock starts. + expect(h.vercel.boxes.size).toBe(2) + expect(h.raw.prepare(`SELECT COUNT(*) AS n FROM account_holds WHERE reason <> 'active'`).get()).toEqual({ n: 0 }) + }) + it('refuses machine sizes billing does not sell', async () => { const h = await createHarness() expect((await h.call('POST', '/v1/sandboxes', { body: { ...create, machineSize: 'xlarge' } })).status).toBe(400) diff --git a/cloud/apps/sandbox-control/test/orca-env.test.ts b/cloud/apps/sandbox-control/test/orca-env.test.ts new file mode 100644 index 00000000000..2086924ea35 --- /dev/null +++ b/cloud/apps/sandbox-control/test/orca-env.test.ts @@ -0,0 +1,148 @@ +// orca-env (image/vm/orca-env): prints lines that bring a running shell up to date with the session +// env file. Values must survive quoting exactly (quotes, newlines, $, backslashes) in POSIX shells +// and fish, and names that were removed must be unset. +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const ORCA_ENV = fileURLToPath(new URL('../image/vm/orca-env', import.meta.url).href) + +const TRICKY: Record = { + SINGLE: "it's a 'quoted' value", + DOUBLE: 'say "hi" and \\"escaped\\"', + DOLLAR: '$HOME $(touch /tmp/orca-env-pwned) `id` ${PATH}', + NEWLINES: 'line one\nline two\n\n', + BACKSLASH: 'C:\\path\\to\\ \\n not-a-newline \\', + SEMI: 'a; echo pwned; b && c | d', + EMPTY: '' +} + +let dir = '' +let envFile = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-env-')) + envFile = join(dir, 'session.env') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeSessionEnv(values: Record, extraLines: string[] = []): void { + const lines = Object.entries(values).map(([name, value]) => `${name}=${Buffer.from(value).toString('base64')}`) + writeFileSync(envFile, [...lines, ...extraLines].join('\n') + '\n') +} + +function orcaEnv(args: string[], names = ''): string { + const result = spawnSync('bash', [ORCA_ENV, ...args], { + encoding: 'utf8', + env: { PATH: process.env.PATH, ORCA_SESSION_ENV_FILE: envFile, ORCA_SESSION_ENV_NAMES: names } + }) + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + return result.stdout +} + +/** Runs `script` in `shell` and returns the env it ends with, read back as JSON through node. */ +function envAfter(shell: string, args: string[], script: string, initial: Record = {}) { + const dump = `"${process.execPath}" -e 'process.stdout.write(JSON.stringify(process.env))'` + const result = spawnSync(shell, ['-c', `${script}\n${dump}`], { + encoding: 'utf8', + env: { + PATH: process.env.PATH, + // A throwaway HOME keeps this machine's rc files out of it. + HOME: dir, + ORCA_SESSION_ENV_FILE: envFile, + ORCA_ENV_BIN: ORCA_ENV, + ...initial + } + }) + expect(result.stderr, shell).toBe('') + return JSON.parse(result.stdout) as Record +} + +function hasShell(shell: string): boolean { + return !spawnSync(shell, ['-c', 'true']).error +} + +describe('orca-env', () => { + it('exports every session name with its exact value in bash, sh and zsh', () => { + writeSessionEnv(TRICKY) + for (const shell of ['bash', 'sh', 'zsh'].filter(hasShell)) { + const env = envAfter(shell, [], 'eval "$(bash "$ORCA_ENV_BIN")"') + for (const [name, value] of Object.entries(TRICKY)) { + expect(env[name], `${shell} ${name}`).toBe(value) + } + expect(env.ORCA_SESSION_ENV_NAMES).toBe(Object.keys(TRICKY).join(' ')) + } + }) + + it('exports the exact values in fish', () => { + if (!hasShell('fish')) { + return + } + writeSessionEnv(TRICKY) + const env = envAfter('fish', ['--fish'], 'bash $ORCA_ENV_BIN --fish | source') + for (const [name, value] of Object.entries(TRICKY)) { + expect(env[name], name).toBe(value) + } + }) + + it('unsets names the session env no longer has, in POSIX shells and fish', () => { + writeSessionEnv({ KEPT: 'new' }) + const initial = { KEPT: 'old', GONE: 'stale', MINE: 'untouched', ORCA_SESSION_ENV_NAMES: 'KEPT GONE' } + expect(orcaEnv([], 'KEPT GONE')).toContain('unset GONE\n') + expect(orcaEnv(['--fish'], 'KEPT GONE')).toContain('set -e GONE\n') + const shells: [string, string][] = [['bash', 'eval "$(bash "$ORCA_ENV_BIN")"']] + if (hasShell('fish')) { + shells.push(['fish', 'bash $ORCA_ENV_BIN --fish | source']) + } + for (const [shell, script] of shells) { + const env = envAfter(shell, [], script, initial) + expect(env.KEPT, shell).toBe('new') + expect(env.GONE, shell).toBeUndefined() + expect(env.MINE, shell).toBe('untouched') + expect(env.ORCA_SESSION_ENV_NAMES, shell).toBe('KEPT') + } + }) + + it('skips names the shell hook skips and globs nothing', () => { + writeSessionEnv({ GOOD: 'x' }, ['PATH=L3RtcA==', 'ORCA_X=eA==', 'LD_PRELOAD=eA==', 'lower=eA==', 'not a line']) + const out = orcaEnv([], '* ORCA_TOKEN') + expect(out).toBe("export GOOD='x'\nexport ORCA_SESSION_ENV_NAMES='GOOD'\n") + }) + + it("never sets or unsets the control plane's reserved names and prefixes (kept in sync with secrets.ts)", () => { + const source = readFileSync(fileURLToPath(new URL('../src/secrets.ts', import.meta.url).href), 'utf8') + const quoted = (text: string | undefined): string[] => [...(text ?? '').matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1] ?? '') + const names = quoted(source.match(/const RESERVED_NAMES[^=]*= new Set\(\[([\s\S]*?)\]\)/)?.[1]) + const prefixes = quoted(source.match(/const RESERVED_PREFIXES = \[([^\]]*)\]/)?.[1]) + expect(names.length).toBeGreaterThan(30) + expect(prefixes.length).toBeGreaterThan(3) + // The control plane's own account placeholder rides this name (D10); users can't store it. + const blocked = [...names.filter((name) => name !== 'CLAUDE_CODE_OAUTH_TOKEN'), ...prefixes.map((prefix) => `${prefix}X`)] + writeSessionEnv({ GOOD: 'x', ...Object.fromEntries(blocked.map((name) => [name, 'evil'])) }) + const out = orcaEnv([], blocked.join(' ')) + expect(out).toBe("export GOOD='x'\nexport ORCA_SESSION_ENV_NAMES='GOOD'\n") + }) + + it('prints nothing before the session env exists and never writes a file', () => { + const result = spawnSync('bash', [ORCA_ENV], { + encoding: 'utf8', + cwd: dir, + env: { PATH: process.env.PATH, ORCA_SESSION_ENV_FILE: join(dir, 'missing'), ORCA_SESSION_ENV_NAMES: 'A B' } + }) + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + expect(spawnSync('ls', ['-A', dir], { encoding: 'utf8' }).stdout).toBe('') + }) + + it('rejects unknown options', () => { + const result = spawnSync('bash', [ORCA_ENV, '--zsh'], { encoding: 'utf8' }) + expect(result.status).toBe(2) + }) +}) diff --git a/cloud/apps/sandbox-control/test/session-cpu.test.ts b/cloud/apps/sandbox-control/test/session-cpu.test.ts new file mode 100644 index 00000000000..9288d5dfaaf --- /dev/null +++ b/cloud/apps/sandbox-control/test/session-cpu.test.ts @@ -0,0 +1,201 @@ +// U22: internal-only per-session CPU, fair-use report, and its operator gate. +import { describe, expect, it } from 'vitest' +import { FAIR_USE_THRESHOLD, fairUseReport, recordSessionEnd, sweepSessionCpu } from '../src/session-cpu' +import type { SandboxRow } from '../src/store' +import { createVercelSandboxApi } from '../src/vercel-sandbox-api' +import { createFakeD1 } from './d1-fake' +import { createHarness } from './harness' + +const OPERATOR = 'o'.repeat(40) +const MIN = 60_000 +const create = { workspaceId: 'ws-1', machineSize: 'standard' } + +async function operator(h: Awaited>, method: string, path: string) { + const response = await h.send( + new Request(`https://sandbox.test${path}`, { method, headers: { authorization: `Bearer ${OPERATOR}` } }) + ) + return { status: response.status, body: (await response.json()) as any } +} + +function client(answer: (url: URL, method: string) => Response) { + return createVercelSandboxApi({ + token: 'vercel_test_token', + teamId: 'team_orca', + projectId: 'prj_orca', + fetch: async (input, init) => answer(new URL(String(input)), init?.method ?? 'GET') + }) +} + +const stoppedSession = { + id: 'sess_1', + status: 'stopped', + startedAt: 1000, + timeout: 1_800_000, + memory: 16384, + vcpus: 8, + region: 'iad1', + duration: 600_000, + stoppedAt: 601_000, + activeCpuDurationMs: 123_456 +} + +describe('Vercel session usage mapping', () => { + it('maps activeCpuDurationMs, duration, vcpus and memory from GET session', async () => { + let seen: URL | null = null + const api = client((url) => ((seen = url), Response.json({ session: stoppedSession, routes: [] }))) + expect(await api.getSessionUsage!('sess_1')).toEqual({ + sessionId: 'sess_1', + status: 'stopped', + activeCpuMs: 123_456, + durationMs: 600_000, + vcpus: 8, + memoryMb: 16384, + stoppedAt: 601_000 + }) + expect(seen!.pathname).toBe('/api/v2/sandboxes/sessions/sess_1') + }) + + it('a running session has no CPU yet; a forgotten one is null', async () => { + const running = client(() => Response.json({ session: { id: 's', status: 'running', timeout: 1, vcpus: 8 } })) + expect(await running.getSessionUsage!('s')).toMatchObject({ activeCpuMs: null, vcpus: 8, durationMs: null }) + const gone = client(() => Response.json({ error: { code: 'not_found' } }, { status: 404 })) + expect(await gone.getSessionUsage!('s')).toBeNull() + }) + + it('reads usage off the stop response, and an unreadable session never fails the stop', async () => { + const withSession = client(() => Response.json({ session: stoppedSession, snapshot: { id: 'snap_1', status: 'created', sourceSessionId: 'sess_1' } })) + expect(await withSession.stopSession('sess_1')).toMatchObject({ snapshotId: 'snap_1', usage: { activeCpuMs: 123_456 } }) + const odd = client(() => Response.json({ session: { id: 7 }, snapshot: { id: 'snap_1', status: 'created', sourceSessionId: 'sess_1' } })) + expect(await odd.stopSession('sess_1')).toEqual({ snapshotId: 'snap_1', usage: null }) + }) +}) + +function row(overrides: Partial = {}): SandboxRow { + return { + id: 'sbx_1', + account_id: 'user:alice', + user_id: 'alice', + session_id: 'sess_1', + session_started_at: 1_000, + ...overrides + } as SandboxRow +} + +describe('session CPU storage', () => { + it('is idempotent per session and the first recorded CPU wins', async () => { + const { db, raw } = createFakeD1() + await recordSessionEnd(db, row(), 61_000, null, 61_000) + await recordSessionEnd(db, row(), 99_000, null, 99_000) + const usage = { sessionId: 'sess_1', status: 'stopped' as const, activeCpuMs: 30_000, durationMs: 60_000, vcpus: 8, memoryMb: 16384, stoppedAt: 61_000 } + await recordSessionEnd(db, row(), 99_000, usage, 100_000) + await recordSessionEnd(db, row(), 99_000, { ...usage, activeCpuMs: 1 }, 200_000) + const rows = raw.prepare('SELECT * FROM session_cpu').all() as any[] + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ session_id: 'sess_1', sandbox_id: 'sbx_1', account_id: 'user:alice', user_id: 'alice', ended_at: 61_000, active_cpu_ms: 30_000, wall_ms: 60_000, vcpus: 8, memory_mb: 16384, fetched_at: 100_000 }) + }) + + it('never throws, even when D1 does', async () => { + const broken = { prepare: () => { throw new Error('d1 down') } } as unknown as D1Database + await expect(recordSessionEnd(broken, row(), 1, null, 1)).resolves.toBeUndefined() + expect(await sweepSessionCpu(broken, { getSessionUsage: async () => null }, 1)).toEqual({ checked: 0, recorded: 0, failed: 0 }) + }) +}) + +describe('session CPU through the lifecycle', () => { + it('suspend records the session (wall time, vCPUs) without touching the meter; the sweep fills CPU later', async () => { + const h = await createHarness({ OPERATOR_TOKEN: OPERATOR }) + const { body } = await h.call('POST', '/v1/sandboxes', { body: create }) + const sessionId = [...h.vercel.boxes.values()][0]!.sessionId + h.advance(10 * MIN) + expect((await h.call('POST', `/v1/sandboxes/${body.sandbox.id}/suspend`)).status).toBe(200) + + const [recorded] = h.raw.prepare('SELECT * FROM session_cpu').all() as any[] + expect(recorded).toMatchObject({ session_id: sessionId, sandbox_id: body.sandbox.id, account_id: 'user:alice', vcpus: 8, wall_ms: 10 * MIN, active_cpu_ms: null }) + // Billing sees exactly the flat-hours segment, with no CPU anywhere in it. + expect(h.billing.usage).toHaveLength(1) + expect(Object.keys(h.billing.usage[0]!).sort()).toEqual(['accountId', 'endedAt', 'machineSize', 'sandboxId', 'startedAt', 'usageId', 'userId']) + + h.vercel.sessionCpu.set(sessionId, 4 * 8 * MIN) + expect((await operator(h, 'POST', '/v1/internal/session-cpu/sweep')).body).toEqual({ sweep: { checked: 1, recorded: 1, failed: 0 } }) + expect((await operator(h, 'POST', '/v1/internal/session-cpu/sweep')).body.sweep.checked).toBe(0) + const report = await operator(h, 'GET', '/v1/internal/fair-use') + expect(report.body.accounts).toEqual([ + { accountId: 'user:alice', sessions: 1, activeCpuMs: 32 * MIN, vcpuMs: 80 * MIN, cpuUtilization: 0.4, fairUseFlag: false } + ]) + }) + + it('a failing CPU fetch never blocks suspend or usage reporting; the sweep backs off', async () => { + const h = await createHarness({ OPERATOR_TOKEN: OPERATOR }) + const { body } = await h.call('POST', '/v1/sandboxes', { body: create }) + h.vercel.failSessionUsage = true + // Even a D1 table missing entirely must not fail the stop. + h.raw.exec('ALTER TABLE session_cpu RENAME TO session_cpu_hidden') + h.advance(5 * MIN) + expect((await h.call('POST', `/v1/sandboxes/${body.sandbox.id}/suspend`)).status).toBe(200) + expect(h.billing.usage).toHaveLength(1) + h.raw.exec('ALTER TABLE session_cpu_hidden RENAME TO session_cpu') + + expect((await h.call('POST', `/v1/sandboxes/${body.sandbox.id}/resume`)).status).toBe(200) + h.advance(5 * MIN) + expect((await h.call('POST', `/v1/sandboxes/${body.sandbox.id}/suspend`)).status).toBe(200) + expect(h.billing.usage).toHaveLength(2) + expect((await operator(h, 'POST', '/v1/internal/session-cpu/sweep')).body.sweep).toEqual({ checked: 1, recorded: 0, failed: 1 }) + expect((h.raw.prepare('SELECT attempts FROM session_cpu').get() as any).attempts).toBe(1) + }) + + it('a session the provider ended on its own is recorded too', async () => { + const h = await createHarness() + const { body } = await h.call('POST', '/v1/sandboxes', { body: create }) + const box = [...h.vercel.boxes.values()][0]! + h.advance(31 * MIN) + h.vercel.expire(box.params.name) + await h.reconcile() + expect(h.raw.prepare('SELECT sandbox_id, wall_ms FROM session_cpu').all()).toEqual([{ sandbox_id: body.sandbox.id, wall_ms: 30 * MIN }]) + }) +}) + +describe('fair-use report', () => { + async function seed(db: D1Database, account: string, session: string, wallMs: number, vcpus: number, cpuMs: number | null, endedAt: number) { + await recordSessionEnd(db, row({ id: `sbx_${session}`, account_id: account, session_id: session, session_started_at: endedAt - wallMs }), endedAt, null, endedAt) + if (cpuMs !== null) { + await recordSessionEnd(db, row({ session_id: session }), endedAt, { sessionId: session, status: 'stopped', activeCpuMs: cpuMs, durationMs: wallMs, vcpus, memoryMb: null, stoppedAt: endedAt }, endedAt) + } + } + + it('averages active CPU over wall × vCPUs per account and flags only above 60%', async () => { + const { db } = createFakeD1() + const now = 100 * 24 * 60 * MIN + const hour = 60 * MIN + await seed(db, 'org:heavy', 'h1', hour, 8, 6 * hour, now - hour) // 75% + await seed(db, 'org:heavy', 'h2', hour, 8, 4 * hour, now - 2 * hour) // 50% → combined 62.5% + await seed(db, 'org:edge', 'e1', hour, 8, 4.8 * hour, now - hour) // exactly 60%: not over + await seed(db, 'user:light', 'l1', 2 * hour, 4, 0.8 * hour, now - hour) // 10% + await seed(db, 'user:light', 'old', hour, 8, 8 * hour, now - 31 * 24 * hour) // outside the window + await seed(db, 'user:light', 'pending', hour, 8, null, now - hour) // no CPU yet: excluded + const report = await fairUseReport(db, now) + expect(report).toMatchObject({ windowDays: 30, threshold: FAIR_USE_THRESHOLD, pendingSessions: 1 }) + expect(report.accounts.map((a) => [a.accountId, a.sessions, a.cpuUtilization, a.fairUseFlag])).toEqual([ + ['org:heavy', 2, 0.625, true], + ['org:edge', 1, 0.6, false], + ['user:light', 1, 0.1, false] + ]) + expect(report.accounts[0]).toMatchObject({ activeCpuMs: 10 * hour, vcpuMs: 16 * hour }) + }) +}) + +describe('fair-use auth gate', () => { + it('refuses customer tokens, wrong tokens and an unset operator token; accepts the operator', async () => { + const h = await createHarness({ OPERATOR_TOKEN: OPERATOR }) + expect((await h.call('GET', '/v1/internal/fair-use')).status).toBe(404) + expect((await h.call('GET', '/v1/internal/fair-use', { bearer: 'x'.repeat(40) })).status).toBe(404) + expect((await h.call('POST', '/v1/internal/session-cpu/sweep')).status).toBe(404) + expect((await operator(h, 'GET', '/v1/internal/fair-use')).body).toMatchObject({ windowDays: 30, accounts: [] }) + expect((await operator(h, 'GET', '/v1/internal/fair-use?days=0')).status).toBe(400) + + const closed = await createHarness() + const response = await closed.send( + new Request('https://sandbox.test/v1/internal/fair-use', { headers: { authorization: `Bearer ${OPERATOR}` } }) + ) + expect(response.status).toBe(404) + }) +}) diff --git a/cloud/apps/sandbox-control/test/workspaces.test.ts b/cloud/apps/sandbox-control/test/workspaces.test.ts index 32f1140a847..7b090930f83 100644 --- a/cloud/apps/sandbox-control/test/workspaces.test.ts +++ b/cloud/apps/sandbox-control/test/workspaces.test.ts @@ -81,6 +81,72 @@ describe('workspace registry', () => { expect((await t.req(t.admin, 'POST', `/v1/sandboxes/${t.id}/resume`)).status).toBe(404) }) + it('archives for real from the team list: the sandbox stops with a snapshot TTL; unarchive leaves it asleep', async () => { + const t = await team() + const name = `orca-test-${t.id}` + expect(t.h.vercel.boxes.get(name)!.status).toBe('running') + const archived = await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: true }) + expect(archived.status).toBe(200) + expect(archived.body.workspace.archivedAt).toBe(t.h.now()) + expect(t.h.vercel.boxes.get(name)!.status).toBe('stopped') + expect(t.h.raw.prepare('SELECT status, archive_expires_at FROM sandboxes WHERE id = ?').get(t.id)).toMatchObject({ + status: 'archived' + }) + expect(t.h.vercel.snapshotExpirations.get(name)).toBe(37 * 24 * 60 * MIN) + const restored = await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: false }) + expect(restored.body.workspace.archivedAt).toBeNull() + expect(t.h.raw.prepare('SELECT status FROM sandboxes WHERE id = ?').get(t.id)).toEqual({ status: 'suspended' }) + // Restoring does not start (or bill) the sandbox; opening it wakes it as usual. + expect(t.h.vercel.boxes.get(name)!.status).toBe('stopped') + expect(t.h.vercel.snapshotExpirations.get(name)).toBe(104 * 24 * 60 * MIN) + }) + + it('refuses to archive a sandbox that is starting or mid-transition, and leaves the row unchanged', async () => { + const t = await team() + for (const status of ['creating', 'suspending']) { + t.h.raw.prepare(`UPDATE sandboxes SET status = ? WHERE id = ?`).run(status, t.id) + const busy = await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: true }) + expect(busy).toMatchObject({ status: 409, body: { reason: `sandbox_${status}` } }) + } + t.h.raw.prepare(`UPDATE sandboxes SET status = 'resuming' WHERE id = ?`).run(t.id) + const res = await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: true }) + expect(res).toMatchObject({ status: 409, body: { error: 'conflict', reason: 'sandbox_resuming' } }) + expect(t.h.raw.prepare('SELECT status, archived_at FROM sandboxes WHERE id = ?').get(t.id)).toEqual({ + status: 'resuming', + archived_at: null + }) + }) + + it('archives an asleep sandbox without waking it', async () => { + const t = await team() + await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/suspend`) + const { START_COMMAND } = await import('../src/sandbox-vm-commands') + const starts = t.h.vercel.commands.filter((c) => c.command === START_COMMAND).length + const res = await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: true }) + expect(res.status).toBe(200) + expect(t.h.raw.prepare('SELECT status FROM sandboxes WHERE id = ?').get(t.id)).toEqual({ status: 'archived' }) + expect(t.h.vercel.commands.filter((c) => c.command === START_COMMAND)).toHaveLength(starts) + expect(t.h.vercel.boxes.get(`orca-test-${t.id}`)!.status).toBe('stopped') + }) + + it('never labels a running sandbox archived when the archive itself fails', async () => { + const t = await team() + const { ProviderError } = await import('../src/sandbox-provider') + t.h.vercel.stopSession = async () => { + throw new ProviderError(500, 'internal_error') + } + const res = await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: true }) + expect(res.status).toBeGreaterThanOrEqual(500) + expect((t.h.raw.prepare('SELECT archived_at FROM sandboxes WHERE id = ?').get(t.id) as { archived_at: number | null }).archived_at).toBeNull() + }) + + it('only the owner or a team admin can archive', async () => { + const t = await team() + await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' }) + expect((await t.req(t.bob, 'PATCH', `/v1/team/workspaces/${t.id}`, { archived: true })).status).toBe(403) + expect(t.h.vercel.boxes.get(`orca-test-${t.id}`)!.status).toBe('running') + }) + it('reports cost from metered compute at the billing rate, plus reported token spend', async () => { const t = await team() t.h.advance(90 * MIN) diff --git a/cloud/apps/sandbox-control/vitest.config.ts b/cloud/apps/sandbox-control/vitest.config.ts index 64447de6145..49d1e4e5c27 100644 --- a/cloud/apps/sandbox-control/vitest.config.ts +++ b/cloud/apps/sandbox-control/vitest.config.ts @@ -1,7 +1,10 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' export default defineConfig({ // A string tsconfig skips the lookup, which otherwise climbs to the desktop app's tsconfig. oxc: { tsconfig: false }, + // workerd-only module (BuildTicker's base class); see the stub. + resolve: { alias: { 'cloudflare:workers': fileURLToPath(new URL('./test/cloudflare-workers-stub.ts', import.meta.url)) } }, test: { include: ['test/**/*.test.ts', 'image/**/*.test.ts'] } }) diff --git a/cloud/apps/sandbox-control/wrangler.build-runner.dev.jsonc b/cloud/apps/sandbox-control/wrangler.build-runner.dev.jsonc new file mode 100644 index 00000000000..45f8c162ebc --- /dev/null +++ b/cloud/apps/sandbox-control/wrangler.build-runner.dev.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // DEV deployment of the environment build runner. Deploy it BEFORE the control plane: + // wrangler deploy -c wrangler.build-runner.dev.jsonc + // It hosts the EnvironmentBuildRunner Durable Object, which the control plane (wrangler.dev.jsonc) + // binds with script_name. Cloudflare generates no preview URLs for a Worker that implements a Durable + // Object, so the class lives here and the control plane keeps its preview aliases. The runner only + // keeps alarms: every build step runs in the control plane over the CONTROL RPC binding (BuildTicker + // in src/index.ts), so it has no D1, no vars and no secrets. See src/build-runner.ts. + "name": "orca-sandbox-build-runner-dev", + "main": "src/build-runner.ts", + "compatibility_date": "2026-11-01", + "workers_dev": false, + "preview_urls": false, + "services": [{ "binding": "CONTROL", "service": "orca-sandbox-control-dev", "entrypoint": "BuildTicker" }], + "migrations": [{ "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }] +} diff --git a/cloud/apps/sandbox-control/wrangler.build-runner.jsonc b/cloud/apps/sandbox-control/wrangler.build-runner.jsonc new file mode 100644 index 00000000000..8396dc57987 --- /dev/null +++ b/cloud/apps/sandbox-control/wrangler.build-runner.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // Local `wrangler dev` only (with wrangler.jsonc: `pnpm dev`): the environment build runner Worker. + // It hosts the EnvironmentBuildRunner Durable Object and nothing else; every build step runs in the + // control plane over the CONTROL RPC binding. See src/build-runner.ts. + "name": "orca-sandbox-build-runner-local", + "main": "src/build-runner.ts", + "compatibility_date": "2026-11-01", + "workers_dev": false, + "services": [{ "binding": "CONTROL", "service": "orca-sandbox-control-local", "entrypoint": "BuildTicker" }], + "migrations": [{ "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }] +} diff --git a/cloud/apps/sandbox-control/wrangler.dev.jsonc b/cloud/apps/sandbox-control/wrangler.dev.jsonc index 5ed1f29ddd6..935681a8b03 100644 --- a/cloud/apps/sandbox-control/wrangler.dev.jsonc +++ b/cloud/apps/sandbox-control/wrangler.dev.jsonc @@ -24,12 +24,24 @@ // Same-account Workers cannot call each other over workers.dev (error 1042); BILLING_URL names the origin. "services": [{ "binding": "BILLING", "service": "orca-billing-dev" }], // Every minute for routines (src/routines); the rest of the cron work runs on minute % 5 == 0. - // Environment builds advance on Durable Object alarms (EnvironmentBuildRunner), not on readers. - "durable_objects": { "bindings": [{ "name": "ENV_BUILD_RUNNER", "class_name": "EnvironmentBuildRunner" }] }, - "migrations": [{ "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }], + // Environment builds advance on Durable Object alarms (EnvironmentBuildRunner), not on readers. The + // runner is its own Worker (wrangler.build-runner.dev.jsonc, deployed first): a Worker implementing a + // Durable Object gets no preview URLs. v2 deletes the class this Worker hosted until then; its only + // state was each build's alarm, and the cron below re-arms every unfinished build within a minute. + // v2 below was applied in a deploy with no binding (Cloudflare error 10061 refuses a delete-class migration + // while any binding names the class, even one with script_name); the binding now points at the runner Worker. + "durable_objects": { + "bindings": [{ "name": "ENV_BUILD_RUNNER", "class_name": "EnvironmentBuildRunner", "script_name": "orca-sandbox-build-runner-dev" }] + }, + "migrations": [ + { "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }, + { "tag": "env-build-runner-v2", "deleted_classes": ["EnvironmentBuildRunner"] } + ], "triggers": { "crons": ["* * * * *"] }, "vars": { "SANDBOX_ENV": "dev", + // Flag S1 (contract D10/D11): Claude via the person's Orca account. Dev only until Neil's S1 sign-off. + "AGENT_ACCOUNTS_CLAUDE": "on", "SANDBOX_POOL_SIZE": "2", // Dev only (ignored unless SANDBOX_ENV is dev): these dev accounts hit the real billing gate. "SANDBOX_DEV_BILLING_ENFORCE_PREFIX": "dev:billing-test-", diff --git a/cloud/apps/sandbox-control/wrangler.jsonc b/cloud/apps/sandbox-control/wrangler.jsonc index ca49d210dbc..327708bb86c 100644 --- a/cloud/apps/sandbox-control/wrangler.jsonc +++ b/cloud/apps/sandbox-control/wrangler.jsonc @@ -17,9 +17,11 @@ } ], // Usage settlement, lease/deadline enforcement and stuck-row reconciliation. - // Environment builds advance on Durable Object alarms (EnvironmentBuildRunner), not on readers. - "durable_objects": { "bindings": [{ "name": "ENV_BUILD_RUNNER", "class_name": "EnvironmentBuildRunner" }] }, - "migrations": [{ "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }], + // Environment builds advance on Durable Object alarms (EnvironmentBuildRunner), not on readers. The + // runner is its own Worker (wrangler.build-runner.jsonc; `pnpm dev` runs both), see src/build-runner.ts. + "durable_objects": { + "bindings": [{ "name": "ENV_BUILD_RUNNER", "class_name": "EnvironmentBuildRunner", "script_name": "orca-sandbox-build-runner-local" }] + }, "triggers": { "crons": ["*/5 * * * *"] }, "vars": { "SANDBOX_ENV": "local", diff --git a/cloud/docs/version-skew.md b/cloud/docs/version-skew.md new file mode 100644 index 00000000000..5c016297e26 --- /dev/null +++ b/cloud/docs/version-skew.md @@ -0,0 +1,54 @@ +## Version skew (desktop vs the orcad in a sandbox) + +Runbook section for `.context/cloud/launch-runbook.md` (kept here so it is reviewed with the code). + +**How the golden tracks integration.** With `GOLDEN_IMAGE_SOURCE=git:https://github.com/nwparker/orca-private#cloud/integration` +(dev today), the per-minute cron (`advanceGoldenImages`, one state machine per region) rebuilds orcad + CLI from +cloud/integration HEAD whenever HEAD changes VM paths (`golden-source.ts` `VM_PATHS`: `src/main|shared|cli|relay|orcad`, +`packages/`, `mobile/`, the image dir, the lockfile). Build, smoke (orcad ready), then an atomic swap: the new row +becomes `active`, the previous one `retired` (still bootable), and older retired rows are deleted. The commit is the +row's `bundle_sha256` (`git:`) and `/opt/orca/runtime/orcad/SOURCE_COMMIT` in the VM. Only **new** sandboxes boot +the new image. Sleep, wake, Restart and the 24 h restart all boot the sandbox's own disk, so a workspace keeps the +orcad it was created with (VM entry-point scripts are synced on boot; orcad is not, and there is no orcad self-update +in the VM). A desktop release can therefore be older or newer than any sandbox. + +**Which build a sandbox runs.** `orca status --json` against the workspace's environment shows `runtime.appVersion`, +`runtime.buildCommit` (from SOURCE_COMMIT, else the bundle's package stamp) and `runtime.runtimeProtocolVersion` / +`runtime.minCompatibleRuntimeClientVersion` (all from `status.get`). In the VM: `cat /opt/orca/runtime/orcad/SOURCE_COMMIT`. +Golden per region: `SELECT id, region, status, bundle_sha256, snapshot_id, expires_at, activated_at FROM golden_images +WHERE status IN ('active','retired')`. + +**Policy.** Compatibility is the runtime protocol range in `src/shared/protocol-version.ts`, never commits or app +versions. The desktop speaks `RUNTIME_PROTOCOL_VERSION` and accepts sandboxes at or above +`MIN_COMPATIBLE_RUNTIME_SERVER_VERSION`; orcad publishes its protocol and the minimum client it accepts +(`MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION`). Inside the range: proceed silently (normal skew). Outside it: the desktop's +existing RPC gate refuses calls (that is the unsafe case) and the workspace shows a notice. Rules for changes: +- Additive RPC changes (new methods, optional fields, ignorable events) bump nothing. +- Bump `RUNTIME_PROTOCOL_VERSION` only for removed methods, new required params or changed meaning. +- Never raise `MIN_COMPATIBLE_RUNTIME_SERVER_VERSION` above the protocol of live sandboxes (they cannot upgrade in + place) unless we accept that those workspaces must be recreated. +- Raising `MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION` strands every desktop below it until its user updates. + +**What users see** (only outside the range): a quiet, dismissible card at the top right of the cloud workspace, never +a cover. +- Desktop too old: "This cloud workspace uses a newer version of Orca" / "Update Orca on this computer to connect to + it. Your files are safe." Button: "Check for updates". +- Sandbox too old: "This cloud workspace uses an older version of Orca" / "This version of Orca can't connect to it, + and restarting won't update it. Your files are safe. New cloud workspaces use the current version." Button: + "Create workspace" (composer preset to Orca Cloud and the same repository). +- Builds and protocol numbers go to the renderer console (`[orca-cloud] version skew`), never the card. + +**Roll a golden back** (a bad build reached `active` in a region). D1 `orca-sandbox-control-`, via +`wrangler d1 execute --remote --command ""`: +1. Stop the cron rebuilding the same HEAD: mark the bad row failed. The cron skips a failed sha for 6 h. + `UPDATE golden_images SET status='failed', error='rolled back', updated_at= WHERE id=''`. + For longer than 6 h, revert the bad commit on cloud/integration (HEAD moves, the next build replaces it) or unset + `GOLDEN_IMAGE_SOURCE` and redeploy with `python3 ~/.orca-cloud-ops/deploy-control.py ""`. +2. Reactivate the retired image: `UPDATE golden_images SET status='active', activated_at=, updated_at= + WHERE id='' AND region=''`. Check its `expires_at` is in the future first; an expired snapshot + cannot boot (fall back to the seed `GOLDEN_SNAPSHOT_ID` or a fresh build). +3. Verify: create a workspace in that region, orcad becomes ready, and `orca status --json` shows the old `buildCommit`. + Warm pool VMs from the bad image are replaced on the next refill (the pool keys VMs by image). +4. Sandboxes created on the bad image keep it (no in-place downgrade); recreate them if the bug affects them. + Environment builds made on the bad golden are stale against the restored one (`base_snapshot_id` differs), so + `refreshStaleEnvironments` rebuilds them in the background; their workspaces, like any sandbox, keep their disk. diff --git a/docs/site/content/docs/model/orca-yaml.mdx b/docs/site/content/docs/model/orca-yaml.mdx index 49bf4c2ca5a..3ce562086f9 100644 --- a/docs/site/content/docs/model/orca-yaml.mdx +++ b/docs/site/content/docs/model/orca-yaml.mdx @@ -129,11 +129,11 @@ scripts: - Ids may use letters, numbers, spaces, dots, underscores, and hyphens. Hyphens show as spaces when `name` is omitted. - `cwd` is relative to the worktree. Absolute paths and `..` are ignored. - **Stop** sends an interrupt (Ctrl+C). If the command is still running after 5 seconds, Orca closes its terminal, which ends every process the shell started. **Restart** stops the action and starts it again in a fresh terminal. -- `runMode: concurrent` lets the same action run in several worktrees at once. `nonconcurrent` stops every other running action of the project first; use it when worktrees share one fixed port, database, or container stack. An action's own `runMode` overrides the project setting. +- `runMode: concurrent` lets the same action run in several worktrees at once. `nonconcurrent` first stops that action in the project's other worktrees (the project's other actions keep running); use it when worktrees share one fixed port, database, or container stack. An action's own `runMode` overrides the project setting. - `autoRunAfterSetup` starts the action once the Setup terminal's script succeeds. - Shared `orca.yaml` actions go through the same approval prompt as setup scripts. Actions imported into local settings are treated as your own. -Every terminal in a worktree gets a stable block of 10 ports: `ORCA_PORT` through `ORCA_PORT+9` (`ORCA_PORT_COUNT` is `10`). Two worktrees on the same host never share a block, so the same dev server can run in each without port collisions. `CONDUCTOR_PORT` is set to the same value, so imported Conductor scripts work unchanged. Ports a run opens appear in the Run menu and the **Ports** panel. +Every terminal in a worktree gets a stable block of 10 ports: `ORCA_PORT` through `ORCA_PORT+9` (`ORCA_PORT_COUNT` is `10`). Two worktrees on the same host never share a block (when a host launcher already sets `ORCA_PORT`, the first workspace keeps that block), so the same dev server can run in each without port collisions. `CONDUCTOR_PORT` is set to the same value, so imported Conductor scripts work unchanged. Ports a run opens appear in the Run menu and the **Ports** panel. ### Terminal defaults and issue commands diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index e24af3d908a..f7613d03e51 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,4 +1,5 @@ import { useLocalSearchParams } from 'expo-router' +import { CloudHostAsleepGate } from '../../../src/cloud/CloudHostAsleepGate' import { firstParam } from '../../../src/navigation/route-param-reader' import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen' import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route' @@ -24,14 +25,17 @@ function HostListScreen() { return } return ( - + // An asleep cloud workspace's VM is not an offline computer: say so and offer Wake first. + + + ) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index fdf8234cbd5..aa13ee51aaa 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,3 +1,4 @@ +import { CloudHostAsleepGate } from '../../../../src/cloud/CloudHostAsleepGate' import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route' import { useWorkspaceExecutionHost, @@ -68,11 +69,13 @@ export default withWorkspaceRoute(function MobileSessionScreen() { // Keep shared shellScreenRouteKey unchanged because other screens treat param changes as identity changes. const { paneKey: _paneKey, paneView: _paneView, ...identity } = routeParams return ( - + + + ) }) diff --git a/mobile/src/cloud/CloudAccessScreen.tsx b/mobile/src/cloud/CloudAccessScreen.tsx index 6076113a714..e180223bfa2 100644 --- a/mobile/src/cloud/CloudAccessScreen.tsx +++ b/mobile/src/cloud/CloudAccessScreen.tsx @@ -178,9 +178,19 @@ export function CloudAccessScreen() { }) } onPress={() => setConfirm({ id: session.id, label: session.label, self: false })} - accessibilityLabel={t('cloud.access.removeLabel', 'Remove {{device}}', { - device: session.label - })} + // Who it is and when it was last used; what a tap does goes in the hint. + accessibilityLabel={ + session.lastUsedAt + ? t('cloud.access.deviceLabel', '{{device}}, last used {{date}}', { + device: session.label, + date: formatDay(session.lastUsedAt) + }) + : t('cloud.access.deviceLabelConnected', '{{device}}, connected {{date}}', { + device: session.label, + date: formatDay(session.createdAt) + }) + } + accessibilityHint={t('cloud.access.removeHint', 'Removes this phone’s access')} /> )) )} diff --git a/mobile/src/cloud/CloudHostAsleepGate.tsx b/mobile/src/cloud/CloudHostAsleepGate.tsx new file mode 100644 index 00000000000..1ae3c01e354 --- /dev/null +++ b/mobile/src/cloud/CloudHostAsleepGate.tsx @@ -0,0 +1,74 @@ +import { useRouter } from 'expo-router' +import { Moon } from 'lucide-react-native' +import { useEffect, useState, type ReactNode } from 'react' +import { ActivityIndicator, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { MobileStateView } from '../components/MobileStateView' +import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { SettingsNavBar } from '../settings/settings-screen-frame' +import { colors } from '../theme/mobile-theme' +import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { cloudHostGate } from './cloud-screen-states' +import { cloudWorkspaceIdForHost } from './cloud-workspace-links' +import { useCloudEnrollment, useCloudWorkspace } from './use-cloud-workspaces' + +/** + * A cloud workspace's VM is a paired host, but an asleep one is not "a computer that's offline": + * before its page loads, say it's asleep and offer Wake (the normal wake → access → connect flow, + * which lands back on this host). Every other host, and a cloud host that is up, passes through. + * One component with fixed hooks, and the page is mounted only once the answer is known, so it is + * never mounted and then thrown away. + */ +export function CloudHostAsleepGate(props: { hostId: string; children: ReactNode }) { + useMobileColorScheme() + const t = useMobileTranslation() + const router = useRouter() + // The answer is kept with the host it was looked up for: a gate reused for another host waits + // instead of reading the previous host's workspace for a render. + const [lookup, setLookup] = useState<{ hostId: string; sandboxId: string | null } | null>(null) + useEffect(() => { + let live = true + const hostId = props.hostId + void cloudWorkspaceIdForHost(hostId) + .then((id) => live && setLookup({ hostId, sandboxId: id })) + .catch(() => live && setLookup({ hostId, sandboxId: null })) + return () => { + live = false + } + }, [props.hostId]) + const sandboxId = lookup?.hostId === props.hostId ? lookup.sandboxId : undefined + const enrollment = useCloudEnrollment() + const listed = useCloudWorkspace(enrollment, sandboxId ?? '') + const gate = cloudHostGate({ + sandboxId, + enrollment, + isPending: listed.isPending, + isError: listed.isError, + status: listed.workspace?.status ?? null + }) + if (gate === 'pass') { + return props.children + } + if (gate === 'wait') { + return ( + + + + ) + } + const sleeping = listed.workspace + return ( + + router.back()} /> + router.replace(`/cloud/${encodeURIComponent(sandboxId ?? '')}?autostart=1`) + }} + /> + + ) +} diff --git a/mobile/src/cloud/CloudStatusCard.tsx b/mobile/src/cloud/CloudStatusCard.tsx index 4917bd9db42..858b91457f4 100644 --- a/mobile/src/cloud/CloudStatusCard.tsx +++ b/mobile/src/cloud/CloudStatusCard.tsx @@ -67,11 +67,9 @@ export function CloudStatusCard(props: { - {active - ? t('cloud.status.waking', 'Waking') - : notConnected - ? t('cloud.status.cantConnect', 'Can’t connect') - : cloudStatusLabel(workspace.status, t)} + {/* Only glossary states: a running workspace this phone can't reach still reads + "Running" (with the warning dot); the error banner below says what went wrong. */} + {active ? t('cloud.status.waking', 'Waking') : cloudStatusLabel(workspace.status, t)} {active ? ( diff --git a/mobile/src/cloud/CloudWorkspaceScreen.tsx b/mobile/src/cloud/CloudWorkspaceScreen.tsx index e3df6f4123e..83ecfff3a70 100644 --- a/mobile/src/cloud/CloudWorkspaceScreen.tsx +++ b/mobile/src/cloud/CloudWorkspaceScreen.tsx @@ -1,4 +1,5 @@ import { useRouter } from 'expo-router' +import { Cloud } from 'lucide-react-native' import { useCallback, useEffect, useRef, useState } from 'react' import { ActivityIndicator, @@ -23,7 +24,11 @@ import { CloudPromptQueueSection } from './CloudPromptQueueSection' import { CloudReadSnapshotSection } from './CloudReadSnapshotSection' import { CloudStatusCard } from './CloudStatusCard' import { CloudWorkspaceActions } from './CloudWorkspaceActions' -import { archiveCloudWorkspace, sleepCloudWorkspace } from './cloud-control-plane-client' +import { + archiveCloudWorkspace, + restartCloudWorkspace, + sleepCloudWorkspace +} from './cloud-control-plane-client' import { CloudRequestError } from './cloud-control-plane-request' import { enqueueCloudPrompt, @@ -37,6 +42,8 @@ import { useCloudHostRemoval } from './cloud-session' import { attachErrorMessage, phaseLabel } from './cloud-workspace-copy' +import { setCloudReturnTo } from './cloud-return-to' +import { cloudMissingState } from './cloud-screen-states' import { cloudRepoLine } from './cloud-workspace-display' import { cloudScrollBottomPadding, @@ -66,8 +73,14 @@ export function CloudWorkspaceScreen({ const insets = useSafeAreaInsets() const removal = useCloudHostRemoval() const enrollment = useCloudEnrollment() - const { workspace, isPending, isFetching, refetch } = useCloudWorkspace(enrollment, sandboxId) - const live = useCloudWorkspaceLiveStatus(sandboxId, workspace?.status === 'running') + const listed = useCloudWorkspace(enrollment, sandboxId) + const { isPending, isFetching, isError, refetch } = listed + const live = useCloudWorkspaceLiveStatus(sandboxId, listed.workspace?.status === 'running') + // The registry only knows "running"; the live status knows when it is up but not answering. + const workspace = + listed.workspace?.status === 'running' && live.data?.state === 'unresponsive' + ? { ...listed.workspace, status: 'unresponsive' as const } + : listed.workspace // Asleep: read its last sealed snapshot instead of waking it. Nothing here ever wakes it. const asleep = workspace?.status === 'sleeping' const snapshot = useCloudReadSnapshot(sandboxId, asleep, workspace?.readSnapshotAt ?? null) @@ -85,7 +98,7 @@ export function CloudWorkspaceScreen({ // keychain), the button already reads Waking rather than keeping "Wake to continue". const [wakeTappedAt, setWakeTappedAt] = useState(null) const wakePending = wakeTappedAt !== null && wakeTappedAt === run - const [busyAction, setBusyAction] = useState<'sleep' | 'archive' | null>(null) + const [busyAction, setBusyAction] = useState<'sleep' | 'archive' | 'restart' | null>(null) useEffect(() => { const load = () => @@ -129,7 +142,7 @@ export function CloudWorkspaceScreen({ ) const lifecycle = useCallback( - async (action: 'sleep' | 'archive') => { + async (action: 'sleep' | 'archive' | 'restart') => { const plane = await loadCloudControlPlane() if (!plane || !workspace) { return @@ -137,7 +150,9 @@ export function CloudWorkspaceScreen({ clearError() setBusyAction(action) try { - if (action === 'sleep') { + if (action === 'restart') { + await restartCloudWorkspace(plane, workspace.id) + } else if (action === 'sleep') { await sleepCloudWorkspace(plane, workspace.id) } else { await archiveCloudWorkspace(plane, workspace.id) @@ -146,12 +161,17 @@ export function CloudWorkspaceScreen({ } catch (error) { await forgetRevokedCloudSession(error, removal) fail( - action === 'archive' && error instanceof CloudRequestError && error.status === 404 + action === 'restart' && error instanceof CloudRequestError && error.status === 404 ? t( - 'cloud.error.archiveUnavailable', - 'Archiving isn’t available yet. Put it to sleep instead.' + 'cloud.error.restartOwner', + 'Only the workspace’s owner can restart it. Your files are safe.' ) - : attachErrorMessage(error, t) + : action === 'archive' && error instanceof CloudRequestError && error.status === 404 + ? t( + 'cloud.error.archiveUnavailable', + 'Archiving isn’t available yet. Put it to sleep instead.' + ) + : attachErrorMessage(error, t) ) } finally { setBusyAction(null) @@ -162,6 +182,7 @@ export function CloudWorkspaceScreen({ ) if (!workspace) { + const missing = cloudMissingState({ enrollment, isPending, isFetching, isError }) return ( router.back()} /> - {isPending || isFetching || enrollment === undefined ? ( + {missing === 'signin' ? ( + // A share link opened on a phone that isn't connected yet: the list query never runs. + <> + + {t('cloud.workspace.signIn', 'Sign in to Orca Cloud on this phone')} + + { + // Connect on Home, then come straight back to this workspace. + setCloudReturnTo(`/cloud/${sandboxId}`) + router.replace('/') + }} + style={styles.linkButton} + > + {t('cloud.workspace.signInAction', 'Sign in')} + + + ) : missing === 'loading' ? ( ) : ( <> - {t( - 'cloud.workspace.missing', - 'This workspace is gone or you no longer have access.' - )} + {missing === 'unreachable' + ? t( + 'cloud.workspace.unreachable', + 'Couldn’t reach Orca Cloud. Check your connection and try again.' + ) + : t( + 'cloud.workspace.missing', + 'This workspace is gone or you no longer have access.' + )} - router.back()} /> + } + onBack={() => router.back()} + /> void lifecycle('sleep')} onArchive={() => void lifecycle('archive')} @@ -327,6 +386,10 @@ export function CloudWorkspaceScreen({ openSession('chat') return } + if (restartable) { + void lifecycle('restart') + return + } setWakeTappedAt(run) // A changed snapshot signer means a rebuilt sandbox: pair afresh, not the saved host. void start('open', { freshPairing: signerChanged }).finally(() => diff --git a/mobile/src/cloud/NewCloudWorkspaceScreen.tsx b/mobile/src/cloud/NewCloudWorkspaceScreen.tsx index a24a3b62605..7c7fbb55e9e 100644 --- a/mobile/src/cloud/NewCloudWorkspaceScreen.tsx +++ b/mobile/src/cloud/NewCloudWorkspaceScreen.tsx @@ -41,7 +41,12 @@ import { import { nextCloudWorkspaceTitle } from './cloud-workspace-display' import { roleForCloudError } from './cloud-entitlement-copy' import { createErrorMessage } from './cloud-workspace-copy' -import { ORCA_CLOUD_SANDBOX_SPEC } from '../../../src/shared/hosted-sandbox/hosted-sandbox-wire' +import { + ORCA_CLOUD_IDLE_SLEEP_MINUTES, + ORCA_CLOUD_SANDBOX_SPEC +} from '../../../src/shared/hosted-sandbox/hosted-sandbox-wire' +import { cloudRepoPickerRows } from './cloud-repo-picker-rows' +import { cloudRepoChoices, loadRecentCloudRepos, recordRecentCloudRepo } from './cloud-recent-repos' export function NewCloudWorkspaceScreen() { useMobileColorScheme() @@ -51,6 +56,18 @@ export function NewCloudWorkspaceScreen() { const invalidate = useInvalidateCloudWorkspaces() const existing = useCloudWorkspaces(useCloudEnrollment()).data const [repo, setRepo] = useState('') + const [recents, setRecents] = useState([]) + // "Other repository…": the free-text field, also the only field when there is nothing to pick. + const [typing, setTyping] = useState(false) + useEffect(() => { + void loadRecentCloudRepos().then(setRecents) + }, []) + const choices = cloudRepoChoices( + recents, + (existing ?? []).flatMap((workspace) => (workspace.repo ? [workspace.repo.url] : [])) + ) + // The field stays while what's typed isn't one of the rows, even if the rows load mid-typing. + const showField = typing || choices.length === 0 || (repo !== '' && !choices.includes(repo)) const [branch, setBranch] = useState('') const [agent, setAgent] = useState(ORCA_CLOUD_DEFAULT_AGENT) const [prompt, setPrompt] = useState('') @@ -125,6 +142,9 @@ export function NewCloudWorkspaceScreen() { } // The sandbox exists from here on: nothing below may report "couldn't create" or create again. const { projectRoot } = created + if (repoUrl) { + void recordRecentCloudRepo(repoUrl).catch(() => undefined) + } const workspace = { ...created.workspace, title: nextCloudWorkspaceTitle(created.workspace.title, existing ?? []) @@ -169,12 +189,18 @@ export function NewCloudWorkspaceScreen() { > 0} title={t('cloud.new.repoHeading', 'Repository')} footer={ repoInvalid ? ( {t('cloud.new.repoInvalid', 'Use a GitHub repository, like owner/repo.')} + ) : choices.length > 0 ? ( + t( + 'cloud.new.repoNotePicker', + 'Private repos need the Orca Cloud GitHub app, connected once from Orca on your computer.' + ) ) : ( t( 'cloud.new.repoNote', @@ -183,20 +209,47 @@ export function NewCloudWorkspaceScreen() { ) } > - - - + {cloudRepoPickerRows({ + choices, + repo, + typing, + disabled: creating, + t, + onPick: (next) => { + setTyping(false) + setRepo(next) + }, + onOther: () => { + // Choosing it again keeps what was typed. + if (!typing) { + setTyping(true) + setRepo('') + } + } + })} + {showField ? ( + + { + setRepo(text) + // Typing here is choosing "Other repository…", even if the rows load mid-typing. + if (choices.length > 0) { + setTyping(true) + } + }} + editable={!creating} + autoFocus={typing} + placeholder={t('cloud.new.repoPlaceholder', 'owner/repo')} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + accessibilityLabel={t('cloud.new.repoLabel', 'GitHub repository')} + /> + + ) : null} @@ -216,10 +269,22 @@ export function NewCloudWorkspaceScreen() { + + {t('cloud.size.note', 'Every sandbox gets {{vcpus}} vCPU · {{memoryGb}} GB.', { + vcpus: String(ORCA_CLOUD_SANDBOX_SPEC.vcpus), + memoryGb: String(ORCA_CLOUD_SANDBOX_SPEC.memoryGb) + })} + + + {t('cloud.idle.sleeps', 'Sleeps after {{count}} minutes idle.', { + count: ORCA_CLOUD_IDLE_SLEEP_MINUTES, + defaultValue_one: 'Sleeps after {{count}} minute idle.' + })} + + + } > diff --git a/mobile/src/cloud/cloud-control-plane-client.ts b/mobile/src/cloud/cloud-control-plane-client.ts index 29086df890d..33043dc67d0 100644 --- a/mobile/src/cloud/cloud-control-plane-client.ts +++ b/mobile/src/cloud/cloud-control-plane-client.ts @@ -27,6 +27,9 @@ export type CloudWorkspaceStatus = | 'sleeping' | 'archived' | 'failed' + /** Up but not answering (glossary "Not responding"); a restart usually fixes it. */ + | 'unresponsive' + | 'deleting' export type CloudWorkspace = { /** The sandbox row id: stable across sleep, wake and rollover. */ @@ -57,8 +60,8 @@ const SANDBOX_STATUS: Record = { sleeping: 'sleeping', archived: 'archived', failed: 'failed', - unresponsive: 'failed', - destroying: 'stopping' + unresponsive: 'unresponsive', + destroying: 'deleting' } const SandboxViewSchema = z.object({ @@ -200,6 +203,20 @@ export async function sleepCloudWorkspace( ) } +/** "Not responding": restart the sandbox (files kept). Owner route; a teammate gets not_found. */ +export async function restartCloudWorkspace( + plane: CloudControlPlane, + sandboxId: string +): Promise { + await request( + plane, + 'POST', + `/v1/sandboxes/${encodeURIComponent(sandboxId)}/restart`, + z.unknown(), + { body: {}, timeoutMs: 120_000 } + ) +} + export async function archiveCloudWorkspace( plane: CloudControlPlane, sandboxId: string diff --git a/mobile/src/cloud/cloud-entitlement-copy.test.ts b/mobile/src/cloud/cloud-entitlement-copy.test.ts index 362641a6dde..199ca63e10a 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.test.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.test.ts @@ -21,13 +21,15 @@ describe('entitlementDeniedMessage', () => { expect( entitlementDeniedMessage(denied('included_hours_exhausted'), 'admin', 'existing', t) ).toBe( - 'Out of sandbox hours. Your files are safe. Turn on overage in Orca on your computer, then try again.' + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage in Orca on your computer, then try again.' ) expect( entitlementDeniedMessage(denied('included_hours_exhausted'), 'member', 'existing', t) - ).toBe('Out of sandbox hours. Your files are safe. Ask an owner or admin to add hours.') + ).toBe( + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.' + ) expect(entitlementDeniedMessage(denied('overage_cap_reached'), 'owner', 'create', t)).toBe( - 'Spend cap reached. Nothing was created, and you weren’t charged. Raise the spend cap in Orca on your computer, then try again.' + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the spend cap in Orca on your computer, then try again.' ) expect(entitlementDeniedMessage(denied('payment_grace_expired'), 'member', 'create', t)).toBe( 'Payment failed, so new sandboxes can’t start. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method.' diff --git a/mobile/src/cloud/cloud-entitlement-copy.ts b/mobile/src/cloud/cloud-entitlement-copy.ts index c015fbd7165..a280fc44e18 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.ts @@ -70,9 +70,15 @@ function problem( ): string { switch (denial) { case 'hours': - return t('cloud.denied.hours', 'Out of sandbox hours.') + return t( + 'cloud.denied.hours2', + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start.' + ) case 'cap': - return t('cloud.denied.cap', 'Spend cap reached.') + return t( + 'cloud.denied.cap2', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start.' + ) case 'payment': return t('cloud.denied.payment', 'Payment failed, so new sandboxes can’t start.') case 'plan': @@ -175,9 +181,12 @@ export function entitlementDeniedMessage( return t('cloud.denied.message', '{{problem}} {{safety}} {{next}}', { problem: problem(denial, when, owner, t), safety: - when === 'create' - ? t('cloud.denied.safety.create', 'Nothing was created, and you weren’t charged.') - : t('cloud.denied.safety.existing', 'Your files are safe.'), + // Out of hours or at the cap, running sandboxes go to sleep, so say what that keeps. + denial === 'hours' || denial === 'cap' + ? t('cloud.denied.safety.asleep', 'Nothing is deleted, and your files are safe.') + : when === 'create' + ? t('cloud.denied.safety.create', 'Nothing was created, and you weren’t charged.') + : t('cloud.denied.safety.existing', 'Your files are safe.'), next: nextStep(denial, manager, when, owner, t) }) } diff --git a/mobile/src/cloud/cloud-recent-repos.test.ts b/mobile/src/cloud/cloud-recent-repos.test.ts new file mode 100644 index 00000000000..594c64595e1 --- /dev/null +++ b/mobile/src/cloud/cloud-recent-repos.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const store = vi.hoisted(() => new Map()) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => store.get(key) ?? null, + setItem: async (key: string, value: string) => void store.set(key, value) + } +})) + +import { + cloudRepoChoices, + cloudRepoSlug, + loadRecentCloudRepos, + recordRecentCloudRepo +} from './cloud-recent-repos' + +describe('repository picker choices', () => { + beforeEach(() => store.clear()) + + it('reads owner/repo from GitHub URLs and slugs, nothing else', () => { + expect(cloudRepoSlug('https://github.com/octocat/Hello-World')).toBe('octocat/Hello-World') + expect(cloudRepoSlug('https://github.com/acme/app.git')).toBe('acme/app') + expect(cloudRepoSlug('acme/app')).toBe('acme/app') + expect(cloudRepoSlug('https://gitlab.com/acme/app')).toBeNull() + }) + + it('lists this phone’s recents first, then the team’s projects, each once', async () => { + await recordRecentCloudRepo('https://github.com/acme/app') + await recordRecentCloudRepo('https://github.com/acme/web') + await recordRecentCloudRepo('https://github.com/acme/app') + expect(await loadRecentCloudRepos()).toEqual(['acme/app', 'acme/web']) + expect( + cloudRepoChoices(await loadRecentCloudRepos(), [ + 'https://github.com/acme/web', + 'https://github.com/octocat/Hello-World' + ]) + ).toEqual(['acme/app', 'acme/web', 'octocat/Hello-World']) + }) +}) diff --git a/mobile/src/cloud/cloud-recent-repos.ts b/mobile/src/cloud/cloud-recent-repos.ts new file mode 100644 index 00000000000..4ca8a3cd704 --- /dev/null +++ b/mobile/src/cloud/cloud-recent-repos.ts @@ -0,0 +1,56 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { z } from 'zod' + +// Repositories this phone started cloud workspaces from, newest first, for the new-workspace picker. + +const KEY = 'orca:cloud-recent-repos:v1' +const MAX = 8 + +/** "owner/repo" for a GitHub URL (or an "owner/repo" already), else null. */ +export function cloudRepoSlug(url: string): string | null { + const match = + /^(?:https?:\/\/github\.com\/)?([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec( + url.trim() + ) + return match ? `${match[1]}/${match[2]}` : null +} + +export async function loadRecentCloudRepos(): Promise { + try { + const parsed = z + .array(z.string()) + .safeParse(JSON.parse((await AsyncStorage.getItem(KEY)) ?? '[]')) + return parsed.success ? parsed.data.slice(0, MAX) : [] + } catch { + return [] + } +} + +export async function recordRecentCloudRepo(url: string): Promise { + const slug = cloudRepoSlug(url) + if (!slug) { + return + } + const next = [slug, ...(await loadRecentCloudRepos()).filter((entry) => entry !== slug)].slice( + 0, + MAX + ) + await AsyncStorage.setItem(KEY, JSON.stringify(next)) +} + +/** Recents first, then the team's projects (repos of its cloud workspaces), each once. */ +export function cloudRepoChoices( + recents: readonly string[], + teamRepoUrls: readonly string[] +): string[] { + const seen = new Set() + const choices: string[] = [] + for (const slug of [...recents, ...teamRepoUrls.flatMap((url) => cloudRepoSlug(url) ?? [])]) { + const key = slug.toLowerCase() + if (!seen.has(key)) { + seen.add(key) + choices.push(slug) + } + } + return choices.slice(0, 12) +} diff --git a/mobile/src/cloud/cloud-repo-picker-rows.tsx b/mobile/src/cloud/cloud-repo-picker-rows.tsx new file mode 100644 index 00000000000..77ddadab6a3 --- /dev/null +++ b/mobile/src/cloud/cloud-repo-picker-rows.tsx @@ -0,0 +1,50 @@ +import type { ReactElement } from 'react' +import { ListRow } from '../components/list/ListGroup' +import type { Translate } from '../i18n/mobile-i18n' + +/** + * The repository picker's rows: recents and the team's projects, "No repository (blank project)", + * then "Other repository…" for any other repo (the free-text field). Keyed rows, not a fragment, so + * the group draws its separators. + */ +export function cloudRepoPickerRows(args: { + choices: readonly string[] + repo: string + typing: boolean + disabled: boolean + t: Translate + onPick: (repo: string) => void + onOther: () => void +}): ReactElement[] { + if (args.choices.length === 0) { + return [] + } + return [ + ...args.choices.map((choice) => ( + args.onPick(choice)} + /> + )), + args.onPick('')} + />, + + ] +} diff --git a/mobile/src/cloud/cloud-return-to.ts b/mobile/src/cloud/cloud-return-to.ts new file mode 100644 index 00000000000..5a61e05c9e7 --- /dev/null +++ b/mobile/src/cloud/cloud-return-to.ts @@ -0,0 +1,17 @@ +// Where to go once this phone is connected to Orca Cloud: a team link opened before connecting +// (e.g. /cloud/) is returned to after "Connect Orca Cloud" on Home succeeds. Memory only, and +// short-lived: a user who gave up on that link and connects later isn't sent back to it. + +const TTL_MS = 10 * 60_000 +let returnTo: { path: string; at: number } | null = null + +export function setCloudReturnTo(path: string, now: number = Date.now()): void { + // Only an in-app cloud route, never an arbitrary destination. + returnTo = /^\/cloud\/[A-Za-z0-9_-]+$/.test(path) ? { path, at: now } : null +} + +export function takeCloudReturnTo(now: number = Date.now()): string | null { + const entry = returnTo + returnTo = null + return entry && now - entry.at <= TTL_MS ? entry.path : null +} diff --git a/mobile/src/cloud/cloud-screen-states.test.ts b/mobile/src/cloud/cloud-screen-states.test.ts new file mode 100644 index 00000000000..34683b21133 --- /dev/null +++ b/mobile/src/cloud/cloud-screen-states.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { setCloudReturnTo, takeCloudReturnTo } from './cloud-return-to' +import { cloudHostGate, cloudMissingState } from './cloud-screen-states' + +const base = { isPending: false, isFetching: false, isError: false } + +describe('cloud workspace screen without its workspace', () => { + it('asks a phone that isn’t connected to sign in, instead of spinning on a query that never runs', () => { + expect(cloudMissingState({ ...base, enrollment: null, isPending: true })).toBe('signin') + }) + + it('spins while connecting or fetching, including a retry after an error', () => { + expect(cloudMissingState({ ...base, enrollment: undefined })).toBe('loading') + expect(cloudMissingState({ ...base, enrollment: {}, isPending: true })).toBe('loading') + expect(cloudMissingState({ ...base, enrollment: {}, isError: true, isFetching: true })).toBe( + 'loading' + ) + }) + + it('says Orca Cloud is unreachable on an error, and gone only when the list loaded without it', () => { + expect(cloudMissingState({ ...base, enrollment: {}, isError: true, isPending: true })).toBe( + 'unreachable' + ) + expect(cloudMissingState({ ...base, enrollment: {} })).toBe('gone') + }) +}) + +describe('cloud host gate', () => { + const known = { enrollment: {}, isPending: false, isError: false } + + it('waits until it knows whether the host is a cloud workspace and what state it is in', () => { + expect(cloudHostGate({ ...known, sandboxId: undefined, status: null })).toBe('wait') + expect(cloudHostGate({ ...known, sandboxId: 'sbx', isPending: true, status: null })).toBe( + 'wait' + ) + }) + + it('shows "asleep" only for an asleep cloud workspace', () => { + expect(cloudHostGate({ ...known, sandboxId: 'sbx', status: 'sleeping' })).toBe('asleep') + // Still going to sleep, or awake: the page handles it. + expect(cloudHostGate({ ...known, sandboxId: 'sbx', status: 'stopping' })).toBe('pass') + expect(cloudHostGate({ ...known, sandboxId: 'sbx', status: 'running' })).toBe('pass') + }) + + it('passes through for desktops, an unconnected phone, or an unreachable registry', () => { + expect(cloudHostGate({ ...known, sandboxId: null, status: null })).toBe('pass') + expect( + cloudHostGate({ ...known, sandboxId: 'sbx', enrollment: null, isPending: true, status: null }) + ).toBe('pass') + expect(cloudHostGate({ ...known, sandboxId: 'sbx', isError: true, status: null })).toBe('pass') + }) +}) + +describe('return after connecting', () => { + it('returns once to an in-app cloud workspace route, never anywhere else', () => { + setCloudReturnTo('/cloud/sbx_abc') + expect(takeCloudReturnTo()).toBe('/cloud/sbx_abc') + expect(takeCloudReturnTo()).toBeNull() + setCloudReturnTo('https://evil.example/cloud/x') + expect(takeCloudReturnTo()).toBeNull() + // Gave up on the link and connected much later: stay on Home. + setCloudReturnTo('/cloud/sbx_abc', 0) + expect(takeCloudReturnTo(11 * 60_000)).toBeNull() + }) +}) diff --git a/mobile/src/cloud/cloud-screen-states.ts b/mobile/src/cloud/cloud-screen-states.ts new file mode 100644 index 00000000000..a52294cbbbb --- /dev/null +++ b/mobile/src/cloud/cloud-screen-states.ts @@ -0,0 +1,52 @@ +import type { CloudWorkspaceStatus } from './cloud-control-plane-client' + +// Which state a cloud screen shows while its workspace isn't in hand. Pure, so each branch is tested. + +export type CloudMissingState = 'signin' | 'loading' | 'unreachable' | 'gone' + +/** + * The workspace screen without its workspace. Not connected (enrollment null) means the list query + * never runs: a share link opened on a fresh phone must ask to sign in, not spin forever. A refetch + * after an error shows the spinner again. + */ +export function cloudMissingState(args: { + enrollment: unknown | null | undefined + isPending: boolean + isFetching: boolean + isError: boolean +}): CloudMissingState { + if (args.enrollment === null) { + return 'signin' + } + if (args.enrollment === undefined || args.isFetching || (args.isPending && !args.isError)) { + return 'loading' + } + return args.isError ? 'unreachable' : 'gone' +} + +export type CloudHostGate = 'wait' | 'pass' | 'asleep' + +/** + * Before a paired host's page loads: `wait` while it isn't known yet whether this host is an asleep + * cloud workspace; `asleep` only for a workspace the registry says is asleep; anything else (not a + * cloud host, not connected, registry unreachable, awake) passes through to the page. + */ +export function cloudHostGate(args: { + /** undefined while the host → workspace lookup runs; null when it's not a cloud workspace. */ + sandboxId: string | null | undefined + enrollment: unknown | null | undefined + isPending: boolean + isError: boolean + status: CloudWorkspaceStatus | null +}): CloudHostGate { + if (args.sandboxId === undefined) { + return 'wait' + } + if (args.sandboxId === null || args.enrollment === null || args.isError) { + return 'pass' + } + if (args.enrollment === undefined || args.isPending) { + return 'wait' + } + return args.status === 'sleeping' ? 'asleep' : 'pass' +} diff --git a/mobile/src/cloud/cloud-workspace-copy.ts b/mobile/src/cloud/cloud-workspace-copy.ts index c5721f88a4c..a05eabf4e15 100644 --- a/mobile/src/cloud/cloud-workspace-copy.ts +++ b/mobile/src/cloud/cloud-workspace-copy.ts @@ -133,6 +133,10 @@ export function statusSentence( return t('cloud.line.archived', 'Unarchive it on your computer to work in it again.') case 'failed': return t('cloud.line.failed', 'The sandbox couldn’t start. Your files are safe.') + case 'unresponsive': + return t('cloud.line.unresponsive', 'The sandbox isn’t responding. Your files are safe.') + case 'deleting': + return t('cloud.line.deleting', 'This workspace is being deleted.') } } diff --git a/mobile/src/cloud/cloud-workspace-display.ts b/mobile/src/cloud/cloud-workspace-display.ts index 98b86e7364f..f1829026a47 100644 --- a/mobile/src/cloud/cloud-workspace-display.ts +++ b/mobile/src/cloud/cloud-workspace-display.ts @@ -19,6 +19,10 @@ export function cloudStatusLabel(status: CloudWorkspaceStatus, t: Translate): st return t('cloud.status.archived', 'Archived') case 'failed': return t('cloud.status.failed', 'Couldn’t start') + case 'unresponsive': + return t('cloud.status.unresponsive', 'Not responding') + case 'deleting': + return t('cloud.status.deleting', 'Deleting') } } @@ -33,6 +37,11 @@ export function cloudStatusColor(status: CloudWorkspaceStatus): string { return colors.textSecondary case 'failed': return colors.statusRed + case 'unresponsive': + // Glossary: "Not responding" uses the status-warning ink. + return colors.statusAmber + case 'deleting': + return colors.textMuted case 'sleeping': case 'archived': return colors.textMuted @@ -40,7 +49,9 @@ export function cloudStatusColor(status: CloudWorkspaceStatus): string { } export function isCloudStatusTransitional(status: CloudWorkspaceStatus): boolean { - return status === 'creating' || status === 'waking' || status === 'stopping' + return ( + status === 'creating' || status === 'waking' || status === 'stopping' || status === 'deleting' + ) } const hourFormatters = new Map() diff --git a/mobile/src/cloud/cloud-workspace-links.ts b/mobile/src/cloud/cloud-workspace-links.ts index 1477593d3c6..539ec73a50a 100644 --- a/mobile/src/cloud/cloud-workspace-links.ts +++ b/mobile/src/cloud/cloud-workspace-links.ts @@ -123,6 +123,12 @@ export async function cloudWorkspaceHostId(sandboxId: string): Promise { + const entry = Object.entries(await readLinks()).find(([, link]) => link.hostId === hostId) + return entry?.[0] ?? null +} + export async function cloudWorkspaceHostIds(): Promise> { const ids = Object.values(await readLinks()).flatMap((link) => (link.hostId ? [link.hostId] : [])) return new Set(ids) diff --git a/mobile/src/cloud/cloud-workspace-states.test.ts b/mobile/src/cloud/cloud-workspace-states.test.ts new file mode 100644 index 00000000000..715f8e407ff --- /dev/null +++ b/mobile/src/cloud/cloud-workspace-states.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import type { Translate } from '../i18n/mobile-i18n' +import { listCloudWorkspaces } from './cloud-control-plane-client' +import { cloudStatusLabel } from './cloud-workspace-display' + +const t: Translate = (_key, fallback) => fallback + +describe('cloud workspace states (glossary)', () => { + it('reads unresponsive as Not responding and destroying as Deleting, never Couldn’t start or Going to sleep', async () => { + const plane = { + baseUrl: 'https://cp.test', + token: () => 'tok', + fetch: async (input: RequestInfo | URL) => + new URL(String(input)).pathname === '/v1/sandboxes' + ? Response.json({ + sandboxes: [ + { + id: 'sbx_a', + workspaceId: 'ws-a', + status: 'unresponsive', + machineSize: 'standard', + createdAt: 1 + }, + { + id: 'sbx_b', + workspaceId: 'ws-b', + status: 'destroying', + machineSize: 'standard', + createdAt: 1 + } + ] + }) + : Response.json({ error: 'not_found' }, { status: 404 }) + } + const { workspaces } = await listCloudWorkspaces(plane) + expect(workspaces.map((w) => [w.status, cloudStatusLabel(w.status, t)])).toEqual([ + ['unresponsive', 'Not responding'], + ['deleting', 'Deleting'] + ]) + }) +}) diff --git a/mobile/src/cloud/cloud-worktree-row.test.ts b/mobile/src/cloud/cloud-worktree-row.test.ts new file mode 100644 index 00000000000..95ad384b4f0 --- /dev/null +++ b/mobile/src/cloud/cloud-worktree-row.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { cloudWorktreeRowStatus } from './cloud-worktree-row' + +describe('cloudWorktreeRowStatus', () => { + it('is null for a row that is not an Orca Cloud workspace', () => { + expect(cloudWorktreeRowStatus(undefined)).toBeNull() + expect(cloudWorktreeRowStatus(null)).toBeNull() + expect(cloudWorktreeRowStatus('running')).toBeNull() + expect(cloudWorktreeRowStatus({})).toBeNull() + }) + + it("maps the desktop's VM states to the phone's", () => { + expect(cloudWorktreeRowStatus({ status: 'provisioning' })).toEqual({ status: 'creating' }) + expect(cloudWorktreeRowStatus({ status: 'running' })).toEqual({ status: 'running' }) + expect(cloudWorktreeRowStatus({ status: 'suspend_failed' })).toEqual({ status: 'running' }) + expect(cloudWorktreeRowStatus({ status: 'suspended' })).toEqual({ status: 'sleeping' }) + expect(cloudWorktreeRowStatus({ status: 'resume_failed' })).toEqual({ status: 'failed' }) + expect(cloudWorktreeRowStatus({ status: 'cleanup_pending' })).toEqual({ status: 'deleting' }) + }) + + it("keeps the row cloud but drops the state for a word this build doesn't know", () => { + expect(cloudWorktreeRowStatus({ status: 'hibernating' })).toEqual({ status: null }) + expect(cloudWorktreeRowStatus({ status: 3 })).toEqual({ status: null }) + expect(cloudWorktreeRowStatus({ status: 'cleanup_failed' })).toEqual({ status: null }) + expect(cloudWorktreeRowStatus({ status: 'cleaned' })).toEqual({ status: null }) + }) +}) diff --git a/mobile/src/cloud/cloud-worktree-row.ts b/mobile/src/cloud/cloud-worktree-row.ts new file mode 100644 index 00000000000..d04a9dd8eda --- /dev/null +++ b/mobile/src/cloud/cloud-worktree-row.ts @@ -0,0 +1,28 @@ +import type { CloudWorkspaceStatus } from './cloud-control-plane-client' + +// The desktop's VM states (EphemeralVmRuntimeStatus) in the phone's words. A failed sleep leaves the +// VM up, so it still reads running. A failed wake joins 'failed' (labelled "Couldn't start") until the +// phone has a status of its own for it. +const FROM_DESKTOP: Record = { + provisioning: 'creating', + running: 'running', + suspend_failed: 'running', + suspended: 'sleeping', + resume_failed: 'failed', + failed: 'failed', + cleanup_pending: 'deleting' + // cleanup_failed and cleaned (the VM is gone) draw no dot: a 'deleting' pulse would never stop. + // The desktop records no waking state, and a sleep from another client shows once it reconciles. +} + +/** A paired desktop's row: is it an Orca Cloud workspace, and in what state (null when this build + * doesn't know the desktop's word for it)? Unchecked wire data, so any shape is tolerated. */ +export function cloudWorktreeRowStatus( + orcaCloud: unknown +): { status: CloudWorkspaceStatus | null } | null { + if (!orcaCloud || typeof orcaCloud !== 'object' || !('status' in orcaCloud)) { + return null + } + const raw = orcaCloud.status + return { status: typeof raw === 'string' ? (FROM_DESKTOP[raw] ?? null) : null } +} diff --git a/mobile/src/cloud/use-cloud-workspaces.ts b/mobile/src/cloud/use-cloud-workspaces.ts index d918f99b568..44752c98844 100644 --- a/mobile/src/cloud/use-cloud-workspaces.ts +++ b/mobile/src/cloud/use-cloud-workspaces.ts @@ -24,7 +24,8 @@ import { const TRANSITIONAL: ReadonlySet = new Set([ 'creating', 'waking', - 'stopping' + 'stopping', + 'deleting' ]) export const cloudWorkspacesQueryKey = (sessionId: string | null) => diff --git a/mobile/src/components/HostKindIcon.tsx b/mobile/src/components/HostKindIcon.tsx index 89bb0f44306..88b09670940 100644 --- a/mobile/src/components/HostKindIcon.tsx +++ b/mobile/src/components/HostKindIcon.tsx @@ -1,12 +1,21 @@ -import { Monitor, Server } from 'lucide-react-native' +import { Cloud, Monitor, Server } from 'lucide-react-native' import { isRemoteExecutionHostId } from '../../../src/shared/execution-host' import { colors } from '../theme/mobile-theme' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' /** The desktop's own machine is a monitor, any other host a server, as on the desktop's run targets. */ -export function HostKindIcon({ hostId, size }: { hostId: string | undefined; size: number }) { +export function HostKindIcon({ + hostId, + size, + cloud = false +}: { + hostId: string | undefined + size: number + /** An Orca Cloud workspace's host: the Cloud glyph, never a server. */ + cloud?: boolean +}) { useMobileColorScheme() // An absent id reads as local: an older host's row may omit it (its label pass fills it in). - const Icon = isRemoteExecutionHostId(hostId) ? Server : Monitor + const Icon = cloud ? Cloud : isRemoteExecutionHostId(hostId) ? Server : Monitor return } diff --git a/mobile/src/components/HostOfflineBanner.tsx b/mobile/src/components/HostOfflineBanner.tsx index 4ba805bb582..693caea6bfb 100644 --- a/mobile/src/components/HostOfflineBanner.tsx +++ b/mobile/src/components/HostOfflineBanner.tsx @@ -113,7 +113,7 @@ export function HostOfflineBanner({ onPress={onRetry} accessibilityRole="button" > - {t('common.retry', 'Retry')} + {t('common.tryAgain', 'Try again')} ) : null} diff --git a/mobile/src/components/WorktreeCloudGlyph.tsx b/mobile/src/components/WorktreeCloudGlyph.tsx new file mode 100644 index 00000000000..1a961f62e3c --- /dev/null +++ b/mobile/src/components/WorktreeCloudGlyph.tsx @@ -0,0 +1,43 @@ +import { Cloud } from 'lucide-react-native' +import { View } from 'react-native' +import type { CloudWorkspaceStatus } from '../cloud/cloud-control-plane-client' +import { CloudStatusDot } from '../cloud/CloudStatusDot' +import { cloudStatusLabel } from '../cloud/cloud-workspace-display' +import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { colors } from '../theme/mobile-theme' +import { themedStyleSheet } from '../theme/themed-styles' +import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' + +// Rows ask whether they're cloud from here, next to the glyph that draws the answer. +export { cloudWorktreeRowStatus } from '../cloud/cloud-worktree-row' + +/** A paired desktop's Orca Cloud workspace in a list row: the Cloud glyph, never swapped by state, + * with the state on a corner dot (the cloud-icon rule; the home Cloud rows draw the same pair). */ +export function WorktreeCloudGlyph({ status }: { status: CloudWorkspaceStatus | null }) { + useMobileColorScheme() + const t = useMobileTranslation() + const label = status + ? t('components.worktreeListRow.cloudWorkspaceState', 'Cloud workspace, {{state}}', { + state: cloudStatusLabel(status, t) + }) + : t('components.worktreeListRow.cloudWorkspace', 'Cloud workspace') + // Only a label, no focus stop of its own: it merges into the row's label, like the remote-host glyph. + return ( + + + {/* No filled ring: the row's fill changes on press and when active, so a ring would show as a patch. */} + {status ? ( + + + + ) : null} + + ) +} + +const styles = themedStyleSheet(() => ({ + // The dot sits mostly outside the glyph's bottom-right so it never reads as part of the strokes; + // the margin keeps it clear of the title. + glyph: { width: 12, height: 12, marginRight: 3 }, + dot: { position: 'absolute', right: -6, bottom: -4 } +})) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 73627d48a4b..0ec4cb95fdf 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -17,6 +17,7 @@ import { projectGroupRowIndent } from '../worktree/project-group-indent' import { getWorktreeMetaGlyphPresence } from '../worktree/worktree-meta-glyph-presence' import { AgentSpinner } from './AgentSpinner' import { HostKindIcon } from './HostKindIcon' +import { WorktreeCloudGlyph, cloudWorktreeRowStatus } from './WorktreeCloudGlyph' import { MobileRepoIcon } from './MobileRepoIcon' import { WorktreeAgentList } from './WorktreeAgentList' import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs' @@ -81,6 +82,7 @@ function WorktreeListRowComponent({ .join(', ') // Why: the desktop's compact card keeps an icon-only glyph for remote projects after the host badge goes. const showHostGlyph = !cardDetails.metaRow && isRemoteExecutionHostId(hostId) + const cloud = cloudWorktreeRowStatus(item.orcaCloud) const showHostBadge = cardDetails.host && Boolean(view.hostContextLabel || view.hostContextHealthLabel) // Why: like the desktop, skip a blank meta lane when every part of it is off. @@ -122,7 +124,8 @@ function WorktreeListRowComponent({ - {showHostGlyph && ( + {cloud && } + {showHostGlyph && !cloud && ( - + {view.hostContextLabel ? ( {view.hostContextLabel} diff --git a/mobile/src/components/list/ListGroup.tsx b/mobile/src/components/list/ListGroup.tsx index b27afdeae3d..db19389a689 100644 --- a/mobile/src/components/list/ListGroup.tsx +++ b/mobile/src/components/list/ListGroup.tsx @@ -149,6 +149,8 @@ type ListRowProps = { /** Working on a press: stops taking taps without the disabled dimming. */ busy?: boolean accessibilityLabel?: string + /** What a tap does, when the title alone doesn't say (e.g. it removes something). */ + accessibilityHint?: string accessibilityRole?: 'button' | 'link' | 'radio' | 'checkbox' | 'switch' testID?: string } @@ -169,6 +171,7 @@ export function ListRow({ disabled = false, busy = false, accessibilityLabel, + accessibilityHint, accessibilityRole, testID }: ListRowProps) { @@ -232,6 +235,7 @@ export function ListRow({ disabled={inertState.disabled} accessibilityRole={accessibilityRole ?? (selected === undefined ? 'button' : 'radio')} accessibilityLabel={accessibilityLabel ?? (value ? `${title}, ${value}` : title)} + accessibilityHint={accessibilityHint} accessibilityState={ selected === undefined ? inertState : { ...inertState, checked: selected, selected } } diff --git a/mobile/src/components/nav-bar/NavBar.tsx b/mobile/src/components/nav-bar/NavBar.tsx index 91a19430c8c..1330722a383 100644 --- a/mobile/src/components/nav-bar/NavBar.tsx +++ b/mobile/src/components/nav-bar/NavBar.tsx @@ -21,6 +21,8 @@ type NavBarProps = { /** Usually the back button; a side-panel or close button where a screen has no back. */ leading?: ReactNode title?: string + /** A small glyph before the title that names what the screen is, e.g. Cloud on a cloud workspace. */ + titleIcon?: ReactNode /** One short line under the title: host, workspace, or a status. */ subtitle?: ReactNode /** Replaces the title block, e.g. the session title that opens its details card. */ @@ -41,6 +43,7 @@ type NavBarProps = { export function NavBar({ leading, title, + titleIcon, subtitle, center, trailing, @@ -58,7 +61,18 @@ export function NavBar({ {center ?? ( <> - {title !== undefined ? ( + {title !== undefined && titleIcon ? ( + + {titleIcon} + + {title} + + + ) : title !== undefined ? ( {title} @@ -223,6 +237,13 @@ const styles = themedStyleSheet(() => ({ fontSize: 16, fontWeight: '600' }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + minWidth: 0 + }, + titleShrink: { flexShrink: 1 }, subtitleRow: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/home/MobileHomeCloudSection.tsx b/mobile/src/home/MobileHomeCloudSection.tsx index ff4aa8d6679..66b154f6125 100644 --- a/mobile/src/home/MobileHomeCloudSection.tsx +++ b/mobile/src/home/MobileHomeCloudSection.tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'expo-router' import { ChevronRight, Cloud, Plus, RotateCw } from 'lucide-react-native' import { Fragment, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' @@ -5,6 +6,7 @@ import { CloudStatusDot } from '../cloud/CloudStatusDot' import { formatReadSnapshotAsOf } from '../cloud/CloudReadSnapshotSection' import type { CloudWorkspace } from '../cloud/cloud-control-plane-client' import { CloudEnrollmentError, enrollCloudDevice } from '../cloud/cloud-device-enrollment' +import { takeCloudReturnTo } from '../cloud/cloud-return-to' import type { CloudEnrollment } from '../cloud/cloud-device-identity' import { cloudRepoLine, cloudStatusLabel } from '../cloud/cloud-workspace-display' import { useCloudWorkspaces } from '../cloud/use-cloud-workspaces' @@ -16,6 +18,7 @@ import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import { homeListStyles as styles } from './home-list-styles' import { MobileHomeSectionHeading } from './MobileHomeSectionHeading' +import { ORCA_CLOUD_IDLE_SLEEP_MINUTES } from '../../../src/shared/hosted-sandbox/hosted-sandbox-wire' export type CloudEnrollDesktop = { name: string; client: RpcClient } @@ -82,6 +85,12 @@ export function MobileHomeCloudSection(props: { 'Start one here. It keeps running while this phone and your computer sleep.' )} + + {t('cloud.idle.sleeps', 'Sleeps after {{count}} minutes idle.', { + count: ORCA_CLOUD_IDLE_SLEEP_MINUTES, + defaultValue_one: 'Sleeps after {{count}} minute idle.' + })} + ) : ( @@ -170,10 +179,14 @@ function CloudWorkspaceRow({ return ( [styles.row, pressed && styles.rowPressed]} onPress={onPress} > @@ -212,6 +225,7 @@ function CloudConnectRow({ deviceLabel: string }) { const t = useMobileTranslation() + const router = useRouter() const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const connect = async () => { @@ -222,6 +236,11 @@ function CloudConnectRow({ setError(null) try { await enrollCloudDevice({ desktop: desktop.client, desktopName: desktop.name, deviceLabel }) + // Arrived here from a team link opened before connecting: go back to that workspace. + const next = takeCloudReturnTo() + if (next) { + router.push(next) + } } catch (failure) { const kind = failure instanceof CloudEnrollmentError ? failure.failure : 'unreachable' setError( diff --git a/mobile/src/home/MobileHomeEmptyState.test.tsx b/mobile/src/home/MobileHomeEmptyState.test.tsx new file mode 100644 index 00000000000..ba4c2d1a133 --- /dev/null +++ b/mobile/src/home/MobileHomeEmptyState.test.tsx @@ -0,0 +1,54 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileHomeEmptyState } from './MobileHomeEmptyState' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('react-native-reanimated', () => ({ default: { View: 'AnimatedView' } })) +vi.mock('lucide-react-native', () => ({ QrCode: 'QrCode' })) +vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) + +describe('MobileHomeEmptyState', () => { + const mounted: ReturnType[] = [] + afterEach(() => { + act(() => mounted.splice(0).forEach((renderer) => renderer.unmount())) + }) + + // The no-desktop Home: the phone reaches Orca Cloud through a paired desktop (#42), so the empty + // state has to say where cloud workspaces will show up (W9 finding; copy signed off by design squad). + it('says Orca Cloud workspaces show up once a desktop is paired', () => { + const onPairDesktop = vi.fn() + act(() => { + mounted.push( + create( + createElement(MobileHomeEmptyState, { + contentMaxWidth: 600, + isWideLayout: false, + bottomInset: 0, + onPairDesktop + }) + ) + ) + }) + expect(mounted).toHaveLength(1) + for (const { root } of mounted) { + const texts = root + .findAll((node) => String(node.type) === 'Text') + .map((node) => node.children.join('')) + expect(texts).toContain('Connect your desktop') + expect(texts).toContain('Your Orca Cloud workspaces show up here once a desktop is paired.') + expect(texts).toContain( + 'Your desktop and your Orca Cloud workspaces will appear here. Everything is encrypted end-to-end.' + ) + const pair = root.find((node) => String(node.type) === 'Pressable') + act(() => pair.props.onPress()) + } + expect(onPairDesktop).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/home/MobileHomeEmptyState.tsx b/mobile/src/home/MobileHomeEmptyState.tsx index 10d3f1f6d5f..f02cffe272a 100644 --- a/mobile/src/home/MobileHomeEmptyState.tsx +++ b/mobile/src/home/MobileHomeEmptyState.tsx @@ -31,8 +31,8 @@ function onboardingSteps(t: Translate) { id: 'connected', title: t('home.emptyState.connectedTitle', "You're connected"), desc: t( - 'home.emptyState.connectedDescription', - 'Your desktop will appear here. Everything is encrypted end-to-end.' + 'home.emptyState.connectedCloudDescription', + 'Your desktop and your Orca Cloud workspaces will appear here. Everything is encrypted end-to-end.' ) } ] @@ -70,6 +70,13 @@ export function MobileHomeEmptyState(props: { 'Pair with Orca on your computer to check on your agents, jump into any terminal, and drive work from your phone.' )} + {/* Why: the phone reaches Orca Cloud through a paired desktop (#42), so say where cloud work shows up. */} + + {t( + 'home.emptyState.cloudNote', + 'Your Orca Cloud workspaces show up here once a desktop is paired.' + )} + {onboardingSteps(t).map((step, index) => ( @@ -139,6 +146,13 @@ const styles = themedStyleSheet(() => ({ marginBottom: 10 }, body: { fontSize: 17, lineHeight: 24, color: colors.textSecondary, textAlign: 'center' }, + cloudNote: { + fontSize: 15, + lineHeight: 20, + color: colors.textSecondary, + textAlign: 'center', + marginTop: 12 + }, steps: { backgroundColor: colors.bgPanel, borderRadius: radii.surface, overflow: 'hidden' }, separator: { height: StyleSheet.hairlineWidth, diff --git a/mobile/src/home/home-list-styles.ts b/mobile/src/home/home-list-styles.ts index 780ccc85973..f78a05756a2 100644 --- a/mobile/src/home/home-list-styles.ts +++ b/mobile/src/home/home-list-styles.ts @@ -102,5 +102,10 @@ export const homeListStyles = themedStyleSheet(() => ({ justifyContent: 'center', backgroundColor: colors.statusAmber }, - countPillText: { fontSize: 12, fontWeight: '700', color: '#000', fontVariant: ['tabular-nums'] } + countPillText: { + fontSize: 12, + fontWeight: '700', + color: colors.onStatusAmber, + fontVariant: ['tabular-nums'] + } })) diff --git a/mobile/src/home/tabs/MobileHomeTabBar.tsx b/mobile/src/home/tabs/MobileHomeTabBar.tsx index f5d84087a79..28376c8df1a 100644 --- a/mobile/src/home/tabs/MobileHomeTabBar.tsx +++ b/mobile/src/home/tabs/MobileHomeTabBar.tsx @@ -172,5 +172,5 @@ const styles = themedStyleSheet(() => ({ borderWidth: 1.5, borderColor: colors.bgBase }, - badgeText: { fontSize: 11, fontWeight: '700', color: '#000' } + badgeText: { fontSize: 11, fontWeight: '700', color: colors.onStatusAmber } })) diff --git a/mobile/src/i18n/en-runtime-required.json b/mobile/src/i18n/en-runtime-required.json index 154fcb60230..efc2a71ff82 100644 --- a/mobile/src/i18n/en-runtime-required.json +++ b/mobile/src/i18n/en-runtime-required.json @@ -141,6 +141,10 @@ } }, "cloud": { + "idle": { + "sleeps_one": "Sleeps after {{count}} minute idle.", + "sleeps_other": "Sleeps after {{count}} minutes idle." + }, "snapshot": { "git": { "files_one": "{{count}} changed file", diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 6093f930436..b04407e2dc0 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -1489,7 +1489,6 @@ "updateDesktop": "Orca auf {{desktop}} aktualisieren, um Automatisierungen zu nutzen." }, "emptyState": { - "connectedDescription": "Der Desktop erscheint hier. Alles ist Ende-zu-Ende-verschlüsselt.", "connectedTitle": "Verbunden", "description": "Mit Orca auf dem Computer koppeln, um Agenten im Blick zu behalten, beliebige Terminals zu öffnen und die Arbeit vom Smartphone aus zu steuern.", "openDesktopDescription": "Einstellungen → Mobil öffnen und einen QR-Code zum Koppeln erstellen.", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 636ec38fa72..3773f60736f 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -591,11 +591,12 @@ "openDesktopDescription": "Go to Settings → Mobile and generate a pairing QR code.", "scanCodeTitle": "Scan the code", "connectedTitle": "You're connected", - "connectedDescription": "Your desktop will appear here. Everything is encrypted end-to-end.", "title": "Connect your desktop", "description": "Pair with Orca on your computer to check on your agents, jump into any terminal, and drive work from your phone.", "pairDesktopButton": "Pair desktop", - "scanCodeBelowDescription": "Tap Pair desktop below, then point your camera at the QR code on your screen." + "scanCodeBelowDescription": "Tap Pair desktop below, then point your camera at the QR code on your screen.", + "connectedCloudDescription": "Your desktop and your Orca Cloud workspaces will appear here. Everything is encrypted end-to-end.", + "cloudNote": "Your Orca Cloud workspaces show up here once a desktop is paired." }, "listFooter": { "hostPickerTitle": "Open Tasks On" @@ -1371,7 +1372,9 @@ "child": "Child", "children_one": "{{count}} child", "children_other": "{{count}} children", - "unsavedName": "Unsaved name: {{name}}" + "unsavedName": "Unsaved name: {{name}}", + "cloudWorkspaceState": "Cloud workspace, {{state}}", + "cloudWorkspace": "Cloud workspace" }, "accountsSnapshot": { "invalidSnapshot": "Invalid accounts snapshot from host" @@ -4960,8 +4963,10 @@ "otherPhones": "Other phones", "noOthers": "No other phones have access.", "lastUsed": "Last used {{date}}", - "removeLabel": "Remove {{device}}", - "disconnectThis": "Disconnect this phone" + "disconnectThis": "Disconnect this phone", + "deviceLabel": "{{device}}, last used {{date}}", + "removeHint": "Removes this phone’s access", + "deviceLabelConnected": "{{device}}, connected {{date}}" }, "agent": { "label": "Agent" @@ -5001,13 +5006,15 @@ }, "status": { "waking": "Waking", - "cantConnect": "Can’t connect", "archived": "Archived", "creating": "Starting", "running": "Running", "stopping": "Going to sleep", "sleeping": "Asleep", - "failed": "Couldn’t start" + "failed": "Couldn’t start", + "deleting": "Deleting", + "restarting": "Restarting", + "unresponsive": "Not responding" }, "actions": { "sleep": "Sleep", @@ -5040,18 +5047,23 @@ "createUnknown": "Lost the connection while starting it. It may still appear in your list. Try again to pick it up.", "rateLimited": "Too many new workspaces at once. Wait a minute, then try again.", "create": "Couldn’t start your cloud workspace. Nothing was created, and you weren’t charged.", - "deliver": "The workspace is running, but a prompt didn’t start. Your files are safe." + "deliver": "The workspace is running, but a prompt didn’t start. Your files are safe.", + "restartOwner": "Only the workspace’s owner can restart it. Your files are safe." }, "workspace": { "title": "Cloud workspace", - "missing": "This workspace is gone or you no longer have access." + "missing": "This workspace is gone or you no longer have access.", + "signIn": "Sign in to Orca Cloud on this phone", + "signInAction": "Sign in", + "unreachable": "Couldn’t reach Orca Cloud. Check your connection and try again." }, "action": { "openSession": "Open session", "settingUp": "Starting…", "open": "Open workspace", "wakeOpen": "Wake and open workspace", - "wakeToContinue": "Wake to continue" + "wakeToContinue": "Wake to continue", + "restart": "Restart sandbox" }, "phase": { "delivering": "Starting your agent", @@ -5082,7 +5094,10 @@ "promptPlaceholder": "Optional. What should the agent start on?", "creating": "Starting your cloud workspace", "createAndStart": "Start workspace and agent", - "create": "Start workspace" + "create": "Start workspace", + "repoOther": "Other repository…", + "repoNotePicker": "Private repos need the Orca Cloud GitHub app, connected once from Orca on your computer.", + "repoNone": "No repository (blank project)" }, "line": { "runningFor": "Up {{elapsed}}. Agents keep working when you close the app.", @@ -5092,7 +5107,9 @@ "waking": "Starting up. This usually takes about 5 seconds.", "stopping": "Files are kept.", "archived": "Unarchive it on your computer to work in it again.", - "failed": "The sandbox couldn’t start. Your files are safe." + "failed": "The sandbox couldn’t start. Your files are safe.", + "unresponsive": "The sandbox isn’t responding. Your files are safe.", + "deleting": "This workspace is being deleted." }, "home": { "heading": "Cloud", @@ -5103,8 +5120,8 @@ "emptyDetail": "Start one here. It keeps running while this phone and your computer sleep.", "new": "New cloud workspace", "createdAgo": "Created {{ago}} ago", - "openWorkspace": "Open {{title}}, {{detail}}", - "createdJustNow": "Created just now" + "createdJustNow": "Created just now", + "workspaceRowLabel": "Cloud workspace, {{title}}, {{detail}}" }, "connect": { "signedOut": "Sign in to Orca on {{desktop}}, then try again.", @@ -5115,8 +5132,6 @@ "title": "Connect Orca Cloud" }, "denied": { - "hours": "Out of sandbox hours.", - "cap": "Spend cap reached.", "payment": "Payment failed, so new sandboxes can’t start.", "plan": "Orca Cloud needs a Team plan.", "teamLimit": "Your team is at its limit of running sandboxes (3 per seat).", @@ -5143,8 +5158,11 @@ "message": "{{problem}} {{safety}} {{next}}", "safety": { "create": "Nothing was created, and you weren’t charged.", - "existing": "Your files are safe." - } + "existing": "Your files are safe.", + "asleep": "Nothing is deleted, and your files are safe." + }, + "hours2": "Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start.", + "cap2": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start." }, "wake": { "slowQueued": "Taking longer than usual. Your files are safe. Your prompt sends as soon as it’s awake.", @@ -5203,6 +5221,15 @@ "asleep_other": "{{hours}} sandbox hours so far. Uses no sandbox hours while asleep.", "running_one": "{{hours}} sandbox hour so far. Uses sandbox hours while running.", "running_other": "{{hours}} sandbox hours so far. Uses sandbox hours while running." + }, + "host": { + "asleep": "{{name}} is asleep", + "asleepDetail": "Files are kept. Wake it to keep working.", + "wake": "Wake" + }, + "idle": { + "sleeps_one": "Sleeps after {{count}} minute idle.", + "sleeps_other": "Sleeps after {{count}} minutes idle." } } } diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index e898993112e..c22b236d8b9 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1530,7 +1530,6 @@ "updateDesktop": "Actualiza Orca en {{desktop}} para usar las automatizaciones." }, "emptyState": { - "connectedDescription": "Tu escritorio aparecerá aquí. Todo está cifrado de extremo a extremo.", "connectedTitle": "Ya tienes conexión", "description": "Empareja tu teléfono con Orca en tu ordenador para supervisar a tus agentes, acceder a cualquier terminal y gestionar el trabajo desde el móvil.", "openDesktopDescription": "Ve a Ajustes → Móvil y genera un código QR de emparejamiento.", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 8d807315fb1..2f9eeeafc6a 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -1530,7 +1530,6 @@ "updateDesktop": "Mettez à jour Orca sur {{desktop}} pour utiliser les automatisations." }, "emptyState": { - "connectedDescription": "Votre ordinateur apparaîtra ici. Tout est chiffré de bout en bout.", "connectedTitle": "Vous êtes connecté", "description": "Appairez votre téléphone à Orca sur votre ordinateur pour suivre vos agents, accéder à n'importe quel terminal et piloter votre travail depuis votre téléphone.", "openDesktopDescription": "Allez dans Paramètres → Mobile et générez un QR code d'appairage.", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index d95daaff72f..24d03a3a998 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1485,7 +1485,6 @@ "updateDesktop": "自動化を使用するには、{{desktop}} の Orca を更新してください。" }, "emptyState": { - "connectedDescription": "デスクトップがここに表示されます。すべての通信はエンドツーエンドで暗号化されます。", "connectedTitle": "接続完了", "description": "コンピュータ上の Orca とペアリングすると、スマートフォンから Agent の状況を確認したり、任意のターミナルを操作したりして、作業を進められます。", "openDesktopDescription": "「設定」→「モバイル」を開き、ペアリング用の QR コードを生成してください。", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 2f8b4179263..61120540629 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1485,7 +1485,6 @@ "updateDesktop": "자동화를 사용하려면 {{desktop}}의 Orca를 업데이트하세요." }, "emptyState": { - "connectedDescription": "데스크톱이 여기에 표시됩니다. 모든 데이터는 종단 간 암호화로 보호됩니다.", "connectedTitle": "연결 완료", "description": "컴퓨터의 Orca와 페어링하여 에이전트 상태를 확인하고, 원하는 터미널에 접속하고, 휴대폰으로 작업을 진행하세요.", "openDesktopDescription": "설정 → 모바일에서 페어링 QR 코드를 생성하세요.", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index bee99bc37a9..c6981bb519b 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -1530,7 +1530,6 @@ "updateDesktop": "Atualize o Orca em {{desktop}} para usar automações." }, "emptyState": { - "connectedDescription": "Seu desktop aparecerá aqui. Tudo é criptografado de ponta a ponta.", "connectedTitle": "Você está conectado", "description": "Pareie com o Orca no seu computador para acompanhar seus agentes, acessar qualquer terminal e gerenciar o trabalho pelo celular.", "openDesktopDescription": "Vá para Configurações → Mobile e gere um código QR de pareamento.", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 8ac3e79c21b..c52095026bd 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -1571,7 +1571,6 @@ "updateDesktop": "Обновите Orca на {{desktop}}, чтобы использовать автоматизации." }, "emptyState": { - "connectedDescription": "Здесь появится ваш компьютер. Все данные защищены сквозным шифрованием.", "connectedTitle": "Вы подключены", "description": "Подключитесь к Orca на компьютере, чтобы следить за агентами, открывать любой терминал и управлять работой с телефона.", "openDesktopDescription": "Откройте «Настройки» → «Мобильный доступ» и создайте QR-код для сопряжения.", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index bf6323cdbc8..2e1eb4d4ba8 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -1489,7 +1489,6 @@ "updateDesktop": "Otomasyonları kullanmak için {{desktop}} üzerindeki Orca'yı güncelleyin." }, "emptyState": { - "connectedDescription": "Masaüstünüz burada görünecek. Her şey uçtan uca şifrelenir.", "connectedTitle": "Bağlandınız", "description": "Ajanlarınızı takip etmek, istediğiniz terminale erişmek ve işlerinizi telefonunuzdan yönetmek için bilgisayarınızdaki Orca ile eşleştirin.", "openDesktopDescription": "Ayarlar → Mobil bölümüne gidin ve eşleştirme için bir QR kodu oluşturun.", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 5ae56b6e610..61beb297b59 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -1485,7 +1485,6 @@ "updateDesktop": "請更新 {{desktop}} 上的 Orca,以使用自動化。" }, "emptyState": { - "connectedDescription": "您的電腦會顯示在這裡。所有資料皆採端對端加密。", "connectedTitle": "已連線", "description": "與電腦上的 Orca 配對,即可用手機查看代理程式狀態、開啟任何終端機,並推進工作。", "openDesktopDescription": "前往「設定」→「行動版」,產生配對用的 QR Code。", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 04c1c5e2e2c..e62a9c0afaf 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1485,7 +1485,6 @@ "updateDesktop": "请更新 {{desktop}} 上的 Orca,以使用自动化。" }, "emptyState": { - "connectedDescription": "桌面将显示在这里。所有数据均采用端到端加密。", "connectedTitle": "已连接", "description": "与电脑上的 Orca 配对,即可在手机上查看智能体、进入任意终端并推进工作。", "openDesktopDescription": "前往“设置” → “移动端”,生成配对二维码。", diff --git a/mobile/src/settings/settings-screen-frame.tsx b/mobile/src/settings/settings-screen-frame.tsx index f452856ce9e..7d86bd51c5a 100644 --- a/mobile/src/settings/settings-screen-frame.tsx +++ b/mobile/src/settings/settings-screen-frame.tsx @@ -12,13 +12,15 @@ import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' type SettingsNavBarProps = { title: string + /** A small glyph before the title (NavBar titleIcon). */ + titleIcon?: ReactNode onBack: () => void /** At most two NavBarButtons. */ trailing?: ReactNode } /** A settings screen's top bar: Back, the centred title, and up to two buttons. */ -export function SettingsNavBar({ title, onBack, trailing }: SettingsNavBarProps) { +export function SettingsNavBar({ title, titleIcon, onBack, trailing }: SettingsNavBarProps) { const t = useMobileTranslation() return ( } title={title} + titleIcon={titleIcon} trailing={trailing} /> ) @@ -53,7 +56,7 @@ export function SettingsScreen({ useMobileColorScheme() const insets = useSafeAreaInsets() return ( - + ({ container: { flex: 1, backgroundColor: colors.bgBase }, + // Grouped lists only: white groups need a grey backdrop in light (screens with their own cards keep + // `container`). + groupedContainer: { flex: 1, backgroundColor: colors.settingsSurface }, scrollContent: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm }, fill: { flexGrow: 1 }, untitledGap: { marginTop: spacing.lg }, diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 963ad2f3995..871379de790 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -11,6 +11,8 @@ const darkColors = { editorSurface: '#1e1e1e', // Sheets sit a step above the screen, their groups a step above the sheet (the desktop's sidebar and secondary). sheetSurface: '#171717', + // Grouped-list screens: the backdrop the groups sit on (dark already differs from the groups). + settingsSurface: '#0a0a0a', sheetGroup: '#262626', // A translucent grey that reads on any surface: off switches, and chips or picked segments on a sheet group. translucentFill: 'rgba(120, 120, 128, 0.36)', @@ -45,6 +47,8 @@ const darkColors = { // --status-success, --status-warning, --destructive statusGreen: '#86efac', statusAmber: '#eab308', + // Text on a statusAmber fill (count pills, badges). + onStatusAmber: '#000000', statusRed: '#ff6568', // Desktop's --destructive-foreground: dark ink on the light-red dark fill. onStatusRed: '#450a0a', @@ -88,6 +92,8 @@ const lightColors: MobilePalette = { editorSurface: '#ffffff', // Light inverts it: a grey sheet under white groups, as iOS does. sheetSurface: '#f5f5f5', + // White groups need a grey backdrop to read as groups (iOS grouped style). + settingsSurface: '#f5f5f5', sheetGroup: '#ffffff', translucentFill: 'rgba(120, 120, 128, 0.16)', composerSurface: '#ffffff', @@ -113,7 +119,10 @@ const lightColors: MobilePalette = { onAccent: '#ffffff', statusGreen: '#147638', - statusAmber: '#ca8a04', + // Amber 700: at least 3:1 on white for the warning dot, and readable as text. + statusAmber: '#b45309', + // White on amber 700 reads at 5:1; black would drop to 4.2:1. + onStatusAmber: '#ffffff', statusRed: '#e40014', onStatusRed: '#ffffff', mergeGreen: '#16a34a', diff --git a/src/cli/cloud/cloud-api-client.ts b/src/cli/cloud/cloud-api-client.ts index 0518e6bffc4..69585d1cd94 100644 --- a/src/cli/cloud/cloud-api-client.ts +++ b/src/cli/cloud/cloud-api-client.ts @@ -2,11 +2,12 @@ // becomes a RuntimeClientError with a stable `cloud_*` code and next steps, so `--json` callers // (agents) and people both get an actionable message. import { RuntimeClientError } from '../runtime/types' +import { cloudApiError, type CloudRequestIntent } from './cloud-api-errors' import { jsonFields, type CloudCredentials } from './cloud-credentials' -export type CloudFetch = (input: string, init: RequestInit) => Promise +export { cloudApiError, type CloudApiErrorBody } from './cloud-api-errors' -export type CloudApiErrorBody = { error?: unknown; reason?: unknown } +export type CloudFetch = (input: string, init: RequestInit) => Promise const DEFAULT_TIMEOUT_MS = 60_000 @@ -19,7 +20,13 @@ export class CloudApiClient { async request( method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, - options: { body?: unknown; timeoutMs?: number; query?: Record } = {} + options: { + body?: unknown + timeoutMs?: number + query?: Record + /** Picks create-time error copy ("Nothing was created") over "Your files are safe". */ + intent?: CloudRequestIntent + } = {} ): Promise { const url = new URL(`${this.credentials.apiUrl}${path}`) for (const [key, value] of Object.entries(options.query ?? {})) { @@ -46,15 +53,17 @@ export class CloudApiClient { throw new RuntimeClientError( 'cloud_unreachable', timedOut - ? `Orca Cloud did not answer ${method} ${path} within ${Math.round((options.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)}s.` - : `Could not reach Orca Cloud at ${this.credentials.apiUrl}: ${error instanceof Error ? error.message : String(error)}`, + ? `Orca Cloud didn't answer within ${Math.round((options.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)} seconds.` + : `Couldn't reach Orca Cloud at ${this.credentials.apiUrl}.`, { apiUrl: this.credentials.apiUrl, + request: `${method} ${path}`, + cause: error instanceof Error ? error.message : String(error), nextSteps: this.credentials.token.startsWith('orca_ws_') ? [ - "Inside an Orca Cloud workspace the control plane must be on the VM's egress allowlist; restart the workspace after a control-plane update." + "Inside a cloud workspace, Orca Cloud must be on the sandbox's egress allowlist. Restart the workspace, then try again." ] - : ['Check the network and the control-plane URL, then retry.'] + : ['Check your network and ORCA_CLOUD_API_URL, then try again.'] } ) } @@ -70,176 +79,12 @@ export class CloudApiClient { if (!response.ok) { throw cloudApiError(response.status, jsonFields(body), { credentials: this.credentials, - retryAfter: response.headers.get('retry-after') + retryAfter: response.headers.get('retry-after'), + intent: options.intent, + path }) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the client's typed boundary; each caller names the control-plane response shape it reads return body as T } } - -/** Maps a control-plane error response to the CLI's error. Exported for tests. */ -export function cloudApiError( - status: number, - body: CloudApiErrorBody, - context: { - credentials: Pick - retryAfter?: string | null - } -): RuntimeClientError { - const code = typeof body.error === 'string' ? body.error : `http_${status}` - const reason = typeof body.reason === 'string' ? body.reason : null - const data = (nextSteps: string[], extra: Record = {}) => ({ - status, - apiError: code, - ...(reason ? { reason } : {}), - ...extra, - ...(nextSteps.length > 0 ? { nextSteps } : {}) - }) - const inVm = context.credentials.token.startsWith('orca_ws_') - if (status === 401) { - return new RuntimeClientError( - 'cloud_unauthorized', - inVm - ? 'Orca Cloud rejected this workspace token. In-workspace CLI access may be turned off for the organization (that revokes every workspace token at once), or the token was replaced at boot or revoked on sleep.' - : 'Orca Cloud rejected the token: it is expired, revoked or for another control plane.', - data( - inVm - ? [ - 'Ask an organization owner or admin to check in-workspace CLI access in Orca Cloud settings (vmCliEnabled); once it is on, restart the workspace for a fresh token.', - 'Or set ORCA_CLOUD_TOKEN to an API key.' - ] - : context.credentials.source === 'env' - ? ['Set ORCA_CLOUD_TOKEN to a valid API key or session token.'] - : ['Run `orca cloud login --token ` with a fresh API key or session token.'] - ) - ) - } - if (status === 403 && reason === 'vm_cli_disabled') { - return new RuntimeClientError( - 'cloud_vm_cli_disabled', - 'In-workspace CLI access is turned off for this Orca Cloud organization, so this workspace cannot call Orca Cloud.', - data([ - 'Ask an organization owner or admin to turn on in-workspace CLI access in Orca Cloud settings (PUT /v1/settings {"vmCliEnabled": true}).' - ]) - ) - } - if (status === 403 && reason === 'own_workspace') { - return new RuntimeClientError( - 'cloud_forbidden', - 'A workspace cannot sleep, archive or destroy itself; pick another workspace.', - data([]) - ) - } - if ( - code === 'entitlement_denied' && - (reason === 'concurrency_limit' || reason === 'member_concurrency_limit') - ) { - return new RuntimeClientError( - 'cloud_at_capacity', - reason === 'member_concurrency_limit' - ? "You're at your limit of running sandboxes. Nothing was created." - : "You're at your team's limit of running sandboxes. Nothing was created.", - data([ - 'Put one to sleep or delete it, then try again: `orca cloud workspace list`, then `orca cloud workspace sleep `.' - ]) - ) - } - if (code === 'entitlement_denied' || code === 'billing_required' || status === 402) { - const outOfHours = reason === 'included_hours_exhausted' || reason === 'overage_cap_reached' - const payment = reason === 'payment_past_due' || reason === 'payment_grace_expired' - if (payment) { - return new RuntimeClientError( - 'cloud_payment_failed', - "Payment failed, so new sandboxes can't start. Nothing was created.", - data(['Ask an owner or admin to update the payment method.']) - ) - } - if (reason === 'machine_size_not_in_plan') { - return new RuntimeClientError( - 'cloud_plan_required', - "That sandbox size isn't in your team's plan. Nothing was created.", - data(['Use --size standard.']) - ) - } - return new RuntimeClientError( - outOfHours ? 'cloud_out_of_hours' : 'cloud_plan_required', - outOfHours - ? 'Your team is out of sandbox hours (or at its monthly spend cap). Nothing was created, and your files are safe.' - : 'Orca Cloud needs a Team plan. Nothing was created, and you weren’t charged.', - data([ - outOfHours - ? 'Ask an owner or admin to turn on overage or raise the spend cap.' - : 'Ask an owner or admin to start a Team plan in Orca → Settings → Plan & billing.' - ]) - ) - } - if (status === 403 && code === 'insufficient_scope') { - return new RuntimeClientError( - 'cloud_forbidden', - `This token lacks the ${reason ?? 'required'} scope.`, - data(['Create an API key with that scope in Orca Cloud settings and log in with it.']) - ) - } - if (status === 403) { - const why: Record = { - session_required: 'This needs a signed-in Orca session, not an API key or workspace token.', - admin_required: 'Only an organization owner or admin can do this.' - } - return new RuntimeClientError( - 'cloud_forbidden', - (reason && why[reason]) || `Orca Cloud refused the request (${reason ?? code}).`, - data([]) - ) - } - if (status === 404) { - return new RuntimeClientError( - 'cloud_not_found', - inVm - ? 'Not found. A workspace token sees only its own workspace and the workspaces it created.' - : 'Not found in this Orca Cloud account.', - data(['Run `orca cloud workspace list` to see the workspaces this token can use.']) - ) - } - if (status === 429) { - const seconds = Number(context.retryAfter) - return new RuntimeClientError( - 'cloud_rate_limited', - reason === 'workspace_creates' - ? 'Too many workspace creates for this account right now.' - : 'Too many requests to Orca Cloud right now.', - data([`Retry in ${Number.isFinite(seconds) && seconds > 0 ? seconds : 60}s.`], { - retryAfterSeconds: Number.isFinite(seconds) ? seconds : null - }) - ) - } - if (status === 400) { - return new RuntimeClientError( - 'invalid_argument', - `Orca Cloud rejected the request: ${reason ?? code}.`, - data([]) - ) - } - if (status === 409) { - return new RuntimeClientError('cloud_conflict', `Conflict: ${reason ?? code}.`, data([])) - } - if (status === 402 || code === 'entitlement_denied' || code === 'billing_required') { - return new RuntimeClientError( - 'cloud_billing', - `Orca Cloud billing refused this (${reason ?? code}).`, - data(['Check the plan and usage limits in Orca Cloud billing settings.']) - ) - } - if (status === 503 && code === 'not_configured') { - return new RuntimeClientError( - 'cloud_unavailable', - 'This Orca Cloud control plane cannot run workspaces right now (not configured).', - data([]) - ) - } - return new RuntimeClientError( - status >= 500 ? 'cloud_unavailable' : 'cloud_api_error', - `Orca Cloud returned ${status} ${code}${reason ? ` (${reason})` : ''}.`, - data(status >= 500 ? ['Retry shortly; the workspace provider may be busy.'] : []) - ) -} diff --git a/src/cli/cloud/cloud-api-errors.ts b/src/cli/cloud/cloud-api-errors.ts new file mode 100644 index 00000000000..0c1d27e95c4 --- /dev/null +++ b/src/cli/cloud/cloud-api-errors.ts @@ -0,0 +1,256 @@ +// Private build only: maps a control-plane error response to the CLI's error. Messages follow the +// product glossary's error tone: say what happened in plain words, say whether anything was lost or +// charged, give one next step. API codes and reasons stay in `error.data` (seen with --json). +import { RuntimeClientError } from '../runtime/types' +import type { CloudCredentials } from './cloud-credentials' + +export type CloudApiErrorBody = { error?: unknown; reason?: unknown } + +/** What the failed request was doing; create-time copy never says "Your files are safe". */ +export type CloudRequestIntent = 'create' | 'existing' + +export type CloudApiErrorContext = { + credentials: Pick + retryAfter?: string | null + intent?: CloudRequestIntent + /** Request path; picks the 404 wording (workspace or routine). */ + path?: string +} + +const NOTHING_CREATED = "Nothing was created, and you weren't charged." +const FILES_SAFE = 'Your files are safe.' +const BUSY_409 = + 'This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.' + +/** "Try again in 12 seconds." (one second is singular). */ +export function tryAgainIn(seconds: number): string { + return `Try again in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}.` +} + +function forbiddenMessage(reason: string | null): string { + switch (reason) { + case 'admin_required': + case 'org_admin_required': + return 'Only a team owner or admin can do this.' + case 'session_required': + return 'This needs a signed-in Orca session, not an Orca API key or workspace token.' + case 'not_owner': + return 'Only the person who created this can do that.' + case null: + default: + return "You don't have access to do this." + } +} + +function conflictMessage(reason: string | null): { message: string; nextSteps: string[] } { + if (reason === 'concurrent_transition' || (reason?.startsWith('sandbox_') ?? false)) { + return { message: BUSY_409, nextSteps: [] } + } + switch (reason) { + case 'workspace_not_running': + return { + message: "This workspace isn't running. Nothing was changed.", + nextSteps: ['Wake it first: `orca cloud workspace wake `.'] + } + case 'idempotency_in_progress': + return { + message: 'The same request is still running. Nothing was changed. Try again in a moment.', + nextSteps: [] + } + case 'environment_not_built': + return { + message: `That environment has no finished build yet. ${NOTHING_CREATED}`, + nextSteps: ['Build it first: `orca cloud env build --wait`.'] + } + case 'region_unavailable': + return { + message: `That region isn't available right now. ${NOTHING_CREATED}`, + nextSteps: ['Try again without --region.'] + } + case 'github_not_connected': + return { + message: `Orca Cloud isn't connected to GitHub for this repo. ${NOTHING_CREATED}`, + nextSteps: ['Connect GitHub in Orca → Settings → Orca Cloud, then try again.'] + } + case 'queue_full': + return { + message: 'This routine already has runs waiting. Nothing was queued.', + nextSteps: ['Try again after one of them finishes.'] + } + case 'duplicate': + return { message: 'The same run is already queued. Nothing was queued.', nextSteps: [] } + case 'project_name_taken': + return { message: 'A project with that name already exists.', nextSteps: [] } + case null: + default: + return { + message: "Orca Cloud couldn't do that right now. Nothing was changed.", + nextSteps: ['Try again in a moment.'] + } + } +} + +/** Maps a control-plane error response to the CLI's error. */ +export function cloudApiError( + status: number, + body: CloudApiErrorBody, + context: CloudApiErrorContext +): RuntimeClientError { + const code = typeof body.error === 'string' ? body.error : `http_${status}` + const reason = typeof body.reason === 'string' ? body.reason : null + const data = (nextSteps: string[], extra: Record = {}) => ({ + status, + apiError: code, + ...(reason ? { reason } : {}), + ...extra, + ...(nextSteps.length > 0 ? { nextSteps } : {}) + }) + const creating = context.intent === 'create' + const inVm = context.credentials.token.startsWith('orca_ws_') + if (status === 401) { + return new RuntimeClientError( + 'cloud_unauthorized', + inVm + ? 'Orca Cloud rejected this workspace token. In-workspace CLI access may be turned off for the team (that revokes every workspace token at once), or the token was replaced at boot or revoked on sleep.' + : 'Orca Cloud rejected the token: it is expired, revoked or for another Orca Cloud server.', + data( + inVm + ? [ + 'Ask a team owner or admin to turn on in-workspace CLI access in Orca Cloud settings, then restart the workspace for a fresh token.', + 'Or set ORCA_CLOUD_TOKEN to an Orca API key.' + ] + : context.credentials.source === 'env' + ? ['Set ORCA_CLOUD_TOKEN to a valid Orca API key or session token.'] + : ['Run `orca cloud login --token ` with a fresh Orca API key or session token.'] + ) + ) + } + if (status === 403 && reason === 'vm_cli_disabled') { + return new RuntimeClientError( + 'cloud_vm_cli_disabled', + 'In-workspace CLI access is turned off for this team, so this workspace cannot call Orca Cloud.', + data( + ['Ask a team owner or admin to turn on in-workspace CLI access in Orca Cloud settings.'], + { setting: 'vmCliEnabled' } + ) + ) + } + if (status === 403 && reason === 'own_workspace') { + return new RuntimeClientError( + 'cloud_forbidden', + "A workspace can't sleep, archive or delete itself. Nothing was changed.", + data(['Pick another workspace.']) + ) + } + const safety = creating ? NOTHING_CREATED : FILES_SAFE + // Out of hours or at the spend cap, billing puts the team's running sandboxes to sleep (never deletes). + const GO_TO_SLEEP = "Running sandboxes go to sleep and new ones can't start." + if ( + code === 'entitlement_denied' && + (reason === 'concurrency_limit' || reason === 'member_concurrency_limit') + ) { + return new RuntimeClientError( + 'cloud_at_capacity', + `${ + reason === 'member_concurrency_limit' + ? "You're at your limit of running sandboxes." + : "You're at your team's limit of running sandboxes." + } ${creating ? 'Nothing was created.' : FILES_SAFE}`, + data([ + 'Put one to sleep or delete it, then try again: `orca cloud workspace list`, then `orca cloud workspace sleep `.' + ]) + ) + } + if (code === 'entitlement_denied' || code === 'billing_required' || status === 402) { + if (reason === 'payment_past_due' || reason === 'payment_grace_expired') { + return new RuntimeClientError( + 'cloud_payment_failed', + `Payment failed, so ${creating ? "new sandboxes can't start" : "this workspace can't wake"}. ${creating ? 'Nothing was created.' : FILES_SAFE}`, + data(['Ask an owner or admin to update the payment method.']) + ) + } + if (reason === 'included_hours_exhausted') { + return new RuntimeClientError( + 'cloud_out_of_hours', + `Your team is out of sandbox hours. ${GO_TO_SLEEP} ${safety}`, + data(['Ask an owner or admin to add hours.']) + ) + } + if (reason === 'overage_cap_reached') { + return new RuntimeClientError( + 'cloud_spend_cap_reached', + `Your team reached its monthly spend cap. ${GO_TO_SLEEP} ${safety}`, + data(['Ask an owner or admin to raise the spend cap.']) + ) + } + return new RuntimeClientError( + 'cloud_plan_required', + `Orca Cloud needs a Team plan. ${creating ? NOTHING_CREATED : FILES_SAFE}`, + data(['Ask an owner or admin to start a Team plan in Orca → Settings → Plan & billing.']) + ) + } + if (status === 403 && code === 'insufficient_scope') { + return new RuntimeClientError( + 'cloud_forbidden', + `This Orca API key is missing the ${reason ?? 'required'} scope.`, + data([ + 'Create an Orca API key with that scope in Orca → Settings → Orca API keys, then run `orca cloud login`.' + ]) + ) + } + if (status === 403) { + return new RuntimeClientError('cloud_forbidden', forbiddenMessage(reason), data([])) + } + if (status === 404) { + const routine = context.path?.startsWith('/v1/routines') ?? false + const noun = routine ? 'routine' : 'workspace' + return new RuntimeClientError( + 'cloud_not_found', + inVm && !routine + ? "No workspace with that ID, or you don't have access to it. A workspace token sees only its own workspace and the workspaces it created." + : `No ${noun} with that ID, or you don't have access to it.`, + data([`Run \`orca cloud ${noun} list\` to see the ${noun}s you can use.`]) + ) + } + if (status === 429) { + const parsed = Number(context.retryAfter) + const seconds = Number.isFinite(parsed) && parsed > 0 ? Math.ceil(parsed) : 60 + return new RuntimeClientError( + 'cloud_rate_limited', + reason === 'workspace_creates' + ? `Too many new workspaces in a short time. ${NOTHING_CREATED}` + : 'Too many requests to Orca Cloud right now.', + data([tryAgainIn(seconds)], { retryAfterSeconds: Number.isFinite(parsed) ? parsed : null }) + ) + } + if (status === 400) { + // Reasons read ": "; the field is useful, the problem text is API wording. + const field = reason ? /^([A-Za-z0-9_.]+):/.exec(reason)?.[1] : undefined + return new RuntimeClientError( + 'invalid_argument', + field && field !== 'body' + ? `Orca Cloud didn't accept the value for ${field}. Nothing was changed.` + : "Orca Cloud didn't accept that request. Nothing was changed.", + data([]) + ) + } + if (status === 409) { + const conflict = conflictMessage(reason) + return new RuntimeClientError('cloud_conflict', conflict.message, data(conflict.nextSteps)) + } + if (status === 503 && code === 'not_configured') { + return new RuntimeClientError( + 'cloud_unavailable', + "This Orca Cloud server can't run workspaces right now.", + data([]) + ) + } + if (status >= 500) { + return new RuntimeClientError( + 'cloud_unavailable', + "Orca Cloud couldn't finish that request.", + data(['Try again in a moment.']) + ) + } + return new RuntimeClientError('cloud_api_error', "Orca Cloud couldn't do that.", data([])) +} diff --git a/src/cli/cloud/cloud-cli.test.ts b/src/cli/cloud/cloud-cli.test.ts index 54b26931fb3..6ee2dd469c5 100644 --- a/src/cli/cloud/cloud-cli.test.ts +++ b/src/cli/cloud/cloud-cli.test.ts @@ -181,7 +181,7 @@ describe('cloud credential resolution', () => { data: expect.objectContaining({ nextSteps: [ expect.stringContaining('orca cloud login'), - expect.stringContaining('vmCliEnabled') + expect.stringContaining('in-workspace CLI access turned on') ] }) }) @@ -240,7 +240,7 @@ describe('cloud API error messages', () => { it('explains token expiry differently for workspace tokens and keys', () => { expect(cloudApiError(401, { error: 'unauthorized' }, { credentials: vm }).message).toContain( - 'turned off for the organization' + 'turned off for the team' ) expect(cloudApiError(401, { error: 'unauthorized' }, { credentials: user }).data).toMatchObject( { @@ -253,7 +253,7 @@ describe('cloud API error messages', () => { expect( cloudApiError(403, { error: 'forbidden', reason: 'own_workspace' }, { credentials: vm }) .message - ).toContain('cannot sleep, archive or destroy itself') + ).toContain("can't sleep, archive or delete itself") expect(cloudApiError(404, { error: 'not_found' }, { credentials: vm }).message).toContain( 'workspaces it created' ) @@ -266,7 +266,7 @@ describe('cloud API error messages', () => { ).toContain('workspaces:write') expect( cloudApiError(429, { error: 'rate_limited' }, { credentials: user, retryAfter: '12' }).data - ).toMatchObject({ retryAfterSeconds: 12, nextSteps: ['Retry in 12s.'] }) + ).toMatchObject({ retryAfterSeconds: 12, nextSteps: ['Try again in 12 seconds.'] }) }) }) @@ -276,7 +276,7 @@ describe('orca cloud handlers', () => { 'POST /v1/workspaces': () => [201, { workspace: workspace() }] }) await createCloudHandlers(() => deps(api.fetchImpl))['cloud workspace create']( - ctx({ name: 'child', repo: 'git@github.com:acme/app.git', ref: 'main', size: 'large' }) + ctx({ name: 'child', repo: 'git@github.com:acme/app.git', ref: 'main' }) ) expect(api.calls[0]).toMatchObject({ method: 'POST', @@ -284,8 +284,7 @@ describe('orca cloud handlers', () => { auth: 'Bearer orca_sk_test', body: { name: 'child', - repo: { url: 'https://github.com/acme/app.git', ref: 'main' }, - machineSize: 'large' + repo: { url: 'https://github.com/acme/app.git', ref: 'main' } } }) const out = lastJson() @@ -326,9 +325,6 @@ describe('orca cloud handlers', () => { await expect( handlers['cloud workspace create'](ctx({ agent: 'gpt', prompt: 'x' })) ).rejects.toThrow('--agent must be one of: claude, codex') - await expect(handlers['cloud workspace create'](ctx({ size: 'huge' }))).rejects.toThrow( - '--size' - ) await expect( handlers['cloud workspace create'](ctx({ repo: 'http://x.test/a' })) ).rejects.toThrow('https') @@ -459,7 +455,8 @@ describe('orca cloud handlers', () => { ) ).rejects.toMatchObject({ code: 'cloud_message_failed', - message: expect.stringContaining('agent_launch_failed') + message: "Message msg_1 couldn't be delivered to the agent.", + data: { message: expect.objectContaining({ error: 'agent_launch_failed' }) } }) const stuck = fakeApi({ diff --git a/src/cli/cloud/cloud-credentials.ts b/src/cli/cloud/cloud-credentials.ts index 2e61cfa15a1..ea1d2fa98dc 100644 --- a/src/cli/cloud/cloud-credentials.ts +++ b/src/cli/cloud/cloud-credentials.ts @@ -70,7 +70,7 @@ const LOGIN_HINT = 'Run `orca cloud login --token --api-url `, or set ORCA_CLOUD_TOKEN and ORCA_CLOUD_API_URL.' // Inside a VM the file only appears when the org allows it, so say why it may be missing. const IN_VM_HINT = - 'Inside an Orca Cloud workspace the token is written at boot only when the organization has in-workspace CLI access turned on (Orca Cloud settings, vmCliEnabled).' + 'Inside an Orca Cloud workspace the token is written at boot only when the team has in-workspace CLI access turned on in Orca Cloud settings.' /** * Only https may carry the bearer token; plain http is allowed for loopback dev servers. diff --git a/src/cli/cloud/cloud-format.ts b/src/cli/cloud/cloud-format.ts index d9f5ba0b716..bd131d403c4 100644 --- a/src/cli/cloud/cloud-format.ts +++ b/src/cli/cloud/cloud-format.ts @@ -1,5 +1,7 @@ // Private build only: text output for `orca cloud …`. JSON output is the API view wrapped as -// { ok, schemaVersion, result } (see printCloudResult). +// { ok, schemaVersion, result } (see printCloudResult). Human output follows the product glossary: +// states and problems in words; raw API statuses and codes appear only under --json. +import { ORCA_CLOUD_SANDBOX_SPEC } from '../../shared/hosted-sandbox/hosted-sandbox-wire' export const CLOUD_CLI_SCHEMA_VERSION = 1 @@ -14,6 +16,9 @@ export type CloudWorkspace = { machineSize: string region: string | null repoUrl: string | null + /** Older control planes omit these two. */ + repoBranch?: string | null + lastActivityAt?: number | null session: { startedAt: number; leaseExpiresAt: number; deadlineAt: number | null } | null lastError: string | null createdAt: number @@ -69,7 +74,7 @@ export function printCloudResult(result: T, json: boolean, format: (value: T) console.log(format(result)) } -function ago(at: number | null | undefined, now: number): string { +export function ago(at: number | null | undefined, now: number): string { if (!at) { return '-' } @@ -86,7 +91,19 @@ function ago(at: number | null | undefined, now: number): string { return `${Math.round(seconds / 86_400)}d ago` } -function table(header: string[], rows: string[][]): string { +/** "42s", "1m 42s", "1h 5m" (the desktop's formatElapsedTime style). */ +export function formatElapsed(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)) + if (seconds < 60) { + return `${seconds}s` + } + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + } + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` +} + +export function table(header: string[], rows: string[][]): string { const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) const line = (cells: string[]) => cells @@ -96,48 +113,116 @@ function table(header: string[], rows: string[][]): string { return [line(header), ...rows.map(line)].join('\n') } +/** "Sandbox size: 8 vCPU · 16 GB". There is one size; it always comes from ORCA_CLOUD_SANDBOX_SPEC. */ +export const CLOUD_SANDBOX_SIZE_LINE = `Sandbox size: ${ORCA_CLOUD_SANDBOX_SPEC.vcpus} vCPU · ${ORCA_CLOUD_SANDBOX_SPEC.memoryGb} GB` + +/** + * The API's workspace status in glossary words (Starting, Running, Asleep, Waking, …). A failed + * wake leaves the workspace asleep with lastError resume_failed: that is "Couldn't wake". + */ +export function cloudWorkspaceStateLabel( + w: Pick & { lastError?: string | null } +): string { + switch (w.status) { + case 'starting': + return 'Starting' + case 'running': + return 'Running' + case 'sleeping': + if (w.archived) { + return 'Archived' + } + return w.lastError === 'resume_failed' ? "Couldn't wake" : 'Asleep' + case 'waking': + return 'Waking' + case 'destroying': + return 'Deleting' + case 'destroyed': + return 'Deleted' + case 'failed': + return "Couldn't start" + default: + return w.archived ? 'Archived' : 'Unknown' + } +} + +/** `https://github.com/acme/app(.git)` → `acme/app`; other hosts keep the full URL. */ +function shortRepo(url: string | null): string { + if (!url) { + return '-' + } + const github = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url) + return github ? github[1] : url +} + +function lastActive(w: CloudWorkspace): number { + return w.lastActivityAt ?? w.updatedAt +} + export function formatCloudWorkspaceList( value: { workspaces: CloudWorkspace[] }, now = Date.now() ): string { if (value.workspaces.length === 0) { - return 'No workspaces.' + return 'No cloud workspaces yet. Create one with: orca cloud workspace create' } return table( - ['ID', 'NAME', 'STATUS', 'ORIGIN', 'CREATED'], + ['ID', 'NAME', 'STATE', 'REPO', 'BRANCH', 'LAST ACTIVE'], value.workspaces.map((w) => [ w.id, w.name, - w.archived ? `${w.status} (archived)` : w.status, - w.origin, - ago(w.createdAt, now) + cloudWorkspaceStateLabel(w), + shortRepo(w.repoUrl), + w.repoBranch || '-', + ago(lastActive(w), now) ]) ) } +const ORIGIN_LABELS: Record = { + desktop: 'the Orca app', + api: 'the Orca Cloud API', + mcp: 'an MCP client', + routine: 'a routine', + vm: 'another cloud workspace' +} + +/** One plain sentence for the workspace's last problem, or null when there is nothing to say. */ +function workspaceProblem(w: CloudWorkspace): string | null { + if (w.status === 'failed') { + return `This workspace couldn't start. Delete it and create a new one: orca cloud workspace delete ${w.id}` + } + if (w.status === 'sleeping' && w.lastError === 'resume_failed') { + return `The last wake didn't finish. Your files are safe. Run: orca cloud workspace wake ${w.id}` + } + return null +} + export function formatCloudWorkspace( value: { workspace: CloudWorkspace; message?: CloudMessage; elapsedMs?: number }, now = Date.now() ): string { const w = value.workspace + const problem = workspaceProblem(w) + const origin = ORIGIN_LABELS[w.origin] const lines = [ `${w.name} (${w.id})`, - `status: ${w.status}${w.archived ? ' (archived)' : ''}`, - `origin: ${w.origin}`, - `size: ${w.machineSize}${w.region ? ` in ${w.region}` : ''}`, - ...(w.repoUrl ? [`repo: ${w.repoUrl}`] : []), - ...(w.projectId ? [`project: ${w.projectId}`] : []), - ...(w.session - ? [`session lease expires: ${new Date(w.session.leaseExpiresAt).toISOString()}`] - : []), - ...(w.lastError ? [`last error: ${w.lastError}`] : []), - `created: ${ago(w.createdAt, now)}` + `State: ${cloudWorkspaceStateLabel(w)}`, + ...(problem ? [problem] : []), + CLOUD_SANDBOX_SIZE_LINE, + ...(w.repoUrl ? [`Repo: ${w.repoUrl}${w.repoBranch ? ` (${w.repoBranch})` : ''}`] : []), + ...(w.projectId ? [`Project: ${w.projectId}`] : []), + ...(origin ? [`Created from: ${origin}`] : []), + `Last active: ${ago(lastActive(w), now)}`, + `Created: ${ago(w.createdAt, now)}` ] if (value.message) { - lines.push(`message: ${value.message.id} (${value.message.status}, ${value.message.agent})`) + lines.push( + `First message: ${value.message.status} for ${value.message.agent} (${value.message.id})` + ) } if (value.elapsedMs !== undefined) { - lines.push(`took ${(value.elapsedMs / 1000).toFixed(1)}s`) + lines.push(`Took ${formatElapsed(value.elapsedMs)}`) } return lines.join('\n') } @@ -172,7 +257,7 @@ export function formatCloudSessions( if (value.sessions.length === 0) { return value.workspaceStatus === 'running' ? 'No agent sessions.' - : `No agent sessions (workspace is ${value.workspaceStatus}; wake it to see live sessions).` + : `No agent sessions. The workspace is ${cloudWorkspaceStateLabel({ status: value.workspaceStatus, archived: false }).toLowerCase()}; wake it to see live sessions.` } return value.sessions .map((s) => @@ -189,12 +274,11 @@ export function formatCloudProjects(value: { projects: CloudProject[] }): string return 'No projects.' } return table( - ['ID', 'NAME', 'REPO', 'SIZE'], + ['ID', 'NAME', 'REPO'], value.projects.map((p) => [ p.id, p.name, - p.repo ? `${p.repo.url}${p.repo.ref ? `#${p.repo.ref}` : ''}` : '-', - p.environment.machineSize + p.repo ? `${p.repo.url}${p.repo.ref ? `#${p.repo.ref}` : ''}` : '-' ]) ) } @@ -209,7 +293,7 @@ export function formatCloudWhoami(value: { return [ `account: ${me.accountId}`, `user: ${me.userId}`, - ...(me.orgId ? [`org: ${me.orgId} (${me.role})`] : [`role: ${me.role}`]), + ...(me.orgId ? [`team: ${me.orgId} (${me.role})`] : [`role: ${me.role}`]), `via: ${me.via} (${value.tokenKind} from ${value.credentialSource})`, ...(me.workspaceId ? [`workspace: ${me.workspaceId}`] : []), `scopes: ${me.scopes.join(', ') || '-'}`, diff --git a/src/cli/cloud/cloud-glossary.test.ts b/src/cli/cloud/cloud-glossary.test.ts new file mode 100644 index 00000000000..02f0506910a --- /dev/null +++ b/src/cli/cloud/cloud-glossary.test.ts @@ -0,0 +1,342 @@ +// Human output of `orca cloud …` follows the product glossary: states, errors and sizes in words, +// raw API codes only under --json. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HandlerContext } from '../dispatch' +import { cloudApiError } from './cloud-api-errors' +import { nodeCloudCredentialFs } from './cloud-credentials' +import { + cloudWorkspaceStateLabel, + formatCloudWorkspace, + formatCloudWorkspaceList, + type CloudWorkspace +} from './cloud-format' +import { createCloudHandlers, type CloudHandlerDeps } from './cloud-handlers' +import { formatRoutineList, routineRunErrorCopy } from './cloud-routines' +import type { Routine } from '../../shared/cloud-api/cloud-api-routine-types' + +const NOW = 10_000_000 + +function workspace(overrides: Partial = {}): CloudWorkspace { + return { + id: 'sbx_aa11', + name: 'child', + projectId: null, + status: 'running', + archived: false, + hidden: false, + origin: 'vm', + machineSize: 'standard', + region: 'iad1', + repoUrl: null, + session: null, + lastError: null, + createdAt: NOW - 3_600_000, + updatedAt: NOW - 60_000, + ...overrides + } +} + +const user = { source: 'env' as const, token: 'orca_sk_x', path: null } + +let logs: string[] +beforeEach(() => { + logs = [] + vi.spyOn(console, 'log').mockImplementation((line: unknown) => void logs.push(String(line))) + vi.spyOn(console, 'error').mockImplementation(() => undefined) +}) +afterEach(() => { + vi.restoreAllMocks() +}) + +function deps(routes: Record [number, unknown]>): { + deps: CloudHandlerDeps + calls: string[] +} { + const calls: string[] = [] + let clock = NOW + const fetchImpl = vi.fn(async (input: string, init: RequestInit) => { + const key = `${init.method ?? 'GET'} ${new URL(input).pathname}` + calls.push(key) + const route = routes[key] + const [status, body] = route ? route() : [404, { error: 'not_found' }] + return Response.json(body, { status }) + }) + return { + calls, + deps: { + env: { ORCA_CLOUD_TOKEN: 'orca_sk_test', ORCA_CLOUD_API_URL: 'https://cloud.test' }, + fetch: fetchImpl, + fs: nodeCloudCredentialFs, + home: '/nonexistent', + now: () => clock, + sleep: async (ms) => { + clock += ms + }, + readStdin: async () => null + } + } +} + +function ctx(flags: Record, json = false): HandlerContext { + return { + flags: new Map(Object.entries(flags)), + get client(): never { + throw new Error('cloud commands must not use the runtime client') + }, + cwd: '/', + json + } +} + +describe('workspace states and output', () => { + it('maps every API status to the glossary', () => { + const label = (status: string, archived = false) => + cloudWorkspaceStateLabel({ status, archived }) + expect(label('starting')).toBe('Starting') + expect(label('running')).toBe('Running') + expect(label('sleeping')).toBe('Asleep') + expect(label('sleeping', true)).toBe('Archived') + expect(label('waking')).toBe('Waking') + expect(label('failed')).toBe("Couldn't start") + expect(label('destroying')).toBe('Deleting') + expect( + cloudWorkspaceStateLabel({ status: 'sleeping', archived: false, lastError: 'resume_failed' }) + ).toBe("Couldn't wake") + }) + + it('lists ID NAME STATE REPO BRANCH LAST ACTIVE, falling back to updatedAt', () => { + const text = formatCloudWorkspaceList( + { + workspaces: [ + workspace({ repoUrl: 'https://github.com/acme/app', repoBranch: 'fix-login' }), + workspace({ id: 'sbx_bb22', name: 'old', status: 'failed', lastError: 'create_failed' }) + ] + }, + NOW + ) + expect(text.split('\n')).toEqual([ + 'ID NAME STATE REPO BRANCH LAST ACTIVE', + 'sbx_aa11 child Running acme/app fix-login 1m ago', + "sbx_bb22 old Couldn't start - - 1m ago" + ]) + }) + + it('shows the sandbox size from ORCA_CLOUD_SANDBOX_SPEC and no VM, size or raw codes', () => { + const text = formatCloudWorkspace( + { workspace: workspace({ status: 'sleeping', lastError: 'resume_failed' }) }, + NOW + ) + expect(text).toContain("State: Couldn't wake") + expect(text).toContain('Sandbox size: 8 vCPU · 16 GB') + expect(text).toContain( + "The last wake didn't finish. Your files are safe. Run: orca cloud workspace wake sbx_aa11" + ) + expect(text).toContain('Created from: another cloud workspace') + expect(text).not.toMatch(/resume_failed|sleeping|standard|\bVM\b|\bvm\b|iad1/) + }) +}) + +describe('glossary error copy', () => { + it('splits out-of-hours from the spend cap and never says "files are safe" on create', () => { + const hours = cloudApiError( + 403, + { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, + { credentials: user, intent: 'create' } + ) + expect(hours.code).toBe('cloud_out_of_hours') + expect(hours.message).toBe( + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." + ) + expect(hours.data).toMatchObject({ nextSteps: ['Ask an owner or admin to add hours.'] }) + const cap = cloudApiError( + 403, + { error: 'entitlement_denied', reason: 'overage_cap_reached' }, + { credentials: user, intent: 'create' } + ) + expect(cap.code).toBe('cloud_spend_cap_reached') + expect(cap.data).toMatchObject({ + nextSteps: ['Ask an owner or admin to raise the spend cap.'] + }) + // Waking an existing workspace keeps its files. + expect( + cloudApiError( + 403, + { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, + { credentials: user } + ).message + ).toBe( + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Your files are safe." + ) + }) + + it('uses the exact 409, admin and rate-limit copy, with codes kept in data', () => { + const busy = cloudApiError( + 409, + { error: 'conflict', reason: 'sandbox_suspending' }, + { credentials: user } + ) + expect(busy.message).toBe( + 'This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.' + ) + expect(busy.data).toMatchObject({ apiError: 'conflict', reason: 'sandbox_suspending' }) + expect( + cloudApiError( + 403, + { error: 'forbidden', reason: 'org_admin_required' }, + { credentials: user } + ).message + ).toBe('Only a team owner or admin can do this.') + expect( + cloudApiError(429, { error: 'rate_limited' }, { credentials: user, retryAfter: '1' }).data + ).toMatchObject({ nextSteps: ['Try again in 1 second.'] }) + expect( + cloudApiError( + 400, + { error: 'invalid_request', reason: 'name: Too big' }, + { credentials: user } + ).message + ).toBe("Orca Cloud didn't accept the value for name. Nothing was changed.") + expect( + cloudApiError(502, { error: 'provider_unavailable' }, { credentials: user }).message + ).not.toContain('provider_unavailable') + expect( + cloudApiError(404, { error: 'not_found' }, { credentials: user, path: '/v1/workspaces/x' }) + .message + ).toBe("No workspace with that ID, or you don't have access to it.") + expect( + cloudApiError(404, { error: 'not_found' }, { credentials: user, path: '/v1/routines/x' }) + ).toMatchObject({ + message: "No routine with that ID, or you don't have access to it.", + data: { nextSteps: ['Run `orca cloud routine list` to see the routines you can use.'] } + }) + }) + + it('turns a failed wake (resume_failed) into the glossary copy', async () => { + const { deps: d } = deps({ + 'POST /v1/workspaces/sbx_aa11/wake': () => [ + 200, + { + workspace: workspace({ status: 'sleeping', lastError: 'resume_failed' }), + state: 'failed' + } + ] + }) + await expect( + createCloudHandlers(() => d)['cloud workspace wake']( + ctx({ workspace: 'sbx_aa11', progress: 'none' }) + ) + ).rejects.toMatchObject({ + code: 'cloud_wake_failed', + message: + "The last wake didn't finish. Your files are safe. Run: orca cloud workspace wake sbx_aa11" + }) + }) +}) + +describe('orca cloud routine', () => { + const routine: Routine = { + id: 'rtn_a1', + name: 'Nightly triage', + projectId: null, + repo: null, + enabled: true, + trigger: { kind: 'schedule', cron: '0 9 * * 1-5', timezone: 'America/New_York' }, + agent: 'claude', + prompt: 'x', + postResults: 'none', + maxRuntimeMinutes: 30, + nextRunAt: null, + lastRun: { id: 'run_1', status: 'cancelled', finishedAt: NOW - 7_200_000 }, + createdBy: 'u', + createdByName: null, + createdAt: 1, + updatedAt: 1 + } + + it('lists routines with the trigger and last run in words', () => { + const text = formatRoutineList( + { + routines: [ + routine, + { + ...routine, + id: 'rtn_b2', + name: 'PR review', + enabled: false, + lastRun: null, + trigger: { kind: 'webhook', urlHint: null } + } + ] + }, + NOW + ) + expect(text).toContain('ID NAME') + expect(text).toMatch( + /rtn_a1 +Nightly triage +Weekdays at 9:00 AM \(America\/New_York\) +Canceled 2h ago/ + ) + expect(text).toMatch(/rtn_b2 +PR review \(paused\) +Webhook +Never run/) + expect(text).not.toContain('cancelled') + }) + + it('queues a run by id without --wait and points at --wait', async () => { + const { deps: d, calls } = deps({ + 'GET /v1/routines/rtn_a1': () => [200, { routine }], + 'POST /v1/routines/rtn_a1/run': () => [ + 202, + { run: { id: 'run_9', status: 'queued', result: null, durationMs: null, error: null } } + ] + }) + await createCloudHandlers(() => d)['cloud routine run'](ctx({ routine: 'rtn_a1' })) + expect(calls).toEqual(['GET /v1/routines/rtn_a1', 'POST /v1/routines/rtn_a1/run']) + expect(logs.at(-1)).toBe( + 'Nightly triage: Queued (run_9)\nAdd --wait to follow a run until it finishes.' + ) + }) + + it('--wait fails with the run error in words and keeps the code in data', async () => { + const failed = { + id: 'run_9', + status: 'failed', + error: 'start_failed:provider_unavailable', + result: null, + durationMs: null + } + const { deps: d } = deps({ + 'GET /v1/routines': () => [200, { routines: [routine] }], + 'POST /v1/routines/rtn_a1/run': () => [202, { run: { ...failed, status: 'queued' } }], + 'GET /v1/routines/rtn_a1/runs/run_9': () => [200, { run: failed }] + }) + await expect( + createCloudHandlers(() => d)['cloud routine run']( + ctx({ routine: 'Nightly triage', wait: true, progress: 'none' }) + ) + ).rejects.toMatchObject({ + code: 'cloud_routine_run_failed', + message: "The workspace couldn't start. Nothing was charged.", + data: { run: { error: 'start_failed:provider_unavailable' } } + }) + }) + + it('refuses --timeout without --wait and unknown names before running anything', async () => { + const { deps: d, calls } = deps({ 'GET /v1/routines': () => [200, { routines: [routine] }] }) + const handlers = createCloudHandlers(() => d) + await expect( + handlers['cloud routine run'](ctx({ routine: 'rtn_a1', timeout: '5' })) + ).rejects.toThrow('--timeout needs --wait.') + await expect(handlers['cloud routine run'](ctx({ routine: 'nope' }))).rejects.toMatchObject({ + code: 'cloud_not_found' + }) + expect(calls).toEqual(['GET /v1/routines']) + }) + + it('maps run errors to plain sentences', () => { + expect(routineRunErrorCopy('workspace_lost:suspended')).toBe( + 'The workspace went to sleep or was deleted before the run finished.' + ) + expect(routineRunErrorCopy('running_interrupted')).toBe('Orca Cloud restarted during the run.') + expect(routineRunErrorCopy('queue_expired')).toBe( + 'The run waited too long to start and was skipped.' + ) + expect(routineRunErrorCopy('weird')).toBe('The run failed.') + }) +}) diff --git a/src/cli/cloud/cloud-handler-group.ts b/src/cli/cloud/cloud-handler-group.ts index e24a84c43e6..89016c25009 100644 --- a/src/cli/cloud/cloud-handler-group.ts +++ b/src/cli/cloud/cloud-handler-group.ts @@ -13,11 +13,14 @@ export const CLOUD_HANDLER_GROUP: HandlerGroup = { 'cloud workspace sleep', 'cloud workspace wake', 'cloud workspace archive', + 'cloud workspace delete', 'cloud workspace destroy', 'cloud message', 'cloud messages', 'cloud sessions', - 'cloud projects list' + 'cloud projects list', + 'cloud routine list', + 'cloud routine run' ], load: async () => (await import('./cloud-handlers.js')).CLOUD_HANDLERS } diff --git a/src/cli/cloud/cloud-handlers.ts b/src/cli/cloud/cloud-handlers.ts index 8ea221b5079..106eee0fb69 100644 --- a/src/cli/cloud/cloud-handlers.ts +++ b/src/cli/cloud/cloud-handlers.ts @@ -1,5 +1,5 @@ // Private build only: `orca cloud …` handlers. They never touch the Orca runtime (ctx.client): they -// call the Orca Cloud public API over HTTPS, so they work inside a VM with no desktop app running. +// call the Orca Cloud public API over HTTPS, so they work inside a sandbox with no desktop app running. import type { CommandHandler } from '../dispatch' import { getOptionalPositiveIntegerFlag, @@ -37,7 +37,6 @@ import { createCloudWorkspace, defaultCloudHandlerDeps, getAgentFlag, - getSizeFlag, normalizeCloudRepoUrl, readMessageFlags, resolveCloudProjectId, @@ -49,6 +48,7 @@ import { } from './cloud-operations' import { cloudLifecycleHandler, type CloudLifecycleAction } from './cloud-lifecycle' +import { createCloudRoutineHandlers } from './cloud-routines' export type { CloudHandlerDeps } from './cloud-operations' @@ -181,7 +181,6 @@ export function createCloudHandlers( if (ref && !repoFlag) { throw new RuntimeClientError('invalid_argument', '--ref needs --repo.', { field: 'ref' }) } - const machineSize = getSizeFlag(flags) const api = cloudClient(deps) const project = getOptionalStringFlag(flags, 'project') const name = getOptionalStringFlag(flags, 'name') @@ -192,7 +191,6 @@ export function createCloudHandlers( ...(repoFlag ? { repo: { url: normalizeCloudRepoUrl(repoFlag), ...(ref ? { ref } : {}) } } : {}), - ...(machineSize ? { machineSize } : {}), ...(region ? { region } : {}), ...(prompt ? { message: { text: prompt, ...(agent ? { agent } : {}) } } : {}) } @@ -202,7 +200,9 @@ export function createCloudHandlers( 'cloud workspace sleep': mutate('sleep'), 'cloud workspace wake': mutate('wake'), 'cloud workspace archive': mutate('archive'), - 'cloud workspace destroy': mutate('destroy'), + 'cloud workspace delete': mutate('delete'), + // Hidden alias kept so scripts written before the rename keep working. + 'cloud workspace destroy': mutate('delete'), 'cloud message': async ({ flags, json }) => { const deps = getDeps() const body = readMessageFlags(flags) @@ -263,7 +263,8 @@ export function createCloudHandlers( const api = cloudClient(deps) const result = await api.request<{ projects: CloudProject[] }>('GET', '/v1/projects') printCloudResult(result, json, formatCloudProjects) - } + }, + ...createCloudRoutineHandlers(getDeps) } } diff --git a/src/cli/cloud/cloud-lifecycle.ts b/src/cli/cloud/cloud-lifecycle.ts index 173bbd143c4..ffc2469ad19 100644 --- a/src/cli/cloud/cloud-lifecycle.ts +++ b/src/cli/cloud/cloud-lifecycle.ts @@ -1,7 +1,9 @@ -// Private build only: `orca cloud workspace sleep|wake|archive|destroy`. Wake and sleep show -// progress on stderr (see cloud-progress.ts) so a multi-second wait is never silent. +// Private build only: `orca cloud workspace sleep|wake|archive|delete` (`destroy` is a hidden alias of +// delete). Wake and sleep show progress on stderr (see cloud-progress.ts) so a multi-second wait is +// never silent. import type { CommandHandler } from '../dispatch' import { getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime/types' import { formatCloudWorkspace, printCloudResult, type CloudWorkspace } from './cloud-format' import { MUTATE_TIMEOUT_MS, @@ -12,7 +14,7 @@ import { } from './cloud-operations' import { cloudProgress, readCloudProgressMode } from './cloud-progress' -export type CloudLifecycleAction = 'sleep' | 'wake' | 'archive' | 'destroy' +export type CloudLifecycleAction = 'sleep' | 'wake' | 'archive' | 'delete' export function cloudLifecycleHandler( action: CloudLifecycleAction, @@ -33,8 +35,8 @@ export function cloudLifecycleHandler( } const started = deps.now() const request = api.request<{ workspace: CloudWorkspace; state?: string }>( - action === 'destroy' ? 'DELETE' : 'POST', - action === 'destroy' ? workspacePath(id) : workspacePath(id, `/${action}`), + action === 'delete' ? 'DELETE' : 'POST', + action === 'delete' ? workspacePath(id) : workspacePath(id, `/${action}`), { timeoutMs: MUTATE_TIMEOUT_MS } ) const result = @@ -45,6 +47,14 @@ export function cloudLifecycleHandler( ) : await request const name = result.workspace.name || selector + if (action === 'wake' && result.state === 'failed') { + // The workspace went back to sleep (lastError resume_failed); its disk is untouched. + throw new RuntimeClientError( + 'cloud_wake_failed', + `The last wake didn't finish. Your files are safe. Run: orca cloud workspace wake ${id}`, + { workspace: result.workspace, state: result.state } + ) + } // The API's wake waits ~30 s; past that it answers `waking` and keeps going in the background. const stillWaking = action === 'wake' && (result.state === 'waking' || result.workspace.status === 'waking') @@ -65,7 +75,11 @@ export function cloudLifecycleHandler( }) } printCloudResult({ ...result, elapsedMs: deps.now() - started }, json, (value) => - formatCloudWorkspace(value) + action !== 'delete' + ? formatCloudWorkspace(value, deps.now()) + : value.workspace.status === 'destroyed' + ? `Deleted ${name} (${id}). Its sandbox and files are gone for good.` + : `Deleting ${name} (${id}). Its sandbox and files will be gone for good.` ) } } diff --git a/src/cli/cloud/cloud-operations.ts b/src/cli/cloud/cloud-operations.ts index 01e1b09caec..efac1293246 100644 --- a/src/cli/cloud/cloud-operations.ts +++ b/src/cli/cloud/cloud-operations.ts @@ -32,7 +32,7 @@ export function isCloudAgent(value: string): value is CloudAgent { const WORKSPACE_ID = /^sbx_[A-Za-z0-9]+$/ const PROJECT_ID = /^prj_[A-Za-z0-9]+$/ const SESSION_HANDLE = /^term_[A-Za-z0-9-]{1,80}$/ -/** A create waits for the VM to boot; the control plane bounds that, this only stops a hang. */ +/** A create waits for the sandbox to boot; the control plane bounds that, this only stops a hang. */ export const CREATE_TIMEOUT_MS = 6 * 60_000 export const MUTATE_TIMEOUT_MS = 3 * 60_000 const MESSAGE_POLL_MS = 1500 @@ -89,7 +89,7 @@ export function getAgentFlag( } /** - * `sbx_…` ids pass through; `self` is the workspace this token was minted for (inside a VM); + * `sbx_…` ids pass through; `self` is the workspace this token was minted for (inside a sandbox); * anything else is matched against workspace names this token can see. */ export async function resolveCloudWorkspaceId( @@ -154,7 +154,6 @@ export type CloudWorkspaceCreateInput = { repo?: { url: string; ref?: string } /** Team environment: `env_…` (its active build) or a build `bld_…`. */ environment?: string - machineSize?: 'standard' | 'large' region?: string message?: { text: string; agent?: (typeof CLOUD_AGENTS)[number] } } @@ -168,7 +167,7 @@ export async function createCloudWorkspace( const created = await api.request<{ workspace: CloudWorkspace; message?: CloudMessage }>( 'POST', '/v1/workspaces', - { body: input, timeoutMs: CREATE_TIMEOUT_MS } + { body: input, timeoutMs: CREATE_TIMEOUT_MS, intent: 'create' } ) return { ...created, elapsedMs: now() - started } } @@ -199,18 +198,6 @@ export function normalizeCloudRepoUrl(raw: string): string { return url.toString() } -export function getSizeFlag( - flags: Map -): 'standard' | 'large' | undefined { - const size = getOptionalStringFlag(flags, 'size') - if (size === undefined || size === 'standard' || size === 'large') { - return size - } - throw new RuntimeClientError('invalid_argument', '--size must be standard or large', { - field: 'size' - }) -} - /** Reads `--text` (required) / `--agent` / `--session` for `cloud message`. */ export function readMessageFlags(flags: Map) { const text = getRequiredStringFlag(flags, 'text') @@ -256,7 +243,7 @@ export async function waitForCloudMessage( if (current.status === 'failed') { throw new RuntimeClientError( 'cloud_message_failed', - `Message ${current.id} could not be delivered: ${current.error ?? 'unknown error'}.`, + `Message ${current.id} couldn't be delivered to the agent.`, { message: current } ) } diff --git a/src/cli/cloud/cloud-progress.test.ts b/src/cli/cloud/cloud-progress.test.ts index 85c2ba2ebb9..564272e2629 100644 --- a/src/cli/cloud/cloud-progress.test.ts +++ b/src/cli/cloud/cloud-progress.test.ts @@ -88,7 +88,7 @@ describe('orca cloud workspace wake progress', () => { ) await run(handlers['cloud workspace wake'](ctx({ workspace: 'sbx_ab12' })), 5000) expect(stderr).toEqual(['- Waking sbx_ab12...', '- child is awake (took 4.8s).']) - expect(stdout.join('\n')).toContain('status: running') + expect(stdout.join('\n')).toContain('State: Running') }) it('adds one reassurance line at 10 s', async () => { @@ -155,8 +155,8 @@ describe('worktree create --on cloud progress', () => { 13_000 ) expect(stderr).toEqual([ - '- Creating an Orca Cloud workspace...', - '- Still starting (10s). This is taking longer than usual; your files are safe.', + '- Starting your cloud workspace...', + '- Still starting (10s). This is taking longer than usual.', '- Workspace sbx_ab12 is running (took 12.0s).' ]) }) @@ -168,7 +168,7 @@ describe('worktree create --on cloud progress', () => { 7000 ) expect(stderr).toEqual([ - '- Creating an Orca Cloud workspace...', + '- Starting your cloud workspace...', '- Workspace sbx_ab12 is running (took 6.0s).' ]) }) diff --git a/src/cli/cloud/cloud-routines.ts b/src/cli/cloud/cloud-routines.ts new file mode 100644 index 00000000000..01c26f50370 --- /dev/null +++ b/src/cli/cloud/cloud-routines.ts @@ -0,0 +1,220 @@ +// Private build only: `orca cloud routine list|run`. Routines live on the control plane +// (GET /v1/routines, POST /v1/routines/:id/run, GET /v1/routines/:id/runs/:runId); each run starts +// its own cloud workspace. Human output uses the glossary's run states; --json prints the API view. +import type { CommandHandler } from '../dispatch' +import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime/types' +import type { + Routine, + RoutineRun, + RoutineRunStatus +} from '../../shared/cloud-api/cloud-api-routine-types' +import { formatAutomationSchedule } from '../../shared/automations/scheduling/automation-schedules' +import type { CloudApiClient } from './cloud-api-client' +import { ago, formatElapsed, printCloudResult, table } from './cloud-format' +import { cloudClient, type CloudHandlerDeps } from './cloud-operations' +import { cloudProgress, readCloudProgressMode } from './cloud-progress' + +const ROUTINE_ID = /^rtn_[A-Za-z0-9_-]+$/ +const RUN_POLL_MS = 5000 +/** --wait without --timeout: the routine's own time limit plus room to start and post results. */ +const WAIT_SLACK_MS = 15 * 60_000 +const DEFAULT_RUNTIME_MINUTES = 60 + +const RUN_STATE_LABELS: ReadonlyMap = new Map([ + ['queued', 'Queued'], + ['starting', 'Starting'], + ['running', 'Running'], + ['posting', 'Posting results'], + ['succeeded', 'Succeeded'], + ['failed', 'Failed'], + ['cancelled', 'Canceled'], + ['timed_out', 'Timed out'] +]) + +const FINISHED: ReadonlySet = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']) + +export function routineRunStateLabel(status: string): string { + return RUN_STATE_LABELS.get(status) ?? 'Unknown' +} + +/** A failed run's error code in words (codes stay in --json). */ +export function routineRunErrorCopy(error: string | null): string { + const code = error ?? '' + if (code.startsWith('start_failed')) { + return "The workspace couldn't start. Nothing was charged." + } + if (code.startsWith('workspace_lost')) { + return 'The workspace went to sleep or was deleted before the run finished.' + } + if (code.startsWith('post_failed')) { + return "The run finished, but Orca couldn't post the results." + } + if (code === 'queue_expired') { + return 'The run waited too long to start and was skipped.' + } + if (code.endsWith('_interrupted')) { + return 'Orca Cloud restarted during the run.' + } + if (code === 'routine_changed') { + return 'The routine changed while this run was queued.' + } + return 'The run failed.' +} + +function describeTrigger(trigger: Routine['trigger']): string { + if (trigger.kind === 'schedule') { + return `${formatAutomationSchedule(trigger.cron)} (${trigger.timezone})` + } + if (trigger.kind === 'webhook') { + return 'Webhook' + } + return 'GitHub' +} + +export function formatRoutineList(value: { routines: Routine[] }, now = Date.now()): string { + if (value.routines.length === 0) { + return 'No routines yet. Create one in the Orca app.' + } + return table( + ['ID', 'NAME', 'WHEN', 'LAST RUN'], + value.routines.map((r) => [ + r.id, + r.enabled ? r.name : `${r.name} (paused)`, + describeTrigger(r.trigger), + r.lastRun + ? `${routineRunStateLabel(r.lastRun.status)}${r.lastRun.finishedAt ? ` ${ago(r.lastRun.finishedAt, now)}` : ''}` + : 'Never run' + ]) + ) +} + +export function formatRoutineRun(value: { + routine: Pick + run: RoutineRun +}): string { + const { run } = value + const lines = [ + `${value.routine.name}: ${routineRunStateLabel(run.status)} (${run.id})`, + ...(run.status === 'failed' ? [routineRunErrorCopy(run.error)] : []), + ...(run.status === 'timed_out' ? ['The run hit its time limit.'] : []), + ...(run.durationMs !== null ? [`Took ${formatElapsed(run.durationMs)}`] : []), + ...(run.result?.prUrl ? [`Pull request: ${run.result.prUrl}`] : []), + ...(run.result?.commentUrl ? [`Comment: ${run.result.commentUrl}`] : []), + ...(run.workspaceId ? [`Workspace: ${run.workspaceId}`] : []), + ...(run.result?.summary ? ['', run.result.summary] : []) + ] + if (!FINISHED.has(run.status)) { + lines.push('Add --wait to follow a run until it finishes.') + } + return lines.join('\n') +} + +async function resolveRoutine(api: CloudApiClient, selector: string): Promise { + if (ROUTINE_ID.test(selector)) { + return ( + await api.request<{ routine: Routine }>('GET', `/v1/routines/${encodeURIComponent(selector)}`) + ).routine + } + const { routines } = await api.request<{ routines: Routine[] }>('GET', '/v1/routines') + const matches = routines.filter((r) => r.name === selector) + if (matches.length === 1) { + return matches[0] + } + if (matches.length === 0) { + throw new RuntimeClientError('cloud_not_found', `No routine named "${selector}".`, { + nextSteps: ['Run `orca cloud routine list` and pass the routine id (rtn_…).'] + }) + } + throw new RuntimeClientError( + 'selector_ambiguous', + `${matches.length} routines are named "${selector}". Pass the id instead.`, + { choices: matches.map((r) => ({ id: r.id, name: r.name })) } + ) +} + +function runFinishedError(routine: Routine, run: RoutineRun): RuntimeClientError | null { + if (run.status === 'failed') { + return new RuntimeClientError('cloud_routine_run_failed', routineRunErrorCopy(run.error), { + run + }) + } + if (run.status === 'timed_out') { + return new RuntimeClientError( + 'cloud_routine_run_timed_out', + `The run hit ${routine.name}'s time limit.`, + { run } + ) + } + if (run.status === 'cancelled') { + return new RuntimeClientError('cloud_routine_run_canceled', 'The run was canceled.', { run }) + } + return null +} + +export function createCloudRoutineHandlers( + getDeps: () => CloudHandlerDeps +): Record { + return { + 'cloud routine list': async ({ json }) => { + const deps = getDeps() + const api = cloudClient(deps) + const result = await api.request<{ routines: Routine[] }>('GET', '/v1/routines') + printCloudResult(result, json, (value) => formatRoutineList(value, deps.now())) + }, + 'cloud routine run': async ({ flags, json }) => { + const deps = getDeps() + const wait = flags.get('wait') === true + const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'timeout') + if (timeoutSeconds !== undefined && !wait) { + throw new RuntimeClientError('invalid_argument', '--timeout needs --wait.', { + field: 'timeout' + }) + } + const progress = cloudProgress(wait ? readCloudProgressMode(flags, json) : 'none', deps.now) + const api = cloudClient(deps) + const routine = await resolveRoutine(api, getRequiredStringFlag(flags, 'routine')) + const routinePath = `/v1/routines/${encodeURIComponent(routine.id)}` + let { run } = await api.request<{ run: RoutineRun }>('POST', `${routinePath}/run`) + if (wait) { + const limitMs = + timeoutSeconds !== undefined + ? timeoutSeconds * 1000 + : (routine.maxRuntimeMinutes ?? DEFAULT_RUNTIME_MINUTES) * 60_000 + WAIT_SLACK_MS + const deadline = deps.now() + limitMs + progress.phase(run.status, `${routine.name}: ${routineRunStateLabel(run.status)}`, { + runId: run.id + }) + while (!FINISHED.has(run.status)) { + if (deps.now() >= deadline) { + throw new RuntimeClientError( + 'cloud_routine_run_wait_timeout', + `The run is still ${routineRunStateLabel(run.status).toLowerCase()} after ${formatElapsed(limitMs)}. It keeps going in Orca Cloud.`, + { run } + ) + } + await deps.sleep(RUN_POLL_MS) + const previous = run.status + run = ( + await api.request<{ run: RoutineRun }>( + 'GET', + `${routinePath}/runs/${encodeURIComponent(run.id)}` + ) + ).run + if (run.status !== previous) { + progress.phase(run.status, `${routine.name}: ${routineRunStateLabel(run.status)}`, { + runId: run.id + }) + } + } + const failure = runFinishedError(routine, run) + if (failure) { + throw failure + } + } + printCloudResult({ routine: { id: routine.id, name: routine.name }, run }, json, (value) => + formatRoutineRun(value) + ) + } + } +} diff --git a/src/cli/cloud/cloud-specs.ts b/src/cli/cloud/cloud-specs.ts index c384ff14620..12dac23ff33 100644 --- a/src/cli/cloud/cloud-specs.ts +++ b/src/cli/cloud/cloud-specs.ts @@ -1,9 +1,10 @@ // Private build only: `orca cloud …` command specs. import type { CommandSpec } from '../args' import { LOCAL_COMMAND_FLAGS } from '../args' +import { ORCA_CLOUD_SANDBOX_SPEC } from '../../shared/hosted-sandbox/hosted-sandbox-wire' const CREDENTIALS_NOTE = - 'Credentials: ORCA_CLOUD_TOKEN (+ ORCA_CLOUD_API_URL), else ~/.config/orca-cloud/credentials.json (written by `orca cloud login`, and at boot inside an Orca Cloud workspace when the organization allows in-workspace CLI access). These commands call Orca Cloud over HTTPS and do not need the Orca app running.' + 'Credentials: ORCA_CLOUD_TOKEN (+ ORCA_CLOUD_API_URL), else ~/.config/orca-cloud/credentials.json (written by `orca cloud login`, and at boot inside an Orca Cloud workspace when the team allows in-workspace CLI access). These commands call Orca Cloud over HTTPS and do not need the Orca app running.' const WORKSPACE_ARG_NOTE = ' is a workspace id (sbx_…), a workspace name, or `self` inside an Orca Cloud workspace.' const JSON_NOTE = @@ -12,9 +13,10 @@ const JSON_NOTE = const PROGRESS_ACTIONS: ReadonlySet = new Set(['sleep', 'wake']) const lifecycle = ( - action: 'sleep' | 'wake' | 'archive' | 'destroy', + action: 'sleep' | 'wake' | 'archive' | 'delete' | 'destroy', summary: string, - notes: string[] = [] + notes: string[] = [], + extra: Partial = {} ): CommandSpec => ({ path: ['cloud', 'workspace', action], summary, @@ -25,7 +27,7 @@ const lifecycle = ( ...(PROGRESS_ACTIONS.has(action) ? ['progress'] : []) ], positionalArgs: ['workspace'], - ...(action === 'destroy' ? { destructive: true } : {}), + ...(action === 'delete' || action === 'destroy' ? { destructive: true } : {}), notes: [ ...notes, ...(PROGRESS_ACTIONS.has(action) @@ -34,9 +36,10 @@ const lifecycle = ( ] : []), WORKSPACE_ARG_NOTE, - 'A workspace token cannot sleep, archive or destroy its own workspace.' + 'A workspace token cannot sleep, archive or delete its own workspace.' ], - examples: [`orca cloud workspace ${action} sbx_0123abcd --json`] + examples: [`orca cloud workspace ${action} sbx_0123abcd --json`], + ...extra }) export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ @@ -78,7 +81,7 @@ export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...LOCAL_COMMAND_FLAGS, 'project', 'include-archived', 'all'], notes: [ '--all also lists archived and hidden workspaces.', - 'A workspace token (inside a VM) lists only the workspaces that workspace created.', + 'A workspace token (inside a sandbox) lists only the workspaces that workspace created.', JSON_NOTE ], examples: ['orca cloud workspace list', 'orca cloud workspace list --all --json'] @@ -103,14 +106,14 @@ export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ 'project', 'repo', 'ref', - 'size', 'region', 'agent', 'prompt' ], notes: [ - 'Returns once the VM is running. --prompt queues a first message for --agent (default claude), delivered as soon as the VM is up; follow it with `orca cloud messages`.', - 'Inside a workspace (workspace token) the new workspace is recorded with origin "vm" and is visible to the workspace that created it.', + 'Returns once the sandbox is running. --prompt queues a first message for --agent (default claude), delivered as soon as the sandbox is up; follow it with `orca cloud messages`.', + `Every sandbox is ${ORCA_CLOUD_SANDBOX_SPEC.vcpus} vCPU · ${ORCA_CLOUD_SANDBOX_SPEC.memoryGb} GB; there is no size to pick.`, + 'Inside a workspace (workspace token) the new workspace is visible to the workspace that created it.', CREDENTIALS_NOTE, JSON_NOTE ], @@ -119,10 +122,19 @@ export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ 'orca cloud workspace create --repo https://github.com/acme/app --name fix-login --agent claude --prompt "Fix the login redirect"' ] }, - lifecycle('sleep', 'Put an Orca Cloud workspace to sleep (disk kept, not billed)'), - lifecycle('wake', 'Wake a sleeping or archived Orca Cloud workspace'), - lifecycle('archive', 'Sleep an Orca Cloud workspace and hide it from default lists'), - lifecycle('destroy', 'Destroy an Orca Cloud workspace and its disk', ['This cannot be undone.']), + lifecycle( + 'sleep', + 'Put an Orca Cloud workspace to sleep (files are kept; asleep time is not billed)' + ), + lifecycle('wake', 'Wake an asleep or archived Orca Cloud workspace'), + lifecycle('archive', 'Put an Orca Cloud workspace to sleep and hide it from default lists'), + lifecycle('delete', 'Delete an Orca Cloud workspace and its sandbox for good', [ + 'This cannot be undone.' + ]), + // Renamed to `delete`; kept hidden so existing scripts keep working. + lifecycle('destroy', 'Delete an Orca Cloud workspace and its sandbox for good', [], { + hidden: true + }), { path: ['cloud', 'message'], summary: 'Send a message to an agent in an Orca Cloud workspace', @@ -140,7 +152,7 @@ export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ positionalArgs: ['workspace'], notes: [ 'Without --session a new agent session starts with the text as its prompt; --session (from `orca cloud sessions` or a previous message) types it into that session.', - 'A sleeping workspace is woken first. --wait polls until the message is delivered to the agent (default timeout 180s); it does not wait for the agent to finish.', + 'An asleep workspace wakes first. --wait polls until the message is delivered to the agent (default timeout 180s); it does not wait for the agent to finish.', WORKSPACE_ARG_NOTE, JSON_NOTE ], @@ -169,7 +181,7 @@ export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...LOCAL_COMMAND_FLAGS, 'workspace'], positionalArgs: ['workspace'], notes: [ - 'Only a running workspace has live sessions; a sleeping one lists none.', + 'Only a running workspace has live sessions; an asleep one lists none.', WORKSPACE_ARG_NOTE, JSON_NOTE ], @@ -181,5 +193,31 @@ export const CLOUD_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca cloud projects list [--json]', allowedFlags: [...LOCAL_COMMAND_FLAGS], notes: [JSON_NOTE] + }, + { + path: ['cloud', 'routine', 'list'], + summary: 'List Orca Cloud routines with their trigger and last run', + usage: 'orca cloud routine list [--json]', + allowedFlags: [...LOCAL_COMMAND_FLAGS], + notes: ['Needs the routines:read scope.', JSON_NOTE], + examples: ['orca cloud routine list', 'orca cloud routine list --json'] + }, + { + path: ['cloud', 'routine', 'run'], + summary: 'Run an Orca Cloud routine now', + usage: + 'orca cloud routine run [--wait [--timeout ] [--progress text|json|none]] [--json]', + allowedFlags: [...LOCAL_COMMAND_FLAGS, 'routine', 'wait', 'timeout', 'progress'], + positionalArgs: ['routine'], + notes: [ + ' is a routine id (rtn_…) or its exact name. Only the routine’s creator can run it. Needs the routines:write scope.', + 'The run starts its own cloud workspace. Run states: Queued, Starting, Running, Posting results, Succeeded, Failed, Canceled, Timed out.', + '--wait follows the run until it finishes (default timeout: the routine’s time limit plus 15 minutes) and exits non-zero if it fails, times out or is canceled. State changes go to stderr.', + JSON_NOTE + ], + examples: [ + 'orca cloud routine run "Triage new issues"', + 'orca cloud routine run rtn_0123abcd --wait --json' + ] } ] diff --git a/src/cli/cloud/index-cloud-commands.test.ts b/src/cli/cloud/index-cloud-commands.test.ts index ee83e53edf6..b6d47003f3a 100644 --- a/src/cli/cloud/index-cloud-commands.test.ts +++ b/src/cli/cloud/index-cloud-commands.test.ts @@ -104,7 +104,8 @@ describe('orca cloud via main()', () => { expect(process.exitCode).toBe(1) const body = JSON.parse(out.join('')) expect(body).toMatchObject({ ok: false, error: { code: 'cloud_vm_cli_disabled' } }) - expect(body.error.data.nextSteps[0]).toContain('vmCliEnabled') + expect(body.error.data).toMatchObject({ setting: 'vmCliEnabled' }) + expect(body.error.data.nextSteps[0]).toContain('Ask a team owner or admin') }) it('prints a readable error without --json', async () => { @@ -125,5 +126,142 @@ describe('orca cloud via main()', () => { it('shows help for the cloud group', async () => { await main(['cloud', '--help'], home) expect(out.join('')).toMatch(/workspace create +Create an Orca Cloud workspace/) + expect(out.join('')).toMatch( + /workspace delete +Delete an Orca Cloud workspace and its sandbox for good/ + ) + expect(out.join('')).toMatch(/routine run +Run an Orca Cloud routine now/) + // `destroy` still works but is hidden from help. + expect(out.join('')).not.toContain('workspace destroy') + }) + + it('deletes with `workspace delete` and the hidden `destroy` alias', async () => { + fetchMock.mockImplementation(async () => + Response.json({ workspace: { id: 'sbx_d1', name: 'old', status: 'destroyed' } }) + ) + await main(['cloud', 'workspace', 'delete', 'sbx_d1'], home) + expect(process.exitCode).toBeUndefined() + expect(out.join('\n')).toContain( + 'Deleted old (sbx_d1). Its sandbox and files are gone for good.' + ) + await main(['cloud', 'workspace', 'destroy', 'sbx_d1', '--json'], home) + expect(process.exitCode).toBeUndefined() + expect( + fetchMock.mock.calls.map(([url, init]) => `${String(init?.method)} ${String(url)}`) + ).toEqual([ + 'DELETE https://cloud.test/v1/workspaces/sbx_d1', + 'DELETE https://cloud.test/v1/workspaces/sbx_d1' + ]) + }) + + it('rejects --size on workspace create before any request', async () => { + await main(['cloud', 'workspace', 'create', '--size', 'large', '--json'], home) + expect(process.exitCode).toBe(1) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('lists workspaces with glossary states and keeps raw statuses for --json', async () => { + const listed = { + workspaces: [ + { + id: 'sbx_l1', + name: 'api', + status: 'sleeping', + archived: true, + repoUrl: 'https://github.com/acme/api.git', + repoBranch: 'main', + lastActivityAt: Date.now() - 120_000, + createdAt: 1, + updatedAt: 1 + } + ] + } + fetchMock.mockImplementation(async () => Response.json(listed)) + await main(['cloud', 'workspace', 'list'], home) + const text = out.join('\n') + expect(text).toMatch(/^ID +NAME +STATE +REPO +BRANCH +LAST ACTIVE$/m) + expect(text).toMatch(/sbx_l1 +api +Archived +acme\/api +main +2m ago/) + expect(text).not.toContain('sleeping') + out = [] + await main(['cloud', 'workspace', 'list', '--json'], home) + expect(JSON.parse(out.join(''))).toMatchObject({ + result: { workspaces: [{ status: 'sleeping', archived: true }] } + }) + }) + + it('prints glossary error copy for admin-only, rate-limited and out-of-hours errors', async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ error: 'forbidden', reason: 'admin_required' }, { status: 403 }) + ) + await main(['cloud', 'workspace', 'list'], home) + expect(err.join('\n')).toContain('Only a team owner or admin can do this.') + err = [] + fetchMock.mockResolvedValueOnce( + Response.json({ error: 'rate_limited' }, { status: 429, headers: { 'retry-after': '7' } }) + ) + await main(['cloud', 'workspace', 'list'], home) + expect(err.join('\n')).toContain('Try again in 7 seconds.') + err = [] + fetchMock.mockResolvedValueOnce( + Response.json( + { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, + { status: 403 } + ) + ) + await main(['cloud', 'workspace', 'create', '--name', 'x'], home) + expect(err.join('\n')).toContain( + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." + ) + expect(err.join('\n')).toContain('Ask an owner or admin to add hours.') + expect(err.join('\n')).not.toMatch(/entitlement_denied|included_hours_exhausted|files are safe/) + }) + + it('runs a routine by name and follows it with --wait', async () => { + const routine = { + id: 'rtn_r1', + name: 'Triage', + enabled: true, + trigger: { kind: 'webhook' }, + maxRuntimeMinutes: 30 + } + const statuses = ['running', 'posting', 'succeeded'] + fetchMock.mockImplementation(async (input, init) => { + const url = String(input) + if (url.endsWith('/v1/routines')) { + return Response.json({ routines: [routine] }) + } + if (url.endsWith('/v1/routines/rtn_r1/run') && init?.method === 'POST') { + return Response.json( + { run: { id: 'run_1', status: 'queued', result: null, durationMs: null } }, + { status: 202 } + ) + } + const status = statuses.shift() ?? 'succeeded' + return Response.json({ + run: { + id: 'run_1', + status, + durationMs: status === 'succeeded' ? 102_000 : null, + result: status === 'succeeded' ? { prUrl: 'https://github.com/acme/api/pull/7' } : null + } + }) + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + try { + const done = main(['cloud', 'routine', 'run', 'Triage', '--wait'], home) + await vi.runAllTimersAsync() + await done + } finally { + vi.useRealTimers() + } + expect(process.exitCode).toBeUndefined() + expect(err).toEqual([ + '- Triage: Queued', + '- Triage: Running', + '- Triage: Posting results', + '- Triage: Succeeded' + ]) + expect(out.join('\n')).toContain('Triage: Succeeded (run_1)') + expect(out.join('\n')).toContain('Took 1m 42s') + expect(out.join('\n')).toContain('Pull request: https://github.com/acme/api/pull/7') }) }) diff --git a/src/cli/cloud/index-worktree-create-on-cloud.test.ts b/src/cli/cloud/index-worktree-create-on-cloud.test.ts index 786f902adbb..c3bdfa9f858 100644 --- a/src/cli/cloud/index-worktree-create-on-cloud.test.ts +++ b/src/cli/cloud/index-worktree-create-on-cloud.test.ts @@ -147,10 +147,21 @@ describe('orca worktree create --on cloud via main()', () => { [ { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, 'cloud_out_of_hours', - 'out of sandbox hours' + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." + ], + [ + { error: 'entitlement_denied', reason: 'overage_cap_reached' }, + 'cloud_spend_cap_reached', + "Your team reached its monthly spend cap. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." + ], + [ + { error: 'conflict', reason: 'concurrent_transition' }, + 'cloud_conflict', + 'This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.', + 409 ] - ])('maps %o to %s with a next step', async (body, code, text) => { - fetchMock.mockResolvedValue(Response.json(body, { status: 403 })) + ])('maps %o to %s with a next step', async (body, code, text, status = 403) => { + fetchMock.mockResolvedValue(Response.json(body, { status })) await main( ['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app', '--json'], home @@ -184,4 +195,39 @@ describe('orca worktree create --on cloud via main()', () => { expect(fetchMock).not.toHaveBeenCalled() expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) + + it('rejects --size before any request (there is one sandbox size)', async () => { + await main(['worktree', 'create', '--on', 'cloud', '--size', 'large', '--json'], home) + expect(process.exitCode).not.toBeUndefined() + expect(JSON.parse(printed())).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('prints the glossary state and the sandbox size without --json, never the raw status', async () => { + fetchMock.mockResolvedValue( + Response.json( + { + workspace: { + id: 'sbx_on3', + name: 'fix', + status: 'running', + archived: false, + origin: 'api', + repoUrl: 'https://github.com/acme/app', + createdAt: 1, + updatedAt: 1 + } + }, + { status: 201 } + ) + ) + await main( + ['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app'], + home + ) + expect(process.exitCode).toBeUndefined() + expect(printed()).toContain('State: Running') + expect(printed()).toContain('Sandbox size: 8 vCPU · 16 GB') + expect(printed()).not.toMatch(/status: running|standard/) + }) }) diff --git a/src/cli/cloud/worktree-create-on-cloud.test.ts b/src/cli/cloud/worktree-create-on-cloud.test.ts index 38759a98369..3c70ed2201d 100644 --- a/src/cli/cloud/worktree-create-on-cloud.test.ts +++ b/src/cli/cloud/worktree-create-on-cloud.test.ts @@ -140,7 +140,7 @@ describe('worktree create --on cloud', () => { () => deps ) expect(bodies[0]).toEqual({ name: 't', projectId: 'prj_9' }) - expect(errs[0]).toContain('Creating an Orca Cloud workspace') + expect(errs[0]).toContain('Starting your cloud workspace') }) it('refuses flags and agents a cloud workspace cannot honour, before any request', async () => { diff --git a/src/cli/cloud/worktree-create-on-cloud.ts b/src/cli/cloud/worktree-create-on-cloud.ts index f9d57322e62..927296b5fc5 100644 --- a/src/cli/cloud/worktree-create-on-cloud.ts +++ b/src/cli/cloud/worktree-create-on-cloud.ts @@ -3,21 +3,20 @@ // [--agent claude|codex --prompt ] [--json] // --on cloud: uses an Orca Cloud project's repo and environment defaults. // It creates an Orca Cloud workspace through the public API with the same credentials as -// `orca cloud …`, so it works on a desktop and inside a VM (no Orca runtime involved). +// `orca cloud …`, so it works on a desktop and inside a sandbox (no Orca runtime involved). import { runProcessSync } from '@orca/process-host' import { existsSync } from 'node:fs' import { isAbsolute, resolve } from 'node:path' import type { HandlerContext } from '../dispatch' import { getOptionalStringFlag } from '../flags' import { RuntimeClientError } from '../runtime/types' -import { formatCloudWorkspace, printCloudResult } from './cloud-format' +import { cloudWorkspaceStateLabel, formatCloudWorkspace, printCloudResult } from './cloud-format' import { cloudProgress, readCloudProgressMode } from './cloud-progress' import { CLOUD_AGENTS, cloudClient, createCloudWorkspace, defaultCloudHandlerDeps, - getSizeFlag, isCloudAgent, normalizeCloudRepoUrl, resolveCloudProjectId, @@ -33,7 +32,6 @@ const SUPPORTED = new Set([ 'name', 'repo', 'base-branch', - 'size', 'agent', 'prompt', 'env', @@ -137,7 +135,7 @@ export async function createWorktreeOnCloud( if (unsupported.length > 0) { throw new RuntimeClientError( 'invalid_argument', - `--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --env, --size, --agent/--prompt.`, + `--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --env, --agent/--prompt.`, { field: unsupported[0] } ) } @@ -157,7 +155,6 @@ export async function createWorktreeOnCloud( } const progress = readCloudProgressMode(flags, json) const deps = getDeps() - const machineSize = getSizeFlag(flags) const ref = getOptionalStringFlag(flags, 'base-branch') const repo = target.project ? null : resolveCloudRepo(flags, cwd, deps.originUrl) if (ref && !repo && !target.project) { @@ -184,21 +181,20 @@ export async function createWorktreeOnCloud( ...(environment ? { environment } : {}), ...(target.project ? { projectId: await resolveCloudProjectId(api, target.project) } : {}), ...(repo ? { repo: { url: repo.url, ...(ref ? { ref } : {}) } } : {}), - ...(machineSize ? { machineSize } : {}), ...(prompt && agent ? { message: { text: prompt, agent } } : {}) } const progressLines = cloudProgress(progress) progressLines.phase( 'create', - `Creating an Orca Cloud workspace${repo ? ` from ${repo.url}` : ''}...` + `Starting your cloud workspace${repo ? ` from ${repo.url}` : ''}...` ) const created = await progressLines.track( createCloudWorkspace(api, input, deps.now), - (s) => `Still starting (${s}s). This is taking longer than usual; your files are safe.` + (s) => `Still starting (${s}s). This is taking longer than usual.` ) progressLines.phase( 'ready', - `Workspace ${created.workspace.id} is ${created.workspace.status} (took ${progressLines.elapsedSeconds()}s).`, + `Workspace ${created.workspace.id} is ${cloudWorkspaceStateLabel(created.workspace).toLowerCase()} (took ${progressLines.elapsedSeconds()}s).`, { workspaceId: created.workspace.id } ) printCloudResult( @@ -224,7 +220,7 @@ export async function maybeCreateWorktreeOnCloud( context: Pick ): Promise { const { flags } = context - if (!flags.has('on') && !flags.has('env') && !flags.has('size') && !flags.has('progress')) { + if (!flags.has('on') && !flags.has('env') && !flags.has('progress')) { return false } if (!readCloudOnTarget(flags)) { @@ -232,7 +228,7 @@ export async function maybeCreateWorktreeOnCloud( 'invalid_argument', flags.has('on') ? '--on supports `cloud` or `cloud:` in this build.' - : '--env, --size and --progress apply to `--on cloud`.', + : '--env and --progress apply to `--on cloud`.', { field: flags.has('on') ? 'on' : 'env' } ) } diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 60b44102359..515c7c175f4 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -246,6 +246,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ ], load: async () => (await import('./handlers/cloud-env.js')).CLOUD_ENV_HANDLERS }, + { + name: 'secret', + keys: ['secret request'], + load: async () => (await import('./handlers/secret.js')).SECRET_HANDLERS + }, { name: 'speech', keys: ['speech openai-key status', 'speech openai-key set', 'speech openai-key clear'], diff --git a/src/cli/handlers/cloud-env-format.ts b/src/cli/handlers/cloud-env-format.ts index 373c5a1bc2a..94ca047581d 100644 --- a/src/cli/handlers/cloud-env-format.ts +++ b/src/cli/handlers/cloud-env-format.ts @@ -15,11 +15,34 @@ export function formatOutcome(build: HostedEnvironmentBuildView): string { } if (build.status === 'failed') { const step = build.failedStep ? ` at the ${build.failedStep} step` : '' - return `Build #${build.number} failed${step}: ${build.error ?? 'unknown error'}.` + return `Build #${build.number} failed${step}. ${buildErrorText(build.error)}` } return `Build #${build.number} is ${build.status}.` } +/** A build's error code in words (mirrors the desktop's buildErrorCopy); --json keeps the code. */ +export function buildErrorText(error: string | null): string { + if (error === 'install_failed' || (error !== null && /^exit_\d+$/.test(error))) { + return 'The install script stopped at a failing command.' + } + if (error !== null && /^provider_\d+$/.test(error)) { + return "Orca Cloud couldn't start a sandbox. Try again in a few minutes." + } + const known: Record = { + clone_failed: + "A repository couldn't be cloned. Check that the Orca GitHub app can access it and the branch exists.", + record_failed: "Orca couldn't record the installed environment.", + timeout: 'The build ran past its time limit.', + snapshot_failed: 'Saving the snapshot failed.', + runtime_removed: 'The build sandbox was removed before it finished.', + boot_failed: 'The build sandbox failed to start.', + boot_timeout: 'The build sandbox took too long to start.', + vm_lost: 'Orca lost contact with the build sandbox.', + canceled: 'The build was canceled.' + } + return (error !== null && known[error]) || 'Something went wrong during the build.' +} + export function formatShow(data: CloudEnvironmentShowData): string { const env = data.environment const lines = [ @@ -75,10 +98,8 @@ function describeStaleness(recommendation: string, configChanged: boolean): stri function describeBuild(build: HostedEnvironmentBuildView): string { const parts = [`#${build.number} ${build.status}`] - if (build.status === 'failed') { - parts.push( - `${build.failedStep ? `at ${build.failedStep}` : ''}${build.error ? ` (${build.error})` : ''}`.trim() - ) + if (build.status === 'failed' && build.failedStep) { + parts.push(`at ${build.failedStep}`) } if (build.durationMs !== null) { parts.push(formatDuration(build.durationMs)) @@ -94,7 +115,7 @@ export function formatBuilds(data: CloudEnvironmentBuildsData): string { } const outcomes = data.builds.map((build) => build.status === 'failed' - ? `failed${build.failedStep ? ` at ${build.failedStep}` : ''}${build.error ? ` (${build.error})` : ''}` + ? `failed${build.failedStep ? ` at ${build.failedStep}` : ''}` : build.status ) // Column width from the widest outcome, so a long failure never shifts the columns after it. diff --git a/src/cli/handlers/cloud-env.test.ts b/src/cli/handlers/cloud-env.test.ts index a8a9ab1b805..eca606ee731 100644 --- a/src/cli/handlers/cloud-env.test.ts +++ b/src/cli/handlers/cloud-env.test.ts @@ -89,7 +89,9 @@ describe('orca cloud env build --wait', () => { await CLOUD_ENV_HANDLERS['cloud env build'](ctx) expect(stdout).toContain('::orca-step:: install') expect(stderr).toContain('npm ERR! missing') - expect(stderr).toContain('Build #7 failed at the install step: install_failed.') + expect(stderr).toContain( + 'Build #7 failed at the install step. The install script stopped at a failing command.' + ) expect(process.exitCode).toBe(1) }) diff --git a/src/cli/handlers/secret.test.ts b/src/cli/handlers/secret.test.ts new file mode 100644 index 00000000000..e7196f317df --- /dev/null +++ b/src/cli/handlers/secret.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientError } from '../runtime/types' +import { parseSecretRequestWait, runSecretRequest, type SecretRequestCliInput } from './secret' + +const HINT = + 'STRIPE_KEY is set for new shells in this workspace. To use it in this shell: eval "$(orca-env)" (fish: orca-env --fish | source).' + +function harness(answers: { request: unknown; statuses?: string[] }) { + const clock = { now: 0 } + const calls: { method: string; params: unknown }[] = [] + const printed: string[] = [] + const statuses = [...(answers.statuses ?? [])] + return { + calls, + printed, + deps: { + call: async (method: string, params: unknown): Promise => { + calls.push({ method, params }) + if (method === 'secrets.request') { + if (answers.request instanceof Error) { + throw answers.request + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: canned reply for this method. + return answers.request as T + } + const status = statuses.shift() ?? 'pending' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: canned requestStatus reply. + return { requestId: 'sr_abcdefgh', name: 'STRIPE_KEY', status, expiresAt: 0 } as T + }, + sleep: async (ms: number) => { + clock.now += ms + }, + now: () => clock.now, + print: (line: string) => printed.push(line) + } + } +} + +const base: SecretRequestCliInput = { + name: 'STRIPE_KEY', + reason: 'Run the checkout tests', + waitSec: null, + json: false, + worktreeId: 'wt-1' +} + +describe('orca secret request', () => { + it('names no terminal or agent: orcad derives them from verified launch evidence', async () => { + const h = harness({ request: { requestId: 'sr_abcdefgh', status: 'pending' } }) + await runSecretRequest({ ...base, scope: 'repo' }, h.deps) + expect(h.calls[0]).toEqual({ + method: 'secrets.request', + params: { + name: 'STRIPE_KEY', + reason: 'Run the checkout tests', + scope: 'repo', + worktreeId: 'wt-1' + } + }) + }) + + it('without --wait, reports the pending request and exits 0', async () => { + const h = harness({ request: { requestId: 'sr_abcdefgh', status: 'pending' } }) + await expect(runSecretRequest(base, h.deps)).resolves.toBe(0) + expect(h.printed).toEqual([ + 'Asked the person for STRIPE_KEY (sr_abcdefgh). Add --wait to wait for the answer.' + ]) + }) + + it('with --wait, polls until provided and prints the D4 reload hint exactly', async () => { + const h = harness({ + request: { requestId: 'sr_abcdefgh', status: 'pending' }, + statuses: ['pending', 'pending', 'provided'] + }) + await expect(runSecretRequest({ ...base, waitSec: 60 }, h.deps)).resolves.toBe(0) + expect(h.printed).toEqual([HINT]) + expect(h.calls.filter((c) => c.method === 'secrets.requestStatus')).toHaveLength(3) + }) + + it('prints JSON with the hint when provided', async () => { + const h = harness({ + request: { requestId: 'sr_abcdefgh', status: 'pending' }, + statuses: ['provided'] + }) + await runSecretRequest({ ...base, waitSec: 60, json: true }, h.deps) + expect(JSON.parse(h.printed[0])).toEqual({ + requestId: 'sr_abcdefgh', + name: 'STRIPE_KEY', + status: 'provided', + hint: HINT + }) + }) + + it('exits 1 when declined, expired, or the wait runs out', async () => { + for (const [status, line] of [ + ['declined', 'The person declined to provide STRIPE_KEY.'], + ['expired', 'The request for STRIPE_KEY expired before anyone answered it.'] + ]) { + const h = harness({ + request: { requestId: 'sr_abcdefgh', status: 'pending' }, + statuses: [status] + }) + await expect(runSecretRequest({ ...base, waitSec: 60 }, h.deps)).resolves.toBe(1) + expect(h.printed).toEqual([line]) + } + const h = harness({ request: { requestId: 'sr_abcdefgh', status: 'pending' } }) + await expect(runSecretRequest({ ...base, waitSec: 5 }, h.deps)).resolves.toBe(1) + expect(h.printed[0]).toBe( + 'Still waiting for STRIPE_KEY (sr_abcdefgh). Run the same command again to keep waiting.' + ) + }) + + it('reports already-set without polling', async () => { + const h = harness({ request: { requestId: null, status: 'already-set' } }) + await expect(runSecretRequest({ ...base, waitSec: 60 }, h.deps)).resolves.toBe(0) + expect(h.calls).toHaveLength(1) + expect(h.printed[0]).toContain('STRIPE_KEY is already set in this workspace.') + }) + + it('explains refusals in words', async () => { + const h = harness({ + request: new RuntimeClientError('runtime_error', 'use_sign_in_or_cloud_secrets') + }) + await expect(runSecretRequest({ ...base, name: 'ANTHROPIC_API_KEY' }, h.deps)).rejects.toThrow( + "ANTHROPIC_API_KEY is an agent credential: it comes from the person's sign-in or Orca Cloud secrets and can't be requested." + ) + }) + + it('parses --wait', () => { + expect(parseSecretRequestWait(undefined)).toBeNull() + expect(parseSecretRequestWait(true)).toBe(900) + expect(parseSecretRequestWait('30')).toBe(30) + expect(parseSecretRequestWait('99999')).toBe(3600) + expect(() => parseSecretRequestWait('soon')).toThrow('--wait takes a number of seconds') + expect(() => parseSecretRequestWait('0')).toThrow() + }) +}) diff --git a/src/cli/handlers/secret.ts b/src/cli/handlers/secret.ts new file mode 100644 index 00000000000..ba89eb2e672 --- /dev/null +++ b/src/cli/handlers/secret.ts @@ -0,0 +1,200 @@ +import type { CommandHandler } from '../dispatch' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime/types' +import { + SECRET_REQUEST_ERRORS, + SECRET_REQUEST_REASON_MAX, + SECRETS_REQUEST_METHOD, + SECRETS_REQUEST_STATUS_METHOD, + secretReloadHint, + type SecretRequestScope, + type SecretRequestStatus, + type SecretsRequestParams, + type SecretsRequestResult, + type SecretsRequestStatusResult +} from '../../shared/secret-request-contract' + +const DEFAULT_WAIT_SEC = 900 +const MAX_WAIT_SEC = 3600 +const POLL_MS = 2000 + +export type SecretRequestOutcome = SecretRequestStatus | 'already-set' + +export type SecretRequestCliDeps = { + call: (method: string, params: unknown) => Promise + sleep: (ms: number) => Promise + now: () => number + print: (line: string) => void +} + +export type SecretRequestCliInput = { + name: string + reason: string + scope?: SecretRequestScope + waitSec: number | null + json: boolean + worktreeId?: string +} + +/** Returns the exit code. */ +export async function runSecretRequest( + input: SecretRequestCliInput, + deps: SecretRequestCliDeps +): Promise { + const params: SecretsRequestParams = { + name: input.name, + reason: input.reason, + ...(input.scope ? { scope: input.scope } : {}), + // The calling terminal (and so the agent) travels as launch evidence that orcad verifies. + ...(input.worktreeId ? { worktreeId: input.worktreeId } : {}) + } + let created: SecretsRequestResult + try { + created = await deps.call(SECRETS_REQUEST_METHOD, params) + } catch (error) { + throw explainRequestError(error, input.name) + } + if (created.status === 'already-set') { + return finish(input, deps, { requestId: null, status: 'already-set' }) + } + const requestId = created.requestId + if (input.waitSec === null) { + return finish(input, deps, { requestId, status: 'pending' }) + } + const deadline = deps.now() + input.waitSec * 1000 + for (;;) { + const current = await deps.call(SECRETS_REQUEST_STATUS_METHOD, { + requestId + }) + if (current.status !== 'pending' || deps.now() >= deadline) { + return finish(input, deps, { requestId, status: current.status, waited: true }) + } + await deps.sleep(Math.min(POLL_MS, Math.max(0, deadline - deps.now()))) + } +} + +function finish( + input: SecretRequestCliInput, + deps: SecretRequestCliDeps, + result: { requestId: string | null; status: SecretRequestOutcome; waited?: boolean } +): number { + const answered = result.status === 'provided' || result.status === 'already-set' + const exitCode = answered || (result.status === 'pending' && !result.waited) ? 0 : 1 + if (input.json) { + deps.print( + JSON.stringify( + { + requestId: result.requestId, + name: input.name, + status: result.status, + ...(result.status === 'provided' || result.status === 'already-set' + ? { hint: secretReloadHint(input.name) } + : {}) + }, + null, + 2 + ) + ) + return exitCode + } + deps.print(formatSecretRequestOutcome(input.name, result.requestId, result.status, result.waited)) + return exitCode +} + +export function formatSecretRequestOutcome( + name: string, + requestId: string | null, + status: SecretRequestOutcome, + waited = false +): string { + switch (status) { + case 'provided': + return secretReloadHint(name) + case 'already-set': + return `${name} is already set in this workspace. If this shell doesn't have it yet: eval "$(orca-env)" (fish: orca-env --fish | source).` + case 'declined': + return `The person declined to provide ${name}.` + case 'expired': + return `The request for ${name} expired before anyone answered it.` + case 'pending': + return waited + ? `Still waiting for ${name} (${requestId}). Run the same command again to keep waiting.` + : `Asked the person for ${name} (${requestId}). Add --wait to wait for the answer.` + } +} + +const ERROR_MESSAGES: Record string> = { + [SECRET_REQUEST_ERRORS.invalidName]: (name) => + `${name} is not a valid secret name: use A-Z, 0-9 and _, not starting with a digit.`, + [SECRET_REQUEST_ERRORS.reservedName]: (name) => + `${name} is reserved: Orca or the system manages it, so it can't be a secret.`, + [SECRET_REQUEST_ERRORS.subscriptionTokenRefused]: () => + 'CLAUDE_CODE_OAUTH_TOKEN is never stored. Ask the person to sign Claude in instead.', + [SECRET_REQUEST_ERRORS.agentCredential]: (name) => + `${name} is an agent credential: it comes from the person's sign-in or Orca Cloud secrets and can't be requested.`, + [SECRET_REQUEST_ERRORS.tooManyPending]: () => + 'This workspace already has 3 secret requests waiting. Wait for one to be answered.', + [SECRET_REQUEST_ERRORS.rateLimited]: () => + 'This workspace asked for 10 secrets in the last hour. Try again later.', + [SECRET_REQUEST_ERRORS.unavailable]: () => + 'Secret requests work only inside an Orca Cloud workspace.' +} + +function explainRequestError(error: unknown, name: string): unknown { + if (!(error instanceof RuntimeClientError)) { + return error + } + const explain = ERROR_MESSAGES[error.message] + return explain ? new RuntimeClientError(error.message, explain(name)) : error +} + +export function parseSecretRequestWait(value: string | boolean | undefined): number | null { + if (value === undefined) { + return null + } + if (value === true) { + return DEFAULT_WAIT_SEC + } + const seconds = Number(value) + if (typeof value !== 'string' || !/^\d+$/.test(value) || seconds < 1) { + throw new RuntimeClientError('invalid_argument', '--wait takes a number of seconds') + } + return Math.min(seconds, MAX_WAIT_SEC) +} + +function parseScope(value: string | undefined): SecretRequestScope | undefined { + if (value === undefined || value === 'personal' || value === 'repo') { + return value + } + throw new RuntimeClientError('invalid_argument', '--scope is personal or repo') +} + +export const SECRET_HANDLERS: Record = { + 'secret request': async ({ flags, client, json }) => { + const name = getRequiredStringFlag(flags, 'name') + const reason = getRequiredStringFlag(flags, 'reason').trim() + if (reason.length === 0 || reason.length > SECRET_REQUEST_REASON_MAX) { + throw new RuntimeClientError( + 'invalid_argument', + `--reason must be 1-${SECRET_REQUEST_REASON_MAX} characters` + ) + } + process.exitCode = await runSecretRequest( + { + name, + reason, + scope: parseScope(getOptionalStringFlag(flags, 'scope')), + waitSec: parseSecretRequestWait(flags.get('wait')), + json, + worktreeId: process.env.ORCA_WORKTREE_ID || undefined + }, + { + call: async (method: string, params: unknown) => + (await client.call(method, params)).result, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: Date.now, + print: (line) => console.log(line) + } + ) + } +} diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index b28fd7feb1c..98692c52020 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -87,12 +87,14 @@ export const ROOT_HELP_TEXT_PRIMARY = [ '', 'Orca Cloud:', ' cloud whoami Show the Orca Cloud account these credentials act as', - ' cloud login Save an Orca Cloud API key for the CLI', - ' cloud workspace List, show, create, sleep, wake, archive or destroy workspaces', + ' cloud login Save an Orca API key for the CLI', + ' cloud workspace List, show, create, sleep, wake, archive or delete workspaces', ' cloud message Send a message to an agent in a cloud workspace', ' cloud messages List or show messages sent to a cloud workspace', ' cloud sessions List agent sessions in a cloud workspace', ' cloud projects list List Orca Cloud projects', + ' cloud routine List Orca Cloud routines or run one now', + ' secret request Ask the person for a secret this workspace needs', '', 'Automations:', ' automations list List scheduled Orca automations', diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 40b8c360b35..30ecf39f6d0 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -9,6 +9,7 @@ import { } from '../../shared/orchestration/orchestration-rpc-contract' import type { PairedRuntimeConnection } from '../../shared/remote-runtime/pairing/paired-runtime-connection' import { openLocalOrca } from './open-local-orca' +import { runtimeBuildFields } from './runtime-build-fields' import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus, @@ -247,7 +248,7 @@ export class RuntimeClient { status: response.result }), runtimeId: response.result.runtimeId, - ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), + ...runtimeBuildFields(response.result), ...(response.result.remoteUpdateSupport ? { remoteUpdateSupport: response.result.remoteUpdateSupport } : {}), diff --git a/src/cli/runtime/orchestration-compatibility-envelope.ts b/src/cli/runtime/orchestration-compatibility-envelope.ts index be78e8dee2f..84fbd908309 100644 --- a/src/cli/runtime/orchestration-compatibility-envelope.ts +++ b/src/cli/runtime/orchestration-compatibility-envelope.ts @@ -28,6 +28,8 @@ export function createAgentLaunchCallerEvidence( } const AGENT_LAUNCH_METHODS: ReadonlySet = new Set(['agent.launch', 'agent.launchReplay']) +/** Methods where orcad names the calling terminal (and its agent) from the shell's launch evidence. */ +const TERMINAL_CALLER_METHODS: ReadonlySet = new Set(['secrets.request']) /** The caller evidence one request carries, read from this process's environment once. */ export class OrchestrationRequestEvidence { @@ -58,6 +60,11 @@ export class OrchestrationRequestEvidence { compatibilityInvocationId: request.requestId ?? base.compatibilityInvocationId } } + if (TERMINAL_CALLER_METHODS.has(method)) { + // Terminal evidence only: it identifies a caller on this host, never on a remote server. + const shell = this.remoteRead.orchestrationCompatibilityEvidence + return request.remote || !shell ? {} : { orchestrationCompatibilityEvidence: shell } + } return AGENT_LAUNCH_METHODS.has(method) && !request.remote && this.agentLaunchCaller ? { orchestrationCompatibilityEvidence: this.agentLaunchCaller } : {} diff --git a/src/cli/runtime/orchestration-request-evidence.test.ts b/src/cli/runtime/orchestration-request-evidence.test.ts index aceef390bc2..9d5d3407068 100644 --- a/src/cli/runtime/orchestration-request-evidence.test.ts +++ b/src/cli/runtime/orchestration-request-evidence.test.ts @@ -26,3 +26,40 @@ describe('caller evidence on requests to another server', () => { ).toEqual({ agentSessionId: '11111111-1111-4111-8111-111111111111' }) }) }) + +describe('terminal evidence on secret requests', () => { + const SHELL = { + ...CHAT, + ORCA_TERMINAL_HANDLE: 'term_1', + ORCA_PANE_KEY: 'tab:leaf', + ORCA_AGENT_LAUNCH_TOKEN: 'tok' + } + + it('sends the shell evidence (never the chat id) to this host so orcad can verify the caller', () => { + const evidence = new OrchestrationRequestEvidence(SHELL) + expect( + evidence.envelopeFor('secrets.request', { + remote: false, + mutation: false, + requestId: undefined + }) + ).toEqual({ + orchestrationCompatibilityEvidence: { + terminalHandle: 'term_1', + paneKey: 'tab:leaf', + launchToken: 'tok' + } + }) + }) + + it('sends nothing to another server', () => { + const evidence = new OrchestrationRequestEvidence(SHELL) + expect( + evidence.envelopeFor('secrets.request', { + remote: true, + mutation: false, + requestId: undefined + }) + ).toEqual({}) + }) +}) diff --git a/src/cli/runtime/runtime-build-fields.test.ts b/src/cli/runtime/runtime-build-fields.test.ts new file mode 100644 index 00000000000..886aabb0b77 --- /dev/null +++ b/src/cli/runtime/runtime-build-fields.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { runtimeBuildFields } from './runtime-build-fields' + +const base: RuntimeStatus = { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 +} + +describe('runtimeBuildFields', () => { + it('reports the answering build and its protocol range', () => { + expect( + runtimeBuildFields({ + ...base, + appVersion: '1.2.3', + buildCommit: 'a'.repeat(40), + runtimeProtocolVersion: 0, + minCompatibleRuntimeClientVersion: 2 + }) + ).toEqual({ + appVersion: '1.2.3', + buildCommit: 'a'.repeat(40), + runtimeProtocolVersion: 0, + minCompatibleRuntimeClientVersion: 2 + }) + }) + + it('leaves out what an older host does not report', () => { + expect(runtimeBuildFields(base)).toEqual({}) + }) +}) diff --git a/src/cli/runtime/runtime-build-fields.ts b/src/cli/runtime/runtime-build-fields.ts new file mode 100644 index 00000000000..b746137b36c --- /dev/null +++ b/src/cli/runtime/runtime-build-fields.ts @@ -0,0 +1,20 @@ +import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' + +type RuntimeBuildFields = Pick< + CliStatusResult['runtime'], + 'appVersion' | 'buildCommit' | 'runtimeProtocolVersion' | 'minCompatibleRuntimeClientVersion' +> + +/** Which build answered: `orca status --json` shows it so version skew is visible without the desktop. */ +export function runtimeBuildFields(status: RuntimeStatus): RuntimeBuildFields { + return { + ...(status.appVersion ? { appVersion: status.appVersion } : {}), + ...(status.buildCommit ? { buildCommit: status.buildCommit } : {}), + ...(typeof status.runtimeProtocolVersion === 'number' + ? { runtimeProtocolVersion: status.runtimeProtocolVersion } + : {}), + ...(typeof status.minCompatibleRuntimeClientVersion === 'number' + ? { minCompatibleRuntimeClientVersion: status.minCompatibleRuntimeClientVersion } + : {}) + } +} diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index bce3467fa4d..dd689b5327c 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -2,6 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { runtimeHostConnectionState } from '../../shared/remote-runtime/host-status/runtime-host-connection-state' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { tryReadMetadata } from './metadata' +import { runtimeBuildFields } from './runtime-build-fields' import { sendRequest } from './transport' import { projectRemoteAppStatus, @@ -115,7 +116,7 @@ function projectConnectedStatus( status: response.result }), runtimeId: response.result.runtimeId, - ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), + ...runtimeBuildFields(response.result), ...(response.result.remoteUpdateSupport ? { remoteUpdateSupport: response.result.remoteUpdateSupport } : {}), diff --git a/src/cli/specs/cloud-env.ts b/src/cli/specs/cloud-env.ts index 5268ac9aed0..7abb74a33cf 100644 --- a/src/cli/specs/cloud-env.ts +++ b/src/cli/specs/cloud-env.ts @@ -39,7 +39,7 @@ export const CLOUD_ENV_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca cloud env build [--wait] [--env ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'env', 'wait'], notes: [ - 'A build boots a VM, clones the repos, runs the install script and snapshots the result. A succeeded build becomes the active one new workspaces start from.', + 'A build starts a sandbox, clones the repos, runs the install script and saves a snapshot. A successful build becomes the one new workspaces start from.', '--wait streams the log to stdout/stderr and exits 1 unless the build succeeds, printing the failed step and error. With --json it prints the final build and the end of the log instead.', 'Interrupting --wait leaves the build running; follow it again with `orca cloud env logs `.', ENV_NOTE diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index dfeb0239e7f..89714b6524c 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -26,6 +26,7 @@ import { VM_COMMAND_SPECS } from './vm' import { VM_ENV_COMMAND_SPECS } from './vm-env' import { CLOUD_ENV_COMMAND_SPECS } from './cloud-env' import { CLOUD_SSH_COMMAND_SPECS } from './cloud-ssh' +import { SECRET_COMMAND_SPECS } from './secret' import { SKILL_COMMAND_SPECS } from './skills' import { ARTIFACT_COMMAND_SPECS } from './artifacts' import { SEARCH_COMMAND_SPECS } from './search' @@ -66,6 +67,7 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...VM_ENV_COMMAND_SPECS, ...CLOUD_ENV_COMMAND_SPECS, ...CLOUD_SSH_COMMAND_SPECS, + ...SECRET_COMMAND_SPECS, ...EMULATOR_COMMAND_SPECS, ...SKILL_COMMAND_SPECS, ...SEARCH_COMMAND_SPECS, diff --git a/src/cli/specs/secret.ts b/src/cli/specs/secret.ts new file mode 100644 index 00000000000..da2b904c9b0 --- /dev/null +++ b/src/cli/specs/secret.ts @@ -0,0 +1,26 @@ +import type { CommandSpec } from '../args' +import { LOCAL_COMMAND_FLAGS } from '../args' + +export const SECRET_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['secret', 'request'], + summary: 'Ask the person to provide a secret for this workspace', + usage: + 'orca secret request --reason "" [--scope personal|repo] [--wait ] [--json]', + allowedFlags: [...LOCAL_COMMAND_FLAGS, 'name', 'reason', 'scope', 'wait'], + positionalArgs: ['name'], + notes: [ + 'Orca shows the person a card with NAME and your reason; they type the value. The value never passes through you or this command.', + 'Once provided, NAME is set for new shells in this workspace. In the current shell run eval "$(orca-env)" (fish: orca-env --fish | source).', + '--reason (at most 200 characters) is shown to the person as your words. Say what the secret unblocks.', + '--scope is a hint: the person chooses where it is saved.', + '--wait waits for an answer (default without a number: 900). Running the same request again while it is pending waits on it rather than asking twice.', + "Agent keys (ANTHROPIC_*, OPENAI_* and similar) cannot be requested: they come from the person's sign-in or Orca Cloud secrets.", + 'Exits 1 when the request is declined, expires, or --wait ends without an answer.' + ], + examples: [ + 'orca secret request STRIPE_TEST_KEY --reason "Run the checkout integration tests" --wait 600', + 'orca secret request SENTRY_DSN --reason "Upload source maps in the release script" --json' + ] + } +] diff --git a/src/cli/specs/worktree-create.ts b/src/cli/specs/worktree-create.ts index 4d0df971e34..db656bb0579 100644 --- a/src/cli/specs/worktree-create.ts +++ b/src/cli/specs/worktree-create.ts @@ -39,7 +39,6 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = { // Private build: `--on cloud` creates an Orca Cloud workspace (src/cli/cloud/worktree-create-on-cloud.ts). 'on', 'env', - 'size', 'progress' ], notes: [ diff --git a/src/main/billing/billing-client.ts b/src/main/billing/billing-client.ts index ff8d113bf25..c53653a6bc7 100644 --- a/src/main/billing/billing-client.ts +++ b/src/main/billing/billing-client.ts @@ -111,7 +111,8 @@ const OffersSchema = z concurrentSandboxesPerSeat: z.number().int().nonnegative(), idleSleepMinutes: z.number().int().positive(), overageCentsPerHour: z.number().int().positive(), - defaultSpendCapCents: z.number().int().nonnegative() + defaultSpendCapCents: z.number().int().nonnegative(), + maxSpendCapCents: z.number().int().positive().nullable().catch(null) }), enterprise: z.object({ label: z.string().max(40), diff --git a/src/main/daemon/pty-subprocess/spawn-environment-ports.test.ts b/src/main/daemon/pty-subprocess/spawn-environment-ports.test.ts new file mode 100644 index 00000000000..1dc815a64ed --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment-ports.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment } from './spawn-environment' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('daemon PTY env and the host launcher port block', () => { + it("keeps the pane's own workspace block over the host's ORCA_PORT", () => { + vi.stubEnv('ORCA_PORT', '3000') + vi.stubEnv('CONDUCTOR_PORT', '3000') + const env = createDaemonPtyEnvironment({ + sessionId: 's', + cols: 80, + rows: 24, + env: { ORCA_PORT: '21010', ORCA_PORT_COUNT: '10', CONDUCTOR_PORT: '21010' } + }) + expect([env.ORCA_PORT, env.ORCA_PORT_COUNT, env.CONDUCTOR_PORT]).toEqual([ + '21010', + '10', + '21010' + ]) + }) + + it("doesn't hand the host's block to a pane that has no workspace block", () => { + vi.stubEnv('ORCA_PORT', '3000') + vi.stubEnv('CONDUCTOR_PORT', '3000') + const env = createDaemonPtyEnvironment({ sessionId: 's', cols: 80, rows: 24, env: {} }) + expect(env.ORCA_PORT).toBeUndefined() + expect(env.CONDUCTOR_PORT).toBeUndefined() + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 8170166e150..5da6296bc0c 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -30,6 +30,7 @@ import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' import type { PtySubprocessOptions } from './pty-subprocess' import { REMOTE_VIEWER_CLIPBOARD_ENV } from '../../../shared/remote-viewer-clipboard-env' import { ORCAD_SERVER_LAUNCH_ENV_KEYS } from '../../../shared/orcad-server-launch-env' +import { WORKSPACE_PORT_ENV_KEYS } from '../../../shared/workspace-port-block' import { stripInheritedClaudeSessionEnv } from '../../../shared/agent-launch/startup/claude-parent-session-env' const PANE_IDENTITY_ENV_KEYS = [ @@ -40,6 +41,8 @@ const PANE_IDENTITY_ENV_KEYS = [ // Not identity but equally per-spawn: an inherited copy names another launch's CLI. 'ORCA_WSL_CLI_DIR', 'JCODE_RUNTIME_DIR', + // The host's own port block (a VM launcher sets one) is not this pane's workspace block. + ...WORKSPACE_PORT_ENV_KEYS, // Per-viewer: an Orca started from a remote viewer's pane must not mark its own local panes. REMOTE_VIEWER_CLIPBOARD_ENV, // A server's own launch env; a user's `orcad` in this shell is not that server. diff --git a/src/main/hosted-sandbox/cloud-workspace-ps-status.test.ts b/src/main/hosted-sandbox/cloud-workspace-ps-status.test.ts new file mode 100644 index 00000000000..ac69293f163 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-workspace-ps-status.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-worktree-contracts' + +const runtimes = vi.hoisted(() => { + const state: { list: unknown[]; reads: number; fail: boolean } = { + list: [], + reads: 0, + fail: false + } + return state +}) +vi.mock('../../shared/ephemeral-vm-runtime-store', () => ({ + listEphemeralVmRuntimes: () => { + runtimes.reads += 1 + if (runtimes.fail) { + throw new Error('unreadable') + } + return runtimes.list + } +})) + +import { notifyEphemeralVmRuntimesChanged } from '../../shared/ephemeral-vm-runtime-store-events' +import { + cloudWorkspaceStatusById, + markCloudWorkspaceRows, + resetCloudWorkspaceStatusCache +} from './cloud-workspace-ps-status' + +const cloud = (workspaceId: string, status: string, createdAt = 0) => ({ + recipe: { provider: 'orca-cloud' }, + workspaceId, + status, + createdAt +}) + +describe('cloudWorkspaceStatusById', () => { + beforeEach(() => { + runtimes.list = [] + runtimes.reads = 0 + runtimes.fail = false + }) + afterEach(() => resetCloudWorkspaceStatusCache()) + + it('lists only Orca Cloud workspaces, and a cleaned VM never hides a live one', () => { + // Newest first, as the store keeps it: wt-c's newer cleaned VM must not hide its live one, and + // wt-d, with only a cleaned VM, is still a cloud workspace (as on the desktop). + runtimes.list = [ + cloud('wt-a', 'running', 300), + { recipe: { provider: 'other' }, workspaceId: 'wt-b', status: 'running', createdAt: 300 }, + cloud('wt-c', 'cleaned', 200), + cloud('wt-c', 'suspended', 100), + cloud('wt-d', 'cleaned', 100) + ] + expect([...cloudWorkspaceStatusById('/u', 0)]).toEqual([ + ['wt-a', 'running'], + ['wt-c', 'suspended'], + ['wt-d', 'cleaned'] + ]) + }) + + it('speaks for a workspace with its newest VM, wherever that record sits in the file', () => { + runtimes.list = [cloud('wt-a', 'running', 200), cloud('wt-a', 'failed', 100)] + expect(cloudWorkspaceStatusById('/u', 0).get('wt-a')).toBe('running') + resetCloudWorkspaceStatusCache() + runtimes.list = [cloud('wt-a', 'failed', 100), cloud('wt-a', 'suspended', 200)] + expect(cloudWorkspaceStatusById('/u', 0).get('wt-a')).toBe('suspended') + }) + + it('reads the store once until it changes or the cache ages out', () => { + runtimes.list = [cloud('wt-a', 'running')] + cloudWorkspaceStatusById('/u', 0) + cloudWorkspaceStatusById('/u', 1_000) + expect(runtimes.reads).toBe(1) + runtimes.list = [cloud('wt-a', 'suspended')] + notifyEphemeralVmRuntimesChanged() + expect(cloudWorkspaceStatusById('/u', 2_000).get('wt-a')).toBe('suspended') + expect(runtimes.reads).toBe(2) + cloudWorkspaceStatusById('/u', 20_000) + expect(runtimes.reads).toBe(3) + }) + + it('marks nothing when the store is unreadable', () => { + runtimes.fail = true + expect(cloudWorkspaceStatusById('/u', 0).size).toBe(0) + }) +}) + +describe('markCloudWorkspaceRows', () => { + it('sets orcaCloud on cloud rows only', () => { + const rows: Pick[] = [ + { worktreeId: 'wt-a' }, + { worktreeId: 'wt-b' } + ] + markCloudWorkspaceRows(rows, new Map([['wt-a', 'suspended' as const]])) + expect(rows[0].orcaCloud).toEqual({ status: 'suspended' }) + expect(rows[1].orcaCloud).toBeUndefined() + }) +}) diff --git a/src/main/hosted-sandbox/cloud-workspace-ps-status.ts b/src/main/hosted-sandbox/cloud-workspace-ps-status.ts new file mode 100644 index 00000000000..1925de4bc46 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-workspace-ps-status.ts @@ -0,0 +1,80 @@ +// Which listed workspaces are Orca Cloud workspaces, for worktree.ps rows (the phone draws them as cloud). +// Why cached: ps runs on every list poll, and the runtime store is a file read plus a schema parse. +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' +import { onEphemeralVmRuntimesChanged } from '../../shared/ephemeral-vm-runtime-store-events' +import type { EphemeralVmRuntimeStatus } from '../../shared/ephemeral-vm-runtimes' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-worktree-contracts' + +// A write that skips the change event still shows up within this long. +const MAX_AGE_MS = 15_000 + +let cached: { + path: string + at: number + byWorkspaceId: Map +} | null = null +let unsubscribe: (() => void) | null = null + +/** Workspace id → the VM state this desktop last recorded, for Orca Cloud workspaces only. */ +export function cloudWorkspaceStatusById( + userDataPath: string, + now = Date.now() +): ReadonlyMap { + if (cached && cached.path === userDataPath && now - cached.at < MAX_AGE_MS) { + return cached.byWorkspaceId + } + unsubscribe ??= onEphemeralVmRuntimesChanged(() => { + cached = null + }) + const byWorkspaceId = new Map() + const createdAtById = new Map() + try { + for (const runtime of listEphemeralVmRuntimes(userDataPath)) { + if (runtime.recipe?.provider !== 'orca-cloud' || !runtime.workspaceId) { + continue + } + // After a rollover a workspace can have an old VM's record too: the newest live VM speaks for + // it. A cleaned record (that VM is gone) counts only when there is nothing else, so the row is + // still cloud, as on the desktop's own list (listCloudWorkspaceIds). + const id = runtime.workspaceId + const seen = createdAtById.get(id) + const seenCleaned = byWorkspaceId.get(id) === 'cleaned' + const cleaned = runtime.status === 'cleaned' + if ( + seen === undefined || + (seenCleaned && !cleaned) || + (seenCleaned === cleaned && runtime.createdAt >= seen) + ) { + createdAtById.set(runtime.workspaceId, runtime.createdAt) + byWorkspaceId.set(runtime.workspaceId, runtime.status) + } + } + } catch { + // An unreadable store marks nothing as cloud; the list itself must still load. + } + cached = { path: userDataPath, at: now, byWorkspaceId } + return byWorkspaceId +} + +/** Marks each Orca Cloud workspace's row; other rows are left as they are. */ +export function markCloudWorkspaceRows( + rows: Iterable>, + statusById: ReadonlyMap +): void { + if (statusById.size === 0) { + return + } + for (const row of rows) { + const status = statusById.get(row.worktreeId) + if (status) { + row.orcaCloud = { status } + } + } +} + +/** Test seam. */ +export function resetCloudWorkspaceStatusCache(): void { + cached = null + unsubscribe?.() + unsubscribe = null +} diff --git a/src/main/orcad/orcad-build-commit.test.ts b/src/main/orcad/orcad-build-commit.test.ts new file mode 100644 index 00000000000..55cdf324288 --- /dev/null +++ b/src/main/orcad/orcad-build-commit.test.ts @@ -0,0 +1,47 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readOrcadBuildCommit } from './orcad-build-commit' + +const A = 'a'.repeat(40) +const B = 'b'.repeat(40) +const roots: string[] = [] + +function installRoot(files: { sourceCommit?: string; pkg?: string }): string { + const root = mkdtempSync(join(tmpdir(), 'orcad-commit-')) + roots.push(root) + if (files.sourceCommit !== undefined) { + writeFileSync(join(root, 'SOURCE_COMMIT'), files.sourceCommit) + } + if (files.pkg !== undefined) { + mkdirSync(join(root, 'out')) + writeFileSync(join(root, 'out', 'package.json'), files.pkg) + } + return root +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('readOrcadBuildCommit', () => { + it('prefers the golden build SOURCE_COMMIT over the package stamp', async () => { + const root = installRoot({ sourceCommit: `${A}\n`, pkg: JSON.stringify({ commit: B }) }) + await expect(readOrcadBuildCommit(root)).resolves.toBe(A) + }) + + it('falls back to the package commit stamp', async () => { + const root = installRoot({ pkg: JSON.stringify({ commit: B }) }) + await expect(readOrcadBuildCommit(root)).resolves.toBe(B) + }) + + it('ignores malformed stamps', async () => { + const malformed = installRoot({ sourceCommit: 'main', pkg: '{"commit":"abc"}' }) + await expect(readOrcadBuildCommit(malformed)).resolves.toBeNull() + await expect(readOrcadBuildCommit(installRoot({ pkg: 'not json' }))).resolves.toBeNull() + await expect(readOrcadBuildCommit(installRoot({}))).resolves.toBeNull() + }) +}) diff --git a/src/main/orcad/orcad-build-commit.ts b/src/main/orcad/orcad-build-commit.ts new file mode 100644 index 00000000000..39a592f44d3 --- /dev/null +++ b/src/main/orcad/orcad-build-commit.ts @@ -0,0 +1,41 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { ORCAD_CLI_PACKAGE_FILENAME } from '../../shared/orcad-artifacts' + +/** Written beside the server by the Orca Cloud golden build (sandbox-control image/source-build.sh). */ +export const ORCAD_SOURCE_COMMIT_FILENAME = 'SOURCE_COMMIT' + +const FULL_COMMIT = /^[0-9a-f]{40}$/ + +async function readText(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch { + return null + } +} + +/** + * The commit this orcad bundle was built from. SOURCE_COMMIT wins: a golden built from uploaded + * source runs `git rev-parse` on a synthetic commit, so the package stamp there is not the real one. + */ +export async function readOrcadBuildCommit(installRoot: string): Promise { + const stamped = (await readText(join(installRoot, ORCAD_SOURCE_COMMIT_FILENAME))) + ?.trim() + .toLowerCase() + if (stamped && FULL_COMMIT.test(stamped)) { + return stamped + } + const pkg = await readText(join(installRoot, ...ORCAD_CLI_PACKAGE_FILENAME.split('/'))) + if (!pkg) { + return null + } + try { + const parsed: unknown = JSON.parse(pkg) + const commit = + parsed && typeof parsed === 'object' && 'commit' in parsed ? parsed.commit : undefined + return typeof commit === 'string' && FULL_COMMIT.test(commit) ? commit : null + } catch { + return null + } +} diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts index 8c5afd39de7..265853b662d 100644 --- a/src/main/orcad/orcad-host-adapters.ts +++ b/src/main/orcad/orcad-host-adapters.ts @@ -9,6 +9,8 @@ import { getOrcadCliLauncherPath } from './orcad-cli-launcher' import { createOrcadSecretStore, type OrcadSecretStore } from './orcad-secret-store' import { setSpeechServiceFactories } from '../speech/speech-runtime-service' import { orcadSpeechServiceFactories } from './orcad-speech-services' +import { readOrcadBuildCommit } from './orcad-build-commit' +import { setRuntimeBuildCommit } from '../runtime/hosts/runtime-build-commit' let quitRegisteredHandlers = (): void => {} @@ -75,6 +77,11 @@ export function stampOrcadAppVersion(installRoot: () => string = resolveOrcadIns export function installOrcadHostAdapters(): void { stampOrcadAppVersion() + // Desktops read it from status.get to tell which build a cloud sandbox runs (version skew). + // Async read: status.get answered in the first milliseconds simply omits it. + void Promise.resolve() + .then(() => readOrcadBuildCommit(resolveOrcadInstallRoot())) + .then(setRuntimeBuildCommit, () => {}) setAppEnvironment(createNodeAppEnvironment()) orcadSecretStore = createOrcadSecretStore() setSecretStore(orcadSecretStore) diff --git a/src/main/runtime/hosts/runtime-build-commit.ts b/src/main/runtime/hosts/runtime-build-commit.ts new file mode 100644 index 00000000000..a5d02fa5dc6 --- /dev/null +++ b/src/main/runtime/hosts/runtime-build-commit.ts @@ -0,0 +1,14 @@ +// The source commit this runtime was built from, reported by status.get as `buildCommit`. +// Only orcad stamps it (orcad/orcad-build-commit.ts); other hosts leave it unset and report nothing. +const FULL_COMMIT = /^[0-9a-f]{40}$/ + +let buildCommit: string | null = null + +export function setRuntimeBuildCommit(commit: string | null): void { + const trimmed = commit?.trim().toLowerCase() ?? '' + buildCommit = FULL_COMMIT.test(trimmed) ? trimmed : null +} + +export function getRuntimeBuildCommit(): string | null { + return buildCommit +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aeebd8bad02..67066396178 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -315,6 +315,23 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { return this.store?.getRemoteLayoutSnapshots?.() ?? {} } + /** The agent a terminal runs and its worktree, from host records (never from request params). */ + getTerminalHandleAgent(handle: string): { agent: string | null; worktreeId: string } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + return { + agent: record.ptyId ? this.getPtyAgent(record.ptyId) : null, + worktreeId: record.worktreeId + } + } + + /** Whether this host knows a worktree by id (a caller's hint is kept only when it does). */ + async isKnownWorktree(worktreeId: string): Promise { + return (await this.getResolvedWorktreeMap()).has(worktreeId) + } + /** Return the already-open record store without waiting. */ openedAgentSessionRecordStore(): AgentSessionRecordStore | null { return peekOpenedAgentSessionRecordStore() diff --git a/src/main/runtime/rpc/methods/client-events/client-events-secret-requests.test.ts b/src/main/runtime/rpc/methods/client-events/client-events-secret-requests.test.ts new file mode 100644 index 00000000000..6823510f6c5 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-events/client-events-secret-requests.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { getSecretRequestStore } from '../../../secrets/secret-request-store' +import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../../core' +// Import the registry to avoid client-events' module-init cycle through ipc/ssh. +import { ALL_RPC_METHODS } from '../rpc-method-registry' + +const methods = eraseRpcMethods(ALL_RPC_METHODS) +const subscribeMethod = methods + .filter(isStreamingMethod) + .find((method) => method.name === 'runtime.clientEvents.subscribe') + +function makeRuntime(): { runtime: OrcaRuntimeService; cleanups: (() => void)[] } { + const cleanups: (() => void)[] = [] + const fake = { + getRuntimeId: () => 'runtime-a', + onClientEvent: () => () => {}, + getClientSettings: () => ({ nativeChatQueueFollowUps: true }), + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => { + cleanups.push(cleanup) + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the subscribe handler reads only these members. + return { runtime: fake as unknown as OrcaRuntimeService, cleanups } +} + +beforeEach(() => { + vi.stubEnv('ORCA_IS_CLOUD', '1') + vi.stubEnv('ORCA_SANDBOX_ID', 'sbx_test') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +const secretEvents = (events: unknown[]): Record[] => + events.filter( + (event): event is Record => + typeof event === 'object' && + event !== null && + 'type' in event && + typeof event.type === 'string' && + event.type.startsWith('secrets.') + ) + +it('sends no secret requests from a desktop runtime', async () => { + vi.stubEnv('ORCA_IS_CLOUD', '') + if (!subscribeMethod) { + throw new Error('runtime.clientEvents.subscribe is not a streaming method') + } + getSecretRequestStore().request({ + name: 'LOCAL_ONLY', + reason: 'r', + agent: null, + worktreeId: null, + terminalHandle: null, + sessionEnvNames: new Set() + }) + const { runtime, cleanups } = makeRuntime() + const seen: unknown[] = [] + const done = subscribeMethod.handler( + {}, + { runtime, connectionId: 'conn-0', clientKind: 'runtime' }, + (event) => seen.push(event) + ) + expect(secretEvents(seen)).toEqual([]) + cleanups.forEach((cleanup) => cleanup()) + await done + getSecretRequestStore().resolve(getSecretRequestStore().pendingEvents()[0].requestId, 'declined') +}) + +it('registers the secrets.* methods', () => { + const names = methods.map((method) => method.name) + for (const name of [ + 'secrets.request', + 'secrets.requestStatus', + 'secrets.touchRequest', + 'secrets.resolveRequest' + ]) { + expect(names).toContain(name) + } +}) + +it('replays pending secret requests on attach, streams new ones, and gives phones status only', async () => { + if (!subscribeMethod) { + throw new Error('runtime.clientEvents.subscribe is not a streaming method') + } + const store = getSecretRequestStore() + // Made while no client was attached. + const early = store.request({ + name: 'EARLY_SECRET', + reason: 'needed before anyone attached', + agent: 'claude', + worktreeId: 'wt-1', + terminalHandle: 'term-1', + sessionEnvNames: new Set() + }) + const { runtime, cleanups } = makeRuntime() + const desktopCtx: RpcContext = { runtime, connectionId: 'conn-1', clientKind: 'runtime' } + const phoneCtx: RpcContext = { runtime, connectionId: 'conn-2', clientKind: 'mobile' } + const desktop: unknown[] = [] + const phone: unknown[] = [] + const done = [ + subscribeMethod.handler({}, desktopCtx, (event) => desktop.push(event)), + subscribeMethod.handler({}, phoneCtx, (event) => phone.push(event)) + ] + + expect(secretEvents(desktop)).toEqual([ + expect.objectContaining({ + type: 'secrets.requested', + requestId: early.requestId, + reason: 'needed before anyone attached' + }) + ]) + expect(secretEvents(phone)).toHaveLength(1) + expect(secretEvents(phone)[0]).not.toHaveProperty('reason') + + const live = store.request({ + name: 'LIVE_SECRET', + reason: 'asked while attached', + agent: null, + worktreeId: null, + terminalHandle: null, + sessionEnvNames: new Set() + }) + if (!live.requestId) { + throw new Error('expected a queued request') + } + store.resolve(live.requestId, 'declined') + expect(secretEvents(desktop).map((event) => event.type)).toEqual([ + 'secrets.requested', + 'secrets.requested', + 'secrets.requestSettled' + ]) + + cleanups.forEach((cleanup) => cleanup()) + await Promise.all(done) + const before = desktop.length + store.request({ + name: 'AFTER_CLOSE', + reason: 'r', + agent: null, + worktreeId: null, + terminalHandle: null, + sessionEnvNames: new Set() + }) + expect(desktop).toHaveLength(before) +}) diff --git a/src/main/runtime/rpc/methods/client-events/client-events.ts b/src/main/runtime/rpc/methods/client-events/client-events.ts index f78a7844631..ed23408c727 100644 --- a/src/main/runtime/rpc/methods/client-events/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events/client-events.ts @@ -16,6 +16,12 @@ import { } from '../../../../../shared/structured-chat-dispatch-telemetry' import { subscribeStructuredChatTelemetry } from '../../../../telemetry/structured-chat-dispatch' import { structuredCallerFor } from '../structured-sessions/creation/structured-agent-session-gate' +import { + getSecretRequestStore, + phoneSecretRequestEvent, + type SecretRequestStoreEvent +} from '../../../secrets/secret-request-store' +import { isCloudWorkspaceOrcad } from '../../../secrets/session-env-names' let clientEventSubscriptionSeq = 0 @@ -60,6 +66,13 @@ export const CLIENT_EVENT_METHODS = [ }) : undefined + // Agent secret requests (secrets.requested): phones get status only. + const emitSecretRequest = (event: SecretRequestStoreEvent): void => + emit(clientKind === 'mobile' ? phoneSecretRequestEvent(event) : event) + // Only a cloud workspace's orcad takes secret requests. + const secretRequests = isCloudWorkspaceOrcad() ? getSecretRequestStore() : null + const unsubscribeSecretRequests = secretRequests?.subscribe(emitSecretRequest) + const seq = ++clientEventSubscriptionSeq const subscriptionId = `runtime-client-events-${connectionId ?? 'inproc'}-${seq}` runtime.registerSubscriptionCleanup( @@ -68,6 +81,7 @@ export const CLIENT_EVENT_METHODS = [ unsubscribe() unregisterHostVolunteer?.() unsubscribeTelemetry?.() + unsubscribeSecretRequests?.() emit({ type: 'end' }) resolve() }, @@ -75,6 +89,10 @@ export const CLIENT_EVENT_METHODS = [ ) // Listen before snapshotting to close the subscribe race and replay sleep state missed during disconnect. + // Requests made while no client was attached wait here until they expire. + for (const event of secretRequests?.pendingEvents() ?? []) { + emitSecretRequest(event) + } for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) { emit(event) } diff --git a/src/main/runtime/rpc/methods/host-management/status.ts b/src/main/runtime/rpc/methods/host-management/status.ts index 8905927e996..8b346854e4d 100644 --- a/src/main/runtime/rpc/methods/host-management/status.ts +++ b/src/main/runtime/rpc/methods/host-management/status.ts @@ -1,6 +1,7 @@ import { defineMethod } from '../../core' import { getRemoteServerUpdaterSnapshot } from '../../../hosts/management/remote-server-updater' import { getPublishedHostDescriptor } from '../../../hosts/platform-probe/host-descriptor' +import { getRuntimeBuildCommit } from '../../../hosts/runtime-build-commit' import { StatusGetParams } from '../../../../../shared/rpc-contract/status-params' import { readHostMachineWithin } from '../../../hosts/platform-probe/runtime-host-machine' import { MACHINE_NAME_PUBLISH_WAIT_MS } from '../../../hosts/platform-probe/runtime-machine-name' @@ -23,12 +24,14 @@ export const STATUS_METHODS = [ ]) const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) const hostDescriptor = getPublishedHostDescriptor(runtime.getRuntimeId()) + const buildCommit = getRuntimeBuildCommit() return { ...runtime.getStatus(), ...(hostDescriptor ? { hostDescriptor } : {}), ...(hostMachine ? { hostMachine } : {}), ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, + ...(buildCommit ? { buildCommit } : {}), remoteUpdateSupport: snapshot.support } } diff --git a/src/main/runtime/rpc/methods/orchestration/turn-sender.ts b/src/main/runtime/rpc/methods/orchestration/turn-sender.ts index 696926a4499..891582cf604 100644 --- a/src/main/runtime/rpc/methods/orchestration/turn-sender.ts +++ b/src/main/runtime/rpc/methods/orchestration/turn-sender.ts @@ -24,6 +24,21 @@ function plainTerminalHandle(ctx: RpcContext): string | null { return terminal && !terminal.launchTokenHash && terminal.paneKey === paneKey ? handle : null } +/** + * The terminal an agent's own CLI call came from, as orcad attests it: a token-launched terminal + * proves its launch token, a plain one must match handle and pane key. Never read from params. + */ +export function verifiedCallerTerminalHandle(ctx: RpcContext): string | null { + // Agents reach orcad through the local CLI only; paired devices are people, not agents. + if (ctx.caller?.kind !== 'local-cli') { + return null + } + const authority = ctx.runtime.verifyOrchestrationCompatibilityCaller( + ctx.orchestrationCompatibilityEvidence + ) + return authority?.terminalHandle ?? plainTerminalHandle(ctx) +} + /** * Who drove me? For an agent's own tools: the caller is the terminal or structured session orcad * attests from its launch evidence, so one agent can never read (or claim) another agent's sender. @@ -40,10 +55,7 @@ function turnSender(ctx: RpcContext): TurnSenderResult { sender: drivingDeviceFor(agentSessionSenderKey(sessionId)) } } - const authority = ctx.runtime.verifyOrchestrationCompatibilityCaller( - ctx.orchestrationCompatibilityEvidence - ) - const handle = authority?.terminalHandle ?? plainTerminalHandle(ctx) + const handle = verifiedCallerTerminalHandle(ctx) if (!handle) { return { caller: null, sender: null } } diff --git a/src/main/runtime/rpc/methods/rpc-method-registry.ts b/src/main/runtime/rpc/methods/rpc-method-registry.ts index 457fe71b374..8f6474dded0 100644 --- a/src/main/runtime/rpc/methods/rpc-method-registry.ts +++ b/src/main/runtime/rpc/methods/rpc-method-registry.ts @@ -47,6 +47,7 @@ import { MANAGED_SERVER_METHODS } from './host-management/managed-server' import { RECIPE_VM_WAKE_METHODS } from './host-management/recipe-vm-wake' import { ORCA_CLOUD_DEVICE_ENROLLMENT_METHODS } from './orca-cloud/orca-cloud-device-enrollment' import { CLOUD_SSH_METHODS } from './cloud-ssh/cloud-ssh-methods' +import { SECRET_REQUEST_METHODS } from './secrets/secret-requests' import { ENVIRONMENT_DOCTOR_METHODS } from './host-management/environment-doctor' import { CLOUD_ENVIRONMENT_METHODS_RPC } from './cloud-environments/cloud-environments' import { SPEECH_METHODS } from './speech/speech' @@ -150,6 +151,7 @@ export const ALL_RPC_METHODS = [ ...MANAGED_SERVER_METHODS, ...RECIPE_VM_WAKE_METHODS, ...ORCA_CLOUD_DEVICE_ENROLLMENT_METHODS, + ...SECRET_REQUEST_METHODS, ...CLOUD_SSH_METHODS, ...ENVIRONMENT_DOCTOR_METHODS, ...CLOUD_ENVIRONMENT_METHODS_RPC, diff --git a/src/main/runtime/rpc/methods/secrets/secret-requests.test.ts b/src/main/runtime/rpc/methods/secrets/secret-requests.test.ts new file mode 100644 index 00000000000..fa06681e83d --- /dev/null +++ b/src/main/runtime/rpc/methods/secrets/secret-requests.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { SecretRequestStore } from '../../../secrets/secret-request-store' +import type { RpcContext, RpcMethod } from '../../core' +import { eraseRpcMethods } from '../../core' +import { createSecretRequestMethods } from './secret-requests' + +/** `verified` stands in for the terminal orcad attests from launch evidence. */ +function setup( + options: { sessionEnv?: string[]; enabled?: boolean; verified?: string | null } = {} +) { + let seq = 0 + const store = new SecretRequestStore(Date.now, () => `sr_rpc${String(++seq).padStart(5, '0')}`) + const methods = new Map( + eraseRpcMethods( + createSecretRequestMethods({ + store: () => store, + sessionEnvNames: async () => new Set(options.sessionEnv ?? []), + enabled: () => options.enabled ?? true, + callerTerminal: (ctx) => + ctx.caller?.kind === 'local-cli' ? (options.verified ?? null) : null + }) + ).map((method) => [method.name, method]) + ) + const terminals: Record = { + 'term-codex': { agent: 'codex', worktreeId: 'wt-real' } + } + const fake = { + getTerminalHandleAgent: (handle: string) => terminals[handle] ?? null, + isKnownWorktree: async (id: string) => id === 'wt-known' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only these members. + const runtime = fake as unknown as OrcaRuntimeService + const call = async (name: string, params: unknown, ctx: Partial = {}) => { + const method = methods.get(name) + if (!method) { + throw new Error(`${name} is not registered`) + } + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, { runtime, ...ctx }) + } + const events: unknown[] = [] + store.subscribe((event) => events.push(event)) + return { call, events } +} + +const desktop: Partial = { caller: { kind: 'paired-device', deviceId: 'dev-1' } } +const agentCli: Partial = { caller: { kind: 'local-cli' } } + +describe('secrets.* RPC', () => { + it('derives the agent and worktree from the verified calling terminal', async () => { + const { call, events } = setup({ verified: 'term-codex' }) + await expect( + call( + 'secrets.request', + { name: 'STRIPE_KEY', reason: 'tests', worktreeId: 'wt-known' }, + agentCli + ) + ).resolves.toEqual({ requestId: 'sr_rpc00001', status: 'pending' }) + expect(events[0]).toMatchObject({ + agent: 'codex', + worktreeId: 'wt-real', + terminalHandle: 'term-codex' + }) + }) + + it('takes no terminal or agent from params', async () => { + const { call } = setup() + for (const extra of [{ terminalHandle: 'term-codex' }, { agent: 'claude' }]) { + await expect( + call('secrets.request', { name: 'A', reason: 'r', ...extra }, agentCli) + ).rejects.toThrow() + } + }) + + it('without a verified terminal: agent unknown, worktree hint kept only if this host knows it', async () => { + const { call, events } = setup({ verified: null }) + await call('secrets.request', { name: 'A', reason: 'r', worktreeId: 'wt-known' }, agentCli) + await call('secrets.request', { name: 'B', reason: 'r', worktreeId: 'wt-made-up' }, agentCli) + expect(events[0]).toMatchObject({ agent: null, worktreeId: 'wt-known' }) + expect(events[0]).not.toHaveProperty('terminalHandle') + expect(events[1]).toMatchObject({ agent: null, worktreeId: null }) + }) + + it('refuses every method outside a cloud workspace orcad', async () => { + const { call } = setup({ enabled: false }) + await expect(call('secrets.request', { name: 'A', reason: 'r' })).rejects.toThrow( + 'secret_requests_unavailable' + ) + await expect( + call('secrets.requestStatus', { requestId: 'sr_rpc00001' }, desktop) + ).rejects.toThrow('secret_requests_unavailable') + }) + + it('answers already-set from the session env and refuses agent credentials', async () => { + const { call } = setup({ sessionEnv: ['SENTRY_DSN'] }) + await expect(call('secrets.request', { name: 'SENTRY_DSN', reason: 'r' })).resolves.toEqual({ + requestId: null, + status: 'already-set' + }) + await expect( + call('secrets.request', { name: 'ANTHROPIC_API_KEY', reason: 'r' }) + ).rejects.toThrow('use_sign_in_or_cloud_secrets') + }) + + it('strips control and bidi characters from the reason, and caps it at 200 characters', async () => { + const { call, events } = setup() + await call('secrets.request', { + name: 'A', + reason: 'Run‮ the\ntests\u0007 ⁦now⁩\u001b[31m' + }) + expect(events[0]).toMatchObject({ reason: 'Run the tests now [31m' }) + await expect(call('secrets.request', { name: 'B', reason: 'x'.repeat(201) })).rejects.toThrow() + await expect(call('secrets.request', { name: 'C', reason: '‮\n\u0000' })).rejects.toThrow() + }) + + it('lets a person, never the agent CLI, touch and resolve; anyone reads status', async () => { + const { call } = setup() + await call('secrets.request', { name: 'A', reason: 'r' }, agentCli) + const id = { requestId: 'sr_rpc00001' } + await expect(call('secrets.touchRequest', id, agentCli)).rejects.toThrow( + 'secret_request_forbidden' + ) + await expect( + call('secrets.resolveRequest', { ...id, outcome: 'provided' }, agentCli) + ).rejects.toThrow('secret_request_forbidden') + await expect(call('secrets.touchRequest', id, desktop)).resolves.toHaveProperty('expiresAt') + await expect( + call('secrets.resolveRequest', { ...id, outcome: 'provided' }, desktop) + ).resolves.toEqual({ outcome: 'provided' }) + await expect(call('secrets.requestStatus', id, agentCli)).resolves.toMatchObject({ + status: 'provided', + name: 'A' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/secrets/secret-requests.ts b/src/main/runtime/rpc/methods/secrets/secret-requests.ts new file mode 100644 index 00000000000..3d52f1afd64 --- /dev/null +++ b/src/main/runtime/rpc/methods/secrets/secret-requests.ts @@ -0,0 +1,122 @@ +import { + SECRET_REQUEST_ERRORS, + SECRETS_REQUEST_METHOD, + SECRETS_REQUEST_STATUS_METHOD, + SECRETS_RESOLVE_REQUEST_METHOD, + SECRETS_TOUCH_REQUEST_METHOD, + SecretsRequestIdParamsSchema, + SecretsRequestParamsSchema, + SecretsResolveRequestParamsSchema, + type SecretsRequestResult, + type SecretsRequestStatusResult, + type SecretsResolveRequestResult, + type SecretsTouchRequestResult +} from '../../../../../shared/secret-request-contract' +import { + getSecretRequestStore, + SecretRequestError, + type SecretRequestStore +} from '../../../secrets/secret-request-store' +import { isCloudWorkspaceOrcad, readSessionEnvNames } from '../../../secrets/session-env-names' +import { defineMethod, type RpcContext } from '../../core' +import { isAgentRpcCaller } from '../../rpc-caller-identity' +import { verifiedCallerTerminalHandle } from '../orchestration/turn-sender' + +export type SecretRequestDeps = { + store: () => SecretRequestStore + sessionEnvNames: () => Promise> + /** Only a cloud workspace's orcad takes requests; a desktop's local runtime refuses them. */ + enabled: () => boolean + /** The terminal the call came from, as orcad verifies it. */ + callerTerminal: (ctx: RpcContext) => string | null +} + +const defaultDeps: SecretRequestDeps = { + store: getSecretRequestStore, + sessionEnvNames: () => readSessionEnvNames(), + enabled: () => isCloudWorkspaceOrcad(), + callerTerminal: verifiedCallerTerminalHandle +} + +type CallerRuntime = Pick + +/** Agent and worktree from the verified calling terminal; a worktree hint only if this host knows it. */ +export async function resolveSecretRequestCaller( + runtime: CallerRuntime, + terminalHandle: string | null, + worktreeHint: string | undefined +): Promise<{ agent: string | null; worktreeId: string | null; terminalHandle: string | null }> { + const terminal = terminalHandle ? runtime.getTerminalHandleAgent(terminalHandle) : null + if (terminal) { + return { agent: terminal.agent, worktreeId: terminal.worktreeId, terminalHandle } + } + const worktreeId = + worktreeHint && (await runtime.isKnownWorktree(worktreeHint)) ? worktreeHint : null + return { agent: null, worktreeId, terminalHandle: null } +} + +/** Only a person's client (desktop or paired device) answers a request, never the agent's own CLI. */ +function requirePersonCaller(ctx: Pick): void { + if (isAgentRpcCaller(ctx.caller)) { + throw new SecretRequestError(SECRET_REQUEST_ERRORS.forbidden) + } +} + +export function createSecretRequestMethods(deps: SecretRequestDeps = defaultDeps) { + // Registered everywhere so the RPC catalog is one shape; accepted only on cloud orcad. + const store = (): SecretRequestStore => { + if (!deps.enabled()) { + throw new SecretRequestError(SECRET_REQUEST_ERRORS.unavailable) + } + return deps.store() + } + return [ + defineMethod({ + name: SECRETS_REQUEST_METHOD, + permission: 'workspace', + params: SecretsRequestParamsSchema, + handler: async (params, ctx): Promise => { + const requests = store() + const caller = await resolveSecretRequestCaller( + ctx.runtime, + deps.callerTerminal(ctx), + params.worktreeId + ) + return requests.request({ + name: params.name, + reason: params.reason, + scope: params.scope, + ...caller, + sessionEnvNames: await deps.sessionEnvNames() + }) + } + }), + defineMethod({ + name: SECRETS_REQUEST_STATUS_METHOD, + permission: 'workspace', + params: SecretsRequestIdParamsSchema, + handler: async (params): Promise => + store().status(params.requestId) + }), + defineMethod({ + name: SECRETS_TOUCH_REQUEST_METHOD, + permission: 'workspace', + params: SecretsRequestIdParamsSchema, + handler: async (params, ctx): Promise => { + requirePersonCaller(ctx) + return store().touch(params.requestId) + } + }), + defineMethod({ + name: SECRETS_RESOLVE_REQUEST_METHOD, + permission: 'workspace', + params: SecretsResolveRequestParamsSchema, + handler: async (params, ctx): Promise => { + requirePersonCaller(ctx) + return store().resolve(params.requestId, params.outcome) + } + }) + ] +} + +export const SECRET_REQUEST_METHODS = createSecretRequestMethods() diff --git a/src/main/runtime/secrets/secret-request-store.test.ts b/src/main/runtime/secrets/secret-request-store.test.ts new file mode 100644 index 00000000000..e38a832e32f --- /dev/null +++ b/src/main/runtime/secrets/secret-request-store.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { SECRET_REQUEST_ERRORS } from '../../../shared/secret-request-contract' +import { + phoneSecretRequestEvent, + SecretRequestError, + SecretRequestStore, + type SecretRequestInput, + type SecretRequestStoreEvent +} from './secret-request-store' + +const MIN = 60_000 + +function makeStore() { + const clock = { now: 1_000_000 } + let seq = 0 + const timers: { at: number; run: () => void; cancelled: boolean }[] = [] + const store = new SecretRequestStore( + () => clock.now, + () => `sr_test${String(++seq).padStart(4, '0')}`, + (run, ms) => { + const timer = { at: clock.now + ms, run, cancelled: false } + timers.push(timer) + return { cancel: () => (timer.cancelled = true) } + } + ) + const events: SecretRequestStoreEvent[] = [] + store.subscribe((event) => events.push(event)) + /** Moves the clock and fires due timers. */ + const advance = (ms: number): void => { + clock.now += ms + for (const timer of timers.splice(0)) { + if (timer.cancelled) { + continue + } + if (timer.at <= clock.now) { + timer.run() + } else { + timers.push(timer) + } + } + } + return { store, clock, events, advance } +} + +function input(name: string, extra: Partial = {}): SecretRequestInput { + return { + name, + reason: 'Run the integration tests', + agent: 'claude', + worktreeId: 'wt-1', + terminalHandle: 'term-1', + sessionEnvNames: new Set(), + ...extra + } +} + +function errorCode(fn: () => unknown): string | undefined { + try { + fn() + } catch (error) { + return error instanceof SecretRequestError ? error.code : undefined + } + return undefined +} + +describe('SecretRequestStore', () => { + it('queues a request, announces it and defaults the scope to personal', () => { + const { store, events } = makeStore() + expect(store.request(input('STRIPE_KEY', { scope: undefined }))).toEqual({ + requestId: 'sr_test0001', + status: 'pending' + }) + expect(events).toEqual([ + { + type: 'secrets.requested', + requestId: 'sr_test0001', + name: 'STRIPE_KEY', + reason: 'Run the integration tests', + scope: 'personal', + agent: 'claude', + worktreeId: 'wt-1', + terminalHandle: 'term-1', + requestedAt: 1_000_000, + expiresAt: 1_000_000 + 15 * MIN + } + ]) + expect(store.status('sr_test0001')).toMatchObject({ status: 'pending', name: 'STRIPE_KEY' }) + }) + + it('answers already-set without queueing when the session env has the name', () => { + const { store, events } = makeStore() + expect( + store.request(input('SENTRY_DSN', { sessionEnvNames: new Set(['SENTRY_DSN']) })) + ).toEqual({ requestId: null, status: 'already-set' }) + expect(events).toEqual([]) + expect(store.pendingEvents()).toEqual([]) + }) + + it.each([ + ['lower_case', SECRET_REQUEST_ERRORS.invalidName], + ['1STARTS_WITH_DIGIT', SECRET_REQUEST_ERRORS.invalidName], + ['A'.repeat(129), SECRET_REQUEST_ERRORS.invalidName], + ['CLAUDE_CODE_OAUTH_TOKEN', SECRET_REQUEST_ERRORS.subscriptionTokenRefused], + ['PATH', SECRET_REQUEST_ERRORS.reservedName], + ['GITHUB_TOKEN', SECRET_REQUEST_ERRORS.reservedName], + ['ORCA_ANYTHING', SECRET_REQUEST_ERRORS.reservedName], + ['LD_PRELOAD', SECRET_REQUEST_ERRORS.reservedName], + ['GIT_CONFIG_GLOBAL', SECRET_REQUEST_ERRORS.reservedName], + ['ANTHROPIC_API_KEY', SECRET_REQUEST_ERRORS.agentCredential], + ['AWS_BEARER_TOKEN_BEDROCK', SECRET_REQUEST_ERRORS.agentCredential], + ['OPENAI_BASE_URL', SECRET_REQUEST_ERRORS.agentCredential] + ])('refuses %s with %s', (name, code) => { + const { store, events } = makeStore() + expect(errorCode(() => store.request(input(name)))).toBe(code) + expect(events).toEqual([]) + }) + + it('still allows names that are not agent credentials (AI_GATEWAY_API_KEY, general AWS_*)', () => { + const { store } = makeStore() + expect(store.request(input('AI_GATEWAY_API_KEY')).status).toBe('pending') + expect(store.request(input('AWS_SECRET_ACCESS_KEY')).status).toBe('pending') + }) + + it('dedupes the same name while pending, without a second card', () => { + const { store, events } = makeStore() + const first = store.request(input('DB_URL')) + const again = store.request(input('DB_URL', { reason: 'different words' })) + expect(again).toEqual(first) + expect(events).toHaveLength(1) + }) + + it('allows at most 3 pending requests', () => { + const { store } = makeStore() + for (const name of ['A1', 'A2', 'A3']) { + store.request(input(name)) + } + expect(errorCode(() => store.request(input('A4')))).toBe(SECRET_REQUEST_ERRORS.tooManyPending) + // Answering one frees a slot. + store.resolve('sr_test0001', 'declined') + expect(store.request(input('A4')).status).toBe('pending') + }) + + it('allows at most 10 requests per hour, counting answered ones', () => { + const { store, clock } = makeStore() + for (let i = 0; i < 10; i += 1) { + const { requestId } = store.request(input(`N${i}`)) + if (!requestId) { + throw new Error('expected a queued request') + } + store.resolve(requestId, 'provided') + clock.now += MIN + } + expect(errorCode(() => store.request(input('N10')))).toBe(SECRET_REQUEST_ERRORS.rateLimited) + // The first falls out of the hour window. + clock.now = 1_000_000 + 60 * MIN + expect(store.request(input('N10')).status).toBe('pending') + }) + + it('expires pending requests after 15 minutes and stops replaying them', () => { + const { store, clock } = makeStore() + store.request(input('TOKEN')) + clock.now += 15 * MIN - 1 + expect(store.pendingEvents()).toHaveLength(1) + clock.now += 1 + expect(store.status('sr_test0001').status).toBe('expired') + expect(store.pendingEvents()).toEqual([]) + // An expired request frees its name and slot. + expect(store.request(input('TOKEN')).requestId).toBe('sr_test0002') + }) + + it('touch extends to now + 15 minutes, capped at 60 minutes after the request', () => { + const { store, clock } = makeStore() + store.request(input('TOKEN')) + clock.now += 10 * MIN + expect(store.touch('sr_test0001')).toEqual({ expiresAt: clock.now + 15 * MIN }) + for (let i = 0; i < 4; i += 1) { + clock.now += 10 * MIN + store.touch('sr_test0001') + } + // 60 minutes after the request, however often it was touched. + expect(store.status('sr_test0001').expiresAt).toBe(1_000_000 + 60 * MIN) + clock.now = 1_000_000 + 60 * MIN + expect(store.status('sr_test0001').status).toBe('expired') + expect(errorCode(() => store.touch('sr_test0001'))).toBe(SECRET_REQUEST_ERRORS.notPending) + }) + + it('resolves once; a request that expired mid-save reports expired', () => { + const { store, clock, events } = makeStore() + store.request(input('A')) + store.request(input('B')) + expect(store.resolve('sr_test0001', 'provided')).toEqual({ outcome: 'provided' }) + expect(store.resolve('sr_test0001', 'declined')).toEqual({ outcome: 'provided' }) + expect(store.status('sr_test0001').status).toBe('provided') + clock.now += 16 * MIN + expect(store.resolve('sr_test0002', 'provided')).toEqual({ outcome: 'expired' }) + expect(events.filter((e) => e.type === 'secrets.requestSettled')).toEqual([ + { type: 'secrets.requestSettled', requestId: 'sr_test0001', status: 'provided' } + ]) + }) + + it('reports unknown ids, and forgets old ones', () => { + const { store, clock } = makeStore() + expect(errorCode(() => store.status('sr_missing00'))).toBe(SECRET_REQUEST_ERRORS.notFound) + store.request(input('A')) + clock.now += 2 * 60 * MIN + expect(errorCode(() => store.status('sr_test0001'))).toBe(SECRET_REQUEST_ERRORS.notFound) + }) + + it('gives phones status only: no reason or terminal handle', () => { + const { store, events } = makeStore() + store.request(input('A')) + const event = events[0] + const phone = phoneSecretRequestEvent(event) + expect(phone).not.toHaveProperty('reason') + expect(phone).not.toHaveProperty('terminalHandle') + expect(phone).toMatchObject({ name: 'A', agent: 'claude', requestId: event.requestId }) + }) + + it('announces a touch, and a natural expiry (at the touched time), to every client', () => { + const { store, events, advance } = makeStore() + store.request(input('TOKEN')) + advance(10 * MIN) + store.touch('sr_test0001') + expect(events.at(-1)).toEqual({ + type: 'secrets.requestUpdated', + requestId: 'sr_test0001', + expiresAt: 1_000_000 + 25 * MIN + }) + // The original 15-minute expiry no longer fires. + advance(6 * MIN) + expect(events.filter((e) => e.type === 'secrets.requestSettled')).toEqual([]) + advance(9 * MIN) + expect(events.at(-1)).toEqual({ + type: 'secrets.requestSettled', + requestId: 'sr_test0001', + status: 'expired' + }) + }) + + it('announces no expiry for an answered request', () => { + const { store, events, advance } = makeStore() + store.request(input('TOKEN')) + store.resolve('sr_test0001', 'provided') + advance(20 * MIN) + expect(events.filter((e) => e.type === 'secrets.requestSettled')).toEqual([ + { type: 'secrets.requestSettled', requestId: 'sr_test0001', status: 'provided' } + ]) + }) +}) diff --git a/src/main/runtime/secrets/secret-request-store.ts b/src/main/runtime/secrets/secret-request-store.ts new file mode 100644 index 00000000000..4201e9af520 --- /dev/null +++ b/src/main/runtime/secrets/secret-request-store.ts @@ -0,0 +1,273 @@ +import { randomBytes } from 'node:crypto' +import { + SECRET_REQUEST_ERRORS, + SECRET_REQUEST_MAX_LIFETIME_MS, + SECRET_REQUEST_MAX_PENDING, + SECRET_REQUEST_MAX_PER_HOUR, + SECRET_REQUEST_TTL_MS, + SECRETS_REQUEST_SETTLED_EVENT, + SECRETS_REQUEST_UPDATED_EVENT, + SECRETS_REQUESTED_EVENT, + type SecretRequestedEvent, + type SecretRequestScope, + type SecretRequestSettledEvent, + type SecretRequestUpdatedEvent, + type SecretRequestStatus, + type SecretsRequestResult, + type SecretsRequestStatusResult, + type SecretsResolveRequestResult, + type SecretsTouchRequestResult +} from '../../../shared/secret-request-contract' +import { secretRequestNameRefusal } from '../../../shared/secret-request-names' + +const HOUR_MS = 60 * 60_000 + +export class SecretRequestError extends Error { + constructor(readonly code: string) { + super(code) + this.name = 'SecretRequestError' + } +} + +type SecretRequestRecord = { + requestId: string + name: string + reason: string + scope: SecretRequestScope + agent: string | null + worktreeId: string | null + terminalHandle: string | null + requestedAt: number + expiresAt: number + outcome: 'provided' | 'declined' | null +} + +export type SecretRequestInput = { + name: string + reason: string + scope?: SecretRequestScope + /** Derived by the caller from the terminal's agent session, never from request params. */ + agent: string | null + worktreeId: string | null + terminalHandle: string | null + /** Names currently in the session env. */ + sessionEnvNames: ReadonlySet +} + +export type SecretRequestStoreEvent = + | SecretRequestedEvent + | SecretRequestUpdatedEvent + | SecretRequestSettledEvent + +/** Schedules `run` after `ms`; the result cancels it. */ +export type SecretRequestTimer = (run: () => void, ms: number) => { cancel: () => void } + +const realTimer: SecretRequestTimer = (run, ms) => { + const handle = setTimeout(run, ms) + // A pending request must never keep orcad alive. + handle.unref?.() + return { cancel: () => clearTimeout(handle) } +} + +/** In-memory only (never on disk): one orcad serves one cloud workspace, so limits and dedupe are + * per store. Records outlive their request by an hour so `--wait` can read the outcome. */ +export class SecretRequestStore { + private readonly records = new Map() + private readonly listeners = new Set<(event: SecretRequestStoreEvent) => void>() + private readonly expiryTimers = new Map void }>() + + constructor( + private readonly now: () => number = Date.now, + private readonly newId: () => string = () => `sr_${randomBytes(12).toString('base64url')}`, + private readonly timer: SecretRequestTimer = realTimer + ) {} + + request(input: SecretRequestInput): SecretsRequestResult { + const refusal = secretRequestNameRefusal(input.name) + if (refusal) { + throw new SecretRequestError(refusal) + } + if (input.sessionEnvNames.has(input.name)) { + return { requestId: null, status: 'already-set' } + } + const now = this.now() + this.prune(now) + const pending = [...this.records.values()].filter((r) => this.statusOf(r, now) === 'pending') + const same = pending.find((r) => r.name === input.name) + if (same) { + return { requestId: same.requestId, status: 'pending' } + } + if (pending.length >= SECRET_REQUEST_MAX_PENDING) { + throw new SecretRequestError(SECRET_REQUEST_ERRORS.tooManyPending) + } + const lastHour = [...this.records.values()].filter((r) => now - r.requestedAt < HOUR_MS) + if (lastHour.length >= SECRET_REQUEST_MAX_PER_HOUR) { + throw new SecretRequestError(SECRET_REQUEST_ERRORS.rateLimited) + } + const record: SecretRequestRecord = { + requestId: this.newId(), + name: input.name, + reason: input.reason, + scope: input.scope ?? 'personal', + agent: input.agent, + worktreeId: input.worktreeId, + terminalHandle: input.terminalHandle, + requestedAt: now, + expiresAt: now + SECRET_REQUEST_TTL_MS, + outcome: null + } + this.records.set(record.requestId, record) + this.scheduleExpiry(record) + this.emit(toRequestedEvent(record)) + return { requestId: record.requestId, status: 'pending' } + } + + status(requestId: string): SecretsRequestStatusResult { + const record = this.require(requestId) + return { + requestId, + name: record.name, + status: this.statusOf(record, this.now()), + expiresAt: record.expiresAt + } + } + + /** D3: the card is in use, so keep the agent waiting: now + 15 min, capped at 60 min after the request. */ + touch(requestId: string): SecretsTouchRequestResult { + const record = this.require(requestId) + const now = this.now() + if (this.statusOf(record, now) !== 'pending') { + throw new SecretRequestError(SECRET_REQUEST_ERRORS.notPending) + } + record.expiresAt = Math.min( + now + SECRET_REQUEST_TTL_MS, + record.requestedAt + SECRET_REQUEST_MAX_LIFETIME_MS + ) + this.scheduleExpiry(record) + this.emit({ type: SECRETS_REQUEST_UPDATED_EVENT, requestId, expiresAt: record.expiresAt }) + return { expiresAt: record.expiresAt } + } + + /** First outcome wins. A request that expired mid-save reports `expired`; the saved value stands. */ + resolve(requestId: string, outcome: 'provided' | 'declined'): SecretsResolveRequestResult { + const record = this.require(requestId) + const status = this.statusOf(record, this.now()) + if (status !== 'pending') { + return { outcome: status } + } + record.outcome = outcome + this.clearExpiry(requestId) + this.emit({ type: SECRETS_REQUEST_SETTLED_EVENT, requestId, status: outcome }) + return { outcome } + } + + /** Requests still waiting, for replay to a client that attaches later. */ + pendingEvents(): SecretRequestedEvent[] { + const now = this.now() + return [...this.records.values()] + .filter((r) => this.statusOf(r, now) === 'pending') + .map(toRequestedEvent) + } + + subscribe(listener: (event: SecretRequestStoreEvent) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** Natural expiry is announced too, so every client drops the card at the same moment. */ + private scheduleExpiry(record: SecretRequestRecord): void { + this.clearExpiry(record.requestId) + const handle = this.timer( + () => { + this.expiryTimers.delete(record.requestId) + const now = this.now() + if (record.outcome || now < record.expiresAt) { + if (!record.outcome) { + this.scheduleExpiry(record) + } + return + } + this.emit({ + type: SECRETS_REQUEST_SETTLED_EVENT, + requestId: record.requestId, + status: 'expired' + }) + }, + Math.max(0, record.expiresAt - this.now()) + ) + this.expiryTimers.set(record.requestId, handle) + } + + private clearExpiry(requestId: string): void { + this.expiryTimers.get(requestId)?.cancel() + this.expiryTimers.delete(requestId) + } + + private statusOf(record: SecretRequestRecord, now: number): SecretRequestStatus { + if (record.outcome) { + return record.outcome + } + return now >= record.expiresAt ? 'expired' : 'pending' + } + + private require(requestId: string): SecretRequestRecord { + this.prune(this.now()) + const record = this.records.get(requestId) + if (!record) { + throw new SecretRequestError(SECRET_REQUEST_ERRORS.notFound) + } + return record + } + + private prune(now: number): void { + for (const [id, record] of this.records) { + if (now - record.requestedAt >= SECRET_REQUEST_MAX_LIFETIME_MS + HOUR_MS) { + this.records.delete(id) + } + } + } + + private emit(event: SecretRequestStoreEvent): void { + for (const listener of this.listeners) { + try { + listener(event) + } catch { + // One broken subscriber must not keep the others from seeing the request. + } + } + } +} + +function toRequestedEvent(record: SecretRequestRecord): SecretRequestedEvent { + return { + type: SECRETS_REQUESTED_EVENT, + requestId: record.requestId, + name: record.name, + reason: record.reason, + scope: record.scope, + agent: record.agent, + worktreeId: record.worktreeId, + ...(record.terminalHandle ? { terminalHandle: record.terminalHandle } : {}), + requestedAt: record.requestedAt, + expiresAt: record.expiresAt + } +} + +/** Phones show status only: no agent-written text. */ +export function phoneSecretRequestEvent(event: SecretRequestStoreEvent): SecretRequestStoreEvent { + if (event.type !== SECRETS_REQUESTED_EVENT) { + return event + } + const { reason: _reason, terminalHandle: _terminalHandle, ...status } = event + return status +} + +let sharedStore: SecretRequestStore | null = null + +/** The process's store: orcad's requests live as long as orcad. */ +export function getSecretRequestStore(): SecretRequestStore { + sharedStore ??= new SecretRequestStore() + return sharedStore +} diff --git a/src/main/runtime/secrets/session-env-names.test.ts b/src/main/runtime/secrets/session-env-names.test.ts new file mode 100644 index 00000000000..c07cbea7ceb --- /dev/null +++ b/src/main/runtime/secrets/session-env-names.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { isCloudWorkspaceOrcad, readSessionEnvNames } from './session-env-names' + +describe('readSessionEnvNames', () => { + it('reads names (never values) from the session env file', async () => { + const paths: string[] = [] + const names = await readSessionEnvNames({ + uid: 1000, + read: async (path) => { + paths.push(path) + return 'STRIPE_KEY=c2VjcmV0\nDB_URL=\nnot a line\nlower=eA==\n' + } + }) + expect(paths).toEqual(['/dev/shm/orca-env-1000/session.env']) + expect([...names]).toEqual(['STRIPE_KEY', 'DB_URL']) + }) + + it("is empty when there's no session env file, never orcad's own env", async () => { + const names = await readSessionEnvNames({ + uid: 1000, + read: async () => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + }) + expect([...names]).toEqual([]) + }) +}) + +describe('isCloudWorkspaceOrcad', () => { + it('needs both the cloud flag and a sandbox id', () => { + expect(isCloudWorkspaceOrcad({ ORCA_IS_CLOUD: '1', ORCA_SANDBOX_ID: 'sbx_1' })).toBe(true) + expect(isCloudWorkspaceOrcad({ ORCA_IS_CLOUD: '1' })).toBe(false) + expect(isCloudWorkspaceOrcad({ ORCA_SANDBOX_ID: 'sbx_1' })).toBe(false) + expect(isCloudWorkspaceOrcad({})).toBe(false) + }) +}) diff --git a/src/main/runtime/secrets/session-env-names.ts b/src/main/runtime/secrets/session-env-names.ts new file mode 100644 index 00000000000..63eef164ff7 --- /dev/null +++ b/src/main/runtime/secrets/session-env-names.ts @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises' + +/** Where /opt/orca/bin/orca-sandbox-env keeps the cloud session env (tmpfs; `NAME=` lines). */ +export function sessionEnvFilePath(uid: number): string { + return `/dev/shm/orca-env-${uid}/session.env` +} + +/** Names only, never values. No file yet means the session env holds nothing; orcad's own process + * env is never consulted, so nothing about the host leaks through `already-set`. */ +export async function readSessionEnvNames( + options: { uid?: number; read?: (path: string) => Promise } = {} +): Promise> { + const uid = options.uid ?? process.getuid?.() + const read = options.read ?? ((path: string) => readFile(path, 'utf8')) + if (uid === undefined) { + return new Set() + } + let text: string + try { + text = await read(sessionEnvFilePath(uid)) + } catch { + return new Set() + } + return new Set( + text + .split('\n') + .filter((line) => line.includes('=')) + .map((line) => line.slice(0, line.indexOf('='))) + .filter((name) => /^[A-Z_][A-Z0-9_]*$/.test(name)) + ) +} + +/** Secret requests exist only on a cloud workspace's orcad (orca-sandbox-start sets these). */ +export function isCloudWorkspaceOrcad(env: NodeJS.ProcessEnv = process.env): boolean { + return env.ORCA_IS_CLOUD === '1' && Boolean(env.ORCA_SANDBOX_ID?.trim()) +} diff --git a/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts b/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts index 6f5424779f2..0792d1c3205 100644 --- a/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts @@ -9,6 +9,10 @@ import { RuntimeChatWorkerEvents } from '../../orchestration/workers/turn-stops/ import type { RuntimeWorktreePsResult } from '../../../../shared/runtime-worktree-contracts' import type { ResolvedWorktreeSnapshot } from '../catalog/runtime-resolved-worktree-cache' import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries' +import { + cloudWorkspaceStatusById, + markCloudWorkspaceRows +} from '../../../hosted-sandbox/cloud-workspace-ps-status' import { buildRuntimeWorktreeSummaryPathIndex, createRuntimeWorktreePsSummaryLookup @@ -86,6 +90,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends RuntimeChatWorkerEven resolvedWorktrees, platformByRepoId }) + markCloudWorkspaceRows(summaries.values(), cloudWorkspaceStatusById(getProfileUserDataPath())) const getSummary = createRuntimeWorktreePsSummaryLookup( summaries, diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 13bd049c0bc..38dfd5329d7 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -66,6 +66,7 @@ export const CLI_COMMAND_NAMES = [ 'scroll', 'scrollintoview', 'search', + 'secret', 'select', 'select-all', 'sender', diff --git a/src/main/workspace-hooks/hooks.ts b/src/main/workspace-hooks/hooks.ts index 94a74cc949d..9b1400d6ebd 100644 --- a/src/main/workspace-hooks/hooks.ts +++ b/src/main/workspace-hooks/hooks.ts @@ -5,6 +5,7 @@ import { resolveHookCommandSourcePolicy } from '../../shared/hook-command-source import { getEffectiveHooksFromConfig } from './effective-hook-config' import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { getSetupEnvVars } from './setup-hook-env-vars' +import { getWorkspacePortBlockForPath } from '../workspace-ports/workspace-port-block-registry' import { iterateLfScriptLines } from './setup-runner-script-text' import { promptGuardShellEnv } from '../git/command-runner/git-process-env' import { dropIncoherentCondaActivationEnv } from '../pty/shell-startup/conda-activation-env' @@ -179,7 +180,15 @@ export function getSetupCommandSource( return null } -export function runHook( +export async function runHook( + ...args: Parameters +): ReturnType { + // Hooks see the workspace's own port block, which may not exist yet if no terminal opened. + await getWorkspacePortBlockForPath(args[1]).catch(() => null) + return runHookWithKnownPorts(...args) +} + +function runHookWithKnownPorts( hookName: 'setup' | 'archive', cwd: string, repo: Repo, diff --git a/src/main/workspace-ports/workspace-port-block-registry.test.ts b/src/main/workspace-ports/workspace-port-block-registry.test.ts index 973187f1601..cf26796da1f 100644 --- a/src/main/workspace-ports/workspace-port-block-registry.test.ts +++ b/src/main/workspace-ports/workspace-port-block-registry.test.ts @@ -6,7 +6,8 @@ import { WORKSPACE_PORT_BLOCK_SIZE, WORKSPACE_PORT_RANGE_END, WORKSPACE_PORT_RANGE_START, - buildWorkspacePortEnv + buildWorkspacePortEnv, + hostLauncherPortBase } from '../../shared/workspace-port-block' import { WorkspacePortBlockRegistry } from './workspace-port-block-registry' @@ -124,3 +125,40 @@ describe('peekBlock', () => { expect(new Set(blocks.map((block) => block.base)).size).toBe(20) }) }) + +describe('a host launcher block (VM launcher sets ORCA_PORT=3000)', () => { + it('goes to the first workspace; later ones get registry blocks that never overlap it', async () => { + const file = registryFile() + const registry = new WorkspacePortBlockRegistry(file, Date.now, 3000) + expect(await registry.getBlock('/vercel/orca/workspaces/project/main')).toEqual({ + base: 3000, + count: 10 + }) + const second = await registry.getBlock('/vercel/orca/workspaces/project/feature') + expect(second.base).toBeGreaterThanOrEqual(WORKSPACE_PORT_RANGE_START) + // After a wake the VM's registry file is read again: the first workspace keeps 3000. + const reloaded = new WorkspacePortBlockRegistry(file, Date.now, 3000) + expect(await reloaded.getBlock('/vercel/orca/workspaces/project/main')).toEqual({ + base: 3000, + count: 10 + }) + expect(await reloaded.getBlock('/vercel/orca/workspaces/project/feature')).toEqual(second) + }) + + it('skips registry slots that overlap a pinned block inside the registry range', async () => { + const registry = new WorkspacePortBlockRegistry(registryFile(), Date.now, 25005) + expect((await registry.getBlock('/first')).base).toBe(25005) + for (let i = 0; i < 200; i++) { + const { base } = await registry.getBlock(`/w-${i}`) + expect(base + 10 <= 25005 || base >= 25015).toBe(true) + } + }) + + it('reads the launcher block from the host env, never from a parent Orca pane', () => { + expect(hostLauncherPortBase({ ORCA_PORT: '3000' })).toBe(3000) + expect(hostLauncherPortBase({ ORCA_PORT: '3000', ORCA_WORKTREE_ID: 'r::/w' })).toBeNull() + expect(hostLauncherPortBase({ ORCA_PORT: '3000', ORCA_PANE_KEY: 't:l' })).toBeNull() + expect(hostLauncherPortBase({ ORCA_PORT: 'x' })).toBeNull() + expect(hostLauncherPortBase({})).toBeNull() + }) +}) diff --git a/src/main/workspace-ports/workspace-port-block-registry.ts b/src/main/workspace-ports/workspace-port-block-registry.ts index 4229e159d53..3d96854db71 100644 --- a/src/main/workspace-ports/workspace-port-block-registry.ts +++ b/src/main/workspace-ports/workspace-port-block-registry.ts @@ -5,6 +5,7 @@ import { WORKSPACE_PORT_BLOCK_SIZE, WORKSPACE_PORT_RANGE_END, WORKSPACE_PORT_RANGE_START, + hostLauncherPortBase, type WorkspacePortBlock } from '../../shared/workspace-port-block' import { splitWorktreeId } from '../../shared/worktree/id' @@ -32,11 +33,14 @@ function slotBase(slot: number): number { return WORKSPACE_PORT_RANGE_START + slot * WORKSPACE_PORT_BLOCK_SIZE } -function readBlock(value: unknown): PersistedBlock | null { +function readBlock(value: unknown, pinnedBase: number | null): PersistedBlock | null { if (typeof value !== 'object' || value === null) { return null } const { base, allocatedAt }: Record = { ...value } + if (typeof base === 'number' && base === pinnedBase && typeof allocatedAt === 'number') { + return { base, allocatedAt } + } if ( typeof base !== 'number' || !Number.isInteger(base) || @@ -50,7 +54,7 @@ function readBlock(value: unknown): PersistedBlock | null { return { base, allocatedAt } } -function parseRegistry(text: string): Map { +function parseRegistry(text: string, pinnedBase: number | null): Map { const blocks = new Map() const parsed: unknown = JSON.parse(text) const entries = @@ -60,7 +64,7 @@ function parseRegistry(text: string): Map { } const seenBases = new Set() for (const [key, value] of Object.entries(entries)) { - const block = readBlock(value) + const block = readBlock(value, pinnedBase) // A hand-edited duplicate keeps its first owner; the second re-allocates on next use. if (block && !seenBases.has(block.base)) { seenBases.add(block.base) @@ -85,7 +89,13 @@ export class WorkspacePortBlockRegistry { constructor( private readonly filePath: string, - private readonly now: () => number = Date.now + private readonly now: () => number = Date.now, + /** + * A host launcher's own block (a VM's launcher sets ORCA_PORT=3000 for everything it starts). + * The first workspace to ask keeps it, so its agents, terminals and setup agree; every later + * workspace gets a registry block, so none collide. + */ + private readonly pinnedBase: number | null = null ) {} /** The block already known in memory, without touching disk (null until first allocation). */ @@ -119,10 +129,18 @@ export class WorkspacePortBlockRegistry { private allocate(key: string, blocks: Map): number { const taken = new Set([...blocks.values()].map((block) => block.base)) + const pinned = this.pinnedBase + if (pinned !== null && !taken.has(pinned)) { + return pinned + } const preferred = hashKey(key) % SLOT_COUNT for (let offset = 0; offset < SLOT_COUNT; offset++) { const base = slotBase((preferred + offset) % SLOT_COUNT) - if (!taken.has(base)) { + const overlapsPinned = + pinned !== null && + base < pinned + WORKSPACE_PORT_BLOCK_SIZE && + pinned < base + WORKSPACE_PORT_BLOCK_SIZE + if (!taken.has(base) && !overlapsPinned) { return base } } @@ -141,7 +159,7 @@ export class WorkspacePortBlockRegistry { private async load(): Promise> { try { - return parseRegistry(await readFile(this.filePath, 'utf8')) + return parseRegistry(await readFile(this.filePath, 'utf8'), this.pinnedBase) } catch { // Missing or unreadable: keep what this process knows; allocations re-derive from the hash. return new Map(this.blocks ?? []) @@ -167,7 +185,9 @@ function getDefaultRegistry(): WorkspacePortBlockRegistry | null { if (!defaultRegistry) { try { defaultRegistry = new WorkspacePortBlockRegistry( - join(getCanonicalUserDataPath(), 'workspace-port-blocks.json') + join(getCanonicalUserDataPath(), 'workspace-port-blocks.json'), + Date.now, + hostLauncherPortBase(process.env) ) } catch { return null @@ -185,6 +205,14 @@ export async function getWorkspacePortBlockForWorktreeId( return registry && path ? registry.getBlock(path) : null } +/** The block for a checkout path (setup and archive hooks run before any terminal may exist). */ +export async function getWorkspacePortBlockForPath( + worktreePath: string +): Promise { + const registry = worktreePath ? getDefaultRegistry() : null + return registry ? registry.getBlock(worktreePath) : null +} + /** For synchronous callers (setup/archive env): known once any terminal in the workspace opened. */ export function peekWorkspacePortBlock(worktreePath: string): WorkspacePortBlock | null { return worktreePath ? (getDefaultRegistry()?.peekBlock(worktreePath) ?? null) : null diff --git a/src/renderer/src/components/billing/PaymentIssueNotice.tsx b/src/renderer/src/components/billing/PaymentIssueNotice.tsx index 968e2c4888c..88996b7aabf 100644 --- a/src/renderer/src/components/billing/PaymentIssueNotice.tsx +++ b/src/renderer/src/components/billing/PaymentIssueNotice.tsx @@ -1,5 +1,6 @@ -import { AlertTriangle, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' @@ -31,48 +32,48 @@ export function PaymentIssueNotice({ minute: '2-digit', timeZoneName: 'short' }).format(ms) + const body = + payment.suspendRunning || payment.graceEndsAt === null + ? t( + 'auto.components.billing.paymentAsleepBody', + 'New sandboxes can’t start and running sandboxes are asleep. They can wake again once the payment goes through. Your files are safe.' + ) + : t( + 'auto.components.billing.paymentGraceBody2', + 'New sandboxes can’t start. Running sandboxes go to sleep on {{value0}} unless the payment goes through.', + { value0: when(payment.graceEndsAt) } + ) return ( -
- -
-

- {t('auto.components.billing.paymentFailed', 'Payment failed')} -

-

- {payment.suspendRunning || payment.graceEndsAt === null - ? t( - 'auto.components.billing.paymentAsleepBody', - 'New sandboxes can’t start and running sandboxes are asleep. They can wake again once the payment goes through. Your files are safe.' - ) - : t( - 'auto.components.billing.paymentGraceBody2', - 'New sandboxes can’t start. Running sandboxes go to sleep on {{value0}} unless the payment goes through.', - { value0: when(payment.graceEndsAt) } - )} -

- {entitlement.canManageBilling ? ( - + + <> + {body}{' '} {t( 'auto.components.billing.paymentAskAdmin', 'Ask an owner or admin to update the payment method.' )} -

- )} -
-
+ + ) + } + action={ + entitlement.canManageBilling ? ( + + ) : undefined + } + /> ) } diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx index c07c627551b..e453da5969b 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -77,6 +77,27 @@ export const overageCapReached = defineFixture({ ) }) +// Overage is off by default: out of included hours, running sandboxes go to sleep. +export const hoursUsedOverageOff = defineFixture({ + width: 600, + render: () => ( + + ) +}) + export const pastDueMember = defineFixture({ width: 600, render: () => ( @@ -126,3 +147,77 @@ export const suspendedAdmin = defineFixture({ /> ) }) + +/** First open of Settings › Orca account: the section holds its shape while billing answers. */ +export const loading = defineFixture({ + width: 600, + render: () => +}) + +/** Billing's 75% level: the early warning under the bar (still the neutral tone below 90%). */ +export const usageWarning75 = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +/** Billing's 90% level: the bar turns to the warning tone; one hour left reads singular. */ +export const usageWarning90 = defineFixture({ + width: 600, + render: () => ( + + ) +}) + +/** An admin types a cap above billing's maximum: the field says what the maximum is. */ +export const capTooHigh = defineFixture({ + width: 600, + render: () => ( + + ), + interact: async (frame) => { + const input = frame.querySelector('input[inputmode="numeric"]') + if (!input) { + return + } + input.focus() + // React listens for native input events. + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, '6000') + input.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise((resolve) => setTimeout(resolve, 100)) + } +}) diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index 47cc1fc4c1d..d76cba535d8 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -3,6 +3,7 @@ import { SettingsGroupHeader } from '@/components/settings/section-layout/Settin import { CreditCard, ExternalLink, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' +import { Skeleton } from '@/components/ui/skeleton' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { formatCents } from './plans/plans-pricing' import { cn } from '@/lib/utils' @@ -18,11 +19,17 @@ import { OverageControls, UsageSummary } from './PlanBillingUsage' import { denyReasonCopy, openFailureCopy, overageFailureCopy } from './plan-billing-copy' import { PlanStatusBadge } from './PlanStatusBadge' import { useOrcaBillingStatus } from './use-orca-billing-status' -import { invalidateOrcaCloudBilling } from '../new-workspace/orca-cloud-billing-cache' +import { useAppStore } from '@/store/index' +import { BILLING_CAPABILITY } from './register-plan-billing-menu-item' export function PlanBillingSection(): React.JSX.Element | null { const t = useTranslate() + const locale = useIntlLocale() const { loading, result, refresh } = useOrcaBillingStatus(true) + // Only billing-flagged accounts get a skeleton; others hear "unconfigured" and show nothing. + const billingFlagged = useAppStore( + (state) => state.orcaProfileAuthStatus?.capabilities?.flags[BILLING_CAPABILITY] === true + ) const [busy, setBusy] = useState(false) const [notice, setNotice] = useState(null) const [userRefreshing, setUserRefreshing] = useState(false) @@ -61,11 +68,12 @@ export function PlanBillingSection(): React.JSX.Element | null { })) setBusy(false) if (outcome.status === 'ok') { + // One shared store: this re-read also updates the Run-on picker's Orca Cloud row. refresh() - // The Run-on picker's Orca Cloud row reads a shared copy; overage/cap just changed it. - invalidateOrcaCloudBilling() } else { - const failure = overageFailureCopy(outcome, t) + const maxCap = + result?.status === 'ok' ? (result.entitlement.offers?.team.maxSpendCapCents ?? null) : null + const failure = overageFailureCopy(outcome, t, maxCap, locale) setNotice(failure ? { kind: 'error', text: failure } : null) setOverageFailures((count) => count + 1) } @@ -74,7 +82,7 @@ export function PlanBillingSection(): React.JSX.Element | null { return ( + } if (!result || result.status === 'unconfigured' || result.status === 'signed-out') { // The pane above already handles sign-in; billing stays out of builds without a service. return null @@ -268,3 +279,36 @@ export function PlanBillingView({ ) } + +/** Same rows as a loaded plan (header, plan line, usage, overage card) so nothing jumps in. */ +function PlanBillingSkeleton(): React.JSX.Element { + const t = useTranslate() + return ( +
+ +
+ + + +
+
+
+ + +
+ + +
+ + +
+ ) +} diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index b56af82d4cd..1a1ca165028 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -11,7 +11,14 @@ import type { OrcaBillingOverageArgs } from '../../../../shared/orca-billing' import { formatHours, includedUsagePercent } from './plan-billing-format' +import { + includedUsageTone, + sandboxHoursLeftLine, + sandboxHoursOverLine, + usageWarningLine +} from './plan-billing-usage-copy' import { formatCents, formatRateCents } from './plans/plans-pricing' +import { capTooHighCopy } from './plan-billing-copy' import { ORCA_BILLING_OVERAGE_SECTION_ID } from '../new-workspace/orca-cloud-run-target-access' /** "Resets and renews Dec 2" when both land the same day (monthly); separate parts otherwise. */ @@ -55,6 +62,8 @@ export function UsageSummary({ const locale = useIntlLocale() const date = (ms: number): string => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(ms) + const overLine = sandboxHoursOverLine(entitlement, t) + const warning = usageWarningLine(entitlement, t) return (
@@ -68,51 +77,15 @@ export function UsageSummary({ } )} - - {t('auto.components.billing.sandboxHoursLeft', '{{value0}} sandbox hours left', { - value0: formatHours(entitlement.remainingIncludedSeconds) - })} - + {sandboxHoursLeftLine(entitlement, t)}
= 100 && !entitlement.overage.optedIn - ? 'warning' - : 'default' - } + tone={includedUsageTone(entitlement)} aria-label={t('auto.components.billing.sandboxHoursUsedLabel', 'Sandbox hours used')} /> - {entitlement.overageSeconds === 0 && - entitlement.usedSeconds > entitlement.includedSeconds && - entitlement.includedSeconds > 0 ? ( -

- {t('auto.components.billing.sandboxHoursOver', '{{count}} sandbox hours over', { - count: Math.round((entitlement.usedSeconds - entitlement.includedSeconds) / 360) / 10, - defaultValue_one: '{{count}} sandbox hour over' - })} -

- ) : null} - {entitlement.overageSeconds > 0 ? ( -

- {t('auto.components.billing.overageHoursOnly', '{{value0}} overage hours this month', { - value0: formatHours(entitlement.overageSeconds) - })} -

- ) : null} - {entitlement.usageWarningPercent !== null && entitlement.usageWarningPercent < 100 ? ( -

- {entitlement.usageWarningPercent >= 100 - ? t('auto.components.billing.warn100b', 'All of this month’s sandbox hours are used.') - : t( - 'auto.components.billing.warnPercent2', - 'You’ve used {{value0}}% of this month’s sandbox hours.', - { - value0: String(entitlement.usageWarningPercent) - } - )} -

- ) : null} + {overLine ?

{overLine}

: null} + {warning ?

{warning}

: null}

{resetLine(entitlement, date, t)}

) @@ -158,20 +131,23 @@ export function OverageControls({ } const optedIn = optimisticOn ?? overage.optedIn const parsedCap = Number(capDollars) - const capValid = capDollars.trim() !== '' && Number.isInteger(parsedCap) && parsedCap >= 0 + const maxCapCents = entitlement.offers?.team.maxSpendCapCents ?? null + const capWhole = capDollars.trim() !== '' && Number.isInteger(parsedCap) && parsedCap >= 0 + const capTooHigh = capWhole && maxCapCents !== null && parsedCap * 100 > maxCapCents + const capValid = capWhole && !capTooHigh const changed = capValid && capDollars.trim() !== savedDollars const paymentFailed = entitlement.payment.pastDueSince !== null const description = t( - 'auto.components.billing.overageDescription6', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).', + 'auto.components.billing.overageDescription7', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default).', { rate: formatRateCents(overage.centsPerHour, locale), cap: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale) } ) const descriptionOn = t( - 'auto.components.billing.overageDescriptionOn3', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.', + 'auto.components.billing.overageDescriptionOn4', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set.', { rate: formatRateCents(overage.centsPerHour, locale) } ) const capPercent = @@ -279,13 +255,17 @@ export function OverageControls({ {t('auto.components.billing.capLabel2', 'Monthly spend cap')}
-
- +
+ $ {capValid ? null : (

- {t('auto.components.billing.capInvalid', 'Enter a whole dollar amount.')} + {capTooHigh && maxCapCents !== null + ? capTooHighCopy(maxCapCents, locale, t) + : t('auto.components.billing.capInvalid', 'Enter a whole dollar amount.')}

)}
@@ -321,8 +303,8 @@ export function OverageControls({ {capValid && entitlement.reason === 'overage_cap_reached' ? (

{t( - 'auto.components.billing.capReachedHint', - 'Raise the cap to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.capReachedHint2', + 'Raise the cap to start sandboxes again. Your sandboxes are asleep, and your files are safe.' )}

) : null} diff --git a/src/renderer/src/components/billing/billing-summary-store.test.ts b/src/renderer/src/components/billing/billing-summary-store.test.ts new file mode 100644 index 00000000000..38de96b89e8 --- /dev/null +++ b/src/renderer/src/components/billing/billing-summary-store.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' +import { + getBillingState, + getBillingSummary, + getLastBillingStatus, + mergeBillingResult, + recordBillingStatus, + refreshBillingStatus, + resetBillingSummary, + watchBillingStatus +} from './billing-summary-store' +import { sampleTeam } from './plan-billing-fixtures' + +const teamA: OrcaBillingStatusResult = { status: 'ok', entitlement: { ...sampleTeam } } +const teamB: OrcaBillingStatusResult = { + status: 'ok', + entitlement: { ...sampleTeam, plan: null, planLabel: null, status: 'none' } +} +const offline: OrcaBillingStatusResult = { status: 'failed', error: 'offline' } + +let pending: ((result: OrcaBillingStatusResult) => void)[] = [] + +beforeEach(() => { + pending = [] + resetBillingSummary() + vi.stubGlobal('api', { + orcaProfiles: { + billingStatus: vi.fn( + () => new Promise((resolve) => pending.push(resolve)) + ) + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function answer(index: number, result: OrcaBillingStatusResult): Promise { + pending[index](result) + await Promise.resolve() + await Promise.resolve() +} + +describe('mergeBillingResult', () => { + it('keeps a good answer through a failure and replaces it otherwise', () => { + expect(mergeBillingResult(teamA, offline)).toBe(teamA) + expect(mergeBillingResult(null, offline)).toBe(offline) + expect(mergeBillingResult(teamA, teamB)).toBe(teamB) + expect(mergeBillingResult(teamA, { status: 'signed-out' })).toEqual({ status: 'signed-out' }) + }) +}) + +describe('billing store', () => { + it('keeps the last good result when a re-read fails', async () => { + const read = refreshBillingStatus() + await answer(0, teamA) + await read + expect(getBillingState()).toMatchObject({ result: teamA, loading: false }) + + const reread = refreshBillingStatus() + expect(getBillingState()).toMatchObject({ result: teamA, loading: true }) + await answer(1, offline) + await reread + expect(getBillingState()).toMatchObject({ result: teamA, loading: false }) + expect(getLastBillingStatus()).toBe(teamA) + expect(getBillingSummary()).toMatchObject({ subscribed: true, planLabel: 'Team' }) + }) + + it('re-reads after a kept failure instead of trusting it as fresh', async () => { + recordBillingStatus(teamA) + recordBillingStatus(offline) + void refreshBillingStatus({ maxAgeMs: 60_000 }) + expect(pending).toHaveLength(1) + }) + + it('joins a running read and only supersedes it when asked', async () => { + void refreshBillingStatus() + void refreshBillingStatus() + expect(pending).toHaveLength(1) + void refreshBillingStatus({ supersede: true }) + expect(pending).toHaveLength(2) + // The older read answers last; the newer one already wrote. + await answer(1, teamB) + await answer(0, teamA) + expect(getBillingState().result).toBe(teamB) + }) + + it('drops an answer that started before a team switch', async () => { + const read = refreshBillingStatus() + resetBillingSummary() + await answer(0, teamA) + await read + expect(getBillingState()).toMatchObject({ result: null, loading: false }) + expect(getLastBillingStatus()).toBeNull() + expect(getBillingSummary().subscribed).toBeNull() + }) + + it("never keeps the previous team's plan through the new team's failed read", async () => { + recordBillingStatus(teamA) + resetBillingSummary() + void refreshBillingStatus() + await answer(0, offline) + expect(getBillingState().result).toEqual(offline) + expect(getLastBillingStatus()).toBeNull() + }) + + it('reads the new team at once when a view is watching through the switch', async () => { + const release = watchBillingStatus() + void refreshBillingStatus() + resetBillingSummary() + expect(pending).toHaveLength(2) + expect(getBillingState()).toMatchObject({ result: null, loading: true }) + await answer(0, teamA) + await answer(1, teamB) + expect(getBillingState().result).toBe(teamB) + release() + }) +}) diff --git a/src/renderer/src/components/billing/billing-summary-store.ts b/src/renderer/src/components/billing/billing-summary-store.ts index b7115772237..5ffba23b560 100644 --- a/src/renderer/src/components/billing/billing-summary-store.ts +++ b/src/renderer/src/components/billing/billing-summary-store.ts @@ -1,6 +1,10 @@ -// What the always-visible upgrade entry points need to know: does this account have a plan? -// Read when the account (or org) changes and refreshed by any billing view that loads status. +// The one billing-status cache. Settings, the plans and Team pages, the build sheet, the Run-on +// picker and the account menu all read it, so they always agree and a read by one serves the others. +// It keeps the last good answer: a failed read (offline for a moment when the window regains focus) +// never replaces it. Answers are scoped to the account and team that asked for them. import { useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import { getOrcaAccountKey } from '@/hooks/use-orca-account-key' import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' import { isActivePlan } from './team/team-seat-math' @@ -12,49 +16,85 @@ export type BillingSummary = { planLabel: string | null } -let summary: BillingSummary = { subscribed: null, canManageBilling: false, planLabel: null } +export type BillingStoreState = { + /** Last answer for the current account and team; 'failed' only when nothing good was read yet. */ + result: OrcaBillingStatusResult | null + /** A read for the current account is running. */ + loading: boolean + /** When `result` was last confirmed by the service; 0 when stale or never. */ + fetchedAt: number +} + +const EMPTY: BillingStoreState = { result: null, loading: false, fetchedAt: 0 } +const SUMMARY_MIN_REFRESH_MS = 60_000 + +let state: BillingStoreState = EMPTY +/** Account the current `result` belongs to. */ +let stateAccount: string | null = null +/** Bumped on every account/team switch; answers that started before it are dropped. */ +let generation = 0 +/** Only the newest read may write; an older one that answers late is dropped. */ +let latestRead = 0 +let inFlight: { generation: number; account: string; promise: Promise } | null = null +/** Mounted views that want billing kept current (re-read after a switch drops a read). */ +let watchers = 0 const listeners = new Set<() => void>() -let lastReadAt = 0 -let inFlight: Promise | null = null -const MIN_REFRESH_MS = 60_000 -let lastStatus: OrcaBillingStatusResult | null = null +function currentAccount(): string { + return getOrcaAccountKey(useAppStore.getState().orcaProfileAuthStatus) +} -/** The latest successful status for the current account, to seed views that open later. */ -export function getLastBillingStatus(): OrcaBillingStatusResult | null { - return lastStatus +function setState(next: BillingStoreState): void { + if ( + next.result === state.result && + next.loading === state.loading && + next.fetchedAt === state.fetchedAt + ) { + return + } + state = next + for (const listener of listeners) { + listener() + } +} + +function readBillingStatus(): Promise { + return window.api.orcaProfiles + .billingStatus() + .catch((error: unknown): OrcaBillingStatusResult => ({ + status: 'failed', + error: error instanceof Error ? error.message : String(error) + })) +} + +/** + * Merges an answer into the cache: a failure keeps the last good result for the same account + * (a brief offline moment must not blank the section); anything else replaces it. + */ +export function mergeBillingResult( + previous: OrcaBillingStatusResult | null, + next: OrcaBillingStatusResult +): OrcaBillingStatusResult { + return next.status === 'failed' && previous?.status === 'ok' ? previous : next } export function recordBillingStatus(result: OrcaBillingStatusResult): void { - if (result.status === 'ok') { - lastStatus = result - } else if (result.status !== 'failed') { - lastStatus = null - } - const next: BillingSummary = - result.status === 'ok' - ? { - subscribed: isActivePlan(result.entitlement), - canManageBilling: result.entitlement.canManageBilling, - planLabel: isActivePlan(result.entitlement) ? result.entitlement.planLabel : null - } - : result.status === 'failed' - ? summary - : { subscribed: null, canManageBilling: false, planLabel: null } - if ( - next.subscribed !== summary.subscribed || - next.canManageBilling !== summary.canManageBilling || - next.planLabel !== summary.planLabel - ) { - summary = next - for (const listener of listeners) { - listener() - } - } + const account = currentAccount() + const previous = stateAccount === account ? state.result : null + const merged = mergeBillingResult(previous, result) + stateAccount = account + setState({ + result: merged, + loading: inFlight !== null, + // A result kept through a failure counts as stale, so the next look reads again. + fetchedAt: merged === result ? Date.now() : 0 + }) } -/** Bumped on every account/org switch; answers from an older account are ignored. */ -let generation = 0 +/** The latest good status for the current account, to seed views that open later. */ +export function getLastBillingStatus(): OrcaBillingStatusResult | null { + return stateAccount === currentAccount() && state.result?.status === 'ok' ? state.result : null +} export function getBillingGeneration(): number { return generation @@ -70,47 +110,153 @@ export function recordBillingStatusIfCurrent( } } -/** Forget the previous account's plan at once (sign-out, account or org switch). */ +/** Forget the previous account's plan at once (sign-out, account or team switch). */ export function resetBillingSummary(): void { generation++ + latestRead++ inFlight = null - lastReadAt = 0 - recordBillingStatus({ status: 'signed-out' }) -} - -/** Re-reads billing status at most once a minute unless `force` (account or org switched). */ -export function refreshBillingSummary(force = false): Promise { - if (!force && (inFlight || Date.now() - lastReadAt < MIN_REFRESH_MS)) { - return inFlight ?? Promise.resolve() + stateAccount = null + setState(EMPTY) + if (watchers > 0) { + // Views still on screen read the new account's billing at once. + void refreshBillingStatus() } - const requestGeneration = generation - lastReadAt = Date.now() - const request = window.api.orcaProfiles - .billingStatus() - .then((result) => { - if (requestGeneration === generation) { - recordBillingStatus(result) - } - }) - .catch(() => undefined) - .finally(() => { - if (inFlight === request) { - inFlight = null - } - }) - inFlight = request - return request } -export function getBillingSummary(): BillingSummary { - return summary +/** Marks the cached answer stale (billing changed elsewhere) so the next read doesn't trust it. */ +export function markBillingStale(): void { + setState({ ...state, fetchedAt: 0 }) } -export function subscribeBillingSummary(listener: () => void): () => void { +export function hasBillingWatchers(): boolean { + return watchers > 0 +} + +/** A mounted view that wants billing current; returns its release. */ +export function watchBillingStatus(): () => void { + watchers++ + let released = false + return () => { + if (!released) { + released = true + watchers-- + } + } +} + +/** The running read for the current account, if any (callers wait on it). */ +export function getBillingReadInFlight(): Promise | null { + return inFlight?.promise ?? null +} + +/** + * Reads billing status. Joins a running read for the same account unless `supersede` (billing just + * changed in-app, so a read that started earlier may be stale). With `maxAgeMs`, a cached answer + * younger than that is trusted and nothing is read. + */ +export function refreshBillingStatus({ + supersede = false, + maxAgeMs = 0 +}: { supersede?: boolean; maxAgeMs?: number } = {}): Promise { + const account = currentAccount() + if ( + !supersede && + inFlight && + inFlight.generation === generation && + inFlight.account === account + ) { + return inFlight.promise + } + if ( + maxAgeMs > 0 && + stateAccount === account && + state.result && + Date.now() - state.fetchedAt < maxAgeMs + ) { + return Promise.resolve() + } + if (stateAccount !== account) { + // Another account's answer must not stay on screen while this one's loads. + stateAccount = account + setState(EMPTY) + } + const startedGeneration = generation + const mine = ++latestRead + const promise = readBillingStatus().then((result): Promise | void => { + if (mine !== latestRead) { + // Superseded by a newer read or a reset; that one writes. + return + } + inFlight = null + if (startedGeneration !== generation || account !== currentAccount()) { + // The account or team changed mid-read: the answer is for the old one. Read again for + // whoever is still looking. + if (watchers > 0) { + return refreshBillingStatus() + } + setState({ ...state, loading: false }) + return + } + recordBillingStatus(result) + }) + inFlight = { generation: startedGeneration, account, promise } + setState({ ...state, loading: true }) + return promise +} + +/** Re-reads billing status at most once a minute unless `force` (account or team switched). */ +export function refreshBillingSummary(force = false): Promise { + return refreshBillingStatus(force ? {} : { maxAgeMs: SUMMARY_MIN_REFRESH_MS }) +} + +export function getBillingState(): BillingStoreState { + return state +} + +export function subscribeBillingState(listener: () => void): () => void { listeners.add(listener) return () => listeners.delete(listener) } +export function useBillingState(): BillingStoreState { + return useSyncExternalStore(subscribeBillingState, getBillingState) +} + +const UNKNOWN_SUMMARY: BillingSummary = { + subscribed: null, + canManageBilling: false, + planLabel: null +} +let summaryFor: OrcaBillingStatusResult | null = null +let summary: BillingSummary = UNKNOWN_SUMMARY + +export function getBillingSummary(): BillingSummary { + const result = state.result + if (result === summaryFor) { + return summary + } + summaryFor = result + const next: BillingSummary = + result?.status === 'ok' + ? { + subscribed: isActivePlan(result.entitlement), + canManageBilling: result.entitlement.canManageBilling, + planLabel: isActivePlan(result.entitlement) ? result.entitlement.planLabel : null + } + : UNKNOWN_SUMMARY + // Keep the same object when nothing visible changed, so subscribers don't re-render. + if ( + next.subscribed !== summary.subscribed || + next.canManageBilling !== summary.canManageBilling || + next.planLabel !== summary.planLabel + ) { + summary = next + } + return summary +} + +export const subscribeBillingSummary = subscribeBillingState + export function useBillingSummary(): BillingSummary { - return useSyncExternalStore(subscribeBillingSummary, getBillingSummary) + return useSyncExternalStore(subscribeBillingState, getBillingSummary) } diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx index 955e69bda54..7b2922b2552 100644 --- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx +++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx @@ -38,53 +38,55 @@ function OrderSummary({ const renewsLabel = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(renews) const seatCount = new Intl.NumberFormat(locale).format(seats) return ( -
-
-
- {interval === 'year' - ? t('auto.components.billing.checkout.planAnnual', '{{value0}} · Annual', { - value0: team.label - }) - : t('auto.components.billing.checkout.planMonthly', '{{value0}} · Monthly', { - value0: team.label - })} -
-
- {perSeat === null - ? null - : interval === 'year' - ? t( - 'auto.components.billing.checkout.lineItemYearly', - '{{seats}} × {{price}} per seat, billed yearly', - { - count: seats, - seats: seatCount, - price: formatCents(perSeat, locale), - defaultValue_one: '{{seats}} × {{price}} per seat, billed yearly' - } - ) - : t( - 'auto.components.billing.checkout.lineItemMonthly', - '{{seats}} × {{price}} per seat per month', - { - count: seats, - seats: seatCount, - price: formatCents(perSeat, locale), - defaultValue_one: '{{seats}} × {{price}} per seat per month' - } - )} -
-
-
-
- {phase.kind === 'paid' - ? t('auto.components.billing.checkout.paidRow', 'Paid') - : t('auto.components.billing.checkout.dueToday', 'Due today')} -
-
- {total === null ? '—' : formatCents(total, locale)} -
-
+
+
+
+
+ {interval === 'year' + ? t('auto.components.billing.checkout.planAnnual', '{{value0}} · Annual', { + value0: team.label + }) + : t('auto.components.billing.checkout.planMonthly', '{{value0}} · Monthly', { + value0: team.label + })} +
+
+ {perSeat === null + ? null + : interval === 'year' + ? t( + 'auto.components.billing.checkout.lineItemYearly', + '{{seats}} × {{price}} per seat, billed yearly', + { + count: seats, + seats: seatCount, + price: formatCents(perSeat, locale), + defaultValue_one: '{{seats}} × {{price}} per seat, billed yearly' + } + ) + : t( + 'auto.components.billing.checkout.lineItemMonthly', + '{{seats}} × {{price}} per seat per month', + { + count: seats, + seats: seatCount, + price: formatCents(perSeat, locale), + defaultValue_one: '{{seats}} × {{price}} per seat per month' + } + )} +
+
+
+
+ {phase.kind === 'paid' + ? t('auto.components.billing.checkout.paidRow', 'Paid') + : t('auto.components.billing.checkout.dueToday', 'Due today')} +
+
+ {total === null ? '—' : formatCents(total, locale)} +
+
+

{t( 'auto.components.billing.checkout.renewsNote', @@ -92,7 +94,7 @@ function OrderSummary({ { value0: renewsLabel } )}

-
+
) } @@ -101,7 +103,7 @@ function StatusIcon({ phase }: { phase: CheckoutPanelProps['phase'] }): React.JS return ( diff --git a/src/renderer/src/components/billing/plan-billing-copy.ts b/src/renderer/src/components/billing/plan-billing-copy.ts index a584182fd4a..df35f015606 100644 --- a/src/renderer/src/components/billing/plan-billing-copy.ts +++ b/src/renderer/src/components/billing/plan-billing-copy.ts @@ -5,6 +5,7 @@ import type { OrcaBillingOpenResult, OrcaBillingOverageResult } from '../../../../shared/orca-billing' +import { formatCents } from './plans/plans-pricing' export function denyReasonCopy( reason: OrcaBillingDenyReason, @@ -47,22 +48,22 @@ export function denyReasonCopy( case 'included_hours_exhausted': return canManage ? t( - 'auto.components.billing.reasonExhausted3', - 'Out of sandbox hours for this month. Turn on overage to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.reasonExhausted4', + 'Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage to start sandboxes again.' ) : t( - 'auto.components.billing.reasonExhaustedMember2', - 'Out of sandbox hours for this month. Ask an owner or admin to add hours. Your files are safe.' + 'auto.components.billing.reasonExhaustedMember3', + 'Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.' ) case 'overage_cap_reached': return canManage ? t( - 'auto.components.billing.reasonCapAdmin2', - 'Spend cap reached. Raise the monthly spend cap to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.reasonCapAdmin3', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the monthly spend cap to start sandboxes again.' ) : t( - 'auto.components.billing.reasonCapMember3', - 'Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.' + 'auto.components.billing.reasonCapMember4', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to raise the spend cap.' ) } } @@ -113,12 +114,27 @@ export function openFailureCopy(result: OrcaBillingOpenResult, t: Translate): st } } -export function overageFailureCopy(result: OrcaBillingOverageResult, t: Translate): string | null { +/** "The spend cap can be at most $5,000." */ +export function capTooHighCopy(maxCents: number, locale: string, t: Translate): string { + return t('auto.components.billing.capAtMost', 'The spend cap can be at most {{max}}.', { + max: formatCents(maxCents, locale) + }) +} + +export function overageFailureCopy( + result: OrcaBillingOverageResult, + t: Translate, + /** The service's highest allowed cap; null from older services that don't send it. */ + maxCapCents: number | null = null, + locale = 'en-US' +): string | null { switch (result.status) { case 'ok': return null case 'cap-too-high': - return t('auto.components.billing.capTooHigh', 'That spend cap is above the allowed maximum.') + return maxCapCents === null + ? t('auto.components.billing.capTooHigh', 'That spend cap is above the allowed maximum.') + : capTooHighCopy(maxCapCents, locale, t) case 'no-subscription': return t('auto.components.billing.overageNoPlan', 'Overage needs an active plan.') case 'forbidden': diff --git a/src/renderer/src/components/billing/plan-billing-fixtures.ts b/src/renderer/src/components/billing/plan-billing-fixtures.ts index 1dc2a62e47b..696ede6ee78 100644 --- a/src/renderer/src/components/billing/plan-billing-fixtures.ts +++ b/src/renderer/src/components/billing/plan-billing-fixtures.ts @@ -16,7 +16,8 @@ export const sampleOffers: OrcaBillingOffers = { concurrentSandboxesPerSeat: 3, idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES, overageCentsPerHour: 100, - defaultSpendCapCents: 2500 + defaultSpendCapCents: 2500, + maxSpendCapCents: 500_000 }, enterprise: { label: 'Enterprise', diff --git a/src/renderer/src/components/billing/plan-billing-usage-copy.test.ts b/src/renderer/src/components/billing/plan-billing-usage-copy.test.ts new file mode 100644 index 00000000000..57bf4e51b26 --- /dev/null +++ b/src/renderer/src/components/billing/plan-billing-usage-copy.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { translate } from '@/i18n/i18n' +import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' +import { sampleTeam } from './plan-billing-fixtures' +import { + accountMenuUsageLines, + includedUsageTone, + sandboxHoursCount, + sandboxHoursLeftLine, + sandboxHoursOverLine, + usageWarningLine +} from './plan-billing-usage-copy' + +const HOUR = 3600 +const t = translate +const team = (patch: Partial): OrcaBillingEntitlement => ({ + ...sampleTeam, + ...patch +}) + +describe('sandbox hours copy', () => { + it('counts hours to one decimal', () => { + expect(sandboxHoursCount(78.5 * HOUR)).toBe(78.5) + expect(sandboxHoursCount(HOUR + 1)).toBe(1) + expect(sandboxHoursCount(-5)).toBe(0) + }) + + it('pluralizes the hours left', () => { + expect(sandboxHoursLeftLine(team({ remainingIncludedSeconds: 78.5 * HOUR }), t)).toBe( + '78.5 sandbox hours left' + ) + expect(sandboxHoursLeftLine(team({ remainingIncludedSeconds: HOUR }), t)).toBe( + '1 sandbox hour left' + ) + expect(sandboxHoursLeftLine(team({ remainingIncludedSeconds: 0 }), t)).toBe( + '0 sandbox hours left' + ) + }) + + it('says how far over the included amount the team is, billed or not', () => { + expect(sandboxHoursOverLine(team({}), t)).toBeNull() + expect( + sandboxHoursOverLine(team({ usedSeconds: 123 * HOUR, overageSeconds: 3 * HOUR }), t) + ).toBe('3 sandbox hours over the included amount this month') + expect(sandboxHoursOverLine(team({ usedSeconds: 121 * HOUR, overageSeconds: 0 }), t)).toBe( + '1 sandbox hour over the included amount this month' + ) + }) + + it('warns at the level billing reports until the hours run out', () => { + expect(usageWarningLine(team({ usageWarningPercent: null }), t)).toBeNull() + expect(usageWarningLine(team({ usageWarningPercent: 75 }), t)).toBe( + 'Your team has used 75% of this month’s sandbox hours.' + ) + expect(usageWarningLine(team({ usageWarningPercent: 90 }), t)).toBe( + 'Your team has used 90% of this month’s sandbox hours.' + ) + expect(usageWarningLine(team({ usageWarningPercent: 80 }), t)).toBe( + 'Your team has used 80% of this month’s sandbox hours.' + ) + expect(usageWarningLine(team({ usageWarningPercent: 100 }), t)).toBeNull() + }) + + it('states the actual share used, floored, not the level that triggered it', () => { + expect(usageWarningLine(team({ usedSeconds: 119 * HOUR, usageWarningPercent: 90 }), t)).toBe( + 'Your team has used 99% of this month’s sandbox hours.' + ) + expect(usageWarningLine(team({ usedSeconds: 92 * HOUR, usageWarningPercent: 75 }), t)).toBe( + 'Your team has used 76% of this month’s sandbox hours.' + ) + // Never "100%" while billing still reports hours left. + expect(usageWarningLine(team({ usedSeconds: 119.9 * HOUR, usageWarningPercent: 90 }), t)).toBe( + 'Your team has used 99% of this month’s sandbox hours.' + ) + }) + + it('turns the hours bar to the warning tone at 90%', () => { + expect(includedUsageTone(team({ usedSeconds: 107 * HOUR }))).toBe('default') + expect(includedUsageTone(team({ usedSeconds: 108 * HOUR }))).toBe('warning') + expect(includedUsageTone(team({ usedSeconds: 200 * HOUR }))).toBe('warning') + }) + + it('gives the account menu hours and the usage warning only on a plan', () => { + expect( + accountMenuUsageLines( + { + status: 'ok', + entitlement: team({ remainingIncludedSeconds: 20 * HOUR, usageWarningPercent: 90 }) + }, + t + ) + ).toEqual({ + hoursLeft: '20 sandbox hours left', + warning: 'Your team has used 90% of this month’s sandbox hours.' + }) + expect( + accountMenuUsageLines( + { status: 'ok', entitlement: team({ plan: null, planLabel: null, status: 'none' }) }, + t + ) + ).toBeNull() + expect(accountMenuUsageLines({ status: 'failed', error: 'offline' }, t)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/billing/plan-billing-usage-copy.ts b/src/renderer/src/components/billing/plan-billing-usage-copy.ts new file mode 100644 index 00000000000..4b2852f860c --- /dev/null +++ b/src/renderer/src/components/billing/plan-billing-usage-copy.ts @@ -0,0 +1,100 @@ +// Sandbox-hours lines shared by Plan & billing, the plans page and the account menu, so every +// surface counts, pluralizes and warns the same way. +import type { Translate } from '@/i18n/use-translate' +import type { + OrcaBillingEntitlement, + OrcaBillingStatusResult +} from '../../../../shared/orca-billing' +import { includedUsagePercent } from './plan-billing-format' +import { isActivePlan } from './team/team-seat-math' + +/** The included-hours bar turns to the warning tone from this share of the month's hours. */ +export const INCLUDED_HOURS_WARNING_PERCENT = 90 + +/** Hours as a plural count: one decimal, whole numbers without it (41.5, 78, 1). */ +export function sandboxHoursCount(seconds: number): number { + return Math.round(Math.max(0, seconds) / 360) / 10 +} + +export function includedUsageTone(entitlement: OrcaBillingEntitlement): 'default' | 'warning' { + return includedUsagePercent(entitlement) >= INCLUDED_HOURS_WARNING_PERCENT ? 'warning' : 'default' +} + +/** "78.5 sandbox hours left" / "1 sandbox hour left". */ +export function sandboxHoursLeftLine(entitlement: OrcaBillingEntitlement, t: Translate): string { + return t('auto.components.billing.sandboxHoursLeftCount', '{{count}} sandbox hours left', { + count: sandboxHoursCount(entitlement.remainingIncludedSeconds), + defaultValue_one: '{{count}} sandbox hour left' + }) +} + +/** Hours past the included amount this month (billed as overage or not), or 0. */ +export function hoursOverIncludedSeconds(entitlement: OrcaBillingEntitlement): number { + if (entitlement.includedSeconds <= 0) { + return entitlement.overageSeconds + } + return Math.max(entitlement.overageSeconds, entitlement.usedSeconds - entitlement.includedSeconds) +} + +/** "3 sandbox hours over the included amount this month", or null when within it. */ +export function sandboxHoursOverLine( + entitlement: OrcaBillingEntitlement, + t: Translate +): string | null { + const count = sandboxHoursCount(hoursOverIncludedSeconds(entitlement)) + if (count <= 0) { + return null + } + return t( + 'auto.components.billing.sandboxHoursOverIncluded', + '{{count}} sandbox hours over the included amount this month', + { + count, + defaultValue_one: '{{count}} sandbox hour over the included amount this month' + } + ) +} + +/** + * The share of included hours actually used, floored, for the warning sentence: never below the + * level billing reported (its count may be fresher) and never 100 while hours remain. + */ +export function usedPercentForWarning(entitlement: OrcaBillingEntitlement): number { + const level = entitlement.usageWarningPercent ?? 0 + const used = + entitlement.includedSeconds > 0 + ? Math.floor((entitlement.usedSeconds / entitlement.includedSeconds) * 100) + : level + return Math.min(99, Math.max(level, used)) +} + +/** + * Shown once billing reports a warning level (75%, 90%) until the hours run out; the sentence states + * the actual share used, not the level. At 100% the bar, the hours-left line and the out-of-hours + * states say it instead. + */ +export function usageWarningLine(entitlement: OrcaBillingEntitlement, t: Translate): string | null { + const level = entitlement.usageWarningPercent + if (level === null || level >= 100) { + return null + } + return t( + 'auto.components.billing.teamUsedPercent', + 'Your team has used {{percent}}% of this month’s sandbox hours.', + { percent: String(usedPercentForWarning(entitlement)) } + ) +} + +/** The account menu's plan lines: hours left, and the early warning once the team reaches it. */ +export function accountMenuUsageLines( + result: OrcaBillingStatusResult | null, + t: Translate +): { hoursLeft: string; warning: string | null } | null { + if (result?.status !== 'ok' || !isActivePlan(result.entitlement)) { + return null + } + return { + hoursLeft: sandboxHoursLeftLine(result.entitlement, t), + warning: usageWarningLine(result.entitlement, t) + } +} diff --git a/src/renderer/src/components/billing/plans/PlansDialogContent.tsx b/src/renderer/src/components/billing/plans/PlansDialogContent.tsx index 16e304cc02c..fca2a5f2531 100644 --- a/src/renderer/src/components/billing/plans/PlansDialogContent.tsx +++ b/src/renderer/src/components/billing/plans/PlansDialogContent.tsx @@ -30,10 +30,12 @@ export function PlansDialogContent( : 'top-[6vh] max-h-[88vh] translate-y-0 sm:max-w-4xl [@media(max-height:800px)]:top-4 [@media(max-height:800px)]:max-h-[calc(100vh-2rem)]' } onOpenAutoFocus={(event) => { - // The dialog itself first; PlansView moves focus to the primary action once plans load. + // The heading, not the first button: Enter on "Continue" would start a monthly Checkout, + // and Tab from here reaches the interval toggle before the plan cards. event.preventDefault() if (event.currentTarget instanceof HTMLElement) { - event.currentTarget.focus() + const heading = event.currentTarget.querySelector('[data-plans-heading]') + ;(heading ?? event.currentTarget).focus() } }} > @@ -41,7 +43,7 @@ export function PlansDialogContent(

{t('auto.components.billing.plans.eyebrow', 'Orca Cloud')}

- + {checkout ? checkoutState === 'paid' ? t('auto.components.billing.checkout.titlePaid', 'Welcome to Orca Cloud') diff --git a/src/renderer/src/components/billing/plans/PlansView.fixture.tsx b/src/renderer/src/components/billing/plans/PlansView.fixture.tsx index 627ee89ebd9..c496a5b2947 100644 --- a/src/renderer/src/components/billing/plans/PlansView.fixture.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.fixture.tsx @@ -105,3 +105,23 @@ export const loadFailed = defineFixture({ width, render: () => }) + +/** Billing's 90% level on the plans page: the strip warns under the bar. */ +export const currentPlanUsageWarning = defineFixture({ + width, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx index dee2a120e06..65c1e3fb12d 100644 --- a/src/renderer/src/components/billing/plans/PlansView.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -15,6 +15,12 @@ import { PlanStatusBadge } from '../PlanStatusBadge' import type { PlanBillingNotice } from '../PlanBillingSection' import { seatCountLabel } from '../team/team-copy' import { formatHours, includedUsagePercent } from '../plan-billing-format' +import { + includedUsageTone, + sandboxHoursCount, + sandboxHoursLeftLine, + usageWarningLine +} from '../plan-billing-usage-copy' import { EnterprisePlanCard } from './EnterprisePlanCard' import { PlansIntervalToggle } from './PlansIntervalToggle' import { TeamPlanCard, type TeamPlanCardMode } from './TeamPlanCard' @@ -56,8 +62,8 @@ function ValueProps(): React.JSX.Element { icon: Zap, title: t('auto.components.billing.plans.vpWakeTitle', 'Wake in seconds'), body: t( - 'auto.components.billing.plans.vpWakeBody2', - 'Idle sandboxes sleep to save hours and wake with your files as you left them.' + 'auto.components.billing.plans.vpWakeBody3', + 'Idle sandboxes sleep to save sandbox hours and wake with your files as you left them.' ) }, { @@ -96,6 +102,7 @@ function CurrentPlanStrip({ const resets = entitlement.usageWindow ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(entitlement.usageWindow.end) : null + const warning = usageWarningLine(entitlement, t) return (
@@ -113,7 +120,7 @@ function CurrentPlanStrip({
= 100 ? 'warning' : 'default'} + tone={includedUsageTone(entitlement)} aria-label={t('auto.components.billing.sandboxHoursUsedLabel', 'Sandbox hours used')} />
@@ -130,16 +137,18 @@ function CurrentPlanStrip({ {resets ? t( - 'auto.components.billing.plans.leftResets', - '{{value0}} hours left · resets {{value1}}', + 'auto.components.billing.plans.sandboxHoursLeftResets', + '{{count}} sandbox hours left · resets {{date}}', { - value0: formatHours(entitlement.remainingIncludedSeconds), - value1: resets + count: sandboxHoursCount(entitlement.remainingIncludedSeconds), + date: resets, + defaultValue_one: '{{count}} sandbox hour left · resets {{date}}' } ) - : null} + : sandboxHoursLeftLine(entitlement, t)}
+ {warning ?

{warning}

: null}
) } @@ -226,15 +235,16 @@ export function PlansView(props: PlansViewProps): React.JSX.Element { const seats = clampSeats(props.seats, ORCA_BILLING_MAX_SEATS) const result = props.result const ready = result?.status === 'ok' - // Once plans load (or after "Back to plans"), land on this state's primary action, but only if - // focus is still on the dialog or nowhere; never steal it from something the user picked. + // Once plans load (or after "Back to plans"), land on the dialog heading, never on a buy button + // (Enter would start Checkout): Tab then reaches the interval toggle before the cards. Only when + // focus is on the dialog or nowhere; never steal it from something the user picked. useEffect(() => { const active = document.activeElement if ( ready && (!active || active === document.body || active.getAttribute('role') === 'dialog') ) { - document.querySelector('[role="dialog"] [data-plans-primary]')?.focus() + document.querySelector('[role="dialog"] [data-plans-heading]')?.focus() } }, [ready]) diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index 34e4e84771c..d4f9d177058 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -70,8 +70,8 @@ export function TeamPlanCard({ ), t('auto.components.billing.plans.featureReady', 'Ready in seconds, nothing to set up or host'), t( - 'auto.components.billing.plans.featureOverage6', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)', + 'auto.components.billing.plans.featureOverage7', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default)', { rate: formatRateCents(team.overageCentsPerHour, locale), cap: formatCents(team.defaultSpendCapCents, locale) @@ -155,7 +155,6 @@ export function TeamPlanCard({ className="w-full" aria-disabled={busy || undefined} onClick={busy ? undefined : onCheckout} - data-plans-primary="" > {busy ? : null} {busy @@ -192,7 +191,6 @@ export function TeamPlanCard({ className="w-full" aria-disabled={busy || undefined} onClick={busy ? undefined : onManage} - data-plans-primary="" > {t('auto.components.billing.manageBilling', 'Manage billing')} diff --git a/src/renderer/src/components/billing/use-orca-billing-status.test.tsx b/src/renderer/src/components/billing/use-orca-billing-status.test.tsx index c9ac1d5ae41..5bb5464963c 100644 --- a/src/renderer/src/components/billing/use-orca-billing-status.test.tsx +++ b/src/renderer/src/components/billing/use-orca-billing-status.test.tsx @@ -3,9 +3,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' -let accountKey = 'user-1:team-a' -vi.mock('@/hooks/use-orca-account-key', () => ({ useOrcaAccountKey: () => accountKey })) - +import { useAppStore } from '@/store' import { getLastBillingStatus, resetBillingSummary } from './billing-summary-store' import { sampleTeam } from './plan-billing-fixtures' import { useOrcaBillingStatus } from './use-orca-billing-status' @@ -19,8 +17,27 @@ const teamB: OrcaBillingStatusResult = { type Pending = { resolve: (result: OrcaBillingStatusResult) => void } let pending: Pending[] = [] +/** Signs in as `user-1` acting for `orgId`, as the auth status would after a team pick. */ +function setAccount(orgId: string): void { + useAppStore.setState({ + orcaProfileAuthStatus: { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'ada@example.com', + linkedAt: 0, + activeOrgId: orgId + } + } + }) +} + beforeEach(() => { - accountKey = 'user-1:team-a' + setAccount('team-a') pending = [] resetBillingSummary() vi.stubGlobal('api', { @@ -43,8 +60,8 @@ afterEach(() => { /** What the auth subscriber does on a team switch: bump the generation, then re-render. */ function switchTeam(rerender: () => void, next: string): void { act(() => { + setAccount(next) resetBillingSummary() - accountKey = next rerender() }) } @@ -55,7 +72,7 @@ describe('useOrcaBillingStatus on a team switch', () => { await act(async () => pending[0].resolve(teamA)) expect(result.current.result).toEqual(teamA) - switchTeam(rerender, 'user-1:team-b') + switchTeam(rerender, 'team-b') expect(result.current).toMatchObject({ loading: true, result: null }) expect(pending).toHaveLength(2) @@ -68,7 +85,7 @@ describe('useOrcaBillingStatus on a team switch', () => { const { result, rerender } = renderHook(() => useOrcaBillingStatus(true)) expect(pending).toHaveLength(1) - switchTeam(rerender, 'user-1:team-b') + switchTeam(rerender, 'team-b') await act(async () => pending[0].resolve(teamA)) expect(result.current).toMatchObject({ loading: true, result: null }) expect(getLastBillingStatus()).toBeNull() @@ -90,3 +107,24 @@ describe('useOrcaBillingStatus on a team switch', () => { expect(result.current).toMatchObject({ loading: false, result: teamB }) }) }) + +describe('useOrcaBillingStatus on a failed re-read', () => { + it('keeps the last good plan when a focus re-read fails', async () => { + const { result } = renderHook(() => useOrcaBillingStatus(true)) + await act(async () => pending[0].resolve(teamA)) + + act(() => { + window.dispatchEvent(new Event('focus')) + }) + expect(pending).toHaveLength(2) + await act(async () => pending[1].resolve({ status: 'failed', error: 'offline' })) + + expect(result.current).toMatchObject({ loading: false, result: teamA }) + }) + + it('shows the failure when nothing was read yet', async () => { + const { result } = renderHook(() => useOrcaBillingStatus(true)) + await act(async () => pending[0].resolve({ status: 'failed', error: 'offline' })) + expect(result.current.result).toEqual({ status: 'failed', error: 'offline' }) + }) +}) diff --git a/src/renderer/src/components/billing/use-orca-billing-status.ts b/src/renderer/src/components/billing/use-orca-billing-status.ts index c3421c097ae..1c85b9baf1d 100644 --- a/src/renderer/src/components/billing/use-orca-billing-status.ts +++ b/src/renderer/src/components/billing/use-orca-billing-status.ts @@ -1,80 +1,49 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect } from 'react' import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' -import { - getBillingGeneration, - getLastBillingStatus, - recordBillingStatusIfCurrent -} from './billing-summary-store' +import { refreshBillingStatus, useBillingState, watchBillingStatus } from './billing-summary-store' type BillingStatusState = { + /** True until the first answer for this account arrives, and while a read runs. */ loading: boolean result: OrcaBillingStatusResult | null } -function readBillingStatus(): Promise { - return window.api.orcaProfiles - .billingStatus() - .catch((error: unknown): OrcaBillingStatusResult => ({ - status: 'failed', - error: error instanceof Error ? error.message : String(error) - })) -} - /** - * Reads the billing status while `enabled`, again on `refresh()`, and whenever the window regains - * focus (the user comes back from Checkout or the Portal in their browser). A team or account - * switch drops the previous team's status and reads the new one; an answer that started before - * the switch is never shown or recorded. + * The shared billing status while `enabled`: read on mount, again on `refresh()`, and whenever the + * window regains focus (the user comes back from Checkout or the Portal in their browser). A failed + * re-read keeps the last good answer. A team or account switch drops the previous team's status and + * reads the new one; an answer that started before the switch is never shown or recorded. */ export function useOrcaBillingStatus( enabled: boolean ): BillingStatusState & { refresh: () => void } { - const [state, setState] = useState(() => ({ - loading: enabled, - result: enabled ? getLastBillingStatus() : null - })) - const [reloadKey, setReloadKey] = useState(0) - const refresh = useCallback(() => setReloadKey((key) => key + 1), []) + const snapshot = useBillingState() const accountKey = useOrcaAccountKey() - const loadedAccount = useRef(accountKey) + // Billing just changed in-app (or the user asked): a read that started earlier may be stale. + const refresh = useCallback(() => void refreshBillingStatus({ supersede: true }), []) useEffect(() => { if (!enabled) { return } - let disposed = false - let latest = 0 - const switched = loadedAccount.current !== accountKey - loadedAccount.current = accountKey - const load = (): void => { - const request = ++latest - const startedGeneration = getBillingGeneration() - // The previous team's plan must not stay on screen while the new team's loads. - setState((previous) => - switched && request === 1 ? { loading: true, result: null } : { ...previous, loading: true } - ) - void readBillingStatus().then((result) => { - // A newer request, or unmounting, supersedes this one. - if (disposed || request !== latest) { - return - } - // The account or team changed while this was in flight: the answer is for the old one. - if (getBillingGeneration() !== startedGeneration) { - load() - return - } - recordBillingStatusIfCurrent(startedGeneration, result) - setState({ loading: false, result }) - }) - } + const release = watchBillingStatus() + const load = (): void => void refreshBillingStatus() load() window.addEventListener('focus', load) return () => { - disposed = true + release() window.removeEventListener('focus', load) } - }, [enabled, reloadKey, accountKey]) + }, [enabled, accountKey]) - return { ...state, refresh } + if (!enabled) { + return { loading: false, result: null, refresh } + } + return { + // No answer yet means one is on its way (the read starts after the first paint). + loading: snapshot.loading || snapshot.result === null, + result: snapshot.result, + refresh + } } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx index f2a64ecea27..ee915e435e5 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx @@ -92,3 +92,26 @@ export const reassignAsAdmin = defineFixture({ /> ) }) + +/** Archiving a teammate's running workspace from the team list stops their agents (DS-01). */ +export const archiveTeammateRunning = defineFixture({ + width: 640, + render: () => + frame( + {}} + onConfirm={() => {}} + /> + ) +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx index a5148df374d..ed37b4ff7ec 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx @@ -1,5 +1,6 @@ -// Confirmation for the two row actions that take something away from people: making a shared -// workspace private (teammates' computers are disconnected) and handing ownership to someone else. +// Confirmation for row actions that take something away from people: making a shared workspace +// private (teammates' computers are disconnected), handing ownership to someone else, and archiving +// a teammate's running workspace (their agents stop). import React, { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { @@ -18,6 +19,8 @@ import { cloudWorkspaceTitle } from './open-cloud-workspace' export type PendingConfirm = | { kind: 'private'; workspace: CloudWorkspace } | { kind: 'owner'; workspace: CloudWorkspace; ownerUserId: string } + /** Archiving a teammate's running workspace stops their agents. */ + | { kind: 'archive'; workspace: CloudWorkspace } /** Teammates who would lose access: here now, or holding an active device grant. */ function useConnectedTeammates(workspace: CloudWorkspace, selfId: string | null): string[] { @@ -100,10 +103,16 @@ export function CloudWorkspaceConfirmDialog({ ? t('auto.components.cloudWorkspaces.makePrivateTitle', 'Make {{title}} private?', { title: cloudWorkspaceTitle(w, t) }) - : t('auto.components.cloudWorkspaces.reassignTitle', 'Make {{name}} the owner?', { - name: memberName(directory, pending.ownerUserId, selfId, t) - }) + : pending.kind === 'archive' + ? t('auto.components.cloudWorkspaces.archiveConfirmTitle', 'Archive {{name}}?', { + name: cloudWorkspaceTitle(w, t) + }) + : t('auto.components.cloudWorkspaces.reassignTitle', 'Make {{name}} the owner?', { + name: memberName(directory, pending.ownerUserId, selfId, t) + }) const losesManagement = pending.kind === 'owner' && w.ownerUserId === selfId && !canAdmin + // Destructive for someone else (their agents stop, or you lose access): focus starts on Cancel. + const focusCancel = losesManagement || pending.kind === 'archive' return ( !next && onCancel()}> @@ -111,6 +120,14 @@ export function CloudWorkspaceConfirmDialog({ {title} {pending.kind === 'private' ? ( + ) : pending.kind === 'archive' ? ( + + {t( + 'auto.components.cloudWorkspaces.archiveConfirmBody', + "{{owner}}'s agents in this workspace will stop. Files are kept and it can be unarchived.", + { owner: memberName(directory, w.ownerUserId, selfId, t) } + )} + ) : ( {losesManagement @@ -127,13 +144,15 @@ export function CloudWorkspaceConfirmDialog({ {/* Giving away your own access starts on Cancel, so Enter cannot do it by accident. */} - - diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx index c65d1b09e81..2475fb18c3e 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx @@ -17,7 +17,9 @@ const ACTIONS: RowActions = { copyLink: () => {}, copyWorkspaceLink: () => {}, goToChat: () => {}, - patch: () => {} + patch: () => {}, + archive: () => {}, + unarchive: () => {} } const SECTIONS = ['Payments', 'Platform'] const MEMBERS = [...DIRECTORY.entries()].map(([userId, m]) => ({ userId, name: m.name })) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx index c8eddfeab97..dae60301e33 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx @@ -33,6 +33,7 @@ function dotState(w: CloudWorkspace): CloudDotState { case 'running': return 'running' case 'creating': + return 'starting' case 'resuming': return 'waking' case 'suspending': @@ -298,8 +299,8 @@ export function CloudWorkspaceRow({ /> - - {hours} + + {usage} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRowMenu.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRowMenu.tsx index e94a44b661a..b5fa3c75bd0 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRowMenu.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRowMenu.tsx @@ -49,6 +49,9 @@ export type RowActions = { /** One-time jump: open the workspace on the chat this teammate has in front. */ goToChat: (w: CloudWorkspace, member: CloudWorkspacePresence) => void patch: (w: CloudWorkspace, patch: CloudWorkspacePatch) => void + /** Sleep and archive with a snapshot (Undo for 5 s); never just a label. */ + archive: (w: CloudWorkspace) => void + unarchive: (w: CloudWorkspace) => void } /** Placeholder replaced by the inline section editor in CloudWorkspacesHome. */ @@ -265,12 +268,12 @@ export function CloudWorkspaceRowMenu({ )} {w.archivedAt === null ? ( - actions.patch(w, { archived: true })}> + actions.archive(w)}> {t('auto.components.cloudWorkspaces.archive', 'Archive')} ) : ( - actions.patch(w, { archived: false })}> + actions.unarchive(w)}> {t('auto.components.cloudWorkspaces.unarchive', 'Unarchive')} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index c23d295c6b3..98a9c76d54f 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -23,6 +23,7 @@ import { formatSandboxHours, sandboxHours, type MemberDirectory } from './cloud- import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender' import { describeError } from './cloud-workspace-errors' import { focusCloudChat } from './cloud-chat-focus' +import * as homeArchive from './cloud-workspace-home-archive' import { frontCloudChat } from './cloud-workspace-links' import { cloudWorkspaceTitle, @@ -85,6 +86,7 @@ export function CloudWorkspacesHome({ const selfId = cloud?.userId ?? null const account = useOrcaAccountKey() const [filters, setFilters] = useState(INITIAL_FILTERS) + homeArchive.usePendingHomeFilters(setFilters) // Owner and repo filters name the previous team's people and repos; a team switch starts over. const [filtersAccount, setFiltersAccount] = useState(account) if (filtersAccount !== account) { @@ -245,12 +247,6 @@ export function CloudWorkspacesHome({ ) } else if (change.ownerUserId) { toast.success(t('auto.components.cloudWorkspaces.ownerChanged', 'Owner changed')) - } else if (change.archived !== undefined) { - toast.success( - change.archived - ? t('auto.components.cloudWorkspaces.archived', 'Archived') - : t('auto.components.cloudWorkspaces.restored', 'Restored') - ) } refresh() }, @@ -267,9 +263,10 @@ export function CloudWorkspacesHome({ }, copyWorkspaceLink: (w: CloudWorkspace) => void copyCloudWorkspaceLink(w.id), goToChat: (w: CloudWorkspace, member: CloudWorkspacePresence) => void open(w, member.chatId), - patch: (w: CloudWorkspace, change: CloudWorkspacePatch) => void patch(w, change) + patch: (w: CloudWorkspace, change: CloudWorkspacePatch) => void patch(w, change), + ...homeArchive.homeArchiveActions({ selfId, t, refresh, confirmArchive: setConfirm }) }), - [open, patch, setReadingId] + [open, patch, refresh, selfId, setReadingId, t] ) // Nothing (not even a skeleton) until we know this build has a control plane and you are in. @@ -410,6 +407,11 @@ export function CloudWorkspacesHome({ canAdmin={canAdmin} onCancel={() => setConfirm(null)} onConfirm={() => { + if (confirm.kind === 'archive') { + setConfirm(null) + homeArchive.runHomeArchive(confirm.workspace, t, refresh) + return + } const change: CloudWorkspacePatch = confirm.kind === 'private' ? { visibility: 'private' } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx index 866fc754bb3..4395a1f1718 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx @@ -5,7 +5,7 @@ import { ErrorState } from './CloudWorkspacesStates' afterEach(cleanup) -it('shows an expired session as a neutral status with Sign in, not an alert with Retry', () => { +it('shows an expired session as a neutral status with Sign in, not an alert with Try again', () => { const onSignIn = vi.fn() const onRetry = vi.fn() render() @@ -16,10 +16,25 @@ it('shows an expired session as a neutral status with Sign in, not an alert with expect(onRetry).not.toHaveBeenCalled() }) -it('keeps other failures as an alert with Retry', () => { +it('keeps other failures as an alert with Try again', () => { const onRetry = vi.fn() render() expect(screen.getByRole('alert')).not.toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) expect(onRetry).toHaveBeenCalledTimes(1) }) + +it('at the running-sandbox limit offers Show cloud workspaces instead of a retry', () => { + const onRetry = vi.fn() + render( + + ) + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() + expect(screen.getByRole('button', { name: 'Show cloud workspaces' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx index 017d87ffb54..3d47983ac18 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx @@ -14,8 +14,9 @@ import { Skeleton } from '@/components/ui/skeleton' import { useTranslate } from '@/i18n/use-translate' import { SignInAgainNotice } from '@/components/orca-cloud/SignInAgainButton' import { describeInlineError } from './cloud-workspace-errors' +import { openCloudWorkspacesHome } from './cloud-workspaces-home-nav' -/** The list failed to load. An expired session offers Sign in; anything else offers Retry. */ +/** The list failed to load. An expired session offers Sign in; anything else offers Try again. */ export function ErrorState({ error, reason, @@ -33,13 +34,21 @@ export function ErrorState({ if (error === 'unauthorized') { return } + // At the running-sandbox limit a retry can't help: show your workspaces so one can go to sleep. + const atLimit = + error === 'entitlement_denied' && + (reason === 'concurrency_limit' || reason === 'member_concurrency_limit') return ( openCloudWorkspacesHome() : onRetry} /> ) } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.test.ts new file mode 100644 index 00000000000..52b82d6142d --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) })) +vi.mock('../orca-cloud/cloud-workspace-actions', () => ({ + runArchiveCloudWorkspace: vi.fn(), + runArchiveWithUndo: vi.fn(), + runUnarchiveCloudWorkspace: vi.fn() +})) + +import { + archiveFromHome, + unarchiveFromHome, + type HomeArchiveDeps +} from './cloud-workspace-home-archive' +import { workspace } from './cloud-workspaces.test-fixture' + +const w = workspace({ id: 'sbx_1' }) + +function deps(local: string | null) { + const patchArchived = vi.fn(async () => ({ + ok: true, + value: w + })) + const archiveWithUndo = vi.fn(async (_key, options) => { + // Commit immediately, as if the Undo window passed. + return (await options.commit()).ok + }) + const d = { + localWorktreeId: vi.fn(async () => local), + patchArchived, + archiveLocal: vi.fn(async () => undefined), + archiveWithUndo, + unarchiveLocal: vi.fn(async () => true) + } satisfies HomeArchiveDeps + return d +} + +describe('archive from the team list', () => { + it('runs the sidebar sleep → archive (with Undo) for a workspace this desktop runs', async () => { + const d = deps('wt-1') + await archiveFromHome(w, 'Fix login', d) + expect(d.archiveLocal).toHaveBeenCalledWith('wt-1', { name: 'Fix login' }) + expect(d.patchArchived).not.toHaveBeenCalled() + }) + + it('asks the control plane to archive (it stops the sandbox) for any other workspace, behind the Undo toast', async () => { + const d = deps(null) + await archiveFromHome(w, 'Fix login', d) + expect(d.archiveWithUndo).toHaveBeenCalledWith( + 'home:sbx_1', + expect.objectContaining({ name: 'Fix login' }) + ) + expect(d.patchArchived).toHaveBeenCalledWith('sbx_1', true) + expect(d.archiveLocal).not.toHaveBeenCalled() + }) + + it('unarchives locally (wakes) or through the control plane (stays asleep)', async () => { + const local = deps('wt-1') + await unarchiveFromHome(w, 'Fix login', local) + expect(local.unarchiveLocal).toHaveBeenCalledWith('wt-1') + const remote = deps(null) + await unarchiveFromHome(w, 'Fix login', remote) + expect(remote.patchArchived).toHaveBeenCalledWith('sbx_1', false) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts new file mode 100644 index 00000000000..75a37a70579 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts @@ -0,0 +1,124 @@ +// Archive and unarchive from the team list. Both are lifecycle changes, never just a label: +// a workspace this desktop runs goes through the same Sleep → archive (with Undo) as the sidebar; +// any other one is archived by the control plane, which stops its sandbox and keeps a snapshot. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { CloudWorkspaceResult } from '../../../../shared/hosted-sandbox/cloud-workspace-api' +import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { + runArchiveCloudWorkspace, + runArchiveWithUndo, + runUnarchiveCloudWorkspace +} from '../orca-cloud/cloud-workspace-actions' +import { describeError } from './cloud-workspace-errors' +export { usePendingHomeFilters } from './cloud-workspaces-home-nav' +import type { Translate } from '@/i18n/use-translate' +import { cloudWorkspaceTitle } from './open-cloud-workspace' + +export type HomeArchiveDeps = { + localWorktreeId: (sandboxId: string) => Promise + patchArchived: ( + sandboxId: string, + archived: boolean + ) => ReturnType + archiveLocal: typeof runArchiveCloudWorkspace + archiveWithUndo: typeof runArchiveWithUndo + unarchiveLocal: typeof runUnarchiveCloudWorkspace +} + +const defaultDeps = (): HomeArchiveDeps => ({ + localWorktreeId: async (sandboxId) => + (await window.api.cloudWorkspaces.localWorkspaces()).find( + (local) => local.sandboxId === sandboxId && local.worktreeId + )?.worktreeId ?? null, + patchArchived: (sandboxId, archived) => window.api.cloudWorkspaces.patch(sandboxId, { archived }), + archiveLocal: runArchiveCloudWorkspace, + archiveWithUndo: runArchiveWithUndo, + unarchiveLocal: runUnarchiveCloudWorkspace +}) + +function asResult( + result: Awaited> +): CloudWorkspaceResult { + if (result.ok) { + return { ok: true, value: null } + } + const message = + // The control plane refuses while the sandbox is starting or mid-transition (409, nothing changed). + result.error === 'conflict' + ? translate( + 'auto.components.cloudWorkspaces.archiveBusy', + 'This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.' + ) + : describeError(result.error, result.reason, translate) + return { ok: false, code: 'failed', message, userMessage: message } +} + +export async function archiveFromHome( + w: CloudWorkspace, + name: string, + deps: HomeArchiveDeps = defaultDeps() +): Promise { + const worktreeId = await deps.localWorktreeId(w.id) + if (worktreeId) { + await deps.archiveLocal(worktreeId, { name }) + return + } + await deps.archiveWithUndo(`home:${w.id}`, { + name, + commit: async () => asResult(await deps.patchArchived(w.id, true)) + }) +} + +export async function unarchiveFromHome( + w: CloudWorkspace, + name: string, + deps: HomeArchiveDeps = defaultDeps() +): Promise { + const worktreeId = await deps.localWorktreeId(w.id) + if (worktreeId) { + // Same as the sidebar: the disk comes back and the workspace wakes. + await deps.unarchiveLocal(worktreeId) + return + } + const result = await deps.patchArchived(w.id, false) + if (!result.ok) { + toast.error( + translate('orcaCloudWorkspace.unarchiveFailedNamed', 'Couldn’t unarchive {{name}}', { name }), + { + description: describeError(result.error, result.reason, translate) + } + ) + return + } + toast.success(translate('orcaCloudWorkspace.unarchivedNamed', 'Unarchived {{name}}', { name }), { + description: translate( + 'orcaCloudWorkspace.unarchivedAsleep', + 'It’s asleep. Opening it wakes it.' + ) + }) +} + +export function runHomeArchive(w: CloudWorkspace, t: Translate, refresh: () => void): void { + void archiveFromHome(w, cloudWorkspaceTitle(w, t)).finally(refresh) +} + +/** Row actions for the team list. A teammate's running workspace asks first (their agents stop). */ +export function homeArchiveActions(options: { + selfId: string | null + t: Translate + refresh: () => void + confirmArchive: (pending: { kind: 'archive'; workspace: CloudWorkspace }) => void +}): { archive: (w: CloudWorkspace) => void; unarchive: (w: CloudWorkspace) => void } { + const { selfId, t, refresh } = options + return { + archive: (w) => { + if (w.ownerUserId !== selfId && w.status === 'running') { + options.confirmArchive({ kind: 'archive', workspace: w }) + return + } + runHomeArchive(w, t, refresh) + }, + unarchive: (w) => void unarchiveFromHome(w, cloudWorkspaceTitle(w, t)).finally(refresh) + } +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts new file mode 100644 index 00000000000..0a6495ba16f --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts @@ -0,0 +1,44 @@ +// "Show cloud workspaces" from limit errors: go to the team list (Home), filtered to your own, +// so you can put one to sleep or delete it and try again. +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import type { CloudWorkspacesFilters } from './CloudWorkspacesToolbar' + +let pendingFilters: Partial | null = null +const listeners = new Set<() => void>() + +/** Home reads (and clears) this when it mounts or is already showing. */ +export function takePendingHomeFilters(): Partial | null { + const next = pendingFilters + pendingFilters = null + return next +} + +export function onPendingHomeFilters(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function openCloudWorkspacesHome(filters: Partial = {}): void { + pendingFilters = { scope: 'mine', archived: false, ...filters } + const state = useAppStore.getState() + state.setActiveView('terminal') + state.setActiveWorktree(null) + listeners.forEach((listener) => listener()) +} + +/** Applies pending filters on mount and whenever another surface asks for Home again. */ +export function usePendingHomeFilters( + setFilters: React.Dispatch> +): void { + useEffect(() => { + const take = (): void => { + const pending = takePendingHomeFilters() + if (pending) { + setFilters((current) => ({ ...current, ...pending })) + } + } + take() + return onPendingHomeFilters(take) + }, [setFilters]) +} diff --git a/src/renderer/src/components/new-workspace/orca-cloud-billing-cache.ts b/src/renderer/src/components/new-workspace/orca-cloud-billing-cache.ts index 75c64bcac9a..00ce2cbcce9 100644 --- a/src/renderer/src/components/new-workspace/orca-cloud-billing-cache.ts +++ b/src/renderer/src/components/new-workspace/orca-cloud-billing-cache.ts @@ -1,101 +1,54 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { useAppStore } from '@/store/index' -import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' +import { useEffect, useMemo } from 'react' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +import { + getBillingReadInFlight, + getBillingState, + hasBillingWatchers, + markBillingStale, + refreshBillingStatus, + resetBillingSummary, + useBillingState, + watchBillingStatus, + type BillingStoreState +} from '@/components/billing/billing-summary-store' import { toOrcaCloudRunTargetAccess, type OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access' /** - * One billing-status read shared by the Run-on picker and the selection hint. The composer prefetches - * it on mount so the Orca Cloud row rarely shows a loading state; focus refreshes it at most every - * STALE_MS (the user may come back from Checkout or the portal). The result is keyed by account and - * org, so a switch never shows the previous one's entitlement, and a newer read always wins. + * The Run-on picker's and selection hint's view of the shared billing store. The composer prefetches + * on mount so the Orca Cloud row rarely shows a loading state; focus re-reads at most every STALE_MS + * (the user may come back from Checkout or the portal). The store is scoped to the account and team, + * keeps the last good answer through a failed read, and a newer read always wins. */ const STALE_MS = 30_000 -type CacheState = { - result: OrcaBillingStatusResult | null - /** Account/org the result belongs to. */ - authKey: string | null - loading: boolean - fetchedAt: number -} - -let state: CacheState = { result: null, authKey: null, loading: false, fetchedAt: 0 } -let inFlight: { generation: number; authKey: string; promise: Promise } | null = null -let generation = 0 -const listeners = new Set<() => void>() - -function set(next: CacheState): void { - state = next - for (const listener of listeners) { - listener() - } -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -function currentAuthKey(): string { - const status = useAppStore.getState().orcaProfileAuthStatus - return `${status?.activeProfileId}:${status?.state}:${status?.cloud?.activeOrgId}` -} - /** - * Starts a read unless a matching one is running or the cached one is fresh for this account. + * Starts a read unless one is running or the cached answer is fresh for this account. * `force` (after an in-app billing change) always starts a new read that supersedes any running one. */ export function prefetchOrcaCloudBilling(force = false): Promise { - const authKey = currentAuthKey() - if (!force && inFlight && inFlight.authKey === authKey) { - return inFlight.promise - } - const fresh = state.authKey === authKey && Date.now() - state.fetchedAt < STALE_MS - if (!force && state.result && fresh) { - return Promise.resolve() - } - const mine = ++generation - const read = async (): Promise => { - try { - return await window.api.orcaProfiles.billingStatus() - } catch (error) { - return { status: 'failed', error: error instanceof Error ? error.message : String(error) } - } - } - const promise = read().then((result) => { - // A newer read, a reset, or an account switch supersedes this one. - if (mine !== generation) { - return - } - inFlight = null - set({ result, authKey, loading: false, fetchedAt: Date.now() }) - }) - inFlight = { generation: mine, authKey, promise } - set({ ...state, loading: true }) - return promise + return refreshBillingStatus(force ? { supersede: true } : { maxAgeMs: STALE_MS }) } /** * After billing changes in-app (overage, cap) the cached read is wrong: refetch now if anyone is - * listening. `refetchNow: false` only marks it stale, for fixes that finish elsewhere (plans, + * looking. `refetchNow: false` only marks it stale, for fixes that finish elsewhere (plans, * portal), so the next mount or focus reads again instead of trusting a 30s-fresh answer. */ export function invalidateOrcaCloudBilling({ refetchNow = true } = {}): void { - set({ ...state, fetchedAt: 0 }) - if (refetchNow && listeners.size > 0) { + markBillingStale() + if (refetchNow && hasBillingWatchers()) { void prefetchOrcaCloudBilling(true) } } -function accessFrom(snapshot: CacheState, authKey: string): OrcaCloudRunTargetAccess { - const result = snapshot.authKey === authKey ? snapshot.result : null - if (!result && snapshot.loading) { +function accessFrom(snapshot: BillingStoreState): OrcaCloudRunTargetAccess { + if (!snapshot.result && snapshot.loading) { return { state: 'loading' } } - return toOrcaCloudRunTargetAccess(result) + return toOrcaCloudRunTargetAccess(snapshot.result) } /** Resolves once billing has answered, or after `timeoutMs` with whatever is known then. */ @@ -109,37 +62,36 @@ export async function waitForOrcaCloudAccess(timeoutMs: number): Promise state) - const authKey = useAppStore((s) => { - const status = s.orcaProfileAuthStatus - return `${status?.activeProfileId}:${status?.state}:${status?.cloud?.activeOrgId}` - }) - // Re-runs on account/org change: the keyed cache then reads as empty and refetches. + const snapshot = useBillingState() + const accountKey = useOrcaAccountKey() + // Re-runs on account/team change: the store then drops the old answer and reads the new one. useEffect(() => { if (!enabled) { return } + const release = watchBillingStatus() void prefetchOrcaCloudBilling() const onFocus = (): void => void prefetchOrcaCloudBilling() window.addEventListener('focus', onFocus) - return () => window.removeEventListener('focus', onFocus) - }, [enabled, authKey]) - return useMemo(() => accessFrom(snapshot, authKey), [snapshot, authKey]) + return () => { + release() + window.removeEventListener('focus', onFocus) + } + }, [enabled, accountKey]) + return useMemo(() => accessFrom(snapshot), [snapshot]) } /** Test seam: forget the cached read and orphan any running one. */ export function resetOrcaCloudBillingCache(): void { - generation += 1 - inFlight = null - set({ result: null, authKey: null, loading: false, fetchedAt: 0 }) + resetBillingSummary() } diff --git a/src/renderer/src/components/orca-cloud/CloudArchivedWorkspaceView.tsx b/src/renderer/src/components/orca-cloud/CloudArchivedWorkspaceView.tsx index 522cbacb0ee..65c184fb09c 100644 --- a/src/renderer/src/components/orca-cloud/CloudArchivedWorkspaceView.tsx +++ b/src/renderer/src/components/orca-cloud/CloudArchivedWorkspaceView.tsx @@ -244,8 +244,12 @@ export function CloudArchivedWorkspaceView({ 'orcaCloudWorkspace.archived.loadFailed', 'Couldn’t load the archive details.' )} - detail={load.message} - retryLabel={t('orcaCloudWorkspace.retry', 'Retry')} + // The raw reason stays out of the card; the plain line says what matters. + detail={t( + 'orcaCloudWorkspace.archived.loadFailedDetail', + 'Your files are safe in the archive.' + )} + retryLabel={t('orcaCloudWorkspace.tryAgain', 'Try again')} onRetry={onRetry} /> ) : nothing ? ( diff --git a/src/renderer/src/components/orca-cloud/CloudStateDot.tsx b/src/renderer/src/components/orca-cloud/CloudStateDot.tsx index 063986e76e2..262d0e17835 100644 --- a/src/renderer/src/components/orca-cloud/CloudStateDot.tsx +++ b/src/renderer/src/components/orca-cloud/CloudStateDot.tsx @@ -16,8 +16,13 @@ export const CLOUD_DOT_CLASS: Record = { // Light: amber nudged toward the foreground just enough for 3:1 on the sidebar; dark: plain amber. unresponsive: 'rounded-full bg-[color-mix(in_srgb,var(--status-warning)_85%,var(--foreground))] dark:bg-status-warning', + // Starting a new sandbox reads like waking (half-filled), without implying it slept before. + starting: + 'rounded-full border-[1.5px] border-foreground bg-[linear-gradient(90deg,var(--foreground)_50%,transparent_50%)] motion-safe:animate-pulse', archived: 'rounded-[2px] bg-muted-foreground', failed: 'rounded-full bg-destructive', + // A failed wake: the workspace is fine, so a hollow destructive ring rather than a solid failure. + wakeFailed: 'rounded-full border-[1.5px] border-destructive bg-transparent', unknown: 'rounded-full border-[1.5px] border-dashed border-muted-foreground bg-transparent' } diff --git a/src/renderer/src/components/orca-cloud/CloudVersionSkewNotice.fixture.tsx b/src/renderer/src/components/orca-cloud/CloudVersionSkewNotice.fixture.tsx new file mode 100644 index 00000000000..bad7a55e0e0 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudVersionSkewNotice.fixture.tsx @@ -0,0 +1,23 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import { CloudVersionSkewNotice, type CloudVersionSkewDirection } from './CloudWorkspaceNotice' + +const noop = (): void => {} + +function skewNotice(direction: CloudVersionSkewDirection) { + return defineFixture({ + width: 420, + render: () => ( +
+ +
+ ) + }) +} + +export const sandboxOlder = skewNotice('sandbox-older') +export const desktopOlder = skewNotice('desktop-older') diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceNotice.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceNotice.tsx index e549a2e4583..1ac33d3a00b 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceNotice.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceNotice.tsx @@ -1,14 +1,82 @@ -import React from 'react' -import { Loader2, MemoryStick, RotateCw, Unplug, X } from 'lucide-react' +import React, { useId } from 'react' +import type { LucideIcon } from 'lucide-react' +import { + CircleArrowUp, + Loader2, + MemoryStick, + Plus, + RefreshCw, + RotateCw, + TriangleAlert, + Unplug, + X +} from 'lucide-react' import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' import type { CloudOomEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api' export type CloudNoticeKind = { kind: 'unresponsive' } | { kind: 'oom'; oom: CloudOomEvent } +/** Which side runs the older Orca when the two are outside each other's protocol range. */ +export type CloudVersionSkewDirection = 'sandbox-older' | 'desktop-older' + /** - * A quiet card in the workspace's top-right corner: the workspace stopped answering, or a process - * ran out of memory. Both offer Restart (same disk). Doesn't block the panes. + * A quiet card in the workspace's top-right corner. Never covers or blocks the panes. + */ +function CloudNoticeCard({ + role = 'alert', + noticeKind, + icon: Icon, + title, + detail, + action, + onDismiss +}: { + /** `alert` for a sandbox in trouble now; `status` for news from a background check. */ + role?: 'alert' | 'status' + noticeKind: string + icon: LucideIcon + title: string + detail: string + action: React.ReactNode + onDismiss?: () => void +}): React.JSX.Element { + const t = useTranslate() + const titleId = useId() + return ( +
+
+ ) +} + +/** + * The workspace stopped answering, or a process ran out of memory. Both offer Restart (same disk). */ export function CloudWorkspaceNotice({ notice, @@ -35,37 +103,84 @@ export function CloudWorkspaceNotice({ 'It was stopped so the sandbox stays up. Restart to recover.' ) : t('orcaCloudWorkspace.detail.restartKeeps', 'Restart keeps your files; agents resume.') - const Icon = isOom ? MemoryStick : Unplug return ( -
-
+ } + /> + ) +} + +/** + * This desktop and the sandbox's Orca are outside each other's protocol range (normal skew inside + * the range shows nothing). A sandbox keeps the Orca it was created with, so an older sandbox is + * fixed by a new workspace, a newer one by updating this computer. + */ +export function CloudVersionSkewNotice({ + direction, + onCheckForUpdates, + onCreateWorkspace, + onDismiss +}: { + direction: CloudVersionSkewDirection + onCheckForUpdates: () => void + onCreateWorkspace: () => void + onDismiss: () => void +}): React.JSX.Element { + const t = useTranslate() + const desktopOlder = direction === 'desktop-older' + return ( + + + {t('orcaCloudWorkspace.versionSkew.checkForUpdates', 'Check for updates')} + + ) : ( + + ) + } + /> ) } diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx index 1b336c9fd97..432fec9bd84 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Archive, ArchiveRestore, Cloud, Laptop, Loader2, Play, RotateCw } from 'lucide-react' +import { Archive, ArchiveRestore, Cloud, Laptop, Loader2, Moon, Play, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' import { cloudWorkspaceMoved } from '@/lib/orca-cloud/orca-cloud-move-store' @@ -11,7 +11,6 @@ import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import { formatBytes, - formatCents, formatDuration, formatSandboxHours, formatShortDate, @@ -33,6 +32,8 @@ export type CloudPanelActions = { onRestart: () => void onArchive: () => void onUnarchive: () => void + /** Put a running sandbox to sleep (frees one of the team's running slots). */ + onSleep?: () => void /** "Move to this computer"; absent where the move isn't offered (fixtures, older bridges). */ onMoveToComputer?: () => void } @@ -157,8 +158,9 @@ export function CloudWorkspaceStatusPanel({ const unavailable = !status && Boolean(entry?.error) const firstLoad = !status && !entry?.error const anyBusy = pending !== undefined - const canRestart = state === 'running' || state === 'unresponsive' || state === 'failed' - const restartPrimary = state === 'unresponsive' || state === 'failed' + const canRestart = + state === 'running' || state === 'unresponsive' || state === 'failed' || state === 'wakeFailed' + const restartPrimary = state === 'unresponsive' || state === 'failed' || state === 'wakeFailed' const canArchive = state === 'running' || state === 'sleeping' || state === 'unresponsive' const memPercent = metrics ? percentOf(metrics.memUsedBytes, metrics.memTotalBytes) : 0 const diskPercent = metrics ? percentOf(metrics.diskUsedBytes, metrics.diskTotalBytes) : 0 @@ -168,9 +170,6 @@ export function CloudWorkspaceStatusPanel({ ? t('orcaCloudWorkspace.detail.restarting', 'Orca resumes your agents after the restart.') : cloudStateDetail(state, t) const showMeters = Boolean(metrics) && (state === 'running' || state === 'unresponsive') - // Overage is what's billed beyond the plan; the status call doesn't carry it yet, so this stays 0 - // (no dollars shown) until billing reports it. - const overageCents = 0 const showAgo = Boolean(entry?.fetchedAt && showUpdatedAgo(entry.fetchedAt, now)) const hasActions = canArchive || canRestart || restarting || state === 'archived' const showFooter = showAgo || hasActions @@ -207,7 +206,7 @@ export function CloudWorkspaceStatusPanel({ void refreshCloudWorkspaceStatus(workspaceId)} density="compact" @@ -261,17 +260,9 @@ export function CloudWorkspaceStatusPanel({ /> ) : null} - {/* Dollars only once the plan's included hours are used up (overage). */} - {overageCents > 0 ? ( - - ) : null} {status.archive?.expiresAt ? ( ) : null} + {actions.onSleep && state === 'running' && !restarting ? ( + + ) : null} {canArchive && !restarting ? ( - {shortcut ? `${primaryLabel} (${shortcut})` : primaryLabel} + {primaryLive ? ( +
+ {t('runActions.showOutputTooltip', 'Show {{name}} output', name)} + {shortcut ? ( + + {t('runActions.shortcutRestarts', '{{shortcut}} restarts it', { shortcut })} + + ) : null} +
+ ) : shortcut ? ( + `${t('runActions.runNamed', 'Run {{name}}', name)} (${shortcut})` + ) : ( + t('runActions.runNamed', 'Run {{name}}', name) + )}
@@ -206,18 +314,22 @@ export function RunActionButtonView({ {t('runActions.noPorts', 'No servers listening in this workspace yet.')}
) : ( - ports.slice(0, 6).map((port) => ( - - - :{port.port} - - {port.processName ?? ''} - - - )) + ports + .slice(0, 6) + .map((port) => ( + + )) )} + + {announcement ?? ''} + ) } diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx index ee4a381618f..0e661f7425a 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.fixture.tsx @@ -1,5 +1,6 @@ import { defineFixture } from '../../component-fixtures/component-fixture' import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' import SidebarToolbar from './SidebarToolbar' import { registerPlanBillingMenuItem } from '../billing/register-plan-billing-menu-item' import { recordBillingStatus } from '../billing/billing-summary-store' @@ -45,18 +46,20 @@ const signedInWithBilling: OrcaProfileAuthStatus = { } /** Registers the billing items as main.tsx does, with a known plan state. */ -const withBilling = (subscribed: boolean) => (): void => { - const result = { - status: 'ok' as const, - entitlement: subscribed - ? sampleTeam - : { ...sampleTeam, plan: null, planLabel: null, status: 'none' } +const withBilling = + (subscribed: boolean, usage: Partial = {}) => + (): void => { + const result = { + status: 'ok' as const, + entitlement: subscribed + ? { ...sampleTeam, ...usage } + : { ...sampleTeam, plan: null, planLabel: null, status: 'none' } + } + // The registration re-reads billing when the account changes; answer like the service would. + window.api.orcaProfiles.billingStatus = async () => result + registerPlanBillingMenuItem() + recordBillingStatus(result) } - // The registration re-reads billing when the account changes; answer like the service would. - window.api.orcaProfiles.billingStatus = async () => result - registerPlanBillingMenuItem() - recordBillingStatus(result) -} const signedOut: OrcaProfileAuthStatus = { activeProfileId: 'local-default', @@ -117,6 +120,18 @@ export const signedInMenuOnTeam = footer({ interact: openAccountMenu }) +/** The team at billing's 75% level: hours left and the early warning in the header. */ +export const signedInMenuUsageWarning = footer({ + height: 360, + authStatus: signedInWithBilling, + setup: withBilling(true, { + usedSeconds: 92 * 3600, + remainingIncludedSeconds: 28 * 3600, + usageWarningPercent: 75 + }), + interact: openAccountMenu +}) + const openTeamSwitcher = async (frame: HTMLElement): Promise => { await openAccountMenu(frame) const trigger = document.querySelector('[data-account-menu-team-switcher]') diff --git a/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx index 7ad8dbc2ab7..4c15bd4dce6 100644 --- a/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx +++ b/src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx @@ -16,7 +16,8 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' -import { useBillingSummary } from '../../billing/billing-summary-store' +import { useBillingState } from '../../billing/billing-summary-store' +import { accountMenuUsageLines } from '../../billing/plan-billing-usage-copy' import { SidebarTeamSubmenu } from './SidebarTeamSubmenu' import { useSetupGuideProgress } from '../../setup-guide/use-setup-guide-progress' import { getAccountMenuIdentity, type AccountMenuIdentity } from './account-menu-identity' @@ -68,21 +69,8 @@ export function SidebarAccountMenu(): React.JSX.Element { const mountedRef = useMountedRef() const identity = getAccountMenuIdentity(authStatus) - const billingSummary = useBillingSummary() - const orgName = authStatus?.state === 'connected' ? authStatus.cloud?.activeOrgName : undefined - // "Acme · Team plan": which team this account acts for, and its plan once billing has answered. - const orgLine = orgName - ? billingSummary.planLabel - ? t( - 'auto.components.sidebar.SidebarAccountMenu.teamPlanLine', - '{{value0}} · {{value1}} plan', - { - value0: orgName, - value1: billingSummary.planLabel - } - ) - : orgName - : null + // The team is named once, on the switcher below; the header carries the plan's hours instead. + const usage = accountMenuUsageLines(useBillingState().result, t) const visibleItems = selectVisibleAccountMenuItems(registeredItems, authStatus) // Freeze the item list while the menu is open: a billing answer arriving mid-read must not // swap rows under the pointer (e.g. Upgrade replacing Plan & billing). @@ -196,8 +184,13 @@ export function SidebarAccountMenu(): React.JSX.Element { {identity.email} ) : null} - {orgLine ? ( - {orgLine} + {usage ? ( + + {usage.hoursLeft} + + ) : null} + {usage?.warning ? ( + {usage.warning} ) : null} ) : ( diff --git a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts index aa53a86449c..c8ccd7e3d23 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts @@ -1,4 +1,5 @@ import { toast } from 'sonner' +import { toastCloudSleepFailure } from '@/lib/orca-cloud/cloud-sleep-failure-toast' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../../../shared/execution-host' @@ -78,7 +79,16 @@ export async function runSleepWorktrees( worktreeIds, { markChatsAsleep: true } ) - toastSleepFailures(new Set(worktreeIds).size, errors, vmStillRunningIds.length > 0) + // An Orca Cloud sandbox left running gets the cloud copy (sandbox hours, Try again) instead. + const cloudToasted = toastCloudSleepFailure( + vmStillRunningIds, + (ids) => void runSleepWorktrees(ids) + ) + toastSleepFailures( + new Set(worktreeIds).size, + errors, + vmStillRunningIds.length > 0 && !cloudToasted + ) // A slept Orca Cloud sandbox won't restart, so its restart warning no longer applies. for (const result of results) { if (result.completed) { diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx index d7f910d40ae..4f5a6389afa 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx @@ -1,4 +1,5 @@ import { getProviderChecksOutcome } from '../../../../../../shared/source-control/checks/provider-check-summary' +import { useIsCloudWorkspace } from '@/lib/orca-cloud/orca-cloud-workspace-store' import React from 'react' import { Bell, GitBranch, Moon } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -144,7 +145,9 @@ export function WorktreeCardStatusSlot({ vmWaking: selectEphemeralVmWorkspaceWaking(state, worktreeId) })) ) - const vmState = vmRuntime ? getEphemeralVmHostState(vmRuntime.status, vmWaking) : null + // Orca Cloud workspaces show their state on the cloud mark; the VM badge would be a second indicator. + const isCloud = useIsCloudWorkspace(worktreeId) + const vmState = vmRuntime && !isCloud ? getEphemeralVmHostState(vmRuntime.status, vmWaking) : null const vmFailure = vmRuntime?.failure ?? null const statusLabel = getWorktreeStatusLabel(status) || status // Sleeping remains distinct from quiet done/branch/review states; attention keeps its own glyph. diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts index 06d1e7b42dd..6e526a0edc0 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts @@ -1,4 +1,5 @@ /** Keep store-level command status while parked; foreground confirmation and input-intent inference remain with mounted panes. */ +import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' import { resolvePaneAgentOwner } from '../../../../../../shared/agent-status/identity/pane-agent-owner' import { parseAppSshPtyId } from '../../../../../../shared/ssh-pty-id' @@ -173,7 +174,12 @@ export function createParkedTerminalCommandStatusPolicy(options: { return } // Commands can change HEAD/index; nudge git UI immediately rather than wait for polling. - dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode, tabId) + dispatchTerminalCommandFinishedEvent( + worktreeId, + bestEffortExitCode, + tabId, + parsePaneKey(paneKey)?.leafId + ) void retireForegroundAgentUnlessConfirmed() // Mirror SSH’s unprobed same-turn drop; local PTYs require mounted foreground confirmation against nested-shell 133;D. if (parseAppSshPtyId(ptyId) === null) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts index b718f5f481f..8844dc92092 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts @@ -1,3 +1,4 @@ +import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import { agentTypeToIconAgent } from '../../../../../../shared/agent-status/presentation/agent-type-icon-agent' import { dismissRetainedProgramResult } from '@/lib/program-status/result-retention' import { isRetainedProgramResult } from '@/lib/program-status/row' @@ -217,7 +218,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { dispatchTerminalCommandFinishedEvent( session.deps.worktreeId, bestEffortExitCode, - session.deps.tabId + session.deps.tabId, + parsePaneKey(session.cacheKey)?.leafId ) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[session.cacheKey] diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index ed2a5906b63..cb800f955c6 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -70,10 +70,11 @@ function DialogContentCloseButton({ variant }: { variant: 'close' | 'minimize' } variant === 'minimize' ? t('auto.components.ui.dialog.minimize', 'Minimize') : t('auto.components.ui.dialog.f26c4baeda', 'Close') + // A 24px hit area around the 16px glyph, centered where the glyph always sat (top/right 16px). const button = ( {variant === 'minimize' ? : } {label} @@ -193,7 +194,8 @@ function DialogTitle({ data-slot="dialog-title" data-size={size} className={cn( - 'text-lg leading-snug font-semibold break-words data-[size=compact]:text-sm data-[size=display]:text-2xl data-[size=display]:leading-tight data-[size=display]:tracking-tight md:data-[size=display]:text-[1.75rem]', + // outline-none: a title is never a control, but dialogs focus it (tabIndex -1) on open. + 'text-lg leading-snug font-semibold break-words outline-none data-[size=compact]:text-sm data-[size=display]:text-2xl data-[size=display]:leading-tight data-[size=display]:tracking-tight md:data-[size=display]:text-[1.75rem]', className )} {...props} diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx index 63d8101f5ac..4fefa20b188 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx @@ -161,7 +161,10 @@ function failureActions( case 'checks': { const retryChecks: Action = { key: 'retry', - label: t('auto.components.worktree.creation.VmBuildSheet.retryChecks', 'Retry checks'), + label: t( + 'auto.components.worktree.creation.VmBuildSheet.runChecksAgain', + 'Run checks again' + ), icon: , onClick: actions.onRetry } @@ -180,16 +183,7 @@ function failureActions( return cloud ? { primary: [tryAgain], more: [] } : { - primary: [ - fix, - { - ...tryAgain, - label: t( - 'auto.components.worktree.creation.WorktreeCreationPanel.34dd5ee38b', - 'Retry' - ) - } - ], + primary: [fix, tryAgain], more: [editRecipe] } case 'concurrency': diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index e2f3bf70f8c..40bd4023c00 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { openCloudWorkspacesHome } from '../cloud-workspaces/cloud-workspaces-home-nav' import { AlertTriangle, ChevronRight, CircleAlert, CircleCheck, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' @@ -8,6 +9,7 @@ import { useAppStore } from '@/store' import { useTranslate, type Translate } from '@/i18n/use-translate' import { resolveVmEnvironmentCheckDecision } from '@/lib/ephemeral-vm-environment-check-decision' import { openPlansPage } from '@/components/billing/plans/plans-page-store' +import { ORCA_BILLING_OVERAGE_SECTION_ID } from '@/components/new-workspace/orca-cloud-run-target-access' import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' import { toast } from 'sonner' import type { EnvironmentSetupFailureContext } from '../../../../shared/environment-setup-agent-prompt' @@ -287,11 +289,20 @@ export function VmBuildSheet({ onCreateLocally: () => onReturnToComposer({ local: true }), onOpenBilling: () => { const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'orca-account', repoId: null }) + store.openSettingsTarget({ + pane: 'orca-account', + repoId: null, + // "Turn on overage" / "Raise spend cap" land on the overage card itself; the + // payment notice is at the top of the section already. + ...(failure?.kind === 'payment' + ? {} + : { sectionId: ORCA_BILLING_OVERAGE_SECTION_ID }) + }) store.openSettingsPage() }, onSignIn: signIn, - onShowWorkspaces: () => useAppStore.getState().setSidebarOpen(true), + // Your workspaces on Home, where you can put one to sleep or delete it. + onShowWorkspaces: () => openCloudWorkspacesHome(), onContactSupport: () => openAppHttpLink(SUPPORT_URL), onConnectGitHub: connectGitHub, onPushToGitHub: () => openAppHttpLink(PUSH_TO_GITHUB_URL) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts index 8a55cc84ea8..306d33527ab 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts @@ -92,12 +92,12 @@ export function vmBuildFailureCopy( ), body: canManageBilling ? t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody', - 'Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody2', + 'Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Turn on overage to start sandboxes again.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember2', - 'Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember3', + 'Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' ) } case 'spendCap': @@ -108,12 +108,12 @@ export function vmBuildFailureCopy( ), body: canManageBilling ? t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody', - 'Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody2', + 'Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Raise the cap to start sandboxes again.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember3', - 'Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember4', + 'Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' ) } case 'payment': diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 28abe1726df..5b965ee4d80 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -6,13 +6,16 @@ export type TerminalCommandFinishedEventDetail = { exitCode: number | null /** The pane's tab, when the emitter knows it; consumers tracking one tab's command need it. */ tabId?: string + /** The pane's stable leaf: a split's commands are not the tab's first pane's. */ + leafId?: string } // Window events let decoupled consumers observe per-pane OSC completion without accessing terminal internals. export function dispatchTerminalCommandFinishedEvent( worktreeId: string, exitCode: number | null, - tabId?: string + tabId?: string, + leafId?: string ): void { // Why: unit tests and non-DOM renderer shims may expose only the preload API. if (typeof window.dispatchEvent !== 'function') { @@ -21,7 +24,7 @@ export function dispatchTerminalCommandFinishedEvent( window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}) } + detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}) } }) ) } diff --git a/src/renderer/src/hooks/use-orca-account-key.ts b/src/renderer/src/hooks/use-orca-account-key.ts index fcc0c894719..cf21dc3e338 100644 --- a/src/renderer/src/hooks/use-orca-account-key.ts +++ b/src/renderer/src/hooks/use-orca-account-key.ts @@ -1,12 +1,16 @@ import { useAppStore } from '@/store' +import type { OrcaProfileAuthStatus } from '../../../shared/orca-profiles' + +/** `${userId}:${activeOrgId}` for the signed-in Orca account, or 'signed-out'. */ +export function getOrcaAccountKey(status: OrcaProfileAuthStatus | null | undefined): string { + const cloud = status?.cloud + return cloud ? `${cloud.userId}:${cloud.activeOrgId ?? ''}` : 'signed-out' +} /** * `${userId}:${activeOrgId}` for the signed-in Orca account. Team-scoped views key on it so a * team switch remounts them: nothing read for the previous team survives into the new one. */ export function useOrcaAccountKey(): string { - return useAppStore((state) => { - const cloud = state.orcaProfileAuthStatus?.cloud - return cloud ? `${cloud.userId}:${cloud.activeOrgId ?? ''}` : 'signed-out' - }) + return useAppStore((state) => getOrcaAccountKey(state.orcaProfileAuthStatus)) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index c62981bd29f..43c799db3c9 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -212,11 +212,15 @@ "lineItemYearly_other": "{{seats}} × {{price}} per seat, billed yearly" }, "plans": { + "sandboxHoursLeftResets_one": "{{count}} sandbox hour left · resets {{date}}", + "sandboxHoursLeftResets_other": "{{count}} sandbox hours left · resets {{date}}", "seatsPerMember_one": "{{count}} seat, one per team member", "seatsPerMember_other": "{{count}} seats, one per team member" }, - "sandboxHoursOver_one": "{{count}} sandbox hour over", - "sandboxHoursOver_other": "{{count}} sandbox hours over", + "sandboxHoursLeftCount_one": "{{count}} sandbox hour left", + "sandboxHoursLeftCount_other": "{{count}} sandbox hours left", + "sandboxHoursOverIncluded_one": "{{count}} sandbox hour over the included amount this month", + "sandboxHoursOverIncluded_other": "{{count}} sandbox hours over the included amount this month", "seatsAnnual_one": "{{count}} seat · Annual", "seatsAnnual_other": "{{count}} seats · Annual", "seatsMonthly_one": "{{count}} seat · Monthly", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 332e49add99..3eae6ed0dfa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2295,7 +2295,8 @@ }, "orcaCloud": { "workspaceActions": { - "unavailable": "Orca Cloud is unavailable." + "unavailable": "Orca Cloud is unavailable.", + "archiveSleepFailed": "It’s still running. Your files are safe." } } }, @@ -4139,7 +4140,6 @@ "fixWithAgent": "Fix with agent", "openAnyway": "Open anyway", "provision": "Provision VM", - "retryChecks": "Retry checks", "provisionCloud": "Start sandbox", "checksCloud": "Prepare environment", "createCloud": "Open workspace", @@ -4161,7 +4161,8 @@ "stateDone": "Done", "stateFailed": "Failed", "openFailedCloud": "Couldn’t open your cloud workspace", - "stateSkipped": "Skipped" + "stateSkipped": "Skipped", + "runChecksAgain": "Run checks again" }, "VmBuildFailureCard": { "createFailedTitle": "Couldn’t create workspace", @@ -4194,9 +4195,9 @@ "connectGitHub": "Connect GitHub", "planBody2": "Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.", "outOfHoursTitle": "Out of sandbox hours", - "outOfHoursBody": "Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.", + "outOfHoursBody2": "Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Turn on overage to start sandboxes again.", "spendCapReachedTitle": "Spend cap reached", - "spendCapReachedBody": "Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.", + "spendCapReachedBody2": "Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Raise the cap to start sandboxes again.", "paymentFailedTitle": "Payment failed", "paymentFailedBody": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.", "teamLimitTitle": "You’re at your team’s limit of running sandboxes", @@ -4216,8 +4217,8 @@ "howToPushToGitHub": "How to push to GitHub", "useThisComputer": "Use this computer instead", "planBodyMember3": "Nothing was created, and you weren’t charged. Ask an owner or admin to choose a Team plan.", - "outOfHoursBodyMember2": "Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", - "spendCapReachedBodyMember3": "Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", + "outOfHoursBodyMember3": "Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", + "spendCapReachedBodyMember4": "Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", "paymentFailedBodyMember2": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method." }, "VmBuildLog": { @@ -7928,8 +7929,7 @@ "account": "Account", "signIn": "Sign in", "accountNamed": "Account: {{value0}}", - "accountReconnect": "Account: sign in again", - "teamPlanLine": "{{value0}} · {{value1}} plan" + "accountReconnect": "Account: sign in again" }, "CloudSshIncludeDialog": { "title": "Let {{editor}} find your cloud workspaces?", @@ -20609,7 +20609,6 @@ "subtitleSubscribed": "Usage this month, your plan, and what Enterprise adds.", "subtitle2": "Run Orca agents in hosted sandboxes that keep going when you step away.", "vpRunningBody2": "Sandboxes keep working while you are away and report back when they finish.", - "leftResets": "{{value0}} hours left · resets {{value1}}", "retry": "Try again", "featureSleep3": "Sleeps after {{value0}} minutes idle, so quiet time is free", "featureReady": "Ready in seconds, nothing to set up or host", @@ -20624,7 +20623,7 @@ "was": "Was", "featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team", "featureSpec": "Every sandbox gets {{value0}} vCPU · {{value1}} GB", - "featureOverage6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)", + "featureOverage7": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default)", "footTerms4": "Prices in USD, before tax. Overage is off until an owner or admin turns it on. Cancel anytime; your plan runs to the end of the billing period.", "lockedBody2": "Only an owner or admin of your team can choose a plan. Ask one of them to open Plan & billing in Orca.", "vpSecretsBody4": "API keys you add to Orca are never written to the sandbox. Agents and terminals get them as environment variables.", @@ -20635,9 +20634,11 @@ "perSeatPerMonth": "per seat per month", "seatsPerMember_one": "{{count}} seat, one per team member", "seatsPerMember_other": "{{count}} seats, one per team member", - "vpWakeBody2": "Idle sandboxes sleep to save hours and wake with your files as you left them.", "totalPerMonth": "{{total}} per month", - "totalPerYear": "{{total}} per year" + "totalPerYear": "{{total}} per year", + "sandboxHoursLeftResets_one": "{{count}} sandbox hour left · resets {{date}}", + "sandboxHoursLeftResets_other": "{{count}} sandbox hours left · resets {{date}}", + "vpWakeBody3": "Idle sandboxes sleep to save sandbox hours and wake with your files as you left them." }, "capLabel2": "Monthly spend cap", "capSaved": "Saved", @@ -20693,45 +20694,46 @@ "upsellLineNoPrice": "Orca Cloud runs agents in hosted sandboxes for your whole team.", "askAdmin2": "Ask an owner or admin to change the plan.", "capInvalid": "Enter a whole dollar amount.", - "capReachedHint": "Raise the cap to keep starting sandboxes. Your files are safe.", + "capReachedHint2": "Raise the cap to start sandboxes again. Your sandboxes are asleep, and your files are safe.", "capSaving": "Saving…", "capUsedLabel": "Overage spend cap used", "enterpriseContactSales": "Contact sales", "openForbidden2": "Only an owner or admin can manage billing.", "openSignedOut2": "Sign in to Orca again to manage your team.", - "overageDescription6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).", - "overageHoursOnly": "{{value0}} overage hours this month", + "overageDescription7": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default).", "overageMemberHint": "Owners and admins can change overage.", "overageOffRead": "Off", "overageOnRead": "On", "overagePaymentFailed": "Update the payment method before changing overage.", "paymentAsleepBody": "New sandboxes can’t start and running sandboxes are asleep. They can wake again once the payment goes through. Your files are safe.", "paymentGraceBody2": "New sandboxes can’t start. Running sandboxes go to sleep on {{value0}} unless the payment goes through.", - "reasonCapAdmin2": "Spend cap reached. Raise the monthly spend cap to keep starting sandboxes. Your files are safe.", - "reasonExhausted3": "Out of sandbox hours for this month. Turn on overage to keep starting sandboxes. Your files are safe.", - "reasonExhaustedMember2": "Out of sandbox hours for this month. Ask an owner or admin to add hours. Your files are safe.", + "reasonCapAdmin3": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the monthly spend cap to start sandboxes again.", + "reasonExhausted4": "Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage to start sandboxes again.", + "reasonExhaustedMember3": "Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.", "reasonInactiveMember": "Your team’s plan is not active. Ask an owner or admin to choose a plan.", "reasonPastDueMember": "Your team’s last payment failed. Ask an owner or admin to update the payment method.", "resetsAndRenews": "Resets and renews {{value0}}", "resetsEnds": "Hours reset {{value0}} · Plan ends {{value1}}", "resetsRenews": "Hours reset {{value0}} · Renews {{value1}}", - "sandboxHoursLeft": "{{value0}} sandbox hours left", "sandboxHoursUsedMonth": "{{value0}} of {{value1}} sandbox hours used this month", - "warn100b": "All of this month’s sandbox hours are used.", - "warnPercent2": "You’ve used {{value0}}% of this month’s sandbox hours.", - "overageDescriptionOn3": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.", - "reasonCapMember3": "Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.", + "overageDescriptionOn4": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set.", + "reasonCapMember4": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to raise the spend cap.", "reasonGraceExpired3": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Update your payment method. Your files are safe.", "reasonGraceExpiredMember2": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Ask an owner or admin to update the payment method. Your files are safe.", "reasonNoPlan2": "Orca Cloud needs a Team plan.", - "sandboxHoursOver_one": "{{count}} sandbox hour over", - "sandboxHoursOver_other": "{{count}} sandbox hours over", "sandboxHoursUsedLabel": "Sandbox hours used", "seatsAnnual_one": "{{count}} seat · Annual", "seatsAnnual_other": "{{count}} seats · Annual", "seatsMonthly_one": "{{count}} seat · Monthly", "seatsMonthly_other": "{{count}} seats · Monthly", - "upsellLine2": "Orca Cloud runs agents in sandboxes. Team is {{value0}} per seat per month." + "upsellLine2": "Orca Cloud runs agents in sandboxes. Team is {{value0}} per seat per month.", + "capAtMost": "The spend cap can be at most {{max}}.", + "loadingPlan": "Loading your plan…", + "sandboxHoursLeftCount_one": "{{count}} sandbox hour left", + "sandboxHoursLeftCount_other": "{{count}} sandbox hours left", + "sandboxHoursOverIncluded_one": "{{count}} sandbox hour over the included amount this month", + "sandboxHoursOverIncluded_other": "{{count}} sandbox hours over the included amount this month", + "teamUsedPercent": "Your team has used {{percent}}% of this month’s sandbox hours." }, "ports": { "RuntimePortAutoForwarder": { @@ -20830,8 +20832,6 @@ "reassignOwner": "Reassign owner", "reassignTitle": "Make {{name}} the owner?", "refresh": "Refresh cloud workspaces", - "restored": "Restored", - "retry": "Retry", "rowActions": "Actions for {{value0}}", "rowDescription2": "{{usage}}. Last active {{lastActive}}. {{visibility}}.", "rowSandboxHours_one": "{{hours}} sandbox hour", @@ -20869,7 +20869,11 @@ "youAreViewing": "You are viewing", "view": "View", "viewAsleep": "View read-only copy", - "routines": "Routines" + "routines": "Routines", + "archiveConfirmTitle": "Archive {{name}}?", + "archiveConfirmBody": "{{owner}}'s agents in this workspace will stop. Files are kept and it can be unarchived.", + "archiveBusy": "This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.", + "showCloudWorkspaces": "Show cloud workspaces" }, "orcaCloud": { "QueuedPrompt": { @@ -26549,7 +26553,8 @@ "moreChanges": "{{count}} more", "noCommits": "No commits on this branch yet.", "files_one": "{{count}} file", - "files_other": "{{count}} files" + "files_other": "{{count}} files", + "loadFailedDetail": "Your files are safe in the archive." }, "state": { "archived": "Archived", @@ -26559,10 +26564,11 @@ "waking": "Waking", "notResponding": "Not responding", "failed": "Couldn’t start", - "unknown": "Checking…" + "unknown": "Checking…", + "starting": "Starting", + "wakeFailed": "Couldn’t wake" }, "unarchive": "Unarchive", - "retry": "Retry", "change": { "modified": "Modified", "added": "Added", @@ -26612,10 +26618,13 @@ "detail": { "restartKeeps": "Restart keeps your files; agents resume.", "restarting": "Orca resumes your agents after the restart.", - "asleep": "Files and chats are saved. Opening it wakes it.", "archived": "Kept as a snapshot. Unarchive to work in it again.", - "failed": "The last start failed. Your files are saved.", - "waking": "Waking…" + "waking": "Waking…", + "asleepSafe": "Your files and chats are safe. Opening it wakes it.", + "starting": "Starting the sandbox…", + "restartToRecover": "Restart to recover. Files are kept and Orca resumes your agents after the restart.", + "startFailed": "The sandbox couldn’t start. Nothing was lost.", + "wakeFailed": "Couldn’t wake the sandbox. Your files and chats are safe." }, "restarting": "Restarting…", "restart": "Restart", @@ -26629,13 +26638,10 @@ "usedOfTotal": "{{used}} / {{total}}", "disk": "Disk", "uptime": "Up for", - "usedLabel": "Used", - "overage": "Overage so far", "keptUntil": "Kept until", "ago": "{{ago}} ago", "wake": "Wake", "error": { - "noHours": "You’re out of sandbox hours. Your files are safe.", "seePlans": "See plans", "unavailable": "Orca Cloud isn’t reachable right now. Try again in a moment.", "generic": "Something went wrong. Try again." @@ -26646,9 +26652,6 @@ "restarted": "Sandbox restarted", "restartedDetail": "Orca resumes your agents after the restart.", "archiveFailed": "Couldn’t archive the workspace", - "archivingNamed": "Archiving {{name}}…", - "archiving": "Archiving workspace…", - "archivingDetail": "It stops and stays browsable: PRs, last changes and files.", "undo": "Undo", "unarchiveFailed": "Couldn’t unarchive the workspace", "asleep": { @@ -26681,6 +26684,28 @@ "recovery": { "reconnectingTitle": "Reconnecting to {{workspaceName}}…", "reconnectingBody": "The connection dropped. Orca is retrying; your files and chats are safe." + }, + "unarchiveFailedNamed": "Couldn’t unarchive {{name}}", + "unarchivedNamed": "Unarchived {{name}}", + "unarchivedAsleep": "It’s asleep. Opening it wakes it.", + "archiveFailedNamed": "Couldn’t archive {{name}}", + "archivedNamed": "Archived {{name}}", + "archivedWorkspace": "Archived the workspace", + "archivedDetail": "It goes to sleep and stays browsable: PRs, last changes and files.", + "tryAgain": "Try again", + "sandboxHoursUsedLabel": "Sandbox hours used", + "sleep": "Sleep", + "thisWorkspace": "This workspace", + "sleepFailedNamed": "Couldn’t put {{name}} to sleep", + "sleepFailedCount": "Couldn’t put {{count}} workspaces to sleep", + "sleepFailedDetail": "Its sandbox is still running and using sandbox hours. Your files are safe.", + "versionSkew": { + "newerTitle": "This cloud workspace uses a newer version of Orca", + "olderTitle": "This cloud workspace uses an older version of Orca", + "newerDetail": "Update Orca on this computer to connect to it. Your files are safe.", + "olderDetail": "This version of Orca can’t connect to it, and restarting won’t update it. Your files are safe. New cloud workspaces use the current version.", + "checkForUpdates": "Check for updates", + "createWorkspace": "Create workspace" } }, "cloudAgentSetup": { @@ -27079,23 +27104,37 @@ "running": "Running", "stopping": "Stopping…", "stopped": "Stopped", - "exited": "Exited", "failed": "Failed", "failedWithCode": "Failed (exit {{code}})", - "notRunning": "Not running" + "notRunning": "Not running", + "finished": "Finished" }, "restart": "Restart", "stop": "Stop", "runAgain": "Run again", "run": "Run", "showOutput": "Show output", - "showRunning": "Show {{name}}", "runNamed": "Run {{name}}", "menu": "Run actions", "ports": "Ports", "noPorts": "No servers listening in this workspace yet.", "tabTitle": "Run: {{name}}", "noneTitle": "No run actions for this workspace", - "noneBody": "Add a command under scripts.run in orca.yaml, for example a dev server on $ORCA_PORT." + "noneBody": "Add a command under scripts.run in orca.yaml, for example a dev server on $ORCA_PORT.", + "announceStopped": "{{name}} stopped", + "announceFailed": "{{name}} failed", + "announceFinished": "{{name}} finished", + "restartNamed": "Restart {{name}}", + "stopNamed": "Stop {{name}}", + "runAgainNamed": "Run {{name}} again", + "showOutputNamed": "Show {{name}} output", + "openInBrowser": "Open in browser", + "shareInPorts": "Share…", + "runningShowOutput": "{{name}} is running. Show output", + "stoppingShowOutput": "{{name}} is stopping. Show output", + "runLastFailed": "Run {{name}}, last run failed", + "showOutputTooltip": "Show {{name}} output", + "shortcutRestarts": "{{shortcut}} restarts it", + "menuRunning": "Run actions, {{count}} running" } } diff --git a/src/renderer/src/lib/orca-cloud/cloud-runtime-skew.test.ts b/src/renderer/src/lib/orca-cloud/cloud-runtime-skew.test.ts new file mode 100644 index 00000000000..933356366ae --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-runtime-skew.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { evaluateCloudRuntimeSkew } from './cloud-runtime-skew' + +// Desktop speaks protocol 5 and still accepts sandboxes down to 3. +const desktop = { protocolVersion: 5, minCompatibleServerProtocolVersion: 3 } +const COMMIT = 'c'.repeat(40) + +describe('evaluateCloudRuntimeSkew', () => { + it('tolerates any sandbox inside the range, older or newer', () => { + for (const sandbox of [3, 4, 5, 6, 9]) { + expect( + evaluateCloudRuntimeSkew( + { runtimeProtocolVersion: sandbox, minCompatibleRuntimeClientVersion: 3 }, + desktop + ) + ).toEqual({ kind: 'compatible' }) + } + }) + + it('reports an older sandbox below the desktop minimum, with its build', () => { + expect( + evaluateCloudRuntimeSkew( + { + runtimeProtocolVersion: 2, + minCompatibleRuntimeClientVersion: 1, + appVersion: '1.4.0', + buildCommit: COMMIT + }, + desktop + ) + ).toEqual({ + kind: 'sandbox-older', + desktopProtocolVersion: 5, + sandboxProtocolVersion: 2, + sandboxAppVersion: '1.4.0', + sandboxBuildCommit: COMMIT + }) + }) + + it('reports an older desktop when the sandbox requires a newer client', () => { + expect( + evaluateCloudRuntimeSkew( + { runtimeProtocolVersion: 8, minCompatibleRuntimeClientVersion: 6 }, + desktop + ) + ).toMatchObject({ kind: 'desktop-older', sandboxProtocolVersion: 8, sandboxBuildCommit: null }) + }) + + it('a desktop that is too old wins over a sandbox that is too old', () => { + expect( + evaluateCloudRuntimeSkew( + { runtimeProtocolVersion: 2, minCompatibleRuntimeClientVersion: 6 }, + desktop + ).kind + ).toBe('desktop-older') + }) + + it('reads the legacy mobile aliases, and treats a missing protocol as an old sandbox', () => { + expect( + evaluateCloudRuntimeSkew({ protocolVersion: 4, minCompatibleMobileVersion: 2 }, desktop) + ).toEqual({ kind: 'compatible' }) + expect(evaluateCloudRuntimeSkew({}, desktop).kind).toBe('sandbox-older') + }) + + it('uses this build’s protocol constants by default', () => { + expect( + evaluateCloudRuntimeSkew({ + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }) + ).toEqual({ kind: 'compatible' }) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-runtime-skew.ts b/src/renderer/src/lib/orca-cloud/cloud-runtime-skew.ts new file mode 100644 index 00000000000..89b6be872aa --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-runtime-skew.ts @@ -0,0 +1,44 @@ +import { + evaluateRuntimeStatusCompat, + type RuntimeStatusCompatInput +} from '@/runtime/rpc/compatibility/runtime-protocol-compat' +import type { RuntimeStatus } from '../../../../shared/runtime-session-contracts' + +/** + * Version skew between this desktop and the orcad inside an Orca Cloud sandbox. The golden image + * follows cloud/integration HEAD and desktops update on their own schedule, so builds rarely match. + * The rule is the runtime protocol range (shared/protocol-version.ts), never commits or app + * versions: inside the range the workspace just works and nothing is shown. + * - `sandbox-older`: the sandbox's orcad speaks a protocol below what this desktop still supports. + * - `desktop-older`: the sandbox's orcad requires a newer protocol than this desktop speaks. + */ +export type CloudRuntimeSkew = + | { kind: 'compatible' } + | { + kind: 'sandbox-older' | 'desktop-older' + desktopProtocolVersion: number + sandboxProtocolVersion: number + sandboxAppVersion: string | null + sandboxBuildCommit: string | null + } + +export type CloudRuntimeSkewInput = RuntimeStatusCompatInput & + Pick + +export function evaluateCloudRuntimeSkew( + status: CloudRuntimeSkewInput, + desktop?: { protocolVersion: number; minCompatibleServerProtocolVersion: number } +): CloudRuntimeSkew { + // Same verdict as the RPC gate, so the notice shows exactly when the gate refuses this sandbox. + const verdict = evaluateRuntimeStatusCompat(status, desktop) + if (verdict.kind === 'ok') { + return { kind: 'compatible' } + } + return { + kind: verdict.reason === 'client-too-old' ? 'desktop-older' : 'sandbox-older', + desktopProtocolVersion: verdict.clientProtocolVersion, + sandboxProtocolVersion: verdict.serverProtocolVersion, + sandboxAppVersion: status.appVersion ?? null, + sandboxBuildCommit: status.buildCommit ?? null + } +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts new file mode 100644 index 00000000000..b7785f50be8 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts @@ -0,0 +1,51 @@ +// Sleep that left an Orca Cloud sandbox running: glossary copy (sleep, sandbox hours, files safe) +// instead of the generic VM wording, with a Try again that reruns the ordinary Sleep. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { findWorktreeById } from '@/store/github/refresh/worktree-refresh' +import { getCloudWorkspaceSnapshot } from './orca-cloud-workspace-store' + +export function isCloudWorkspaceId(worktreeId: string): boolean { + return getCloudWorkspaceSnapshot().ids.has(worktreeId) +} + +function workspaceName(worktreeId: string): string { + return ( + findWorktreeById(useAppStore.getState(), worktreeId)?.displayName ?? + translate('orcaCloudWorkspace.thisWorkspace', 'This workspace') + ) +} + +/** True when it showed the cloud toast (every id is an Orca Cloud workspace). */ +export function toastCloudSleepFailure( + worktreeIds: readonly string[], + retry: (ids: readonly string[]) => void +): boolean { + if (worktreeIds.length === 0 || !worktreeIds.every(isCloudWorkspaceId)) { + return false + } + const title = + worktreeIds.length === 1 + ? translate('orcaCloudWorkspace.sleepFailedNamed', 'Couldn’t put {{name}} to sleep', { + name: workspaceName(worktreeIds[0]!) + }) + : translate( + 'orcaCloudWorkspace.sleepFailedCount', + 'Couldn’t put {{count}} workspaces to sleep', + { + count: worktreeIds.length + } + ) + toast.error(title, { + description: translate( + 'orcaCloudWorkspace.sleepFailedDetail', + 'Its sandbox is still running and using sandbox hours. Your files are safe.' + ), + action: { + label: translate('orcaCloudWorkspace.tryAgain', 'Try again'), + onClick: () => retry(worktreeIds) + } + }) + return true +} diff --git a/src/renderer/src/lib/orca-cloud/coalesce-with-follow-up.ts b/src/renderer/src/lib/orca-cloud/coalesce-with-follow-up.ts new file mode 100644 index 00000000000..1a89078c132 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/coalesce-with-follow-up.ts @@ -0,0 +1,31 @@ +// Coalesces concurrent reads per key, but never hands back a read that may predate the request: +// asked again while a read is in flight, exactly one more read runs after it and every caller +// waits for that one (a wake/archive/restart event must not settle on a pre-event answer). +export type FollowUpCoalescer = { + run: (key: string, read: () => Promise) => Promise + clear: () => void +} + +export function createFollowUpCoalescer(): FollowUpCoalescer { + const inflight = new Map>() + const dirty = new Set() + const coalescer: FollowUpCoalescer = { + run(key, read) { + const existing = inflight.get(key) + if (existing) { + dirty.add(key) + return existing + } + const settled = read() + .finally(() => inflight.delete(key)) + .then(() => (dirty.delete(key) ? coalescer.run(key, read) : undefined)) + inflight.set(key, settled) + return settled + }, + clear() { + inflight.clear() + dirty.clear() + } + } + return coalescer +} diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-actions.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-actions.ts index 97be3f20a17..9351f431e83 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-actions.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-actions.ts @@ -58,7 +58,8 @@ export function restartCloudWorkspace(workspaceId: string): Promise Promise + /** The ordinary Sleep; a result whose workspaces did not all sleep stops the archive. */ + sleep: (workspaceId: string) => Promise pullRequests?: HostedArchivePullRequest[] } ): Promise> { @@ -67,7 +68,15 @@ export function archiveCloudWorkspace( return Promise.resolve(unavailable()) } return withPending(workspaceId, 'archive', async () => { - await deps.sleep(workspaceId) + const slept = await deps.sleep(workspaceId) + if (slept && !slept.every((result) => result.completed)) { + // Never archive a sandbox that is still running: it would keep using hours behind an "Archived" label. + const message = translate( + 'auto.lib.orcaCloud.workspaceActions.archiveSleepFailed', + 'It’s still running. Your files are safe.' + ) + return { ok: false, code: 'failed', message, userMessage: message } + } return bridge.archive({ workspaceId, ...(deps.pullRequests?.length ? { pullRequests: deps.pullRequests } : {}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.test.ts index b19fa8f6c6b..174cdd4728a 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.test.ts @@ -83,6 +83,10 @@ describe('deriveCloudDotState', () => { sleeping: true, now: NOW }) + ).toBe('wakeFailed') + // A failed create (not a wake) stays "Couldn't start". + expect( + deriveCloudDotState({ status: status('failed'), wake: undefined, sleeping: false, now: NOW }) ).toBe('failed') }) @@ -111,7 +115,7 @@ describe('deriveCloudDotState', () => { sleeping: false, now: NOW }) - ).toBe('waking') + ).toBe('starting') expect(deriveCloudDotState({ status: null, wake: undefined, sleeping: false, now: NOW })).toBe( 'unknown' ) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.ts index b69ccb74dbf..5180520162c 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-state.ts @@ -11,9 +11,14 @@ export type CloudDotState = | 'running' | 'sleeping' | 'waking' + /** A new sandbox being created (not a wake). */ + | 'starting' | 'unresponsive' | 'archived' + /** Creating the sandbox failed. */ | 'failed' + /** Waking an existing sandbox failed; the workspace itself is fine. */ + | 'wakeFailed' | 'unknown' /** A wake event older than this without a terminal event is treated as stale (the step timed out). */ @@ -50,7 +55,10 @@ export function deriveCloudDotState(input: { if (status?.state === 'archived' || status?.archive) { return 'archived' } - if (isWakeFailedFresh(wake, now) || status?.state === 'failed') { + if (isWakeFailedFresh(wake, now)) { + return 'wakeFailed' + } + if (status?.state === 'failed') { return 'failed' } // The control plane is the truth for the VM; the desktop's own sleep record only fills in @@ -67,8 +75,9 @@ export function deriveCloudDotState(input: { case 'stopping': return 'sleeping' case 'waking': - case 'provisioning': return 'waking' + case 'provisioning': + return 'starting' } } diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts index 6c5c05fd076..50a2f43ca86 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts @@ -62,10 +62,15 @@ describe('orca cloud workspace store', () => { expect(bridge.getStatus).toHaveBeenCalledTimes(2) }) - it('coalesces concurrent status reads and keeps the last good status on error', async () => { + it('coalesces concurrent status reads into one follow-up and keeps the last good status on error', async () => { const bridge = stubApi() - await Promise.all([refreshCloudWorkspaceStatus('a'), refreshCloudWorkspaceStatus('a')]) - expect(bridge.getStatus).toHaveBeenCalledTimes(1) + // Three asks while one read is in flight: one follow-up read, not three. + await Promise.all([ + refreshCloudWorkspaceStatus('a'), + refreshCloudWorkspaceStatus('a'), + refreshCloudWorkspaceStatus('a') + ]) + expect(bridge.getStatus).toHaveBeenCalledTimes(2) bridge.getStatus = vi.fn(async () => ({ ok: false as const, code: 'unavailable' as const, @@ -91,6 +96,27 @@ describe('orca cloud workspace store', () => { expect(getCloudWorkspaceSnapshot().ids.has('a')).toBe(false) }) + it('re-reads after an in-flight read that started before the wake finished (no stale Asleep)', async () => { + let release: () => void = () => {} + const states: HostedWorkspaceStatus['state'][] = ['sleeping', 'ready'] + const bridge = stubApi({ + getStatus: vi.fn(async () => { + const state = states.shift()! + if (state === 'sleeping') { + await new Promise((resolve) => (release = resolve)) + } + return ok({ ...ready, state }) + }) + }) + const first = refreshCloudWorkspaceStatus('a') + // The wake becomes ready while the first (pre-wake) read is still out. + const afterWake = refreshCloudWorkspaceStatus('a') + release() + await Promise.all([first, afterWake]) + expect(bridge.getStatus).toHaveBeenCalledTimes(2) + expect(getCloudWorkspaceSnapshot().status.a?.status?.state).toBe('ready') + }) + it('archives after the ordinary sleep, and marks pending while it runs', async () => { const bridge = stubApi() const order: string[] = [] @@ -104,6 +130,16 @@ describe('orca cloud workspace store', () => { expect(getCloudWorkspaceSnapshot().pending.a).toBeUndefined() }) + it('does not archive when the sleep did not finish (the sandbox would keep running)', async () => { + const bridge = stubApi() + const result = await archiveCloudWorkspace('a', { + sleep: async () => [{ completed: false }] + }) + expect(result).toMatchObject({ ok: false, code: 'failed' }) + expect(bridge.archive).not.toHaveBeenCalled() + expect(getCloudWorkspaceSnapshot().pending.a).toBeUndefined() + }) + it('unarchives, clears the archived status, then runs the wake path', async () => { stubApi() seedCloudWorkspaceSnapshot({ diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.ts index beb3f985fd1..821f07bf22f 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.ts @@ -2,6 +2,7 @@ // status from the control plane, in-flight wakes and OOM notices. The one source of cloud ids and // wake state for every cloud surface. A tiny external store (not an app-store slice) so the // hundreds of sidebar rows that are not cloud workspaces subscribe to nothing. +import { createFollowUpCoalescer } from './coalesce-with-follow-up' import { armInterruptedTurnsForHostRestart, continueInterruptedTurns, @@ -98,42 +99,37 @@ export function isCloudWorkspaceArchived(workspaceId: string): boolean { return entry?.state === 'archived' || Boolean(entry?.archive) } -const inflight = new Map>() +const statusReads = createFollowUpCoalescer() -/** Fetches status once (coalesced); keeps the last good status visible on error. */ +/** Fetches status (coalesced, re-read once if asked mid-read); keeps the last good status on error. */ export function refreshCloudWorkspaceStatus(workspaceId: string): Promise { - const existing = inflight.get(workspaceId) - if (existing) { - return existing - } const bridge = cloudWorkspaceApi() if (!bridge) { return Promise.resolve() } - updateCloudWorkspaceSnapshot((s) => ({ - status: { - ...s.status, - [workspaceId]: { - status: s.status[workspaceId]?.status ?? null, - error: s.status[workspaceId]?.error ?? null, - fetchedAt: s.status[workspaceId]?.fetchedAt ?? null, - loading: true + return statusReads.run(workspaceId, () => { + updateCloudWorkspaceSnapshot((s) => ({ + status: { + ...s.status, + [workspaceId]: { + status: s.status[workspaceId]?.status ?? null, + error: s.status[workspaceId]?.error ?? null, + fetchedAt: s.status[workspaceId]?.fetchedAt ?? null, + loading: true + } } - } - })) - const run = bridge - .getStatus({ workspaceId }) - .then((result) => applyStatus(workspaceId, result)) - .catch((error: unknown) => - applyStatus(workspaceId, { - ok: false, - code: 'failed', - message: error instanceof Error ? error.message : String(error) - }) - ) - .finally(() => inflight.delete(workspaceId)) - inflight.set(workspaceId, run) - return run + })) + return bridge + .getStatus({ workspaceId }) + .then((result) => applyStatus(workspaceId, result)) + .catch((error: unknown) => + applyStatus(workspaceId, { + ok: false, + code: 'failed', + message: error instanceof Error ? error.message : String(error) + }) + ) + }) } /** Mount-time refresh that skips a status read in the last `maxAgeMs` (sidebar rows remount often). */ @@ -319,7 +315,7 @@ export function noteCloudOom(event: CloudOomEvent): void { /** Test/fixture seam: replace the whole snapshot. */ export function seedCloudWorkspaceSnapshot(next: Partial): void { - inflight.clear() + statusReads.clear() updateCloudWorkspaceSnapshot(() => ({ ...EMPTY, ...next })) } diff --git a/src/renderer/src/lib/orca-cloud/use-cloud-runtime-skew.test.tsx b/src/renderer/src/lib/orca-cloud/use-cloud-runtime-skew.test.tsx new file mode 100644 index 00000000000..00cf1f3065f --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/use-cloud-runtime-skew.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { + checkCloudRuntimeSkew, + resetCloudRuntimeSkewForTests, + useCloudRuntimeSkew +} from './use-cloud-runtime-skew' + +const owner = vi.hoisted(() => { + const state: { environmentId: string | null } = { environmentId: 'env-1' } + return state +}) +vi.mock('@/lib/workspace/ownership/resolve-owner', () => ({ + runtimeEnvironmentIdForWorkspaceOwner: () => ({ + kind: 'resolved', + environmentId: owner.environmentId + }) +})) + +const call = vi.fn() +const IN_RANGE = { + runtimeId: 'rt', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} +const NEWER = { runtimeId: 'rt', runtimeProtocolVersion: 99, minCompatibleRuntimeClientVersion: 99 } +const OLDER = { runtimeId: 'rt', runtimeProtocolVersion: 0, minCompatibleRuntimeClientVersion: 0 } + +function ok(result: Record): unknown { + return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } } +} + +beforeEach(() => { + call.mockReset() + owner.environmentId = 'env-1' + Object.assign(window, { api: { runtimeEnvironments: { call } } }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + resetCloudRuntimeSkewForTests() + vi.restoreAllMocks() +}) + +describe('checkCloudRuntimeSkew', () => { + it('reads status.get once, shares a call in flight, and reuses the answer', async () => { + call.mockResolvedValue(ok(IN_RANGE)) + const [a, b] = await Promise.all([ + checkCloudRuntimeSkew('env-1'), + checkCloudRuntimeSkew('env-1') + ]) + expect(a).toEqual({ kind: 'compatible' }) + expect(b).toEqual({ kind: 'compatible' }) + await checkCloudRuntimeSkew('env-1') + expect(call).toHaveBeenCalledTimes(1) + expect(call.mock.calls[0][0]).toMatchObject({ selector: 'env-1', method: 'status.get' }) + }) + + it('asks again once the answer is older than 30 minutes', async () => { + call.mockResolvedValue(ok(IN_RANGE)) + await checkCloudRuntimeSkew('env-1') + await checkCloudRuntimeSkew('env-1', Date.now() + 31 * 60_000) + expect(call).toHaveBeenCalledTimes(2) + }) + + it('an unreachable sandbox is unknown (null), not skew, and is asked again next time', async () => { + call.mockRejectedValueOnce(new Error('offline')) + await expect(checkCloudRuntimeSkew('env-1')).resolves.toBeNull() + call.mockResolvedValueOnce({ id: 'r', ok: false, error: { code: 'x', message: 'y' } }) + await expect(checkCloudRuntimeSkew('env-1')).resolves.toBeNull() + expect(call).toHaveBeenCalledTimes(2) + }) +}) + +describe('useCloudRuntimeSkew', () => { + it('shows nothing inside the range', async () => { + call.mockResolvedValue(ok(IN_RANGE)) + const { result } = renderHook(() => useCloudRuntimeSkew('ws', true)) + await waitFor(() => expect(call).toHaveBeenCalled()) + expect(result.current.skew).toBeNull() + }) + + it('does not ask a sandbox that is not running', () => { + renderHook(() => useCloudRuntimeSkew('ws', false)) + expect(call).not.toHaveBeenCalled() + }) + + it('flags an older sandbox', async () => { + call.mockResolvedValue(ok(OLDER)) + const { result } = renderHook(() => useCloudRuntimeSkew('ws', true)) + await waitFor(() => expect(result.current.skew?.kind).toBe('sandbox-older')) + }) + + it('flags a newer sandbox until dismissed, and stays dismissed on remount', async () => { + call.mockResolvedValue(ok(NEWER)) + const first = renderHook(() => useCloudRuntimeSkew('ws', true)) + await waitFor(() => expect(first.result.current.skew?.kind).toBe('desktop-older')) + act(() => first.result.current.dismiss()) + expect(first.result.current.skew).toBeNull() + first.unmount() + const again = renderHook(() => useCloudRuntimeSkew('ws', true)) + expect(again.result.current.skew).toBeNull() + }) + + it('drops the verdict when the workspace moves to another environment', async () => { + call.mockResolvedValueOnce(ok(NEWER)) + const { result, rerender } = renderHook(() => useCloudRuntimeSkew('ws', true)) + await waitFor(() => expect(result.current.skew?.kind).toBe('desktop-older')) + call.mockReturnValue(new Promise(() => {})) + owner.environmentId = 'env-2' + rerender() + expect(result.current.skew).toBeNull() + }) + + it('retries while the sandbox has not answered yet', async () => { + vi.useFakeTimers() + call.mockRejectedValueOnce(new Error('not yet')).mockResolvedValue(ok(NEWER)) + const { result } = renderHook(() => useCloudRuntimeSkew('ws', true)) + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.skew?.kind).toBe('desktop-older') + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/use-cloud-runtime-skew.ts b/src/renderer/src/lib/orca-cloud/use-cloud-runtime-skew.ts new file mode 100644 index 00000000000..987063937b6 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/use-cloud-runtime-skew.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppStore } from '@/store' +import { runtimeEnvironmentIdForWorkspaceOwner } from '@/lib/workspace/ownership/resolve-owner' +import { unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-session-contracts' +import { evaluateCloudRuntimeSkew, type CloudRuntimeSkew } from './cloud-runtime-skew' + +const STATUS_TIMEOUT_MS = 10_000 +/** A sandbox keeps its orcad across sleep and restart, so one answer holds for a long while. */ +const SKEW_TTL_MS = 30 * 60_000 +const RETRIES = 3 +const RETRY_DELAY_MS = 5_000 + +const checked = new Map() +const inFlight = new Map>() +/** Workspaces whose skew notice was dismissed this app session (it comes back after a relaunch). */ +const dismissed = new Set() + +/** + * Reads status.get straight from the sandbox, outside the RPC compatibility gate (which throws on + * exactly the skew this reports). Null when the sandbox doesn't answer: unknown is not skew. + */ +export function checkCloudRuntimeSkew( + environmentId: string, + now: number = Date.now() +): Promise { + const cached = checked.get(environmentId) + if (cached && now - cached.at < SKEW_TTL_MS) { + return Promise.resolve(cached.skew) + } + const running = inFlight.get(environmentId) + if (running) { + return running + } + const check = window.api.runtimeEnvironments + .call({ selector: environmentId, method: 'status.get', timeoutMs: STATUS_TIMEOUT_MS }) + .then((response) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: status.get answers RuntimeStatus; every field read here is optional and range-checked. + const status = unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + const skew = evaluateCloudRuntimeSkew(status) + checked.set(environmentId, { skew, at: Date.now() }) + if (skew.kind !== 'compatible') { + // Builds and protocols are support details: logged here, never shown in the notice. + console.warn('[orca-cloud] version skew', { environmentId, ...skew }) + } + return skew + }) + .catch(() => null) + .finally(() => inFlight.delete(environmentId)) + inFlight.set(environmentId, check) + return check +} + +/** "Running" can flip before orcad answers: a few spaced tries instead of never checking. */ +async function checkWithRetries( + environmentId: string, + isCancelled: () => boolean +): Promise { + for (let attempt = 0; ; attempt += 1) { + const result = await checkCloudRuntimeSkew(environmentId) + if (result || attempt >= RETRIES || isCancelled()) { + return result + } + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)) + } +} + +export function resetCloudRuntimeSkewForTests(): void { + checked.clear() + inFlight.clear() + dismissed.clear() +} + +/** + * The skew between this desktop and the workspace's sandbox, checked once it is running. Null + * while unknown, when compatible, or after the person dismissed the notice. + */ +export function useCloudRuntimeSkew( + workspaceId: string, + running: boolean +): { skew: Exclude | null; dismiss: () => void } { + const environmentId = useAppStore((state) => { + const owner = runtimeEnvironmentIdForWorkspaceOwner(state, { workspaceId }) + return owner.kind === 'resolved' ? owner.environmentId : null + }) + // Keyed by environment: a re-pair or a stop never shows the previous environment's verdict. + const [answer, setAnswer] = useState<{ environmentId: string; skew: CloudRuntimeSkew } | null>( + null + ) + const [isDismissed, setDismissed] = useState(() => dismissed.has(workspaceId)) + useEffect(() => { + if (!running || !environmentId || isDismissed) { + return + } + let cancelled = false + void checkWithRetries(environmentId, () => cancelled).then((result) => { + if (!cancelled && result) { + setAnswer({ environmentId, skew: result }) + } + }) + return () => { + cancelled = true + } + }, [running, environmentId, isDismissed]) + const dismiss = useCallback(() => { + dismissed.add(workspaceId) + setDismissed(true) + }, [workspaceId]) + const skew = + running && !isDismissed && answer && answer.environmentId === environmentId ? answer.skew : null + return { + skew: skew && skew.kind !== 'compatible' ? skew : null, + dismiss + } +} diff --git a/src/renderer/src/lib/run-actions/run-action-config.ts b/src/renderer/src/lib/run-actions/run-action-config.ts index be7d6b2ad80..0ac4ee3ef3f 100644 --- a/src/renderer/src/lib/run-actions/run-action-config.ts +++ b/src/renderer/src/lib/run-actions/run-action-config.ts @@ -21,6 +21,8 @@ export type WorktreeRunActions = { scripts: OrcaRunScripts /** Ids defined by the shared orca.yaml (they need the user's approval before running). */ sharedIds: ReadonlySet + /** The host prepared the checkout itself (setup ran in an environment build, not a Setup tab). */ + setupProvisioned?: true } export type RunActionTarget = { @@ -96,13 +98,20 @@ export async function loadWorktreeRunActions( } const repo = state.repos.find((candidate) => candidate.id === target.repoId) let shared: OrcaHooks['scripts'] | undefined + let setupProvisioned = false try { const result = await checkRuntimeHooks(target.hostId, target.repoId, options) shared = result.status === 'error' ? undefined : (result.hooks?.scripts ?? undefined) + setupProvisioned = result.environmentProvisioned === true } catch { shared = undefined } - return mergeRunActions(shared, repo?.hookSettings?.scripts, target.isLocal ? 'local' : 'remote') + const merged = mergeRunActions( + shared, + repo?.hookSettings?.scripts, + target.isLocal ? 'local' : 'remote' + ) + return setupProvisioned ? { ...merged, setupProvisioned: true } : merged } /** The action the Run button and shortcut act on: the last one still running here, else the default. */ diff --git a/src/renderer/src/lib/run-actions/run-action-controller.test.ts b/src/renderer/src/lib/run-actions/run-action-controller.test.ts index d6fc27a7354..9f828ebd204 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.test.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.test.ts @@ -10,6 +10,7 @@ type MockState = { { id: string; entityId: string; contentType: string; groupId: string }[] > ptyIdsByTabId: Record + terminalLayoutsByTabId: Record createTab: ReturnType closeTab: ReturnType setTabCustomTitle: ReturnType @@ -69,7 +70,8 @@ vi.stubGlobal('window', new EventTarget()) const { mergeRunActions, loadWorktreeRunActions } = await import('./run-action-config') const { startRunAction, stopRunAction, relaunchInterruptedRunActions, armRunActionRelaunch } = await import('./run-action-controller') -const { installRunActionEvents, armAutoRunAfterSetup } = await import('./run-action-events') +const { installRunActionEvents, armAutoRunAfterSetup, autoRunProvisionedWorkspaceOnce } = + await import('./run-action-events') const { getRunActionRun, resetRunActionRunsForTests } = await import('./run-actions-store') const { dispatchTerminalCommandFinishedEvent } = await import('@/hooks/terminal-command-finished-event') @@ -90,6 +92,7 @@ function makeState(): MockState { tabsByWorktree: {}, unifiedTabsByWorktree: {}, ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, createTab: vi.fn((worktreeId: string) => { const tab = { id: `tab-${++nextTab}` } state.tabsByWorktree[worktreeId] = [...(state.tabsByWorktree[worktreeId] ?? []), tab] @@ -226,6 +229,25 @@ describe('run actions', () => { expect(getRunActionRun(WT_B, 'web')?.status).toBe('running') }) + it('nonconcurrent leaves the project’s other actions running, here and elsewhere', async () => { + const worker: OrcaRunAction = { id: 'worker', name: 'Worker', command: 'pnpm worker' } + mocks.checkRuntimeHooks.mockResolvedValue({ + status: 'ok', + hooks: { scripts: { run: [web, worker], runMode: 'nonconcurrent' } } + }) + await startRunAction(WT_A, web, config([web, worker], 'nonconcurrent')) + await startRunAction(WT_B, worker, config([web, worker], 'nonconcurrent')) + const starting = startRunAction(WT_A, worker, config([web, worker], 'nonconcurrent')) + await vi.advanceTimersByTimeAsync(0) + // Only the same action in the other workspace is interrupted. + expect(mocks.sendRuntimePtyInput).toHaveBeenCalledTimes(1) + expect(getRunActionRun(WT_B, 'worker')?.status).toBe('stopping') + dispatchTerminalCommandFinishedEvent(WT_B, 130, getRunActionRun(WT_B, 'worker')!.tabId) + await starting + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + expect(getRunActionRun(WT_A, 'worker')?.status).toBe('running') + }) + it('concurrent leaves other workspaces running', async () => { await startRunAction(WT_A, web, config([web])) await startRunAction(WT_B, web, config([web])) @@ -303,6 +325,73 @@ describe('run actions', () => { expect(await startRunAction(WT_B, web, config([web]))).toBe(false) }) + it('a command finishing in a split of the run tab does not end the run', async () => { + await startRunAction(WT_A, web, config([web])) + const { tabId } = getRunActionRun(WT_A, 'web')! + const L1 = '11111111-1111-4111-8111-111111111111' + const L2 = '22222222-2222-4222-8222-222222222222' + const notifyLayouts = (): void => { + for (const listener of mocks.subscribers) { + listener(state, { tabsByWorktree: state.tabsByWorktree, terminalLayoutsByTabId: {} }) + } + } + state.terminalLayoutsByTabId = { [tabId]: { root: { type: 'leaf', leafId: L1 } } } + notifyLayouts() + expect(getRunActionRun(WT_A, 'web')?.leafId).toBe(L1) + state.terminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: L1 }, + second: { type: 'leaf', leafId: L2 } + } + } + } + notifyLayouts() + dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, L2) + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + dispatchTerminalCommandFinishedEvent(WT_A, 1, tabId, L1) + expect(getRunActionRun(WT_A, 'web')).toMatchObject({ status: 'failed', exitCode: 1 }) + }) + + it('ignores a pane it cannot attribute while the tab has several panes', async () => { + await startRunAction(WT_A, web, config([web])) + const { tabId } = getRunActionRun(WT_A, 'web')! + state.terminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + } + } + } + dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, '22222222-2222-4222-8222-222222222222') + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + }) + + it('auto-runs once on first open when the host ran setup in the environment build', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => void storage.set(key, value) + }) + try { + const provisioned = { ...config([web]), setupProvisioned: true as const } + provisioned.scripts = { ...provisioned.scripts, autoRunAfterSetup: true } + expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(true) + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + dispatchTerminalCommandFinishedEvent(WT_A, 0, getRunActionRun(WT_A, 'web')!.tabId) + expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(false) + const notProvisioned = { ...provisioned, setupProvisioned: undefined } + expect(await autoRunProvisionedWorkspaceOnce(WT_B, notProvisioned)).toBe(false) + } finally { + vi.stubGlobal('localStorage', undefined) + } + }) + it('auto-runs the default action once setup succeeds, not when it fails', async () => { mocks.checkRuntimeHooks.mockResolvedValue({ status: 'ok', diff --git a/src/renderer/src/lib/run-actions/run-action-controller.ts b/src/renderer/src/lib/run-actions/run-action-controller.ts index 2d515fe0acb..d060e038a86 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.ts @@ -21,7 +21,7 @@ import { type RunActionTarget, type WorktreeRunActions } from './run-action-config' -import { findLiveRunTabId, sameRunTab } from './run-action-tabs' +import { findLiveRunTabId, runPanePtyId, sameRunTab } from './run-action-tabs' /** Conductor's grace period: an interrupted dev server gets this long before its shell is closed. */ export const RUN_ACTION_STOP_GRACE_MS = 5_000 @@ -101,8 +101,7 @@ async function stopRunActionOnce(worktreeId: string, actionId: string): Promise< return } const state = useAppStore.getState() - const liveTabId = findLiveRunTabId(state, worktreeId, run.tabId) - const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined + const ptyId = runPanePtyId(state, worktreeId, run.tabId, run.leafId) if (!ptyId) { deleteRunActionRun(worktreeId, actionId) return @@ -124,12 +123,14 @@ async function stopConflictingRuns( if (effectiveRunMode(action, scripts) !== 'nonconcurrent') { return } - // nonconcurrent: the project shares one port/database/stack, so any other live action must stop first. + // nonconcurrent (Conductor's run_mode): the same action can't run in two workspaces of the project + // at once (one fixed port, database or stack). Other actions keep running. const conflicting = listRunActionRuns().filter( (run) => run.repoId === target.repoId && - (run.status === 'running' || run.status === 'stopping') && - !(run.worktreeId === target.worktreeId && run.actionId === action.id) + run.actionId === action.id && + run.worktreeId !== target.worktreeId && + (run.status === 'running' || run.status === 'stopping') ) await Promise.all(conflicting.map((run) => stopRunAction(run.worktreeId, run.actionId))) } diff --git a/src/renderer/src/lib/run-actions/run-action-events.ts b/src/renderer/src/lib/run-actions/run-action-events.ts index a29bdf4e3bb..609db852b4b 100644 --- a/src/renderer/src/lib/run-actions/run-action-events.ts +++ b/src/renderer/src/lib/run-actions/run-action-events.ts @@ -13,10 +13,15 @@ import { findRunActionRunByTab, getRunActionRun, listRunActionRuns, - patchRunActionRun + patchRunActionRun, + type RunActionRun } from './run-actions-store' -import { loadWorktreeRunActions, pickPrimaryRunAction } from './run-action-config' -import { findLiveRunTabId, sameRunTab } from './run-action-tabs' +import { + loadWorktreeRunActions, + pickPrimaryRunAction, + type WorktreeRunActions +} from './run-action-config' +import { findLiveRunTabId, runPanePtyId, runTabLeafIds, sameRunTab } from './run-action-tabs' import { isRunActionRelaunchArmed, notifyRunActionStopped, @@ -59,6 +64,10 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void if (!run || (run.status !== 'running' && run.status !== 'stopping')) { return } + if (!isRunPane(run, detail.leafId)) { + // A command finished in a split of the run's tab, not the action itself. + return + } // OSC 133;D may omit the exit code; without one the run is only known to have ended. const status = run.status === 'stopping' @@ -70,6 +79,31 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void notifyRunActionStopped(tabId) } +function isRunPane(run: RunActionRun, leafId: string | undefined): boolean { + if (!leafId) { + return true + } + if (run.leafId) { + return run.leafId === leafId + } + // The run's pane isn't known yet: only a tab with one pane proves which pane finished. + const leaves = runTabLeafIds(useAppStore.getState(), run.worktreeId, run.tabId) + return leaves.length <= 1 +} + +/** A run learns its pane while its tab still has exactly one, before any split exists. */ +function learnRunPanes(state: AppState): void { + for (const run of listRunActionRuns()) { + if (run.leafId) { + continue + } + const leaves = runTabLeafIds(state, run.worktreeId, run.tabId) + if (leaves.length === 1) { + patchRunActionRun(run.worktreeId, run.actionId, { leafId: leaves[0] }) + } + } +} + function pruneClosedRunTabs(state: AppState): void { for (const run of listRunActionRuns()) { // A restarting host rebuilds its tab list; the relaunch owns these runs until it settles. @@ -91,6 +125,42 @@ function pruneClosedRunTabs(state: AppState): void { } } +const AUTO_RAN_KEY = 'orca.runActions.autoRan.v1' + +function readAutoRan(): string[] { + try { + const parsed: unknown = JSON.parse(globalThis.localStorage?.getItem(AUTO_RAN_KEY) ?? '[]') + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +/** + * The host ran setup while building the environment, so no Setup tab will ever finish here: + * honor `autoRunAfterSetup` the first time the workspace is opened, once per workspace. + */ +export async function autoRunProvisionedWorkspaceOnce( + worktreeId: string, + config: WorktreeRunActions +): Promise { + const action = config.setupProvisioned ? pickAutoRunAction(config.scripts) : null + const autoRan = readAutoRan() + if (!action || autoRan.includes(worktreeId) || getRunActionRun(worktreeId, action.id)) { + return false + } + try { + // Recorded before starting, so a second open (or window) can't start it twice. + globalThis.localStorage?.setItem( + AUTO_RAN_KEY, + JSON.stringify([...autoRan, worktreeId].slice(-500)) + ) + } catch { + // Best effort: without storage this still runs once per window session. + } + return startRunAction(worktreeId, action, config, { activate: false }) +} + /** Follows run terminals: their exits update status, and closing one forgets its run. */ export function installRunActionEvents(): () => void { const onFinished = (event: Event): void => { @@ -104,6 +174,9 @@ export function installRunActionEvents(): () => void { let pruneTimer: ReturnType | null = null const installedAt = Date.now() const unsubscribe = useAppStore.subscribe((state, previous) => { + if (state.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId) { + learnRunPanes(state) + } if (state.tabsByWorktree === previous.tabsByWorktree || pruneTimer) { return } @@ -136,8 +209,7 @@ const STARTUP_PRUNE_GRACE_MS = 15_000 async function reconcileLocalRunsAfterStartup(): Promise { const state = useAppStore.getState() for (const run of listRunActionRuns()) { - const liveTabId = findLiveRunTabId(state, run.worktreeId, run.tabId) - const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined + const ptyId = runPanePtyId(state, run.worktreeId, run.tabId, run.leafId) if (run.status !== 'running' || !ptyId || getRemoteRuntimePtyOwner(ptyId)) { continue } diff --git a/src/renderer/src/lib/run-actions/run-action-tabs.ts b/src/renderer/src/lib/run-actions/run-action-tabs.ts index b1b00ee5471..4310b155267 100644 --- a/src/renderer/src/lib/run-actions/run-action-tabs.ts +++ b/src/renderer/src/lib/run-actions/run-action-tabs.ts @@ -1,4 +1,5 @@ import type { AppState } from '@/store/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/workspace-layout/terminal-tab-types' /** A remote host adopts a tab the window created under `web-terminal-`; both name one tab. */ const MIRRORED_TAB_PREFIX = 'web-terminal-' @@ -21,3 +22,43 @@ export function findLiveRunTabId( (state.tabsByWorktree[worktreeId] ?? []).find((tab) => sameRunTab(tab.id, tabId))?.id ?? null ) } + +function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined, out: string[]): string[] { + if (!node) { + return out + } + if (node.type === 'leaf') { + out.push(node.leafId) + } else { + collectLeafIds(node.first, out) + collectLeafIds(node.second, out) + } + return out +} + +/** Every pane of the run's tab, in layout order. */ +export function runTabLeafIds( + state: Pick, + worktreeId: string, + tabId: string +): string[] { + const liveTabId = findLiveRunTabId(state, worktreeId, tabId) + return liveTabId ? collectLeafIds(state.terminalLayoutsByTabId?.[liveTabId]?.root, []) : [] +} + +/** The PTY of the run's own pane, falling back to the tab's first PTY. */ +export function runPanePtyId( + state: Pick, + worktreeId: string, + tabId: string, + leafId: string | undefined +): string | undefined { + const liveTabId = findLiveRunTabId(state, worktreeId, tabId) + if (!liveTabId) { + return undefined + } + const byLeaf = leafId + ? state.terminalLayoutsByTabId?.[liveTabId]?.ptyIdsByLeafId?.[leafId] + : undefined + return byLeaf ?? state.ptyIdsByTabId[liveTabId]?.[0] +} diff --git a/src/renderer/src/lib/run-actions/run-actions-store.ts b/src/renderer/src/lib/run-actions/run-actions-store.ts index 363fb0310d3..803167d10a1 100644 --- a/src/renderer/src/lib/run-actions/run-actions-store.ts +++ b/src/renderer/src/lib/run-actions/run-actions-store.ts @@ -11,6 +11,8 @@ export type RunActionRun = { actionName: string /** The terminal tab showing this run's output. */ tabId: string + /** The tab's pane running the action (learned once the pane exists); splits are other panes. */ + leafId?: string status: RunActionStatus exitCode: number | null startedAt: number @@ -40,7 +42,8 @@ function readRun(value: unknown): RunActionRun | null { return null } const run: Record = { ...value } - const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt } = run + const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt, leafId } = + run if ( typeof worktreeId !== 'string' || typeof repoId !== 'string' || @@ -60,7 +63,8 @@ function readRun(value: unknown): RunActionRun | null { tabId, status: STATUSES.find((known) => known === status) ?? 'exited', exitCode: typeof exitCode === 'number' ? exitCode : null, - startedAt + startedAt, + ...(typeof leafId === 'string' ? { leafId } : {}) } } @@ -124,7 +128,7 @@ export function setRunActionRun(run: RunActionRun): void { export function patchRunActionRun( worktreeId: string, actionId: string, - patch: Partial> + patch: Partial> ): void { const current = runs[worktreeId]?.[actionId] if (!current) { diff --git a/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.ts b/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.ts index 35ccff96aad..b18e9892bda 100644 --- a/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.ts @@ -2,6 +2,7 @@ import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-activation' +import { toastCloudSleepFailure } from '@/lib/orca-cloud/cloud-sleep-failure-toast' import { parseWorkspaceKey } from '../../../../../shared/workspace-scope' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -184,7 +185,10 @@ export async function suspendEphemeralVmWorkspace(worktreeId: string): Promise void suspendEphemeralVmWorkspace(ids[0]!)) + ) { toast.error(translate('workspace.vm.suspendFailed', 'Failed to suspend VM workspace'), { description: ephemeralVmErrorText(result) }) diff --git a/src/renderer/src/runtime/rpc/compatibility/runtime-protocol-compat.ts b/src/renderer/src/runtime/rpc/compatibility/runtime-protocol-compat.ts index 0b1365fb92a..de9e3fddde5 100644 --- a/src/renderer/src/runtime/rpc/compatibility/runtime-protocol-compat.ts +++ b/src/renderer/src/runtime/rpc/compatibility/runtime-protocol-compat.ts @@ -1,4 +1,7 @@ -import { evaluateRuntimeCompat } from '../../../../../shared/protocol-compat' +import { + evaluateRuntimeCompat, + type RuntimeCompatVerdict +} from '../../../../../shared/protocol-compat' import { describeRuntimeCompatBlock } from '@/lib/runtime-host-copy' import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, @@ -17,14 +20,33 @@ export function isRuntimeCompatBlockError(error: unknown): boolean { return error instanceof Error && (error as { code?: string }).code === RUNTIME_COMPAT_BLOCK_CODE } -export function assertRuntimeStatusCompatible(status: RuntimeStatus): void { - const verdict = evaluateRuntimeCompat({ - clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, +export type RuntimeStatusCompatInput = Pick< + RuntimeStatus, + | 'runtimeProtocolVersion' + | 'minCompatibleRuntimeClientVersion' + | 'protocolVersion' + | 'minCompatibleMobileVersion' +> + +/** The one mapping from a host's status to the protocol verdict; the gate and cloud skew notice share it. */ +export function evaluateRuntimeStatusCompat( + status: RuntimeStatusCompatInput, + client: { protocolVersion: number; minCompatibleServerProtocolVersion: number } = { + protocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + } +): RuntimeCompatVerdict { + return evaluateRuntimeCompat({ + clientProtocolVersion: client.protocolVersion, + minCompatibleServerProtocolVersion: client.minCompatibleServerProtocolVersion, serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, serverMinCompatibleClientProtocolVersion: status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion }) +} + +export function assertRuntimeStatusCompatible(status: RuntimeStatus): void { + const verdict = evaluateRuntimeStatusCompat(status) if (verdict.kind === 'blocked') { // Keep Error message while tagging code so callers distinguish protocol blocks from transient transport failures. const error = new Error(describeRuntimeCompatBlock(verdict)) diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index b369e2dc025..5c9c76f35ae 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -19,7 +19,14 @@ export type CloudWorkspaceErrorCode = export type CloudWorkspaceResult = | { ok: true; value: T } - | { ok: false; code: CloudWorkspaceErrorCode; message: string; reason?: string | null } + | { + ok: false + code: CloudWorkspaceErrorCode + message: string + reason?: string | null + /** Copy written for people (renderer-authored); shown as is. `message` may be raw. */ + userMessage?: string + } /** Why a wake started; every source goes through the control plane's single-flight wake. */ export type CloudWakeSource = 'open' | 'message' | 'ssh' | 'preview' | 'restart' | 'unarchive' diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index 23252573f91..f12e089b8c2 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -77,6 +77,8 @@ export type OrcaBillingOffers = { idleSleepMinutes: number overageCentsPerHour: number defaultSpendCapCents: number + /** Highest monthly spend cap billing accepts; null from older services. */ + maxSpendCapCents: number | null } enterprise: { label: string; priceLabel: string; contactUrl: string } } @@ -97,7 +99,7 @@ export type OrcaBillingEntitlement = { includedSeconds: number usedSeconds: number remainingIncludedSeconds: number - /** Highest included-hours warning level reached (80, 100), or null. */ + /** Highest included-hours warning level reached (75, 90 or 100), or null. */ usageWarningPercent: number | null overageSeconds: number overage: { diff --git a/src/shared/orca-yaml-hook-types.ts b/src/shared/orca-yaml-hook-types.ts index 8d8479be1ec..c628eb39f53 100644 --- a/src/shared/orca-yaml-hook-types.ts +++ b/src/shared/orca-yaml-hook-types.ts @@ -26,8 +26,8 @@ export type OrcaWorktreeDefaults = { /** * `concurrent`: the action may run in several workspaces at once. - * `nonconcurrent`: starting it first stops every other running action of the same project, - * for projects that share one fixed port, database or container stack. + * `nonconcurrent`: starting it first stops the same action running in the project's other + * workspaces, for projects that share one fixed port, database or container stack. */ export type OrcaRunMode = 'concurrent' | 'nonconcurrent' diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 28b524adbe2..c2ecacbccc1 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -779,6 +779,11 @@ import { WorktreeTeardownMissingTerminalsParams } from './worktree-params' import { WorktreeAdoptProvisionedRoot } from './worktree-provisioned-root-params' +import { + SecretsRequestIdParamsSchema, + SecretsRequestParamsSchema, + SecretsResolveRequestParamsSchema +} from '../secret-request-contract' import { RepoIconDataUrlsParams } from '../sidebar-list/repo-icon-data-urls-contract' import { SidebarInputsReadParams } from '../sidebar-list/sidebar-inputs-feed-contract' import { SkillBundleInstallRequestSchema } from '../skill-bundle-install-contract' @@ -1523,6 +1528,10 @@ export const RPC_PARAMS_BY_METHOD = { 'runtime.clientEvents.subscribe': ClientEventsSubscribeParams, 'runtime.clientEvents.unsubscribe': ClientEventsUnsubscribeParams, 'runtimeEnvironment.wakeRecipeVm': RecipeVmWakeParams, + 'secrets.request': SecretsRequestParamsSchema, + 'secrets.requestStatus': SecretsRequestIdParamsSchema, + 'secrets.resolveRequest': SecretsResolveRequestParamsSchema, + 'secrets.touchRequest': SecretsRequestIdParamsSchema, 'session.readTerminalScrollback': SessionReadTerminalScrollbackParams, 'session.tabs.activate': ActivateTab, 'session.tabs.close': CloseTab, diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 65cda9b9c1c..12b7e672bdd 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -12,6 +12,11 @@ import type { RuntimeNavigationTarget } from './runtime-navigation' import type { WorktreeSortOrderUpdate } from './worktree/sort-order-update' import type { SidebarInputsChangedEvent } from './sidebar-list/sidebar-inputs-feed-contract' import type { BrowserClientHostRequestEvent } from './browser/client-hosting/browser-client-host-request-protocol' +import type { + SecretRequestedEvent, + SecretRequestSettledEvent, + SecretRequestUpdatedEvent +} from './secret-request-contract' export type RuntimeClientEvent = | { @@ -94,6 +99,10 @@ export type RuntimeClientEventStreamMessage = | RuntimeClientEvent // Addressed to this subscriber only, never broadcast. | BrowserClientHostRequestEvent + // Agent secret requests; replayed on attach while pending. Phones get no reason text. + | SecretRequestedEvent + | SecretRequestUpdatedEvent + | SecretRequestSettledEvent | { type: 'end' } export type RuntimeActivateWorktreeEvent = Extract diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 934e6ea7a53..2616f6188a7 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -83,6 +83,8 @@ export type RuntimeStatus = { /** Absent on older hosts; missing degradation reports never prove all optional features work. */ degradations?: RuntimeDegradation[] appVersion?: string + /** Full source commit of the answering build; orcad only (Orca Cloud golden builds stamp it). */ + buildCommit?: string remoteUpdateSupport?: RemoteServerUpdateSupport remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null hostPlatform?: NodeJS.Platform @@ -119,6 +121,9 @@ export type CliStatusResult = { connectionState?: RuntimeHostConnectionState runtimeId: string | null appVersion?: string + buildCommit?: string + runtimeProtocolVersion?: number + minCompatibleRuntimeClientVersion?: number remoteUpdateSupport?: RemoteServerUpdateSupport capabilities?: RuntimeCapability[] degradations?: RuntimeDegradation[] diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index cdd0f2d6d44..e104014f71d 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -25,6 +25,7 @@ import type { RuntimeListingHostScope } from './runtime-listing-host-scope' import type { GitWorktreeInfo, Worktree } from './worktree/types' import type { WorkspaceStatusWarning } from './workspace-status-warning' import type { WorktreeStatus } from './worktree-status-rollup' +import type { EphemeralVmRuntimeStatus } from './ephemeral-vm-runtimes' export type RuntimeWorktreeSetResult = { worktree: RuntimeWorktreeRecord @@ -137,6 +138,8 @@ export type RuntimeWorktreePsSummary = { agents: RuntimeWorktreeAgentRow[] /** See `Worktree.removing`; sent only to clients that advertise background removal. */ removing?: true + /** An Orca Cloud workspace, with the VM state this desktop last recorded. Old hosts omit it. */ + orcaCloud?: { status: EphemeralVmRuntimeStatus } /** See `GitWorktreeInfo.removalError`. */ removalError?: string /** Host-derived workspace kinds. Missing defaultBranch retains reader guesses; other missing flags show the row. diff --git a/src/shared/secret-request-contract.ts b/src/shared/secret-request-contract.ts new file mode 100644 index 00000000000..8f026882fd2 --- /dev/null +++ b/src/shared/secret-request-contract.ts @@ -0,0 +1,128 @@ +import { z } from 'zod' + +/** Agent-initiated secret requests (agent-sources contract §3). The value never travels through orcad + * or the agent: the desktop saves it to the control plane, which refreshes the session env. */ +export const SECRETS_REQUEST_METHOD = 'secrets.request' +export const SECRETS_REQUEST_STATUS_METHOD = 'secrets.requestStatus' +export const SECRETS_TOUCH_REQUEST_METHOD = 'secrets.touchRequest' +export const SECRETS_RESOLVE_REQUEST_METHOD = 'secrets.resolveRequest' + +/** Client event types (runtime.clientEvents.subscribe). */ +export const SECRETS_REQUESTED_EVENT = 'secrets.requested' +export const SECRETS_REQUEST_UPDATED_EVENT = 'secrets.requestUpdated' +export const SECRETS_REQUEST_SETTLED_EVENT = 'secrets.requestSettled' + +export const SECRET_REQUEST_REASON_MAX = 200 +export const SECRET_REQUEST_TTL_MS = 15 * 60_000 +/** touchRequest extends to now + TTL, never past this long after the request. */ +export const SECRET_REQUEST_MAX_LIFETIME_MS = 60 * 60_000 +export const SECRET_REQUEST_MAX_PENDING = 3 +export const SECRET_REQUEST_MAX_PER_HOUR = 10 + +export const SecretRequestScopeSchema = z.enum(['personal', 'repo']) +export type SecretRequestScope = z.infer + +const RequestId = z.string().regex(/^sr_[A-Za-z0-9_-]{8,64}$/) + +// C0/C1 controls (newlines, escapes) become spaces; bidi overrides and isolates are dropped, so the +// agent's text can't reorder or restyle what the person reads. +// oxlint-disable-next-line no-control-regex -- SAFETY: matching control characters is the point. +const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/g +const BIDI_CHARS = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g + +export function sanitizeSecretRequestReason(reason: string): string { + return reason.replace(BIDI_CHARS, '').replace(CONTROL_CHARS, ' ').replace(/ {2,}/g, ' ').trim() +} + +export const SecretsRequestParamsSchema = z + .object({ + // Shape only; the name rules (reserved names, agent credentials) are checked by the handler so the + // agent gets a specific error code back. + name: z.string().min(1).max(128), + reason: z + .string() + .max(SECRET_REQUEST_REASON_MAX * 4) + .transform(sanitizeSecretRequestReason) + .pipe(z.string().min(1).max(SECRET_REQUEST_REASON_MAX)), + /** A hint only: the card always preselects Personal (D2). */ + scope: SecretRequestScopeSchema.optional(), + /** A hint, kept only when this orcad knows the worktree. The agent and its terminal come from the + * caller identity orcad verifies (launch evidence), never from params. */ + worktreeId: z.string().min(1).max(512).optional() + }) + .strict() +export type SecretsRequestParams = z.infer + +export const SecretsRequestIdParamsSchema = z.object({ requestId: RequestId }).strict() + +export const SecretsResolveRequestParamsSchema = z + .object({ requestId: RequestId, outcome: z.enum(['provided', 'declined']) }) + .strict() + +export type SecretRequestStatus = 'pending' | 'provided' | 'declined' | 'expired' + +export type SecretsRequestResult = + | { requestId: string; status: 'pending' } + /** The name is already in the session env; nothing was queued. */ + | { requestId: null; status: 'already-set' } + +export type SecretsRequestStatusResult = { + requestId: string + name: string + status: SecretRequestStatus + expiresAt: number +} + +export type SecretsTouchRequestResult = { expiresAt: number } + +export type SecretsResolveRequestResult = { outcome: 'provided' | 'declined' | 'expired' } + +/** Sent to connected clients when a request is made, and replayed on attach until it expires. */ +export type SecretRequestedEvent = { + type: typeof SECRETS_REQUESTED_EVENT + requestId: string + name: string + /** Untrusted agent text: show quoted and muted, never linkified. Omitted for phones (status only). */ + reason?: string + scope: SecretRequestScope + /** From the calling terminal's agent session, never from the caller; null when unknown. */ + agent: string | null + worktreeId: string | null + terminalHandle?: string + requestedAt: number + expiresAt: number +} + +/** touchRequest moved the expiry, so every client keeps the card as long. */ +export type SecretRequestUpdatedEvent = { + type: typeof SECRETS_REQUEST_UPDATED_EVENT + requestId: string + expiresAt: number +} + +/** A request was provided, declined or expired, so other clients can drop their card. */ +export type SecretRequestSettledEvent = { + type: typeof SECRETS_REQUEST_SETTLED_EVENT + requestId: string + status: Exclude +} + +/** Error codes (RPC error message) the CLI and desktop map to guidance. */ +export const SECRET_REQUEST_ERRORS = { + invalidName: 'invalid_name', + reservedName: 'reserved_name', + subscriptionTokenRefused: 'subscription_token_refused', + agentCredential: 'use_sign_in_or_cloud_secrets', + tooManyPending: 'too_many_pending_requests', + rateLimited: 'secret_request_rate_limited', + notFound: 'secret_request_not_found', + notPending: 'secret_request_not_pending', + forbidden: 'secret_request_forbidden', + /** Only a cloud workspace's orcad takes secret requests. */ + unavailable: 'secret_requests_unavailable' +} as const + +/** D4: printed by the CLI once a request is provided. */ +export function secretReloadHint(name: string): string { + return `${name} is set for new shells in this workspace. To use it in this shell: eval "$(orca-env)" (fish: orca-env --fish | source).` +} diff --git a/src/shared/secret-request-names.test.ts b/src/shared/secret-request-names.test.ts new file mode 100644 index 00000000000..01198ddc6cf --- /dev/null +++ b/src/shared/secret-request-names.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + secretRequestNameRefusal, + WORKER_RESERVED_SECRET_NAMES, + WORKER_RESERVED_SECRET_PREFIXES +} from './secret-request-names' + +const WORKER_SECRETS = fileURLToPath( + new URL('../../cloud/apps/sandbox-control/src/secrets.ts', import.meta.url) +) + +function quotedStrings(text: string): string[] { + return [...text.matchAll(/'([A-Z0-9_]+)'/g)].map((match) => match[1]) +} + +describe('secret request names match the control plane', () => { + const source = readFileSync(WORKER_SECRETS, 'utf8') + + it('uses the Worker reserved names and prefixes', () => { + const names = source.match(/const RESERVED_NAMES[^=]*= new Set\(\[([\s\S]*?)\]\)/)?.[1] + const prefixes = source.match(/const RESERVED_PREFIXES = \[([^\]]*)\]/)?.[1] + expect(names, 'RESERVED_NAMES not found in the Worker').toBeTruthy() + expect(prefixes, 'RESERVED_PREFIXES not found in the Worker').toBeTruthy() + expect(new Set(quotedStrings(names ?? ''))).toEqual(WORKER_RESERVED_SECRET_NAMES) + expect(quotedStrings(prefixes ?? '')).toEqual([...WORKER_RESERVED_SECRET_PREFIXES]) + }) + + it('uses the Worker name pattern and length', () => { + expect(source).toContain('const NAME = /^[A-Z_][A-Z0-9_]*$/') + expect(source).toContain('const MAX_NAME_LENGTH = 128') + expect(secretRequestNameRefusal('A'.repeat(128))).toBeNull() + expect(secretRequestNameRefusal('_PRIVATE_1')).toBeNull() + }) +}) diff --git a/src/shared/secret-request-names.ts b/src/shared/secret-request-names.ts new file mode 100644 index 00000000000..bde801b87e6 --- /dev/null +++ b/src/shared/secret-request-names.ts @@ -0,0 +1,86 @@ +import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names' +import { SECRET_REQUEST_ERRORS } from './secret-request-contract' + +// The control plane's secret-name rules (cloud/apps/sandbox-control/src/secrets.ts checkName), so a +// request the Worker would refuse is refused before anyone is asked. secret-request-names.test.ts +// diffs these lists against the Worker source. +const NAME = /^[A-Z_][A-Z0-9_]*$/ +const MAX_NAME_LENGTH = 128 + +export const WORKER_RESERVED_SECRET_NAMES: ReadonlySet = new Set([ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'SHELL', + 'PWD', + 'OLDPWD', + 'LANG', + 'TERM', + 'TMPDIR', + 'IFS', + 'ENV', + 'BASH_ENV', + 'PS4', + 'PROMPT_COMMAND', + 'SHELLOPTS', + 'BASHOPTS', + 'NODE_OPTIONS', + 'NODE_PATH', + 'NODE_EXTRA_CA_CERTS', + 'NODE_TLS_REJECT_UNAUTHORIZED', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'CURL_CA_BUNDLE', + 'REQUESTS_CA_BUNDLE', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'PYTHONPATH', + 'PYTHONSTARTUP', + 'PERL5OPT', + 'RUBYOPT', + 'JAVA_TOOL_OPTIONS', + 'GIT_SSH', + 'GIT_SSH_COMMAND', + 'GIT_ASKPASS', + 'GIT_EXEC_PATH', + 'GIT_PROXY_COMMAND', + 'SSH_AUTH_SOCK', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN' +]) + +export const WORKER_RESERVED_SECRET_PREFIXES: readonly string[] = [ + 'LD_', + 'DYLD_', + 'ORCA_', + 'VERCEL_', + 'BASH_FUNC_', + 'GIT_CONFIG' +] + +const AGENT_CREDENTIALS: ReadonlySet = new Set(Object.values(AGENT_CREDENTIAL_NAMES).flat()) + +/** Null when an agent may request this name; otherwise the error code to return. */ +export function secretRequestNameRefusal(name: string): string | null { + if (name.length > MAX_NAME_LENGTH || !NAME.test(name)) { + return SECRET_REQUEST_ERRORS.invalidName + } + if (name === 'CLAUDE_CODE_OAUTH_TOKEN') { + return SECRET_REQUEST_ERRORS.subscriptionTokenRefused + } + if ( + WORKER_RESERVED_SECRET_NAMES.has(name) || + WORKER_RESERVED_SECRET_PREFIXES.some((prefix) => name.startsWith(prefix)) + ) { + return SECRET_REQUEST_ERRORS.reservedName + } + // Agent keys come from the person's sign-in or Cloud secrets, never from an agent's request. + if (AGENT_CREDENTIALS.has(name)) { + return SECRET_REQUEST_ERRORS.agentCredential + } + return null +} diff --git a/src/shared/workspace-port-block.ts b/src/shared/workspace-port-block.ts index 761370ec365..b427a7e296d 100644 --- a/src/shared/workspace-port-block.ts +++ b/src/shared/workspace-port-block.ts @@ -24,3 +24,18 @@ export function buildWorkspacePortEnv(block: WorkspacePortBlock): Record= block.base && port < block.base + block.count } + +/** + * The block a host launcher set for everything it starts (ORCA_PORT in the host process env), or + * null. A copy inherited from a parent Orca pane carries pane identity and names that pane's + * workspace instead, so it doesn't count. + */ +export function hostLauncherPortBase(env: NodeJS.ProcessEnv): number | null { + if (env.ORCA_PANE_KEY || env.ORCA_WORKTREE_ID) { + return null + } + const base = Number(env.ORCA_PORT) + return Number.isInteger(base) && base >= 1024 && base <= 65535 - WORKSPACE_PORT_BLOCK_SIZE + ? base + : null +}