feat(cloud): sign agents out before sandbox delete, scrub env-build images, recipe env warning (A9, P-4)

- Worker: run orca-agent-signout (each agent's logout, then credential files) on a running sandbox
  before the provider delete; bounded (20 s) and best effort, never blocks the delete.
- Environment builds: scrub agent auth (--scrub-only) before the snapshot; fail closed.
- New VM script registered in VM_SCRIPTS + provision loop; vm-scripts regenerated.
- Main IPC ephemeralVm:forwardedEnvSummary returns names only, grouped by credential-looking.
- P-4 warning component (+ fixture) shown under Settings > recipes; delete dialog line for awake
  Orca Cloud sandboxes.
This commit is contained in:
Neil
2026-11-10 04:07:13 -08:00
parent 0f8ab90314
commit 12fe0c867f
21 changed files with 690 additions and 4 deletions
@@ -11,6 +11,7 @@ export const VM_SCRIPTS = [
'orca-session-env.sh',
'orca-egress-probe',
'orca-image-wipe',
'orca-agent-signout',
'orca-share-proxy',
'orca-sshd',
'orca-ssh-keys',
@@ -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-egress-probe orca-image-wipe orca-agent-signout 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"
File diff suppressed because one or more lines are too long
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Signs every agent out on this sandbox (A9). Two callers, both from the control plane:
# - before Orca deletes a workspace sandbox: each agent's own logout, then the credential files go;
# - `--scrub-only` before an environment build is snapshotted into an image other people boot: the
# files only (no CLI runs, so nothing else in the image changes).
# Never fails: whatever happens, the sandbox is deleted or snapshotted next. Values are never printed.
set -u
HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}"
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}"
claude_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
claude_json="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
codex_dir="${CODEX_HOME:-$HOME/.codex}"
logged_out=0
if [ "${1:-}" != --scrub-only ]; then
# Each logout gets a few seconds; a hung CLI must not hold up the delete.
if command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout </dev/null >/dev/null 2>&1; then
logged_out=$((logged_out + 1))
fi
if command -v codex >/dev/null 2>&1 && timeout 8 codex logout </dev/null >/dev/null 2>&1; then
logged_out=$((logged_out + 1))
fi
fi
# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.
rm -f -- "$claude_dir"/.credentials* "$HOME/.claude"/.credentials* "$codex_dir/auth.json" "$HOME/.codex/auth.json" 2>/dev/null
# Orca-managed Codex homes keep their own auth.json under orcad's data dir.
if [ -d "$HOME/.orcad-data" ]; then
find "$HOME/.orcad-data" -path '*codex*' -type f -name auth.json -delete 2>/dev/null
fi
# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.
node_bin="$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)"
for file in "$claude_json" "$HOME/.claude.json"; do
[ -f "$file" ] || continue
if [ -n "$node_bin" ]; then
ORCA_FILE="$file" "$node_bin" -e '
const fs = require("fs")
const file = process.env.ORCA_FILE
let c
try { c = JSON.parse(fs.readFileSync(file, "utf8")) } catch { process.exit(2) }
if (!c || typeof c !== "object" || Array.isArray(c)) process.exit(2)
let changed = false
for (const key of Object.keys(c)) {
if (/oauth/i.test(key) || key === "primaryApiKey") { delete c[key]; changed = true }
}
if (changed) {
const tmp = `${file}.orca-${process.pid}`
fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n", { mode: 0o600 })
fs.renameSync(tmp, file)
}
' 2>/dev/null || rm -f -- "$file"
else
# No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.
rm -f -- "$file"
fi
done
left=0
for file in "$claude_dir"/.credentials* "$HOME/.claude"/.credentials* "$codex_dir/auth.json" "$HOME/.codex/auth.json"; do
[ -e "$file" ] && left=$((left + 1))
done
printf '{"loggedOut":%d,"left":%d}\n' "$logged_out" "$left"
@@ -0,0 +1,73 @@
// A9: agents are signed out of a sandbox before Orca deletes it, and an environment build's VM is
// scrubbed of agent sign-ins before it becomes an image other people boot.
//
// Both run the image's orca-agent-signout by source (`bash -c`), not by its installed path, so
// sandboxes booted from images built before the script existed get the same treatment.
import { VM_SCRIPT_SOURCES } from '../image/vm-scripts.generated'
import type { SandboxProviderApi } from './sandbox-provider'
export const AGENT_SIGNOUT_SCRIPT = VM_SCRIPT_SOURCES['orca-agent-signout']
/** Upper bound the delete waits for sign-out; the script gives each CLI 8 s. */
export const AGENT_SIGNOUT_TIMEOUT_MS = 20_000
const SCRUB_TIMEOUT_MS = 30_000
const log = (event: string, fields: Record<string, unknown>) => console.log(JSON.stringify({ event, ...fields }))
function signoutCommand(scrubOnly: boolean): { command: string; args: string[] } {
return { command: 'bash', args: ['-c', AGENT_SIGNOUT_SCRIPT, 'orca-agent-signout', ...(scrubOnly ? ['--scrub-only'] : [])] }
}
/** True when the script ran and reports no credential file left. */
function scrubbed(result: { exitCode: number; stdout: string }): boolean {
if (result.exitCode !== 0) {
return false
}
try {
const report = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '') as { left?: unknown }
return report.left === 0
} catch {
return false
}
}
/**
* Best effort and bounded: each agent's own logout, then its credential files, on the running VM.
* Never throws and never takes longer than AGENT_SIGNOUT_TIMEOUT_MS, so it cannot block a delete.
*/
export async function signOutAgentsBeforeDelete(
provider: SandboxProviderApi,
sessionId: string,
context: { sandboxId: string }
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), AGENT_SIGNOUT_TIMEOUT_MS)
})
try {
const outcome = await Promise.race([
provider.runCommand(sessionId, { ...signoutCommand(false), timeoutMs: AGENT_SIGNOUT_TIMEOUT_MS }),
timedOut
])
if (outcome === 'timeout') {
log('sandbox_agent_signout', { sandboxId: context.sandboxId, ok: false, reason: 'timeout' })
return false
}
const ok = scrubbed(outcome)
log('sandbox_agent_signout', { sandboxId: context.sandboxId, ok, exitCode: outcome.exitCode })
return ok
} catch (error) {
log('sandbox_agent_signout', { sandboxId: context.sandboxId, ok: false, error: String(error).slice(0, 120) })
return false
} finally {
clearTimeout(timer)
}
}
/**
* Removes agent credential files from a build VM before its snapshot. Fails closed: an image that
* might carry someone's sign-in must not be handed to the rest of the team.
*/
export async function scrubAgentAuthForImage(provider: SandboxProviderApi, sessionId: string): Promise<boolean> {
const result = await provider.runCommand(sessionId, { ...signoutCommand(true), timeoutMs: SCRUB_TIMEOUT_MS })
return scrubbed(result)
}
@@ -26,6 +26,7 @@ import { ProviderError, type SandboxProviderApi } from './sandbox-provider'
import { ORCAD_PORT, startOrcad } from './sandbox-vm-commands'
import type { Keyring } from './secrets-crypto'
import { SANDBOX_VCPUS } from './machine-size'
import { scrubAgentAuthForImage } from './agent-signout'
export const BUILD_DEADLINE_MS = 45 * 60_000
const STARTING_STUCK_MS = 5 * 60_000
@@ -445,6 +446,12 @@ async function finalize(deps: BuildDeps, row: BuildRow, exitCode: number): Promi
if (!recorded) {
return (await getBuild(deps.db, row.environment_id, row.id)) ?? row
}
// The image is shared with everyone who uses the environment: no agent sign-in may ride along
// (A9 clone scrub). Last before the snapshot, so nothing the build ran can leave one after it.
if (!(await scrubAgentAuthForImage(deps.provider, sessionId))) {
log('environment_build_agent_scrub_failed', { buildId: row.id })
return (await failBuild(deps, recorded, 'record', 'record_failed')) ?? recorded
}
const snapshotId = await deps.provider.createSnapshot(sessionId, 0)
return completeBuild(deps, recorded, snapshotId)
} catch (error) {
@@ -1,6 +1,7 @@
// 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 { deleteAgentSourcesForSandbox } from './agent-sources'
import { signOutAgentsBeforeDelete } from './agent-signout'
import type { EntitlementClient, MachineSize } from './entitlements'
import { parseGitHubRepo, type GitHubAppMinter } from './github-app'
import {
@@ -812,6 +813,11 @@ export async function destroySandbox(deps: LifecycleDeps, row: SandboxRow): Prom
if (settled !== destroying.usage_settled_at) {
destroying = await claim(deps, destroying, { usage_settled_at: settled })
}
// A9: sign each agent out on the VM first (bounded, best effort; never blocks the delete). A
// suspended sandbox has no session and is never woken for it: its disk goes with the delete.
if (destroying.session_id) {
await signOutAgentsBeforeDelete(deps.provider, destroying.session_id, { sandboxId: row.id })
}
await deps.provider.deleteSandbox(destroying.provider_name)
// Persistent sandboxes leave automatic snapshots behind delete; prove none survive.
const listed = await deps.provider.listSnapshots(destroying.provider_name)
@@ -0,0 +1,192 @@
// A9: agents are signed out on a sandbox before Orca deletes it, and environment-build images are
// scrubbed of agent sign-ins before anyone else boots them.
import { execFileSync } from 'node:child_process'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AGENT_SIGNOUT_TIMEOUT_MS, signOutAgentsBeforeDelete } from '../src/agent-signout'
import { ProviderError, type RunCommandParams, type SandboxProviderApi } from '../src/sandbox-provider'
import { createHarness } from './harness'
const create = { workspaceId: 'ws-1', machineSize: 'standard' }
const isSignout = (params: RunCommandParams) => params.command === 'bash' && params.args?.[2] === 'orca-agent-signout'
const SCRIPT = fileURLToPath(new URL('../image/vm/orca-agent-signout', import.meta.url).href)
describe('sign agents out before delete', () => {
it('runs each agent sign-out on the running VM before the provider delete', async () => {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
const name = `orca-test-${body.sandbox.id}`
const seen: { scrubOnly: boolean; boxAlive: boolean }[] = []
h.vercel.commandHandler = (params) => {
if (isSignout(params)) {
seen.push({ scrubOnly: params.args!.includes('--scrub-only'), boxAlive: h.vercel.boxes.has(name) })
}
return undefined
}
const destroyed = await h.call('DELETE', `/v1/sandboxes/${body.sandbox.id}`)
expect(destroyed.body.sandbox.status).toBe('destroyed')
expect(seen).toEqual([{ scrubOnly: false, boxAlive: true }])
expect(h.vercel.boxes.has(name)).toBe(false)
})
it('still deletes when the sign-out fails or the provider errors', async () => {
for (const failure of ['exit', 'throw'] as const) {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
h.vercel.commandHandler = (params) => {
if (!isSignout(params)) {
return undefined
}
if (failure === 'throw') {
throw new ProviderError(502, 'bad_gateway')
}
return { exitCode: 1, stdout: '', stderr: 'boom' }
}
const destroyed = await h.call('DELETE', `/v1/sandboxes/${body.sandbox.id}`)
expect(destroyed.body.sandbox.status).toBe('destroyed')
expect(h.vercel.boxes.size).toBe(0)
}
})
it('never wakes a suspended sandbox to sign out', async () => {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
await h.call('POST', `/v1/sandboxes/${body.sandbox.id}/suspend`)
const before = h.vercel.commands.length
const destroyed = await h.call('DELETE', `/v1/sandboxes/${body.sandbox.id}`)
expect(destroyed.body.sandbox.status).toBe('destroyed')
expect(h.vercel.commands.slice(before).filter((c) => c.args?.[2] === 'orca-agent-signout')).toEqual([])
})
describe('bounded wait', () => {
afterEach(() => {
vi.useRealTimers()
})
it('gives up after the timeout when the VM never answers', async () => {
vi.useFakeTimers()
const provider = { runCommand: () => new Promise(() => undefined) } as unknown as SandboxProviderApi
const done = signOutAgentsBeforeDelete(provider, 'sess', { sandboxId: 'sb' })
await vi.advanceTimersByTimeAsync(AGENT_SIGNOUT_TIMEOUT_MS)
await expect(done).resolves.toBe(false)
})
})
})
describe('scrub agent sign-ins on clone (environment builds)', () => {
const RESULT = JSON.stringify({ durationSec: 1, repos: [], diskUsedBytes: 1, diskTotalBytes: 2 })
async function setup() {
const h = await createHarness()
h.vercel.buildResult = RESULT
const admin = await h.token('alice', 'acme', 'sandbox', { orgRole: 'admin' })
const call = (method: string, path: string, body?: unknown) => h.call(method, path, { bearer: admin, body })
const created = await call('POST', '/v1/environments', {
name: 'Default',
repos: [{ url: 'acme/web', defaultBranch: 'main' }],
installScript: 'claude auth login',
envVarNames: []
})
const envId: string = created.body.environment.id
async function build() {
const started = await call('POST', `/v1/environments/${envId}/builds`)
const command = [...h.vercel.buildCommands.values()].at(-1)!
command.exitCode = 0
h.advance(1000)
return (await call('GET', `/v1/environments/${envId}/builds/${started.body.build.id}`)).body.build
}
return { h, build }
}
it('scrubs agent auth (files only) before the build is snapshotted', async () => {
const { h, build } = await setup()
const order: string[] = []
const createSnapshot = h.vercel.createSnapshot.bind(h.vercel)
h.vercel.createSnapshot = async (sessionId, expirationMs) => {
order.push('snapshot')
return createSnapshot(sessionId, expirationMs)
}
h.vercel.commandHandler = (params) => {
if (isSignout(params)) {
order.push(params.args!.includes('--scrub-only') ? 'scrub' : 'signout')
}
return undefined
}
expect(await build()).toMatchObject({ status: 'succeeded', active: true })
expect(order.slice(0, 2)).toEqual(['scrub', 'snapshot'])
})
it('fails the build, with no image, when the scrub cannot confirm the sign-ins are gone', async () => {
const { h, build } = await setup()
const images = () => [...h.vercel.snapshots.values()].filter((s) => s.creationMethod === 'manual').length
h.vercel.commandHandler = (params) =>
isSignout(params) ? { exitCode: 0, stdout: '{"loggedOut":0,"left":1}\n', stderr: '' } : undefined
expect(await build()).toMatchObject({ status: 'failed', failedStep: 'record', active: false })
expect(images()).toBe(0)
})
})
describe('orca-agent-signout (VM script)', () => {
let home = ''
afterEach(() => {
rmSync(home, { recursive: true, force: true })
})
function seed(): void {
home = mkdtempSync(join(tmpdir(), 'orca-signout-'))
mkdirSync(join(home, '.claude'), { recursive: true })
mkdirSync(join(home, '.codex'), { recursive: true })
mkdirSync(join(home, '.orcad-data/codex-accounts/a'), { recursive: true })
writeFileSync(join(home, '.claude/.credentials.json'), '{"claudeAiOauth":{"accessToken":"x"}}')
writeFileSync(join(home, '.codex/auth.json'), '{"tokens":{}}')
writeFileSync(join(home, '.codex/config.toml'), 'model = "x"\n')
writeFileSync(join(home, '.orcad-data/codex-accounts/a/auth.json'), '{}')
writeFileSync(
join(home, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: 'a@b.c' }, primaryApiKey: 'sk', hasCompletedOnboarding: true })
)
}
function run(args: string[]): { loggedOut: number; left: number } {
const env = { HOME: home, PATH: process.env.PATH ?? '/usr/bin:/bin' }
return JSON.parse(execFileSync('bash', [SCRIPT, ...args], { env, encoding: 'utf8' }).trim())
}
it('--scrub-only removes credential files and sign-in fields, keeping settings and running no CLI', () => {
seed()
const bin = join(home, '.local/bin')
mkdirSync(bin, { recursive: true })
for (const cli of ['claude', 'codex']) {
writeFileSync(join(bin, cli), `#!/bin/sh\ntouch "${home}/ran-${cli}"\n`)
chmodSync(join(bin, cli), 0o755)
}
expect(run(['--scrub-only'])).toEqual({ loggedOut: 0, left: 0 })
expect(existsSync(join(home, '.claude/.credentials.json'))).toBe(false)
expect(existsSync(join(home, '.codex/auth.json'))).toBe(false)
expect(existsSync(join(home, '.orcad-data/codex-accounts/a/auth.json'))).toBe(false)
expect(existsSync(join(home, '.codex/config.toml'))).toBe(true)
expect(JSON.parse(readFileSync(join(home, '.claude.json'), 'utf8'))).toEqual({ hasCompletedOnboarding: true })
expect(existsSync(join(home, 'ran-claude'))).toBe(false)
expect(existsSync(join(home, 'ran-codex'))).toBe(false)
})
it('runs each agent logout, then removes the files even when a logout fails', () => {
seed()
const bin = join(home, '.local/bin')
mkdirSync(bin, { recursive: true })
// A portable stand-in for coreutils timeout, so the test does not depend on the host having it.
writeFileSync(join(bin, 'timeout'), '#!/bin/sh\nshift\nexec "$@"\n')
writeFileSync(join(bin, 'claude'), `#!/bin/sh\necho "$@" >"${home}/claude-args"\n`)
writeFileSync(join(bin, 'codex'), '#!/bin/sh\nexit 1\n')
for (const file of ['timeout', 'claude', 'codex']) {
chmodSync(join(bin, file), 0o755)
}
expect(run([])).toEqual({ loggedOut: 1, left: 0 })
expect(readFileSync(join(home, 'claude-args'), 'utf8').trim()).toBe('auth logout')
expect(existsSync(join(home, '.codex/auth.json'))).toBe(false)
expect(existsSync(join(home, '.claude/.credentials.json'))).toBe(false)
})
})
@@ -293,6 +293,10 @@ export function createFakeVercel(now: () => number): FakeVercel {
if (params.command === 'bash' && params.args?.[1]?.includes('result.json')) {
return fake.runtimeMissing ? { exitCode: 1, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${fake.buildResult}\n`, stderr: '' }
}
if (params.command === 'bash' && params.args?.[2] === 'orca-agent-signout') {
// orca-agent-signout run by source (A9); tests override through commandHandler.
return { exitCode: 0, stdout: '{"loggedOut":2,"left":0}\n', stderr: '' }
}
if (params.command === '/opt/orca/bin/orca-image-wipe') {
return { exitCode: 0, stdout: '{"wiped":true}\n', stderr: '' }
}