diff --git a/cloud/apps/acceptance/desktop/run-desktop.mjs b/cloud/apps/acceptance/desktop/run-desktop.mjs
index dd0b5d787a3..8ce06a5a754 100644
--- a/cloud/apps/acceptance/desktop/run-desktop.mjs
+++ b/cloud/apps/acceptance/desktop/run-desktop.mjs
@@ -1029,7 +1029,7 @@ 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') {
+ 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' })
@@ -2024,7 +2024,7 @@ async function runMenuItem(step, actionName, item) {
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)\b/.test(text) && !text.startsWith(actionName)) under = false
+ 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'
@@ -2036,6 +2036,203 @@ async function runMenuItem(step, actionName, item) {
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`
@@ -2309,6 +2506,10 @@ async function runActionsFlow() {
* 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
diff --git a/cloud/apps/acceptance/findings.json b/cloud/apps/acceptance/findings.json
index 738775c3c29..366d33600b2 100644
--- a/cloud/apps/acceptance/findings.json
+++ b/cloud/apps/acceptance/findings.json
@@ -1,7 +1,8 @@
{
"findings": [
"Wake delivery (target p95 < 6 s) — MET in pdx1: after core U17/U18 (default region pdx1, Standard 8 vCPU/16 GB, golden 63b6999), n=20, persistent connection. Wake to echo p50 5.04 s, p95 5.85 s, p99 5.94 s; 0/20 slow hosts. Pinned to iad1 at 8/16: p50 5.31, p95 10.59, p99 11.80 s, with 4/20 slow hosts. BEFORE (iad1 4/8): 5.28 / 8.99 / 9.40 s. Region, not size, removes the tail. From this Mac the first terminal list (0.18 vs 0.44 s) and echo (0.07 vs 0.15 s) are also faster in pdx1. Create p50: pdx1 2.03 s, iad1-pinned 4.26 s (pinned creates seem to skip the warm pool).",
- "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control and the phone view are owner-reported (capped).",
+ "run.ports (cloud) passes on a #233 golden (iad1 d323e4a): first workspace 3000/10/3000, a second worktree on the same VM 26600/10/26600, both unchanged after sleep/wake, both persisted in the VM registry. pdx1 still runs 2d99d9b (pre-#233) and gives a hash block until its golden rotates. Desktop/golden version skew is a launch risk to watch. run.nonconcurrent passes with #233 (only the same action stops, 471 ms).",
+ "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control is covered by life.read-asleep-access. The phone view was checked in W9's own headless simulator run: “Latest from this workspace · As of just now” showed the marker branch and NOTES.md, and Vercel stayed stopped.",
"Read-asleep access, triggered by W9 itself: the operator members/removed route revokes the member; their pre-removal token and a fresh token both get 401, and their wrapped keys go 1 to 0, while the other teammate still reads. Making the workspace private gives that teammate 404 and 0 keys. Phone protocol: device token plus mobile reader key reads a sleeping private workspace in 125 ms, and unpairing revokes access.",
"Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html): five rows are capped at partial because they passed on a weaker bar than the Conductor column: real-model agent run, subscription sign-in, default machine size, and the download and remote-desktop UIs. Four rows Conductor has that our bar left out are added as not landed: local core workflow, Run actions with per-workspace ports, read-while-asleep from any device, and per-turn code checkpoints. Our 'pass' counts measure the team's own bar. Every cloud result is on dev Workers, Stripe TEST mode and dev/staging tokens; nothing is shipped (mvp-tracker §5 launch items are all open).",
"Agents and machine size (2026-11-10): image.agents: Claude Code, Codex, Cursor, OpenCode, Gemini and Copilot all answer --version on a fresh VM. agent.keys-swap: stand-in Gemini/Cursor keys exist on the VM only as placeholders; the provider sees the swapped key (Gemini 400 API_KEY_INVALID, Cursor 401); a foreign key or a disallowed path is refused (403); the stand-in values appear in 0 files and 0 process environments. sub.size cap lifted: Standard is now 8 vCPU / 16 GB (core U18, #194), and machineSize large maps to standard.",
@@ -28,7 +29,6 @@
"auditCaps": {
"agent.real-task": "Proven only with moonshotai/kimi-k2 through the Claude Code CLI. Per the user decision (2026-11-10) there are no AI Gateway real-model runs; lifted by the user's own e2e session with their own credentials, and on the coordinator's e2e checklist. Image now also has Cursor, OpenCode, Gemini and Copilot (image.agents); W9 has shown Cursor and Gemini launch with their keys swapped at egress (agent.keys-swap), but no real-model run for those two.",
"agent.signin": "Auth folders persist across sleep/wake and rollover, but subscription OAuth on a VM was never exercised. This cap is lifted by the user's own e2e session with their own credentials (after their review of #40228). It is on the coordinator's e2e checklist.",
- "agent.more-agents": "Owner-reported (gap-agents) real-task runs on kimi-k2 via the gateway. W9 is not re-running them (no gateway spend per user decision). Lifted by the user e2e session with their own credentials.",
- "life.read-asleep-phone": "Phone protocol verified by W9 (device token, mobile reader key, decrypt, no wake, revoked on unpair). The phone UI rendering (\"Latest from this workspace\") is still owner-reported (gap5 simulator screenshots); a W9 simulator UI run is pending."
+ "agent.more-agents": "Owner-reported (gap-agents) real-task runs on kimi-k2 via the gateway. W9 is not re-running them (no gateway spend per user decision). Lifted by the user e2e session with their own credentials."
}
}
\ No newline at end of file
diff --git a/cloud/apps/acceptance/mobile/cdp.mjs b/cloud/apps/acceptance/mobile/cdp.mjs
new file mode 100644
index 00000000000..33083975541
--- /dev/null
+++ b/cloud/apps/acceptance/mobile/cdp.mjs
@@ -0,0 +1,101 @@
+// Headless helpers over Metro's CDP for the Orca mobile dev client on a simulator (no GUI).
+// Adapted from W8/gap5's /tmp/w8/cdp-*.mjs. Port: W9M_METRO_PORT (default 8131).
+// node cdp.mjs identity-install stores {seedB64, enrollment} as the app's Orca Cloud identity
+// node cdp.mjs identity-clear removes the stored identity (the app makes a fresh, unenrolled key)
+// node cdp.mjs texts rendered text strings containing (read-only)
+// node cdp.mjs eval ''
+// Hermes's CDP does not await promises: the result is parked on a global and read back.
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+import { resolve, dirname } from 'node:path'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const require = createRequire(resolve(here, '../../../../mobile/package.json'))
+const WebSocket = require('ws')
+const PORT = process.env.W9M_METRO_PORT ?? '8131'
+const KEY = 'orca.cloud-device.v1'
+const [mode, arg] = process.argv.slice(2)
+
+async function session() {
+ const targets = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
+ const target = targets.find((t) => t.appId === 'com.stably.orca.mobile') ?? targets[0]
+ if (!target) {
+ throw new Error('no CDP target (is the app running?)')
+ }
+ const ws = new WebSocket(target.webSocketDebuggerUrl)
+ await new Promise((ok, fail) => {
+ ws.on('open', ok)
+ ws.on('error', fail)
+ })
+ let n = 0
+ const pending = new Map()
+ ws.on('message', (data) => {
+ const msg = JSON.parse(String(data))
+ pending.get(msg.id)?.(msg)
+ })
+ const evaluate = (expression) =>
+ new Promise((ok) => {
+ const id = ++n
+ pending.set(id, ok)
+ ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true } }))
+ }).then((m) => m.result?.result?.value ?? m.result?.exceptionDetails?.text ?? null)
+ return { evaluate, close: () => ws.close() }
+}
+
+const timer = setTimeout(() => {
+ console.log('"cdp timeout"')
+ process.exit(2)
+}, 20000)
+const s = await session()
+try {
+ if (mode === 'identity-install' || mode === 'identity-clear') {
+ const value = mode === 'identity-install' ? readFileSync(arg, 'utf8') : null
+ await s.evaluate(`(function () {
+ globalThis.__w9Identity = { pending: true }
+ var m = globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.ExpoSecureStore
+ if (!m) { globalThis.__w9Identity = { error: 'no ExpoSecureStore' }; return }
+ var options = { keychainAccessible: m.WHEN_UNLOCKED_THIS_DEVICE_ONLY }
+ var work = ${JSON.stringify(value)} === null
+ ? m.deleteValueWithKeyAsync(${JSON.stringify(KEY)}, options)
+ : m.setValueWithKeyAsync(${JSON.stringify(value)}, ${JSON.stringify(KEY)}, options)
+ work.then(function () { globalThis.__w9Identity = { ok: true } }, function (e) { globalThis.__w9Identity = { error: String(e) } })
+ })()`)
+ await new Promise((r) => setTimeout(r, 1500))
+ console.log(await s.evaluate('JSON.stringify(globalThis.__w9Identity)'))
+ } else if (mode === 'texts') {
+ console.log(
+ await s.evaluate(`(() => {
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__
+ if (!hook) return '"no hook"'
+ const out = new Set()
+ const take = (v) => {
+ if (typeof v === 'string' && v.includes(${JSON.stringify(arg ?? '')})) out.add(v)
+ else if (Array.isArray(v)) v.forEach(take)
+ }
+ for (const id of hook.renderers.keys()) {
+ const roots = hook.getFiberRoots(id)
+ roots && roots.forEach((r) => {
+ const stack = [r.current]
+ while (stack.length) {
+ const f = stack.pop()
+ if (!f) continue
+ if (f.memoizedProps) { take(f.memoizedProps.children); take(f.memoizedProps.text); take(f.memoizedProps.accessibilityLabel) }
+ if (f.child) stack.push(f.child)
+ if (f.sibling) stack.push(f.sibling)
+ }
+ })
+ }
+ return JSON.stringify([...out].slice(0, 80))
+ })()`)
+ )
+ } else if (mode === 'eval') {
+ console.log(JSON.stringify(await s.evaluate(arg)))
+ } else {
+ console.log('usage: cdp.mjs identity-install | identity-clear | texts | eval ')
+ }
+} finally {
+ s.close()
+ clearTimeout(timer)
+}
+process.exit(0)
diff --git a/cloud/apps/acceptance/mobile/read-asleep-sim.ts b/cloud/apps/acceptance/mobile/read-asleep-sim.ts
new file mode 100644
index 00000000000..d20f8b10c6a
--- /dev/null
+++ b/cloud/apps/acceptance/mobile/read-asleep-sim.ts
@@ -0,0 +1,135 @@
+// W9 simulator run for life.read-asleep-phone: the Orca mobile app shows a SLEEPING workspace's
+// "Latest from this workspace" without waking it. Driven in phases so the sim steps (CDP identity
+// install, deep link, screenshot) run between them; see run-read-asleep-sim.sh.
+// tsx mobile/read-asleep-sim.ts enroll device session (Ed25519 phone key) for the owner; writes the identity file (0600)
+// tsx mobile/read-asleep-sim.ts readerkey waits for the app's own mobile reader key (GET /v1/reader-keys)
+// tsx mobile/read-asleep-sim.ts workspace creates the workspace, writes NOTES.md + a commit with a marker, sleeps it
+// tsx mobile/read-asleep-sim.ts provider Vercel GET of the sandbox (no-wake check)
+// tsx mobile/read-asleep-sim.ts cleanup destroys the sandbox, revokes the device session + reader key, Vercel 404 check
+// State (ids, timings; no secrets) lives in $W9M_DIR/state.json; the phone seed only in identity.json (0600).
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto'
+import { control, vercel, vercelRest } from '../src/clients'
+import { config } from '../src/env'
+import type { Identity } from '../src/tokens'
+
+const DIR = process.env.W9M_DIR ?? '/tmp/w9m'
+const STATE = join(DIR, 'state.json')
+const owner: Identity = { sub: process.env.W9M_OWNER ?? 'w9-mobile' }
+const state: Record = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {}
+const save = () => writeFileSync(STATE, JSON.stringify(state, null, 2))
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+const providerStatus = async (id: string) => {
+ const r = await vercelRest('GET', `/v2/sandboxes/orca-dev-${id}`, { projectId: config.vercelProjectId })
+ return { http: r.status, sandbox: r.json?.sandbox?.status ?? null, session: r.json?.session?.status ?? null }
+}
+
+const phase = process.argv[2]
+if (phase === 'enroll') {
+ // The app's identity is a 32-byte Ed25519 seed (tweetnacl fromSeed == RFC 8032 private key).
+ const seed = randomBytes(32)
+ const der = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed])
+ const pub = Buffer.from(createPublicKey(createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })).export({ format: 'jwk' }).x!, 'base64')
+ const t0 = performance.now()
+ const r = await control.as(owner, 'POST', '/v1/device-sessions', { label: 'W9 eval iPhone (sim)', platform: 'ios', publicKeyB64: pub.toString('base64') })
+ const ds = r.body?.deviceSession?.id
+ console.log('enroll', r.status, ds ?? JSON.stringify(r.body).slice(0, 200))
+ if (!ds) process.exit(1)
+ writeFileSync(
+ join(DIR, 'identity.json'),
+ JSON.stringify({ seedB64: seed.toString('base64'), enrollment: { controlPlaneUrl: config.controlPlaneUrl, deviceSessionId: ds, label: 'W9 eval iPhone (sim)', enrolledAt: Date.now() } }),
+ { mode: 0o600 }
+ )
+ Object.assign(state, { owner: `dev:${owner.sub}`, deviceSessionId: ds, enrollMs: Math.round(performance.now() - t0) })
+ save()
+} else if (phase === 'readerkey') {
+ for (let i = 0; i < 40; i++) {
+ const r = await control.as(owner, 'GET', '/v1/reader-keys')
+ const keys: any[] = r.body?.keys ?? r.body?.readerKeys ?? []
+ const mobile = keys.find((k) => (k.deviceKind ?? k.kind) === 'mobile')
+ if (mobile) {
+ state.readerKeyId = mobile.id
+ save()
+ console.log('reader key', mobile.id, mobile.deviceKind ?? mobile.kind, mobile.deviceLabel ?? mobile.label)
+ process.exit(0)
+ }
+ if (i === 0) console.log('reader-keys', r.status, `${keys.length} keys`)
+ await sleep(3000)
+ }
+ console.log('no mobile reader key registered by the app')
+ process.exit(1)
+} else if (phase === 'workspace') {
+ const marker = `W9MOB${randomBytes(3).toString('hex').toUpperCase()}`
+ const t0 = performance.now()
+ const created = await control.as(owner, 'POST', '/v1/sandboxes', {
+ workspaceId: `w9-mobile-${marker.toLowerCase()}`,
+ machineSize: 'standard',
+ title: `W9 phone read ${marker}`,
+ repo: { url: 'https://github.com/octocat/Hello-World' }
+ })
+ const id = created.body?.sandbox?.id
+ console.log('create', created.status, created.ms, 'ms', id ?? JSON.stringify(created.body).slice(0, 200))
+ if (!id) process.exit(1)
+ Object.assign(state, { marker, sandboxId: id, createMs: created.ms })
+ save()
+ const root = created.body.connection.projectRoot
+ const script = [
+ `cd ${root}`,
+ `git checkout -q -b w9-${marker}`,
+ `printf '# Notes\\n\\nW9 phone read-asleep marker: ${marker}\\n' > NOTES.md`,
+ `git add NOTES.md`,
+ `git -c user.name='W9 eval' -c user.email='w9@example.invalid' commit -q -m 'Add NOTES.md (${marker})'`,
+ `printf 'Edited after the commit: ${marker}\\n' >> NOTES.md`,
+ `git log --oneline -1; git status --short; git branch --show-current`
+ ].join(' && ')
+ const ex: any = await vercel().runCommand(created.body.sandbox.session!.id, { command: 'bash', args: ['-c', script], timeoutMs: 60_000 })
+ const out = String(ex?.stdout ?? ex?.output ?? JSON.stringify(ex)).trim()
+ console.log('vm:', out.slice(0, 400))
+ state.vmOutput = out.slice(0, 400)
+ const s0 = performance.now()
+ const sus = await control.as(owner, 'POST', `/v1/sandboxes/${id}/suspend`)
+ state.suspendStatus = sus.status
+ state.suspendMs = Math.round(performance.now() - s0)
+ state.readSnapshotAt = sus.body?.sandbox?.readSnapshotAt ?? null
+ state.afterSuspend = await providerStatus(id)
+ state.workspaceSetupMs = Math.round(performance.now() - t0)
+ save()
+ console.log('suspend', sus.status, state.suspendMs, 'ms; provider', JSON.stringify(state.afterSuspend))
+ const g = await control.as(owner, 'GET', `/v1/sandboxes/${id}`)
+ console.log('control-plane view', g.body?.sandbox?.status, 'readSnapshotAt', g.body?.sandbox?.readSnapshotAt ?? g.body?.readSnapshotAt ?? '?')
+} else if (phase === 'provider') {
+ const p = await providerStatus(state.sandboxId)
+ const g = await control.as(owner, 'GET', `/v1/sandboxes/${state.sandboxId}`)
+ const label = process.argv[3] ?? 'check'
+ state.provider = { ...(state.provider ?? {}), [label]: { ...p, controlPlane: g.body?.sandbox?.status, at: new Date().toISOString() } }
+ save()
+ console.log(label, JSON.stringify(state.provider[label]))
+} else if (phase === 'cleanup') {
+ const out: Record = {}
+ if (state.sandboxId) {
+ const d = await control.as(owner, 'DELETE', `/v1/sandboxes/${state.sandboxId}`)
+ out.destroy = d.status
+ for (let i = 0; i < 20; i++) {
+ const p = await providerStatus(state.sandboxId)
+ out.vercelAfterDestroy = p.http
+ if (p.http === 404) break
+ await sleep(3000)
+ }
+ }
+ if (state.deviceSessionId) {
+ out.revokeDeviceSession = (await control.as(owner, 'DELETE', `/v1/device-sessions/${state.deviceSessionId}`)).status
+ }
+ if (state.readerKeyId) {
+ out.readerKeyDelete = (await control.as(owner, 'DELETE', `/v1/reader-keys/${state.readerKeyId}`)).status
+ }
+ const keys = await control.as(owner, 'GET', '/v1/reader-keys')
+ out.liveReaderKeysLeft = (keys.body?.keys ?? keys.body?.readerKeys ?? []).length
+ const list = await control.as(owner, 'GET', '/v1/sandboxes')
+ out.liveSandboxesLeft = (list.body?.sandboxes ?? []).filter((s: any) => !['destroyed', 'deleted'].includes(s.status)).length
+ state.cleanup = out
+ save()
+ console.log(JSON.stringify(out))
+} else {
+ console.log('phases: enroll | readerkey | workspace | provider [label] | cleanup')
+}
diff --git a/cloud/apps/acceptance/src/checks/lifecycle.ts b/cloud/apps/acceptance/src/checks/lifecycle.ts
index 75b36de0f89..ddb423f80c7 100644
--- a/cloud/apps/acceptance/src/checks/lifecycle.ts
+++ b/cloud/apps/acceptance/src/checks/lifecycle.ts
@@ -8,7 +8,7 @@ import { join } from 'node:path'
import { control } from '../clients'
import { config } from '../env'
import { log, type Check, type Ctx } from '../context'
-import { orca, openTerminal } from '../orca-cli'
+import { orca, openTerminal, typeAndSee } from '../orca-cli'
import { sandboxToken } from '../tokens'
const ARCHIVE = { 'x-orca-sandbox-features': 'archive' }
@@ -318,6 +318,55 @@ export const lifecycleChecks: Record = {
}
},
+
+ 'run.ports': async (ctx) => {
+ // Cloud half of per-workspace ports (#216/#233), headless over the pairing: the VM's first workspace
+ // gets the launcher block (3000), a second worktree on the same VM gets its own registry block, both
+ // are persisted and survive sleep/wake. The local half (distinct blocks, stable across app restart)
+ // was proven in the real app on m4air (desktop-20261110-033734).
+ const box = await ctx.sandboxes.create(ctx.solo, { workspaceId: `w9-ports-${ctx.run.file.runId}`, repo: { url: config.testRepo }, ...(process.env.ORCA_ACCEPTANCE_REGION ? { region: process.env.ORCA_ACCEPTANCE_REGION } : {}) })
+ if (box.status !== 200) {
+ return { status: 'fail', detail: `create ${box.status}` }
+ }
+ const id = box.body.sandbox.id
+ let code = box.body.connection.pairingCode as string
+ const root = box.body.connection.projectRoot as string
+ try {
+ const first = await openTerminal(code, root)
+ const worktree: string | undefined = first.term.json?.result?.terminal?.worktreeId
+ const repoId = worktree?.split('::')[0]
+ const second = await orca(code, ['worktree', 'create', '--repo', `id:${repoId}`, '--name', 'w9-second'])
+ const secondPath: string | undefined = second.json?.result?.worktree?.path
+ const portsIn = async (path: string, label: string) => {
+ const t = await orca(code, ['terminal', 'create', '--worktree', `path:${path}`, '--title', label])
+ const handle = t.json?.result?.terminal?.handle
+ if (!handle) {
+ return `no-terminal(${t.stderr.slice(0, 80)})`
+ }
+ const seen = await typeAndSee(code, handle, `echo PORTS_\${ORCA_PORT}_\${ORCA_PORT_COUNT}_\${CONDUCTOR_PORT}_END`, '_END')
+ return /PORTS_(\d+)_(\d+)_(\d+)_END/.exec(seen.output)?.slice(1).join('/') ?? `unseen(${seen.error})`
+ }
+ const a1 = await portsIn(root, 'first')
+ const b1 = secondPath ? await portsIn(secondPath, 'second') : `no second worktree (${second.stderr.slice(0, 120) || JSON.stringify(second.json?.error ?? '').slice(0, 120)})`
+ const registry = (await ctx.exec((await ctx.sandboxes.get(id)).body.sandbox.session!.id, 'cat /vercel/.orcad-data/workspace-port-blocks.json 2>/dev/null')).stdout.trim()
+ await ctx.sandboxes.suspend(id)
+ const w = await ctx.sandboxes.resume(id)
+ code = w.body.connection.pairingCode
+ const a2 = await portsIn(root, 'first-after')
+ const b2 = secondPath ? await portsIn(secondPath, 'second-after') : 'n/a'
+ const aPort = Number(a1.split('/')[0])
+ const bPort = Number(b1.split('/')[0])
+ const ok =
+ aPort === 3000 && a1.endsWith('/10/3000') && bPort >= 20000 && bPort <= 31999 && bPort !== aPort && a1 === a2 && b1 === b2
+ return {
+ status: ok ? 'pass' : 'fail',
+ detail: `cloud VM ports (ORCA_PORT/COUNT/CONDUCTOR_PORT): first workspace ${a1}, second worktree ${b1}; after sleep/wake ${a2} and ${b2}; registry ${registry.slice(0, 200)}. Local blocks (distinct, stable across restart) proven on m4air desktop-20261110-033734.`,
+ evidence: [log('port registry', registry)]
+ }
+ } finally {
+ await ctx.sandboxes.destroy(id)
+ }
+ },
'acc.ssh': async (ctx) => {
const b = await refresh(ctx)
const { publicKey } = generateKeyPairSync('ed25519')
diff --git a/cloud/apps/acceptance/src/golden-status.ts b/cloud/apps/acceptance/src/golden-status.ts
index fc9cc9b6cb0..ac01e674d6a 100644
--- a/cloud/apps/acceptance/src/golden-status.ts
+++ b/cloud/apps/acceptance/src/golden-status.ts
@@ -32,7 +32,18 @@ function contains(commit: string, ancestor: string): boolean {
}
for (;;) {
- const a = active()
+ let a: ReturnType = null
+ try {
+ a = active()
+ } catch (error) {
+ // Transient Cloudflare API/auth errors: keep waiting rather than dying.
+ console.log(JSON.stringify({ at: new Date().toISOString(), error: String(error).split('\n')[0]!.slice(0, 160) }))
+ if (!wait) {
+ break
+ }
+ await new Promise((r) => setTimeout(r, 120_000))
+ continue
+ }
const ok = a?.commit && required ? contains(a.commit, required) : null
console.log(JSON.stringify({ at: new Date().toISOString(), ...a, containsRequired: ok }))
if (!wait || ok) {