diff --git a/packages/agent/src/adapters/local-tools/tools/list-repos.ts b/packages/agent/src/adapters/local-tools/tools/list-repos.ts index d11287a64b..5432a49c8d 100644 --- a/packages/agent/src/adapters/local-tools/tools/list-repos.ts +++ b/packages/agent/src/adapters/local-tools/tools/list-repos.ts @@ -60,8 +60,16 @@ export const listReposTool = defineLocalTool({ ); try { + // An empty token is a managed logout, not "no preference": clear both token + // vars so gh cannot fall back to the previous actor's frozen process-env + // token and enumerate their private repos. Only an undefined token (an + // unmanaged local/desktop sandbox) inherits the process env unchanged. + const env = + token === undefined + ? process.env + : { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token }; const { stdout } = await execFileAsync("gh", cmdArgs, { - env: token ? { ...process.env, GH_TOKEN: token } : process.env, + env, maxBuffer: 1024 * 1024 * 8, }); const parsed = ghRepoSchema.safeParse(JSON.parse(stdout)); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 224f42fa16..0b980a6b4a 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -16,6 +16,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import { execGh } from "@posthog/git/gh"; import { getCurrentBranch } from "@posthog/git/queries"; +import { ghTokenEnv } from "@posthog/git/signed-commit"; import { type Adapter, buildPrOutput, @@ -94,6 +95,7 @@ import { resolveGatewayProduct, resolveLlmGatewayUrl, } from "../utils/gateway"; +import { resolveGithubToken } from "../utils/github-token"; import { Logger } from "../utils/logger"; import { logAgentshRuntimeInfo } from "./agentsh-runtime"; import { @@ -4405,6 +4407,15 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} } } + /** Env for a `gh` call that must run as the *current* actor. Prefers the live + * sandbox token (rewritten on an actor transition) over the process env + * (frozen at launch); returns undefined when unmanaged (local/desktop) so + * execGh falls back to the process env. */ + private ghActorEnv(): Record | undefined { + const token = resolveGithubToken(); + return token === undefined ? undefined : ghTokenEnv(token); + } + private async fetchPrAttribution( prUrl: string, ): Promise<{ createdAt: string | null; author: string | null }> { @@ -4413,6 +4424,7 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} { cwd: this.config.repositoryPath, timeoutMs: 10_000, + env: this.ghActorEnv(), }, ); if (res.exitCode !== 0) return { createdAt: null, author: null }; @@ -4431,11 +4443,21 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} } private ghLoginPromise: Promise | null = null; + private ghLoginToken: string | undefined; private fetchGhLogin(): Promise { - this.ghLoginPromise ??= execGh(["api", "user", "--jq", ".login"], { + // Key the memoized login on the live token: an actor transition rebinds + // /tmp/agent-env, so a cached login would otherwise attribute the new actor's + // work to the previous one (or reject their PR). + const token = resolveGithubToken(); + if (this.ghLoginPromise !== null && this.ghLoginToken === token) { + return this.ghLoginPromise; + } + this.ghLoginToken = token; + this.ghLoginPromise = execGh(["api", "user", "--jq", ".login"], { cwd: this.config.repositoryPath, timeoutMs: 10_000, + env: token === undefined ? undefined : ghTokenEnv(token), }) .then((res) => { const login = res.exitCode === 0 ? res.stdout.trim() : ""; diff --git a/packages/agent/src/utils/github-token.test.ts b/packages/agent/src/utils/github-token.test.ts index 08ef9428b9..3b1dccd254 100644 --- a/packages/agent/src/utils/github-token.test.ts +++ b/packages/agent/src/utils/github-token.test.ts @@ -49,10 +49,33 @@ describe("github-token", () => { ).toBeUndefined(); }); + it("fails closed ('') when the file exists but is unreadable (not ENOENT)", () => { + // A directory path triggers EISDIR, standing in for a transiently unreadable + // managed file during a transition — must not resurrect the process env. + const dir = mkdtempSync(join(tmpdir(), "agent-env-dir-")); + expect(readGithubTokenFromSandboxEnvFile(dir)).toBe(""); + }); + it("ignores an empty token value", () => { const path = writeEnvFile("GH_TOKEN=\0GITHUB_TOKEN=ghs_real\0"); expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_real"); }); + + it("returns '' (explicit logout) when every token var is present but empty", () => { + const path = writeEnvFile("PATH=/usr/bin\0GH_TOKEN=\0GITHUB_TOKEN=\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe(""); + }); + + it("returns '' (logout) when the managed file is truncated to zero bytes", () => { + // The backend logs the sandbox out by writing an empty file, not emptied vars. + expect(readGithubTokenFromSandboxEnvFile(writeEnvFile(""))).toBe(""); + expect(readGithubTokenFromSandboxEnvFile(writeEnvFile(" \n"))).toBe(""); + }); + + it("returns undefined when the file carries no token var at all", () => { + const path = writeEnvFile("PATH=/usr/bin\0HOME=/root\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBeUndefined(); + }); }); describe("resolveGithubToken", () => { @@ -72,5 +95,35 @@ describe("github-token", () => { "ghs_fromprocess", ); }); + + it("does not resurrect the process-env token after a logout (emptied file)", () => { + // The backend logs the sandbox out by emptying the token vars in the file. + // The frozen launch-time process env still holds the previous actor's + // token; resolving must NOT fall back to it. + vi.stubEnv("GH_TOKEN", "ghs_previous_actor"); + const path = writeEnvFile("GH_TOKEN=\0GITHUB_TOKEN=\0"); + expect(resolveGithubToken(path)).toBe(""); + }); + + it("does not resurrect the process-env token when the file is zero bytes (logout)", () => { + // The backend's actual logout truncates the file to zero bytes; resolving + // must treat that as logout, not fall back to the frozen process env. + vi.stubEnv("GH_TOKEN", "ghs_previous_actor"); + expect(resolveGithubToken(writeEnvFile(""))).toBe(""); + }); + + it("falls back to the process env when the file carries no token var", () => { + vi.stubEnv("GH_TOKEN", "ghs_fromprocess"); + const path = writeEnvFile("PATH=/usr/bin\0"); + expect(resolveGithubToken(path)).toBe("ghs_fromprocess"); + }); + + it("does not fall back to the process env when the file is unreadable", () => { + // Present-but-unreadable (EISDIR here) is a managed sandbox mid-transition, + // not an absent file, so it must not resurrect the frozen process token. + vi.stubEnv("GH_TOKEN", "ghs_previous_actor"); + const dir = mkdtempSync(join(tmpdir(), "agent-env-dir-")); + expect(resolveGithubToken(dir)).toBe(""); + }); }); }); diff --git a/packages/agent/src/utils/github-token.ts b/packages/agent/src/utils/github-token.ts index 1e1ae73228..5486cc9d48 100644 --- a/packages/agent/src/utils/github-token.ts +++ b/packages/agent/src/utils/github-token.ts @@ -1,5 +1,8 @@ import { readFileSync } from "node:fs"; -import { readGithubTokenFromEnv } from "@posthog/git/signed-commit"; +import { + GITHUB_TOKEN_ENV_VARS, + readGithubTokenFromEnv, +} from "@posthog/git/signed-commit"; // helpers for resolving the in-sandbox GitHub token // Dedicated agentsh credential file (NUL-delimited `key=value` pairs) that the @@ -12,19 +15,45 @@ const SANDBOX_GITHUB_ENV_FILE = "/tmp/agent-github-env"; export function readGithubTokenFromSandboxEnvFile( envFilePath: string = SANDBOX_GITHUB_ENV_FILE, ): string | undefined { + let raw: string; try { - const raw = readFileSync(envFilePath, "utf8"); - const env: Record = {}; - for (const entry of raw.split("\0")) { - const eq = entry.indexOf("="); - if (eq > 0) { - env[entry.slice(0, eq)] = entry.slice(eq + 1); - } + raw = readFileSync(envFilePath, "utf8"); + } catch (err) { + // A genuinely absent file (local/desktop or test) is unmanaged: signal that so + // the caller falls back to the process env. But an existing-yet-unreadable file + // during an actor transition must NOT resurrect the frozen process token, so + // treat any other read error as an explicit logout (fail closed). + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; } - // Reuse the shared token-var allowlist + precedence instead of hardcoding. - return readGithubTokenFromEnv(env); - } catch { - // No env file (local/desktop or test) — fall back to the process env. + return ""; + } + // The backend logs the sandbox out by truncating this file to zero bytes, so a + // successfully-read but empty (or whitespace-only) managed file is an explicit + // logout — return "" so the caller does NOT resurrect the previous actor's token + // from the frozen launch-time process env. Only an absent file is "unmanaged". + if (raw.trim() === "") { + return ""; + } + const env: Record = {}; + for (const entry of raw.split("\0")) { + const eq = entry.indexOf("="); + if (eq > 0) { + env[entry.slice(0, eq)] = entry.slice(eq + 1); + } + } + // A non-empty value wins by the shared token-var precedence. + const token = readGithubTokenFromEnv(env); + if (token) { + return token; + } + // The file is the backend's live credential channel. If it carries the token + // vars but they are emptied, that is an explicit logout on an actor + // transition — return "" so the caller does NOT resurrect the previous + // actor's token from the frozen launch-time process env. Only a file with no + // token vars at all is "unmanaged" and defers to the process env. + if (GITHUB_TOKEN_ENV_VARS.some((name) => name in env)) { + return ""; } return undefined; }