From 7d8d1fbc1dfd342f6ee9bf2c31eeeca57ceb43ca Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:56:12 -0500 Subject: [PATCH] fix(adapter-utils): provide a default author for local commits (#18094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://entire.com/gh/paperclipai/-/trails/209/changes/paperclip/209 ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use managed launchers for Git and GitHub commands. > - These launchers exclude host credentials and host Git identity. > - A checkout without an author cannot save a local commit when managed GitHub identity is absent. > - A local author default belongs in the launcher that owns this isolation. > - This pull request supplies a private Paperclip default and preserves explicit and managed identity selection. > - Agents can save local work without manual author setup or extra GitHub access. ## Linked Issues or Issue Description **What happened?** An ordinary `git commit` fails with `Author identity unknown` in a fresh checkout without author settings or a usable managed GitHub identity. An agent must set its own author to continue. **Expected behavior** The managed launcher supplies a default local author. Repository and command settings override that default. A current managed identity retains its existing precedence. **Steps to reproduce** Run the managed launcher in a fresh Git repository without author settings or broker credentials. Add a file, then run `git commit -m work`. **Paperclip version or commit** Reproduced at `063cfd43813cabf18417605fb9eefb9aacf95c8d` on Linux with Node 25.9.0 and pnpm 9.15.4. **Deployment mode** Managed sandbox execution. The shared launcher also serves local and SSH execution that selects managed access. Related: #16662 changes precedence for a connected GitHub account. #8945 assigns identity to managed worktrees. This repair supplies the missing anonymous launcher default and keeps the current managed precedence. It does not require either change. ### Hypothesis and thesis for a fix **Hypothesis:** The launcher removes inherited author variables and host Git config. Without a broker identity or explicit repository settings, it has no author. Four real-Git cases reproduce the reported error on the base revision. **Thesis for a fix:** Put `Paperclip ` in a private Git config for each operation. Git treats it as global config, so repository and command settings can override it. The existing broker environment still takes precedence. Do not store a person's identity in shared checkout config, where it could remain after steering or revocation. The default grants no GitHub access. ## What Changed - Write the default author to the existing private operation directory with mode `0600`. Remove it when the operation exits. - Point managed Git at this file while keeping host global/system config, credentials and SSH identity excluded. - Add real-Git regressions for missing, absent, unavailable and rejected access. Preserve explicit overrides, identity changes and revocation controls. - Verify two shell commits through normal launcher staging and a commit after anonymous heartbeat cleanup. Check saved files, commit metadata and temporary config cleanup. - Update identity documentation and the two run map recipes. ## Verification - Red: the four new local-commit cases fail on the base launcher with `Author identity unknown`. - Green: all 80 focused tests pass in one run across `github-launcher.test.ts`, `github-launcher-environment.test.ts`, `heartbeat-github-launchers.test.ts` and `native-github-access.test.ts` (23.31 seconds). - A controlled filesystem failure first made the candidate discard valid managed credentials. After the correction, the same real launcher process retains them. Default-author file failure does not change operation-directory readiness. - Review found a second failure: a partial write can leave invalid Git config. The real-process test failed before the repair with `fatal: bad config line` from `git status`. The launcher now selects the default file only after the write succeeds. Both write-failure cases retain managed credentials, allow status, require identity when no default is available, and save a commit with explicit identity. The test reads the stored commit metadata with real Git. - Real shells, generated launchers and Git create and inspect saved commits. Sandbox transport and broker replies use controlled fixtures. No live cloud rollout or GitHub write acceptance is claimed. - The isolated test process excludes inherited task launcher paths, Git config and broker authentication variables. An initial run exposed those test-environment inputs; the isolated replay passed. The heartbeat assertion now checks the private default file and its cleanup instead of expecting `/dev/null`. - Adapter-utils typecheck and build pass. Map validation and `git diff --check` pass. - Conflict repair on 2026-10-29: merged master `fa5e70faf47ef2ca20bf2daf95b7503c94469b89` into the PR branch. Resolved 19 conflict paths by retaining master for unrelated files. All seven Git identity files remain byte-for-byte equal to the reviewed fix. The final diff is seven files, 145 additions and 15 deletions. - At head `c501e3f28369d404c91082b89188dc150c9362a9`, all 80 focused tests pass again (37.53 seconds). Adapter-utils typecheck and build pass. Map validation and migration checks pass. The PR diff passes `git diff --check`. - Full workspace typecheck and build were attempted again. Both stop because this sandbox has no Rust `cargo`. The duplicate full local Vitest run was stopped after about five minutes without a final result while full CI was running. The earlier full local attempt was stopped without a final result. No local full-suite pass is claimed. - GitHub now reports the PR as mergeable. All current-head checks have completed. [Full CI run 40878973679](https://github.com/paperclipai/paperclip/actions/runs/40878973679) passes, including typecheck, build, Rust, test shards and all eight E2E shards. The exact-head check inventory has 54 successes, one neutral Paperclip Review and two Storybook skips. Snyk passes. Greptile and Paperclip each reviewed `c501e3f28369d404c91082b89188dc150c9362a9` at 5/5 with no actionable findings. All review threads are resolved. GitHub reports the PR as clean and mergeable. - The prior CI run had one failed E2E test: `a lost creation response requires reload and cannot blindly replay commands`. Its YAML parser received page text that starts with `Skip to Main Content`. The new run passes E2E shard 7/8 and the full E2E gate. The prior failure remains part of this record. A passing run does not establish the earlier failure's cause. This change does not alter that test or the agent configuration UI. ## Risks - Commits without explicit or managed identity now use the generic Paperclip author. Operators can still set repository or command identity. - `git config --global` writes to temporary operation config and does not persist. The documentation states this behavior. - If the private config directory cannot be prepared, commits still need explicit identity. Existing redacted diagnostics and credential isolation remain. - One small synchronous file write is added to a directory the launcher already creates. No new subprocess, network request, persistent identity or authority cache is added. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, shell execution and tool use. The runtime does not expose the exact model ID or context window size to this agent. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-github-identity.md | 17 ++-- maps/agent-access-map/runs-adapters.md | 12 +++ maps/feature-map/runs-adapters.md | 11 +++ .../src/github-launcher-environment.test.ts | 23 ++++++ .../adapter-utils/src/github-launcher.test.ts | 78 ++++++++++++++++++- packages/adapter-utils/src/github-launcher.ts | 8 +- .../heartbeat-github-launchers.test.ts | 11 ++- 7 files changed, 145 insertions(+), 15 deletions(-) diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index 498cf704de5..ce3eaf8358d 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -1,6 +1,6 @@ # GitHub identity during agent execution -Shared agents use the GitHub connection of the person whose accepted instructions they are executing. Task ownership remains unchanged. GitHub is optional: ordinary work can start without a connection; a private checkout, authenticated API call, or commit can fail when that operation needs credentials or author metadata. +Shared agents use the GitHub connection of the person whose accepted instructions they are executing. Task ownership remains unchanged. GitHub is optional: ordinary work can start without a connection. Managed launchers provide a default author for local commits; private checkouts and authenticated API calls still require eligible credentials. ## Accepted instructions and continuations @@ -54,10 +54,17 @@ runner startup and provider startup. Shell operations acquire credentials for the active run through that broker. They do not require a persistent GitHub CLI login or inherit an ambient host broker capability. -Without a managed identity, local commits can use an explicitly configured -repository identity or `git -c user.name=... -c user.email=...`. The launcher -leaves author/committer environment variables unset and requires configured -identity instead of guessing the host user's details. Managed shell profiles +Without a managed identity, local commits default to +`Paperclip `. The launcher writes this token-free default +to a private per-operation global Git configuration file, removed on exit. +Repository identity and `git -c user.name=... -c user.email=...` override it. +The launcher leaves author/committer environment variables unset instead of +guessing the host user's details. `git config --global` changes only this +temporary file; use repository configuration for persistent overrides. +If the private configuration directory or default author file cannot be prepared, +Git ignores the incomplete file. Local commits still require an explicit +repository or command identity when no managed identity is available. A failed +default author write does not remove valid managed credentials. Managed shell profiles remove empty identity overrides after environment merging, so agents do not need to unset them per command. A captured managed identity still takes precedence over repository configuration. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 9fc5dbd9431..84b1e806d77 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -4,6 +4,18 @@ Product map: [runs-adapters](../feature-map/runs-adapters.md). Feature ID: `runs-adapters`. +Scoped local Git author contract (`runs-adapters/runs`): an admitted execution +with managed launchers can create a local commit without a GitHub grant. A +private per-operation Git config supplies `Paperclip ` +only as a default. Repository/command configuration and captured managed identity +retain precedence. The default conveys no API, repository-network or credential +authority; current run, company, trust and GitHub-grant checks stay in force. +The [launcher controls](../../packages/adapter-utils/src/github-launcher.test.ts) +exercise real Git with controlled broker responses; the +[staging controls](../../packages/adapter-utils/src/github-launcher-environment.test.ts) +exercise real local shells through a simulated sandbox transport. Neither +qualifies live cloud execution or GitHub writes. No REST operation changes. + Delegated native entries inherit the [native admission and work-mode limits](README.md#native-rest-transport). Review date: 2026-10-26. Source base: `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`; implementation: [#17737](https://github.com/paperclipai/paperclip/pull/17737). diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index e8d3f27196a..21470054436 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -40,6 +40,17 @@ The agent header has one Run now action. Administrators can enable Capture raw provider traces under the agent configuration’s Debugging section for future runs. The header shows Raw tracing on while that setting is enabled. +Managed Git launchers let agents save local commits without a GitHub connection +or manual author setup. Verify a fresh checkout and a second shell in the same +workspace: both commits should use `Paperclip ` when no +identity is configured. Repository and command settings override this default; +an eligible managed identity still takes precedence. Inspect stored commit +metadata and confirm the default did not change repository or host configuration. +Repeat with unavailable GitHub access; authenticated operations must still obey +the existing credential checks. The [launcher controls](../../packages/adapter-utils/src/github-launcher.test.ts) +and [real-shell staging controls](../../packages/adapter-utils/src/github-launcher-environment.test.ts) +cover this local behavior, separately from live cloud execution and GitHub writes. + For latency diagnosis with an operator OTLP endpoint, search the hashed run ID without restricting the root span name. Compare workspace preparation with its `workspace_` intervals. Measure controller readiness separately through diff --git a/packages/adapter-utils/src/github-launcher-environment.test.ts b/packages/adapter-utils/src/github-launcher-environment.test.ts index e7a19513173..b678e33725c 100644 --- a/packages/adapter-utils/src/github-launcher-environment.test.ts +++ b/packages/adapter-utils/src/github-launcher-environment.test.ts @@ -314,6 +314,29 @@ describe("managed GitHub launcher environment", () => { } }); + it("saves local commits through staged sandbox shells without author setup", async () => { + const fixture = await sandbox("usr/bin"); + await rm(path.join(fixture.bin, "git")); + await writeFile(path.join(fixture.root, ".gitconfig"), "[user]\nname = Image User\nemail = image@example.test\n"); + const env = await prepareGitHubOperationLaunchers({ + runId: "default-local-author", target: fixture.target, cwd: fixture.root, + env: githubBrokerEnvironment({}, { url: "", token: "" }), + }); + const command = async (script: string) => { + const result = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", script], env }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout.trim(); + }; + await command("git init -q && printf 'first\\n' > work.txt && git add work.txt && git commit -qm first"); + await command("printf 'second\\n' >> work.txt && git add work.txt && git commit -qm second"); + expect(await command("git log --format='%an <%ae>|%cn <%ce>'")) + .toBe(Array(2).fill("Paperclip |Paperclip ").join("\n")); + expect(await command("git show HEAD:work.txt")).toBe("first\nsecond"); + expect(await readFile(path.join(fixture.root, ".git", "config"), "utf8")).not.toContain("[user]"); + expect(await readFile(path.join(fixture.root, ".gitconfig"), "utf8")).toContain("Image User"); + expect(await readdir(env.GH_CONFIG_DIR!)).toEqual([]); + }); + it("keeps task-only startup free of command probes and host credentials", async () => { const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "plugin", remoteCwd: "/project", runnerTask: async () => ({}) }; await expect(prepareGitHubExecutionEnvironment({ target, cwd: "/absent-controller-path", env: {}, hostCredentials: false, networkAccess: true })) diff --git a/packages/adapter-utils/src/github-launcher.test.ts b/packages/adapter-utils/src/github-launcher.test.ts index 198848e74ea..7101b86ed43 100644 --- a/packages/adapter-utils/src/github-launcher.test.ts +++ b/packages/adapter-utils/src/github-launcher.test.ts @@ -11,6 +11,37 @@ const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); describe("managed GitHub launchers", () => { + it.each(["missing", "absent", "unavailable", "rejected"])("commits local work without an identity when GitHub access is %s", async (access) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-default-author-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const bin = path.join(root, "managed"); + await mkdir(bin); + await exec("git", ["init", root]); + await writeFile(path.join(bin, "git"), githubLauncherSource(), { mode: 0o700 }); + const server = createServer((_req, res) => { + res.writeHead(access === "rejected" ? 403 : 200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: access, env: { + GH_TOKEN: "rejected-token", GIT_AUTHOR_NAME: "Rejected", GIT_AUTHOR_EMAIL: "rejected@example.test", + GIT_COMMITTER_NAME: "Rejected", GIT_COMMITTER_EMAIL: "rejected@example.test", + } })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise(resolve => server.close(() => resolve()))); + const { port } = server.address() as { port: number }; + const env = { ...process.env, ...githubBrokerEnvironment({ + GH_TOKEN: "host-token", GIT_AUTHOR_NAME: "Host", GIT_AUTHOR_EMAIL: "host@example.test", + GIT_COMMITTER_NAME: "Host", GIT_COMMITTER_EMAIL: "host@example.test", + }, { url: `http://127.0.0.1:${port}`, token: access === "missing" ? "" : "run-capability" }), + PATH: `${bin}:${process.env.PATH}` }; + await writeFile(path.join(root, "work.txt"), "saved work\n"); + await exec(path.join(bin, "git"), ["add", "work.txt"], { cwd: root, env }); + await exec(path.join(bin, "git"), ["commit", "-m", "Save local work"], { cwd: root, env }); + expect((await exec("git", ["-C", root, "log", "-1", "--format=%an <%ae>|%cn <%ce>"])).stdout.trim()) + .toBe("Paperclip |Paperclip "); + expect((await exec("git", ["-C", root, "show", "HEAD:work.txt"])).stdout).toBe("saved work\n"); + await expect(exec("git", ["-C", root, "config", "--local", "--get", "user.name"])).rejects.toThrow(); + await expect(exec("git", ["-C", root, "config", "--local", "--get", "user.email"])).rejects.toThrow(); + }); it.each(["repository", "command"])("uses explicit %s identity for local commits without managed credentials", async (identitySource) => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-local-identity-")); cleanups.push(() => rm(root, { recursive: true, force: true })); @@ -22,9 +53,8 @@ describe("managed GitHub launchers", () => { GH_TOKEN: "host-token", GIT_AUTHOR_NAME: "Host", GIT_COMMITTER_NAME: "Host", }, { url: "", token: "" }), PATH: `${bin}:${process.env.PATH}` }; const git = async (...args: string[]) => (await exec(path.join(bin, "git"), args, { cwd: root, env })).stdout.trim(); - // No configured identity must fail, rather than guessing the host user's. - await expect(git("var", "GIT_AUTHOR_IDENT")).rejects.toThrow(); - await expect(git("var", "GIT_COMMITTER_IDENT")).rejects.toThrow(); + expect(await git("var", "GIT_AUTHOR_IDENT")).toMatch(/^Paperclip /); + expect(await git("var", "GIT_COMMITTER_IDENT")).toMatch(/^Paperclip /); if (identitySource === "repository") { await git("config", "user.name", "Local Author"); await git("config", "user.email", "local@example.test"); @@ -82,6 +112,44 @@ describe("managed GitHub launchers", () => { expect(result.stderr).toContain("More than one managed GitHub identity matches this run"); expect(result.stderr).not.toMatch(/host-token|must-not-be-used|run-capability/); }); + it.each(["before-write", "partial-write"])("keeps Git and managed credentials available after a %s failure", async (failure) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-default-write-failure-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const bin = path.join(root, "managed"), realBin = path.join(root, "real"); + await mkdir(bin); await mkdir(realBin); + await exec("git", ["init", root]); + for (const name of ["git", "gh"]) await writeFile(path.join(bin, name), githubLauncherSource(), { mode: 0o700 }); + await writeFile(path.join(realBin, "gh"), '#!/usr/bin/env node\nprocess.stdout.write(process.env.GH_TOKEN || "anonymous");', { mode: 0o700 }); + const fault = path.join(root, "write-fault.cjs"); + await writeFile(fault, `const fs = require('node:fs'); const write = fs.writeFileSync; +fs.writeFileSync = (file, ...args) => { + if (String(file).endsWith('/gitconfig')) { + if (${failure === "partial-write"}) write(file, '[user', { mode: 0o600 }); + throw new Error('fixture disk full'); + } + return write(file, ...args); +};`); + const server = createServer((_req, res) => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ status: "available", env: { GH_TOKEN: "managed-token" } })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise(resolve => server.close(() => resolve()))); + const { port } = server.address() as { port: number }; + const env = { + ...process.env, ...githubBrokerEnvironment({}, { url: `http://127.0.0.1:${port}`, token: "run-capability" }), + PATH: `${bin}:${realBin}:${process.env.PATH}`, + }; + const result = await exec(process.execPath, ["--require", fault, path.join(bin, "gh")], { cwd: root, env }); + expect(result.stdout).toBe("managed-token"); + expect(result.stderr).toContain("configuration_directory_unavailable"); + const git = (...args: string[]) => exec(process.execPath, ["--require", fault, path.join(bin, "git"), ...args], { cwd: root, env }); + expect((await git("status", "--porcelain", "--untracked-files=no")).stdout).toBe(""); + await expect(git("commit", "--allow-empty", "-m", "No default")).rejects.toMatchObject({ stderr: expect.stringContaining("Author identity unknown") }); + await git("-c", "user.name=Explicit", "-c", "user.email=explicit@example.test", "commit", "--allow-empty", "-m", "Explicit identity"); + expect((await exec("git", ["-C", root, "log", "-1", "--format=%an <%ae>|%cn <%ce>"])).stdout.trim()) + .toBe("Explicit |Explicit "); + }); // The first broker request fails, the second succeeds: the managed token must still reach gh. it.each([ ["connection drops before the response", (res: import("node:http").ServerResponse) => { res.socket?.destroy(); }], @@ -177,7 +245,9 @@ process.stdout.write(JSON.stringify({identity, token:process.env.GH_TOKEN ?? nul expect(anonymous.token).toBeNull(); await git("config", "--unset", "user.name"); await git("config", "--unset", "user.email"); - await expect(git("var", "GIT_AUTHOR_IDENT")).rejects.toThrow(); + await git("commit", "--allow-empty", "-m", "Default identity after revocation"); + expect(await git("log", "-1", "--format=%an <%ae>|%cn <%ce>")) + .toBe("Paperclip |Paperclip "); expect(await git("status", "--porcelain")).toBe(""); // unrelated public/local Git still works expect(env.GH_TOKEN).toBe(""); expect(env.GIT_AUTHOR_NAME).toBe(""); diff --git a/packages/adapter-utils/src/github-launcher.ts b/packages/adapter-utils/src/github-launcher.ts index 340b81ae42d..2b16211745c 100644 --- a/packages/adapter-utils/src/github-launcher.ts +++ b/packages/adapter-utils/src/github-launcher.ts @@ -39,12 +39,15 @@ async function main() { // fallback deliberately cannot load the host's gh authentication files. let configDirectory = path.join(directory, 'unavailable-gh-config'); let configReady = false; + let authorReady = false; try { fs.mkdirSync(configRoot, { recursive: true, mode: 0o700 }); configDirectory = fs.mkdtempSync(path.join(configRoot, 'paperclip-github-operation-')); fs.chmodSync(configDirectory, 0o700); configReady = true; process.once('exit', () => { try { fs.rmSync(configDirectory, { recursive: true, force: true }); } catch {} }); + fs.writeFileSync(path.join(configDirectory, 'gitconfig'), '[user]\n\tname = Paperclip\n\temail = noreply@paperclip.ing\n', { mode: 0o600 }); + authorReady = true; } catch { diagnostic('configuration_directory_unavailable'); } { for (const key of Object.keys(env)) { @@ -52,11 +55,8 @@ async function main() { } Object.assign(env, { GH_CONFIG_DIR: configDirectory, SSH_AUTH_SOCK: '', - GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', + GIT_CONFIG_GLOBAL: authorReady ? path.join(configDirectory, 'gitconfig') : '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_TERMINAL_PROMPT: '0', - // The inherited identity was deleted above. Empty identity env values - // override even explicit repository/command config and break local commits. - // Require configured identity instead of guessing the OS user's details. GIT_CONFIG_COUNT: '5', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_1: 'git@github.com:', GIT_CONFIG_KEY_2: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_2: 'ssh://git@github.com/', diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts index 10626f369be..fcf402f9a1b 100644 --- a/server/src/services/heartbeat-github-launchers.test.ts +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -97,7 +97,7 @@ it("keeps anonymous wrappers usable after run cleanup and excludes sandbox image await writeFile(path.join(root, ".config", "gh", "hosts.yml"), "ambient-image-secret"); await writeFile(path.join(bin, "gh"), `#!${process.execPath} const fs = require('node:fs'); -process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 }); +process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, globalConfig:fs.readFileSync(process.env.GIT_CONFIG_GLOBAL, 'utf8'), imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 }); const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" }; const execute: CommandManagedRuntimeRunner["execute"] = async (input) => { const startedAt = new Date().toISOString(); @@ -117,7 +117,14 @@ process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubTok expect(await readFile(path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain("ambient-token"); // A still-live provider uses its first-turn environment, not the new one. const command = await execute({ command: path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), env: first.env }); - expect(JSON.parse(command.stdout)).toEqual({ token: "", githubToken: "", ssh: "", global: "/dev/null", imageConfig: false }); + const result = JSON.parse(command.stdout); + expect(result).toEqual({ token: "", githubToken: "", ssh: "", imageConfig: false, + global: expect.stringMatching(/\/paperclip-github-operation-[^/]+\/gitconfig$/), + globalConfig: "[user]\n\tname = Paperclip\n\temail = noreply@paperclip.ing\n" }); + await expect(readFile(result.global)).rejects.toMatchObject({ code: "ENOENT" }); + const commit = await execute({ command: "bash", args: ["--noprofile", "--norc", "-c", + "git init -q && git commit --allow-empty -qm 'Local work' && git log -1 --format='%an <%ae>|%cn <%ce>'"], env: first.env }); + expect(commit.stdout.trim()).toBe("Paperclip |Paperclip "); const changedPath = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-three", env: { ...imageEnv, PATH: `${imageEnv.PATH}:/extra` } }); expect(changedPath.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).not.toBe(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR); } finally {