mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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-",
|
||||
|
||||
Reference in New Issue
Block a user