Files
PaperClipAI/packages/adapter-utils/src/github-launcher.ts
T
Jamie Buchanan 3339582810 fix(adapter-utils): stop asking the GitHub broker on every local git call (#17886)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs use `git` and `gh` through the managed GitHub launcher.
> - The launcher asks the broker for credentials before every `git` and
`gh` call. It also asks for local commands that do not use the network,
such as `git status`.
> - Each request runs a transaction and an UPDATE in the database. On a
busy install this gives hundreds of thousands of writes each day and
long COMMIT waits.
> - This pull request stops the request for local `git` commands and
writes the identity summary only when it changes.
> - The benefit is fewer broker requests and fewer database writes. The
credential rules do not change.

## Linked Issues or Issue Description

- Fixes #17879

## What Changed

- The managed `git` launcher does not ask the broker for local
subcommands (`status`, `diff`, `log`, `rev-parse`, `add`, `config`,
`branch`, `checkout` and others). It reads past global options such as
`-C` and `-c`. An unknown subcommand or option still asks. `commit` and
`var` still ask, because the managed author comes from the broker
answer.
- The launcher keeps no broker answer between invocations, so no GitHub
credential is written to a file. The suggestion in the issue to reuse
one answer per run is not part of this pull request, because the reused
answer would have to rest on disk.
- `resolveGitHubOperationCredentials` writes
`run_identity_contexts.github` only when the summary changes.
- `doc/execution-github-identity.md` describes the behavior.

## Verification

- New launcher tests run real `git` against a fake broker that counts
POSTs. A 12-command sequence (init, status, config, rev-parse, add,
diff, commit, log, branch, show, status, diff) made 12 POSTs before and
1 POST after. The one POST is `commit`.
- The same tests check that `var`, `remote`, `fetch`, an alias, and an
unknown option still ask, and that `git var GIT_AUTHOR_IDENT` returns
the managed author.
- A new test covers the unchanged-summary write.
- `vitest run` on the launcher, launcher environment, callback bridge,
github-operation-credentials, run-identity, heartbeat-github-launchers,
native-github-access, openapi-routes, run-trust-preset,
http-log-redaction, skill-source-github-access and chat-channels tests:
12 files, 1901 passed, 1 skipped.
- `vitest run --project @paperclipai/adapter-utils` on `src`: 1903
passed, 12 skipped, 1 failed. The failure is
`sandbox-callback-bridge.test.ts`, a file this PR does not change. It
passes alone (89 of 89, three runs).
- `tsc --noEmit` passes in `packages/adapter-utils` and in `server`.
- Three deliberate breakages each made a test fail: `var` back in the
local list, the broker always asked, and the summary write skip removed.
- Not run: the full server suite and `pnpm build` (the local machine has
no `cargo` for the runner binary).

## Risks

- In a partial clone or an LFS repository, a local command that must
fetch a missing object runs without a token.
- A `git` command that is not local (`commit`, `fetch`, `push`) and
every `gh` command still asks the broker once.
- No migration. No credential is stored at rest.

## Model Used

- Claude Sonnet 5.5 (`claude-sonnet-5-5`, Anthropic) through Claude
Code, with tool use (shell, file edits, test runs), wrote the code and
tests.
- Claude Opus 5.5 (`claude-opus-5-5`, Anthropic) through Claude Code
wrote the issue text and this description.
- A person asked for the change and submits it.

## 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
2026-10-28 09:06:14 -05:00

154 lines
9.0 KiB
TypeScript

/** Standalone source is staged unchanged on local, SSH, and sandbox runtimes. No secrets in files. */
export function githubLauncherSource(): string {
return String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawn } = require('node:child_process');
const directory = path.dirname(fs.realpathSync(process.argv[1]));
const program = path.basename(process.argv[1]);
const originalPath = (process.env.PATH || '').split(path.delimiter).filter(p => {
try { return fs.realpathSync(p) !== directory; } catch { return true; }
});
const executable = originalPath.map(p => path.join(p, program)).find(p => {
try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; }
});
if (!['git', 'gh'].includes(program) || !executable) {
process.stderr.write('Paperclip: requested GitHub command is not installed.\n');
process.exit(127);
}
const localGitCommands = new Set(['init', 'status', 'add', 'config', 'rev-parse', 'log', 'diff', 'show', 'branch', 'checkout', 'switch', 'restore', 'reset', 'rm', 'mv', 'worktree', 'grep', 'blame', 'describe', 'ls-files', 'ls-tree', 'cat-file', 'rev-list', 'merge-base', 'show-ref', 'for-each-ref', 'symbolic-ref', 'check-ignore', 'version', 'help']);
const gitValueOptions = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix', '--attr-source'];
const gitFlagOptions = ['-p', '-P', '--paginate', '--no-pager', '--bare', '--no-replace-objects', '--no-lazy-fetch', '--no-optional-locks', '--no-advice', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs'];
function isLocalGit(args) {
if (program !== 'git') return false;
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (gitValueOptions.includes(arg)) { index += 1; continue; }
if (gitValueOptions.some(option => arg.startsWith(option + '='))) continue;
if (gitFlagOptions.includes(arg)) continue;
return arg === '--version' || arg === '--help' || localGitCommands.has(arg);
}
return false;
}
async function main() {
let env = { ...process.env };
const diagnostic = (code) => process.stderr.write('Paperclip: GitHub ' + code + '; continuing without managed credentials.\n');
const configRoot = env.GH_CONFIG_DIR || os.tmpdir();
// A missing/unwritable scratch directory must not break local Git. The
// fallback deliberately cannot load the host's gh authentication files.
let configDirectory = path.join(directory, 'unavailable-gh-config');
let configReady = 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 {} });
} catch { diagnostic('configuration_directory_unavailable'); }
{
for (const key of Object.keys(env)) {
if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_AUTHOR_.*|GIT_COMMITTER_.*|GIT_CONFIG_.*|GIT_ASKPASS|SSH_ASKPASS|SSH_AUTH_SOCK|GIT_SSH.*)$/.test(key)) delete env[key];
}
Object.assign(env, {
GH_CONFIG_DIR: configDirectory, SSH_AUTH_SOCK: '',
GIT_CONFIG_GLOBAL: '/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/',
GIT_CONFIG_KEY_3: 'core.askPass', GIT_CONFIG_VALUE_3: '',
GIT_CONFIG_KEY_4: 'user.useConfigOnly', GIT_CONFIG_VALUE_4: 'true',
});
const base = env.PAPERCLIP_GITHUB_BROKER_URL || env.PAPERCLIP_API_URL;
const askBroker = !isLocalGit(process.argv.slice(2));
try {
let response;
if (askBroker && base && env.PAPERCLIP_GITHUB_BROKER_TOKEN) {
const url = base.replace(/\/+$/, '').replace(/\/api$/, '') + '/runtime-tools/github/credentials';
// A slow or restarting control plane must not cost the operation its
// managed identity, so a failed request is retried before giving up.
// Busy (409) responses and transport failures keep separate budgets, and
// the body is read inside the retry so a failed read is retried too.
let transportFailures = 0, conflicts = 0, result;
for (;;) {
try {
response = await fetch(url, {
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
headers: { authorization: 'Bearer ' + (env.PAPERCLIP_GITHUB_BRIDGE_TOKEN || env.PAPERCLIP_API_KEY || env.PAPERCLIP_GITHUB_BROKER_TOKEN),
'x-paperclip-github-capability': env.PAPERCLIP_GITHUB_BROKER_TOKEN, 'content-type': 'application/json' },
body: '{}',
});
if (response.status === 409 && conflicts < 29) {
conflicts += 1;
await response.arrayBuffer();
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
result = response.ok ? await response.json() : null;
break;
} catch (error) {
transportFailures += 1;
if (transportFailures >= 3) throw error;
await new Promise(resolve => setTimeout(resolve, 500 * transportFailures));
}
}
if (!response.ok) {
diagnostic(response.status === 401 || response.status === 403 ? 'capability_rejected' : 'broker_response_unavailable');
} else {
if (result.status === 'unavailable') {
const reason = typeof result.reason === 'string'
? result.reason.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 500)
: 'Check the GitHub connection in Paperclip';
process.stderr.write('Paperclip: GitHub access unavailable: ' + reason + '. Continuing without GitHub credentials.\n');
}
if (result.status === 'available' && configReady) {
for (const [key, value] of Object.entries(result.env || {})) {
if (/^(GH_TOKEN|GITHUB_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_TERMINAL_PROMPT|GIT_AUTHOR_(NAME|EMAIL)|GIT_COMMITTER_(NAME|EMAIL)|GIT_CONFIG_COUNT|GIT_CONFIG_(KEY|VALUE)_\d+)$/.test(key) && typeof value === 'string') env[key] = value;
}
}
}
} else if (askBroker) { diagnostic('capability_missing'); }
} catch { diagnostic('broker_transport_unavailable'); }
}
// Only this invocation and its children inherit the captured credential.
// Its Git children use the real binary, so steering cannot split a gh operation.
env.PATH = originalPath.join(path.delimiter);
// Nested shell aliases must not reload the parent launcher profile and
// recapture a newer identity. All ordinary descendants stay in this operation.
env.ZDOTDIR = configDirectory;
env.BASH_ENV = '/dev/null';
env.GIT_SSH_COMMAND = 'ssh -F /dev/null -o IdentityAgent=none -o IdentitiesOnly=yes -o IdentityFile=none -o BatchMode=yes';
const child = spawn(executable, process.argv.slice(2), { env, stdio: 'inherit' });
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => child.kill(signal));
child.once('error', () => { process.stderr.write('Paperclip: GitHub command could not start.\n'); process.exitCode = 1; });
child.once('exit', (code, signal) => { process.exitCode = code === null ? 128 : code; });
}
main().catch(() => { process.stderr.write('Paperclip: GitHub launcher_setup_failed.\n'); process.exitCode = 1; });
`;
}
/** Override inherited credentials even when adapters merge the host environment later. */
export function githubBrokerEnvironment(input: Record<string, unknown>, broker: { url: string; token: string }): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) if (typeof value === "string") env[key] = value;
for (const key of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_CONFIG_COUNT", "PAPERCLIP_GITHUB_OPERATION_ACTIVE"]) env[key] = "";
for (const key of Object.keys(env)) {
if (/^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) env[key] = "";
}
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_SYSTEM = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_TERMINAL_PROMPT = "0";
env.GIT_ASKPASS = "";
env.SSH_ASKPASS = "";
env.GIT_SSH_COMMAND = "ssh -F /dev/null -o IdentityAgent=none -o IdentitiesOnly=yes -o IdentityFile=none -o BatchMode=yes";
env.SSH_AUTH_SOCK = "";
env.PAPERCLIP_GITHUB_BROKER_URL = broker.url;
env.PAPERCLIP_GITHUB_BROKER_TOKEN = broker.token;
return env;
}