mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
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,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}$/
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user