mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): seal a read snapshot at drain; reader keys, read endpoint, revocation pruning
This commit is contained in:
@@ -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);
|
||||
@@ -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 } : {}) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user