@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.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- 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
|
+
}
|