Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 01134be

Browse files
fix(agent): treat an emptied sandbox env file as GitHub logout (#3611)
1 parent 1f174f1 commit 01134be

4 files changed

Lines changed: 126 additions & 14 deletions

File tree

packages/agent/src/adapters/local-tools/tools/list-repos.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,16 @@ export const listReposTool = defineLocalTool({
6060
);
6161

6262
try {
63+
// An empty token is a managed logout, not "no preference": clear both token
64+
// vars so gh cannot fall back to the previous actor's frozen process-env
65+
// token and enumerate their private repos. Only an undefined token (an
66+
// unmanaged local/desktop sandbox) inherits the process env unchanged.
67+
const env =
68+
token === undefined
69+
? process.env
70+
: { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token };
6371
const { stdout } = await execFileAsync("gh", cmdArgs, {
64-
env: token ? { ...process.env, GH_TOKEN: token } : process.env,
72+
env,
6573
maxBuffer: 1024 * 1024 * 8,
6674
});
6775
const parsed = ghRepoSchema.safeParse(JSON.parse(stdout));

packages/agent/src/server/agent-server.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { type ServerType, serve } from "@hono/node-server";
1717
import { execGh } from "@posthog/git/gh";
1818
import { getCurrentBranch } from "@posthog/git/queries";
19+
import { ghTokenEnv } from "@posthog/git/signed-commit";
1920
import {
2021
type Adapter,
2122
buildPrOutput,
@@ -94,6 +95,7 @@ import {
9495
resolveGatewayProduct,
9596
resolveLlmGatewayUrl,
9697
} from "../utils/gateway";
98+
import { resolveGithubToken } from "../utils/github-token";
9799
import { Logger } from "../utils/logger";
98100
import { logAgentshRuntimeInfo } from "./agentsh-runtime";
99101
import {
@@ -4405,6 +4407,15 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
44054407
}
44064408
}
44074409

4410+
/** Env for a `gh` call that must run as the *current* actor. Prefers the live
4411+
* sandbox token (rewritten on an actor transition) over the process env
4412+
* (frozen at launch); returns undefined when unmanaged (local/desktop) so
4413+
* execGh falls back to the process env. */
4414+
private ghActorEnv(): Record<string, string> | undefined {
4415+
const token = resolveGithubToken();
4416+
return token === undefined ? undefined : ghTokenEnv(token);
4417+
}
4418+
44084419
private async fetchPrAttribution(
44094420
prUrl: string,
44104421
): Promise<{ createdAt: string | null; author: string | null }> {
@@ -4413,6 +4424,7 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
44134424
{
44144425
cwd: this.config.repositoryPath,
44154426
timeoutMs: 10_000,
4427+
env: this.ghActorEnv(),
44164428
},
44174429
);
44184430
if (res.exitCode !== 0) return { createdAt: null, author: null };
@@ -4431,11 +4443,21 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
44314443
}
44324444

44334445
private ghLoginPromise: Promise<string | null> | null = null;
4446+
private ghLoginToken: string | undefined;
44344447

44354448
private fetchGhLogin(): Promise<string | null> {
4436-
this.ghLoginPromise ??= execGh(["api", "user", "--jq", ".login"], {
4449+
// Key the memoized login on the live token: an actor transition rebinds
4450+
// /tmp/agent-env, so a cached login would otherwise attribute the new actor's
4451+
// work to the previous one (or reject their PR).
4452+
const token = resolveGithubToken();
4453+
if (this.ghLoginPromise !== null && this.ghLoginToken === token) {
4454+
return this.ghLoginPromise;
4455+
}
4456+
this.ghLoginToken = token;
4457+
this.ghLoginPromise = execGh(["api", "user", "--jq", ".login"], {
44374458
cwd: this.config.repositoryPath,
44384459
timeoutMs: 10_000,
4460+
env: token === undefined ? undefined : ghTokenEnv(token),
44394461
})
44404462
.then((res) => {
44414463
const login = res.exitCode === 0 ? res.stdout.trim() : "";

packages/agent/src/utils/github-token.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,33 @@ describe("github-token", () => {
4949
).toBeUndefined();
5050
});
5151

52+
it("fails closed ('') when the file exists but is unreadable (not ENOENT)", () => {
53+
// A directory path triggers EISDIR, standing in for a transiently unreadable
54+
// managed file during a transition — must not resurrect the process env.
55+
const dir = mkdtempSync(join(tmpdir(), "agent-env-dir-"));
56+
expect(readGithubTokenFromSandboxEnvFile(dir)).toBe("");
57+
});
58+
5259
it("ignores an empty token value", () => {
5360
const path = writeEnvFile("GH_TOKEN=\0GITHUB_TOKEN=ghs_real\0");
5461
expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_real");
5562
});
63+
64+
it("returns '' (explicit logout) when every token var is present but empty", () => {
65+
const path = writeEnvFile("PATH=/usr/bin\0GH_TOKEN=\0GITHUB_TOKEN=\0");
66+
expect(readGithubTokenFromSandboxEnvFile(path)).toBe("");
67+
});
68+
69+
it("returns '' (logout) when the managed file is truncated to zero bytes", () => {
70+
// The backend logs the sandbox out by writing an empty file, not emptied vars.
71+
expect(readGithubTokenFromSandboxEnvFile(writeEnvFile(""))).toBe("");
72+
expect(readGithubTokenFromSandboxEnvFile(writeEnvFile(" \n"))).toBe("");
73+
});
74+
75+
it("returns undefined when the file carries no token var at all", () => {
76+
const path = writeEnvFile("PATH=/usr/bin\0HOME=/root\0");
77+
expect(readGithubTokenFromSandboxEnvFile(path)).toBeUndefined();
78+
});
5679
});
5780

5881
describe("resolveGithubToken", () => {
@@ -72,5 +95,35 @@ describe("github-token", () => {
7295
"ghs_fromprocess",
7396
);
7497
});
98+
99+
it("does not resurrect the process-env token after a logout (emptied file)", () => {
100+
// The backend logs the sandbox out by emptying the token vars in the file.
101+
// The frozen launch-time process env still holds the previous actor's
102+
// token; resolving must NOT fall back to it.
103+
vi.stubEnv("GH_TOKEN", "ghs_previous_actor");
104+
const path = writeEnvFile("GH_TOKEN=\0GITHUB_TOKEN=\0");
105+
expect(resolveGithubToken(path)).toBe("");
106+
});
107+
108+
it("does not resurrect the process-env token when the file is zero bytes (logout)", () => {
109+
// The backend's actual logout truncates the file to zero bytes; resolving
110+
// must treat that as logout, not fall back to the frozen process env.
111+
vi.stubEnv("GH_TOKEN", "ghs_previous_actor");
112+
expect(resolveGithubToken(writeEnvFile(""))).toBe("");
113+
});
114+
115+
it("falls back to the process env when the file carries no token var", () => {
116+
vi.stubEnv("GH_TOKEN", "ghs_fromprocess");
117+
const path = writeEnvFile("PATH=/usr/bin\0");
118+
expect(resolveGithubToken(path)).toBe("ghs_fromprocess");
119+
});
120+
121+
it("does not fall back to the process env when the file is unreadable", () => {
122+
// Present-but-unreadable (EISDIR here) is a managed sandbox mid-transition,
123+
// not an absent file, so it must not resurrect the frozen process token.
124+
vi.stubEnv("GH_TOKEN", "ghs_previous_actor");
125+
const dir = mkdtempSync(join(tmpdir(), "agent-env-dir-"));
126+
expect(resolveGithubToken(dir)).toBe("");
127+
});
75128
});
76129
});

packages/agent/src/utils/github-token.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { readFileSync } from "node:fs";
2-
import { readGithubTokenFromEnv } from "@posthog/git/signed-commit";
2+
import {
3+
GITHUB_TOKEN_ENV_VARS,
4+
readGithubTokenFromEnv,
5+
} from "@posthog/git/signed-commit";
36

47
// helpers for resolving the in-sandbox GitHub token
58
// Dedicated agentsh credential file (NUL-delimited `key=value` pairs) that the
@@ -12,19 +15,45 @@ const SANDBOX_GITHUB_ENV_FILE = "/tmp/agent-github-env";
1215
export function readGithubTokenFromSandboxEnvFile(
1316
envFilePath: string = SANDBOX_GITHUB_ENV_FILE,
1417
): string | undefined {
18+
let raw: string;
1519
try {
16-
const raw = readFileSync(envFilePath, "utf8");
17-
const env: Record<string, string> = {};
18-
for (const entry of raw.split("\0")) {
19-
const eq = entry.indexOf("=");
20-
if (eq > 0) {
21-
env[entry.slice(0, eq)] = entry.slice(eq + 1);
22-
}
20+
raw = readFileSync(envFilePath, "utf8");
21+
} catch (err) {
22+
// A genuinely absent file (local/desktop or test) is unmanaged: signal that so
23+
// the caller falls back to the process env. But an existing-yet-unreadable file
24+
// during an actor transition must NOT resurrect the frozen process token, so
25+
// treat any other read error as an explicit logout (fail closed).
26+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
27+
return undefined;
2328
}
24-
// Reuse the shared token-var allowlist + precedence instead of hardcoding.
25-
return readGithubTokenFromEnv(env);
26-
} catch {
27-
// No env file (local/desktop or test) — fall back to the process env.
29+
return "";
30+
}
31+
// The backend logs the sandbox out by truncating this file to zero bytes, so a
32+
// successfully-read but empty (or whitespace-only) managed file is an explicit
33+
// logout — return "" so the caller does NOT resurrect the previous actor's token
34+
// from the frozen launch-time process env. Only an absent file is "unmanaged".
35+
if (raw.trim() === "") {
36+
return "";
37+
}
38+
const env: Record<string, string> = {};
39+
for (const entry of raw.split("\0")) {
40+
const eq = entry.indexOf("=");
41+
if (eq > 0) {
42+
env[entry.slice(0, eq)] = entry.slice(eq + 1);
43+
}
44+
}
45+
// A non-empty value wins by the shared token-var precedence.
46+
const token = readGithubTokenFromEnv(env);
47+
if (token) {
48+
return token;
49+
}
50+
// The file is the backend's live credential channel. If it carries the token
51+
// vars but they are emptied, that is an explicit logout on an actor
52+
// transition — return "" so the caller does NOT resurrect the previous
53+
// actor's token from the frozen launch-time process env. Only a file with no
54+
// token vars at all is "unmanaged" and defers to the process env.
55+
if (GITHUB_TOKEN_ENV_VARS.some((name) => name in env)) {
56+
return "";
2857
}
2958
return undefined;
3059
}

0 commit comments

Comments
 (0)