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 {