Skip to content
Draft
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
127 changes: 127 additions & 0 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
174 changes: 173 additions & 1 deletion packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -57,6 +62,7 @@ import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
import {
handoffLocalGitStateSchema,
jsonRpcRequestSchema,
setGithubTokenBodySchema,
validateCommandParams,
} from "./schemas";
import type { AgentServerConfig } from "./types";
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -450,13 +483,74 @@ 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);
});

return app;
}

private async setGithubToken(token: string): Promise<void> {
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<void> {
await new Promise<void>((resolve) => {
this.server = serve(
Expand All @@ -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<void> {
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<void> {
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.bokerqi.top.helper",
]).catch(() => {
// unset-all returns non-zero when the key is absent; that's fine.
});
await execFileAsync("git", [
"config",
"--global",
"credential.https://github.bokerqi.top.helper",
"",
]);
await execFileAsync("git", [
"config",
"--global",
"--add",
"credential.https://github.bokerqi.top.helper",
helperPath,
]);

this.logger.info("Installed git credential helper", { helperPath });
}

private async loadResumeState(
taskId: string,
resumeRunId: string,
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/server/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading