mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge remote-tracking branch 'private/cloud/integration' into cloud/read-snapshot-signed
# Conflicts: # cloud/apps/sandbox-control/image/vm-scripts.generated.ts
This commit is contained in:
@@ -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 <tmp>/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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"findings": [
|
||||
"<b>Wake delivery (target p95 < 6 s) — MET in pdx1:</b> 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).",
|
||||
"<b>run.ports (cloud) passes on a #233 golden (iad1 d323e4a):</b> 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).",
|
||||
"<b>Read while asleep (gap 5, #189/#218), verified by W9 live:</b> 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.",
|
||||
"<b>Read-asleep access, triggered by W9 itself:</b> 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.",
|
||||
"<b>Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html):</b> 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).",
|
||||
"<b>Agents and machine size (2026-11-10):</b> 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.",
|
||||
"<b>Audit caps lifted by real-UI runs (m4air, 855d8ed34b6, desktop-20261109-234651):</b> 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).",
|
||||
|
||||
@@ -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 <file> 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 <needle> rendered text strings containing <needle> (read-only)
|
||||
// node cdp.mjs eval '<expr>'
|
||||
// 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 <file> | identity-clear | texts <needle> | eval <expr>')
|
||||
}
|
||||
} finally {
|
||||
s.close()
|
||||
clearTimeout(timer)
|
||||
}
|
||||
process.exit(0)
|
||||
@@ -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<string, any> = 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<string, unknown> = {}
|
||||
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')
|
||||
}
|
||||
@@ -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: <name>" 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' },
|
||||
|
||||
@@ -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<void> {
|
||||
await stripe('POST', `/v1/test_helpers/test_clocks/${clock}/advance`, { frozen_time: Math.floor(to) })
|
||||
|
||||
@@ -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<string, Check> = {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
'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')
|
||||
|
||||
@@ -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<typeof x25519P
|
||||
return JSON.parse(Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString('utf8'))
|
||||
}
|
||||
|
||||
/** Opens a read snapshot (src/shared/cloud-workspaces/read-snapshot.ts construction) with node:crypto. */
|
||||
function openReadSnapshot(s: any, pair: ReturnType<typeof x25519Pair>): 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<string, Check> = {
|
||||
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<typeof x25519Pair>) => {
|
||||
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 } })
|
||||
|
||||
@@ -32,7 +32,18 @@ function contains(commit: string, ancestor: string): boolean {
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const a = active()
|
||||
let a: ReturnType<typeof active> = 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) {
|
||||
|
||||
@@ -27,7 +27,11 @@ const MAP: Record<string, string> = {
|
||||
'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'] = []
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<StripeObject & { event_name: string }>(
|
||||
'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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) {
|
||||
async function request<T>(method: 'GET' | 'POST' | 'DELETE', path: string, params?: FormRecord, idempotencyKey?: string) {
|
||||
assertKeyMode(env)
|
||||
const headers: Record<string, string> = {
|
||||
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 }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -11,7 +11,15 @@ export type FakeSubscription = {
|
||||
cancel_at_period_end: boolean
|
||||
livemode: boolean
|
||||
metadata: Record<string, string>
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)'
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
+87
@@ -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-<uid>/session.env (written by orca-sandbox-env, `NAME=<base64>` 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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=<placeholder>`
|
||||
// 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<SandboxRow, 'id'>): Record<string, string>
|
||||
/** Header the client sends the placeholder in. */
|
||||
header: string
|
||||
placeholder(row: Pick<SandboxRow, 'id'>): string
|
||||
format(credential: string): string
|
||||
/** Extra headers derived from the real credential (e.g. Codex's ChatGPT account id). */
|
||||
extraHeaders?(credential: string): Record<string, string>
|
||||
/**
|
||||
* 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<unknown>) => void }): Promise<void>
|
||||
/** The real value for this person, or null (none, expired, revoked). */
|
||||
credential(db: D1Database, keyring: Keyring, userId: string, now: number): Promise<string | null>
|
||||
}
|
||||
|
||||
/** D13: a workspace shared with teammates never uses a personal account. */
|
||||
export function isSharedWorkspace(row: Pick<SandboxRow, 'visibility'>): 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)
|
||||
}
|
||||
@@ -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<Env, 'AGENT_ACCOUNTS_CLAUDE' | 'AGENT_ACCOUNTS_CLAUDE_DISABLED' | 'AGENT_ACCOUNTS_CODEX' | 'AGENT_ACCOUNTS_CODEX_DISABLED'>): Record<CredentialAgent, boolean> {
|
||||
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<CredentialRow, 'state' | 'expires_at'> | 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<CredentialRow | null> {
|
||||
return db.prepare('SELECT * FROM agent_credentials WHERE user_id = ? AND agent = ?').bind(userId, agent).first<CredentialRow>()
|
||||
}
|
||||
|
||||
export async function agentCredentialState(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise<CredentialState> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<CredentialRow>()
|
||||
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<typeof PutClaudeSchema>, 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 }))
|
||||
}
|
||||
@@ -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<CredentialAgent, boolean> = { 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<CredentialAgent, boolean>
|
||||
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<void> {
|
||||
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<SandboxRow, 'id' | 'user_id' | 'visibility'>,
|
||||
env: Readonly<Record<string, string>>,
|
||||
options: {
|
||||
providerEnabled: Record<CredentialAgent, boolean>
|
||||
accountsUsable: boolean
|
||||
now: number
|
||||
readable?: (agent: CredentialAgent) => Promise<boolean>
|
||||
}
|
||||
): Promise<Record<CredentialAgent, AgentMode>> {
|
||||
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<CredentialAgent, AgentMode>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ const schemas: Record<string, object> = {
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number | null>
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
const body = (await request.json().catch(() => null)) as Partial<RunnerBuild> | 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<void> {
|
||||
const build = await this.state.storage.get<RunnerBuild>('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<BuildRunnerEnv>
|
||||
@@ -35,7 +35,12 @@ export const NO_CREDENTIALS: CredentialBroker = { authorizationFor: async () =>
|
||||
export type ProxiedRoute = {
|
||||
header: string
|
||||
placeholders: readonly string[]
|
||||
credential: () => Promise<string | null>
|
||||
/** The header value, or null (refused), or `{ deny }` to refuse with a specific reason. */
|
||||
credential: () => Promise<string | null | { deny: string }>
|
||||
/** Extra headers set with the credential (e.g. an account id the client can't know). */
|
||||
extraHeaders?: () => Record<string, string>
|
||||
/** 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)
|
||||
}
|
||||
|
||||
@@ -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/<slug>). */
|
||||
GITHUB_APP_SLUG?: string
|
||||
|
||||
@@ -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<unknown>) => (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<string, { at: number; value: Promise<string | null> }>()
|
||||
function cachedAccountCredential(db: D1Database, keyring: Keyring, userId: string, agent: 'claude' | 'codex', now: number): Promise<string | null> {
|
||||
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<string, number>()
|
||||
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<void> {
|
||||
const { results } = await env.SANDBOX_DB.prepare(`SELECT * FROM sandboxes WHERE user_id = ? AND status = 'running' LIMIT 200`).bind(userId).all<SandboxRow>()
|
||||
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<Response> {
|
||||
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<LifecycleDeps['secrets']> {
|
||||
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<ReturnType<typeof patchWorkspace>> =
|
||||
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<Response> {
|
||||
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<Env> {
|
||||
tickBuild(build: RunnerBuild): Promise<number | null> {
|
||||
return runBuildTick(this.env, build)
|
||||
}
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
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<number | null> {
|
||||
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) {
|
||||
|
||||
@@ -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<string>
|
||||
egressPlaceholdersOff?: ReadonlySet<string>
|
||||
/** 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<SandboxRow> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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-<secret>-<sandbox>`). 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 <value>`). */
|
||||
altHeaderFormats?: Record<string, readonly ((value: string) => 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.
|
||||
|
||||
@@ -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<ProviderSandboxState>
|
||||
/** `resume: true` boots a stopped persistent sandbox from its latest snapshot. Null when absent. */
|
||||
getSandbox(name: string, options: { resume: boolean }): Promise<ProviderSandboxState | null>
|
||||
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<ProviderSessionUsage | null>
|
||||
extendTimeout(sessionId: string, durationMs: number): Promise<ProviderSession>
|
||||
updateNetworkPolicy(sessionId: string, policy: NetworkPolicy): Promise<void>
|
||||
/** Sets the full exposed-port list on the sandbox (live on a running session); returns its routes. */
|
||||
|
||||
@@ -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<string, string> = { 'api.anthropic.com': 'ANTHROPIC_API_KEY', 'api.openai.com': 'OPENAI_API_KEY' }
|
||||
|
||||
export function secretPlaceholder(row: Pick<SandboxRow, 'id'>, 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:<agent>:<owner>@<host>` 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<SandboxRow, 'egress_secret_hosts'>): { 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
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<SandboxProviderApi, 'getSessionUsage'>,
|
||||
now: number
|
||||
): Promise<SessionCpuSweep> {
|
||||
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<FairUseReport> {
|
||||
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
|
||||
}
|
||||
@@ -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<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
|
||||
): Promise<ProviderEnv> {
|
||||
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<LifecycleDeps, 'secrets' | 'config'>, row: Pick<SandboxRow, 'account_id'>): 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<LifecycleDeps, 'db' | 'secrets' | 'config'>,
|
||||
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<string, string> = {}
|
||||
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). */
|
||||
|
||||
@@ -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<typeof Session>): 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<typeof SandboxAndSession>): 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) {
|
||||
|
||||
@@ -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<SandboxRow> {
|
||||
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<SandboxRow[]> {
|
||||
const { results } = await db
|
||||
|
||||
@@ -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<SandboxRow> {
|
||||
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 } : {})
|
||||
})
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, string> = {}) {
|
||||
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<string, string>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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}`]))
|
||||
|
||||
@@ -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<string, unknown>()
|
||||
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<typeof runnerFor> | 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' })
|
||||
})
|
||||
})
|
||||
@@ -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<Env = unknown> {
|
||||
constructor(
|
||||
protected readonly ctx: unknown,
|
||||
protected readonly env: Env
|
||||
) {}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ export type FakeVercel = SandboxProviderApi & GoldenImageProviderApi & {
|
||||
snapshotExpirations: Map<string, number>
|
||||
/** 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<string, number>
|
||||
/** 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<string, FakeBuildCommand>()
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string>, 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<string, string> = {}) {
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<ReturnType<typeof createHarness>>, 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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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'] }
|
||||
})
|
||||
|
||||
@@ -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"] }]
|
||||
}
|
||||
@@ -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"] }]
|
||||
}
|
||||
@@ -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-",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:<sha>`) 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-<env>`, via
|
||||
`wrangler d1 execute <db> --remote --command "<sql>"`:
|
||||
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=<now ms> WHERE id='<bad 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 "<note>"`.
|
||||
2. Reactivate the retired image: `UPDATE golden_images SET status='active', activated_at=<now ms>, updated_at=<now ms>
|
||||
WHERE id='<retired id>' AND region='<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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
// Remount on host change to discard the old session’s grants; `open` updates the document in place.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
onRouteParamClear={onRouteParamClear}
|
||||
onPageLocation={onPageLocation}
|
||||
/>
|
||||
// An asleep cloud workspace's VM is not an offline computer: say so and offer Wake first.
|
||||
<CloudHostAsleepGate hostId={hostId}>
|
||||
<MobileWebShellScreen
|
||||
// Remount on host change to discard the old session’s grants; `open` updates the document in place.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
onRouteParamClear={onRouteParamClear}
|
||||
onPageLocation={onPageLocation}
|
||||
/>
|
||||
</CloudHostAsleepGate>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey({ pathname: route.pathname, params: identity })}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
onRouteParamClear={erasePaneKey}
|
||||
/>
|
||||
<CloudHostAsleepGate hostId={hostId}>
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey({ pathname: route.pathname, params: identity })}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
onRouteParamClear={erasePaneKey}
|
||||
/>
|
||||
</CloudHostAsleepGate>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color={colors.textMuted} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const sleeping = listed.workspace
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bgBase }} edges={['top']}>
|
||||
<SettingsNavBar title={sleeping?.title ?? ''} onBack={() => router.back()} />
|
||||
<MobileStateView
|
||||
icon={Moon}
|
||||
title={t('cloud.host.asleep', '{{name}} is asleep', { name: sleeping?.title ?? '' })}
|
||||
message={t('cloud.host.asleepDetail', 'Files are kept. Wake it to keep working.')}
|
||||
action={{
|
||||
label: t('cloud.host.wake', 'Wake'),
|
||||
onPress: () => router.replace(`/cloud/${encodeURIComponent(sandboxId ?? '')}?autostart=1`)
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -67,11 +67,9 @@ export function CloudStatusCard(props: {
|
||||
<View style={styles.statusHeadline}>
|
||||
<CloudStatusDot status={active ? 'waking' : workspace.status} warn={notConnected} />
|
||||
<Text style={styles.statusLabel} accessibilityRole="header">
|
||||
{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)}
|
||||
</Text>
|
||||
</View>
|
||||
{active ? (
|
||||
|
||||
@@ -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<CloudWorkspaceRun | null>(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 (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<SettingsNavBar
|
||||
@@ -169,15 +190,38 @@ export function CloudWorkspaceScreen({
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={styles.centered}>
|
||||
{isPending || isFetching || enrollment === undefined ? (
|
||||
{missing === 'signin' ? (
|
||||
// A share link opened on a phone that isn't connected yet: the list query never runs.
|
||||
<>
|
||||
<Text style={styles.emptyText}>
|
||||
{t('cloud.workspace.signIn', 'Sign in to Orca Cloud on this phone')}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => {
|
||||
// Connect on Home, then come straight back to this workspace.
|
||||
setCloudReturnTo(`/cloud/${sandboxId}`)
|
||||
router.replace('/')
|
||||
}}
|
||||
style={styles.linkButton}
|
||||
>
|
||||
<Text style={styles.link}>{t('cloud.workspace.signInAction', 'Sign in')}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : missing === 'loading' ? (
|
||||
<ActivityIndicator color={colors.textMuted} />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.emptyText}>
|
||||
{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.'
|
||||
)}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
@@ -193,10 +237,12 @@ export function CloudWorkspaceScreen({
|
||||
)
|
||||
}
|
||||
|
||||
const awake = workspace.status === 'running'
|
||||
// Up either way: a prompt sent now goes out without a wake.
|
||||
const awake = workspace.status === 'running' || workspace.status === 'unresponsive'
|
||||
const unavailable =
|
||||
workspace.status === 'creating' ||
|
||||
workspace.status === 'stopping' ||
|
||||
workspace.status === 'deleting' ||
|
||||
workspace.status === 'archived'
|
||||
const waking =
|
||||
wakePending ||
|
||||
@@ -205,7 +251,9 @@ export function CloudWorkspaceScreen({
|
||||
workspace.status === 'waking'
|
||||
// Asleep, or waking from it: the read-only copy stays readable (header says Waking) until it opens.
|
||||
const showSnapshot = asleep || (waking && !awake && snapshot.data !== undefined)
|
||||
const busy = active || wakePending
|
||||
// Up but not answering: the one thing that helps is a restart (glossary "Restarting").
|
||||
const restartable = workspace.status === 'unresponsive' && run.kind !== 'launched' && !active
|
||||
const busy = active || wakePending || busyAction === 'restart'
|
||||
const primaryLabel = wakePending
|
||||
? phaseLabel('waking', t)
|
||||
: run.kind === 'launched'
|
||||
@@ -218,11 +266,17 @@ export function CloudWorkspaceScreen({
|
||||
? t('cloud.action.settingUp', 'Starting…')
|
||||
: workspace.status === 'archived'
|
||||
? t('cloud.status.archived', 'Archived')
|
||||
: awake
|
||||
? t('cloud.action.open', 'Open workspace')
|
||||
: asleep
|
||||
? t('cloud.action.wakeToContinue', 'Wake to continue')
|
||||
: t('cloud.action.wakeOpen', 'Wake and open workspace')
|
||||
: workspace.status === 'deleting'
|
||||
? t('cloud.status.deleting', 'Deleting')
|
||||
: busyAction === 'restart'
|
||||
? t('cloud.status.restarting', 'Restarting')
|
||||
: restartable
|
||||
? t('cloud.action.restart', 'Restart sandbox')
|
||||
: awake
|
||||
? t('cloud.action.open', 'Open workspace')
|
||||
: asleep
|
||||
? t('cloud.action.wakeToContinue', 'Wake to continue')
|
||||
: t('cloud.action.wakeOpen', 'Wake and open workspace')
|
||||
const primaryDisabled = busy || unavailable
|
||||
|
||||
return (
|
||||
@@ -231,7 +285,12 @@ export function CloudWorkspaceScreen({
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<SettingsNavBar title={workspace.title} onBack={() => router.back()} />
|
||||
<SettingsNavBar
|
||||
title={workspace.title}
|
||||
// The cloud-icon rule: a bare, muted Cloud that never changes with state (the card says it).
|
||||
titleIcon={<Cloud size={14} strokeWidth={2} color={colors.textMuted} />}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scroll,
|
||||
@@ -310,7 +369,7 @@ export function CloudWorkspaceScreen({
|
||||
|
||||
<CloudWorkspaceActions
|
||||
workspace={workspace}
|
||||
busy={busyAction}
|
||||
busy={busyAction === 'restart' ? null : busyAction}
|
||||
disabled={active || busyAction !== null}
|
||||
onSleep={() => 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(() =>
|
||||
|
||||
@@ -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<string[]>([])
|
||||
// "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() {
|
||||
>
|
||||
<SettingsSection
|
||||
first
|
||||
radioGroup={choices.length > 0}
|
||||
title={t('cloud.new.repoHeading', 'Repository')}
|
||||
footer={
|
||||
repoInvalid ? (
|
||||
<SettingsNote error>
|
||||
{t('cloud.new.repoInvalid', 'Use a GitHub repository, like owner/repo.')}
|
||||
</SettingsNote>
|
||||
) : 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() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<View key="repo" style={styles.field}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={repo}
|
||||
onChangeText={setRepo}
|
||||
editable={!creating}
|
||||
placeholder={t('cloud.new.repoPlaceholder', 'owner/repo')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
accessibilityLabel={t('cloud.new.repoLabel', 'GitHub repository')}
|
||||
/>
|
||||
</View>
|
||||
{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 ? (
|
||||
<View key="repo" style={styles.field}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={repo}
|
||||
onChangeText={(text) => {
|
||||
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')}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t('cloud.new.branchLabel', 'Branch')}>
|
||||
<View key="branch" style={styles.field}>
|
||||
@@ -216,10 +269,22 @@ export function NewCloudWorkspaceScreen() {
|
||||
|
||||
<SettingsSection
|
||||
title={t('cloud.new.agentHeading', 'First task')}
|
||||
footer={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)
|
||||
})}
|
||||
footer={
|
||||
<>
|
||||
<SettingsNote>
|
||||
{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)
|
||||
})}
|
||||
</SettingsNote>
|
||||
<SettingsNote>
|
||||
{t('cloud.idle.sleeps', 'Sleeps after {{count}} minutes idle.', {
|
||||
count: ORCA_CLOUD_IDLE_SLEEP_MINUTES,
|
||||
defaultValue_one: 'Sleeps after {{count}} minute idle.'
|
||||
})}
|
||||
</SettingsNote>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View key="task" style={styles.field}>
|
||||
<CloudAgentPicker value={agent} onChange={setAgent} disabled={creating} />
|
||||
|
||||
@@ -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<string, CloudWorkspaceStatus> = {
|
||||
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<void> {
|
||||
await request(
|
||||
plane,
|
||||
'POST',
|
||||
`/v1/sandboxes/${encodeURIComponent(sandboxId)}/restart`,
|
||||
z.unknown(),
|
||||
{ body: {}, timeoutMs: 120_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function archiveCloudWorkspace(
|
||||
plane: CloudControlPlane,
|
||||
sandboxId: string
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const store = vi.hoisted(() => new Map<string, string>())
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<string[]> {
|
||||
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<void> {
|
||||
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<string>()
|
||||
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)
|
||||
}
|
||||
@@ -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) => (
|
||||
<ListRow
|
||||
key={`repo:${choice}`}
|
||||
title={choice}
|
||||
selected={!args.typing && args.repo === choice}
|
||||
accessibilityRole="radio"
|
||||
disabled={args.disabled}
|
||||
onPress={() => args.onPick(choice)}
|
||||
/>
|
||||
)),
|
||||
<ListRow
|
||||
key="repo:none"
|
||||
title={args.t('cloud.new.repoNone', 'No repository (blank project)')}
|
||||
selected={!args.typing && args.repo === ''}
|
||||
accessibilityRole="radio"
|
||||
disabled={args.disabled}
|
||||
onPress={() => args.onPick('')}
|
||||
/>,
|
||||
<ListRow
|
||||
key="repo:other"
|
||||
title={args.t('cloud.new.repoOther', 'Other repository…')}
|
||||
selected={args.typing}
|
||||
accessibilityRole="radio"
|
||||
disabled={args.disabled}
|
||||
onPress={args.onOther}
|
||||
/>
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Where to go once this phone is connected to Orca Cloud: a team link opened before connecting
|
||||
// (e.g. /cloud/<id>) 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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Intl.NumberFormat>()
|
||||
|
||||
@@ -123,6 +123,12 @@ export async function cloudWorkspaceHostId(sandboxId: string): Promise<string |
|
||||
return host.id
|
||||
}
|
||||
|
||||
/** Which cloud workspace a paired host is, if it is one. */
|
||||
export async function cloudWorkspaceIdForHost(hostId: string): Promise<string | null> {
|
||||
const entry = Object.entries(await readLinks()).find(([, link]) => link.hostId === hostId)
|
||||
return entry?.[0] ?? null
|
||||
}
|
||||
|
||||
export async function cloudWorkspaceHostIds(): Promise<ReadonlySet<string>> {
|
||||
const ids = Object.values(await readLinks()).flatMap((link) => (link.hostId ? [link.hostId] : []))
|
||||
return new Set(ids)
|
||||
|
||||
@@ -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']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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<string, CloudWorkspaceStatus> = {
|
||||
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 }
|
||||
}
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
const TRANSITIONAL: ReadonlySet<CloudWorkspace['status']> = new Set([
|
||||
'creating',
|
||||
'waking',
|
||||
'stopping'
|
||||
'stopping',
|
||||
'deleting'
|
||||
])
|
||||
|
||||
export const cloudWorkspacesQueryKey = (sessionId: string | null) =>
|
||||
|
||||
@@ -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 <Icon size={size} color={colors.textMuted} />
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function HostOfflineBanner({
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.retryText}>{t('common.retry', 'Retry')}</Text>
|
||||
<Text style={styles.retryText}>{t('common.tryAgain', 'Try again')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -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 (
|
||||
<View accessibilityLabel={label} style={styles.glyph}>
|
||||
<Cloud size={12} strokeWidth={2} color={colors.textMuted} />
|
||||
{/* No filled ring: the row's fill changes on press and when active, so a ring would show as a patch. */}
|
||||
{status ? (
|
||||
<View style={styles.dot}>
|
||||
<CloudStatusDot status={status} size={7} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}))
|
||||
@@ -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({
|
||||
|
||||
<View style={styles.worktreeMain}>
|
||||
<View style={styles.worktreeNameRow}>
|
||||
{showHostGlyph && (
|
||||
{cloud && <WorktreeCloudGlyph status={cloud.status} />}
|
||||
{showHostGlyph && !cloud && (
|
||||
<View
|
||||
accessibilityLabel={
|
||||
hostAccessibilityLabel || t('components.worktreeListRow.remoteHost', 'Remote host')
|
||||
@@ -186,7 +189,7 @@ function WorktreeListRowComponent({
|
||||
style={[styles.childBadge, styles.hostBadge]}
|
||||
accessibilityLabel={hostAccessibilityLabel}
|
||||
>
|
||||
<HostKindIcon hostId={hostId} size={10} />
|
||||
<HostKindIcon hostId={hostId} size={10} cloud={Boolean(cloud)} />
|
||||
{view.hostContextLabel ? (
|
||||
<Text style={[styles.childBadgeText, styles.hostBadgeText]} numberOfLines={1}>
|
||||
{view.hostContextLabel}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Animated.View style={[styles.center, titleOpacity && { opacity: titleOpacity }]}>
|
||||
{center ?? (
|
||||
<>
|
||||
{title !== undefined ? (
|
||||
{title !== undefined && titleIcon ? (
|
||||
<View style={styles.titleRow}>
|
||||
{titleIcon}
|
||||
<Text
|
||||
style={[styles.title, styles.titleShrink]}
|
||||
numberOfLines={1}
|
||||
accessibilityRole="header"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
) : title !== undefined ? (
|
||||
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t('cloud.idle.sleeps', 'Sleeps after {{count}} minutes idle.', {
|
||||
count: ORCA_CLOUD_IDLE_SLEEP_MINUTES,
|
||||
defaultValue_one: 'Sleeps after {{count}} minute idle.'
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
@@ -170,10 +179,14 @@ function CloudWorkspaceRow({
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('cloud.home.openWorkspace', 'Open {{title}}, {{detail}}', {
|
||||
title: workspace.title,
|
||||
detail: line
|
||||
})}
|
||||
accessibilityLabel={t(
|
||||
'cloud.home.workspaceRowLabel',
|
||||
'Cloud workspace, {{title}}, {{detail}}',
|
||||
{
|
||||
title: workspace.title,
|
||||
detail: line
|
||||
}
|
||||
)}
|
||||
style={({ pressed }) => [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<string | null>(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(
|
||||
|
||||
@@ -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: <T,>(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<typeof create>[] = []
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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.'
|
||||
)}
|
||||
</Text>
|
||||
{/* Why: the phone reaches Orca Cloud through a paired desktop (#42), so say where cloud work shows up. */}
|
||||
<Text style={styles.cloudNote}>
|
||||
{t(
|
||||
'home.emptyState.cloudNote',
|
||||
'Your Orca Cloud workspaces show up here once a desktop is paired.'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.steps}>
|
||||
{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,
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user