feat(cloud): sign in once via your Orca account (contract D10/D11/D13/D14) — Claude setup-token store (Personal only, flag S1, kill switch), egress-time Source with one stable account placeholder per sandbox, never in shared workspaces; migration 0044

This commit is contained in:
Neil
2026-11-10 04:22:16 -08:00
parent 77af3b072a
commit a25934cf64
14 changed files with 698 additions and 29 deletions
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -48,7 +48,9 @@ skip_name() {
CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \
PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \
GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \
GH_TOKEN | GITHUB_TOKEN | CLAUDE_CODE_OAUTH_TOKEN) return 0 ;;
GH_TOKEN | GITHUB_TOKEN) return 0 ;;
# CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the
# Orca-account placeholder (contract D10), never a stored value.
esac
return 1
}
@@ -0,0 +1,19 @@
-- Account credentials for agents, Personal only (agent-sources contract D11): the Claude setup-token or the
-- Codex broker's tokens (JSON), envelope-encrypted like secrets (AAD: scope 'account', user id, agent). Values
-- never leave the Worker except swapped into a request at egress (D10). Flags S1 (Claude) / S2 (Codex).
CREATE TABLE agent_credentials (
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
agent TEXT NOT NULL CHECK (agent IN ('claude', 'codex')),
kek_id TEXT NOT NULL,
wrapped_dek TEXT NOT NULL,
ciphertext TEXT NOT NULL,
-- What the person sees ("ada@example.com"); never a token.
label TEXT,
expires_at INTEGER,
-- active | revoked (expired/expiring are derived from expires_at)
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'revoked')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, agent)
);
@@ -0,0 +1,45 @@
// "Via your Orca account" (agent-sources contract D10/D13): an agent signs in once, account-wide, and every one
// of the person's own cloud workspaces uses that account through an egress placeholder. The sandbox never holds
// the real credential. The proxy picks per request, so renewals, Source changes and the kill switch apply to
// the next request. One provider per agent: Claude here (A1 spike: `CLAUDE_CODE_OAUTH_TOKEN=<placeholder>`
// is accepted and sent as `Authorization: Bearer`), Codex in codex-credentials.ts.
import type { CredentialAgent } from './agent-credential-names'
import { readUsableAgentCredential } from './agent-credentials'
import type { Keyring } from './secrets-crypto'
import type { SandboxRow } from './store'
export type AccountProvider = {
agent: CredentialAgent
/** Egress hosts where the account placeholder is swapped (also allowlisted while the account applies). */
hosts: readonly string[]
/** Env the session gets while the account applies: placeholders only. */
sessionEnv(row: Pick<SandboxRow, 'id'>): Record<string, string>
/** Header the client sends the placeholder in. */
header: string
placeholder(row: Pick<SandboxRow, 'id'>): string
format(credential: string): string
/** The real value for this person, or null (none, expired, revoked). */
credential(db: D1Database, keyring: Keyring, userId: string, now: number): Promise<string | null>
}
/** D13: a workspace shared with teammates never uses a personal account. */
export function isSharedWorkspace(row: Pick<SandboxRow, 'visibility'>): boolean {
return row.visibility === 'team'
}
export const claudeAccount: AccountProvider = {
agent: 'claude',
// Messages plus the OAuth-side endpoints Claude Code calls with the same token (A1 spike).
hosts: ['api.anthropic.com'],
placeholder: (row) => `orca-ph-claude-account-${row.id}`,
sessionEnv: (row) => ({ CLAUDE_CODE_OAUTH_TOKEN: `orca-ph-claude-account-${row.id}` }),
header: 'authorization',
format: (credential) => `Bearer ${credential}`,
credential: (db, keyring, userId, now) => readUsableAgentCredential(db, keyring, userId, 'claude', now)
}
export const ACCOUNT_PROVIDERS: AccountProvider[] = [claudeAccount]
export function accountProvider(agent: CredentialAgent): AccountProvider | undefined {
return ACCOUNT_PROVIDERS.find((provider) => provider.agent === agent)
}
@@ -0,0 +1,168 @@
// Account credentials for agents (agent-sources contract D11): one per person per agent, Personal only.
// GET /v1/agent-credentials -> {claude, codex: {state, label?, expiresAt?}, providerEnabled}
// PUT /v1/agent-credentials/claude {token, label?, expiresAt?} (flag S1; desktop main runs `claude setup-token`)
// DELETE /v1/agent-credentials/:agent
// Values are never returned or logged. They leave the Worker only when the egress proxy swaps them into a
// request from the person's own, unshared workspace (D10, D13). The Codex broker (flag S2) stores its tokens
// through putAgentCredential too (codex-broker.ts).
import { z } from 'zod'
import type { CredentialAgent } from './agent-credential-names'
import type { SandboxCaller } from './auth'
import type { Env } from './env'
import type { Keyring, SecretBinding } from './secrets-crypto'
export type CredentialState = 'none' | 'active' | 'expiring' | 'expired' | 'revoked'
const EXPIRING_MS = 30 * 24 * 60 * 60_000
/** `claude setup-token` issues a one-year token. */
const CLAUDE_SETUP_TOKEN_TTL_MS = 365 * 24 * 60 * 60_000
type CredentialRow = {
user_id: string
account_id: string
agent: CredentialAgent
kek_id: string
wrapped_dek: string
ciphertext: string
label: string | null
expires_at: number | null
state: 'active' | 'revoked'
updated_at: number
}
export class AgentCredentialError extends Error {
constructor(
readonly status: 400 | 403 | 404 | 503,
readonly code: string,
readonly reason?: string
) {
super(reason ? `${code}:${reason}` : code)
}
}
const binding = (userId: string, agent: CredentialAgent): SecretBinding => ({ scope: 'account', ownerId: userId, name: agent })
/** Flags S1/S2 turn a provider on; the *_DISABLED switch turns it off again (kill switch, operator-only). */
export function providerEnabled(env: Pick<Env, 'AGENT_ACCOUNTS_CLAUDE' | 'AGENT_ACCOUNTS_CLAUDE_DISABLED' | 'AGENT_ACCOUNTS_CODEX' | 'AGENT_ACCOUNTS_CODEX_DISABLED'>): Record<CredentialAgent, boolean> {
return {
claude: env.AGENT_ACCOUNTS_CLAUDE === 'on' && env.AGENT_ACCOUNTS_CLAUDE_DISABLED !== '1',
codex: env.AGENT_ACCOUNTS_CODEX === 'on' && env.AGENT_ACCOUNTS_CODEX_DISABLED !== '1'
}
}
export function credentialState(row: Pick<CredentialRow, 'state' | 'expires_at'> | null, now: number): CredentialState {
if (!row) {
return 'none'
}
if (row.state === 'revoked') {
return 'revoked'
}
if (row.expires_at !== null && row.expires_at <= now) {
return 'expired'
}
if (row.expires_at !== null && row.expires_at - now <= EXPIRING_MS) {
return 'expiring'
}
return 'active'
}
/** Usable for a request now: active or expiring (still valid). */
export function isUsable(state: CredentialState): boolean {
return state === 'active' || state === 'expiring'
}
async function rowFor(db: D1Database, userId: string, agent: CredentialAgent): Promise<CredentialRow | null> {
return db.prepare('SELECT * FROM agent_credentials WHERE user_id = ? AND agent = ?').bind(userId, agent).first<CredentialRow>()
}
export async function agentCredentialState(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise<CredentialState> {
return credentialState(await rowFor(db, userId, agent), now)
}
export async function putAgentCredential(
db: D1Database,
keyring: Keyring,
input: { userId: string; accountId: string; agent: CredentialAgent; value: string; label?: string | null; expiresAt?: number | null },
now: number
): Promise<void> {
const sealed = await keyring.seal(binding(input.userId, input.agent), input.value)
await db
.prepare(
`INSERT INTO agent_credentials (user_id, account_id, agent, kek_id, wrapped_dek, ciphertext, label, expires_at, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
ON CONFLICT (user_id, agent) DO UPDATE SET account_id = excluded.account_id, kek_id = excluded.kek_id,
wrapped_dek = excluded.wrapped_dek, ciphertext = excluded.ciphertext, label = excluded.label,
expires_at = excluded.expires_at, state = 'active', updated_at = excluded.updated_at`
)
.bind(input.userId, input.accountId, input.agent, sealed.kekId, sealed.wrappedDek, sealed.ciphertext, input.label ?? null, input.expiresAt ?? null, now, now)
.run()
}
/** The real value for egress, or null when there is none or it can't be used now. */
export async function readUsableAgentCredential(db: D1Database, keyring: Keyring, userId: string, agent: CredentialAgent, now: number): Promise<string | null> {
const row = await rowFor(db, userId, agent)
if (!isUsable(credentialState(row, now)) || !row) {
return null
}
try {
return await keyring.open(binding(userId, agent), { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext })
} catch {
console.log(JSON.stringify({ event: 'agent_credential_unreadable', agent }))
return null
}
}
/** The provider said the credential is no longer valid (401 at egress): stop using it until renewed. */
export async function markAgentCredentialRevoked(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise<void> {
await db.prepare(`UPDATE agent_credentials SET state = 'revoked', updated_at = ? WHERE user_id = ? AND agent = ?`).bind(now, userId, agent).run()
}
export async function deleteAgentCredential(db: D1Database, userId: string, agent: CredentialAgent): Promise<boolean> {
const result = await db.prepare('DELETE FROM agent_credentials WHERE user_id = ? AND agent = ?').bind(userId, agent).run()
return result.meta.changes === 1
}
export async function agentCredentialsView(db: D1Database, env: Env, userId: string, now: number) {
const { results } = await db.prepare('SELECT * FROM agent_credentials WHERE user_id = ?').bind(userId).all<CredentialRow>()
const view = (agent: CredentialAgent) => {
const row = results.find((r) => r.agent === agent) ?? null
const state = credentialState(row, now)
return { state, ...(row?.label ? { label: row.label } : {}), ...(row?.expires_at ? { expiresAt: row.expires_at } : {}) }
}
return { claude: view('claude'), codex: view('codex'), providerEnabled: providerEnabled(env) }
}
// A Claude setup-token: `sk-ant-oat01-` then URL-safe characters. Shape-checked, never logged.
const CLAUDE_TOKEN = /^sk-ant-oat01-[A-Za-z0-9_-]{20,500}$/
export const PutClaudeSchema = z
.object({
token: z.string().max(600),
label: z.string().max(200).optional(),
expiresAt: z.number().int().positive().optional()
})
.strict()
export async function putClaudeCredential(db: D1Database, keyring: Keyring, env: Env, caller: SandboxCaller, body: z.infer<typeof PutClaudeSchema>, now: number) {
if (env.AGENT_ACCOUNTS_CLAUDE !== 'on') {
throw new AgentCredentialError(404, 'not_found')
}
const token = body.token.trim()
if (!CLAUDE_TOKEN.test(token)) {
throw new AgentCredentialError(400, 'invalid_request', 'invalid_token')
}
// Personal only: a person's account is never stored for a team (D13 keeps it out of shared workspaces too).
await putAgentCredential(
db,
keyring,
{
userId: caller.userId,
accountId: caller.accountId,
agent: 'claude',
value: token,
label: body.label?.trim() || caller.name || null,
expiresAt: Math.min(body.expiresAt ?? now + CLAUDE_SETUP_TOKEN_TTL_MS, now + CLAUDE_SETUP_TOKEN_TTL_MS)
},
now
)
// Names only, never the value.
console.log(JSON.stringify({ event: 'agent_credential_written', agent: 'claude', by: caller.userId }))
}
@@ -12,6 +12,8 @@ import type { SandboxCaller } from './auth'
import { memberNamesFor } from './member-names'
import { effectiveSecrets } from './secrets'
import { getSandbox, type SandboxRow } from './store'
import { agentCredentialState, isUsable } from './agent-credentials'
import { accountProvider, isSharedWorkspace } from './account-providers'
export type AgentSource = 'sign-in' | 'key'
type OfferAnswer = 'unanswered' | 'accepted' | 'declined'
@@ -97,7 +99,13 @@ async function ownedWorkspace(db: D1Database, caller: SandboxCaller, workspaceId
return row
}
export async function agentSourcesView(db: D1Database, caller: SandboxCaller, workspaceId: string | null) {
export async function agentSourcesView(
db: D1Database,
caller: SandboxCaller,
workspaceId: string | null,
enabled: Record<CredentialAgent, boolean> = { claude: false, codex: false },
now: number = Date.now()
) {
const row = workspaceId ? await ownedWorkspace(db, caller, workspaceId) : null
// The sandbox applies its owner's choice (shared sandboxes, v1): show the owner's, not the caller's.
const subject = row ? row.user_id : caller.userId
@@ -106,6 +114,11 @@ export async function agentSourcesView(db: D1Database, caller: SandboxCaller, wo
const workspace = row ? byAgent(rows, row.id) : null
const effective = await effectiveSecrets(db, caller, row?.repo_url ?? null)
const covered = coveredByKey(new Set(effective.map((secret) => secret.name)))
// v4: with no key covering it, a set-up Orca account is the default (no question asked).
const usable = {} as Record<CredentialAgent, boolean>
for (const agent of CREDENTIAL_AGENTS) {
usable[agent] = enabled[agent] && isUsable(await agentCredentialState(db, subject, agent, now))
}
const offerRows = rows.filter((r) => r.scope === OFFER_SCOPE)
const signInOffer = Object.fromEntries(
CREDENTIAL_AGENTS.map((agent) => [
@@ -122,7 +135,7 @@ export async function agentSourcesView(db: D1Database, caller: SandboxCaller, wo
if (defaults[agent]) {
return [agent, { source: defaults[agent], origin: 'default' }]
}
return [agent, { source: covered[agent] ? 'key' : null, origin: 'implicit' }]
return [agent, { source: covered[agent] ? 'key' : usable[agent] ? 'sign-in' : null, origin: 'implicit' }]
})
)
let owner: { userId: string; displayName: string | null; isCaller: boolean } | null = null
@@ -130,7 +143,9 @@ export async function agentSourcesView(db: D1Database, caller: SandboxCaller, wo
const names = await memberNamesFor(db, caller.accountId, [row.user_id])
owner = { userId: row.user_id, displayName: names.get(row.user_id) ?? null, isCaller: row.user_id === caller.userId }
}
return { defaults, workspace, effective: resolved, coveredByKey: covered, signInOffer, owner }
// D13: personal accounts never apply in a workspace shared with teammates.
const sharedWorkspace = row ? isSharedWorkspace(row) : false
return { defaults, workspace, effective: resolved, coveredByKey: covered, signInOffer, owner, sharedWorkspace }
}
async function upsert(db: D1Database, caller: SandboxCaller, scope: string, agent: CredentialAgent, source: string | null, now: number) {
@@ -181,3 +196,43 @@ export async function runningSandboxesForDefault(db: D1Database, userId: string,
export async function deleteAgentSourcesForSandbox(db: D1Database, sandboxId: string): Promise<void> {
await db.prepare('DELETE FROM agent_sources WHERE scope = ?').bind(sandboxId).run()
}
/**
* How each agent authenticates in this session (D10):
* - 'account': the owner's Orca-account credential through an egress placeholder (keys for that agent dropped);
* - 'omit': kill-switch fallback (Source sign-in, provider off): keys dropped, the agent's own login in the sandbox;
* - 'keep': API keys as resolved (precedence), the default.
* Source: explicit (workspace, then default), else implicit: a covering key wins, else a usable account.
*/
export type AgentMode = 'account' | 'omit' | 'keep'
export async function agentModesFor(
db: D1Database,
row: Pick<SandboxRow, 'id' | 'user_id' | 'visibility'>,
env: Readonly<Record<string, string>>,
options: { providerEnabled: Record<CredentialAgent, boolean>; accountsUsable: boolean; now: number }
): Promise<Record<CredentialAgent, AgentMode>> {
const rows = await rowsFor(db, row.user_id, [row.id, DEFAULT_SCOPE])
const workspace = byAgent(rows, row.id)
const defaults = byAgent(rows, DEFAULT_SCOPE)
const covered = coveredByKey(new Set(Object.keys(env)))
const modes = {} as Record<CredentialAgent, AgentMode>
for (const agent of CREDENTIAL_AGENTS) {
const explicit = workspace[agent] ?? defaults[agent]
const provider = accountProvider(agent)
const enabled = options.providerEnabled[agent] && provider !== undefined
const usable = enabled ? isUsable(await agentCredentialState(db, row.user_id, agent, options.now)) : false
const source = explicit ?? (covered[agent] ? 'key' : usable ? 'sign-in' : null)
if (source !== 'sign-in') {
modes[agent] = 'keep'
} else if (!enabled) {
// Kill switch (or no provider yet): the agent signs in inside the sandbox; keys stay out (Rule 3).
modes[agent] = 'omit'
} else if (usable && options.accountsUsable && !isSharedWorkspace(row)) {
modes[agent] = 'account'
} else {
// Expired/revoked, placeholders off, or a shared workspace (D13): the winning key, if any.
modes[agent] = 'keep'
}
}
return modes
}
+8
View File
@@ -90,6 +90,14 @@ export type Env = {
SECRETS_EGRESS_PLACEHOLDERS?: string
/** Per-team kill switch (comma account ids): those accounts get plain env injection. */
SECRETS_EGRESS_PLACEHOLDERS_OFF?: string
/** Flag S1 (`on`): Claude via the person's Orca account (setup-token store + egress swap, contract D10/D11). */
AGENT_ACCOUNTS_CLAUDE?: string
/** `1`: kill switch for S1 (agents fall back to keys, or sign in inside the sandbox). */
AGENT_ACCOUNTS_CLAUDE_DISABLED?: string
/** Flag S2 (`on`): Codex via the account broker. */
AGENT_ACCOUNTS_CODEX?: string
/** `1`: kill switch for S2. */
AGENT_ACCOUNTS_CODEX_DISABLED?: string
/** App slug for the install URL (github.com/apps/<slug>). */
GITHUB_APP_SLUG?: string
+155 -6
View File
@@ -92,9 +92,19 @@ import { GuardrailError, consumeCreateToken, guardrailsFromEnv } from './guardra
import { handleOperator, isOperator } from './operator'
import { DEFAULT_RETENTION, type RetentionConfig } from './retention'
import { reconcile } from './reconcile'
import { egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets'
import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh'
import { accountProvider, isSharedWorkspace } from './account-providers'
import {
AgentCredentialError,
PutClaudeSchema,
agentCredentialsView,
deleteAgentCredential,
providerEnabled,
putClaudeCredential,
readUsableAgentCredential
} from './agent-credentials'
import {
AgentSourceError,
PutOfferSchema,
@@ -291,6 +301,38 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) =>
}))
)
}
// D10/D13: re-decided per request, so a Source change, renewal, revocation, kill switch or the workspace
// becoming shared applies to the next request with no restart.
const accountRoutes = (row: SandboxRow, host: string): ProxiedRoute[] =>
egressAccountRoutes(row)
.filter((route) => route.host === host)
.flatMap(({ agent }) => {
const provider = accountProvider(agent)
const keyring = keyringFor(env)
if (!provider || !keyring) {
return []
}
return [
{
header: provider.header,
placeholders: [provider.format(provider.placeholder(row))],
label: `account:${agent}`,
credential: async () => {
const now = (deps.now ?? Date.now)()
if (!providerEnabled(env)[agent] || isSharedWorkspace(row)) {
// Refused rather than silently billed elsewhere; the env refresh below moves the session to its key.
scheduleEnvRefresh(env, deps, row)
return null
}
const value = await cachedAccountCredential(env.SANDBOX_DB, await keyring, row.user_id, agent, now)
if (!value) {
scheduleEnvRefresh(env, deps, row)
}
return value ? provider.format(value) : null
}
}
]
})
return async (row, host) => {
if (host === 'github.com') {
return members.routes(row, host)
@@ -304,7 +346,103 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) =>
...secretRoutes(row, host)
]
}
return secretRoutes(row, host)
return [...accountRoutes(row, host), ...secretRoutes(row, host)]
}
}
// Account credentials per person, decrypted at most once a minute per isolate; an edit or delete drops it.
const ACCOUNT_CACHE_MS = 60_000
const accountCache = new Map<string, { at: number; value: Promise<string | null> }>()
function cachedAccountCredential(db: D1Database, keyring: Keyring, userId: string, agent: 'claude' | 'codex', now: number): Promise<string | null> {
const key = `${userId}:${agent}`
const hit = accountCache.get(key)
if (hit && now - hit.at < ACCOUNT_CACHE_MS) {
return hit.value
}
const value = readUsableAgentCredential(db, keyring, userId, agent, now)
accountCache.set(key, { at: now, value })
value.catch(() => accountCache.delete(key))
return value
}
export function forgetAccountCredential(userId: string, agent: 'claude' | 'codex'): void {
accountCache.delete(`${userId}:${agent}`)
}
/** Background env refresh for one sandbox (proxy-side self-healing when its account route stops applying). */
const lastHealRefresh = new Map<string, number>()
function scheduleEnvRefresh(env: Env, deps: Deps, row: SandboxRow): void {
const now = (deps.now ?? Date.now)()
if (now - (lastHealRefresh.get(row.id) ?? 0) < 60_000) {
return
}
lastHealRefresh.set(row.id, now)
const work = lifecycleDeps(env, deps).then((life) => (life ? refreshSandboxEnv(life, row) : false))
if (deps.waitUntil) {
deps.waitUntil(work.catch(() => undefined))
} else {
void work.catch(() => undefined)
}
}
/** Running sandboxes of this person whose session env depends on their account credential. */
async function refreshOwnersSandboxes(env: Env, deps: Deps, userId: string): Promise<void> {
const { results } = await env.SANDBOX_DB.prepare(`SELECT * FROM sandboxes WHERE user_id = ? AND status = 'running' LIMIT 200`).bind(userId).all<SandboxRow>()
if (results.length === 0) {
return
}
const work = lifecycleDeps(env, deps).then(async (life) => {
if (life) {
for (const row of results) {
await refreshSandboxEnv(life, row).catch(() => false)
}
}
})
if (deps.waitUntil) {
deps.waitUntil(work.catch(() => undefined))
} else {
await work
}
}
async function agentCredentialsRoute(request: Request, url: URL, env: Env, deps: Deps, caller: SandboxCaller): Promise<Response> {
const db = env.SANDBOX_DB
const now = (deps.now ?? Date.now)()
const noStore = (body: unknown) => Response.json(body, { headers: { 'cache-control': 'no-store' } })
const keyring = keyringFor(env)
if (!keyring) {
return fail('not_configured', 503)
}
try {
if (request.method === 'GET' && url.pathname === '/v1/agent-credentials') {
return noStore(await agentCredentialsView(db, env, caller.userId, now))
}
if (request.method === 'PUT' && url.pathname === '/v1/agent-credentials/claude') {
const body = PutClaudeSchema.safeParse(await readJson(request))
if (!body.success) {
return fail('invalid_request', 400)
}
await putClaudeCredential(db, await keyring, env, caller, body.data, now)
forgetAccountCredential(caller.userId, 'claude')
await refreshOwnersSandboxes(env, deps, caller.userId)
return noStore(await agentCredentialsView(db, env, caller.userId, now))
}
const del = /^\/v1\/agent-credentials\/(claude|codex)$/.exec(url.pathname)
if (request.method === 'DELETE' && del) {
const agent = del[1] as 'claude' | 'codex'
if (!(await deleteAgentCredential(db, caller.userId, agent))) {
return fail('not_found', 404)
}
forgetAccountCredential(caller.userId, agent)
console.log(JSON.stringify({ event: 'agent_credential_deleted', agent, by: caller.userId }))
await refreshOwnersSandboxes(env, deps, caller.userId)
return noStore(await agentCredentialsView(db, env, caller.userId, now))
}
return fail('not_found', 404)
} catch (error) {
if (error instanceof AgentCredentialError) {
return fail(error.code, error.status, error.reason)
}
throw error
}
}
@@ -765,7 +903,7 @@ async function secretsDeps(env: Env): Promise<LifecycleDeps['secrets']> {
return undefined
}
try {
return { keyring: await keyring, ...secretsEgressMode(env) }
return { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) }
} catch {
// A malformed KEK must not block sandbox starts; the secrets routes surface it as 503.
console.log(JSON.stringify({ event: 'secrets_kek_invalid' }))
@@ -1088,6 +1226,17 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
}
return response
}
if (url.pathname === '/v1/agent-credentials' || url.pathname.startsWith('/v1/agent-credentials/')) {
const caller = await authenticateDesktop(env, request, (deps.now ?? Date.now)())
if (!caller) {
return fail('unauthorized', 401)
}
// Desktop main only: never a phone's device session (and the renderer never holds the token).
if (caller.deviceSessionId) {
return fail('forbidden', 403, 'device_session')
}
return await agentCredentialsRoute(request, url, env, deps, caller)
}
if (url.pathname === '/v1/agent-sources' || url.pathname === '/v1/agent-sources/offer') {
const caller = await authenticateDesktop(env, request, (deps.now ?? Date.now)())
if (!caller) {
@@ -1713,7 +1862,7 @@ async function agentSourcesRoute(request: Request, url: URL, env: Env, deps: Dep
const noStore = (body: unknown) => Response.json(body, { headers: { 'cache-control': 'no-store' } })
try {
if (request.method === 'GET' && url.pathname === '/v1/agent-sources') {
return noStore(await agentSourcesView(db, caller, url.searchParams.get('workspaceId')))
return noStore(await agentSourcesView(db, caller, url.searchParams.get('workspaceId'), providerEnabled(env), now))
}
if (request.method === 'PUT' && url.pathname === '/v1/agent-sources/offer') {
const body = PutOfferSchema.safeParse(await readJson(request))
@@ -1721,7 +1870,7 @@ async function agentSourcesRoute(request: Request, url: URL, env: Env, deps: Dep
return fail('invalid_request', 400)
}
await putOfferAnswer(db, caller, body.data, now)
return noStore(await agentSourcesView(db, caller, null))
return noStore(await agentSourcesView(db, caller, null, providerEnabled(env), now))
}
if (request.method === 'PUT' && url.pathname === '/v1/agent-sources') {
const body = PutSourceSchema.safeParse(await readJson(request))
@@ -1745,7 +1894,7 @@ async function agentSourcesRoute(request: Request, url: URL, env: Env, deps: Dep
await work
}
}
return noStore(await agentSourcesView(db, caller, body.data.workspaceId ?? null))
return noStore(await agentSourcesView(db, caller, body.data.workspaceId ?? null, providerEnabled(env), now))
}
return fail('not_found', 404)
} catch (error) {
+11 -1
View File
@@ -1,5 +1,6 @@
// Sandbox lifecycle: one persistent provider sandbox per Orca workspace, with an Orca-owned lease,
// a forced stop before the provider's 24h cap, usage metering and write-ahead rows.
import { accountProvider } from './account-providers'
import { deleteAgentSourcesForSandbox } from './agent-sources'
import type { EntitlementClient, MachineSize } from './entitlements'
import { parseGitHubRepo, type GitHubAppMinter } from './github-app'
@@ -18,7 +19,7 @@ import { memberPlaceholder, type MemberGitIdentity } from './github-user-tokens'
import { connectedMembers, memberGitConfigs } from './member-git'
import type { RetentionConfig } from './retention'
import type { ProviderSandboxState, SandboxProviderApi } from './sandbox-provider'
import { egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env'
import type { Keyring } from './secrets-crypto'
import { captureReadSnapshot, deleteReadSnapshot } from './read-snapshots'
@@ -71,6 +72,8 @@ export type LifecycleDeps = {
keyring: Keyring
egressPlaceholders: boolean | ReadonlySet<string>
egressPlaceholdersOff?: ReadonlySet<string>
/** Flags S1/S2 minus the kill switch: which agents may use an Orca-account credential (D11). */
accountProviders?: Record<'claude' | 'codex', boolean>
}
now: () => number
/** A member's GitHub identity (user token) for git-as-sender; absent = app identity only. */
@@ -234,6 +237,13 @@ export function egressPolicy(deps: LifecycleDeps, row: SandboxRow, gitToken?: st
},
// Model API keys swapped at egress (phase 2); each host also ends in the 403 deny rule, except a
// host whose client sends its own short-lived token on later calls (Cursor).
// An agent's Orca-account placeholder (D10): forwarded, claimed only by that exact Bearer value.
...egressAccountRoutes(row).flatMap(({ agent, host }): CredentialRoute[] => {
const provider = accountProvider(agent)
return provider
? [{ host, header: provider.header, placeholders: [provider.format(provider.placeholder(row))], via: 'forward', forwardURL: proxyUrl }]
: []
}),
...egressSecretHosts(row).flatMap((entry) =>
Object.keys(entry.headers).map(
(header): CredentialRoute => ({
@@ -69,3 +69,15 @@ export function applySecretPlaceholders(
}
return { env: next, targets }
}
/** `account:<agent>@<host>` entries: hosts where an agent's Orca-account placeholder is swapped (D10). */
export function egressAccountRoutes(row: Pick<SandboxRow, 'egress_secret_hosts'>): { agent: 'claude' | 'codex'; host: string }[] {
const out: { agent: 'claude' | 'codex'; host: string }[] = []
for (const entry of (row.egress_secret_hosts ?? '').split(',')) {
const match = /^account:(claude|codex)@([a-z0-9.-]+)$/.exec(entry)
if (match) {
out.push({ agent: match[1] as 'claude' | 'codex', host: match[2]! })
}
}
return out
}
+40 -15
View File
@@ -2,7 +2,9 @@
// presets filled in, and with egress placeholders on, model keys swapped for placeholders. The same
// answer drives the egress allowlist and the proxy's credential routes, at boot and on refresh.
import type { LifecycleDeps } from './lifecycle'
import { signInAgentsFor, withoutSignInCredentials } from './agent-sources'
import { agentModesFor, withoutSignInCredentials, type AgentMode } from './agent-sources'
import { accountProvider } from './account-providers'
import type { CredentialAgent } from './agent-credential-names'
import { deriveProviderEnv, deriveProviderScopes, providerHosts } from './provider-env'
import { applySecretPlaceholders, encodeEgressSecretHosts } from './secret-egress'
import { resolveSandboxSecrets } from './secrets'
@@ -27,11 +29,10 @@ export async function resolvedProviderEnv(
keyring: Keyring,
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
): Promise<ProviderEnv> {
const [{ env: stored, scopes }, signIn] = await Promise.all([
resolveSandboxSecrets(db, keyring, row),
signInAgentsFor(db, row)
])
const env = withoutSignInCredentials(deriveProviderEnv(stored), signIn)
const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row)
// The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time
// through the row's recorded routes (an 'account' agent has no key route left).
const env = deriveProviderEnv(stored)
return { env, scopes: deriveProviderScopes(stored, env, scopes) }
}
@@ -66,6 +67,17 @@ export function forgetProviderEnv(sandboxId: string): void {
}
}
function egressSwapOn(deps: Pick<LifecycleDeps, 'secrets' | 'config'>, row: Pick<SandboxRow, 'account_id'>): boolean {
const placeholders = deps.secrets?.egressPlaceholders
return (
!deps.secrets?.egressPlaceholdersOff?.has(row.account_id) &&
(placeholders === true || (placeholders instanceof Set && placeholders.has(row.account_id))) &&
Boolean(deps.config.egressProxyUrl)
)
}
export type { AgentMode }
export async function sessionSecretEnv(
deps: Pick<LifecycleDeps, 'db' | 'secrets' | 'config'>,
row: SandboxRow
@@ -74,22 +86,35 @@ export async function sessionSecretEnv(
return EMPTY
}
const { env: stored, scopes: storedScopes, skipped } = await resolveSandboxSecrets(deps.db, deps.secrets.keyring, row)
// Agents on "Your sign-in" (owner's choice): their credentials never reach this session.
const signIn = await signInAgentsFor(deps.db, row)
const env = withoutSignInCredentials(deriveProviderEnv(stored), signIn)
const swap = egressSwapOn(deps, row)
const derived = deriveProviderEnv(stored)
// D10: per agent, the owner's account (placeholder), the kill-switch fallback, or the keys.
const modes = await agentModesFor(deps.db, row, derived, {
providerEnabled: deps.secrets.accountProviders ?? { claude: false, codex: false },
accountsUsable: swap,
now: Date.now()
})
const dropped = new Set((Object.keys(modes) as CredentialAgent[]).filter((agent) => modes[agent] !== 'keep'))
const env = withoutSignInCredentials(derived, dropped)
const scopes = deriveProviderScopes(stored, env, storedScopes)
const egressAllowHosts = providerHosts(env).join(',') || null
// Counts only.
console.log(JSON.stringify({ event: 'session_secrets', sandboxId: row.id, injected: Object.keys(env).length, skipped }))
const placeholders = deps.secrets.egressPlaceholders
const swap =
!deps.secrets.egressPlaceholdersOff?.has(row.account_id) &&
(placeholders === true || (placeholders instanceof Set && placeholders.has(row.account_id)))
if (!swap || !deps.config.egressProxyUrl) {
if (!swap) {
return { env, egressSecretHosts: null, egressAllowHosts }
}
const swapped = applySecretPlaceholders(row, env, scopes)
return { env: swapped.env, egressSecretHosts: encodeEgressSecretHosts(swapped.targets), egressAllowHosts }
const accountEnv: Record<string, string> = {}
const accountRoutes: string[] = []
for (const agent of Object.keys(modes) as CredentialAgent[]) {
const provider = modes[agent] === 'account' ? accountProvider(agent) : undefined
if (provider) {
Object.assign(accountEnv, provider.sessionEnv(row))
accountRoutes.push(...provider.hosts.map((host) => `account:${agent}@${host}`))
}
}
const secretHosts = [encodeEgressSecretHosts(swapped.targets), ...accountRoutes].filter(Boolean).join(',') || null
return { env: { ...swapped.env, ...accountEnv }, egressSecretHosts: secretHosts, egressAllowHosts }
}
/** Provider hosts from the session's secrets plus the workspace's declared tool hosts (MCP). */
@@ -0,0 +1,173 @@
import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ENV_COMMAND, START_COMMAND } from '../src/sandbox-vm-commands'
import { createHarness } from './harness'
const KEK = Buffer.alloc(32, 7).toString('base64')
const PROXY_URL = 'https://sandbox.test/v1/egress/proxy'
const SETUP_TOKEN = 'sk-ant-oat01-FAKEsetupTOKEN_for_tests_only_000000000000'
const TEAM_KEY = 'sk-ant-api03-TEAM-fake-0000-aaaa'
let logs: string[] = []
beforeEach(() => {
logs = []
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '))
})
}
})
afterEach(() => {
for (const value of [SETUP_TOKEN, TEAM_KEY]) {
expect(logs.join('\n')).not.toContain(value)
}
vi.restoreAllMocks()
})
function decode(encoded: string | undefined): Record<string, string> {
return Object.fromEntries(
(encoded ?? '')
.split('\n')
.filter(Boolean)
.map((line) => {
const at = line.indexOf('=')
return [line.slice(0, at), Buffer.from(line.slice(at + 1), 'base64').toString('utf8')]
})
)
}
async function setup(overrides: Record<string, string> = {}) {
const { privateKey, publicKey } = await generateKeyPair('RS256')
const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] }
const upstream: { url: string; headers: Headers }[] = []
const h = await createHarness(
{ SECRETS_KEK: KEK, EGRESS_PROXY_URL: PROXY_URL, SECRETS_EGRESS_PLACEHOLDERS: 'on', AGENT_ACCOUNTS_CLAUDE: 'on', ...overrides },
{
egressKeysForIssuer: () => createLocalJWKSet(jwks),
credentials: { authorizationFor: async () => null },
egressFetch: async (input, init) => {
upstream.push({ url: String(input), headers: new Headers(init?.headers) })
return new Response('{}', { status: 200 })
}
}
)
const as = (sub: string, org?: string, role?: string) => h.token(sub, org, 'sandbox', role ? { orgRole: role } : {})
const lastEnv = (command: string) => decode(h.vercel.commands.filter((c) => c.command === command).at(-1)?.env.ORCA_SECRET_ENV)
async function proxied(sandboxId: string, host: string, headers: Record<string, string>) {
const oidc = await new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_name: `orca-test-${sandboxId}` })
.setProtectedHeader({ alg: 'RS256', kid: 'v1' })
.setIssuer('https://oidc.vercel.com/team_orca')
.setAudience(PROXY_URL)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey)
const response = await h.send(
new Request(`${PROXY_URL}/v1/messages`, {
method: 'POST',
body: '{}',
headers: {
'vercel-forwarded-host': host,
'vercel-forwarded-scheme': 'https',
'vercel-forwarded-port': '443',
'vercel-forwarded-path': '/v1/messages',
'vercel-sandbox-oidc-token': oidc,
...headers
}
})
)
return { status: response.status, body: await response.text() }
}
return { h, as, lastEnv, proxied, upstream }
}
describe('Orca-account credentials (D11)', () => {
it('stores a Claude setup-token Personal-only and never returns or logs it; flag off = no route', async () => {
const off = await setup({ AGENT_ACCOUNTS_CLAUDE: '' })
const mia0 = await off.as('mia')
expect((await off.h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia0, body: { token: SETUP_TOKEN } })).status).toBe(404)
const { h, as } = await setup()
const mia = await as('mia')
expect((await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: 'sk-ant-api03-not-a-setup-token' } })).body.reason).toBe('invalid_token')
const put = await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN, label: 'mia@example.com' } })
expect(put.status).toBe(200)
expect(put.body.claude).toMatchObject({ state: 'active', label: 'mia@example.com' })
expect(put.body.providerEnabled).toEqual({ claude: true, codex: false })
expect(JSON.stringify(put.body)).not.toContain(SETUP_TOKEN)
// Someone else sees nothing of Mia's.
expect((await h.call('GET', '/v1/agent-credentials', { bearer: await as('bob') })).body.claude.state).toBe('none')
expect((await h.call('DELETE', '/v1/agent-credentials/claude', { bearer: mia })).body.claude.state).toBe('none')
})
})
describe('egress-time Source (D10) and shared workspaces (D13)', () => {
it('a set-up account is the implicit Source with no key: placeholder in the VM, real token only at egress', async () => {
const { h, as, lastEnv, proxied, upstream } = await setup()
const mia = await as('mia')
await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } })
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia })
const id: string = created.body.sandbox.id
const env = lastEnv(START_COMMAND)
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe(`orca-ph-claude-account-${id}`)
expect(JSON.stringify(env)).not.toContain(SETUP_TOKEN)
const box = [...h.vercel.boxes.values()][0]!
expect(box.policy.allow['api.anthropic.com']!.at(-1)).toEqual({ response: { statusCode: 403, body: 'orca-egress-denied', contentType: 'text/plain' } })
expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(200)
expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${SETUP_TOKEN}`)
const view = await h.call('GET', `/v1/agent-sources?workspaceId=${id}`, { bearer: mia })
expect(view.body.effective.claude).toEqual({ source: 'sign-in', origin: 'implicit' })
expect(view.body.sharedWorkspace).toBe(false)
})
it('a team key wins by default; choosing "Your account" swaps the key out live; deleting the account falls back', async () => {
const { h, as, lastEnv, proxied, upstream } = await setup()
const ada = await as('ada', 'acme', 'admin')
const mia = await as('mia', 'acme', 'member')
await h.call('PUT', '/v1/secrets/org/ANTHROPIC_API_KEY', { bearer: ada, body: { value: TEAM_KEY } })
await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } })
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia })
const id: string = created.body.sandbox.id
expect(lastEnv(START_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(lastEnv(START_COMMAND).ANTHROPIC_API_KEY).toMatch(/^orca-ph-anthropic-api-key-/)
await h.call('PUT', '/v1/agent-sources', { bearer: mia, body: { agent: 'claude', source: 'sign-in' } })
let env = lastEnv(ENV_COMMAND)
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe(`orca-ph-claude-account-${id}`)
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(200)
expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${SETUP_TOKEN}`)
await h.call('DELETE', '/v1/agent-credentials/claude', { bearer: mia })
// The proxy refuses at once; the session moves back to the team key.
expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(403)
env = lastEnv(ENV_COMMAND)
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toMatch(/^orca-ph-anthropic-api-key-/)
})
it('never uses a personal account in a workspace shared with teammates', async () => {
const { h, as, lastEnv, proxied } = await setup()
const mia = await as('mia', 'acme', 'member')
await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } })
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia })
const id: string = created.body.sandbox.id
expect(lastEnv(START_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBe(`orca-ph-claude-account-${id}`)
// The workspace becomes shared: the very next request is refused, and the session refreshes off the account.
await h.env.SANDBOX_DB.prepare(`UPDATE sandboxes SET visibility = 'team' WHERE id = ?`).bind(id).run()
const refused = await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })
expect(refused.status).toBe(403)
expect(lastEnv(ENV_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect((await h.call('GET', `/v1/agent-sources?workspaceId=${id}`, { bearer: mia })).body.sharedWorkspace).toBe(true)
})
it('kill switch: Source "sign-in" falls back to the agent signing in inside the sandbox (no key, no placeholder)', async () => {
const { h, as, lastEnv } = await setup({ AGENT_ACCOUNTS_CLAUDE_DISABLED: '1' })
const ada = await as('ada', 'acme', 'admin')
const mia = await as('mia', 'acme', 'member')
await h.call('PUT', '/v1/secrets/org/ANTHROPIC_API_KEY', { bearer: ada, body: { value: TEAM_KEY } })
await h.call('PUT', '/v1/agent-sources', { bearer: mia, body: { agent: 'claude', source: 'sign-in' } })
await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia })
const env = lastEnv(START_COMMAND)
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect((await h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.providerEnabled.claude).toBe(false)
})
})
@@ -118,12 +118,13 @@ describe('orca-env', () => {
it("never sets or unsets the control plane's reserved names and prefixes (kept in sync with secrets.ts)", () => {
const source = readFileSync(fileURLToPath(new URL('../src/secrets.ts', import.meta.url).href), 'utf8')
const quoted = (text: string | undefined): string[] => [...(text ?? '').matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1])
const quoted = (text: string | undefined): string[] => [...(text ?? '').matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1] ?? '')
const names = quoted(source.match(/const RESERVED_NAMES[^=]*= new Set\(\[([\s\S]*?)\]\)/)?.[1])
const prefixes = quoted(source.match(/const RESERVED_PREFIXES = \[([^\]]*)\]/)?.[1])
expect(names.length).toBeGreaterThan(30)
expect(prefixes.length).toBeGreaterThan(3)
const blocked = [...names, ...prefixes.map((prefix) => `${prefix}X`)]
// The control plane's own account placeholder rides this name (D10); users can't store it.
const blocked = [...names.filter((name) => name !== 'CLAUDE_CODE_OAUTH_TOKEN'), ...prefixes.map((prefix) => `${prefix}X`)]
writeSessionEnv({ GOOD: 'x', ...Object.fromEntries(blocked.map((name) => [name, 'evil'])) })
const out = orcaEnv([], blocked.join(' '))
expect(out).toBe("export GOOD='x'\nexport ORCA_SESSION_ENV_NAMES='GOOD'\n")
@@ -38,6 +38,8 @@
"triggers": { "crons": ["* * * * *"] },
"vars": {
"SANDBOX_ENV": "dev",
// Flag S1 (contract D10/D11): Claude via the person's Orca account. Dev only until Neil's S1 sign-off.
"AGENT_ACCOUNTS_CLAUDE": "on",
"SANDBOX_POOL_SIZE": "2",
// Dev only (ignored unless SANDBOX_ENV is dev): these dev accounts hit the real billing gate.
"SANDBOX_DEV_BILLING_ENFORCE_PREFIX": "dev:billing-test-",