mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Preserve active ACPX child snapshots through reusable lease close. Align the canonical provider lock and CLI pins with the current qualified profiles, retain local Git fallback, and preserve narrowed heartbeat values across diagnostics. Co-Authored-By: Paperclip <noreply@paperclip.ing>
191 lines
9.8 KiB
TypeScript
191 lines
9.8 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);
|
|
}
|
|
// The file gateway owns a 30s response window. Let it report its result before
|
|
// retrying only this repeat-safe acquisition; the Git command has not started.
|
|
const credentialRequestTimeoutMs = 35000;
|
|
const credentialAcquisitionTimeoutMs = 75000;
|
|
function credentialError(category) {
|
|
const error = new Error('GitHub credential acquisition failed');
|
|
error.credentialCategory = category;
|
|
return error;
|
|
}
|
|
function transientCredentialError(error) {
|
|
if (error && error.name === 'TimeoutError') return true;
|
|
const codes = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN',
|
|
'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
|
|
return Boolean(error && (codes.has(error.code) || (error.cause && codes.has(error.cause.code))));
|
|
}
|
|
async function acquireCredentials(url, headers) {
|
|
const deadline = Date.now() + credentialAcquisitionTimeoutMs;
|
|
let transientFailures = 0;
|
|
let conflicts = 0;
|
|
const remaining = () => {
|
|
const ms = deadline - Date.now();
|
|
if (ms <= 0) throw credentialError('timeout');
|
|
return ms;
|
|
};
|
|
const pause = async (ms) => {
|
|
await new Promise(resolve => setTimeout(resolve, Math.min(ms, remaining())));
|
|
remaining();
|
|
};
|
|
while (true) {
|
|
let response;
|
|
const requestSignal = AbortSignal.timeout(Math.min(credentialRequestTimeoutMs, remaining()));
|
|
try {
|
|
response = await fetch(url, {
|
|
method: 'POST', redirect: 'error',
|
|
signal: requestSignal,
|
|
headers, body: '{}',
|
|
});
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
remaining(); // A late credential must never start a command.
|
|
if (!result || !['available', 'absent', 'unavailable'].includes(result.status) ||
|
|
(result.status === 'available' && (!result.env || typeof result.env !== 'object' || Array.isArray(result.env)))) {
|
|
throw credentialError('invalidresponse');
|
|
}
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
if (error && error.credentialCategory) throw error;
|
|
const timedOut = requestSignal.aborted && requestSignal.reason?.name === 'TimeoutError';
|
|
if (timedOut) error = requestSignal.reason;
|
|
if (error instanceof SyntaxError) throw credentialError('invalidresponse');
|
|
if (!transientCredentialError(error)) throw credentialError('unavailable');
|
|
transientFailures++;
|
|
if (transientFailures >= 3) throw credentialError(error.name === 'TimeoutError' ? 'timeout' : 'unavailable');
|
|
await pause(250 * transientFailures);
|
|
continue;
|
|
}
|
|
// Discard credentials/error bodies without printing or retaining them.
|
|
await response.body?.cancel().catch(() => {});
|
|
if (response.status === 401 || response.status === 403) throw credentialError('denied');
|
|
if (response.status === 409) {
|
|
if (++conflicts >= 30) throw credentialError('unavailable');
|
|
await pause(1000);
|
|
continue;
|
|
}
|
|
if (![502, 503, 504].includes(response.status) || ++transientFailures >= 3) {
|
|
throw credentialError('unavailable');
|
|
}
|
|
await pause(250 * transientFailures);
|
|
}
|
|
}
|
|
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',
|
|
GIT_AUTHOR_NAME: '', GIT_AUTHOR_EMAIL: '', GIT_COMMITTER_NAME: '', GIT_COMMITTER_EMAIL: '',
|
|
GIT_CONFIG_COUNT: '4', 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: '',
|
|
});
|
|
const base = env.PAPERCLIP_GITHUB_BROKER_URL || env.PAPERCLIP_API_URL;
|
|
try {
|
|
if (base && env.PAPERCLIP_GITHUB_BROKER_TOKEN) {
|
|
const url = base.replace(/\/+$/, '').replace(/\/api$/, '') + '/runtime-tools/github/credentials';
|
|
const result = await acquireCredentials(url, {
|
|
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',
|
|
});
|
|
{
|
|
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 { diagnostic('capability_missing'); }
|
|
} catch (error) {
|
|
if (program === 'gh') throw error;
|
|
diagnostic(error && error.credentialCategory === 'denied' ? 'capability_rejected' : '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((error) => {
|
|
const category = ['timeout', 'unavailable', 'denied', 'invalidresponse'].includes(error && error.credentialCategory)
|
|
? error.credentialCategory : 'unavailable';
|
|
process.stderr.write('Paperclip: GitHub credential context unavailable (' + category + '); retry this operation.\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;
|
|
}
|