Skip to content

Commit 775a0f1

Browse files
committed
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.
1 parent b1f0509 commit 775a0f1

4 files changed

Lines changed: 44 additions & 7 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: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3824,12 +3824,21 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
38243824
}
38253825

38263826
private ghLoginPromise: Promise<string | null> | null = null;
3827+
private ghLoginToken: string | undefined;
38273828

38283829
private fetchGhLogin(): Promise<string | null> {
3829-
this.ghLoginPromise ??= execGh(["api", "user", "--jq", ".login"], {
3830+
// Key the memoized login on the live token: an actor transition rebinds
3831+
// /tmp/agent-env, so a cached login would otherwise attribute the new actor's
3832+
// work to the previous one (or reject their PR).
3833+
const token = resolveGithubToken();
3834+
if (this.ghLoginPromise !== null && this.ghLoginToken === token) {
3835+
return this.ghLoginPromise;
3836+
}
3837+
this.ghLoginToken = token;
3838+
this.ghLoginPromise = execGh(["api", "user", "--jq", ".login"], {
38303839
cwd: this.config.repositoryPath,
38313840
timeoutMs: 10_000,
3832-
env: this.ghActorEnv(),
3841+
env: token === undefined ? undefined : ghTokenEnv(token),
38333842
})
38343843
.then((res) => {
38353844
const login = res.exitCode === 0 ? res.stdout.trim() : "";

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ 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");
@@ -97,5 +104,13 @@ describe("github-token", () => {
97104
const path = writeEnvFile("PATH=/usr/bin\0");
98105
expect(resolveGithubToken(path)).toBe("ghs_fromprocess");
99106
});
107+
108+
it("does not fall back to the process env when the file is unreadable", () => {
109+
// Present-but-unreadable (EISDIR here) is a managed sandbox mid-transition,
110+
// not an absent file, so it must not resurrect the frozen process token.
111+
vi.stubEnv("GH_TOKEN", "ghs_previous_actor");
112+
const dir = mkdtempSync(join(tmpdir(), "agent-env-dir-"));
113+
expect(resolveGithubToken(dir)).toBe("");
114+
});
100115
});
101116
});

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ export function readGithubTokenFromSandboxEnvFile(
1818
let raw: string;
1919
try {
2020
raw = readFileSync(envFilePath, "utf8");
21-
} catch {
22-
// No env file (local/desktop or test) — signal "unmanaged" so the caller
23-
// falls back to the process env.
24-
return undefined;
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;
28+
}
29+
return "";
2530
}
2631
const env: Record<string, string> = {};
2732
for (const entry of raw.split("\0")) {

0 commit comments

Comments
 (0)