@bivy/bivy 0.0.0 → 0.1.0-staging.10

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 (147) 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 +4215 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/port-picker.mjs +40 -0
  9. package/bin/prune-sessions.mjs +52 -0
  10. package/bin/sessions-list.mjs +27 -0
  11. package/bin/shim-path.mjs +126 -0
  12. package/bin/uninstall-paths.mjs +48 -0
  13. package/dist/approval.js +87 -0
  14. package/dist/attach.js +248 -0
  15. package/dist/auth.js +258 -0
  16. package/dist/bivy-login.js +180 -0
  17. package/dist/browser-open.js +50 -0
  18. package/dist/control-plane-tasks.js +236 -0
  19. package/dist/data-dir.js +25 -0
  20. package/dist/device-registry.js +201 -0
  21. package/dist/e2e.js +70 -0
  22. package/dist/ephemeral-exec.js +109 -0
  23. package/dist/exec.js +209 -0
  24. package/dist/git-auth.js +155 -0
  25. package/dist/github-app-auth.js +107 -0
  26. package/dist/github-app-connect.js +235 -0
  27. package/dist/github-app-manifest.js +82 -0
  28. package/dist/github-app-sync-cli.js +93 -0
  29. package/dist/github-app-vault.js +106 -0
  30. package/dist/github-apps.js +121 -0
  31. package/dist/github-connect-repo.js +74 -0
  32. package/dist/github-device-auth.js +109 -0
  33. package/dist/github-tasks.js +650 -0
  34. package/dist/guard.js +109 -0
  35. package/dist/harness/cache-evict.js +88 -0
  36. package/dist/harness/checkpoint.js +0 -0
  37. package/dist/harness/cow-clone.js +84 -0
  38. package/dist/harness/dep-cache.js +78 -0
  39. package/dist/harness/disk-admission.js +46 -0
  40. package/dist/harness/egress.js +30 -0
  41. package/dist/harness/manager.js +97 -0
  42. package/dist/harness/mcp-config-formats.js +164 -0
  43. package/dist/harness/mcp-config.js +111 -0
  44. package/dist/harness/mcp-inject.js +134 -0
  45. package/dist/harness/mcp-proxy-cli.js +88 -0
  46. package/dist/harness/mcp-proxy.js +150 -0
  47. package/dist/harness/net-proxy.js +120 -0
  48. package/dist/harness/sandbox.js +96 -0
  49. package/dist/history-sync.js +26 -0
  50. package/dist/hosted-endpoints.d.mts +14 -0
  51. package/dist/hosted-endpoints.mjs +35 -0
  52. package/dist/identity.js +153 -0
  53. package/dist/integrations/index.js +4 -0
  54. package/dist/integrations/manager.js +279 -0
  55. package/dist/integrations/oauth.js +78 -0
  56. package/dist/integrations/registry.js +239 -0
  57. package/dist/integrations/store.js +54 -0
  58. package/dist/integrations/types.js +1 -0
  59. package/dist/linear-tasks.js +49 -0
  60. package/dist/metadata.js +226 -0
  61. package/dist/multiplexer.js +79 -0
  62. package/dist/native-pi.js +38 -0
  63. package/dist/node-stats.js +237 -0
  64. package/dist/pairing-crypto.js +105 -0
  65. package/dist/policy/conditions.js +103 -0
  66. package/dist/policy/policy-engine.js +20 -0
  67. package/dist/policy/risk.js +18 -0
  68. package/dist/policy/ruleset.js +113 -0
  69. package/dist/policy/run-policy.js +108 -0
  70. package/dist/policy/session-reroute.js +96 -0
  71. package/dist/pty-runner.py +95 -0
  72. package/dist/question.js +146 -0
  73. package/dist/redact.js +97 -0
  74. package/dist/relay-attach.js +345 -0
  75. package/dist/relay-chunk.js +73 -0
  76. package/dist/relay-cli-crypto.js +70 -0
  77. package/dist/relay-client.js +372 -0
  78. package/dist/relay-setup.js +262 -0
  79. package/dist/repo-workspace.js +208 -0
  80. package/dist/runtime/adoption.js +45 -0
  81. package/dist/runtime/agent-service-bin.js +149 -0
  82. package/dist/runtime/agent-service.js +439 -0
  83. package/dist/runtime/ansi.js +27 -0
  84. package/dist/runtime/anthropic-preflight.js +80 -0
  85. package/dist/runtime/claude-code.js +1408 -0
  86. package/dist/runtime/cli-parsers.js +647 -0
  87. package/dist/runtime/codex-auth.js +168 -0
  88. package/dist/runtime/codex-preflight.js +60 -0
  89. package/dist/runtime/codex-sessions.js +279 -0
  90. package/dist/runtime/control-plane-location.js +74 -0
  91. package/dist/runtime/credential-ingest.js +122 -0
  92. package/dist/runtime/credential-provisioning.js +79 -0
  93. package/dist/runtime/credential-store.js +435 -0
  94. package/dist/runtime/credentials.js +153 -0
  95. package/dist/runtime/host.js +153 -0
  96. package/dist/runtime/index.js +1552 -0
  97. package/dist/runtime/local-model-store.js +194 -0
  98. package/dist/runtime/location-registry.js +28 -0
  99. package/dist/runtime/model-catalog.js +97 -0
  100. package/dist/runtime/model-namer.js +85 -0
  101. package/dist/runtime/native-process-scan.js +102 -0
  102. package/dist/runtime/native-session-discovery.js +103 -0
  103. package/dist/runtime/normalize.js +75 -0
  104. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  105. package/dist/runtime/oauth/model-oauth.js +324 -0
  106. package/dist/runtime/opencode-preflight.js +55 -0
  107. package/dist/runtime/pi-auth.js +82 -0
  108. package/dist/runtime/pi-oauth.js +52 -0
  109. package/dist/runtime/pi-session-discovery.js +42 -0
  110. package/dist/runtime/pi.js +539 -0
  111. package/dist/runtime/process.js +499 -0
  112. package/dist/runtime/protocol.js +646 -0
  113. package/dist/runtime/remote.js +541 -0
  114. package/dist/runtime/rpc-protocol.js +56 -0
  115. package/dist/runtime/ruleset-store.js +117 -0
  116. package/dist/runtime/session-location.js +50 -0
  117. package/dist/runtime/types.js +17 -0
  118. package/dist/secrets-cli.js +134 -0
  119. package/dist/secrets.js +264 -0
  120. package/dist/server.js +9435 -0
  121. package/dist/session/bivy-session.js +1 -0
  122. package/dist/session/checkpoint-pack.js +133 -0
  123. package/dist/session/event-log.js +340 -0
  124. package/dist/session/fork-dirty.js +73 -0
  125. package/dist/session/fork-prereqs.js +61 -0
  126. package/dist/session/fork.js +90 -0
  127. package/dist/session/native-import.js +56 -0
  128. package/dist/session/reconnect.js +168 -0
  129. package/dist/session/replication-service.js +236 -0
  130. package/dist/session/replication.js +106 -0
  131. package/dist/session/replicator.js +140 -0
  132. package/dist/session/session-new-dedupe.js +42 -0
  133. package/dist/session/sibling-client.js +201 -0
  134. package/dist/session/transcript-merge.js +131 -0
  135. package/dist/session/transcript-normal.js +195 -0
  136. package/dist/session/workspace-context.js +1 -0
  137. package/dist/session-event-coalescer.js +50 -0
  138. package/dist/session-identity.js +34 -0
  139. package/dist/session-ref.js +65 -0
  140. package/dist/stt-cli.js +131 -0
  141. package/dist/stt.js +168 -0
  142. package/dist/terminal.js +409 -0
  143. package/dist/wire-format.js +67 -0
  144. package/dist/worktree-provision.js +118 -0
  145. package/dist/worktree.js +117 -0
  146. package/package.json +40 -6
  147. package/public/qr.js +464 -0
@@ -0,0 +1,262 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import readline from "node:readline";
6
+ import { fileURLToPath } from "node:url";
7
+ import { NodeIdentity } from "./identity.js";
8
+ import { hostedEndpoints } from "./hosted-endpoints.mjs";
9
+ import { openBrowser } from "./browser-open.js";
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ /**
12
+ * One-time node relay setup.
13
+ *
14
+ * Signs into the control plane (hands-free magic-link by default), enrolls THIS
15
+ * node (using its stable nodeId), generates an E2E key, and writes
16
+ * `.bivy/relay.json`. After this the daemon dials the relay automatically
17
+ * on next start.
18
+ *
19
+ * Endpoints default to the baked-in hosted service (see hosted-endpoints.mjs);
20
+ * a user never has to type a URL. Everything is overridable for self-hosting:
21
+ *
22
+ * npm run relay:setup -- --email you@example.com
23
+ * BIVY_HOSTED_DOMAIN=bivy.sh npm run relay:setup -- --email you@…
24
+ * npm run relay:setup -- --control-plane https://app.x --relay wss://relay.x \
25
+ * --email you@example.com
26
+ *
27
+ * Sign-in is hands-free: we email a link, you click it in a browser, and this
28
+ * command detects completion by polling. Pass `--session-token` to skip sign-in
29
+ * when you already have an account session.
30
+ */
31
+ function arg(name, fallback) {
32
+ const i = process.argv.indexOf(`--${name}`);
33
+ if (i !== -1 && process.argv[i + 1])
34
+ return process.argv[i + 1];
35
+ return fallback;
36
+ }
37
+ function sleep(ms) {
38
+ return new Promise((resolve) => setTimeout(resolve, ms));
39
+ }
40
+ async function fetchJson(url, init) {
41
+ let response;
42
+ try {
43
+ response = await fetch(url, init);
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`Could not reach ${url}: ${message}`);
48
+ }
49
+ const data = await response.json().catch(() => ({}));
50
+ return { ok: response.ok, status: response.status, data };
51
+ }
52
+ async function askYesNo(question, defaultYes) {
53
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
54
+ const hint = defaultYes ? "Y/n" : "y/N";
55
+ try {
56
+ for (;;) {
57
+ const answer = await new Promise((resolve) => rl.question(`\n${question} (${hint})\n > `, resolve));
58
+ const v = answer.trim().toLowerCase();
59
+ if (!v)
60
+ return defaultYes;
61
+ if (v === "y" || v === "yes")
62
+ return true;
63
+ if (v === "n" || v === "no")
64
+ return false;
65
+ console.log("Please answer yes or no.");
66
+ }
67
+ }
68
+ finally {
69
+ rl.close();
70
+ }
71
+ }
72
+ async function checkControlPlane(controlPlaneUrl) {
73
+ // /me returns 401 when healthy and unauthenticated. That is good enough to
74
+ // catch DNS/TLS/reverse-proxy mistakes before the user waits for email.
75
+ try {
76
+ const response = await fetch(`${controlPlaneUrl}/me`);
77
+ if (response.status === 401 || response.ok)
78
+ return;
79
+ throw new Error(`health check returned HTTP ${response.status}`);
80
+ }
81
+ catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ throw new Error(`Hosted control plane is not reachable at ${controlPlaneUrl}. ${message}`);
84
+ }
85
+ }
86
+ /** Poll a started device login until it completes; returns the session token. */
87
+ async function pollDevice(controlPlaneUrl, start) {
88
+ const intervalMs = Number(start.intervalMs) || 2000;
89
+ const deadline = Date.now() + (Number(start.expiresInMs) || 15 * 60_000);
90
+ process.stdout.write("Waiting for you to finish sign-in");
91
+ while (Date.now() < deadline) {
92
+ await sleep(intervalMs);
93
+ process.stdout.write(".");
94
+ const pollResult = await fetchJson(`${controlPlaneUrl}/auth/device/poll`, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/json" },
97
+ body: JSON.stringify({ deviceId: start.deviceId, deviceSecret: start.deviceSecret }),
98
+ }).catch(() => null);
99
+ const poll = pollResult?.data;
100
+ if (poll?.status === "complete" && poll.token) {
101
+ process.stdout.write("\n");
102
+ return poll.token;
103
+ }
104
+ if (poll?.status === "expired")
105
+ break;
106
+ }
107
+ process.stdout.write("\n");
108
+ throw new Error("Sign-in timed out or expired. Run setup again.");
109
+ }
110
+ /**
111
+ * Hands-free magic-link sign-in. Emails a link, opens/prints it, polls until
112
+ * clicked. Returns an account session token.
113
+ */
114
+ async function deviceLogin(controlPlaneUrl, email) {
115
+ const started = await fetchJson(`${controlPlaneUrl}/auth/device/start`, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({ email }),
119
+ });
120
+ const start = started.data;
121
+ if (!started.ok || !start?.deviceId || !start?.deviceSecret) {
122
+ throw new Error(`Sign-in could not start (${started.status}): ${JSON.stringify(start)}`);
123
+ }
124
+ if (start.devLink) {
125
+ const opened = openBrowser(start.devLink);
126
+ console.log(opened
127
+ ? `\nOpening sign-in link (no email configured on the server):\n ${start.devLink}`
128
+ : `\nNo email configured on the server — open this sign-in link in a browser (this machine has none):\n ${start.devLink}`);
129
+ }
130
+ else {
131
+ console.log(`\nWe emailed a sign-in link to ${email}. Open it in your browser to continue.`);
132
+ }
133
+ return pollDevice(controlPlaneUrl, start);
134
+ }
135
+ /**
136
+ * Hands-free GitHub sign-in (primary). Opens the GitHub authorize URL and polls
137
+ * until the OAuth callback completes the device login. Returns a session token.
138
+ */
139
+ async function githubDeviceLogin(controlPlaneUrl) {
140
+ const started = await fetchJson(`${controlPlaneUrl}/auth/device/github/start`, {
141
+ method: "POST",
142
+ headers: { "content-type": "application/json" },
143
+ body: "{}",
144
+ });
145
+ const start = started.data;
146
+ if (started.status === 501)
147
+ throw new Error("GitHub sign-in is not enabled on this control plane. Use --email instead.");
148
+ if (!started.ok || !start?.deviceId || !start?.authorizeUrl) {
149
+ throw new Error(`GitHub sign-in could not start (${started.status}): ${JSON.stringify(start)}`);
150
+ }
151
+ const opened = openBrowser(start.authorizeUrl);
152
+ console.log(opened
153
+ ? `\nSign in with GitHub in your browser:\n ${start.authorizeUrl}`
154
+ : `\nSign in with GitHub — open this link in a browser on any device (this machine has none):\n ${start.authorizeUrl}`);
155
+ return pollDevice(controlPlaneUrl, start);
156
+ }
157
+ async function main() {
158
+ const repoRoot = path.resolve(__dirname, "..");
159
+ // Honor the same override as every other entry point (server.ts, native-pi.ts,
160
+ // bivy-login.ts, secrets-cli.ts, …) so a global/packaged install writes
161
+ // relay.json and the node identity into the real data dir instead of a
162
+ // package directory that gets wiped on update. See issue #2.
163
+ const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
164
+ const endpoints = hostedEndpoints();
165
+ const controlPlaneUrl = (arg("control-plane", process.env.BIVY_CONTROL_PLANE_URL) ?? endpoints.controlPlane).replace(/\/$/, "");
166
+ const relayUrl = (arg("relay", process.env.BIVY_RELAY_URL) ?? endpoints.relay).replace(/\/$/, "");
167
+ const clientBaseUrl = (arg("client", process.env.BIVY_CLIENT_BASE_URL) ?? endpoints.clientBaseUrl).replace(/\/$/, "") || controlPlaneUrl;
168
+ const email = arg("email", process.env.BIVY_EMAIL);
169
+ const sessionToken = arg("session-token", process.env.BIVY_SESSION_TOKEN);
170
+ // GitHub is the primary sign-in: used when --github is passed, or by default
171
+ // when neither an email nor an existing session token is supplied.
172
+ const useGithub = process.argv.includes("--github") || process.env.BIVY_AUTH === "github" || (!email && !sessionToken);
173
+ const identity = NodeIdentity.load(appDir);
174
+ console.log(`Node: ${identity.name} (${identity.nodeId})`);
175
+ console.log(`Control plane: ${controlPlaneUrl}`);
176
+ console.log(`Relay: ${relayUrl}`);
177
+ await checkControlPlane(controlPlaneUrl);
178
+ const token = sessionToken ?? (useGithub ? await githubDeviceLogin(controlPlaneUrl) : await deviceLogin(controlPlaneUrl, email));
179
+ async function enrollNode() {
180
+ return fetchJson(`${controlPlaneUrl}/nodes/enroll`, {
181
+ method: "POST",
182
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
183
+ body: JSON.stringify({ nodeId: identity.nodeId, name: identity.name }),
184
+ });
185
+ }
186
+ let enrolled = await enrollNode();
187
+ let enroll = enrolled.data;
188
+ if (!enrolled.ok && enrolled.status === 402 && /node limit/i.test(String(enroll?.error ?? ""))) {
189
+ const listed = await fetchJson(`${controlPlaneUrl}/nodes`, { headers: { authorization: `Bearer ${token}` } });
190
+ const nodes = Array.isArray(listed.data) ? listed.data : [];
191
+ if (nodes.length) {
192
+ // Lead with the upgrade paths — subscribing or increasing the plan is the
193
+ // intended way to add a node. Removing an existing node is the fallback.
194
+ const accountUrl = `${clientBaseUrl}/?account=1`;
195
+ console.log("\nYour plan's node limit is reached.");
196
+ console.log(`To connect more nodes, subscribe or increase your plan:\n ${accountUrl}`);
197
+ console.log("\nOr free a slot by removing an existing node:");
198
+ for (const [i, node] of nodes.entries()) {
199
+ console.log(` ${i + 1}. ${node.name ?? "Node"} — ${node.online ? "online" : "offline"} — ${node.id}`);
200
+ }
201
+ const replace = await askYesNo(`Remove ${nodes[0].name ?? nodes[0].id} and enroll this node instead?`, !nodes[0].online);
202
+ if (replace) {
203
+ const removed = await fetchJson(`${controlPlaneUrl}/nodes/${encodeURIComponent(nodes[0].id)}`, {
204
+ method: "DELETE",
205
+ headers: { authorization: `Bearer ${token}` },
206
+ });
207
+ if (!removed.ok)
208
+ throw new Error(`Could not remove old node (${removed.status}): ${JSON.stringify(removed.data)}`);
209
+ enrolled = await enrollNode();
210
+ enroll = enrolled.data;
211
+ }
212
+ }
213
+ }
214
+ if (!enrolled.ok || !enroll?.enrollmentToken)
215
+ throw new Error(`Enroll failed (${enrolled.status}): ${JSON.stringify(enroll)}`);
216
+ // The control plane keeps node names unique per account, so a colliding name may
217
+ // have been auto-suffixed (e.g. "Mac-2"). Adopt the assigned name locally so the
218
+ // node serves the matching `bivy/<name>` label (and the UI agrees on the name).
219
+ const assignedName = typeof enroll?.node?.name === "string" ? enroll.node.name : undefined;
220
+ if (assignedName && assignedName !== identity.name) {
221
+ try {
222
+ identity.setName(assignedName);
223
+ console.log(`Node name adjusted to "${assignedName}" (kept unique on your account).`);
224
+ }
225
+ catch {
226
+ /* non-fatal — routing still works via the shared bivy label */
227
+ }
228
+ }
229
+ const config = {
230
+ url: relayUrl,
231
+ controlPlaneUrl,
232
+ clientBaseUrl,
233
+ enrollmentToken: enroll.enrollmentToken,
234
+ };
235
+ const filePath = path.join(appDir, "relay.json");
236
+ fs.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
237
+ fs.chmodSync(filePath, 0o600);
238
+ // Optional handoff: when `bivy setup` runs us with --emit-session, drop the
239
+ // account session we just obtained (plus this node's id) into a 0600 file so
240
+ // setup can open the remote app signed into the whole account — not a
241
+ // node-scoped grant that would show only this node. The account bearer never
242
+ // goes into relay.json (the node keeps only its enrollment token); this file is
243
+ // read once and deleted by setup. We skip it when the caller supplied a session
244
+ // token only via --session-token/env with no --emit-session, i.e. non-setup use.
245
+ const emitSession = arg("emit-session");
246
+ if (emitSession) {
247
+ try {
248
+ const handoff = JSON.stringify({ session: token, nodeId: identity.nodeId });
249
+ fs.writeFileSync(emitSession, `${handoff}\n`, { mode: 0o600 });
250
+ fs.chmodSync(emitSession, 0o600);
251
+ }
252
+ catch {
253
+ // best effort — setup falls back to opening the plain remote app URL
254
+ }
255
+ }
256
+ console.log(`\n✓ Signed in and enrolled this node. Wrote ${filePath}`);
257
+ console.log('Run "bivy link" to pair a phone, or use "Link remote device" in the app (bivy open).');
258
+ }
259
+ main().catch((error) => {
260
+ console.error(error);
261
+ process.exit(1);
262
+ });
@@ -0,0 +1,208 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { resolveSecret } from "./secrets.js";
8
+ import { cleanRemoteUrl, credConfigArgs, gitNonInteractiveEnv, configureRepoCredentialHelper } from "./git-auth.js";
9
+ const exec = promisify(execFile);
10
+ /** Parse "owner/repo" (also tolerates a full github.com URL or trailing .git). */
11
+ export function parseRepo(input) {
12
+ const cleaned = String(input)
13
+ .trim()
14
+ .replace(/^https?:\/\/github\.com\//i, "")
15
+ .replace(/^git@github\.com:/i, "")
16
+ .replace(/\.git$/i, "")
17
+ .replace(/\/+$/, "");
18
+ const m = cleaned.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
19
+ return m ? { owner: m[1], repo: m[2], slug: `${m[1]}/${m[2]}` } : undefined;
20
+ }
21
+ /** Parse a GitHub owner/repo slug from a git remote URL. */
22
+ export function parseGitHubRemote(input) {
23
+ const value = String(input).trim();
24
+ const https = value.match(/^https?:\/\/(?:[^/@]+(?::[^/@]*)?@)?github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
25
+ if (https)
26
+ return parseRepo(`${https[1]}/${https[2]}`);
27
+ const ssh = value.match(/^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
28
+ if (ssh)
29
+ return parseRepo(`${ssh[1]}/${ssh[2]}`);
30
+ return undefined;
31
+ }
32
+ /** Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout. */
33
+ export async function inferGitHubRepoFromWorkspace(workspace) {
34
+ try {
35
+ const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"]);
36
+ return parseGitHubRemote(stdout);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** A GitHub token from env or the local `gh` login, or undefined (public only). */
43
+ export async function resolveGitHubToken(env = process.env) {
44
+ const fromEnv = env.BIVY_GITHUB_TOKEN?.trim();
45
+ if (fromEnv) {
46
+ if (fromEnv.startsWith("secret://") || fromEnv.startsWith("op://") || fromEnv.startsWith("env://"))
47
+ return resolveSecret(fromEnv);
48
+ return fromEnv;
49
+ }
50
+ try {
51
+ const { stdout } = await exec("gh", ["auth", "token"]);
52
+ return stdout.trim() || undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /**
59
+ * Refresh the remote-tracking refs so a session branches off the CURRENT state
60
+ * of `origin`, not whatever the local checkout last saw. Best-effort: an offline
61
+ * node (or a checkout with no reachable origin) keeps its existing refs and the
62
+ * caller branches off those. `cloneOrUpdateRepo` already fetches on the clone
63
+ * path, so this is for the "workspace is an existing local checkout" path, which
64
+ * otherwise never pulls the latest origin before cutting a branch.
65
+ */
66
+ export async function fetchOrigin(repoDir) {
67
+ try {
68
+ await exec("git", ["-C", repoDir, "fetch", "origin", "--prune"], { timeout: 120_000 });
69
+ }
70
+ catch {
71
+ // offline / no origin / no rights — branch off the refs we already have.
72
+ }
73
+ }
74
+ /**
75
+ * Resolve the ref a new branch should be cut from: the remote's default branch
76
+ * (`origin/main`, `origin/master`, …). Falls back to `origin/main` when the
77
+ * remote HEAD isn't recorded. Used so a repo-backed session branches off the
78
+ * upstream default rather than whatever the shared checkout happens to be on.
79
+ */
80
+ export async function resolveDefaultBaseRef(repoDir) {
81
+ // origin/HEAD points at the remote's default branch when it's been recorded.
82
+ try {
83
+ const { stdout } = await exec("git", ["-C", repoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
84
+ const ref = stdout.trim();
85
+ if (ref)
86
+ return ref; // e.g. "origin/main"
87
+ }
88
+ catch {
89
+ // origin/HEAD is often unset on a fresh clone — record it, then retry.
90
+ }
91
+ try {
92
+ await exec("git", ["-C", repoDir, "remote", "set-head", "origin", "--auto"]);
93
+ const { stdout } = await exec("git", ["-C", repoDir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
94
+ const ref = stdout.trim();
95
+ if (ref)
96
+ return ref;
97
+ }
98
+ catch {
99
+ // best effort
100
+ }
101
+ return "origin/main";
102
+ }
103
+ /**
104
+ * Resolve the ref to branch a new session from when the caller (the composer's
105
+ * branch pill) requested a SPECIFIC remote branch instead of the repo's
106
+ * default. `cloneOrUpdateRepo`/`fetchOrigin` have already brought every remote
107
+ * branch's tracking ref down before this runs, so this only needs to verify
108
+ * `origin/<branch>` actually exists — surfacing a clear error instead of
109
+ * silently falling back to the default when the requested branch is missing
110
+ * (e.g. typo'd, deleted upstream since the picker's list was fetched).
111
+ */
112
+ export async function resolveBranchBaseRef(repoDir, branch) {
113
+ const ref = `origin/${branch}`;
114
+ try {
115
+ await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", ref]);
116
+ return ref;
117
+ }
118
+ catch {
119
+ throw new Error(`Branch "${branch}" was not found on the remote.`);
120
+ }
121
+ }
122
+ /**
123
+ * Whether an existing Bivy-owned checkout at `dest` can be reused as-is, i.e. it
124
+ * has a `.git` entry AND `git rev-parse` accepts it as a real repository. A
125
+ * `.git` can survive an interrupted/corrupt clone, so presence alone is not
126
+ * enough — callers that reuse on presence alone hand a broken directory to the
127
+ * worktree flow, which then fails with "Not a git repository". Exported for
128
+ * tests.
129
+ */
130
+ export async function isReusableCheckout(dest) {
131
+ if (!fs.existsSync(path.join(dest, ".git")))
132
+ return false;
133
+ try {
134
+ await exec("git", ["-C", dest, "rev-parse", "--show-toplevel"]);
135
+ return true;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ /**
142
+ * Ensure a working clone of the repo exists under `root` and return its path.
143
+ * Clones on first use; on later use fetches the latest. One checkout per repo.
144
+ */
145
+ // The remote URL is now token-free — auth goes through the daemon credential
146
+ // helper (see src/git-auth.ts), so no token is written into `.git/config`.
147
+ // Still tighten `.git/config` to 0600 (git writes it 0644 by default): it holds
148
+ // the credential-helper config and other local settings, and a least-readable
149
+ // posture is cheap insurance. Best-effort.
150
+ function hardenGitConfigPerms(dest) {
151
+ try {
152
+ fs.chmodSync(path.join(dest, ".git", "config"), 0o600);
153
+ }
154
+ catch {
155
+ // no .git/config yet, or a filesystem without POSIX modes — ignore
156
+ }
157
+ }
158
+ export async function cloneOrUpdateRepo(opts) {
159
+ const dest = path.join(opts.root, `${opts.owner}__${opts.repo}`);
160
+ // Keep the remote URL token-free; auth flows through the daemon credential
161
+ // helper (see src/git-auth.ts), which fetches a fresh token on demand — so no
162
+ // token is written into this clone's `.git/config`, where an agent (or
163
+ // `git remote -v` / a log / a screenshot) could read it.
164
+ const url = cleanRemoteUrl(opts.owner, opts.repo);
165
+ const env = gitNonInteractiveEnv();
166
+ const cc = credConfigArgs();
167
+ // Reuse the existing checkout only if it is a REAL git repository. A `.git`
168
+ // entry can survive a clone that was interrupted (network drop, killed
169
+ // process) or otherwise left corrupt — the directory exists and has a `.git`,
170
+ // but `git rev-parse` rejects it. Trusting `.git`'s mere presence took the
171
+ // fetch path and returned that broken directory, so every later "new session
172
+ // on this repo" failed downstream in createWorktree with "Not a git
173
+ // repository" — permanently, because the wipe-and-reclone repair below only
174
+ // ran when `.git` was entirely absent. Validate first; if it's broken, fall
175
+ // through and rebuild it.
176
+ if (await isReusableCheckout(dest)) {
177
+ try {
178
+ // Rewrite origin to the clean URL. This also MIGRATES any pre-existing
179
+ // clone whose remote still carries an embedded token from before this fix.
180
+ await exec("git", ["-C", dest, "remote", "set-url", "origin", url]);
181
+ await configureRepoCredentialHelper((a) => exec("git", a), dest);
182
+ await exec("git", ["-C", dest, ...cc, "fetch", "--all", "--prune"], { timeout: 120_000, env });
183
+ // Re-point origin/HEAD at the remote's CURRENT default branch. Without
184
+ // this a cached checkout keeps whatever default it recorded at first
185
+ // clone, so if the repo's default later changed (e.g. main → master) a new
186
+ // session would branch off the stale remote default. --auto is best-effort
187
+ // and only rewrites the local origin/HEAD pointer, never a branch.
188
+ await exec("git", ["-C", dest, "remote", "set-head", "origin", "--auto"]);
189
+ }
190
+ catch {
191
+ // offline / fetch failed — reuse the existing checkout as-is
192
+ }
193
+ hardenGitConfigPerms(dest);
194
+ return dest;
195
+ }
196
+ fs.mkdirSync(opts.root, { recursive: true });
197
+ // A failed/interrupted/corrupt clone can leave a non-repository (or broken
198
+ // repository) directory behind; because this path is Bivy-owned
199
+ // (<root>/<owner>__<repo>), repair it instead of letting every later "new repo
200
+ // session" fail with "destination exists" or "Not a git repository".
201
+ if (fs.existsSync(dest))
202
+ fs.rmSync(dest, { recursive: true, force: true });
203
+ await exec("git", [...cc, "clone", url, dest], { timeout: 600_000, env });
204
+ // Persist the helper config so agent-run git in this clone authenticates too.
205
+ await configureRepoCredentialHelper((a) => exec("git", a), dest);
206
+ hardenGitConfigPerms(dest);
207
+ return dest;
208
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * The agent service's `attach` op replies "No detached session to attach: <id>"
5
+ * (see agent-service.ts handleStart) when it is reachable but has no such live
6
+ * session — the only DEFINITIVE "gone" signal. Every other failure (connection
7
+ * refused / reset / closed-during-start / timeout) is transient: the service may
8
+ * be down or restarting, so the child may well still exist.
9
+ */
10
+ export function classifyAttachFailure(err) {
11
+ const message = err instanceof Error ? err.message : String(err);
12
+ return /no detached session to attach/i.test(message) ? "gone" : "transient";
13
+ }
14
+ /**
15
+ * Attempt to re-attach to each adoptable session concurrently, applying the
16
+ * failure policy. Never throws — a single session's failure only affects its own
17
+ * classification. Callers should PRE-RECORD every row's address into the durable
18
+ * location registry BEFORE calling this, so a racing `advertiseSessions()` (the
19
+ * replace-all-per-node POST) preserves the addresses even while attaches are still
20
+ * in flight — otherwise the first advert would wipe the very column adoption reads.
21
+ */
22
+ export async function attachAdoptedSessions(rows, deps) {
23
+ const adopted = [];
24
+ const kept = [];
25
+ const forgotten = [];
26
+ await Promise.all(rows.map(async (location) => {
27
+ try {
28
+ await deps.attach(location);
29
+ adopted.push(location.sessionId);
30
+ deps.log?.(`adopted ${location.sessionId} at ${location.agentServiceAddress}`);
31
+ }
32
+ catch (error) {
33
+ if (classifyAttachFailure(error) === "gone") {
34
+ forgotten.push(location.sessionId);
35
+ await deps.forget(location.sessionId).catch(() => { });
36
+ deps.log?.(`forgot definitively-gone session ${location.sessionId}`);
37
+ }
38
+ else {
39
+ kept.push(location.sessionId);
40
+ deps.log?.(`kept unreachable session ${location.sessionId} (${location.agentServiceAddress}) — will retry`);
41
+ }
42
+ }
43
+ }));
44
+ return { adopted, kept, forgotten };
45
+ }
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * Agent service entry point — runs the real agent runtimes in a process
5
+ * SEPARATE from the node daemon and exposes them over the RPC protocol
6
+ * (src/runtime/rpc-protocol.ts). Launch it beside a daemon and point the daemon
7
+ * at it with BIVY_REMOTE_RUNTIME=1 + BIVY_REMOTE_RUNTIME_ADDR (see
8
+ * docs/agent-runtime-rpc.md).
9
+ *
10
+ * BIVY_AGENT_SERVICE_LISTEN where to listen: "unix:/path.sock" | "PORT" | "host:PORT"
11
+ * BIVY_DATA_DIR data dir (defaults to <cwd>/.bivy), same as the daemon
12
+ * BIVY_REMOTE_RUNTIME_DETACH_REAP_MS optional: reap a detached, idle session after this many ms (off by default)
13
+ *
14
+ * Dev: tsx src/runtime/agent-service-bin.ts
15
+ * Prod: node dist/runtime/agent-service-bin.js
16
+ *
17
+ * The service must have the same agent binaries on PATH and credential access
18
+ * the in-process daemon would (the Claude SDK execs the `claude` CLI internally),
19
+ * because the child now runs HERE, not in the daemon.
20
+ */
21
+ import net from "node:net";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { AgentService } from "./agent-service.js";
26
+ import { encodeFrame, FrameDecoder } from "./rpc-protocol.js";
27
+ import { parseRemoteAddress } from "./remote.js";
28
+ import { makeRuntime } from "./index.js";
29
+ import { defaultDataDir } from "../data-dir.js";
30
+ /** Adapt a raw socket to the transport-agnostic ServiceConnection. */
31
+ export function socketConnection(socket) {
32
+ const decoder = new FrameDecoder();
33
+ let onMessage;
34
+ let onClose;
35
+ socket.on("data", (chunk) => {
36
+ let messages;
37
+ try {
38
+ messages = decoder.push(chunk);
39
+ }
40
+ catch {
41
+ socket.destroy();
42
+ return;
43
+ }
44
+ if (onMessage)
45
+ for (const message of messages)
46
+ onMessage(message);
47
+ });
48
+ socket.on("error", () => {
49
+ // A 'close' event always follows; teardown happens there.
50
+ });
51
+ socket.on("close", () => onClose?.());
52
+ return {
53
+ send(message) {
54
+ try {
55
+ socket.write(encodeFrame(message));
56
+ }
57
+ catch {
58
+ // peer went away mid-write; 'close' will reap
59
+ }
60
+ },
61
+ onMessage(handler) {
62
+ onMessage = handler;
63
+ },
64
+ onClose(handler) {
65
+ onClose = handler;
66
+ },
67
+ close() {
68
+ socket.destroy();
69
+ },
70
+ };
71
+ }
72
+ /** Start a socket server that hands each connection to the AgentService. */
73
+ export function startAgentServiceServer(options) {
74
+ const server = net.createServer((socket) => options.service.accept(socketConnection(socket)));
75
+ return new Promise((resolve, reject) => {
76
+ server.once("error", reject);
77
+ server.listen(parseRemoteAddress(options.listen), () => {
78
+ server.removeListener("error", reject);
79
+ resolve(server);
80
+ });
81
+ });
82
+ }
83
+ /** Build the default runtime provider backed by the real runtime registry. */
84
+ export function defaultRuntimeProvider() {
85
+ const appDir = defaultDataDir();
86
+ const piDir = path.join(appDir, "pi");
87
+ const sessionsDir = path.join(piDir, "sessions");
88
+ // The shared, agent-neutral credential vault (not inside any agent's dir).
89
+ const credsDir = path.join(appDir, "credentials");
90
+ // Build the runtime the daemon asked for directly via makeRuntime — NOT
91
+ // through RuntimeHost.resolveRuntimeId, whose picker/availability gate is the
92
+ // daemon's UI concern. The daemon already chose the id; the service just hosts
93
+ // it (makeRuntime still throws if the agent binary/SDK is missing here).
94
+ // Node-hosted tools (Bivy integrations / MCP) reach the runtime here via the
95
+ // per-session ToolProvider the daemon supplies over RPC (agent-agnostic), so
96
+ // the service needs no runtime-specific tool wiring of its own.
97
+ const factory = { credsDir, piDir, sessionsDir };
98
+ const cache = new Map();
99
+ return (runtimeId, sandbox) => {
100
+ const key = sandbox ? `${runtimeId}::sandbox=${sandbox}` : runtimeId;
101
+ let rt = cache.get(key);
102
+ if (!rt) {
103
+ rt = makeRuntime({ ...factory, runtime: runtimeId, sandbox: sandbox });
104
+ cache.set(key, rt);
105
+ }
106
+ return rt;
107
+ };
108
+ }
109
+ async function main() {
110
+ const listen = process.env.BIVY_AGENT_SERVICE_LISTEN?.trim();
111
+ if (!listen) {
112
+ console.error("[agent-service] BIVY_AGENT_SERVICE_LISTEN is required (e.g. unix:/tmp/bivy-agent.sock or 4711)");
113
+ process.exit(2);
114
+ }
115
+ // Idle-reaper for DETACHED sessions (Stage 3). Off by default; set
116
+ // BIVY_REMOTE_RUNTIME_DETACH_REAP_MS to bound how long a session the daemon
117
+ // evicted/lost persists here with no bound connection.
118
+ const detachReapMs = Number(process.env.BIVY_REMOTE_RUNTIME_DETACH_REAP_MS) || 0;
119
+ const service = new AgentService({ runtimeProvider: defaultRuntimeProvider(), log: (message) => console.error(`[agent-service] ${message}`), detachReapMs });
120
+ if (detachReapMs > 0)
121
+ console.error(`[agent-service] detached-session idle reaper: ${detachReapMs}ms`);
122
+ const server = await startAgentServiceServer({ service, listen });
123
+ console.error(`[agent-service] listening on ${listen} (pid ${process.pid}, host ${os.hostname()})`);
124
+ let shuttingDown = false;
125
+ const shutdown = (signal) => {
126
+ if (shuttingDown)
127
+ return;
128
+ shuttingDown = true;
129
+ console.error(`[agent-service] ${signal} — reaping ${service.sessionCount} session(s)`);
130
+ service.disposeAll(); // reap every child so the service leaves no orphans
131
+ server.close(() => process.exit(0));
132
+ setTimeout(() => process.exit(0), 3000).unref();
133
+ };
134
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
135
+ process.on("SIGINT", () => shutdown("SIGINT"));
136
+ // Crash net: this service process hosts detached agent sessions, so a stray
137
+ // unhandled rejection/exception must not tear all of them down. Log and keep
138
+ // running; a real supervisor restart remains the fallback.
139
+ process.on("unhandledRejection", (reason) => {
140
+ console.error("[bivy agent-service] unhandledRejection (kept running):", reason);
141
+ });
142
+ process.on("uncaughtException", (error) => {
143
+ console.error("[bivy agent-service] uncaughtException (kept running):", error);
144
+ });
145
+ }
146
+ // Run only when executed directly (not when imported by a test).
147
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
148
+ void main();
149
+ }