mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): COPILOT_GITHUB_TOKEN and GOOGLE_API_KEY swapped at egress; routines run all six image agents; AWS keys marked stored on workspace
- COPILOT_GITHUB_TOKEN: placeholder in the VM, swapped on api.github.com (Bearer or token form, next to the workspace token's own route) and on the Copilot API hosts (left open for Copilot's own session token). GOOGLE_API_KEY: swapped on generativelanguage.googleapis.com and aiplatform.googleapis.com (Vertex express). A credential may now route to several hosts. - Routines accept claude, codex, opencode, gemini, cursor and copilot; the in-VM runner has a headless command for each (checked against a local stand-in model: prompts starting with '-' stay prompts). The editor picks the agent from a list. - AWS_* keys (SigV4, signed in the VM) and Bedrock API keys can't be swapped: the secrets UI marks them 'Stored on workspace' with the reason, in the list, the .env preview, the effective view and the editor. - Gemini's auth method is seeded for GOOGLE_API_KEY too (vertex-ai with GOOGLE_GENAI_USE_VERTEXAI); headless Gemini refuses to start without one.
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ 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'
|
||||
@@ -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,6 +276,21 @@ 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). */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}$/
|
||||
@@ -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,13 @@ 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[]
|
||||
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 +132,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 +156,29 @@ 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]
|
||||
},
|
||||
{
|
||||
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 +192,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, ...shape } = spec
|
||||
return shape
|
||||
}
|
||||
|
||||
@@ -175,25 +200,42 @@ 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)
|
||||
return { ...rest, ...(open ? { allowUnclaimed: true as const } : {}), 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
|
||||
|
||||
@@ -95,6 +95,26 @@ 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 -- "$(cat "$D/prompt.txt")" \
|
||||
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
|
||||
;;
|
||||
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 text -- "$(cat "$D/prompt.txt")" \
|
||||
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
|
||||
;;
|
||||
copilot)
|
||||
timeout --kill-after=20 "$limit" copilot --allow-all --silent --prompt="$(cat "$D/prompt.txt")" \
|
||||
>"$D/agent.out" 2>"$D/agent.err" </dev/null || code=$?
|
||||
;;
|
||||
*) code=2; note=unknown_agent; finish ;;
|
||||
esac
|
||||
[ -n "$summary" ] || summary="$(tail -c 2000 "$D/agent.out" 2>/dev/null || 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))] : []
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ export const AGENT_KEY_NAMES: ReadonlySet<string> = new Set([
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_MODEL',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'COPILOT_GITHUB_TOKEN',
|
||||
'COPILOT_PROVIDER_BASE_URL',
|
||||
'COPILOT_PROVIDER_API_KEY',
|
||||
'COPILOT_PROVIDER_BEARER_TOKEN',
|
||||
|
||||
@@ -59,6 +59,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 +190,24 @@ 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.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' } }))
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { cloudSecretStoredOnVm } from '../../../../../shared/cloud-secrets/cloud-secret-rules'
|
||||
import { cloudSecretVmWarning } from './CloudSecretVmBadge'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
@@ -26826,6 +26824,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]
|
||||
|
||||
@@ -29,9 +29,31 @@ 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_GITHUB_TOKEN',
|
||||
'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,37 @@
|
||||
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)', () => {
|
||||
for (const name of [
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'COPILOT_GITHUB_TOKEN',
|
||||
'COPILOT_PROVIDER_API_KEY',
|
||||
'COPILOT_PROVIDER_BASE_URL'
|
||||
]) {
|
||||
expect(cloudSecretKind(name)).toBe('agent_key')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user