From 9b0e6bdbefa5cfefeb2791de7f6d3652b0807d4a Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Fri, 29 May 2026 15:00:26 +0100 Subject: [PATCH] feat(cloud-agent): sandbox gh token refresh via refresh-session chore: clean --- .../agent/src/adapters/claude/claude-agent.ts | 8 +- .../agent/src/adapters/codex/codex-agent.ts | 3 +- .../local-tools/tools/signed-commit.ts | 8 +- packages/agent/src/server/agent-server.ts | 16 ++++ packages/agent/src/utils/common.ts | 6 -- packages/agent/src/utils/github-token.test.ts | 76 +++++++++++++++++++ packages/agent/src/utils/github-token.ts | 44 +++++++++++ 7 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 packages/agent/src/utils/github-token.test.ts create mode 100644 packages/agent/src/utils/github-token.ts diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index cf900998d7..3a2c3a5f65 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -58,12 +58,8 @@ import { type FileEnrichmentDeps, } from "../../enrichment/file-enricher"; import type { PostHogAPIConfig } from "../../types"; -import { - isCloudRun, - resolveGithubToken, - unreachable, - withTimeout, -} from "../../utils/common"; +import { isCloudRun, unreachable, withTimeout } from "../../utils/common"; +import { resolveGithubToken } from "../../utils/github-token"; import { Logger } from "../../utils/logger"; import { Pushable } from "../../utils/streams"; import { BaseAcpAgent } from "../base-acp-agent"; diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index f064778ec5..11a878c2ce 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -57,7 +57,8 @@ import { type PermissionMode, } from "../../execution-mode"; import type { PostHogAPIConfig, ProcessSpawnedCallback } from "../../types"; -import { isCloudRun, resolveGithubToken } from "../../utils/common"; +import { isCloudRun } from "../../utils/common"; +import { resolveGithubToken } from "../../utils/github-token"; import { Logger } from "../../utils/logger"; import { nodeReadableToWebReadable, diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts index 24b78e5f03..933a4c5158 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts @@ -1,4 +1,5 @@ -import { isCloudRun, resolveGithubToken } from "../../../utils/common"; +import { isCloudRun } from "../../../utils/common"; +import { resolveGithubToken } from "../../../utils/github-token"; import { runSignedCommitTool, SIGNED_COMMIT_TOOL_DESCRIPTION, @@ -21,7 +22,10 @@ export const signedCommitTool = defineLocalTool({ alwaysLoad: true, isEnabled: (_ctx, meta) => isCloudRun(meta), handler: (ctx, args) => { - const token = ctx.token ?? resolveGithubToken(); + // Prefer a freshly-resolved token (reads the live agentsh env file) over + // the one captured at session setup, so a mid-session credential refresh + // takes effect without rebuilding the session. + const token = resolveGithubToken() ?? ctx.token; if (!token) { return Promise.resolve({ content: [ diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 565137783a..c5307e44ff 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -750,6 +750,22 @@ export class AgentServer { const mcpServers = Array.isArray(params.mcpServers) ? params.mcpServers : []; + const refreshedCredentials = Array.isArray(params.refreshedCredentials) + ? (params.refreshedCredentials as string[]) + : []; + const authorship = + typeof params.authorship === "string" ? params.authorship : ""; + + if (refreshedCredentials.length > 0) { + const owner = authorship ? ` (${authorship})` : ""; + this.logger.debug( + `Refreshed sandbox credentials${owner}: ${refreshedCredentials.join(", ")}`, + ); + } + + if (mcpServers.length === 0) { + return { refreshed: true }; + } this.logger.debug("Refresh session requested", { serverCount: mcpServers.length, diff --git a/packages/agent/src/utils/common.ts b/packages/agent/src/utils/common.ts index 30f5542318..0ca649e7ff 100644 --- a/packages/agent/src/utils/common.ts +++ b/packages/agent/src/utils/common.ts @@ -1,4 +1,3 @@ -import { readGithubTokenFromEnv } from "@posthog/git/signed-commit"; import type { Logger } from "./logger"; /** @@ -39,11 +38,6 @@ export function isCloudRun( return !!process.env.IS_SANDBOX; } -/** The GitHub token available to the sandbox, if any. */ -export function resolveGithubToken(): string | undefined { - return readGithubTokenFromEnv(); -} - export function unreachable(value: never, logger: Logger): void { let valueAsString: string; try { diff --git a/packages/agent/src/utils/github-token.test.ts b/packages/agent/src/utils/github-token.test.ts new file mode 100644 index 0000000000..08ef9428b9 --- /dev/null +++ b/packages/agent/src/utils/github-token.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + readGithubTokenFromSandboxEnvFile, + resolveGithubToken, +} from "./github-token"; + +function writeEnvFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "agent-env-")); + const path = join(dir, "agent-env"); + writeFileSync(path, contents); + return path; +} + +describe("github-token", () => { + describe("readGithubTokenFromSandboxEnvFile", () => { + it.each([ + { + name: "GH_TOKEN", + contents: "PATH=/usr/bin\0GH_TOKEN=ghs_fresh123\0HOME=/root\0", + expected: "ghs_fresh123", + }, + { + name: "GITHUB_TOKEN when GH_TOKEN is absent", + contents: "GITHUB_TOKEN=ghu_user456\0PATH=/usr/bin\0", + expected: "ghu_user456", + }, + ])( + "reads $name from the NUL-delimited env file", + ({ contents, expected }) => { + expect(readGithubTokenFromSandboxEnvFile(writeEnvFile(contents))).toBe( + expected, + ); + }, + ); + + it("reflects an updated file (live read, not cached)", () => { + const path = writeEnvFile("GH_TOKEN=ghs_old\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_old"); + writeFileSync(path, "GH_TOKEN=ghs_new\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_new"); + }); + + it("returns undefined when the file is missing", () => { + expect( + readGithubTokenFromSandboxEnvFile("/nonexistent/agent-env"), + ).toBeUndefined(); + }); + + it("ignores an empty token value", () => { + const path = writeEnvFile("GH_TOKEN=\0GITHUB_TOKEN=ghs_real\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_real"); + }); + }); + + describe("resolveGithubToken", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("prefers the sandbox env file over the process env", () => { + vi.stubEnv("GH_TOKEN", "ghs_fromprocess"); + const path = writeEnvFile("GH_TOKEN=ghs_fromfile\0"); + expect(resolveGithubToken(path)).toBe("ghs_fromfile"); + }); + + it("falls back to the process env when the sandbox file is absent", () => { + vi.stubEnv("GH_TOKEN", "ghs_fromprocess"); + expect(resolveGithubToken("/nonexistent/agent-env")).toBe( + "ghs_fromprocess", + ); + }); + }); +}); diff --git a/packages/agent/src/utils/github-token.ts b/packages/agent/src/utils/github-token.ts new file mode 100644 index 0000000000..33a8f15a5e --- /dev/null +++ b/packages/agent/src/utils/github-token.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { 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 +// rewrites in place when it refreshes the sandbox's GitHub credentials +// mid-session. The agent-server process env is frozen at launch, so reading +// this live file is how in-process tools pick up a refreshed token without a +// process restart. +export const SANDBOX_ENV_FILE = "/tmp/agent-env"; + +export function readGithubTokenFromSandboxEnvFile( + envFilePath: string = SANDBOX_ENV_FILE, +): string | undefined { + 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); + } catch { + // No env file (local/desktop or test) — fall back to the process env. + } + return undefined; +} + +/** The GitHub token available to the sandbox, if any. + * + * Prefers the live agentsh env file (refreshed in place mid-session) over the + * process env (frozen at launch) so long-running in-process tools — e.g. the + * signed-commit tool — pick up a refreshed token without a restart. + */ +export function resolveGithubToken( + envFilePath: string = SANDBOX_ENV_FILE, +): string | undefined { + return ( + readGithubTokenFromSandboxEnvFile(envFilePath) ?? readGithubTokenFromEnv() + ); +}