diff --git a/agents/claude.ts b/agents/claude.ts index 61eacdf..f7be1b2 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -926,6 +926,16 @@ export const claude = agent({ env.CLAUDE_CODE_USE_BEDROCK = "1"; } + // claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth + // token when both are set, so we strip the API key to fall through to the + // Max-subscription path. bedrock route uses AWS creds and is excluded. + if (env.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env.ANTHROPIC_API_KEY) { + log.debug( + "» CLAUDE_CODE_OAUTH_TOKEN present — stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used" + ); + delete env.ANTHROPIC_API_KEY; + } + const repoDir = process.cwd(); log.info(`» effort: ${effort}`); diff --git a/main.ts b/main.ts index f57bc9c..81aba18 100644 --- a/main.ts +++ b/main.ts @@ -36,6 +36,7 @@ import { resolveInstructions } from "./utils/instructions.ts"; import { readLearningsFile, seedLearningsFile } from "./utils/learnings.ts"; import { executeLifecycleHook } from "./utils/lifecycle.ts"; import { normalizeEnv, sanitizeSecret } from "./utils/normalizeEnv.ts"; +import { applyOverrides } from "./utils/overrides.ts"; import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts"; import { resolvePayload, resolvePromptInput } from "./utils/payload.ts"; import { isRouterKeylimitExhaustedError } from "./utils/providerErrors.ts"; @@ -537,6 +538,25 @@ export async function main(): Promise { // normalize env var names to uppercase (handles case-insensitive workflow files) normalizeEnv(); + // apply caller-supplied env overrides — JSON object forwarded as the + // UNSAFE_OVERRIDES env var (NOT a `with:` input). gated by `actions:write` + // on the repo and refuses integrity-critical names; see utils/overrides.ts + // for the deny-list and wiki/e2e-testing.md for usage + threat model. + // the `unsafe` prefix is intentional: GH echoes the env-block value in the + // step-header log, so the raw JSON is visible to anyone with `actions:read`. + const overridesRaw = process.env.UNSAFE_OVERRIDES ?? ""; + if (overridesRaw.trim()) { + const result = applyOverrides({ raw: overridesRaw, env: process.env }); + if (result.applied.length > 0) { + log.info(`» applied ${result.applied.length} env override(s): ${result.applied.join(", ")}`); + } + if (result.denied.length > 0) { + log.warning( + `» refused to override ${result.denied.length} protected env var(s): ${result.denied.join(", ")}` + ); + } + } + // write usage summary on SIGINT/SIGTERM so the worker can read it after sandbox.exec const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH; if (usageSummaryPath) { diff --git a/utils/overrides.ts b/utils/overrides.ts new file mode 100644 index 0000000..f798fe1 --- /dev/null +++ b/utils/overrides.ts @@ -0,0 +1,98 @@ +/** + * Parse + apply the action's `unsafe_overrides` input — a JSON object of env + * var overrides that mutate `process.env` at the start of a run. Designed for + * e2e testing / debugging from `workflow_dispatch`; only callers with + * `actions:write` on the repo can supply it. + * + * The `unsafe` prefix is load-bearing: GH Actions echoes the value verbatim + * in the runner's step-header log, so the raw JSON (including any values + * passed in) is visible to anyone with `actions:read` on the calling repo. + * Treat the run log as compromised for any value placed in `unsafe_overrides`. + */ + +import * as core from "@actions/core"; + +/** + * Names refused even when present in the input. Overriding these would let a + * caller escape pullfrog's scope (GITHUB_TOKEN), break runner internals + * (ACTIONS_RUNTIME_*), forge OIDC tokens (ACTIONS_ID_TOKEN_REQUEST_*), or + * substitute our server-side auth (PULLFROG_API_SECRET). Customer-facing + * provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, + * etc.) are intentionally NOT denied — overriding those is the use case. + */ +export const DENIED_OVERRIDE_NAMES: ReadonlySet = new Set([ + "GITHUB_TOKEN", + "GH_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "ACTIONS_RUNTIME_URL", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_CACHE_URL", + "PULLFROG_API_SECRET", + "VERCEL_AUTOMATION_BYPASS_SECRET", +]); + +export interface ApplyOverridesResult { + applied: string[]; + denied: string[]; +} + +/** Parse the JSON input. Returns `{}` for empty/whitespace. Throws on shape errors. */ +export function parseOverrides(raw: string): Record { + const trimmed = raw.trim(); + if (!trimmed) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + throw new Error( + `invalid UNSAFE_OVERRIDES: not valid JSON (${err instanceof Error ? err.message : String(err)})` + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`invalid UNSAFE_OVERRIDES: must be a JSON object`); + } + + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value !== "string") { + throw new Error( + `invalid UNSAFE_OVERRIDES: key "${key}" must have a string value (got ${typeof value})` + ); + } + out[key] = value; + } + return out; +} + +/** + * Mutate `params.env` in place with the supplied JSON overrides, skipping any + * names in `DENIED_OVERRIDE_NAMES`. Each applied value is registered with + * `core.setSecret` so the runner masks it in subsequent log output, and the + * raw `UNSAFE_OVERRIDES` env var is deleted so spawned subprocesses don't + * inherit the original JSON (which would defeat both the deny-list and the + * masking by exposing the values verbatim). + * + * Returns the applied/denied breakdown so the caller can render an audit log. + */ +export function applyOverrides(params: { + raw: string; + env: NodeJS.ProcessEnv; +}): ApplyOverridesResult { + const overrides = parseOverrides(params.raw); + const applied: string[] = []; + const denied: string[] = []; + for (const [key, value] of Object.entries(overrides)) { + if (DENIED_OVERRIDE_NAMES.has(key)) { + denied.push(key); + continue; + } + if (value.length > 0) core.setSecret(value); + params.env[key] = value; + applied.push(key); + } + delete params.env.UNSAFE_OVERRIDES; + return { applied, denied }; +}