Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 60 additions & 14 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,52 @@ export function createAnthropicAi(opts: { apiKey: string; model?: string | undef
}

// ── Subscription CLI providers (#979) — locally-authenticated `claude` / `codex` as a subprocess ──────────
// SECURITY: the child env DELETES the billable API keys so a misconfigured CLI cannot silently bill the
// metered API instead of using the subscription OAuth token. The CLI runs read-only / no extra tools. Any
// non-zero exit / empty output / error-envelope THROWS so the caller degrades — never a silent answer.
const BILLABLE_KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY"] as const;
// SECURITY: subscription CLIs get a strict allowlisted env, not the worker env. This keeps runtime
// credentials out of prompt-injectable subprocesses while preserving CLI auth/home/proxy/cert settings. The CLI
// runs read-only / no extra tools, and non-zero exit / empty output / error-envelope THROWS so the caller degrades.
const SUBSCRIPTION_CLI_ENV_ALLOWLIST = [
"CODEX_HOME",
"HOME",
"HTTPS_PROXY",
"HTTP_PROXY",
"LANG",
"LC_ALL",
"NODE_EXTRA_CA_CERTS",
"NO_PROXY",
"PATH",
"SSL_CERT_DIR",
"SSL_CERT_FILE",
"TERM",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"https_proxy",
"http_proxy",
"no_proxy",
] as const;

function scrubBillableKeys(parent: Record<string, string | undefined>): Record<string, string | undefined> {
const child = { ...parent };
for (const k of BILLABLE_KEY_VARS) delete child[k];
export function subscriptionCliEnv(
parent: Record<string, string | undefined>,
extra: Record<string, string | undefined> = {},
): Record<string, string | undefined> {
const child: Record<string, string | undefined> = {};
for (const key of SUBSCRIPTION_CLI_ENV_ALLOWLIST) {
const value = parent[key];
if (value !== undefined) child[key] = value;
}
for (const [key, value] of Object.entries(extra)) {
if (value !== undefined) child[key] = value;
}
return child;
}

async function isolatedCliCwd(): Promise<string> {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
return mkdtemp(join(tmpdir(), "gittensory-ai-"));
}

/** Pull the assistant's final text out of a CLI's JSON output (Claude Code `{result}` or Codex JSONL). */
export function extractCliText(stdout: string): string {
const trimmed = stdout.trim();
Expand Down Expand Up @@ -177,14 +212,18 @@ export function claudeErrorStatus(stdout: string): string | null {
return null;
}

type SpawnFn = (cmd: string, args: string[], opts: { env: Record<string, string | undefined>; input?: string; timeoutMs: number }) => Promise<{ stdout: string; code: number | null }>;
type SpawnFn = (
cmd: string,
args: string[],
opts: { env: Record<string, string | undefined>; input?: string; timeoutMs: number; cwd?: string },
) => Promise<{ stdout: string; code: number | null }>;

async function defaultSpawn(): Promise<SpawnFn> {
const cp = await import("node:child_process");
return (cmd, args, o) =>
new Promise((resolve, reject) => {
const stdio: ["pipe", "pipe", "pipe"] = ["pipe", "pipe", "pipe"];
const child = cp.spawn(cmd, args, { env: o.env as NodeJS.ProcessEnv, stdio });
const child = cp.spawn(cmd, args, { cwd: o.cwd, env: o.env as NodeJS.ProcessEnv, stdio });
let stdout = "";
/* v8 ignore start */ // a 120s subprocess timeout is not unit-testable without a 2-minute wait
const timer = setTimeout(() => {
Expand Down Expand Up @@ -218,13 +257,16 @@ export function createClaudeCodeAi(parentEnv: Record<string, string | undefined>
if (options.text) throw new Error("claude_code_no_embed");
const token = parentEnv.CLAUDE_CODE_OAUTH_TOKEN;
if (!token) throw new Error("claude_code_no_oauth_token");
const env = scrubBillableKeys(parentEnv);
env.CLAUDE_CODE_OAUTH_TOKEN = token;
const env = subscriptionCliEnv(parentEnv, { CLAUDE_CODE_OAUTH_TOKEN: token });
const prompt = toMessages(options).map((m) => m.content).join("\n\n");
const spawn = spawnImpl ?? (await defaultSpawn());
const claudeModel = resolveModel(configuredModel(parentEnv), model, "claude-sonnet-4-6");
const effort = resolveEffort(parentEnv.AI_EFFORT);
const { stdout, code } = await spawn("claude", ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"], { env, input: prompt, timeoutMs: resolveCliTimeoutMs(parentEnv) });
const { stdout, code } = await spawn(
"claude",
["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"],
{ env, input: prompt, timeoutMs: resolveCliTimeoutMs(parentEnv), cwd: await isolatedCliCwd() },
);
if (code !== 0) throw new Error(`claude_code_exit_${code ?? "null"}`);
const errStatus = claudeErrorStatus(stdout);
if (errStatus) throw new Error(`claude_code_error_${errStatus}`);
Expand All @@ -243,7 +285,7 @@ export function createCodexAi(parentEnv: Record<string, string | undefined>, spa
async run(model, options) {
// Codex is chat-only here — reject embed requests so the chain routes them to an embed-capable provider.
if (options.text) throw new Error("codex_no_embed");
const env = scrubBillableKeys(parentEnv);
const env = subscriptionCliEnv(parentEnv);
const prompt = toMessages(options).map((m) => m.content).join("\n\n");
const spawn = spawnImpl ?? (await defaultSpawn());
// codex 0.142+: `exec` is non-interactive — the old `--ask-for-approval` flag was REMOVED (passing it errors).
Expand All @@ -254,7 +296,11 @@ export function createCodexAi(parentEnv: Record<string, string | undefined>, spa
const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"];
if (codexModel) args.push("--model", codexModel);
args.push("--", prompt);
const { stdout, code } = await spawn("codex", args, { env, timeoutMs: resolveCliTimeoutMs(parentEnv) });
const { stdout, code } = await spawn("codex", args, {
env,
timeoutMs: resolveCliTimeoutMs(parentEnv),
cwd: await isolatedCliCwd(),
});
if (code !== 0) throw new Error(`codex_exit_${code ?? "null"}`);
const text = extractCliText(stdout);
if (!text) throw new Error("codex_empty_output");
Expand Down
47 changes: 40 additions & 7 deletions test/unit/selfhost-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildProvider, claudeErrorStatus, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, resolveAiReviewerPlan, resolveCliTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, routeProviders } from "../../src/selfhost/ai";
import { buildProvider, claudeErrorStatus, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, resolveAiReviewerPlan, resolveCliTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, routeProviders, subscriptionCliEnv } from "../../src/selfhost/ai";

describe("resolveModel (#979 — never leak the Workers-AI default to a self-host backend)", () => {
const WORKERS_DEFAULT = "@cf/meta/llama-3.1-8b-instruct-fp8-fast";
Expand Down Expand Up @@ -53,7 +53,11 @@ describe("resolveCliTimeoutMs (#selfhost — subprocess timeout scales with effo
afterEach(() => vi.unstubAllGlobals());

type SpawnResult = { stdout: string; code: number | null };
type StubSpawn = (cmd: string, args: string[], opts: { env: Record<string, string | undefined>; input?: string; timeoutMs: number }) => Promise<SpawnResult>;
type StubSpawn = (
cmd: string,
args: string[],
opts: { env: Record<string, string | undefined>; input?: string; timeoutMs: number; cwd?: string },
) => Promise<SpawnResult>;

describe("createOpenAiCompatibleAi (#979)", () => {
it("POSTs to /chat/completions and returns { response }", async () => {
Expand Down Expand Up @@ -304,6 +308,17 @@ describe("branch coverage — defaults + edge inputs", () => {
});
});

describe("subscriptionCliEnv (allowlist + extra-override arms)", () => {
it("copies only allowlisted parent vars and drops everything else", () => {
const child = subscriptionCliEnv({ PATH: "/bin", HOME: "/root", ANTHROPIC_API_KEY: "sk-bill", WORKER_ONLY_VALUE: "internal" });
expect(child).toEqual({ PATH: "/bin", HOME: "/root" });
});
it("merges a defined extra value but skips an undefined one", () => {
const child = subscriptionCliEnv({ PATH: "/bin" }, { CLAUDE_CODE_OAUTH_TOKEN: "t", UNSET: undefined });
expect(child).toEqual({ PATH: "/bin", CLAUDE_CODE_OAUTH_TOKEN: "t" }); // UNSET (undefined) skips the extra-loop false arm
});
});

describe("subscription CLI helpers + fail-safe", () => {
it("extractCliText pulls the result/text field", () => {
expect(extractCliText(JSON.stringify({ type: "result", result: "ok" }))).toBe("ok");
Expand All @@ -323,9 +338,12 @@ describe("subscription CLI helpers + fail-safe", () => {
capturedEnv = o.env;
return { stdout: JSON.stringify({ type: "result", result: "review text" }), code: 0 };
};
const out = await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t", ANTHROPIC_API_KEY: "sk-bill" }, stub).run("sonnet", { prompt: "x" });
const out = await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t", ANTHROPIC_API_KEY: "sk-bill", WORKER_ONLY_VALUE: "internal" }, stub).run("sonnet", {
prompt: "x",
});
expect(out.response).toBe("review text");
expect(capturedEnv.ANTHROPIC_API_KEY).toBeUndefined(); // scrubbed
expect(capturedEnv.ANTHROPIC_API_KEY).toBeUndefined(); // allowlisted subprocess env does not inherit metered API keys
expect(capturedEnv.WORKER_ONLY_VALUE).toBeUndefined();
expect(capturedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe("t");
});

Expand Down Expand Up @@ -366,17 +384,32 @@ describe("subscription CLI helpers + fail-safe", () => {

it("Codex: 0.142+ exec flags (no --ask-for-approval, has --skip-git-repo-check); --model only when configured", async () => {
let seen: string[] = [];
let capturedEnv: Record<string, string | undefined> = {};
let capturedCwd = "";
let timeout = 0;
const ok: StubSpawn = async (_cmd, args, o) => { seen = args; timeout = o.timeoutMs; return { stdout: JSON.stringify({ type: "result", result: "codex review" }), code: 0 }; };
const ok: StubSpawn = async (_cmd, args, opts) => {
seen = args;
capturedEnv = opts.env;
capturedCwd = opts.cwd ?? "";
timeout = opts.timeoutMs;
return { stdout: JSON.stringify({ type: "result", result: "codex review" }), code: 0 };
};
// no configured model + the dual-router's empty model id → OMIT --model (codex picks the account default;
// forcing e.g. gpt-5 fails on a ChatGPT-account login). And the removed --ask-for-approval must never appear.
expect((await createCodexAi({ AI_TIMEOUT_MS: "300000" }, ok).run("", { prompt: "x" })).response).toBe("codex review");
expect(
(await createCodexAi({ PATH: "/bin", CODEX_HOME: "/tmp/codex", WORKER_ONLY_VALUE: "internal", OPENAI_API_KEY: "sk-bill", AI_TIMEOUT_MS: "300000" }, ok).run("", {
prompt: "x",
})).response,
).toBe("codex review");
expect(seen).toEqual(["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--", "x"]);
expect(seen).not.toContain("--ask-for-approval");
expect(capturedEnv).toEqual({ CODEX_HOME: "/tmp/codex", PATH: "/bin" });
expect(capturedCwd).toContain("gittensory-ai-");
expect(timeout).toBe(300_000); // codex honors the same AI_TIMEOUT_MS override as Claude Code
// an explicit model (AI_MODEL, or a `codex:<model>` reviewer id) IS passed through
// an explicit model (AI_MODEL, or a `codex:<model>` reviewer id) IS passed through but not inherited as env.
await createCodexAi({ AI_MODEL: "o4-mini" }, ok).run("", { prompt: "x" });
expect(seen.join(" ")).toContain("--model o4-mini");
expect(capturedEnv.AI_MODEL).toBeUndefined();
const bad: StubSpawn = async () => ({ stdout: "", code: 1 });
await expect(createCodexAi({}, bad).run("", { prompt: "x" })).rejects.toThrow(/codex_exit_1/);
});
Expand Down
Loading