mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents with a managed GitHub connection get `git` and `gh` launchers that use the connection's credential > - The launcher gives that credential to any `gh` command, and the broker does not see which command runs > - So `gh repo create` and `gh repo fork` skip the connection's tool switches, ask-first approvals, owner defaults, and audit > - The connection's `create_repository` and `fork_repository` tools apply all of these > - This pull request stops `gh repo create`, `gh repo new`, and `gh repo fork` when a managed credential is in use, and names the tool to use instead > - The benefit is that repository creation follows the connection's rules, whichever path the agent tries first ## Linked Issues or Issue Description Refs #20141 (default owner for `create_repository` and `fork_repository`). **What happened?** An agent with a managed GitHub connection can run `gh repo create` in its shell. The command uses the connection's token, but it does not use the connection's tool rules. If the user turns off or reviews `create_repository` on the connection, the agent can still create a repository with `gh`. The owner default from #20141 also does not apply, so the command can fail with `403 Resource not accessible by integration` when the App is installed only on an organization. **Expected behavior** With a managed connection, repository creation and forks go through the connection's tools. The tool switch, approvals, owner default, and audit apply. **Steps to reproduce** 1. Connect GitHub with a managed connection. 2. Set the connection's `create_repository` tool to off or ask first. 3. In an agent run, run `gh repo create demo --private`. 4. The command runs with the connection's credential and ignores the tool setting. ## What Changed - `packages/adapter-utils/src/github-launcher.ts`: when the broker returns a managed credential, `gh repo create`, `gh repo new`, and `gh repo fork` exit with code 1 before `gh` starts. The message names the `create_repository` or `fork_repository` tool and tells the agent to finish with Git. - `--help` still runs. Other `gh` commands do not change. Executions without a managed credential run these commands as before. - `doc/execution-github-identity.md`: describes the behavior and states that this is guidance, not a security boundary. ## Verification - New test in `packages/adapter-utils/src/github-launcher.test.ts`: "sends repository creation and forks to the connection's tools when a connection is in use". It covers `create`, the `new` alias, `fork`, `--help`, another `gh repo` command, and the case without a connection. It fails without the launcher change. - `npx vitest run src/execution-target.test.ts src/github-launcher-environment.test.ts src/github-launcher.test.ts` in `packages/adapter-utils`: 83 passed. - `npx vitest run src/drivers/acpx/github-launcher.test.ts` in `packages/paperclip-runner`: 3 passed. - `npx vitest run src/services/heartbeat-github-launchers.test.ts` in `server`: 12 passed. - `npx tsc --noEmit -p packages/adapter-utils` passes. ## Risks - Agents that used `gh repo create --source . --push` must now call `create_repository`, then add the remote and push with Git. The message tells them to do this. - If the connection's tool is off for the agent, the agent cannot create a repository. This is the purpose of the tool setting. - This is not a security boundary. Other commands that receive the token, such as `gh api`, can still call the same GitHub endpoints. - Running launchers keep their earlier source until the next run stages new launchers. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (code reading, shell commands, and test runs). ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import { isAbsolute } from "node:path";
|
|
|
|
/** Standalone source for local, SSH, and sandbox runtimes. No secrets in files. */
|
|
export function githubLauncherSource(options: { brokerFile?: boolean; interpreterPath?: string } = {}): string {
|
|
if (options.interpreterPath !== undefined && (typeof options.interpreterPath !== "string"
|
|
|| !isAbsolute(options.interpreterPath) || /[\s\0]/u.test(options.interpreterPath))) {
|
|
throw new Error("GitHub launcher interpreter must be an absolute path without whitespace");
|
|
}
|
|
return String.raw`#!${options.interpreterPath ?? "/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');
|
|
// A PATH shim can capture this launcher's absolute path and call it again.
|
|
// Reject that cycle before resolving another executable or capturing authority.
|
|
if (process.env.PAPERCLIP_GITHUB_OPERATION_ACTIVE) {
|
|
process.stderr.write('Paperclip: GitHub launcher_recursion_rejected.\n');
|
|
process.exit(126);
|
|
}
|
|
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', 'diff-files', 'write-tree', '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;
|
|
}
|
|
// With a Paperclip GitHub connection, repositories are created and forked
|
|
// through its tools so access rules, approvals, owner defaults and audit apply.
|
|
const connectorRepositoryTools = new Map([['create', 'create_repository'], ['new', 'create_repository'], ['fork', 'fork_repository']]);
|
|
function connectorRepositoryTool(args) {
|
|
if (program !== 'gh' || args[0] !== 'repo' || args.includes('--help') || args.includes('-h')) return null;
|
|
return connectorRepositoryTools.get(args[1]) || null;
|
|
}
|
|
async function main() {
|
|
let env = { ...process.env };
|
|
${options.brokerFile ? String.raw`
|
|
delete env.PAPERCLIP_API_URL;
|
|
delete env.PAPERCLIP_API_KEY;
|
|
delete env.PAPERCLIP_GITHUB_BRIDGE_TOKEN;
|
|
env.PAPERCLIP_GITHUB_BROKER_URL = '';
|
|
env.PAPERCLIP_GITHUB_BROKER_TOKEN = '';
|
|
try {
|
|
const broker = JSON.parse(fs.readFileSync(path.join(directory, 'broker.json'), 'utf8'));
|
|
if (typeof broker.url === 'string' && typeof broker.token === 'string') {
|
|
env.PAPERCLIP_GITHUB_BROKER_URL = broker.url;
|
|
env.PAPERCLIP_GITHUB_BROKER_TOKEN = broker.token;
|
|
}
|
|
} catch {}
|
|
` : ''}
|
|
const diagnostic = (code) => process.stderr.write('Paperclip: GitHub ' + code + '; continuing without managed credentials.\n');
|
|
let connected = false;
|
|
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;
|
|
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)) {
|
|
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: authorReady ? path.join(configDirectory, 'gitconfig') : '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
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');
|
|
}
|
|
connected = result.status === 'available';
|
|
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'); }
|
|
}
|
|
const connectorTool = connected ? connectorRepositoryTool(process.argv.slice(2)) : null;
|
|
if (connectorTool) {
|
|
process.stderr.write("Paperclip: use the GitHub connection's " + connectorTool + " tool instead of gh repo " + process.argv[3] +
|
|
", so the connection's access rules apply. " + (connectorTool === 'fork_repository'
|
|
? 'Then clone the fork or add it as a git remote.' : 'Then add the repository as a git remote and push.') + '\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// Only this invocation and its children inherit the captured credential.
|
|
env.PAPERCLIP_GITHUB_OPERATION_ACTIVE = '1';
|
|
// 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;
|
|
}
|