diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index f61aa293d..b1e2b89dc 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -778,4 +778,131 @@ describe("AgentServer HTTP Mode", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); }); + + describe("github token bridge", () => { + let savedSecret: string | undefined; + let savedUrl: string | undefined; + let savedGhToken: string | undefined; + let savedGithubToken: string | undefined; + + beforeEach(() => { + savedSecret = process.env.POSTHOG_GH_WRAPPER_SECRET; + savedUrl = process.env.POSTHOG_GH_WRAPPER_URL; + savedGhToken = process.env.GH_TOKEN; + savedGithubToken = process.env.GITHUB_TOKEN; + delete process.env.POSTHOG_GH_WRAPPER_SECRET; + delete process.env.POSTHOG_GH_WRAPPER_URL; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + }); + + afterEach(() => { + const restore = (key: string, value: string | undefined) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + restore("POSTHOG_GH_WRAPPER_SECRET", savedSecret); + restore("POSTHOG_GH_WRAPPER_URL", savedUrl); + restore("GH_TOKEN", savedGhToken); + restore("GITHUB_TOKEN", savedGithubToken); + }); + + it("exports wrapper secret + URL into process.env on start", async () => { + await createServer().start(); + + expect(process.env.POSTHOG_GH_WRAPPER_SECRET).toMatch(/^[0-9a-f]{64}$/); + expect(process.env.POSTHOG_GH_WRAPPER_URL).toBe( + `http://127.0.0.1:${port}/github-token`, + ); + }, 30000); + + it("POST /github-token requires JWT", async () => { + await createServer().start(); + + const response = await fetch(`http://localhost:${port}/github-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: "ghp_test" }), + }); + + expect(response.status).toBe(401); + }, 30000); + + it("POST /github-token rejects invalid body", async () => { + await createServer().start(); + const token = createToken(); + + const response = await fetch(`http://localhost:${port}/github-token`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: "" }), + }); + + expect(response.status).toBe(400); + }, 30000); + + it("POST /github-token stores token and updates GH_TOKEN/GITHUB_TOKEN", async () => { + await createServer().start(); + const token = createToken(); + + const response = await fetch(`http://localhost:${port}/github-token`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: "ghp_new_token" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(process.env.GH_TOKEN).toBe("ghp_new_token"); + expect(process.env.GITHUB_TOKEN).toBe("ghp_new_token"); + }, 30000); + + it("GET /github-token rejects request without local secret", async () => { + await createServer().start(); + + const response = await fetch(`http://localhost:${port}/github-token`); + expect(response.status).toBe(403); + }, 30000); + + it("GET /github-token returns 404 when no token has been set", async () => { + await createServer().start(); + + const secret = process.env.POSTHOG_GH_WRAPPER_SECRET; + expect(secret).toBeTruthy(); + + const response = await fetch(`http://localhost:${port}/github-token`, { + headers: { "x-posthog-local-secret": secret as string }, + }); + + expect(response.status).toBe(404); + }, 30000); + + it("GET /github-token returns the stored token when secret matches", async () => { + await createServer().start(); + const jwtToken = createToken(); + const secret = process.env.POSTHOG_GH_WRAPPER_SECRET as string; + + await fetch(`http://localhost:${port}/github-token`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: "ghp_round_trip" }), + }); + + const response = await fetch(`http://localhost:${port}/github-token`, { + headers: { "x-posthog-local-secret": secret }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ token: "ghp_round_trip" }); + }, 30000); + }); }); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index f534144f4..3993a465c 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1,6 +1,10 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import type { ContentBlock, RequestPermissionRequest, @@ -12,6 +16,7 @@ import { PROTOCOL_VERSION, } from "@agentclientprotocol/sdk"; import { type ServerType, serve } from "@hono/node-server"; +import { getConnInfo } from "@hono/node-server/conninfo"; import { getCurrentBranch } from "@posthog/git/queries"; import { Hono } from "hono"; import { z } from "zod"; @@ -57,6 +62,7 @@ import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt"; import { handoffLocalGitStateSchema, jsonRpcRequestSchema, + setGithubTokenBodySchema, validateCommandParams, } from "./schemas"; import type { AgentServerConfig } from "./types"; @@ -71,6 +77,27 @@ const errorWithClassificationSchema = z.object({ data: z.object({ classification: agentErrorClassificationSchema }), }); +const execFileAsync = promisify(execFile); + +function isLoopbackAddress(addr: string | null): boolean { + if (!addr) return false; + return ( + addr === "127.0.0.1" || + addr === "::1" || + addr === "::ffff:127.0.0.1" || + addr.startsWith("127.") + ); +} + +function timingSafeEqualString(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i += 1) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +} + type MessageCallback = (message: unknown) => void; class NdJsonTap { @@ -233,6 +260,12 @@ export class AgentServer { }) => void; } >(); + private currentGithubToken: string | null = null; + // Per-process secret that gates GET /github-token. Generated at start() and + // exported into process.env so the gh wrapper and the credential helper + // (both running inside the sandbox) can read it. Codex-acp inherits this at + // spawn time — that's the whole point. + private wrapperSecret: string | null = null; private detachSseController(controller: SseController): void { if (this.session?.sseController === controller) { @@ -450,6 +483,56 @@ export class AgentServer { } }); + app.post("/github-token", async (c) => { + try { + this.authenticateRequest(c.req.header.bind(c.req)); + } catch (error) { + return c.json( + { + error: + error instanceof JwtValidationError + ? error.message + : "Invalid token", + }, + 401, + ); + } + + const rawBody = await c.req.json().catch(() => null); + const parsed = setGithubTokenBodySchema.safeParse(rawBody); + if (!parsed.success) { + return c.json( + { error: parsed.error.issues[0]?.message ?? "Invalid body" }, + 400, + ); + } + + await this.setGithubToken(parsed.data.token); + return c.json({ ok: true }); + }); + + app.get("/github-token", (c) => { + const remote = getConnInfo(c).remote.address ?? null; + if (!isLoopbackAddress(remote)) { + return c.json({ error: "Forbidden" }, 403); + } + + const provided = c.req.header("x-posthog-local-secret"); + if ( + !this.wrapperSecret || + !provided || + !timingSafeEqualString(provided, this.wrapperSecret) + ) { + return c.json({ error: "Forbidden" }, 403); + } + + if (!this.currentGithubToken) { + return c.json({ error: "No token available" }, 404); + } + + return c.json({ token: this.currentGithubToken }); + }); + app.notFound((c) => { return c.json({ error: "Not found" }, 404); }); @@ -457,6 +540,17 @@ export class AgentServer { return app; } + private async setGithubToken(token: string): Promise { + this.currentGithubToken = token; + // Mutate process.env so the in-process Claude adapter (and any subprocess + // we spawn after this point) sees the fresh token. Codex-acp was already + // spawned with a snapshotted env, so it relies on the gh wrapper + git + // credential helper instead — both fetch from GET /github-token. + process.env.GH_TOKEN = token; + process.env.GITHUB_TOKEN = token; + this.logger.info("GitHub token updated"); + } + async start(): Promise { await new Promise((resolve) => { this.server = serve( @@ -473,9 +567,87 @@ export class AgentServer { ); }); + // Set up the gh-token wrapper secret + git credential helper before any + // session bootstrap. autoInitializeSession() can spawn codex-acp, which + // snapshots process.env at spawn time — env additions after that point + // are invisible to the child. + await this.setupGithubTokenBridge(); + await this.autoInitializeSession(); } + private async setupGithubTokenBridge(): Promise { + this.wrapperSecret = randomBytes(32).toString("hex"); + const wrapperUrl = `http://127.0.0.1:${this.config.port}/github-token`; + + process.env.POSTHOG_GH_WRAPPER_URL = wrapperUrl; + process.env.POSTHOG_GH_WRAPPER_SECRET = this.wrapperSecret; + + // The bridge mutates --global git config, which we never want to touch on + // a developer machine. The agent-server only runs in dedicated sandboxes, + // so anywhere else (e.g. vitest) this side-effect is opt-in. + if (process.env.VITEST || process.env.POSTHOG_AGENT_SKIP_GH_BRIDGE) { + return; + } + + try { + await this.installGitCredentialHelper(wrapperUrl, this.wrapperSecret); + } catch (error) { + this.logger.warn("Failed to install git credential helper", { error }); + } + } + + private async installGitCredentialHelper( + wrapperUrl: string, + secret: string, + ): Promise { + const helperDir = join(tmpdir(), "posthog-agent"); + await mkdir(helperDir, { recursive: true }); + const helperPath = join(helperDir, "git-credential-posthog"); + + // POSIX credential helper: prints `username=...` + `password=...` for + // `git credential fill`. We only handle `get` — `store`/`erase` are no-ops. + const script = `#!/bin/sh +if [ "$1" != "get" ]; then + exit 0 +fi +TOKEN=$(curl -fsS -H "x-posthog-local-secret: ${secret}" "${wrapperUrl}" | sed -n 's/.*"token":"\\([^"]*\\)".*/\\1/p') +if [ -z "$TOKEN" ]; then + exit 0 +fi +printf 'username=x-access-token\\npassword=%s\\n' "$TOKEN" +`; + + await writeFile(helperPath, script, { encoding: "utf8" }); + await chmod(helperPath, 0o700); + + // Scope the helper to github.com only — don't intercept credentials for + // other hosts. Replace any prior helper for this URL so reruns are clean. + await execFileAsync("git", [ + "config", + "--global", + "--unset-all", + "credential.https://github.com.helper", + ]).catch(() => { + // unset-all returns non-zero when the key is absent; that's fine. + }); + await execFileAsync("git", [ + "config", + "--global", + "credential.https://github.com.helper", + "", + ]); + await execFileAsync("git", [ + "config", + "--global", + "--add", + "credential.https://github.com.helper", + helperPath, + ]); + + this.logger.info("Installed git credential helper", { helperPath }); + } + private async loadResumeState( taskId: string, resumeRunId: string, diff --git a/packages/agent/src/server/schemas.ts b/packages/agent/src/server/schemas.ts index 2dfa791a9..2b6c0c572 100644 --- a/packages/agent/src/server/schemas.ts +++ b/packages/agent/src/server/schemas.ts @@ -96,6 +96,10 @@ export const refreshSessionParamsSchema = z.object({ mcpServers: mcpServersSchema, }); +export const setGithubTokenBodySchema = z.object({ + token: z.string().min(1, "token is required"), +}); + export const closeParamsSchema = z .object({ localGitState: handoffLocalGitStateSchema.optional(), diff --git a/packages/agent/src/test/setup.ts b/packages/agent/src/test/setup.ts new file mode 100644 index 000000000..77f001d60 --- /dev/null +++ b/packages/agent/src/test/setup.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll } from "vitest"; + +// Isolate git's global config for the duration of the test suite. Several +// tests shell out to `git` (creating repos, committing, configuring +// credential helpers) and we don't want to inherit the developer's host +// settings — most importantly `commit.gpgsign`, which fails commits when no +// gpg agent is reachable. +let isolatedDir: string | null = null; +const savedGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL; + +beforeAll(() => { + isolatedDir = mkdtempSync(join(tmpdir(), "agent-test-gitconfig-")); + const configPath = join(isolatedDir, "gitconfig"); + writeFileSync( + configPath, + "[commit]\n gpgsign = false\n[tag]\n gpgsign = false\n", + ); + process.env.GIT_CONFIG_GLOBAL = configPath; +}); + +afterAll(() => { + if (savedGitConfigGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = savedGitConfigGlobal; + } + if (isolatedDir) { + rmSync(isolatedDir, { recursive: true, force: true }); + isolatedDir = null; + } +}); diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index 0a4a270c7..34389aa45 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ exclude: ["**/node_modules/**", "**/dist/**"], isolate: true, fileParallelism: false, + setupFiles: ["./src/test/setup.ts"], coverage: { provider: "v8", reporter: ["text", "json", "html"],