fix(adapter-utils): provide a default author for local commits (#18094)

<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/209/changes/paperclip/209
<!-- entire-trail-link-end -->

## 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 <noreply@paperclip.ing>` 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-29 08:56:12 -05:00
1 parent 018c4c9c9b
commit 7d8d1fbc1d
7 files changed
+145 -15

No files matched your search

+12 -5
View File
@@ -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 <noreply@paperclip.ing>`. 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.
+12
View File
@@ -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 <noreply@paperclip.ing>`
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).
+11
View File
@@ -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 <noreply@paperclip.ing>` 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_<step>` intervals. Measure controller readiness separately through
@@ -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 <noreply@paperclip.ing>|Paperclip <noreply@paperclip.ing>").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 }))
@@ -11,6 +11,37 @@ const cleanups: Array<() => Promise<unknown>> = [];
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<void>(resolve => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>(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 <noreply@paperclip.ing>|Paperclip <noreply@paperclip.ing>");
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 <noreply@paperclip.ing>/);
expect(await git("var", "GIT_COMMITTER_IDENT")).toMatch(/^Paperclip <noreply@paperclip.ing>/);
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<void>(resolve => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>(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@example.test>|Explicit <explicit@example.test>");
});
// 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 <noreply@paperclip.ing>|Paperclip <noreply@paperclip.ing>");
expect(await git("status", "--porcelain")).toBe(""); // unrelated public/local Git still works
expect(env.GH_TOKEN).toBe("");
expect(env.GIT_AUTHOR_NAME).toBe("");
@@ -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/',
@@ -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 <noreply@paperclip.ing>|Paperclip <noreply@paperclip.ing>");
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 {