From 3c607e11541404d6ad0d76c86237415511181dfa Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Tue, 21 Jul 2026 11:21:58 +0200 Subject: [PATCH 1/4] fix(agent): treat an emptied sandbox env file as GitHub logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveGithubToken prefers the live /tmp/agent-env file (which the backend rewrites when the sandbox's actor changes mid-session) but fell back to the process env whenever the file yielded no token. On an actor transition the backend logs the sandbox out by emptying the token vars in that file, so the fallback resurrected the previous actor's token — frozen in the agent-server's launch-time process env — letting a follow-up actor push as the prior actor. Distinguish a file that carries the token vars but empties them (an explicit logout: return "") from a file that never had them (unmanaged: defer to the process env). Only the latter falls back. --- packages/agent/src/utils/github-token.test.ts | 25 +++++++++++ packages/agent/src/utils/github-token.ts | 41 +++++++++++++------ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/agent/src/utils/github-token.test.ts b/packages/agent/src/utils/github-token.test.ts index 08ef9428b9..20029f9d40 100644 --- a/packages/agent/src/utils/github-token.test.ts +++ b/packages/agent/src/utils/github-token.test.ts @@ -53,6 +53,16 @@ describe("github-token", () => { 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 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 +82,20 @@ 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("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"); + }); }); }); diff --git a/packages/agent/src/utils/github-token.ts b/packages/agent/src/utils/github-token.ts index 1e1ae73228..2bae9f98de 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,33 @@ 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); - } - } - // Reuse the shared token-var allowlist + precedence instead of hardcoding. - return readGithubTokenFromEnv(env); + raw = readFileSync(envFilePath, "utf8"); } catch { - // No env file (local/desktop or test) — fall back to the process env. + // No env file (local/desktop or test) — signal "unmanaged" so the caller + // falls back to the process env. + return undefined; + } + 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; } From 2e4665ba431cbe5e186d8fc6973efe0e1a0156b4 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Tue, 21 Jul 2026 11:41:26 +0200 Subject: [PATCH 2/4] fix(agent): run gh attribution/whoami as the current actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchPrAttribution and fetchGhLogin called gh with no env, inheriting the process env — the actor's token frozen at launch. After a mid-session actor transition that reports the wrong identity, and once the backend stops baking the token into the process env these calls would run unauthenticated. Resolve the live sandbox token (same file-first path as the signed-commit tools) and pass it explicitly; fall back to the process env only when unmanaged. --- packages/agent/src/server/agent-server.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 224f42fa16..05118b481e 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 }; @@ -4436,6 +4448,7 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} this.ghLoginPromise ??= execGh(["api", "user", "--jq", ".login"], { cwd: this.config.repositoryPath, timeoutMs: 10_000, + env: this.ghActorEnv(), }) .then((res) => { const login = res.exitCode === 0 ? res.stdout.trim() : ""; From 9d80e3110da59a42941c7969ba8c2f5a6713b980 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Tue, 21 Jul 2026 14:58:56 +0200 Subject: [PATCH 3/4] fix(agent): honor the logout sentinel in every token consumer Address review findings on the emptied-env-file logout: - list_repos built its gh env with a truthiness check, so an empty (logged-out) token fell back to the raw process env and could enumerate the previous actor's private repos. Clear both token vars on a managed logout; only an undefined (unmanaged) token inherits the process env. - fetchGhLogin memoized the login across actor transitions, so after a rebind attribution used the previous actor's login. Key the cache on the live token. - readGithubTokenFromSandboxEnvFile treated every read error as an unmanaged sandbox; a present-but-unreadable file during a transition then resurrected the frozen process token. Only ENOENT falls back; other errors fail closed. --- .../src/adapters/local-tools/tools/list-repos.ts | 10 +++++++++- packages/agent/src/server/agent-server.ts | 13 +++++++++++-- packages/agent/src/utils/github-token.test.ts | 15 +++++++++++++++ packages/agent/src/utils/github-token.ts | 13 +++++++++---- 4 files changed, 44 insertions(+), 7 deletions(-) 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 05118b481e..0b980a6b4a 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -4443,12 +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: this.ghActorEnv(), + 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 20029f9d40..bbed0db637 100644 --- a/packages/agent/src/utils/github-token.test.ts +++ b/packages/agent/src/utils/github-token.test.ts @@ -49,6 +49,13 @@ 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"); @@ -97,5 +104,13 @@ describe("github-token", () => { 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 2bae9f98de..c4c7d65561 100644 --- a/packages/agent/src/utils/github-token.ts +++ b/packages/agent/src/utils/github-token.ts @@ -18,10 +18,15 @@ export function readGithubTokenFromSandboxEnvFile( let raw: string; try { raw = readFileSync(envFilePath, "utf8"); - } catch { - // No env file (local/desktop or test) — signal "unmanaged" so the caller - // falls back to the process env. - return undefined; + } 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; + } + return ""; } const env: Record = {}; for (const entry of raw.split("\0")) { From 4923c1efe40134fcd89b875fbd77d9bd84230d75 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Thu, 23 Jul 2026 14:16:03 +0200 Subject: [PATCH 4/4] fix(agent): treat a zero-byte credential file as logout The backend logs the sandbox out by truncating the github env file to zero bytes. The resolver returned undefined for that shape, letting consumers fall back to the frozen process-env token and resurrect the previous actor's credentials. Treat an empty (or whitespace-only) managed file as an explicit logout (return ""), with regression tests for the zero-byte case. --- packages/agent/src/utils/github-token.test.ts | 13 +++++++++++++ packages/agent/src/utils/github-token.ts | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/packages/agent/src/utils/github-token.test.ts b/packages/agent/src/utils/github-token.test.ts index bbed0db637..3b1dccd254 100644 --- a/packages/agent/src/utils/github-token.test.ts +++ b/packages/agent/src/utils/github-token.test.ts @@ -66,6 +66,12 @@ describe("github-token", () => { 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(); @@ -99,6 +105,13 @@ describe("github-token", () => { 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"); diff --git a/packages/agent/src/utils/github-token.ts b/packages/agent/src/utils/github-token.ts index c4c7d65561..5486cc9d48 100644 --- a/packages/agent/src/utils/github-token.ts +++ b/packages/agent/src/utils/github-token.ts @@ -28,6 +28,13 @@ export function readGithubTokenFromSandboxEnvFile( } 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("=");