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 @@ -82,6 +83,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 @@ -3786,6 +3788,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 @@ -3794,6 +3805,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 @@ -3812,11 +3824,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
40 changes: 40 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,27 @@ 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 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 +89,28 @@ 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");
});

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("");
});
});
});
46 changes: 34 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
// agentsh env file (NUL-delimited `key=value` pairs) that the PostHog backend
Expand All @@ -12,19 +15,38 @@ export const SANDBOX_ENV_FILE = "/tmp/agent-env";
export function readGithubTokenFromSandboxEnvFile(
envFilePath: string = SANDBOX_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 "";
}
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;
}
Expand Down
Loading