@bivy/bivy 0.0.0 → 0.1.0-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
package/dist/exec.js ADDED
@@ -0,0 +1,209 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // `bivy exec "<prompt>"` — one-shot, headless agent run for scripting and pipes.
4
+ // Creates (or resumes) a session, sends one prompt, waits for the turn to finish,
5
+ // prints the final answer to stdout, and exits. Working details stay on stderr so
6
+ // stdout is just the answer (pipe-friendly). `--json` emits a structured result.
7
+ //
8
+ // bivy exec "summarize README.md"
9
+ // bivy exec --agent claude "what does src/server.ts do?"
10
+ // bivy exec --session <ref> "and now add a test" # continue a session
11
+ // echo "explain this" | bivy exec - # prompt from stdin
12
+ import { WebSocket } from "ws";
13
+ const err = (s) => process.stderr.write(s);
14
+ function parseArgs(argv) {
15
+ let url = process.env.BIVY_URL || `http://localhost:${process.env.PORT || "4317"}`;
16
+ let token = process.env.BIVY_DEVICE_TOKEN || undefined;
17
+ let agent;
18
+ let session;
19
+ let json = false;
20
+ let timeoutMs = Number(process.env.BIVY_EXEC_TIMEOUT_MS) || 10 * 60 * 1000;
21
+ const prompt = [];
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const arg = argv[i];
24
+ if (arg === "--url" && argv[i + 1])
25
+ url = argv[++i];
26
+ else if (arg.startsWith("--url="))
27
+ url = arg.slice("--url=".length);
28
+ else if (arg === "--token" && argv[i + 1])
29
+ token = argv[++i];
30
+ else if (arg.startsWith("--token="))
31
+ token = arg.slice("--token=".length);
32
+ else if ((arg === "-a" || arg === "--agent") && argv[i + 1])
33
+ agent = argv[++i];
34
+ else if (arg.startsWith("--agent="))
35
+ agent = arg.slice("--agent=".length);
36
+ else if (arg === "--session" && argv[i + 1])
37
+ session = argv[++i];
38
+ else if (arg.startsWith("--session="))
39
+ session = arg.slice("--session=".length);
40
+ else if (arg === "--json")
41
+ json = true;
42
+ else if (arg === "--timeout" && argv[i + 1])
43
+ timeoutMs = Number(argv[++i]) * 1000;
44
+ else
45
+ prompt.push(arg);
46
+ }
47
+ return { url: url.replace(/\/+$/, ""), token, agent, session, prompt: prompt.join(" "), json, timeoutMs };
48
+ }
49
+ function authHeaders(token) {
50
+ return { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) };
51
+ }
52
+ async function api(url, token, pathName, init = {}) {
53
+ const res = await fetch(`${url}${pathName}`, { ...init, headers: { ...authHeaders(token), ...(init.headers || {}) } });
54
+ const data = (await res.json().catch(() => ({})));
55
+ if (!res.ok)
56
+ throw new Error(data?.error || `HTTP ${res.status} for ${pathName}`);
57
+ return data;
58
+ }
59
+ function wsUrl(baseUrl, token) {
60
+ const u = new URL(baseUrl);
61
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
62
+ u.pathname = "/ws";
63
+ u.search = token ? `?access_token=${encodeURIComponent(token)}` : "";
64
+ return u.toString();
65
+ }
66
+ // Pull plain text out of a runtime message/content shape (mirrors the TUI).
67
+ function textContent(value) {
68
+ if (value === undefined || value === null)
69
+ return "";
70
+ if (typeof value === "string")
71
+ return value;
72
+ if (Array.isArray(value))
73
+ return value.map(textContent).filter(Boolean).join("");
74
+ if (typeof value === "object") {
75
+ const r = value;
76
+ if (r.type === "thinking" || r.type === "reasoning")
77
+ return "";
78
+ if (typeof r.text === "string")
79
+ return r.text;
80
+ if (typeof r.content === "string" || Array.isArray(r.content))
81
+ return textContent(r.content);
82
+ if (typeof r.output === "string")
83
+ return r.output;
84
+ }
85
+ return "";
86
+ }
87
+ function assistantText(event) {
88
+ const message = event.message;
89
+ return textContent(message?.content ?? event.content ?? event.text);
90
+ }
91
+ async function readStdin() {
92
+ const chunks = [];
93
+ for await (const chunk of process.stdin)
94
+ chunks.push(chunk);
95
+ return Buffer.concat(chunks).toString("utf8");
96
+ }
97
+ async function main() {
98
+ const args = parseArgs(process.argv.slice(2));
99
+ let prompt = args.prompt.trim();
100
+ if (prompt === "-" || (!prompt && !process.stdin.isTTY))
101
+ prompt = (await readStdin()).trim();
102
+ if (!prompt) {
103
+ err('Usage: bivy exec "<prompt>" (or pipe the prompt on stdin)\n');
104
+ process.exit(2);
105
+ }
106
+ // Establish the session — resume by ref or create fresh with the chosen agent.
107
+ let sessionId;
108
+ try {
109
+ if (args.session) {
110
+ const opened = await api(args.url, args.token, "/api/sessions/open", {
111
+ method: "POST",
112
+ body: JSON.stringify({ path: args.session, ...(args.agent ? { agent: args.agent } : {}) }),
113
+ });
114
+ sessionId = opened.id;
115
+ }
116
+ else {
117
+ const created = await api(args.url, args.token, "/api/session", {
118
+ method: "POST",
119
+ body: JSON.stringify(args.agent ? { agent: args.agent } : {}),
120
+ });
121
+ sessionId = created.id;
122
+ }
123
+ }
124
+ catch (error) {
125
+ err(`Could not start a session: ${error instanceof Error ? error.message : String(error)}\n`);
126
+ process.exit(1);
127
+ return;
128
+ }
129
+ err(`\x1b[2m· session ${sessionId.slice(0, 8)} — working…\x1b[22m\n`);
130
+ const socket = new WebSocket(wsUrl(args.url, args.token));
131
+ let answer = "";
132
+ let settled = false;
133
+ const finish = (code, errorText) => {
134
+ if (settled)
135
+ return;
136
+ settled = true;
137
+ try {
138
+ socket.close();
139
+ }
140
+ catch { }
141
+ if (errorText) {
142
+ err(`${errorText}\n`);
143
+ process.exit(code);
144
+ return;
145
+ }
146
+ if (args.json)
147
+ process.stdout.write(JSON.stringify({ sessionId, answer }) + "\n");
148
+ else
149
+ process.stdout.write(answer.replace(/\s+$/, "") + "\n");
150
+ process.exit(code);
151
+ };
152
+ const timer = setTimeout(() => finish(1, `Timed out after ${Math.round(args.timeoutMs / 1000)}s.`), args.timeoutMs);
153
+ timer.unref?.();
154
+ socket.on("open", async () => {
155
+ try {
156
+ await api(args.url, args.token, "/api/session/prompt", {
157
+ method: "POST",
158
+ body: JSON.stringify({ sessionId, text: prompt, clientMessageId: `exec-${sessionId}` }),
159
+ });
160
+ }
161
+ catch (error) {
162
+ finish(1, `Prompt failed: ${error instanceof Error ? error.message : String(error)}`);
163
+ }
164
+ });
165
+ socket.on("error", (e) => finish(1, `Connection error: ${e instanceof Error ? e.message : String(e)}`));
166
+ // A close here means the socket dropped before an `agent_end` event was ever
167
+ // seen (a real completion calls finish(0) itself, closing the socket and
168
+ // setting `settled` first — so this handler is then a no-op). That's always
169
+ // abnormal: a mid-turn disconnect must exit non-zero even when partial
170
+ // assistant text already streamed in, otherwise a script reading `answer`
171
+ // (or just checking $?) sees a clean success for a truncated reply.
172
+ socket.on("close", () => {
173
+ if (settled)
174
+ return;
175
+ finish(1, answer ? "Connection closed before the turn finished (received a partial answer)." : "Connection closed before the turn finished.");
176
+ });
177
+ socket.on("message", (raw) => {
178
+ let msg;
179
+ try {
180
+ msg = JSON.parse(raw.toString());
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ if (msg.sessionId && msg.sessionId !== sessionId)
186
+ return;
187
+ const type = msg.type;
188
+ if (type === "session.event") {
189
+ const ev = (msg.event || {});
190
+ if (ev.type === "message_start" || ev.type === "message_update" || ev.type === "message_end") {
191
+ const text = assistantText(ev);
192
+ if (text)
193
+ answer = text; // events carry the full message text so far
194
+ }
195
+ else if (ev.type === "agent_end") {
196
+ clearTimeout(timer);
197
+ finish(0);
198
+ }
199
+ }
200
+ else if (type === "session.error") {
201
+ clearTimeout(timer);
202
+ finish(1, String(msg.error || "Session error"));
203
+ }
204
+ });
205
+ }
206
+ main().catch((error) => {
207
+ err(`${error instanceof Error ? error.stack : String(error)}\n`);
208
+ process.exit(1);
209
+ });
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Git credential handling for repos Bivy clones on behalf of a user.
4
+ //
5
+ // Remotes are kept CLEAN (https://github.com/<owner>/<repo>.git); credentials are
6
+ // supplied on demand by a daemon-owned git credential helper. The helper stores
7
+ // NO token on disk — on every git operation it calls the node's loopback
8
+ // git-credential endpoint (see src/server.ts) and gets a FRESH token. This keeps
9
+ // nothing long-lived or stale on disk (important once tokens are short-lived
10
+ // GitHub App installation tokens), and works for both daemon- and agent-run git
11
+ // since both run under a live daemon.
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { defaultDataDir } from "./data-dir.js";
15
+ // Set by configureGitAuth() at daemon startup. Falls back to BIVY_DATA_DIR / the
16
+ // default data dir so the module still works in tests and one-off scripts.
17
+ let credRootOverride = null;
18
+ function credDir() {
19
+ return credRootOverride ?? path.join(defaultDataDir(), "git-cred");
20
+ }
21
+ function shimPath() {
22
+ return path.join(credDir(), "credential-helper.sh");
23
+ }
24
+ function workerPath() {
25
+ return path.join(credDir(), "credential-helper.mjs");
26
+ }
27
+ function endpointPath() {
28
+ return path.join(credDir(), "endpoint.json");
29
+ }
30
+ /** The token-free remote URL for a repo. */
31
+ export function cleanRemoteUrl(owner, repo) {
32
+ return `https://github.com/${owner}/${repo}.git`;
33
+ }
34
+ // The credential-helper worker (plain ESM, run by node). Reads git's request on
35
+ // stdin, and for a github.com repo fetches a fresh token from the node's loopback
36
+ // endpoint. Finds endpoint.json next to itself (import.meta.url), so no paths are
37
+ // baked in. Prints nothing on any miss/error, so git fails auth cleanly (fast,
38
+ // because the daemon runs it with GIT_TERMINAL_PROMPT=0).
39
+ const WORKER_SOURCE = `import fs from 'node:fs';
40
+ import http from 'node:http';
41
+ if (process.argv[2] !== 'get') process.exit(0);
42
+ let input = '';
43
+ try { input = fs.readFileSync(0, 'utf8'); } catch { process.exit(0); }
44
+ const attrs = {};
45
+ for (const line of input.split('\\n')) { const i = line.indexOf('='); if (i > 0) attrs[line.slice(0, i)] = line.slice(i + 1).trim(); }
46
+ if (attrs.host !== 'github.com') process.exit(0);
47
+ const m = /^([^/]+)\\/(.+?)(?:\\.git)?$/.exec(attrs.path || '');
48
+ if (!m) process.exit(0);
49
+ let ep;
50
+ try { ep = JSON.parse(fs.readFileSync(new URL('./endpoint.json', import.meta.url), 'utf8')); } catch { process.exit(0); }
51
+ if (!ep || !ep.url || !ep.secret) process.exit(0);
52
+ const u = new URL(ep.url + '/api/git-credential');
53
+ u.searchParams.set('owner', m[1]);
54
+ u.searchParams.set('repo', m[2]);
55
+ const token = await new Promise((resolve) => {
56
+ const req = http.get(u, { headers: { 'x-bivy-bootstrap': ep.secret } }, (res) => {
57
+ if (res.statusCode !== 200) { res.resume(); return resolve(null); }
58
+ let body = ''; res.setEncoding('utf8');
59
+ res.on('data', (d) => (body += d));
60
+ res.on('end', () => { try { resolve(JSON.parse(body).token || null); } catch { resolve(null); } });
61
+ });
62
+ req.on('error', () => resolve(null));
63
+ req.setTimeout(5000, () => { req.destroy(); resolve(null); });
64
+ });
65
+ if (token) process.stdout.write('username=x-access-token\\npassword=' + token + '\\n');
66
+ `;
67
+ function shDq(s) {
68
+ return `"${s.replace(/(["\\$\`])/g, "\\$1")}"`;
69
+ }
70
+ function writeIfChanged(file, content, mode) {
71
+ let existing = "";
72
+ try {
73
+ existing = fs.readFileSync(file, "utf8");
74
+ }
75
+ catch {
76
+ // not written yet
77
+ }
78
+ if (existing !== content)
79
+ fs.writeFileSync(file, content, { mode });
80
+ fs.chmodSync(file, mode);
81
+ }
82
+ /**
83
+ * (Re)write the credential helper and return the git-config helper path. The
84
+ * helper is a tiny sh shim that execs THIS daemon's node (absolute path baked in,
85
+ * so it works regardless of the caller's PATH — e.g. an agent shell) on the worker
86
+ * script above. Idempotent.
87
+ */
88
+ export function ensureCredentialHelper() {
89
+ fs.mkdirSync(credDir(), { recursive: true, mode: 0o700 });
90
+ writeIfChanged(workerPath(), WORKER_SOURCE, 0o700);
91
+ const shim = `#!/usr/bin/env sh\nexec ${shDq(process.execPath)} ${shDq(workerPath())} "$@"\n`;
92
+ writeIfChanged(shimPath(), shim, 0o700);
93
+ return shimPath();
94
+ }
95
+ /** Point the module at the node data dir and materialize the helper. */
96
+ export function configureGitAuth(appDir) {
97
+ credRootOverride = path.join(appDir, "git-cred");
98
+ ensureCredentialHelper();
99
+ }
100
+ /**
101
+ * Publish the loopback endpoint (URL + bootstrap secret) that the helper calls to
102
+ * fetch a fresh token. 0600 — the secret gates the endpoint against other local
103
+ * users on a shared host. Call once the daemon knows its port + bootstrap secret.
104
+ */
105
+ export function writeGitCredentialEndpoint(url, secret) {
106
+ fs.mkdirSync(credDir(), { recursive: true, mode: 0o700 });
107
+ writeIfChanged(endpointPath(), `${JSON.stringify({ url, secret })}\n`, 0o600);
108
+ }
109
+ /**
110
+ * `-c` flags that make a single git invocation use the helper for github.com with
111
+ * the repo path (so the helper can resolve owner/repo). Non-secret (only the
112
+ * helper's path). Use for daemon-run clone/fetch/push against a clean URL.
113
+ */
114
+ export function credConfigArgs() {
115
+ const helper = ensureCredentialHelper();
116
+ return [
117
+ // Reset the credential-helper chain BEFORE adding bivy's helper. Git collects
118
+ // helpers from system/global/local config and consults them in order, using the
119
+ // FIRST that returns a password; command-line `-c` is appended last. So without a
120
+ // reset, any pre-existing github.com helper on the host (osxkeychain, `gh`, store,
121
+ // manager-core) is consulted first and would hand the agent the human's personal,
122
+ // broadly-scoped token instead of bivy's scoped, short-lived one. An empty helper
123
+ // value clears the accumulated list, so bivy's is the only helper that runs.
124
+ "-c",
125
+ "credential.helper=",
126
+ "-c",
127
+ "credential.https://github.com.helper=",
128
+ "-c",
129
+ `credential.https://github.com.helper=${helper}`,
130
+ "-c",
131
+ "credential.https://github.com.useHttpPath=true",
132
+ ];
133
+ }
134
+ /** Env that makes git non-interactive, so a credential miss fails fast (never hangs). */
135
+ export function gitNonInteractiveEnv() {
136
+ return { ...process.env, GIT_TERMINAL_PROMPT: "0" };
137
+ }
138
+ /**
139
+ * Persist the helper config into a clone's local `.git/config` so AGENT-run git
140
+ * (a bare `git push origin` from inside the workspace) authenticates too, without
141
+ * the daemon's `-c` flags. Only the helper path is stored — never a token.
142
+ */
143
+ export async function configureRepoCredentialHelper(git, dest) {
144
+ const helper = ensureCredentialHelper();
145
+ await git(["-C", dest, "config", "--local", "credential.https://github.com.useHttpPath", "true"]);
146
+ // Reset the helper chain for this repo, then set ONLY bivy's helper, so AGENT-run
147
+ // git never falls back to an inherited host helper holding the human's personal
148
+ // token (see credConfigArgs for the full rationale). Clear any prior values first
149
+ // so re-running stays idempotent (--unset-all returns 5 when the key is absent).
150
+ await git(["-C", dest, "config", "--local", "--unset-all", "credential.helper"]).catch(() => { });
151
+ await git(["-C", dest, "config", "--local", "--unset-all", "credential.https://github.com.helper"]).catch(() => { });
152
+ await git(["-C", dest, "config", "--local", "--add", "credential.helper", ""]);
153
+ await git(["-C", dest, "config", "--local", "--add", "credential.https://github.com.helper", ""]);
154
+ await git(["-C", dest, "config", "--local", "--add", "credential.https://github.com.helper", helper]);
155
+ }
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { createSign } from "node:crypto";
4
+ /**
5
+ * GitHub App installation tokens (M2, flavor A: user-owned app, key on the node).
6
+ *
7
+ * The node holds the app private key and mints its own short-lived installation
8
+ * access tokens — the control plane never sees a repo-capable credential. A JWT
9
+ * signed with the app key is exchanged for a 1h installation token scoped to the
10
+ * repos the app is installed on; comments/PRs then post as `<app>[bot]`.
11
+ *
12
+ * Dependency-free (node:crypto only), matching the rest of the webhook path.
13
+ */
14
+ const b64url = (input) => Buffer.from(input).toString("base64url");
15
+ /**
16
+ * Build a short-lived GitHub App JWT (RS256). `iat` is backdated 30s to tolerate
17
+ * clock skew; `exp` is 9 min out (GitHub's ceiling is 10). `iss` is the app id.
18
+ */
19
+ export function createAppJwt(appId, privateKeyPem, nowSec) {
20
+ const header = { alg: "RS256", typ: "JWT" };
21
+ const payload = { iat: nowSec - 30, exp: nowSec + 540, iss: String(appId) };
22
+ const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
23
+ const signature = createSign("RSA-SHA256").update(signingInput).end().sign(privateKeyPem);
24
+ return `${signingInput}.${b64url(signature)}`;
25
+ }
26
+ /** Exchange an app JWT for a 1h installation access token. */
27
+ export async function mintInstallationToken(opts) {
28
+ const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1000);
29
+ const jwt = createAppJwt(opts.appId, opts.privateKeyPem, nowSec);
30
+ const doFetch = opts.fetchImpl ?? fetch;
31
+ const res = await doFetch(`https://api.github.com/app/installations/${opts.installationId}/access_tokens`, {
32
+ method: "POST",
33
+ headers: {
34
+ authorization: `Bearer ${jwt}`,
35
+ accept: "application/vnd.github+json",
36
+ "x-github-api-version": "2022-11-28",
37
+ "user-agent": "bivy",
38
+ },
39
+ });
40
+ if (!res.ok) {
41
+ const detail = await res.text().catch(() => "");
42
+ throw new Error(`GitHub installation token request failed (${res.status}): ${detail.slice(0, 200)}`);
43
+ }
44
+ const data = (await res.json().catch(() => ({})));
45
+ if (!data.token)
46
+ throw new Error("GitHub installation token response had no token");
47
+ return { token: data.token, expiresAt: data.expires_at ?? "" };
48
+ }
49
+ /**
50
+ * Resolve the installation id for a repo the app is installed on, via
51
+ * `GET /repos/{owner}/{repo}/installation` (authed with an app JWT — no
52
+ * installation token needed yet, which is the chicken/egg this breaks). Returns
53
+ * undefined when the app isn't installed there (404) or on any API error, so the
54
+ * caller falls back to the user PAT. `owner`/`repo` are expected pre-validated.
55
+ */
56
+ export async function resolveInstallationId(opts) {
57
+ const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1000);
58
+ const jwt = createAppJwt(opts.appId, opts.privateKeyPem, nowSec);
59
+ const doFetch = opts.fetchImpl ?? fetch;
60
+ const res = await doFetch(`https://api.github.com/repos/${opts.owner}/${opts.repo}/installation`, {
61
+ headers: {
62
+ authorization: `Bearer ${jwt}`,
63
+ accept: "application/vnd.github+json",
64
+ "x-github-api-version": "2022-11-28",
65
+ "user-agent": "bivy",
66
+ },
67
+ });
68
+ if (!res.ok)
69
+ return undefined;
70
+ const data = (await res.json().catch(() => ({})));
71
+ return data.id != null ? String(data.id) : undefined;
72
+ }
73
+ /**
74
+ * Per-installation token cache. Reuses a token until ~5 min before it expires,
75
+ * so one webhook burst doesn't mint a token per work item. Injectable fetch/clock
76
+ * for tests.
77
+ */
78
+ export class InstallationTokenCache {
79
+ appId;
80
+ privateKeyPem;
81
+ fetchImpl;
82
+ now;
83
+ cache = new Map();
84
+ static SKEW_MS = 5 * 60 * 1000;
85
+ constructor(appId, privateKeyPem, fetchImpl = fetch, now = () => Date.now()) {
86
+ this.appId = appId;
87
+ this.privateKeyPem = privateKeyPem;
88
+ this.fetchImpl = fetchImpl;
89
+ this.now = now;
90
+ }
91
+ async get(installationId) {
92
+ const key = String(installationId);
93
+ const hit = this.cache.get(key);
94
+ if (hit?.expiresAt && Date.parse(hit.expiresAt) - this.now() > InstallationTokenCache.SKEW_MS) {
95
+ return hit.token;
96
+ }
97
+ const fresh = await mintInstallationToken({
98
+ appId: this.appId,
99
+ privateKeyPem: this.privateKeyPem,
100
+ installationId,
101
+ nowSec: Math.floor(this.now() / 1000),
102
+ fetchImpl: this.fetchImpl,
103
+ });
104
+ this.cache.set(key, fresh);
105
+ return fresh.token;
106
+ }
107
+ }