Merge pull request #203 from nwparker/cloud/agents-gaps2

feat(cloud): swap COPILOT_GITHUB_TOKEN and GOOGLE_API_KEY at egress; routines run all six image agents; AWS keys marked stored on workspace
This commit is contained in:
Neil
2026-11-10 02:16:56 -08:00
committed by GitHub
22 changed files with 422 additions and 83 deletions
File diff suppressed because one or more lines are too long
@@ -44,7 +44,8 @@ while IFS= read -r line; do
OPENAI_MODEL) openai_model="$(printf '%s' "$b64" | base64 -d)" ;;
ANTHROPIC_MODEL) anthropic_model="$(printf '%s' "$b64" | base64 -d)" ;;
AI_GATEWAY_API_KEY) gateway_key=1 ;;
GEMINI_API_KEY) gemini_key=1 ;;
GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key="${gemini_key:-gemini-api-key}" ;;
GOOGLE_GENAI_USE_VERTEXAI) case "$(printf '%s' "$b64" | base64 -d)" in true | 1) gemini_key=vertex-ai ;; esac ;;
# Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.
ANTHROPIC_API_KEY) anthropic_key_tail="$(printf '%s' "$b64" | base64 -d | tail -c 20)" ;;
esac
@@ -160,7 +161,9 @@ if (isObject(g)) {
general.disableAutoUpdate ??= true
general.disableUpdateNag ??= true
section(g, "privacy").usageStatisticsEnabled ??= false
if (process.env.ORCA_GEMINI_KEY) section(section(g, "security"), "auth").selectedType ??= "gemini-api-key"
// gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs
// refuse to start without a method ("Invalid auth method selected").
if (process.env.ORCA_GEMINI_KEY) section(section(g, "security"), "auth").selectedType ??= process.env.ORCA_GEMINI_KEY
if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)
}
+29 -18
View File
@@ -89,12 +89,12 @@ import { GuardrailError, consumeCreateToken, guardrailsFromEnv } from './guardra
import { handleOperator, isOperator } from './operator'
import { DEFAULT_RETENTION, type RetentionConfig } from './retention'
import { reconcile } from './reconcile'
import { egressSecretHosts, secretPlaceholder } from './secret-egress'
import { egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets'
import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh'
import { EgressHostsSchema, egressUserHosts, normalizeUserHosts, setEgressUserHosts } from './egress-hosts'
import { cachedProviderEnv } from './session-env'
import { credentialTargets } from './provider-env'
import { credentialTargets, type CredentialTarget } from './provider-env'
import type { Keyring } from './secrets-crypto'
import { ProviderError, type SandboxProviderApi } from './sandbox-provider'
import { DesktopCommandError, VmCommandError, startDesktop } from './sandbox-vm-commands'
@@ -250,17 +250,7 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) =>
: null
}
})
return async (row, host) => {
if (host === 'github.com') {
return members.routes(row, host)
}
if (host === 'api.github.com') {
return [
// Members first: a member placeholder must never fall through to the app's token.
...(await members.routes(row, host)),
{ header: 'authorization', placeholders: githubPlaceholderHeaders(row), credential: () => broker.authorizationFor(row, host) }
]
}
const secretRoutes = (row: SandboxRow, host: string): ProxiedRoute[] => {
const entries = egressSecretHosts(row).filter((candidate) => candidate.host === host)
const keyring = keyringFor(env)
if (entries.length === 0 || !keyring) {
@@ -269,7 +259,7 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) =>
return entries.flatMap((entry) =>
Object.entries(entry.headers).map(([header, format]): ProxiedRoute => ({
header,
placeholders: [format(secretPlaceholder(row, entry.secret))],
placeholders: placeholderValues(entry, header, secretPlaceholder(row, entry.secret)),
label: entry.secret,
...(entry.paths ? { pathAllowed: requestMatches(entry.paths) } : {}),
...(entry.keepResponseFields ? { keepResponseFields: entry.keepResponseFields } : {}),
@@ -286,13 +276,34 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) =>
}))
)
}
return async (row, host) => {
if (host === 'github.com') {
return members.routes(row, host)
}
if (host === 'api.github.com') {
return [
// Members first: a member placeholder must never fall through to the app's token.
...(await members.routes(row, host)),
{ header: 'authorization', placeholders: githubPlaceholderHeaders(row), credential: () => broker.authorizationFor(row, host) },
// A user's own GitHub token for Copilot CLI (COPILOT_GITHUB_TOKEN), on its own placeholder.
...secretRoutes(row, host)
]
}
return secretRoutes(row, host)
}
}
/** Exact method and path (the query string is ignored). */
function requestMatches(allowed: readonly { method: string; path: string }[]): (method: string, path: string) => boolean {
/** Method and path (the query string is ignored); a prefix entry matches below a normalized path only. */
function requestMatches(allowed: NonNullable<CredentialTarget['paths']>): (method: string, path: string) => boolean {
return (method, path) => {
const pathname = path.split('?')[0]
return allowed.some((entry) => entry.method === method.toUpperCase() && entry.path === pathname)
const pathname = path.split('?')[0] ?? ''
// A prefix grant never stretches across encoded or dot segments into another API.
const plain = /^(\/[A-Za-z0-9._~-]+)+\/?$/.test(pathname) && !/(^|\/)\.\.?(\/|$)/.test(pathname)
return allowed.some(
(entry) =>
entry.method === method.toUpperCase() &&
(entry.prefix ? plain && pathname.startsWith(entry.path) : entry.path === pathname)
)
}
}
+4 -4
View File
@@ -17,7 +17,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, secretPlaceholder } from './secret-egress'
import { egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env'
import type { Keyring } from './secrets-crypto'
import { ORCAD_PORT, SHARE_PROXY_PORT, drainOrcad, runningOrcad, startOrcad, type OrcadReady, type StartInputs } from './sandbox-vm-commands'
@@ -232,11 +232,11 @@ 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).
...egressSecretHosts(row).flatMap((entry) =>
Object.entries(entry.headers).map(
([header, format]): CredentialRoute => ({
Object.keys(entry.headers).map(
(header): CredentialRoute => ({
host: entry.host,
header,
placeholders: [format(secretPlaceholder(row, entry.secret))],
placeholders: placeholderValues(entry, header, secretPlaceholder(row, entry.secret)),
via: 'forward',
forwardURL: proxyUrl,
...(entry.allowUnclaimed ? { allowUnclaimed: true } : {})
@@ -80,6 +80,8 @@ export const DEFAULT_EGRESS_ALLOWLIST: readonly string[] = [
'accounts.google.com',
// Vercel AI Gateway: Anthropic/OpenAI-compatible endpoint teams point agents at (ANTHROPIC_BASE_URL).
'ai-gateway.vercel.sh',
// Vertex AI express mode (GOOGLE_API_KEY with GOOGLE_GENAI_USE_VERTEXAI=true).
'aiplatform.googleapis.com',
// Gemini CLI signed in with Google: Code Assist API and the account lookup after OAuth.
'cloudcode-pa.googleapis.com',
'www.googleapis.com',
+76 -23
View File
@@ -8,6 +8,14 @@ const AI_GATEWAY_OPENAI_BASE_URL = `https://${AI_GATEWAY_HOST}/v1`
export const GEMINI_HOST = 'generativelanguage.googleapis.com'
export const CURSOR_API_HOST = 'api2.cursor.sh'
export const VERTEX_HOST = 'aiplatform.googleapis.com'
/** Copilot's API, per plan; which one a token uses comes from GitHub at sign-in. */
export const COPILOT_API_HOSTS = [
'api.githubcopilot.com',
'api.individual.githubcopilot.com',
'api.business.githubcopilot.com',
'api.enterprise.githubcopilot.com'
] as const
export const BASE_URL_NAMES: ReadonlySet<string> = new Set(['ANTHROPIC_BASE_URL', 'OPENAI_BASE_URL', 'COPILOT_PROVIDER_BASE_URL'])
const HOST = /^([a-z0-9-]+\.)+[a-z]{2,63}$/
@@ -81,8 +89,8 @@ export type CredentialTarget = {
} & CredentialPolicy
export type CredentialPolicy = {
/** Only these requests may carry the key (exact method, path up to an optional query). Absent: any. */
paths?: readonly { method: string; path: string }[]
/** Only these requests may carry the key (method, then the path up to its query: exact, or a prefix). Absent: any. */
paths?: readonly { method: string; path: string; prefix?: true }[]
/**
* The host stays reachable without the placeholder. Cursor trades the key for a short-lived access
* token and sends that on every later call, so a 403 for unclaimed requests would break it.
@@ -93,6 +101,8 @@ export type CredentialPolicy = {
* headers that set state either); anything else is refused.
*/
keepResponseFields?: readonly string[]
/** More ways the client may send the key in a header (gh-style `token <value>`). */
altHeaderFormats?: Record<string, readonly ((value: string) => string)[]>
}
const bearer = (value: string) => `Bearer ${value}`
@@ -102,11 +112,15 @@ const raw = (value: string) => value
export type ValueScopes = Readonly<Record<string, string>>
type CredentialSpec = CredentialPolicy & {
/** Hosts (of the default ones) that stay open without the placeholder. */
allowUnclaimedHosts?: readonly string[]
/** Per-host request limits, over `paths`. */
pathsByHost?: Readonly<Record<string, NonNullable<CredentialPolicy['paths']>>>
secret: string
/** Env var that may point this key at a custom host (honoured only from the key's own scope). */
baseUrl?: string
/** Host without a base URL; null: the key is only routed together with its base URL. */
defaultHost: string | null
/** Hosts without a base URL (a base URL replaces them); empty: only routed with its base URL. */
defaultHosts: readonly string[]
headers: Record<string, (value: string) => string>
}
@@ -120,23 +134,23 @@ const CREDENTIALS: readonly CredentialSpec[] = [
{
secret: 'ANTHROPIC_API_KEY',
baseUrl: 'ANTHROPIC_BASE_URL',
defaultHost: 'api.anthropic.com',
defaultHosts: ['api.anthropic.com'],
headers: { 'x-api-key': raw, authorization: bearer }
},
{
secret: 'ANTHROPIC_AUTH_TOKEN',
baseUrl: 'ANTHROPIC_BASE_URL',
defaultHost: 'api.anthropic.com',
defaultHosts: ['api.anthropic.com'],
headers: { authorization: bearer, 'x-api-key': raw }
},
// AI SDK clients (OpenCode's `vercel/...` models) read the gateway key directly.
{ secret: 'AI_GATEWAY_API_KEY', defaultHost: AI_GATEWAY_HOST, headers: { authorization: bearer, 'x-api-key': raw } },
{ secret: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL', defaultHost: 'api.openai.com', headers: { authorization: bearer } },
{ secret: 'GEMINI_API_KEY', defaultHost: GEMINI_HOST, headers: { 'x-goog-api-key': raw } },
{ secret: 'GOOGLE_GENERATIVE_AI_API_KEY', defaultHost: GEMINI_HOST, headers: { 'x-goog-api-key': raw } },
{ secret: 'AI_GATEWAY_API_KEY', defaultHosts: [AI_GATEWAY_HOST], headers: { authorization: bearer, 'x-api-key': raw } },
{ secret: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL', defaultHosts: ['api.openai.com'], headers: { authorization: bearer } },
{ secret: 'GEMINI_API_KEY', defaultHosts: [GEMINI_HOST], headers: { 'x-goog-api-key': raw } },
{ secret: 'GOOGLE_GENERATIVE_AI_API_KEY', defaultHosts: [GEMINI_HOST], headers: { 'x-goog-api-key': raw } },
{
secret: 'CURSOR_API_KEY',
defaultHost: CURSOR_API_HOST,
defaultHosts: [CURSOR_API_HOST],
headers: { authorization: bearer },
paths: [{ method: 'POST', path: '/auth/exchange_user_api_key' }],
allowUnclaimed: true,
@@ -144,16 +158,37 @@ const CREDENTIALS: readonly CredentialSpec[] = [
// and exchanges the key again when it expires; with one it would persist a long-lived credential.
keepResponseFields: ['accessToken']
},
// Gemini CLI and the Google GenAI SDK read GOOGLE_API_KEY for the Gemini API and for Vertex AI
// express mode (GOOGLE_GENAI_USE_VERTEXAI=true).
{ secret: 'GOOGLE_API_KEY', defaultHosts: [GEMINI_HOST, VERTEX_HOST], headers: { 'x-goog-api-key': raw } },
{
// A user's own GitHub token for Copilot CLI (fine-grained PAT with Copilot Requests). Copilot
// checks it on api.github.com, then calls its API with it. The Copilot hosts stay open without
// the placeholder: a session token Copilot gets back is sent there too.
secret: 'COPILOT_GITHUB_TOKEN',
defaultHosts: ['api.github.com', ...COPILOT_API_HOSTS],
headers: { authorization: bearer },
altHeaderFormats: { authorization: [(value: string) => `token ${value}`] },
allowUnclaimedHosts: [...COPILOT_API_HOSTS],
// On GitHub's API the user's token checks Copilot access and nothing else: the rest of the API
// (repos, gists, ...) stays out of reach of whatever runs in the workspace.
pathsByHost: {
'api.github.com': [
{ method: 'GET', path: '/user' },
{ method: 'GET', path: '/copilot_internal/', prefix: true }
]
}
},
{
secret: 'COPILOT_PROVIDER_API_KEY',
baseUrl: 'COPILOT_PROVIDER_BASE_URL',
defaultHost: null,
defaultHosts: [],
headers: { authorization: bearer, 'api-key': raw }
},
{
secret: 'COPILOT_PROVIDER_BEARER_TOKEN',
baseUrl: 'COPILOT_PROVIDER_BASE_URL',
defaultHost: null,
defaultHosts: [],
headers: { authorization: bearer }
}
]
@@ -167,7 +202,7 @@ export function credentialShape(secret: string): Omit<CredentialTarget, 'host'>
if (!spec) {
return null
}
const { baseUrl: _baseUrl, defaultHost: _defaultHost, ...shape } = spec
const { baseUrl: _baseUrl, defaultHosts: _defaultHosts, allowUnclaimedHosts: _openHosts, pathsByHost: _paths, ...shape } = spec
return shape
}
@@ -175,25 +210,43 @@ export function credentialShape(secret: string): Omit<CredentialTarget, 'host'>
* A base URL only redirects a credential from the same scope: a member's personal base URL must
* never route the org's shared key to a host the member picked (the proxy would hand it over).
*/
function hostFor(env: Record<string, string>, spec: CredentialSpec, scopes?: ValueScopes): string | null {
function hostsFor(env: Record<string, string>, spec: CredentialSpec, scopes?: ValueScopes): readonly string[] {
const base = spec.baseUrl ? env[spec.baseUrl] : undefined
if (!base || !spec.baseUrl) {
return spec.defaultHost
return spec.defaultHosts
}
if (scopes && scopes[spec.baseUrl] !== scopes[spec.secret]) {
return spec.defaultHost
return spec.defaultHosts
}
return baseUrlHost(base)
const host = baseUrlHost(base)
return host ? [host] : []
}
/** Where each model credential in `env` is used, so the egress proxy can swap it in on that host only. */
/** How a credential is sent and policed on one of its hosts. */
export function credentialTarget(secret: string, host: string): CredentialTarget | null {
const spec = CREDENTIALS.find((candidate) => candidate.secret === secret)
const shape = credentialShape(secret)
if (!spec || !shape) {
return null
}
const { allowUnclaimed: _open, ...rest } = shape
const open = spec.allowUnclaimed || spec.allowUnclaimedHosts?.includes(host)
const paths = spec.pathsByHost?.[host] ?? spec.paths
return { ...rest, ...(open ? { allowUnclaimed: true as const } : {}), ...(paths ? { paths } : {}), host }
}
/** Where each model credential in `env` is used, so the egress proxy can swap it in on those hosts only. */
export function credentialTargets(env: Record<string, string>, scopes?: ValueScopes): CredentialTarget[] {
const targets: CredentialTarget[] = []
for (const spec of CREDENTIALS) {
const host = env[spec.secret] ? hostFor(env, spec, scopes) : null
const shape = host ? credentialShape(spec.secret) : null
if (host && shape) {
targets.push({ ...shape, host })
if (!env[spec.secret]) {
continue
}
for (const host of hostsFor(env, spec, scopes)) {
const target = credentialTarget(spec.secret, host)
if (target) {
targets.push(target)
}
}
}
return targets
@@ -114,6 +114,23 @@ function detailOf(run: RunRow): RunDetail {
}
}
/** Untrusted context in a prompt, in UTF-8 bytes; the whole prompt stays far below one argv entry's 128 KiB. */
export const MAX_CONTEXT_BYTES = 48 * 1024
function truncateUtf8(text: string, maxBytes: number): string {
let bytes = 0
let end = 0
for (const char of text) {
const size = new TextEncoder().encode(char).length
if (bytes + size > maxBytes) {
break
}
bytes += size
end += char.length
}
return text.slice(0, end)
}
/** The agent's full prompt: the routine's prompt, what happens to its output, and fenced untrusted context. */
export function composePrompt(routine: Pick<RoutineRow, 'prompt' | 'post_results'>, detail: RunDetail): string {
const parts = [routine.prompt.trim()]
@@ -128,6 +145,14 @@ export function composePrompt(routine: Pick<RoutineRow, 'prompt' | 'post_results
}
if (detail.context !== undefined && detail.context !== null) {
let context = JSON.stringify(detail.context, null, 2)
// The prompt reaches some agents as one argv entry (Linux caps one at 128 KiB): indentation of a
// deeply nested payload must not push it over, so a large context is sent compact, then cut.
if (new TextEncoder().encode(context).length > MAX_CONTEXT_BYTES) {
context = JSON.stringify(detail.context)
}
if (new TextEncoder().encode(context).length > MAX_CONTEXT_BYTES) {
context = `${truncateUtf8(context, MAX_CONTEXT_BYTES)}\n[truncated]`
}
// A payload cannot close the fence early.
context = context.replaceAll('</untrusted_context>', '<\\/untrusted_context>')
parts.push(
@@ -95,9 +95,43 @@ codex)
--output-last-message "$D/last.txt" - >"$D/agent.out" 2>"$D/agent.err" <"$D/prompt.txt" || code=$?
summary="$(head -c 6000 "$D/last.txt" 2>/dev/null || true)"
;;
opencode)
# "--" ends the flags, so a prompt starting with "-" stays the message.
timeout --kill-after=20 "$limit" opencode run --auto --format json -- "$(cat "$D/prompt.txt")" \
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
# JSON events: the final message is the last text part (tool output never becomes the summary).
summary="$(jq -rs 'map(select(.type == "text")) | last | .part.text // empty' "$D/agent.out" 2>/dev/null | head -c 6000)"
;;
gemini)
# --prompt=TEXT, not "-p TEXT": a prompt starting with "-" would read as a flag.
timeout --kill-after=20 "$limit" gemini --yolo --output-format json --prompt="$(cat "$D/prompt.txt")" \
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
summary="$(jq -r '.response // empty' "$D/agent.out" 2>/dev/null | head -c 6000)"
if [ "$code" = 0 ] && [ -n "$(jq -r '.error // empty' "$D/agent.out" 2>/dev/null)" ]; then code=1; fi
;;
cursor)
timeout --kill-after=20 "$limit" cursor-agent --print --force --trust --output-format json -- "$(cat "$D/prompt.txt")" \
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
summary="$(jq -rs 'map(select(type == "object" and .type == "result")) | last | .result // empty' "$D/agent.out" 2>/dev/null | head -c 6000)"
;;
copilot)
# --silent: only the agent's response, no tool output or stats.
timeout --kill-after=20 "$limit" copilot --allow-all --silent --prompt="$(cat "$D/prompt.txt")" \
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
summary="$(head -c 6000 "$D/agent.out" 2>/dev/null || true)"
;;
*) code=2; note=unknown_agent; finish ;;
esac
[ -n "$summary" ] || summary="$(tail -c 2000 "$D/agent.out" 2>/dev/null || true)"
# The summary can be posted publicly (comment / PR body): no session secret value leaves in it, even
# one an agent printed (AWS keys and plain env secrets are real values in this VM).
summary="$(ORCA_SUMMARY="$summary" node -e '
let s = process.env.ORCA_SUMMARY || ""
for (const name of (process.env.ORCA_SESSION_ENV_NAMES || "").split(/\s+/)) {
const value = process.env[name]
if (value && value.length >= 8) s = s.split(value).join("[redacted]")
}
process.stdout.write(s)' 2>/dev/null)" || summary=""
echo "routine: agent exit $code"
if [ "$code" = 0 ] && [ "$post" = pr ] && [ -n "$start_sha" ]; then
git add -A >/dev/null 2>&1 || true
@@ -5,7 +5,7 @@
// Codex) hang under Vercel transform rules, so these hosts always take the forwardURL path through
// /v1/egress/proxy, the same path api.github.com uses. Placeholders are not secrets: the proxy
// authenticates the sandbox by Vercel's OIDC token, the placeholder only marks which header to swap.
import { SWAPPED_CREDENTIALS, credentialShape, credentialTargets, type CredentialTarget, type ValueScopes } from './provider-env'
import { SWAPPED_CREDENTIALS, credentialTarget, credentialTargets, type CredentialTarget, type ValueScopes } from './provider-env'
import type { SandboxRow } from './store'
export type SecretEgressHost = CredentialTarget
@@ -24,21 +24,28 @@ export function encodeEgressSecretHosts(targets: readonly CredentialTarget[]): s
/** Credential routes recorded on the row for this session. */
export function egressSecretHosts(row: Pick<SandboxRow, 'egress_secret_hosts'>): SecretEgressHost[] {
const recorded: Record<string, string> = {}
const recorded = new Map<string, Set<string>>()
for (const entry of (row.egress_secret_hosts ?? '').split(',').filter(Boolean)) {
const at = entry.indexOf('@')
const [secret, host] = at === -1 ? [LEGACY_HOSTS[entry], entry] : [entry.slice(0, at), entry.slice(at + 1)]
if (secret && host) {
recorded[secret] = host
recorded.set(secret, (recorded.get(secret) ?? new Set()).add(host))
}
}
// Header shapes and policies come from the code, never from the row. The base URL value is only
// used to pick the default-vs-custom host, and the recorded host is what was allowlisted at boot.
return SWAPPED_CREDENTIALS.flatMap((secret) => {
const host = recorded[secret]
const shape = host ? credentialShape(secret) : null
return host && shape ? [{ ...shape, host }] : []
})
// used to pick the default-vs-custom host, and the recorded hosts are what was allowlisted at boot.
return SWAPPED_CREDENTIALS.flatMap((secret) =>
[...(recorded.get(secret) ?? [])].flatMap((host) => {
const target = credentialTarget(secret, host)
return target ? [target] : []
})
)
}
/** Every header value the VM may send a credential's placeholder in. */
export function placeholderValues(target: CredentialTarget, header: string, placeholder: string): string[] {
const format = target.headers[header]
return format ? [format(placeholder), ...(target.altHeaderFormats?.[header] ?? []).map((alt) => alt(placeholder))] : []
}
/**
@@ -4,7 +4,9 @@
import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { compileNetworkPolicy } from '../src/network-policy'
import { credentialTargets } from '../src/provider-env'
import { SWAPPED_CREDENTIALS, credentialTargets } from '../src/provider-env'
import { AGENT_KEY_NAMES } from '../src/secrets'
import { cloudSecretStoredOnVm } from '../../../../src/shared/cloud-secrets/cloud-secret-rules'
import { egressSecretHosts, encodeEgressSecretHosts } from '../src/secret-egress'
import { ENV_COMMAND } from '../src/sandbox-vm-commands'
import { createHarness } from './harness'
@@ -59,6 +61,22 @@ describe('credential routes for Gemini, Cursor and Copilot BYOK', () => {
expect(credentialTargets(env, { COPILOT_PROVIDER_BASE_URL: 'user:mia', COPILOT_PROVIDER_API_KEY: 'org:acme' })).toEqual([])
})
it('routes GOOGLE_API_KEY to the Gemini API and Vertex AI, and COPILOT_GITHUB_TOKEN to GitHub and the Copilot API', () => {
const targets = credentialTargets({ GOOGLE_API_KEY: GEMINI_KEY, COPILOT_GITHUB_TOKEN: COPILOT_KEY })
expect(targets.map((t) => `${t.secret}@${t.host}${t.allowUnclaimed ? ' open' : ''}`)).toEqual([
'GOOGLE_API_KEY@generativelanguage.googleapis.com',
'GOOGLE_API_KEY@aiplatform.googleapis.com',
'COPILOT_GITHUB_TOKEN@api.github.com',
'COPILOT_GITHUB_TOKEN@api.githubcopilot.com open',
'COPILOT_GITHUB_TOKEN@api.individual.githubcopilot.com open',
'COPILOT_GITHUB_TOKEN@api.business.githubcopilot.com open',
'COPILOT_GITHUB_TOKEN@api.enterprise.githubcopilot.com open'
])
// Several hosts per credential survive the row encoding.
const row = { egress_secret_hosts: encodeEgressSecretHosts(targets) }
expect(egressSecretHosts(row).map((t) => `${t.secret}@${t.host}`)).toEqual(targets.map((t) => `${t.secret}@${t.host}`))
})
it('round-trips through the row with the policy taken from code, never from the row', () => {
const row = {
egress_secret_hosts: encodeEgressSecretHosts(credentialTargets({ CURSOR_API_KEY: CURSOR_KEY, GEMINI_API_KEY: GEMINI_KEY }))
@@ -174,6 +192,38 @@ describe('egress swap on a sandbox with the new keys', () => {
expect(logs.some((line) => line.includes('"event":"egress_swap"') && line.includes('"credential":"GEMINI_API_KEY"'))).toBe(true)
})
it('Copilot token: swapped on api.github.com next to the workspace token, in Bearer or gh-style token form', async () => {
const { h, id, send, upstream } = await setup()
const mia = await h.token('mia', undefined, 'sandbox')
expect((await h.call('PUT', '/v1/secrets/personal/COPILOT_GITHUB_TOKEN', { body: { value: COPILOT_KEY }, bearer: mia })).status).toBe(200)
const ph = `orca-ph-copilot-github-token-${id}`
const policy = [...h.vercel.boxes.values()][0]!.policy
const values = policy.allow['api.github.com']!.flatMap((rule) => ('match' in rule && rule.match?.headers ? [rule.match.headers[0]!.value.exact] : []))
expect(values).toEqual(expect.arrayContaining([`Bearer ${ph}`, `token ${ph}`, `Bearer orca-ph-${id}`]))
expect(policy.allow['api.github.com']!.at(-1)).toEqual(DENY)
expect(policy.allow['api.individual.githubcopilot.com']!.some((rule) => 'response' in rule)).toBe(false)
for (const header of [`Bearer ${ph}`, `token ${ph}`]) {
expect((await send('api.github.com', 'GET', '/copilot_internal/user', { authorization: header })).status).toBe(200)
expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${COPILOT_KEY}`)
}
expect((await send('api.github.com', 'GET', '/user', { authorization: `Bearer ${ph}` })).status).toBe(200)
// The rest of GitHub's API is out of reach of the user's token, however the path is dressed up.
const sent = upstream.length
for (const [method, path] of [
['GET', '/user/repos'],
['POST', '/copilot_internal/user'],
['GET', '/copilot_internal/../user/repos'],
['GET', '/copilot_internal/%2e%2e/user/repos'],
['GET', '/copilot_internal//../gists'],
['DELETE', '/repos/acme/app']
]) {
expect(await send('api.github.com', method!, path!, { authorization: `Bearer ${ph}` }), path).toEqual({ status: 403, body: 'path_not_allowed' })
}
expect(upstream).toHaveLength(sent)
expect((await send('api.individual.githubcopilot.com', 'POST', '/chat/completions', { authorization: `Bearer ${ph}` })).status).toBe(200)
expect(upstream.at(-1)!.url).toBe('https://api.individual.githubcopilot.com/chat/completions')
})
it('Cursor: only the key exchange carries the key, and the refresh token never reaches the VM', async () => {
const { id, send, upstream, setReply } = await setup()
setReply(() => new Response(JSON.stringify({ accessToken: 'at-short', refreshToken: 'rt-long-lived' }), { status: 200, headers: { 'content-type': 'application/json', 'content-length': '60' } }))
@@ -224,3 +274,17 @@ describe('egress swap on a sandbox with the new keys', () => {
expect(JSON.stringify(env)).not.toContain(COPILOT_KEY)
})
})
describe('every agent credential is either swapped or marked "stored on workspace"', () => {
it('leaves no agent key reaching the VM raw without the warning', () => {
const settings = /(_MODEL|_BASE_URL|_REGION|_PROJECT|_PROJECT_ID)$|^CLAUDE_CODE_USE_/
for (const name of AGENT_KEY_NAMES) {
if (settings.test(name)) {
continue
}
const swapped = SWAPPED_CREDENTIALS.includes(name)
expect(swapped !== cloudSecretStoredOnVm(name), `${name}: swapped=${swapped}`).toBe(true)
}
expect(SWAPPED_CREDENTIALS.includes('COPILOT_GITHUB_TOKEN')).toBe(true)
})
})
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ORCA_CLOUD_IMAGE_AGENTS } from '../../../../src/shared/hosted-sandbox/orca-cloud-image-agents'
import type { GitHubAppMinter } from '../src/github-app'
import type { RunCommandParams } from '../src/sandbox-provider'
import { redactLog } from '../src/routines/vm-runner'
@@ -199,6 +200,26 @@ describe('routine runs (state machine on the fake provider)', () => {
])
})
it('runs every agent the image ships, headless, and refuses any other', async () => {
const h = await harness()
for (const agent of ORCA_CLOUD_IMAGE_AGENTS) {
const created = await h.create({ name: `r-${agent}`, agent })
expect(created.status, agent).toBe(201)
expect(created.body.routine.agent).toBe(agent)
const run = (await h.call('POST', `/v1/routines/${created.body.routine.id}/run`)).body.run
await h.tick()
const start = h.runnerCalls.find((c) => c.mode === 'start' && c.args.includes(run.id))!
expect(start.args[5]).toBe(agent)
// The worker has a headless command for it (an unknown agent ends as unknown_agent).
expect(start.args.at(-1)).toMatch(new RegExp(`^${agent}\\)$`, 'm'))
h.runs.set(run.id, h.done())
await h.tick()
}
expect((await h.create({ agent: 'aider' })).body.reason).toMatch(/^agent/)
const routine = (await h.create({ name: 'patch-me' })).body.routine
expect((await h.call('PATCH', `/v1/routines/${routine.id}`, { body: { agent: 'gemini' } })).body.routine.agent).toBe('gemini')
})
it('pr mode: falls back to opening the PR from the control plane when gh did not report one', async () => {
const h = await harness()
const routine = (await h.create({ postResults: 'pr' })).body.routine
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label'
import {
CalendarClock,
Github,
@@ -161,9 +162,7 @@ export function RoutineRow({
{where ? <span className="text-muted-foreground"> · {where}</span> : null}
<span className="text-muted-foreground">
{' · '}
{routine.agent === 'codex'
? t('auto.components.settings.cloudRoutines.agentCodex', 'Codex')
: t('auto.components.settings.cloudRoutines.agentClaude', 'Claude Code')}
{formatAgentTypeLabel(routine.agent)}
</span>
{viewerUserId && routine.createdBy !== viewerUserId ? (
<span className="text-muted-foreground">
@@ -1,12 +1,29 @@
import { useId } from 'react'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { SettingsSegmentedControl } from '../form-controls/SettingsFormControls'
import { FormField } from './cloud-api-parts'
import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label'
import {
ROUTINE_AGENTS,
type RoutineAgent
} from '../../../../../shared/cloud-api/cloud-api-routine-types'
import type { RoutineDraft, RoutineDraftField } from './routine-draft'
/** The Select hands back a string; only the image's agents are routine agents. */
export function agentFrom(value: string): RoutineAgent {
return ROUTINE_AGENTS.find((agent) => agent === value) ?? 'claude'
}
export function routineFieldError(
code: string | undefined,
field: RoutineDraftField,
@@ -84,23 +101,22 @@ export function RoutineAgentSection({
<p id={`${ids}-agent`} className="text-xs font-medium">
{t('auto.components.settings.cloudRoutines.editor.agent', 'Agent')}
</p>
<SettingsSegmentedControl<'claude' | 'codex'>
size="sm"
value={draft.agent}
onChange={(agent) => update({ agent })}
equalWidth
ariaLabelledBy={`${ids}-agent`}
options={[
{
value: 'claude',
label: t('auto.components.settings.cloudRoutines.agentClaude', 'Claude Code')
},
{
value: 'codex',
label: t('auto.components.settings.cloudRoutines.agentCodex', 'Codex')
}
]}
/>
<Select value={draft.agent} onValueChange={(agent) => update({ agent: agentFrom(agent) })}>
<SelectTrigger
id={`${ids}-agent-select`}
className="w-full"
aria-labelledby={`${ids}-agent`}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ROUTINE_AGENTS.map((agent) => (
<SelectItem key={agent} value={agent}>
{formatAgentTypeLabel(agent)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FormField
id={`${ids}-prompt`}
@@ -4,7 +4,8 @@ import { cn } from '@/lib/utils'
import { useTranslate } from '@/i18n/use-translate'
import {
cloudSecretNameError,
cloudSecretValueError
cloudSecretValueError,
cloudSecretStoredOnVm
} from '../../../../../shared/cloud-secrets/cloud-secret-rules'
import type { CloudSecretsErrorCode } from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
import { Button } from '../../ui/button'
@@ -12,6 +13,7 @@ import { Input } from '../../ui/input'
import { Label } from '../../ui/label'
import { cloudSecretsErrorCopy } from './cloud-secrets-copy'
import { SecretValueInput } from './SecretValueInput'
import { cloudSecretVmWarning } from './CloudSecretVmBadge'
const NAME_EXAMPLE = 'DATABASE_URL'
@@ -164,6 +166,12 @@ export function CloudSecretEditor({
) : (
<p id={`${formId}-help`} className="text-2xs text-muted-foreground">
{t('cloudSecrets.editor.help', 'Write-only. You won’t see it again after saving.')}
{cloudSecretStoredOnVm(name.trim()) ? (
<>
{' '}
<span className="text-foreground">{cloudSecretVmWarning(t)}</span>
</>
) : null}
</p>
)}
</div>
@@ -14,6 +14,7 @@ import { cloudSecretKindLabel, cloudSecretsErrorCopy } from './cloud-secrets-cop
import { CloudSecretEditor, type CloudSecretEditorTarget } from './CloudSecretEditor'
import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts'
import { CloudSecretsPasteEnv } from './CloudSecretsPasteEnv'
import { CloudSecretVmBadge } from './CloudSecretVmBadge'
import type { CloudSecretListModel } from './use-cloud-secrets'
export type CloudSecretPeople = {
@@ -85,6 +86,7 @@ function SecretRow({
{cloudSecretKindLabel(secret.name, t)}
</Badge>
) : null}
<CloudSecretVmBadge name={secret.name} />
</span>
<span className="w-[9ch] shrink-0 font-mono text-xs whitespace-nowrap text-muted-foreground">
<span aria-hidden>{maskedHint(secret.hint)}</span>
@@ -0,0 +1,27 @@
import { TriangleAlert } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { cloudSecretStoredOnVm } from '../../../../../shared/cloud-secrets/cloud-secret-rules'
import { Badge } from '../../ui/badge'
/** Marks a key the workspace VM holds in full (it can't be swapped at egress), never silently. */
export function CloudSecretVmBadge({ name }: { name: string }): React.JSX.Element | null {
const t = useTranslate()
if (!cloudSecretStoredOnVm(name)) {
return null
}
const why = cloudSecretVmWarning(t)
return (
<Badge variant="referenceLabel" title={why}>
<TriangleAlert aria-hidden />
{t('cloudSecrets.vm.badge', 'Stored on workspace')}
<span className="sr-only">{why}</span>
</Badge>
)
}
export function cloudSecretVmWarning(t: ReturnType<typeof useTranslate>): string {
return t(
'cloudSecrets.vm.warning',
'Orca can’t swap this key at the network edge, so cloud workspaces get the real value. Anything running in the workspace can read it.'
)
}
@@ -5,6 +5,7 @@ import type {
CloudSecretsEffective
} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
import { Badge } from '../../ui/badge'
import { CloudSecretVmBadge } from './CloudSecretVmBadge'
import { InlineError } from '../../ui/inline-error'
import { Skeleton } from '../../ui/skeleton'
import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts'
@@ -132,6 +133,7 @@ export function CloudSecretsEffectiveView({
{cloudSecretKindLabel(secret.name, t)}
</Badge>
) : null}
<CloudSecretVmBadge name={secret.name} />
</span>
</td>
<td className="px-3 py-2">
@@ -12,6 +12,7 @@ import {
type DotenvPasteIssue
} from '../../../../../shared/cloud-secrets/dotenv-paste'
import { Badge } from '../../ui/badge'
import { CloudSecretVmBadge } from './CloudSecretVmBadge'
import { Button } from '../../ui/button'
import { Input } from '../../ui/input'
import { cloudSecretKindLabel, cloudSecretsErrorCopy } from './cloud-secrets-copy'
@@ -176,6 +177,7 @@ export function PasteRowEditor({
{row.name && cloudSecretKind(row.name) === 'agent_key' ? (
<Badge variant="referenceLabel">{cloudSecretKindLabel(row.name, t)}</Badge>
) : null}
{row.name ? <CloudSecretVmBadge name={row.name} /> : null}
{removeButton}
</div>
<p
+4 -2
View File
@@ -14455,8 +14455,6 @@
"cloudRoutines": {
"queued": "Run queued. It shows up in the history below.",
"pausedSr": "(paused)",
"agentCodex": "Codex",
"agentClaude": "Claude Code",
"createdByPerson": "by <person></person>",
"lastRun": "Last run:",
"neverRun": "Never run",
@@ -26828,6 +26826,10 @@
"unterminatedQuote": "The closing quote is missing.",
"overridden": "Set again further down; the later line is used.",
"unknown": "Something went wrong in Orca Cloud. Nothing was changed. Try again."
},
"vm": {
"badge": "Stored on workspace",
"warning": "Orca can’t swap this key at the network edge, so cloud workspaces get the real value. Anything running in the workspace can read it."
}
},
"cloudMcpSync": {
@@ -1,5 +1,6 @@
// Orca Cloud routines: /v1/routines wire shapes and the editor's request bodies.
import { z } from 'zod'
import { ORCA_CLOUD_IMAGE_AGENTS } from '../hosted-sandbox/orca-cloud-image-agents'
import { HttpsUrl, Id, Millis, PersonName, ShortText } from './cloud-api-primitives'
// ── Routines ────────────────────────────────────────────────────────────────
@@ -14,7 +15,8 @@ export const ROUTINE_GITHUB_EVENTS = [
] as const
export type RoutineGithubEvent = (typeof ROUTINE_GITHUB_EVENTS)[number]
export const ROUTINE_AGENTS = ['claude', 'codex'] as const
/** A routine runs any agent the Orca Cloud image ships, headless. */
export const ROUTINE_AGENTS = ORCA_CLOUD_IMAGE_AGENTS
export type RoutineAgent = (typeof ROUTINE_AGENTS)[number]
export const ROUTINE_POST_RESULTS = ['none', 'comment', 'pr'] as const
export type RoutinePostResults = (typeof ROUTINE_POST_RESULTS)[number]
+22 -1
View File
@@ -29,9 +29,30 @@ const AGENT_KEY_NAMES: ReadonlySet<string> = new Set([
'CLAUDE_CODE_USE_VERTEX',
'ANTHROPIC_VERTEX_PROJECT_ID',
'CLOUD_ML_REGION',
'GOOGLE_CLOUD_PROJECT'
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_GENERATIVE_AI_API_KEY',
'COPILOT_PROVIDER_BASE_URL',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_BEARER_TOKEN',
'COPILOT_MODEL'
])
/**
* Agent keys the egress proxy cannot swap, so the workspace VM holds the real value: AWS requests
* are signed in the VM (SigV4) with the secret key, and Bedrock API keys are not routed yet. Every
* other agent key reaches the VM only as a placeholder. Mirrors the control plane's swap list.
*/
const STORED_ON_VM_NAMES: ReadonlySet<string> = new Set([
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AWS_BEARER_TOKEN_BEDROCK'
])
export function cloudSecretStoredOnVm(name: string): boolean {
return STORED_ON_VM_NAMES.has(name)
}
const RESERVED_NAMES: ReadonlySet<string> = new Set([
'PATH',
'HOME',
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { cloudSecretKind, cloudSecretStoredOnVm } from './cloud-secret-rules'
describe('agent keys the workspace VM holds in full', () => {
it('marks only the AWS keys, which the egress proxy cannot swap', () => {
for (const name of [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AWS_BEARER_TOKEN_BEDROCK'
]) {
expect(cloudSecretStoredOnVm(name)).toBe(true)
}
for (const name of [
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'CURSOR_API_KEY',
'COPILOT_GITHUB_TOKEN',
'DATABASE_URL',
'AWS_REGION'
]) {
expect(cloudSecretStoredOnVm(name)).toBe(false)
}
})
it('treats the new agent credentials as agent keys (shared beats personal), except a personal GitHub token', () => {
for (const name of [
'GOOGLE_GENERATIVE_AI_API_KEY',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_BASE_URL'
]) {
expect(cloudSecretKind(name)).toBe('agent_key')
}
// One person's GitHub token: a personal value wins over a team one.
expect(cloudSecretKind('COPILOT_GITHUB_TOKEN')).toBe('env')
})
})