UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

474 lines • 23.8 kB
/** * githubBugReporter — file a bug report, with the run attached, into GitHub. * * import { exportBugReport, githubBugReporter } from 'agentfootprint/observe'; * * const reporter = githubBugReporter({ * issueRepo: 'acme/checkout-agent', // where the ISSUE goes * evidenceRepo: 'acme/agent-evidence', // where the ZIP goes (default: issueRepo) * }); // token: GITHUB_TOKEN, or `token` * * const { issueUrl, zipUrl } = await reporter.file(report); * * Two HTTP calls and no SDK: `PUT /repos/{evidenceRepo}/contents/{path}` commits * the zip, `POST /repos/{issueRepo}/issues` files the issue with a manifest * table and a link to the committed bundle. Plain `fetch`, zero dependencies, * `apiBase` for GitHub Enterprise Server — so this works unchanged on a network * that never reaches github.com. * * ## TWIN TARGETS: the issue and the evidence may live in different repos * * The case this exists for: a field tester finds a bug in a LIBRARY. The issue * belongs in the library's public repo, where the maintainers and the next * person to hit it will find it. The evidence — a real run, with real prompts, * real tool arguments and real retrieved documents — does not. So the zip goes * into a PRIVATE repo the maintainers can read, and the issue links it and says * plainly that the evidence is private. * * ```ts * githubBugReporter({ * issueRepo: 'footprintjs/agentfootprint', // public — the conversation * evidenceRepo: 'acme/af-bug-evidence', // private — the run * }); * ``` * * ## DEFAULT-TARGET DOCTRINE * * **File into the application's OWN repo.** That is the default (`evidenceRepo` * defaults to `issueRepo`) and it is the right default: the run belongs to the * organisation that produced it. Sending a run's evidence across an * organisational boundary — to a vendor, to an upstream library, to anyone * whose access your company did not grant — is a HUMAN act with consequences a * library cannot weigh. This adapter will do it, because a field tester filing * upstream is a real and valuable thing; it will not do it quietly. The * consent manifest (`describeBugReport`) exists so a person sees exactly what * would leave before it does, and this reporter refuses to commit evidence to a * PUBLIC repo unless the caller says `acknowledgePublicEvidence: true` out loud. * * ## Provisioning the token: fine-grained, and scoped to two repos * * Use a **fine-grained personal access token** (GitHub → Settings → Developer * settings → Fine-grained tokens), scoped to ONLY `issueRepo` and * `evidenceRepo`, with exactly two permissions — **Contents: read and write** * (to commit the zip) and **Issues: read and write** (to file the issue) — and * an expiry date. Put it in the server's environment as `GITHUB_TOKEN`, or pass * it as `token`. * * The contrast matters: a CLASSIC PAT's `repo` scope is coarse — it grants * read/write across every repository the account can reach, so a leaked * bug-report token is a leaked key to the whole account. With a fine-grained * token scoped as above, the blast radius of a leak is filing bug reports and * committing files to one evidence repo, and nothing else. GitHub App * installation tokens (short-lived, org-installed, revocable centrally) are the * next rung for an organisation that wants one; this adapter does not mint * them — hand it the token your app already obtained. * * ## Secrecy (the two-clause law) * * The token appears in no message, no error and no log this adapter can * produce, and neither does the bundle's content. A failed request is reported * as **the status and GitHub's own `message` field** — never the request, never * the headers, never the body that carried the token, never a byte of * evidence. Transport failures are re-wrapped rather than rethrown, because a * `fetch` implementation is free to put the request (headers included) into the * error it throws. Nothing here writes to a console. Pinned by a suite that * forces every failure path and greps the message, the stack and the JSON * projection for the token. * * @example A server route (the app's own repo, the default target) * ```ts * const reporter = githubBugReporter({ issueRepo: 'acme/checkout-agent' }); * app.post('/bug-report', async (req, res) => { * const report = exportBugReport(recordings.get(req.body.runId), req.body.fields); * res.json(await reporter.file(report)); * }); * ``` */ import { formatBytes } from '../../bug-report/build.js'; import { isSlash, trimBoth, trimTrailing } from '../../lib/linearText.js'; /** GitHub's own ceiling for a file committed through the contents API is 100 MB, * but an issue attachment that large is not a bug report anybody opens. 24 MB * keeps a bundle inside every practical limit including the 25 MB the web UI * accepts for a drag-and-dropped file. */ const DEFAULT_MAX_ZIP_BYTES = 24 * 1024 * 1024; const DEFAULT_API_BASE = 'https://api.github.com'; const FINE_GRAINED_TOKEN_PAGE = 'Settings → Developer settings → Fine-grained tokens'; export function githubBugReporter(options) { const issueRepo = checkedRepo(options.issueRepo, 'issueRepo'); const evidenceRepo = options.evidenceRepo ? checkedRepo(options.evidenceRepo, 'evidenceRepo') : issueRepo; const token = options.token ?? readEnv('GITHUB_TOKEN'); if (!token) { throw new TypeError('githubBugReporter: no GitHub token. Pass `token`, or set the GITHUB_TOKEN environment ' + 'variable on the server (a token belongs in the environment, not in the option in a ' + `committed config file). Make it a FINE-GRAINED token — ${FINE_GRAINED_TOKEN_PAGE} — ` + `scoped to just ${evidenceRepo === issueRepo ? issueRepo : `${issueRepo} and ${evidenceRepo}`}, ` + 'with Contents: read/write and Issues: read/write, and an expiry date. A classic PAT ' + "grants those permissions across every repo the account can reach; this token's " + 'blast radius should be filing bug reports and nothing else.'); } const apiBase = trimTrailing(options.apiBase ?? DEFAULT_API_BASE, isSlash); const dir = trimBoth(options.dir ?? 'bug-reports', isSlash); const maxZipBytes = options.maxZipBytes ?? DEFAULT_MAX_ZIP_BYTES; const doFetch = options._fetch ?? ((...args) => fetch(...args)); return { async file(report) { if (!report || !(report.zip instanceof Uint8Array) || !report.manifest) { throw new TypeError('githubBugReporter.file: expected the object exportBugReport() returns ' + '({ manifest, files, zip, filename }).'); } // 1. SIZE, before a byte moves. Refusing after a 24 MB upload wastes the // reporter's time and GitHub's, and the manifest already computed the // way out. if (report.zip.length > maxZipBytes) { throw new Error(`githubBugReporter: refusing to upload a ${formatBytes(report.zip.length)} bundle — ` + `the ceiling is ${formatBytes(maxZipBytes)}. GitHub accepts far larger files through the ` + `contents API, but a bundle this size is not one a maintainer opens. Send fewer ` + `units: ${trimAdvice(report)}`); } // 2. VISIBILITY. The doctrine's teeth: evidence is a real run, and a // public repo publishes it permanently. const visibility = await readVisibility({ apiBase, evidenceRepo, token, doFetch }); if (visibility.checked && visibility.isPrivate === false && !options.acknowledgePublicEvidence) { throw new Error(`githubBugReporter: refusing to commit evidence to '${evidenceRepo}', which is a ` + `PUBLIC repository. A bug-report bundle carries a real run — prompts, tool ` + `arguments, retrieved documents — and committing it here publishes all of it ` + `permanently. Point \`evidenceRepo\` at a private repo the maintainers can read ` + `(the issue can still go to the public one), or pass ` + `\`acknowledgePublicEvidence: true\` if this is intended.`); } // 3. COMMIT the zip. const uploaded = await uploadZip({ apiBase, evidenceRepo, dir, token, doFetch, report, ...(options.branch !== undefined && { branch: options.branch }), }); // 4. FILE the issue, pointing at it. const issueUrl = await createIssue({ apiBase, issueRepo, token, doFetch, report, zipUrl: uploaded.zipUrl, zipPath: uploaded.zipPath, evidenceRepo, crossRepo: evidenceRepo !== issueRepo, evidencePrivate: visibility.checked ? visibility.isPrivate : undefined, ...(options.labels !== undefined && { labels: options.labels }), }); return { issueUrl, zipUrl: uploaded.zipUrl, zipPath: uploaded.zipPath, evidenceRepo, checkedVisibility: visibility.checked, ...(visibility.checked && { evidenceRepoPrivate: visibility.isPrivate }), }; }, }; } /** * Read the evidence repo's visibility. * * Never blocks on a permissions quirk: a token with `contents: write` but no * metadata read is a legitimate configuration, and a bug report that cannot be * filed because a GUARD could not run has failed at its one job. The result * carries `checked: false` so the caller learns the guard was skipped. */ async function readVisibility(args) { try { const res = await args.doFetch(`${args.apiBase}/repos/${args.evidenceRepo}`, { method: 'GET', headers: githubHeaders(args.token), }); if (!res.ok) return { checked: false, isPrivate: false }; const body = (await res.json()); if (typeof body?.private !== 'boolean') return { checked: false, isPrivate: false }; return { checked: true, isPrivate: body.private }; } catch { // A transport failure here is not the report's problem, and the error is // deliberately not propagated — it is the one place where a thrown fetch // could carry request headers into a message. return { checked: false, isPrivate: false }; } } /** Commit the zip, suffixing the name if that path is already taken. */ async function uploadZip(args) { const base = args.report.filename.replace(/\.zip$/, ''); const content = toBase64(args.report.zip); const title = args.report.manifest.report?.title ?? 'bug report'; // A name collision is ordinary (two reports of the same bug on the same day), // and GitHub answers a create-without-sha on an existing path with 409/422. // Suffix and retry rather than overwrite: the earlier bundle is somebody // else's evidence. for (let attempt = 1; attempt <= 5; attempt++) { const path = `${args.dir}/${base}-${shortId()}.zip`; const res = await request({ ...args, url: `${args.apiBase}/repos/${args.evidenceRepo}/contents/${encodePath(path)}`, method: 'PUT', body: { message: `bug report: ${title}`, content, ...(args.branch !== undefined && { branch: args.branch }), }, what: `commit the evidence bundle to '${args.evidenceRepo}'`, allowStatuses: [409, 422], }); if (res.ok) { const body = res.body; const htmlUrl = typeof body?.content?.html_url === 'string' ? body.content.html_url : `https://github.com/${args.evidenceRepo}/blob/HEAD/${path}`; return { zipUrl: htmlUrl, zipPath: path }; } if (attempt === 5) { throw new Error(`githubBugReporter: could not commit the evidence bundle to '${args.evidenceRepo}' — ` + `five names in '${args.dir}/' were already taken (GitHub answered ${res.status}: ` + `${res.message}). Check that the token has Contents: read/write there, or set ` + `\`dir\` to somewhere less crowded.`); } } /* istanbul ignore next — the loop always returns or throws. */ throw new Error('githubBugReporter: unreachable'); } async function createIssue(args) { const res = await request({ ...args, url: `${args.apiBase}/repos/${args.issueRepo}/issues`, method: 'POST', body: { title: args.report.manifest.report?.title ?? 'Bug report', body: issueBody(args), ...(args.labels && args.labels.length > 0 && { labels: [...args.labels] }), }, what: `file the issue on '${args.issueRepo}'`, allowStatuses: [], }); const body = res.body; return typeof body?.html_url === 'string' ? body.html_url : `https://github.com/${args.issueRepo}/issues`; } /** * One request. * * Everything about the secrecy law lives here: the token goes into a header and * nowhere else; a thrown fetch is re-wrapped so no implementation can smuggle * its request (headers included) into the text; a non-2xx names the status and * GitHub's `message` field ONLY — never the request body, which for the upload * is the base64 of the evidence and for every call travelled beside the token. */ async function request(args) { let res; try { res = await args.doFetch(args.url, { method: args.method, headers: { ...githubHeaders(args.token), 'content-type': 'application/json' }, body: JSON.stringify(args.body), }); } catch (err) { throw new Error(`githubBugReporter: could not reach GitHub to ${args.what} (${transportReason(err)}). ` + `Check the network and \`apiBase\`.`); } let body; try { body = await res.json(); } catch { body = undefined; } const message = githubMessage(body); if (res.ok) return { ok: true, status: res.status, message, body }; if (args.allowStatuses.includes(res.status)) { return { ok: false, status: res.status, message, body }; } throw new Error(`githubBugReporter: GitHub answered ${res.status} when asked to ${args.what}` + `${message ? ` — "${message}"` : ''}.${statusHint(res.status)}`); } /** GitHub's error envelope is `{ message, documentation_url, errors }`. Only * `message` is ever surfaced: `errors[]` can quote the request. */ function githubMessage(body) { const message = body?.message; return typeof message === 'string' ? message.slice(0, 200) : ''; } function statusHint(status) { if (status === 401) { return (` The token is missing, expired or revoked — mint a new fine-grained one ` + `(${FINE_GRAINED_TOKEN_PAGE}).`); } if (status === 403) { return (` The token is valid but not permitted here. A fine-grained token needs Contents: ` + `read/write on the evidence repo and Issues: read/write on the issue repo, and it must ` + `list BOTH repositories in its repository access.`); } if (status === 404) { return (` Either the repository does not exist under that name, or the token cannot see it — ` + `GitHub answers 404 rather than 403 for a private repo a token has no access to.`); } if (status === 410) return ' Issues are disabled on that repository.'; return ''; } function githubHeaders(token) { return { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', }; } /** A short, payload-free reason. Never the error's own message: a fetch may put * the request — headers included — in it. */ function transportReason(err) { if (err instanceof Error) { if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'the request timed out'; return err.name || 'network error'; } return 'network error'; } // ─── Small pure helpers ────────────────────────────────────────────── function checkedRepo(repo, option) { if (typeof repo !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(repo)) { throw new TypeError(`githubBugReporter: \`${option}\` must be 'owner/name' (e.g. 'acme/checkout-agent'), ` + `not '${String(repo)}'. \`issueRepo\` is where the ISSUE is filed; \`evidenceRepo\` ` + `is where the evidence ZIP is committed, and defaults to \`issueRepo\`.`); } return repo; } function readEnv(name) { const env = globalThis.process ?.env; const value = env?.[name]; return typeof value === 'string' && value !== '' ? value : undefined; } /** Base64 for the contents API. Node-only, and it says so by name. */ function toBase64(bytes) { const buffer = globalThis.Buffer; if (!buffer) { throw new Error('githubBugReporter is a server-side reporter and needs Node (it base64-encodes the ' + 'bundle with Buffer). In a browser, build the bundle with exportBugReport() and POST ' + 'it to your own server, which files it — a browser holding a GitHub token is a token ' + 'anyone with the page can read.'); } return buffer.from(bytes).toString('base64'); } /** Six characters of collision resistance in a filename. */ function shortId() { const random = globalThis .crypto; if (random?.getRandomValues) { const bytes = random.getRandomValues(new Uint8Array(4)); return [...bytes] .map((byte) => byte.toString(16).padStart(2, '0')) .join('') .slice(0, 6); } return Math.random().toString(36).slice(2, 8); } const encodePath = (path) => path.split('/').map(encodeURIComponent).join('/'); /** The manifest already worked out what to drop; repeat it rather than invent. */ function trimAdvice(report) { const hints = report.manifest.oversize?.trimHints; if (hints && hints.length > 0) return hints.join(' '); const biggest = [...report.manifest.units] .filter((unit) => unit.kind === 'conversation') .sort((left, right) => right.bytes - left.bytes) .slice(0, 3); return biggest.length > 0 ? `pass \`include\` to exportBugReport() without ${biggest .map((unit) => `${unit.id} (${formatBytes(unit.bytes)})`) .join(' / ')}.` : 'pass `include` to exportBugReport() with fewer units.'; } // ─── The issue body ────────────────────────────────────────────────── function issueBody(args) { const { manifest } = args.report; const fields = manifest.report; const lines = []; if (fields) { lines.push('### Steps to reproduce', '', fields.stepsToReproduce, ''); lines.push('### Expected', '', fields.expected, ''); lines.push('### Actual', '', fields.actual, ''); } lines.push('### The run is attached', ''); lines.push(`[\`${args.zipPath}\`](${args.zipUrl}) — the recorded run itself.`); if (args.crossRepo) { lines.push('', args.evidencePrivate === false ? `The evidence is committed to \`${args.evidenceRepo}\`, a separate PUBLIC repository.` : `The evidence is committed to \`${args.evidenceRepo}\`, a private repository visible ` + `to maintainers — this issue links it but does not contain it.`); } // Name the shape that is REALLY in this bundle. A conversation whose run // facts were stated rides as an archive envelope; one whose facts were // missing rides as the bare recording, and a bundle can be mixed. Printing // one sentence for all three cases would send a maintainer looking for a file // that is not there, so each case gets its own. const included = manifest.units.filter((unit) => unit.kind === 'conversation' && manifest.selected.includes(unit.id)); const enveloped = included.filter((unit) => unit.enveloped === true).length; const opener = 'Open it with `observeRecording()` (agentfootprint-lens) or the trace tools'; lines.push('', enveloped === 0 ? `${opener} — \`recording.json\` is the canon \`{ snapshot, events, structure }\`.` : enveloped === included.length ? `${opener} — the evidence is a recording envelope, and the canon ` + '`{ snapshot, events, structure }` is its `recording` field.' : `${opener}. ${enveloped} of ${included.length} conversations ride as recording ` + 'envelopes (the canon `{ snapshot, events, structure }` is under `recording`); the ' + 'rest are bare recordings, and the notes below say which fact was missing.', ''); lines.push('### What is in the bundle', ''); lines.push('| file | bytes | events | turns |', '|---|---:|---:|---:|'); for (const file of manifest.files) { lines.push(`| \`${file.name}\` | ${file.bytes} | ${file.eventCount ?? ''} | ${file.turnCount ?? ''} |`); } lines.push('', `${manifest.counts.conversations} conversation(s), ${manifest.counts.runs} run(s), ` + `${manifest.counts.events} event(s), ${manifest.counts.turns} turn(s), ` + `${manifest.totalBytes} bytes uncompressed.`, ''); if (manifest.excluded.conversations > 0 || manifest.excluded.files > 0) { lines.push(`**The reporter chose a subset.** Left out: ${manifest.excluded.conversations} ` + `conversation(s) (${manifest.excluded.events} events, ${manifest.excluded.turns} ` + `turns) and ${manifest.excluded.files} file(s).`, ''); } lines.push('### Redacted keys', '', manifest.redactedKeys.length > 0 ? `Held as a redaction placeholder in this bundle, listed by name only (a run's policy ` + `keeps state out as it is committed and events at their source): ` + `${manifest.redactedKeys.map((key) => `\`${key}\``).join(', ')}.` : 'None — no value in the bundle is a redaction placeholder, so every value in it is ' + 'real: the run had no redaction policy, or its policy selected nothing the run recorded.', ''); const env = manifest.environment; lines.push('### Environment', ''); lines.push('```'); lines.push(`agentfootprint ${env.agentfootprint}`); lines.push(`footprintjs ${env.footprintjs}`); lines.push(`node ${env.node}`); lines.push(`platform ${env.platform}/${env.arch}`); if (env.appVersion) lines.push(`app ${env.appVersion}`); lines.push('```'); if (manifest.warnings.length > 0) { lines.push('', '### Warnings', ''); for (const warning of manifest.warnings) lines.push(`- ${warning}`); } if (manifest.notes.length > 0) { lines.push('', '<details><summary>Notes about this bundle</summary>', ''); for (const note of manifest.notes) lines.push(`- ${note}`); lines.push('', '</details>'); } return lines.join('\n'); } //# sourceMappingURL=githubBugReporter.js.map