feat(cloud): seal a read snapshot at drain; reader keys, read endpoint, revocation pruning

This commit is contained in:
Neil
2026-11-10 00:13:19 -08:00
parent 86aafd99c6
commit 1bc418da8a
15 changed files with 1190 additions and 14 deletions
@@ -20,6 +20,8 @@ export const VM_SCRIPTS = [
'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',
@@ -158,7 +158,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-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-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
@@ -0,0 +1,329 @@
#!/usr/bin/env node
// Gathers what a sleeping workspace should still show (recent chats per agent session, agent
// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals
// it to the reader keys the control plane passed, so only ciphertext leaves the VM.
// Run by orca-sandbox-read-snapshot right before the drain. Env:
// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>
// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover
// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)
// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.
// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):
// body = AES-256-GCM(dek, nonce, JSON, AAD "orca-cloud-read-v1:<workspaceId>:<snapshotId>")
// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, "orca-cloud-read-wrap-v1"),
// nonce_i, dek, AAD "orca-cloud-read-wrap-v1:<workspaceId>:<snapshotId>:<keyId>")
'use strict'
const crypto = require('node:crypto')
const fs = require('node:fs')
const net = require('node:net')
const path = require('node:path')
const INFO = 'orca-cloud-read-v1'
const WRAP_INFO = 'orca-cloud-read-wrap-v1'
const PROJECT = '/vercel/sandbox/project'
const LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }
/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */
const MAX_PLAIN_BYTES = 150_000
const CALL_TIMEOUT_MS = 5_000
const TOTAL_BUDGET_MS = 20_000
function b64(bytes) {
return Buffer.from(bytes).toString('base64')
}
function x25519PublicKey(raw) {
return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })
}
function gcmSeal(key, nonce, plain, aad) {
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
cipher.setAAD(Buffer.from(aad))
return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])
}
/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */
function seal(payload, recipients, workspaceId, snapshotId) {
const scope = `${workspaceId}:${snapshotId}`
const dek = crypto.randomBytes(32)
const nonce = crypto.randomBytes(12)
const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)
const sealedTo = []
for (const recipient of recipients) {
const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')
if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {
continue
}
let shared
const eph = crypto.generateKeyPairSync('x25519')
try {
shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })
} catch {
continue
}
if (shared.every((byte) => byte === 0)) {
continue
}
const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')
const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))
const wrapNonce = crypto.randomBytes(12)
const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)
sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })
}
dek.fill(0)
return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }
}
// --- gathering -------------------------------------------------------------------------------
/** One request per connection, like the CLI. A streaming method resolves on its first frame. */
function rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {
return new Promise((resolve) => {
const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))
if (!transport) {
resolve(null)
return
}
const socket = net.createConnection(transport.endpoint)
const id = crypto.randomUUID()
let buffer = ''
let done = false
const finish = (value) => {
if (!done) {
done = true
clearTimeout(timer)
socket.destroy()
resolve(value)
}
}
const timer = setTimeout(() => finish(null), timeoutMs)
socket.setEncoding('utf8')
socket.on('error', () => finish(null))
socket.on('close', () => finish(null))
socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\n`))
socket.on('data', (chunk) => {
buffer += chunk
let newline
while ((newline = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newline)
buffer = buffer.slice(newline + 1)
let frame
try {
frame = JSON.parse(line)
} catch {
continue
}
if (!frame || frame.id !== id) {
continue
}
finish(frame.ok === true ? frame.result : null)
return
}
})
})
}
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
function messagesOf(history) {
const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []
const out = []
for (const item of items) {
const body = isObj(item) && isObj(item.body) ? item.body : null
if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {
continue
}
const role = body.role
if (role !== 'user' && role !== 'assistant' && role !== 'system') {
continue
}
const text = body.blocks
.filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (text) {
out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })
}
}
return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }
}
function gitOf(status, log) {
if (!isObj(status) && !isObj(log)) {
return null
}
const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []
let added = 0
let removed = 0
let counted = false
const changes = entries.slice(0, LIMITS.changes).map((e) => {
const a = num(e.added)
const r = num(e.removed)
if (a !== null || r !== null) {
counted = true
}
return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }
})
for (const e of entries) {
added += num(e.added) || 0
removed += num(e.removed) || 0
}
const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []
return {
branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\/heads\//, '') : null,
head: isObj(status) ? str(status.head, 64) : null,
changes,
changeCount: entries.length,
added: counted ? added : null,
removed: counted ? removed : null,
commits: items.slice(0, LIMITS.commits).map((c) => ({
id: String(c.id).slice(0, 64),
subject: String(c.subject ?? '').slice(0, 300),
author: str(c.author, 120),
at: num(c.timestamp)
}))
}
}
function prOf(linked) {
if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {
return null
}
return {
number: linked.number,
title: String(linked.title ?? '').slice(0, 300),
state: str(linked.state, 20) || 'open',
url: linked.url.slice(0, 500),
checksStatus: str(linked.checksStatus, 20),
checksSummary: str(linked.checksSummary, 200)
}
}
const STATUSES = new Set(['working', 'attention', 'idle'])
/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */
async function gather(call, { workspaceId, snapshotId, reason, now, deadline }) {
const timeLeft = () => deadline - Date.now()
const [statusFeed, ps] = await Promise.all([call('agentSession.subscribeStatus', null), call('worktree.ps', { limit: 20 })])
const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []
const project = worktrees.find((w) => w.path === PROJECT) || worktrees[0] || null
const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`
const [status, log] = await Promise.all([
call('git.status', { worktree: selector, includeLineStats: true }),
call('git.history', { worktree: selector, limit: LIMITS.commits })
])
const summaries = isObj(statusFeed) && Array.isArray(statusFeed.sessions) ? statusFeed.sessions.filter(isObj) : []
// Newest activity first; chats with nothing in them still list (agent + status).
const picked = summaries
.filter((s) => typeof s.sessionId === 'string')
.sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))
.slice(0, LIMITS.sessions)
const sessions = []
for (const s of picked) {
const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null
const { messages, hasOlder } = messagesOf(history)
sessions.push({
sessionId: s.sessionId.slice(0, 200),
agent: str(s.agent, 40),
title: str(s.conversationName, 200) || str(s.latestPrompt, 200),
status: STATUSES.has(s.status) ? s.status : 'unknown',
turnOutcome: str(s.turnOutcome, 40),
updatedAt: num(s.updatedAt),
messages: messages.slice(-LIMITS.messagesPerSession),
truncated: hasOlder || messages.length > LIMITS.messagesPerSession
})
}
return {
v: 1,
workspaceId,
snapshotId,
capturedAt: now,
reason,
git: gitOf(status, log),
pr: prOf(project && project.linkedPR),
sessions
}
}
/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */
function fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {
const size = () => Buffer.byteLength(JSON.stringify(payload))
let guard = 0
while (size() > maxBytes && guard++ < 10_000) {
const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)
if (longest && longest.messages.length > 4) {
longest.messages.shift()
longest.truncated = true
continue
}
const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)
if (longText) {
longText.text = `${longText.text.slice(0, 500)}…`
continue
}
if (payload.git && payload.git.changes.length > 20) {
payload.git.changes = payload.git.changes.slice(0, 20)
continue
}
if (payload.sessions.length > 1) {
payload.sessions.pop()
continue
}
payload.sessions = []
break
}
return payload
}
async function main() {
const started = Date.now()
const env = process.env
const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''
const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''
if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {
process.stderr.write('bad ids\n')
process.exit(2)
}
let recipients
try {
recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')
} catch {
recipients = null
}
if (!Array.isArray(recipients) || recipients.length === 0) {
process.stderr.write('no recipients\n')
process.exit(2)
}
const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')
let metadata
try {
metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))
} catch {
metadata = null
}
if (!metadata || !metadata.authToken) {
process.stderr.write('orcad not running\n')
process.exit(3)
}
const deadline = started + TOTAL_BUDGET_MS
const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))
const payload = fitToBudget(await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline }))
const out = seal(payload, recipients, workspaceId, snapshotId)
out.stats = {
sessions: payload.sessions.length,
messages: payload.sessions.reduce((n, s) => n + s.messages.length, 0),
plainBytes: Buffer.byteLength(JSON.stringify(payload)),
collectMs: Date.now() - started
}
process.stdout.write(`${JSON.stringify(out)}\n`)
}
module.exports = { seal, gather, fitToBudget, messagesOf, gitOf, prOf, rpc }
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`read snapshot failed: ${String(error && error.message ? error.message : error).slice(0, 200)}\n`)
process.exit(1)
})
}
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the
# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see
# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).
set -euo pipefail
umask 077
ORCA_HOME=/opt/orca
export ORCA_USER_DATA_PATH="${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}"
node="$(find "$ORCA_HOME/runtime/runtimes" -type f -name node | head -n 1)"
[ -n "$node" ] || { echo 'node not found' >&2; exit 1; }
exec timeout 35 "$node" "$ORCA_HOME/bin/orca-read-snapshot.cjs"
@@ -0,0 +1,45 @@
-- Read a sleeping workspace without waking it (gap 5). Every device keeps one X25519 "reader key";
-- only the public half is stored here. At each drain the VM seals a compact snapshot (chats, agent
-- status, git, PR) to the reader keys allowed to open the workspace; this database keeps only that
-- ciphertext and one wrapped content key per reader key, never plaintext.
CREATE TABLE IF NOT EXISTS reader_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
dev INTEGER NOT NULL DEFAULT 0,
public_key TEXT NOT NULL,
device_kind TEXT NOT NULL,
device_label TEXT NOT NULL,
-- A phone's key dies with its device session (unpair, revoke, member removal).
device_session_id TEXT,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
revoked_at INTEGER,
revoked_by TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS reader_keys_live ON reader_keys (account_id, user_id, public_key) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS reader_keys_account ON reader_keys (account_id, revoked_at);
CREATE INDEX IF NOT EXISTS reader_keys_device_session ON reader_keys (device_session_id);
-- One snapshot per workspace, replaced at every drain.
CREATE TABLE IF NOT EXISTS read_snapshots (
sandbox_id TEXT PRIMARY KEY,
snapshot_id TEXT NOT NULL,
stored_at INTEGER NOT NULL,
bytes INTEGER NOT NULL,
nonce TEXT NOT NULL,
ciphertext TEXT NOT NULL
);
-- The snapshot's content key wrapped to one reader key. Deleting a row is how access is pruned
-- (member removed, workspace made private, key revoked) without waking the VM.
CREATE TABLE IF NOT EXISTS read_snapshot_keys (
sandbox_id TEXT NOT NULL,
key_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
ephemeral_public_key TEXT NOT NULL,
nonce TEXT NOT NULL,
wrapped_key TEXT NOT NULL,
PRIMARY KEY (sandbox_id, key_id)
);
CREATE INDEX IF NOT EXISTS read_snapshot_keys_key ON read_snapshot_keys (key_id);
+26 -1
View File
@@ -32,6 +32,7 @@
// Operator (OPERATOR_TOKEN): /v1/internal/* (see operator.ts)
// Public share links (src/share-gateway.ts): /s/<token>/*, plus the root-path cookie fallback
// Vercel network-policy forwardURL target (Vercel sandbox OIDC): /v1/egress/proxy
import { ReadSnapshotRouteError, getReadSnapshot, handleReaderKeysRoute, pruneReadSnapshotToOwner, revokeDeviceSessionReaderKeys } from './read-snapshots'
import { refreshStaleEnvironments } from './environment-golden-refresh'
import { ORCA_CLOUD_IDLE_SLEEP_MINUTES } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire'
import { z } from 'zod'
@@ -836,6 +837,14 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
const state = await createConnectState(env.SANDBOX_DB, connect.stateSecret, { accountId: `dev:${sub}`, userId: sub }, (deps.now ?? Date.now)())
return Response.redirect(authorizeUrl(connect, url.origin, state), 302)
}
if (url.pathname === '/v1/reader-keys' || url.pathname.startsWith('/v1/reader-keys/')) {
const now = (deps.now ?? Date.now)()
const caller = await authenticateDesktop(env, request, now)
if (!caller) {
return fail('unauthorized', 401)
}
return await handleReaderKeysRoute(request, url, { db: env.SANDBOX_DB, caller, now, readJson: () => readJson(request) })
}
if (url.pathname === '/v1/device-sessions' || url.pathname.startsWith('/v1/device-sessions/')) {
const now = (deps.now ?? Date.now)()
const caller = await authenticateDesktop(env, request, now)
@@ -851,6 +860,8 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
onRevoked:
deps.onDeviceSessionRevoked ??
(async (deviceSessionId) => {
// Its reader key first: no VM involved, so this can't be held up by a sleeping workspace.
await revokeDeviceSessionReaderKeys(env.SANDBOX_DB, deviceSessionId, now)
const life = await lifecycleDeps(env, deps)
if (life) {
await revokeGrantsForDeviceSession(life, deviceSessionId)
@@ -1283,7 +1294,7 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps,
if (url.pathname === '/v1/team/workspaces') {
return request.method === 'GET' ? json(await listWorkspaces(db, caller, url, now)) : fail('not_found', 404)
}
const match = /^\/v1\/team\/workspaces\/(sbx_[0-9a-f]{24})(?:\/(presence|wake|lease|grants|sent)(?:\/(grt_[0-9a-f]{24}))?)?$/.exec(url.pathname)
const match = /^\/v1\/team\/workspaces\/(sbx_[0-9a-f]{24})(?:\/(presence|wake|lease|grants|sent|read-snapshot)(?:\/(grt_[0-9a-f]{24}))?)?$/.exec(url.pathname)
if (!match) {
return fail('not_found', 404)
}
@@ -1294,6 +1305,16 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps,
if (route === 'GET ') {
return json({ workspace: await view() })
}
if (route === 'GET read-snapshot') {
try {
return json(await getReadSnapshot(db, caller, row, url.searchParams.get('keyId'), now))
} catch (error) {
if (error instanceof ReadSnapshotRouteError) {
return fail(error.code, error.status)
}
throw error
}
}
if (route === 'POST sent') {
// The caller's own client: "I just sent a message here" (opens their git-as-sender window).
if (!canOpen(caller, row)) {
@@ -1320,6 +1341,10 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps,
return fail('invalid_request', 400)
}
const patched = await patchWorkspace(db, caller, row, body.data, now)
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)
return json({ workspace: await view(patched.row), ...(revoked ? { revoked } : {}) })
+10 -2
View File
@@ -20,6 +20,7 @@ import type { ProviderSandboxState, SandboxProviderApi } from './sandbox-provide
import { egressSecretHosts, secretPlaceholder } from './secret-egress'
import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env'
import type { Keyring } from './secrets-crypto'
import { captureReadSnapshot, deleteReadSnapshot } from './read-snapshots'
import { ORCAD_PORT, SHARE_PROXY_PORT, drainOrcad, runningOrcad, startOrcad, type OrcadReady, type StartInputs } from './sandbox-vm-commands'
import { WORKSPACE_PRELUDE_SCRIPT } from '../image/vm-scripts.generated'
import { resolveEnvironmentBoot, type BootSource } from './environments'
@@ -624,9 +625,14 @@ export async function failAndRelease(deps: LifecycleDeps, row: SandboxRow): Prom
export async function stopForSuspend(
deps: LifecycleDeps,
row: SandboxRow,
options: { drainSeconds?: number } = {}
options: { drainSeconds?: number; reason?: 'sleep' | 'rollover'; captureRead?: boolean } = {}
): Promise<SandboxRow> {
if (row.session_id) {
// While orcad still serves: what a sleeping workspace shows to readers (sealed on the VM).
// Skipped for Restart (orcad isn't answering) and Archive (an archived workspace isn't readable).
if (options.captureRead !== false) {
await captureReadSnapshot(deps, row, options.reason ?? 'sleep').catch(() => undefined)
}
await drainOrcad(deps.provider, row.session_id, options.drainSeconds)
const stopped = await deps.provider.stopSession(row.session_id)
if (stopped.snapshotId) {
@@ -743,7 +749,7 @@ export async function rolloverSandbox(deps: LifecycleDeps, row: SandboxRow): Pro
throw new GuardrailError('maintenance')
}
const suspending = await claim(deps, row, { status: 'suspending' })
const stopped = await stopForSuspend(deps, suspending)
const stopped = await stopForSuspend(deps, suspending, { reason: 'rollover' })
await flushUsage(deps.db, deps.entitlements, deps.now(), row.id)
return wake(deps, await claim(deps, stopped, { status: 'resuming' }))
}
@@ -790,6 +796,8 @@ export async function destroySandbox(deps: LifecycleDeps, row: SandboxRow): Prom
await markSnapshotsDeleted(deps.db, row.id, deps.now())
// Archive metadata (repo state, file tree) goes with the workspace.
await deps.db.prepare('DELETE FROM sandbox_archives WHERE sandbox_id = ?').bind(row.id).run()
// So does its sealed read snapshot.
await deleteReadSnapshot(deps.db, row.id)
const destroyed = await claim(deps, destroying, {
status: 'destroyed',
...NO_SESSION,
@@ -6,6 +6,7 @@ import { revokeMemberDeviceSessions } from './device-sessions'
import { revokeGrantsForMember } from './grants'
import type { LifecycleDeps } from './lifecycle'
import { memberChangeTime, recordMemberChange } from './member-changes'
import { revokeMemberReaderKeys } from './read-snapshots'
import { disableMemberRoutines } from './routines/runs'
export type MemberRemoval = {
@@ -14,6 +15,7 @@ export type MemberRemoval = {
grantsRevoked: number
grantsQueued: number
routinesDisabled: number
readerKeysRevoked: number
}
export async function removeMember(
@@ -37,6 +39,8 @@ export async function removeMember(
])
// Their phones sign as them; then every workspace device they were granted (phones' included).
const devicesRevoked = await revokeMemberDeviceSessions(life.db, accountId, userId, now)
// No device of theirs can open a sleeping workspace's read snapshot again (wrapped keys deleted).
const readerKeysRevoked = await revokeMemberReaderKeys(life.db, accountId, userId, now)
const grants = await revokeGrantsForMember(life, accountId, userId)
// Routines run with their creator's secrets: a departed member's stop, and nothing of theirs keeps running.
const routines = await disableMemberRoutines({ life }, accountId, userId)
@@ -45,7 +49,8 @@ export async function removeMember(
devicesRevoked,
grantsRevoked: grants.revoked,
grantsQueued: grants.queued,
routinesDisabled: routines.disabled
routinesDisabled: routines.disabled,
readerKeysRevoked
}
await audit(life.db, { accountId, type: 'system', id: 'auth', userId }, { action: 'member.removed', detail: { ...result, runsCancelled: routines.cancelled } }, now)
return result
@@ -0,0 +1,447 @@
// Read a sleeping workspace without waking it (gap 5), end-to-end encrypted:
// PUT /v1/reader-keys {publicKeyB64, deviceLabel, deviceKind} register this device's X25519 key
// GET /v1/reader-keys the caller's live keys
// DELETE /v1/reader-keys/:id
// GET /v1/team/workspaces/:id/read-snapshot?keyId=rk_… the body + only that key's wrapped key
// At every drain the control plane asks the VM (image/vm/orca-sandbox-read-snapshot) to seal a
// compact snapshot to the reader keys allowed to open the workspace, and stores the ciphertext. It
// never holds a reader secret, so it can't read a snapshot. Access mirrors canOpen at read time, and
// pruning (removal, private, revoke) deletes wrapped keys at once, without the VM.
import { z } from 'zod'
import type { SandboxCaller } from './auth'
import { ensureEntryPoints } from './golden-images'
import type { LifecycleDeps } from './lifecycle'
import type { SandboxRow } from './store'
import { WorkspaceError, canOpen, isOwner } from './workspaces'
export const READ_SNAPSHOT_COMMAND = '/opt/orca/bin/orca-sandbox-read-snapshot'
const READ_SNAPSHOT_TIMEOUT_MS = 40_000
/** Above this the VM trims the payload; anything bigger here is refused (D1 rows stay small). */
export const MAX_READ_SNAPSHOT_BYTES = 220_000
/** A sleeping workspace is sealed to at most this many devices (newest first). */
export const MAX_READ_RECIPIENTS = 64
const MAX_KEYS_PER_USER = 12
const B64_32 = z
.string()
.regex(/^[A-Za-z0-9+/]{43}=$/)
.refine((value) => atob(value).length === 32, 'expected 32 bytes')
const B64_NONCE = z.string().regex(/^[A-Za-z0-9+/]{16}$/)
const KEY_ID = /^rk_[0-9a-f]{24}$/
const SNAPSHOT_ID = /^rsn_[0-9a-f]{24}$/
export const ReaderKeyRequestSchema = z
.object({
publicKeyB64: B64_32,
deviceLabel: z.string().trim().min(1).max(80).regex(/^[^\u0000-\u001f]+$/),
deviceKind: z.enum(['desktop', 'mobile'])
})
.strict()
export type ReaderKeyRow = {
id: string
user_id: string
account_id: string
dev: number
public_key: string
device_kind: string
device_label: string
device_session_id: string | null
created_at: number
last_seen_at: number
revoked_at: number | null
revoked_by: string | null
}
export class ReadSnapshotRouteError extends Error {
constructor(
readonly code: 'not_found' | 'no_snapshot' | 'not_sealed_for_key' | 'invalid_request',
readonly status: number
) {
super(code)
}
}
function randomHexId(prefix: string): string {
const bytes = crypto.getRandomValues(new Uint8Array(12))
return `${prefix}_${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`
}
function readerKeyView(row: ReaderKeyRow) {
return {
id: row.id,
deviceKind: row.device_kind,
deviceLabel: row.device_label,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at
}
}
/** Idempotent per (caller, public key): the same id comes back and lastSeenAt moves. */
export async function registerReaderKey(
db: D1Database,
caller: SandboxCaller,
body: z.infer<typeof ReaderKeyRequestSchema>,
now: number
): Promise<ReaderKeyRow> {
const live = await db
.prepare('SELECT * FROM reader_keys WHERE account_id = ? AND user_id = ? AND public_key = ? AND revoked_at IS NULL')
.bind(caller.accountId, caller.userId, body.publicKeyB64)
.first<ReaderKeyRow>()
if (live) {
// A phone's key must stay bound to the session that registered it; another session (or a
// desktop token) presenting the same public key is refused rather than rebinding it.
if ((live.device_session_id ?? null) !== (caller.deviceSessionId ?? null)) {
throw new ReadSnapshotRouteError('invalid_request', 409)
}
await db
.prepare('UPDATE reader_keys SET last_seen_at = ?, device_label = ? WHERE id = ?')
.bind(now, body.deviceLabel, live.id)
.run()
return { ...live, last_seen_at: now, device_label: body.deviceLabel }
}
const row: ReaderKeyRow = {
id: randomHexId('rk'),
user_id: caller.userId,
account_id: caller.accountId,
dev: caller.dev ? 1 : 0,
public_key: body.publicKeyB64,
device_kind: body.deviceKind,
device_label: body.deviceLabel,
device_session_id: caller.deviceSessionId ?? null,
created_at: now,
last_seen_at: now,
revoked_at: null,
revoked_by: null
}
await db
.prepare(
`INSERT INTO reader_keys (id, user_id, account_id, dev, public_key, device_kind, device_label, device_session_id, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(row.id, row.user_id, row.account_id, row.dev, row.public_key, row.device_kind, row.device_label, row.device_session_id, now, now)
.run()
// Bounded per member: the least recently seen keys beyond the cap are retired.
const { results: stale } = await db
.prepare(
`SELECT id FROM reader_keys WHERE account_id = ? AND user_id = ? AND revoked_at IS NULL
ORDER BY last_seen_at DESC, created_at DESC LIMIT -1 OFFSET ?`
)
.bind(caller.accountId, caller.userId, MAX_KEYS_PER_USER)
.all<{ id: string }>()
if (stale.length > 0) {
await revokeReaderKeys(db, stale.map((s) => s.id), 'system:key_limit', now)
}
return row
}
/** Revokes the keys and deletes every wrapped snapshot key they had (no VM involved). */
export async function revokeReaderKeys(db: D1Database, ids: readonly string[], by: string, now: number): Promise<number> {
let revoked = 0
for (let i = 0; i < ids.length; i += 50) {
const chunk = ids.slice(i, i + 50)
const marks = chunk.map(() => '?').join(',')
const [update] = await db.batch([
db.prepare(`UPDATE reader_keys SET revoked_at = ?, revoked_by = ? WHERE id IN (${marks}) AND revoked_at IS NULL`).bind(now, by, ...chunk),
db.prepare(`DELETE FROM read_snapshot_keys WHERE key_id IN (${marks})`).bind(...chunk)
])
revoked += update?.meta.changes ?? 0
}
return revoked
}
async function revokeWhere(db: D1Database, where: string, binds: (string | number)[], by: string, now: number): Promise<number> {
const { results } = await db
.prepare(`SELECT id FROM reader_keys WHERE ${where} AND revoked_at IS NULL`)
.bind(...binds)
.all<{ id: string }>()
return revokeReaderKeys(db, results.map((r) => r.id), by, now)
}
/** A member left the account: none of their devices may read any of its workspaces again. */
export function revokeMemberReaderKeys(db: D1Database, accountId: string, userId: string, now: number): Promise<number> {
return revokeWhere(db, 'account_id = ? AND user_id = ?', [accountId, userId], 'system:member_removed', now)
}
/** A phone was unpaired or revoked: its reader key goes with it. */
export function revokeDeviceSessionReaderKeys(db: D1Database, deviceSessionId: string, now: number): Promise<number> {
return revokeWhere(db, 'device_session_id = ?', [deviceSessionId], 'system:device_revoked', now)
}
/**
* Visibility or owner changed: drop every wrapped key of this workspace whose holder is not its
* (new) owner. Team members get back in at the next drain if it becomes team-visible again.
*/
export async function pruneReadSnapshotToOwner(db: D1Database, row: SandboxRow): Promise<number> {
const result = await db
.prepare(
`DELETE FROM read_snapshot_keys WHERE sandbox_id = ? AND key_id NOT IN
(SELECT id FROM reader_keys WHERE account_id = ? AND user_id = ? AND revoked_at IS NULL)`
)
.bind(row.id, row.account_id, row.user_id)
.run()
return result.meta.changes
}
export async function deleteReadSnapshot(db: D1Database, sandboxId: string): Promise<void> {
await db.batch([
db.prepare('DELETE FROM read_snapshot_keys WHERE sandbox_id = ?').bind(sandboxId),
db.prepare('DELETE FROM read_snapshots WHERE sandbox_id = ?').bind(sandboxId)
])
}
/** The live keys allowed to open this workspace right now (owner; every member when team-visible). */
export async function readerKeysFor(db: D1Database, row: SandboxRow): Promise<{ id: string; public_key: string }[]> {
if (row.archived_at !== null) {
return []
}
const team = row.visibility === 'team'
const { results } = await db
.prepare(
`SELECT id, public_key FROM reader_keys
WHERE account_id = ? AND revoked_at IS NULL ${team ? '' : 'AND user_id = ?'}
ORDER BY (user_id = ?) DESC, last_seen_at DESC LIMIT ?`
)
.bind(...(team ? [row.account_id] : [row.account_id, row.user_id]), row.user_id, MAX_READ_RECIPIENTS)
.all<{ id: string; public_key: string }>()
return results
}
const VmOutputSchema = z
.object({
v: z.literal(1),
snapshotId: z.string().regex(SNAPSHOT_ID),
workspaceId: z.string(),
nonceB64: B64_NONCE,
ciphertextB64: z.string().regex(/^[A-Za-z0-9+/]+=*$/).max(MAX_READ_SNAPSHOT_BYTES),
recipients: z
.array(
z
.object({
keyId: z.string().regex(KEY_ID),
ephemeralPublicKeyB64: B64_32,
nonceB64: B64_NONCE,
wrappedKeyB64: z.string().regex(/^[A-Za-z0-9+/]{64}$/)
})
.strict()
)
.max(MAX_READ_RECIPIENTS),
stats: z.record(z.string(), z.number()).optional()
})
.strict()
type CaptureOutcome =
| { captured: true; snapshotId: string; recipients: number; bytes: number; stats?: Record<string, number> }
| { captured: false; reason: string }
export type CaptureResult = CaptureOutcome & { ms: number }
/**
* Best effort, before orcad drains: the VM gathers and seals the snapshot (it never leaves the VM
* in plaintext); this stores the ciphertext and replaces the previous one. A failure keeps the old
* snapshot and never blocks the sleep.
*/
export async function captureReadSnapshot(deps: LifecycleDeps, row: SandboxRow, reason: string): Promise<CaptureResult> {
const started = deps.now()
const done = (r: CaptureOutcome): CaptureResult => {
const result: CaptureResult = { ...r, ms: deps.now() - started }
console.log(JSON.stringify({ event: 'read_snapshot', sandboxId: row.id, reason, ...result }))
return result
}
if (!row.session_id) {
return done({ captured: false, reason: 'no_session' })
}
const keys = await readerKeysFor(deps.db, row)
if (keys.length === 0) {
return done({ captured: false, reason: 'no_reader_keys' })
}
const snapshotId = randomHexId('rsn')
const sessionId = row.session_id
let stdout: string
try {
// VMs booted from an older image get the current entry points (this script) first.
await ensureEntryPoints(deps, sessionId).catch(() => undefined)
const result = await deps.provider.runCommand(sessionId, {
command: READ_SNAPSHOT_COMMAND,
env: {
ORCA_READ_SNAPSHOT_ID: snapshotId,
ORCA_READ_WORKSPACE_ID: row.id,
ORCA_READ_REASON: reason,
ORCA_READ_RECIPIENTS: JSON.stringify(keys.map((k) => ({ keyId: k.id, publicKeyB64: k.public_key })))
},
timeoutMs: READ_SNAPSHOT_TIMEOUT_MS
})
if (result.exitCode !== 0) {
return done({ captured: false, reason: `exit_${result.exitCode}:${result.stderr.trim().slice(0, 120)}` })
}
stdout = result.stdout
} catch (error) {
return done({ captured: false, reason: `run_failed:${String(error).slice(0, 120)}` })
}
let parsed: z.infer<typeof VmOutputSchema>
try {
const line = stdout.trim().split('\n').pop() ?? ''
const out = VmOutputSchema.safeParse(JSON.parse(line))
if (!out.success) {
return done({ captured: false, reason: 'bad_output' })
}
parsed = out.data
} catch {
return done({ captured: false, reason: 'bad_output' })
}
const asked = new Set(keys.map((k) => k.id))
if (parsed.snapshotId !== snapshotId || parsed.workspaceId !== row.id || parsed.recipients.some((r) => !asked.has(r.keyId))) {
return done({ captured: false, reason: 'mismatched_output' })
}
const now = deps.now()
const statements = [
deps.db.prepare('DELETE FROM read_snapshot_keys WHERE sandbox_id = ?').bind(row.id),
deps.db
.prepare(
`INSERT INTO read_snapshots (sandbox_id, snapshot_id, stored_at, bytes, nonce, ciphertext) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(sandbox_id) DO UPDATE SET snapshot_id = excluded.snapshot_id, stored_at = excluded.stored_at,
bytes = excluded.bytes, nonce = excluded.nonce, ciphertext = excluded.ciphertext`
)
.bind(row.id, snapshotId, now, parsed.ciphertextB64.length, parsed.nonceB64, parsed.ciphertextB64),
...parsed.recipients.map((r) =>
deps.db
.prepare(
'INSERT INTO read_snapshot_keys (sandbox_id, key_id, snapshot_id, ephemeral_public_key, nonce, wrapped_key) VALUES (?, ?, ?, ?, ?, ?)'
)
.bind(row.id, r.keyId, snapshotId, r.ephemeralPublicKeyB64, r.nonceB64, r.wrappedKeyB64)
),
// A key revoked (or a member removed) while the VM was sealing must not slip back in.
deps.db
.prepare(
`DELETE FROM read_snapshot_keys WHERE sandbox_id = ? AND key_id IN (SELECT id FROM reader_keys WHERE revoked_at IS NOT NULL)`
)
.bind(row.id)
]
await deps.db.batch(statements)
// The workspace may have been made private while sealing: re-apply the current rule.
const current = await deps.db.prepare('SELECT * FROM sandboxes WHERE id = ?').bind(row.id).first<SandboxRow>()
if (current && current.visibility !== 'team') {
await pruneReadSnapshotToOwner(deps.db, current)
}
return done({
captured: true,
snapshotId,
recipients: parsed.recipients.length,
bytes: parsed.ciphertextB64.length,
...(parsed.stats ? { stats: parsed.stats } : {})
})
}
/** For workspace views: when each workspace's snapshot was stored (only rows the caller can open). */
export async function readSnapshotTimes(db: D1Database, ids: readonly string[]): Promise<Map<string, number>> {
const out = new Map<string, number>()
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90)
if (chunk.length === 0) {
continue
}
const { results } = await db
.prepare(`SELECT sandbox_id, stored_at FROM read_snapshots WHERE sandbox_id IN (${chunk.map(() => '?').join(',')})`)
.bind(...chunk)
.all<{ sandbox_id: string; stored_at: number }>()
for (const r of results) {
out.set(r.sandbox_id, r.stored_at)
}
}
return out
}
export async function getReadSnapshot(db: D1Database, caller: SandboxCaller, row: SandboxRow, keyId: string | null, now: number) {
// Belt and braces over canOpen: a private workspace's snapshot opens only for its owner.
if (!canOpen(caller, row) || (row.visibility !== 'team' && !isOwner(caller, row))) {
throw new WorkspaceError('not_found')
}
if (!keyId || !KEY_ID.test(keyId)) {
throw new ReadSnapshotRouteError('invalid_request', 400)
}
// The key must be the caller's own, live one: nobody fetches another member's wrapped key.
const key = await db
.prepare('SELECT * FROM reader_keys WHERE id = ? AND account_id = ? AND user_id = ? AND revoked_at IS NULL')
.bind(keyId, caller.accountId, caller.userId)
.first<ReaderKeyRow>()
if (!key) {
throw new ReadSnapshotRouteError('invalid_request', 400)
}
const snapshot = await db
.prepare('SELECT * FROM read_snapshots WHERE sandbox_id = ?')
.bind(row.id)
.first<{ snapshot_id: string; stored_at: number; nonce: string; ciphertext: string }>()
if (!snapshot) {
throw new ReadSnapshotRouteError('no_snapshot', 404)
}
const wrapped = await db
.prepare('SELECT * FROM read_snapshot_keys WHERE sandbox_id = ? AND key_id = ? AND snapshot_id = ?')
.bind(row.id, keyId, snapshot.snapshot_id)
.first<{ ephemeral_public_key: string; nonce: string; wrapped_key: string }>()
if (!wrapped) {
throw new ReadSnapshotRouteError('not_sealed_for_key', 404)
}
if (now - key.last_seen_at > 60 * 60_000) {
await db.prepare('UPDATE reader_keys SET last_seen_at = ? WHERE id = ?').bind(now, key.id).run()
}
return {
snapshot: {
v: 1 as const,
snapshotId: snapshot.snapshot_id,
workspaceId: row.id,
storedAt: snapshot.stored_at,
nonceB64: snapshot.nonce,
ciphertextB64: snapshot.ciphertext,
recipient: {
keyId,
ephemeralPublicKeyB64: wrapped.ephemeral_public_key,
nonceB64: wrapped.nonce,
wrappedKeyB64: wrapped.wrapped_key
}
}
}
}
export type ReaderKeysRouteDeps = { db: D1Database; caller: SandboxCaller; now: number; readJson: () => Promise<unknown> }
export async function handleReaderKeysRoute(request: Request, url: URL, deps: ReaderKeysRouteDeps): Promise<Response> {
const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
const match = /^\/v1\/reader-keys(?:\/(rk_[0-9a-f]{24}))?$/.exec(url.pathname)
if (!match) {
return json({ error: 'not_found' }, 404)
}
const [, id] = match
try {
if (!id && request.method === 'PUT') {
const body = ReaderKeyRequestSchema.safeParse(await deps.readJson())
if (!body.success) {
return json({ error: 'invalid_request' }, 400)
}
return json({ readerKey: readerKeyView(await registerReaderKey(deps.db, deps.caller, body.data, deps.now)) })
}
if (!id && request.method === 'GET') {
const { results } = await deps.db
.prepare('SELECT * FROM reader_keys WHERE account_id = ? AND user_id = ? AND revoked_at IS NULL ORDER BY last_seen_at DESC')
.bind(deps.caller.accountId, deps.caller.userId)
.all<ReaderKeyRow>()
return json({ readerKeys: results.map(readerKeyView) })
}
if (id && request.method === 'DELETE') {
const owned = await deps.db
.prepare('SELECT id FROM reader_keys WHERE id = ? AND account_id = ? AND user_id = ? AND revoked_at IS NULL')
.bind(id, deps.caller.accountId, deps.caller.userId)
.first<{ id: string }>()
if (!owned) {
return json({ error: 'not_found' }, 404)
}
await revokeReaderKeys(deps.db, [id], deps.caller.userId, deps.now)
return json({ ok: true })
}
return json({ error: 'not_found' }, 404)
} catch (error) {
if (error instanceof ReadSnapshotRouteError) {
return json({ error: error.code }, error.status)
}
throw error
}
}
@@ -227,7 +227,7 @@ export async function restartSandbox(deps: LifecycleDeps, row: SandboxRow): Prom
throw new LifecycleError('conflict', `sandbox_${current.status}`)
}
const suspending = await claim(deps, current, { status: 'suspending' })
const stopped = await stopForSuspend(deps, suspending, { drainSeconds: RESTART_DRAIN_SECONDS })
const stopped = await stopForSuspend(deps, suspending, { drainSeconds: RESTART_DRAIN_SECONDS, captureRead: false })
await flushUsage(deps.db, deps.entitlements, deps.now(), row.id)
return wake(deps, await claim(deps, stopped, { status: 'resuming' }))
}
@@ -294,7 +294,7 @@ export async function archiveSandbox(
let stopped = row
if (row.status === 'running') {
// Short drain: the desktop's Sleep normally ran first; a long drain would outlive the caller.
stopped = await stopForSuspend(deps, await claim(deps, row, { status: 'suspending' }), { drainSeconds: ARCHIVE_DRAIN_SECONDS })
stopped = await stopForSuspend(deps, await claim(deps, row, { status: 'suspending' }), { drainSeconds: ARCHIVE_DRAIN_SECONDS, captureRead: false })
await flushUsage(deps.db, deps.entitlements, deps.now(), row.id)
}
const ttl = config.archiveTtlMs ?? DEFAULT_ARCHIVE_TTL_MS
+7 -3
View File
@@ -13,6 +13,7 @@ import type { SandboxCaller } from './auth'
import type { MachineSize } from './entitlements'
import { getSandbox, touchActivity, updateRegistry, type SandboxRow } from './store'
import { memberNamesFor } from './member-names'
import { readSnapshotTimes } from './read-snapshots'
import { sandboxView } from './wire'
export const PRESENCE_TTL_MS = 45_000
@@ -158,10 +159,11 @@ function repoView(row: SandboxRow) {
export async function workspaceViews(db: D1Database, caller: SandboxCaller, rows: readonly SandboxRow[], now: number) {
const ids = rows.map((r) => r.id)
const [presence, compute, granted] = await Promise.all([
const [presence, compute, granted, readAt] = await Promise.all([
presenceFor(db, ids, now),
computeMsFor(db, ids),
activeGrantUsers(db, ids, caller.userId)
activeGrantUsers(db, ids, caller.userId),
readSnapshotTimes(db, ids)
])
const referenced = (row: SandboxRow, live: readonly PresenceRow[]) =>
[row.user_id, row.created_by, row.last_activity_by, ...live.map((p) => p.user_id)].filter(
@@ -195,7 +197,9 @@ export async function workspaceViews(db: D1Database, caller: SandboxCaller, rows
access,
presence: live.map((p) => ({ userId: p.user_id, clientId: p.client_id, state: p.state, chatId: p.chat_id, at: p.updated_at })),
// Display names (token `name` claim) for the ids above; the roster wins where it has one.
memberNames
memberNames,
// When its sleeping read-only copy was saved ("As of"); only for those who may read it.
readSnapshotAt: canOpen(caller, row) ? (readAt.get(row.id) ?? null) : null
}
})
}
+1 -1
View File
@@ -191,7 +191,7 @@ describe('member removal', () => {
const { secret } = (await h.call('POST', '/v1/api-keys', { bearer: member, body: { name: 'bob ci', scopes: ['workspaces:read'] } })).body
expect((await h.call('GET', '/v1/me', { bearer: secret })).status).toBe(200)
const removed = await h.call('POST', '/v1/internal/members/removed', { bearer: 'o'.repeat(40), body: { accountId: 'org:acme', userId: 'bob' } })
expect(removed.body).toEqual({ revoked: 1, keysRevoked: 1, devicesRevoked: 0, grantsRevoked: 0, grantsQueued: 0, routinesDisabled: 0 })
expect(removed.body).toEqual({ revoked: 1, keysRevoked: 1, devicesRevoked: 0, grantsRevoked: 0, grantsQueued: 0, routinesDisabled: 0, readerKeysRevoked: 0 })
expect((await h.call('GET', '/v1/me', { bearer: secret })).status).toBe(401)
})
})
@@ -0,0 +1,300 @@
import { createRequire } from 'node:module'
import { SignJWT, exportJWK, generateKeyPair } from 'jose'
import { describe, expect, it, vi } from 'vitest'
import { generateGrantRecipientKey, nodeSealedGrantPrimitives } from '../../../../src/main/cloud-workspaces/sealed-grant-node'
import { openReadSnapshot, type ReadSnapshot } from '../../../../src/shared/cloud-workspaces/read-snapshot'
import type { RunCommandParams } from '../src/sandbox-provider'
import { MAX_READ_RECIPIENTS, READ_SNAPSHOT_COMMAND } from '../src/read-snapshots'
import { createHarness } from './harness'
// The real VM collector + sealer (image/vm/orca-read-snapshot.cjs), fed a fake orcad.
const require = createRequire(import.meta.url)
const vm = require('../image/vm/orca-read-snapshot.cjs') as {
seal: (payload: unknown, recipients: unknown[], workspaceId: string, snapshotId: string) => Record<string, unknown>
gather: (call: (method: string, params: unknown) => Promise<unknown>, opts: Record<string, unknown>) => Promise<Record<string, unknown>>
fitToBudget: <T>(payload: T, maxBytes?: number) => T
}
const SECRET_LINE = 'the deploy key is in 1password, not in the repo'
function fakeOrcad(method: string): unknown {
switch (method) {
case 'agentSession.subscribeStatus':
return {
type: 'snapshot',
sessions: [
{ sessionId: 'chat-1', agent: 'claude', status: 'idle', conversationName: 'Fix login', turnOutcome: 'completed', updatedAt: 2 },
{ sessionId: 'chat-2', agent: 'codex', status: 'working', conversationName: 'Add tests', updatedAt: 1 }
]
}
case 'worktree.ps':
return {
worktrees: [
{
path: '/vercel/sandbox/project',
linkedPR: { number: 42, title: 'Fix login', state: 'open', url: 'https://github.com/acme/app/pull/42', checksStatus: 'success', checksSummary: '3/3 passed' }
}
]
}
case 'git.status':
return { branch: 'refs/heads/fix/login', head: 'abc1234', entries: [{ path: 'src/login.ts', status: 'modified', area: 'unstaged', added: 12, removed: 3 }] }
case 'git.history':
return { items: [{ id: 'abc1234', subject: 'Fix the login redirect', author: 'Alice', timestamp: 1 }] }
case 'agentSession.history':
return {
page: {
hasOlder: false,
items: [
{ itemId: 'i1', observedAt: 1, body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Why does login loop?' }] } },
{ itemId: 'i2', observedAt: 2, body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: SECRET_LINE }] } },
{ itemId: 'i3', observedAt: 3, body: { kind: 'tool', blocks: [] } }
]
}
}
default:
return null
}
}
/** What orca-sandbox-read-snapshot does on a VM, minus the socket: gather from orcad, then seal. */
async function runVmSealer(params: RunCommandParams): Promise<string> {
const env = params.env ?? {}
const payload = vm.fitToBudget(
await vm.gather(async (method) => fakeOrcad(method), {
workspaceId: env.ORCA_READ_WORKSPACE_ID,
snapshotId: env.ORCA_READ_SNAPSHOT_ID,
reason: env.ORCA_READ_REASON,
now: 1_800_000_000_000,
deadline: Date.now() + 10_000
})
)
const out = vm.seal(payload, JSON.parse(env.ORCA_READ_RECIPIENTS ?? '[]'), env.ORCA_READ_WORKSPACE_ID!, env.ORCA_READ_SNAPSHOT_ID!)
return `${JSON.stringify(out)}\n`
}
async function setup() {
vi.spyOn(console, 'log').mockImplementation((line: string) => { if (process.env.DBG && String(line).includes('read_snapshot')) process.stderr.write(line + '\n') })
const h = await createHarness({ OPERATOR_TOKEN: 'o'.repeat(40) })
// The fake's runCommand awaits whatever the handler returns, so the real (async) sealer can run.
let vmUp = true
h.vercel.commandHandler = ((params: RunCommandParams) => {
if (params.command !== READ_SNAPSHOT_COMMAND) {
return undefined
}
return vmUp
? runVmSealer(params).then((stdout) => ({ exitCode: 0, stdout, stderr: '' }))
: { exitCode: 3, stdout: '', stderr: 'orcad not running' }
}) as never
type Who = { sub: string; org: string; role: 'owner' | 'admin' | 'member' }
const as = (sub: string, role: Who['role'] = 'member', org = 'acme'): Who => ({ sub, org, role })
const req = async (who: Who, method: string, path: string, body?: unknown) =>
h.call(method, path, { bearer: await h.token(who.sub, who.org, 'sandbox', { org_role: who.role }), body })
const alice = as('alice')
const bob = as('bob')
const created = await req(alice, 'POST', '/v1/sandboxes', {
workspaceId: 'ws-1',
machineSize: 'standard',
title: 'Fix login',
repo: { url: 'https://github.com/acme/app', ref: 'fix/login' }
})
expect(created.status).toBe(200)
const id = created.body.sandbox.id as string
const register = async (who: Who, kind: 'desktop' | 'mobile' = 'desktop', bearer?: string) => {
const key = generateGrantRecipientKey()
const res = bearer
? await h.call('PUT', '/v1/reader-keys', { bearer, body: { publicKeyB64: key.publicKeyB64, deviceLabel: `${who.sub} ${kind}`, deviceKind: kind } })
: await req(who, 'PUT', '/v1/reader-keys', { publicKeyB64: key.publicKeyB64, deviceLabel: `${who.sub} ${kind}`, deviceKind: kind })
expect(res.status).toBe(200)
return { ...key, id: res.body.readerKey.id as string }
}
/** Sleep the workspace (the drain seals first). `orcadDown`: the VM script finds no orcad. */
const sleep = async (who: Who = alice, orcadDown = false) => {
vmUp = !orcadDown
return req(who, 'POST', `/v1/sandboxes/${id}/suspend`)
}
const read = (who: Who, keyId: string, bearer?: string) =>
bearer
? h.call('GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`, { bearer })
: req(who, 'GET', `/v1/team/workspaces/${id}/read-snapshot?keyId=${keyId}`)
const open = async (body: { snapshot: never }, secretKey: Uint8Array): Promise<ReadSnapshot> =>
openReadSnapshot(body.snapshot, id, secretKey, nodeSealedGrantPrimitives)
return { h, req, as, alice, bob, id, register, sleep, read, open }
}
describe('read snapshots of sleeping workspaces (E2EE)', () => {
it('seals at sleep to the owner devices; the control plane stores only ciphertext; the VM stays asleep', async () => {
const t = await setup()
const laptop = await t.register(t.alice)
const phone = await t.register(t.alice, 'mobile')
expect((await t.sleep()).status).toBe(200)
const row = t.h.raw.prepare('SELECT status FROM sandboxes WHERE id = ?').get(t.id) as { status: string }
expect(row.status).toBe('suspended')
const before = t.h.vercel.commands.length
for (const key of [laptop, phone]) {
const res = await t.read(t.alice, key.id)
expect(res.status).toBe(200)
const snap = await t.open(res.body, key.secretKey)
expect(snap.sessions.map((s) => [s.agent, s.title, s.status])).toEqual([
['claude', 'Fix login', 'idle'],
['codex', 'Add tests', 'working']
])
expect(snap.sessions[0]!.messages.map((m) => m.text)).toEqual(['Why does login loop?', SECRET_LINE])
expect(snap.git).toMatchObject({ branch: 'fix/login', changeCount: 1, added: 12, removed: 3 })
expect(snap.pr).toMatchObject({ number: 42, url: 'https://github.com/acme/app/pull/42', checksStatus: 'success' })
// Only this device's wrapped key is handed out.
expect(res.body.snapshot.recipient.keyId).toBe(key.id)
}
// Reading never touched the VM.
expect(t.h.vercel.commands.length).toBe(before)
expect((t.h.raw.prepare('SELECT status FROM sandboxes WHERE id = ?').get(t.id) as { status: string }).status).toBe('suspended')
// Nothing readable at rest.
const dump = JSON.stringify([
t.h.raw.prepare('SELECT * FROM read_snapshots').all(),
t.h.raw.prepare('SELECT * FROM read_snapshot_keys').all()
])
expect(dump).not.toContain('1password')
expect(dump).not.toContain('login loop')
// The list says when, for "As of" without fetching the blob.
const list = await t.req(t.alice, 'GET', '/v1/team/workspaces?scope=mine')
expect(list.body.workspaces[0].readSnapshotAt).toBe(t.h.now())
})
it('opens for teammates only on team workspaces; private stays owner-only; bad keys and other people’s keys are refused', async () => {
const t = await setup()
const own = await t.register(t.alice)
const bobs = await t.register(t.bob)
await t.sleep()
// Private: Bob can't see it at all, and was never a recipient.
expect((await t.read(t.bob, bobs.id)).body).toEqual({ error: 'not_found' })
await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/resume`)
await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' })
await t.sleep()
const res = await t.read(t.bob, bobs.id)
expect(res.status).toBe(200)
expect((await t.open(res.body, bobs.secretKey)).sessions).toHaveLength(2)
// Bob can't fetch Alice's wrapped key, nor open his blob with another key.
expect((await t.read(t.bob, own.id)).status).toBe(400)
await expect(t.open(res.body, own.secretKey)).rejects.toThrow('decrypt_failed')
// Another org's member sees nothing.
const mallory = t.as('mallory', 'member', 'evil')
const mk = await t.register(mallory)
expect((await t.read(mallory, mk.id)).status).toBe(404)
})
it('making it private prunes teammates at once, without the VM', async () => {
const t = await setup()
await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' })
const own = await t.register(t.alice)
const bobs = await t.register(t.bob)
await t.sleep()
expect((await t.read(t.bob, bobs.id)).status).toBe(200)
await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'private' })
expect((await t.read(t.bob, bobs.id)).status).toBe(404)
expect(t.h.raw.prepare('SELECT key_id FROM read_snapshot_keys').all()).toEqual([{ key_id: own.id }])
// Even back to team: Bob's wrapped key is gone until the next sleep re-seals.
await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' })
expect((await t.read(t.bob, bobs.id)).body).toEqual({ error: 'not_sealed_for_key' })
})
it('a removed member can no longer read, and their wrapped keys are deleted', async () => {
const t = await setup()
await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' })
await t.register(t.alice)
const bobs = await t.register(t.bob)
await t.sleep()
expect((await t.read(t.bob, bobs.id)).status).toBe(200)
const removed = await t.h.call('POST', '/v1/internal/org-members/removed', { bearer: 'o'.repeat(40), body: { accountId: 'org:acme', userId: 'bob' } })
expect(removed.body.readerKeysRevoked).toBe(1)
// A token minted after removal (stale org claim) still gets nothing: key revoked, wrapped key gone.
t.h.advance(1000)
const again = await t.read(t.bob, bobs.id)
expect(again.status).not.toBe(200)
expect(t.h.raw.prepare('SELECT key_id FROM read_snapshot_keys WHERE key_id = ?').all(bobs.id)).toEqual([])
// Next sleep is not sealed to him either.
await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/resume`)
await t.sleep()
expect(t.h.raw.prepare('SELECT key_id FROM read_snapshot_keys WHERE key_id = ?').all(bobs.id)).toEqual([])
})
it('a device added after the last sleep gets not_sealed_for_key; no snapshot yet is no_snapshot', async () => {
const t = await setup()
const first = await t.register(t.alice)
expect((await t.read(t.alice, first.id)).body).toEqual({ error: 'no_snapshot' })
await t.sleep()
const later = await t.register(t.alice)
expect((await t.read(t.alice, later.id)).body).toEqual({ error: 'not_sealed_for_key' })
expect((await t.read(t.alice, 'rk_nope')).status).toBe(400)
})
it('registration is idempotent, per member capped, and a key revoke drops its wrapped key', async () => {
const t = await setup()
const key = generateGrantRecipientKey()
const body = { publicKeyB64: key.publicKeyB64, deviceLabel: 'Mac', deviceKind: 'desktop' }
const a = await t.req(t.alice, 'PUT', '/v1/reader-keys', body)
const b = await t.req(t.alice, 'PUT', '/v1/reader-keys', body)
expect(a.body.readerKey.id).toBe(b.body.readerKey.id)
expect((await t.req(t.alice, 'PUT', '/v1/reader-keys', { ...body, publicKeyB64: 'AAAA' })).status).toBe(400)
await t.sleep()
expect((await t.read(t.alice, a.body.readerKey.id)).status).toBe(200)
expect((await t.req(t.bob, 'DELETE', `/v1/reader-keys/${a.body.readerKey.id}`)).status).toBe(404)
expect((await t.req(t.alice, 'DELETE', `/v1/reader-keys/${a.body.readerKey.id}`)).body).toEqual({ ok: true })
expect((await t.read(t.alice, a.body.readerKey.id)).status).toBe(400)
// Re-registering a revoked key yields a new id (never resurrected), with nothing sealed to it.
const c = await t.req(t.alice, 'PUT', '/v1/reader-keys', body)
expect(c.body.readerKey.id).not.toBe(a.body.readerKey.id)
for (let i = 0; i < 14; i++) {
await t.register(t.alice)
}
expect((await t.req(t.alice, 'GET', '/v1/reader-keys')).body.readerKeys).toHaveLength(12)
})
it('a phone key dies with its device session', async () => {
const t = await setup()
const { privateKey, publicKey } = await generateKeyPair('Ed25519', { extractable: true })
const publicKeyB64 = Buffer.from((await exportJWK(publicKey)).x!, 'base64url').toString('base64')
const enrolled = await t.h.call('POST', '/v1/device-sessions', {
body: { label: 'iPhone', platform: 'ios', publicKeyB64 },
bearer: await t.h.token('alice', 'acme', 'sandbox', { org_role: 'member' })
})
const sessionId = enrolled.body.deviceSession.id as string
const phoneToken = async () => {
const iat = Math.floor(t.h.now() / 1000)
return new SignJWT({}).setProtectedHeader({ alg: 'EdDSA', kid: sessionId }).setIssuer('orca-device').setSubject(sessionId)
.setAudience('orca-sandbox').setIssuedAt(iat).setExpirationTime(iat + 120).sign(privateKey)
}
const key = await t.register(t.alice, 'mobile', await phoneToken())
await t.sleep()
const res = await t.read(t.alice, key.id, await phoneToken())
expect(res.status).toBe(200)
expect((await t.open(res.body, key.secretKey)).sessions[0]!.messages[1]!.text).toBe(SECRET_LINE)
await t.h.call('DELETE', `/v1/device-sessions/${sessionId}`, { bearer: await t.h.token('alice', 'acme') })
expect(t.h.raw.prepare('SELECT revoked_by FROM reader_keys WHERE id = ?').get(key.id)).toEqual({ revoked_by: 'system:device_revoked' })
expect(t.h.raw.prepare('SELECT * FROM read_snapshot_keys WHERE key_id = ?').all(key.id)).toEqual([])
})
it('a failed capture keeps the previous snapshot and never blocks the sleep', async () => {
const t = await setup()
const key = await t.register(t.alice)
await t.sleep()
const first = (await t.read(t.alice, key.id)).body.snapshot.snapshotId
await t.req(t.alice, 'POST', `/v1/sandboxes/${t.id}/resume`)
// The VM script finds no orcad (exit 3).
expect((await t.sleep(t.alice, true)).status).toBe(200)
expect((await t.read(t.alice, key.id)).body.snapshot.snapshotId).toBe(first)
})
it('the VM sealer bounds what it seals and caps recipients', () => {
const big = {
v: 1,
sessions: Array.from({ length: 3 }, (_, i) => ({ sessionId: `s${i}`, messages: Array.from({ length: 60 }, (_, j) => ({ itemId: `${j}`, role: 'user', text: 'x'.repeat(4000), at: j })) })),
git: { changes: [] }
}
const fitted = vm.fitToBudget(big, 150_000)
expect(Buffer.byteLength(JSON.stringify(fitted))).toBeLessThanOrEqual(150_000)
expect(MAX_READ_RECIPIENTS).toBe(64)
// A malformed recipient is skipped, not fatal.
const out = vm.seal({ v: 1 }, [{ keyId: 'rk_bad', publicKeyB64: 'x' }, { keyId: 'rk_000000000000000000000000', publicKeyB64: generateGrantRecipientKey().publicKeyB64 }], 'sbx_0', 'rsn_0') as { recipients: unknown[] }
expect(out.recipients).toHaveLength(1)
})
})
@@ -233,7 +233,7 @@ describe('workspace grants (E2EE pairing through the control plane)', () => {
await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${t.id}`, { visibility: 'team' })
await t.req(t.bob, 'POST', `/v1/team/workspaces/${t.id}/grants`, { recipientPublicKeyB64: generateGrantRecipientKey().publicKeyB64, deviceLabel: 'b', deviceKind: 'desktop' })
const res = await t.h.call('POST', '/v1/internal/org-members/removed', { bearer: 'o'.repeat(40), body: { accountId: 'org:acme', userId: 'bob' } })
expect(res.body).toEqual({ revoked: 1, queued: 0, keysRevoked: 0, devicesRevoked: 0, grantsRevoked: 1, grantsQueued: 0, routinesDisabled: 0 })
expect(res.body).toEqual({ revoked: 1, queued: 0, keysRevoked: 0, devicesRevoked: 0, grantsRevoked: 1, grantsQueued: 0, routinesDisabled: 0, readerKeysRevoked: 0 })
})
})