Skip to content
Open
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
10 changes: 9 additions & 1 deletion packages/agent/src/adapters/local-tools/tools/list-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
24 changes: 23 additions & 1 deletion packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> | undefined {
const token = resolveGithubToken();
return token === undefined ? undefined : ghTokenEnv(token);
}

private async fetchPrAttribution(
prUrl: string,
): Promise<{ createdAt: string | null; author: string | null }> {
Expand All @@ -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 };
Expand All @@ -4431,11 +4443,21 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
}

private ghLoginPromise: Promise<string | null> | null = null;
private ghLoginToken: string | undefined;

private fetchGhLogin(): Promise<string | null> {
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() : "";
Expand Down
53 changes: 53 additions & 0 deletions packages/agent/src/utils/github-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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("");
});
});
});
53 changes: 41 additions & 12 deletions packages/agent/src/utils/github-token.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string> = {};
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<string, string> = {};
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 "";
Comment thread
veria-ai[bot] marked this conversation as resolved.
}
return undefined;
}
Comment on lines 57 to 59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BE clears this file to zero bytes, which returns undefined here and lets consumers fall back to the previous actor’s cached token. Can we tsreat any successfully read empty credential file as logout and add a zero-byte regression test just to make this a bit cleaner perhaps?

Expand Down
Loading