@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/auth.js ADDED
@@ -0,0 +1,258 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import { spawnSync } from "node:child_process";
6
+ const NOLOGIN_SHELL = /\/(nologin|false|sync)$/;
7
+ /** Count /etc/passwd entries that look like a real, loginable human account.
8
+ * System/service accounts (UID < 1000, or a nologin/false/sync shell) don't
9
+ * count — nearly every Linux box ships dozens of those even with a single
10
+ * human owner, so counting them would misclassify almost every ordinary
11
+ * machine as shared. */
12
+ function countLinuxHumanAccounts(passwdText) {
13
+ let count = 0;
14
+ for (const line of passwdText.split("\n")) {
15
+ const fields = line.split(":");
16
+ if (fields.length < 7)
17
+ continue;
18
+ const uid = Number(fields[2]);
19
+ const shell = (fields[6] ?? "").trim();
20
+ if (!Number.isFinite(uid) || uid < 1000 || uid >= 65534)
21
+ continue;
22
+ if (!shell || NOLOGIN_SHELL.test(shell))
23
+ continue;
24
+ count++;
25
+ }
26
+ return count;
27
+ }
28
+ /** Count real macOS user accounts from `dscl . -list /Users UniqueID` output
29
+ * (one `<name> <uid>` pair per line). Apple's underscore-prefixed service
30
+ * accounts and root/daemon/nobody don't count; accounts created in System
31
+ * Settings get uid >= 500. */
32
+ function countMacHumanAccounts(dsclOutput) {
33
+ let count = 0;
34
+ for (const line of dsclOutput.split("\n")) {
35
+ const match = /^(\S+)\s+(\d+)\s*$/.exec(line.trim());
36
+ if (!match)
37
+ continue;
38
+ const [, name, uidText] = match;
39
+ if (name.startsWith("_") || name === "root" || name === "nobody" || name === "daemon")
40
+ continue;
41
+ if (Number(uidText) >= 500)
42
+ count++;
43
+ }
44
+ return count;
45
+ }
46
+ function readDsclOutput() {
47
+ try {
48
+ const result = spawnSync("dscl", [".", "-list", "/Users", "UniqueID"], { encoding: "utf8", timeout: 2000 });
49
+ return result.status === 0 ? result.stdout : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function detectMultiUserHost(deps) {
56
+ const platform = deps.platform ?? os.platform();
57
+ try {
58
+ if (platform === "darwin") {
59
+ const out = deps.dsclOutput !== undefined ? deps.dsclOutput : readDsclOutput();
60
+ return out !== null && countMacHumanAccounts(out) > 1;
61
+ }
62
+ if (platform === "win32")
63
+ return false; // not detected today — loopback bypass still applies
64
+ const text = deps.passwdText ?? fs.readFileSync("/etc/passwd", "utf8");
65
+ return countLinuxHumanAccounts(text) > 1;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ // The detection above touches the filesystem (or, on macOS, spawns a
72
+ // subprocess) — cheap once, but `loopbackAllowed()` runs on every request, so
73
+ // cache the real-environment result for the life of the process. Calls that
74
+ // pass explicit `deps` (tests) always recompute.
75
+ let cachedMultiUserHost = null;
76
+ /**
77
+ * Best-effort detection of a shared, multi-user host: more than one human
78
+ * account can run processes here, so loopback alone is not isolation between
79
+ * them (see docs/security-model.md, "Local daemon exposure").
80
+ *
81
+ * Conservative by design: a false negative (a shared host we fail to detect)
82
+ * just keeps today's loopback-bypass behavior; a false positive would force
83
+ * token auth on an ordinary single-user laptop, which is worse. Override
84
+ * explicitly with `BIVY_MULTI_USER_HOST=1` / `=0` when detection is wrong for
85
+ * your box — `BIVY_REQUIRE_LOCAL_AUTH` (see `loopbackAllowed`) takes
86
+ * precedence over both.
87
+ */
88
+ export function isMultiUserHost(deps = {}) {
89
+ const override = process.env.BIVY_MULTI_USER_HOST;
90
+ if (override === "1")
91
+ return true;
92
+ if (override === "0")
93
+ return false;
94
+ const hasOverrideDeps = Object.keys(deps).length > 0;
95
+ if (hasOverrideDeps)
96
+ return detectMultiUserHost(deps);
97
+ if (cachedMultiUserHost === null)
98
+ cachedMultiUserHost = detectMultiUserHost(deps);
99
+ return cachedMultiUserHost;
100
+ }
101
+ function loopbackAllowed() {
102
+ const override = process.env.BIVY_REQUIRE_LOCAL_AUTH;
103
+ if (override === "1")
104
+ return false;
105
+ if (override === "0")
106
+ return true;
107
+ return !isMultiUserHost();
108
+ }
109
+ export function isLoopbackAddress(address) {
110
+ if (!address)
111
+ return false;
112
+ const addr = address.replace(/^::ffff:/, "");
113
+ return addr === "127.0.0.1" || addr === "::1" || addr === "localhost";
114
+ }
115
+ export function extractToken(headerValue) {
116
+ if (!headerValue)
117
+ return null;
118
+ const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim());
119
+ return match ? match[1].trim() : null;
120
+ }
121
+ /**
122
+ * Pull a token from an HTTP request: Authorization header first, then the
123
+ * `access_token` query param (used by WebSocket upgrades where headers are
124
+ * awkward to set from browsers).
125
+ */
126
+ export function tokenFromRequest(req) {
127
+ const header = extractToken(req.headers["authorization"]);
128
+ if (header)
129
+ return header;
130
+ try {
131
+ const url = new URL(req.url ?? "", "http://localhost");
132
+ return url.searchParams.get("access_token");
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ /** Resolve auth for any incoming request (HTTP or WS upgrade). `loopback` is
139
+ * the raw physical fact (independent of `loopbackAllowed()`'s policy), so
140
+ * loopback-gated-by-secret endpoints (`/api/git-credential`,
141
+ * `/api/auth/bootstrap`) can rely on it even when the general bypass in
142
+ * `isAuthorized` is off. */
143
+ export function resolveAuth(identity, req) {
144
+ const loopback = isLoopbackAddress(req.socket?.remoteAddress);
145
+ const deviceId = identity.verifyToken(tokenFromRequest(req));
146
+ return { deviceId, loopback };
147
+ }
148
+ export function isAuthorized(ctx) {
149
+ return ctx.deviceId !== null || (ctx.loopback && loopbackAllowed());
150
+ }
151
+ /**
152
+ * Cross-origin / DNS-rebinding guard for the node's actionable surface (/api,
153
+ * /ws). The daemon runs shell and edits files, and by default it authorizes any
154
+ * loopback caller without a token — so without this guard, (a) any web page the
155
+ * user visits could open a cross-origin WebSocket to ws://localhost:4317/ws and
156
+ * drive the agent, and (b) DNS rebinding (attacker domain → 127.0.0.1) would
157
+ * defeat the browser's same-origin protections entirely.
158
+ *
159
+ * Direct inbound connections to the node are only ever local/LAN: a direct-mode
160
+ * web client (?local=1), a LAN device browsing the node directly, or CLI/native
161
+ * clients (no Origin).
162
+ * Remote phones reach the node through the relay, which the node dials
163
+ * *outbound* — those never arrive here as inbound upgrades. So we allow only
164
+ * local/private hostnames and reject a public Host (rebinding) or public Origin
165
+ * (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
166
+ * hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
167
+ */
168
+ function hostnameIsLocal(hostname) {
169
+ const h = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/^::ffff:/, "");
170
+ if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0")
171
+ return true;
172
+ if (h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".ts.net") || h.endsWith(".internal"))
173
+ return true;
174
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
175
+ if (v4) {
176
+ const a = Number(v4[1]);
177
+ const b = Number(v4[2]);
178
+ if (a === 10)
179
+ return true; // 10.0.0.0/8 private
180
+ if (a === 192 && b === 168)
181
+ return true; // 192.168.0.0/16 private
182
+ if (a === 172 && b >= 16 && b <= 31)
183
+ return true; // 172.16.0.0/12 private
184
+ if (a === 169 && b === 254)
185
+ return true; // 169.254.0.0/16 link-local
186
+ if (a === 100 && b >= 64 && b <= 127)
187
+ return true; // 100.64.0.0/10 CGNAT (Tailscale)
188
+ return false;
189
+ }
190
+ if (/^f[cd][0-9a-f]{2}:/.test(h))
191
+ return true; // fc00::/7 unique-local IPv6
192
+ if (/^fe[89ab][0-9a-f]:/.test(h))
193
+ return true; // fe80::/10 link-local IPv6
194
+ return false;
195
+ }
196
+ /** Extract the hostname from a `Host`/`Origin`-style `host[:port]` value. */
197
+ function hostnameOf(value) {
198
+ const trimmed = value.trim();
199
+ if (!trimmed)
200
+ return null;
201
+ if (trimmed.startsWith("["))
202
+ return trimmed.slice(0, trimmed.indexOf("]") + 1); // [::1]:4317 → [::1]
203
+ const colon = trimmed.lastIndexOf(":");
204
+ return colon > 0 && /^\d+$/.test(trimmed.slice(colon + 1)) ? trimmed.slice(0, colon) : trimmed;
205
+ }
206
+ function allowedExtraHosts() {
207
+ return new Set((process.env.BIVY_ALLOWED_HOSTS ?? "")
208
+ .split(",")
209
+ .map((h) => h.trim().toLowerCase())
210
+ .filter(Boolean));
211
+ }
212
+ /**
213
+ * True when a request may touch the actionable surface. Rejects a public Host
214
+ * header (DNS rebinding) or a public browser Origin (cross-site). Requests with
215
+ * no Origin (CLI/native/curl) are allowed through the Host check only.
216
+ */
217
+ export function requestOriginAllowed(req) {
218
+ if (process.env.BIVY_ALLOW_ANY_ORIGIN === "1")
219
+ return true;
220
+ const extra = allowedExtraHosts();
221
+ const ok = (hostname) => !!hostname && (hostnameIsLocal(hostname) || extra.has(hostname.toLowerCase()));
222
+ const hostHeader = req.headers["host"];
223
+ if (typeof hostHeader === "string") {
224
+ const host = hostnameOf(hostHeader);
225
+ if (host && !ok(host))
226
+ return false;
227
+ }
228
+ const originHeader = req.headers["origin"];
229
+ const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
230
+ if (origin && origin !== "null") {
231
+ let originHost = null;
232
+ try {
233
+ originHost = new URL(origin).hostname;
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ if (!ok(originHost))
239
+ return false;
240
+ }
241
+ return true;
242
+ }
243
+ /** Express middleware enforcing auth on /api routes. */
244
+ export function authMiddleware(identity) {
245
+ return (req, res, next) => {
246
+ if (!requestOriginAllowed(req)) {
247
+ res.status(403).json({ error: "Forbidden origin" });
248
+ return;
249
+ }
250
+ const ctx = resolveAuth(identity, req);
251
+ if (!isAuthorized(ctx)) {
252
+ res.status(401).json({ error: "Unauthorized" });
253
+ return;
254
+ }
255
+ req.auth = ctx;
256
+ next();
257
+ };
258
+ }
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createCredentialVault } from "./runtime/credential-store.js";
8
+ import { listPiProviders } from "./runtime/pi-oauth.js";
9
+ import { loginModelOAuth } from "./runtime/oauth/model-oauth.js";
10
+ import { openBrowser } from "./browser-open.js";
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const repoRoot = path.resolve(__dirname, "..");
13
+ const dataDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
14
+ const piDir = path.join(dataDir, "pi");
15
+ // The shared, agent-neutral credential vault (not inside any agent's dir).
16
+ const credsDir = path.join(dataDir, "credentials");
17
+ const BEDROCK_PROVIDER_ID = "amazon-bedrock";
18
+ async function makeProviders() {
19
+ const catalog = await listPiProviders(credsDir, piDir);
20
+ return catalog
21
+ .map((provider) => ({ id: provider.id, name: provider.name, authType: provider.oauth ? "oauth" : "api_key" }))
22
+ .sort((a, b) => `${a.name} ${a.authType}`.localeCompare(`${b.name} ${b.authType}`));
23
+ }
24
+ async function askLine(question) {
25
+ const rl = createInterface({ input, output });
26
+ try {
27
+ return await rl.question(question);
28
+ }
29
+ finally {
30
+ rl.close();
31
+ }
32
+ }
33
+ async function askHidden(question) {
34
+ if (!input.isTTY)
35
+ return askLine(question);
36
+ return new Promise((resolve, reject) => {
37
+ let value = "";
38
+ const onData = (chunk) => {
39
+ const text = chunk.toString("utf8");
40
+ for (const char of text) {
41
+ if (char === "") {
42
+ cleanup();
43
+ output.write("\n");
44
+ reject(new Error("Cancelled"));
45
+ return;
46
+ }
47
+ if (char === "\r" || char === "\n") {
48
+ cleanup();
49
+ output.write("\n");
50
+ resolve(value);
51
+ return;
52
+ }
53
+ if (char === "\u007f" || char === "\b") {
54
+ value = value.slice(0, -1);
55
+ continue;
56
+ }
57
+ value += char;
58
+ }
59
+ };
60
+ const cleanup = () => {
61
+ input.off("data", onData);
62
+ input.setRawMode(false);
63
+ input.pause();
64
+ };
65
+ output.write(question);
66
+ input.setRawMode(true);
67
+ input.resume();
68
+ input.on("data", onData);
69
+ });
70
+ }
71
+ async function choose(title, options) {
72
+ if (options.length === 1)
73
+ return options[0];
74
+ console.log(`\n${title}`);
75
+ options.forEach((option, index) => console.log(` ${index + 1}. ${option.name} (${option.id})`));
76
+ while (true) {
77
+ const answer = (await askLine("Select: ")).trim();
78
+ const index = Number(answer) - 1;
79
+ if (Number.isInteger(index) && options[index])
80
+ return options[index];
81
+ const match = options.find((option) => option.id === answer || option.name.toLowerCase() === answer.toLowerCase());
82
+ if (match)
83
+ return match;
84
+ console.log("Enter a number or provider id.");
85
+ }
86
+ }
87
+ async function loginApiKey(provider) {
88
+ if (provider.id === BEDROCK_PROVIDER_ID) {
89
+ console.log("Amazon Bedrock uses AWS credentials instead of a single API key.");
90
+ console.log("Configure AWS_PROFILE, IAM keys, a Bedrock bearer token, or role-based credentials.");
91
+ return;
92
+ }
93
+ const apiKey = (await askHidden(`Enter API key for ${provider.name}: `)).trim();
94
+ if (!apiKey)
95
+ throw new Error("API key cannot be empty.");
96
+ await createCredentialVault(credsDir).setApiKey(provider.id, apiKey);
97
+ console.log(`Saved API key for ${provider.name} to Bivy's credential vault.`);
98
+ }
99
+ /** Bridge Pi's AuthInteraction to the terminal (prompt/notify). */
100
+ function terminalInteraction(provider, signal) {
101
+ return {
102
+ signal,
103
+ notify(event) {
104
+ switch (event.type) {
105
+ case "auth_url":
106
+ console.log(`\nOpen this URL to login to ${provider.name}:\n${event.url}\n`);
107
+ if (event.instructions)
108
+ console.log(`${event.instructions}\n`);
109
+ openBrowser(event.url);
110
+ break;
111
+ case "device_code":
112
+ console.log(`\nOpen: ${event.verificationUri}`);
113
+ console.log(`Enter code: ${event.userCode}`);
114
+ console.log("Waiting for authentication…\n");
115
+ break;
116
+ case "info":
117
+ console.log(event.message);
118
+ for (const link of event.links ?? [])
119
+ console.log(link.label ? `${link.label}: ${link.url}` : link.url);
120
+ break;
121
+ case "progress":
122
+ console.log(event.message);
123
+ break;
124
+ }
125
+ },
126
+ async prompt(prompt) {
127
+ switch (prompt.type) {
128
+ case "secret":
129
+ return (await askHidden(`${prompt.message} `)).trim();
130
+ case "select": {
131
+ const selected = await choose(prompt.message, prompt.options.map((option) => ({ id: option.id, name: option.label })));
132
+ return selected.id;
133
+ }
134
+ case "text":
135
+ case "manual_code":
136
+ default:
137
+ return (await askLine(`${prompt.message} `)).trim();
138
+ }
139
+ },
140
+ };
141
+ }
142
+ async function loginOAuth(provider) {
143
+ const abort = new AbortController();
144
+ const onSigint = () => abort.abort();
145
+ process.once("SIGINT", onSigint);
146
+ try {
147
+ await loginModelOAuth(credsDir, provider.id, terminalInteraction(provider, abort.signal));
148
+ console.log(`Logged in to ${provider.name}. Credentials saved to Bivy's vault.`);
149
+ }
150
+ finally {
151
+ process.off("SIGINT", onSigint);
152
+ }
153
+ }
154
+ async function main() {
155
+ const requestedProvider = process.argv.slice(2).find((arg) => !arg.startsWith("-"));
156
+ const providers = await makeProviders();
157
+ if (providers.length === 0)
158
+ throw new Error("No login providers are available.");
159
+ let provider;
160
+ if (requestedProvider) {
161
+ provider = providers.find((candidate) => candidate.id === requestedProvider || candidate.name.toLowerCase() === requestedProvider.toLowerCase());
162
+ if (!provider)
163
+ throw new Error(`Unknown provider: ${requestedProvider}`);
164
+ }
165
+ else {
166
+ const authType = await choose("Authentication method", [
167
+ { id: "oauth", name: "Use a subscription" },
168
+ { id: "api_key", name: "Use an API key" },
169
+ ]);
170
+ provider = await choose("Provider", providers.filter((candidate) => candidate.authType === authType.id));
171
+ }
172
+ if (provider.authType === "oauth")
173
+ await loginOAuth(provider);
174
+ else
175
+ await loginApiKey(provider);
176
+ }
177
+ main().catch((error) => {
178
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
179
+ process.exit(1);
180
+ });
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ /**
5
+ * Best-effort guess at whether this machine can actually open a browser.
6
+ * macOS and Windows always can; a Linux box needs a display server *and* an
7
+ * opener (`xdg-open`) — a headless server (the common case for a Bivy node)
8
+ * has neither, so callers should print instructions/URLs instead of silently
9
+ * spawning an opener that can't do anything.
10
+ *
11
+ * Mirrors the equivalent check in bin/bivy.mjs (kept separate since the CLI
12
+ * entrypoint doesn't import from src/).
13
+ */
14
+ export function canOpenBrowser(env = process.env) {
15
+ if (process.platform === "darwin" || process.platform === "win32")
16
+ return true;
17
+ const hasDisplay = Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
18
+ if (!hasDisplay)
19
+ return false;
20
+ const check = spawnSync("sh", ["-lc", 'command -v -- xdg-open >/dev/null 2>&1'], { env });
21
+ return check.status === 0;
22
+ }
23
+ /**
24
+ * Best-effort open a URL in the local browser. Returns false (and never
25
+ * spawns anything) when canOpenBrowser() is false, so a headless server never
26
+ * wastes a process trying to open a browser that isn't there — callers should
27
+ * print the URL regardless of the return value so the user can open it
28
+ * themselves (on this machine or any other device).
29
+ */
30
+ export function openBrowser(target) {
31
+ if (!canOpenBrowser())
32
+ return false;
33
+ const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
34
+ const args = process.platform === "win32" ? ["/c", "start", "", target] : [target];
35
+ try {
36
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
37
+ // spawn reports a missing opener asynchronously via an 'error' event, not a
38
+ // synchronous throw — without a listener that becomes an unhandled error
39
+ // that crashes the process (see the crash this guarded against previously:
40
+ // canOpenBrowser() already filters out the common "no opener" case, but a
41
+ // race — the opener disappearing between the check and the spawn — is
42
+ // still possible). The URL is also printed by the caller either way.
43
+ child.on("error", () => { });
44
+ child.unref();
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }