@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — network effect boundary (egress broker).
|
|
4
|
+
//
|
|
5
|
+
// Third pillar of "govern the substrate, not the agent". Almost every CLI agent
|
|
6
|
+
// (and the tools it spawns) honors the conventional HTTP_PROXY / HTTPS_PROXY /
|
|
7
|
+
// ALL_PROXY environment variables, so a single local proxy the daemon injects
|
|
8
|
+
// into the agent's env sees — and can allow/deny/log — all of its outbound
|
|
9
|
+
// network traffic, with zero agent-specific code.
|
|
10
|
+
//
|
|
11
|
+
// It handles both proxy modes:
|
|
12
|
+
// * plain HTTP → an ordinary forward proxy (absolute-URI requests),
|
|
13
|
+
// * HTTPS/TLS → the CONNECT method (we see host:port and tunnel bytes; TLS
|
|
14
|
+
// stays end-to-end so we log the destination, not content).
|
|
15
|
+
//
|
|
16
|
+
// Per the locked decision this ships as observe-and-log first (default allow),
|
|
17
|
+
// but the decider can deny by host now (e.g. block a data-exfil domain). Pure
|
|
18
|
+
// networking, unit-tested in test/harness-net-proxy.test.ts.
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import net from "node:net";
|
|
21
|
+
/** Split "host:port" (CONNECT target) into parts, defaulting the port. */
|
|
22
|
+
export function parseHostPort(authority, defaultPort) {
|
|
23
|
+
// IPv6 literal like [::1]:443
|
|
24
|
+
const m = /^\[([^\]]+)\]:?(\d+)?$/.exec(authority);
|
|
25
|
+
if (m)
|
|
26
|
+
return { host: m[1], port: m[2] ? Number(m[2]) : defaultPort };
|
|
27
|
+
const idx = authority.lastIndexOf(":");
|
|
28
|
+
if (idx > 0 && /^\d+$/.test(authority.slice(idx + 1))) {
|
|
29
|
+
return { host: authority.slice(0, idx), port: Number(authority.slice(idx + 1)) };
|
|
30
|
+
}
|
|
31
|
+
return { host: authority, port: defaultPort };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A running egress proxy. Construct via `EgressProxy.start()`; inject its
|
|
35
|
+
* `env()` into an agent subprocess so all its traffic flows through it.
|
|
36
|
+
*/
|
|
37
|
+
export class EgressProxy {
|
|
38
|
+
server;
|
|
39
|
+
port;
|
|
40
|
+
host;
|
|
41
|
+
constructor(server, port, host) {
|
|
42
|
+
this.server = server;
|
|
43
|
+
this.port = port;
|
|
44
|
+
this.host = host;
|
|
45
|
+
}
|
|
46
|
+
static start(options = {}, listenHost = "127.0.0.1") {
|
|
47
|
+
const decide = options.decide ?? (() => ({ allow: true }));
|
|
48
|
+
const onEvent = options.onEvent ?? (() => { });
|
|
49
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
50
|
+
// Plain-HTTP forward proxy: the request line carries an absolute URI.
|
|
51
|
+
let target;
|
|
52
|
+
try {
|
|
53
|
+
target = new URL(clientReq.url ?? "");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
clientRes.writeHead(400).end("Bad proxy request");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const port = target.port ? Number(target.port) : 80;
|
|
60
|
+
void Promise.resolve(decide(target.hostname, port)).then((decision) => {
|
|
61
|
+
onEvent({ type: "http", host: target.hostname, port, method: clientReq.method ?? "GET", url: target.href, allowed: decision.allow, reason: decision.reason });
|
|
62
|
+
if (!decision.allow) {
|
|
63
|
+
clientRes.writeHead(403).end(decision.reason || "Blocked by Bivy egress policy");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const proxyReq = http.request({ hostname: target.hostname, port, path: `${target.pathname}${target.search}`, method: clientReq.method, headers: clientReq.headers }, (proxyRes) => {
|
|
67
|
+
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
68
|
+
proxyRes.pipe(clientRes);
|
|
69
|
+
});
|
|
70
|
+
proxyReq.on("error", () => clientRes.writeHead(502).end("Upstream error"));
|
|
71
|
+
clientReq.pipe(proxyReq);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
// HTTPS via CONNECT: we see host:port and tunnel raw bytes (TLS end-to-end).
|
|
75
|
+
server.on("connect", (req, clientSocket, head) => {
|
|
76
|
+
const { host, port } = parseHostPort(req.url ?? "", 443);
|
|
77
|
+
void Promise.resolve(decide(host, port)).then((decision) => {
|
|
78
|
+
onEvent({ type: "connect", host, port, allowed: decision.allow, reason: decision.reason });
|
|
79
|
+
if (!decision.allow) {
|
|
80
|
+
clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
81
|
+
clientSocket.destroy();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const upstream = net.connect(port, host, () => {
|
|
85
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
86
|
+
if (head && head.length)
|
|
87
|
+
upstream.write(head);
|
|
88
|
+
upstream.pipe(clientSocket);
|
|
89
|
+
clientSocket.pipe(upstream);
|
|
90
|
+
});
|
|
91
|
+
upstream.on("error", () => clientSocket.destroy());
|
|
92
|
+
clientSocket.on("error", () => upstream.destroy());
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
server.listen(0, listenHost, () => {
|
|
97
|
+
const addr = server.address();
|
|
98
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
99
|
+
resolve(new EgressProxy(server, port, listenHost));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** Proxy env to merge into an agent subprocess so its traffic is governed. */
|
|
104
|
+
env() {
|
|
105
|
+
const url = `http://${this.host}:${this.port}`;
|
|
106
|
+
return {
|
|
107
|
+
HTTP_PROXY: url,
|
|
108
|
+
HTTPS_PROXY: url,
|
|
109
|
+
http_proxy: url,
|
|
110
|
+
https_proxy: url,
|
|
111
|
+
ALL_PROXY: url,
|
|
112
|
+
// Never route node-local traffic (the daemon's own API, MCP proxy) through here.
|
|
113
|
+
NO_PROXY: "127.0.0.1,localhost,::1",
|
|
114
|
+
no_proxy: "127.0.0.1,localhost,::1",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
stop() {
|
|
118
|
+
return new Promise((resolve) => this.server.close(() => resolve()));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — exec sandbox (configure the agent's own sandbox).
|
|
4
|
+
//
|
|
5
|
+
// Several agents ship their OWN sandbox, so Bivy just selects the right tier per
|
|
6
|
+
// session from one policy knob and lets each agent enforce it natively. This gives
|
|
7
|
+
// real, in-agent enforcement (a read-only session physically refuses writes) with
|
|
8
|
+
// a single data mapping and no OS-specific code. Agents without a native sandbox
|
|
9
|
+
// run under the filesystem, MCP, and network governance channels instead.
|
|
10
|
+
//
|
|
11
|
+
// Tiers use Codex's vocabulary because it's the clearest:
|
|
12
|
+
// read-only — may read the workspace; no writes, no network.
|
|
13
|
+
// workspace-write — may read/write the worktree; escapes need approval.
|
|
14
|
+
// danger-full-access — no in-agent limit (explicit opt-out).
|
|
15
|
+
//
|
|
16
|
+
// Chosen by BIVY_SANDBOX (default workspace-write — the safe, useful middle).
|
|
17
|
+
// Agents without a native sandbox (Goose, OpenCode, Aider) return no flags and
|
|
18
|
+
// remain governed by the filesystem, MCP, and network channels instead.
|
|
19
|
+
const TIERS = ["read-only", "workspace-write", "danger-full-access"];
|
|
20
|
+
/** Coerce an arbitrary value to a valid SandboxTier, or undefined if it isn't one. */
|
|
21
|
+
export function normalizeSandboxTier(value) {
|
|
22
|
+
const raw = String(value ?? "").trim().toLowerCase().replace(/_/g, "-");
|
|
23
|
+
return TIERS.includes(raw) ? raw : undefined;
|
|
24
|
+
}
|
|
25
|
+
// The node's configured default tier (from settings.json), set on startup and
|
|
26
|
+
// whenever node settings change. `BIVY_SANDBOX` still wins so an explicit env on
|
|
27
|
+
// the process overrides the persisted setting; a per-session override (passed to
|
|
28
|
+
// sandboxTier) wins over both.
|
|
29
|
+
let configuredTier;
|
|
30
|
+
export function setConfiguredSandboxTier(value) {
|
|
31
|
+
configuredTier = normalizeSandboxTier(value);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The sandbox tier to launch an agent at. Precedence:
|
|
35
|
+
* per-session override (arg) > BIVY_SANDBOX env > node setting > workspace-write.
|
|
36
|
+
*/
|
|
37
|
+
export function sandboxTier(override) {
|
|
38
|
+
return (normalizeSandboxTier(override) ??
|
|
39
|
+
normalizeSandboxTier(process.env.BIVY_SANDBOX) ??
|
|
40
|
+
configuredTier ??
|
|
41
|
+
"workspace-write");
|
|
42
|
+
}
|
|
43
|
+
/** Gemini expresses containment through --approval-mode rather than a tier flag. */
|
|
44
|
+
function geminiApprovalArgs(tier) {
|
|
45
|
+
switch (tier) {
|
|
46
|
+
case "read-only":
|
|
47
|
+
return ["--approval-mode", "plan"]; // plan mode = read-only
|
|
48
|
+
case "danger-full-access":
|
|
49
|
+
return ["--approval-mode", "yolo"]; // auto-approve everything
|
|
50
|
+
case "workspace-write":
|
|
51
|
+
default:
|
|
52
|
+
return ["--approval-mode", "auto_edit"]; // auto-approve edits, still bounded
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Native sandbox/approval flags for an agent at a tier. Empty when the agent has
|
|
57
|
+
* no native sandbox. These are inserted by the runtime when composing the
|
|
58
|
+
* launch args (see CLI_AGENT_SPECS.composeArgs).
|
|
59
|
+
*/
|
|
60
|
+
export function sandboxArgsFor(agentId, tier) {
|
|
61
|
+
switch (agentId) {
|
|
62
|
+
case "codex":
|
|
63
|
+
return ["--sandbox", tier];
|
|
64
|
+
// Qwen Code is a Gemini-CLI fork and shares its `--approval-mode` flag.
|
|
65
|
+
case "gemini":
|
|
66
|
+
case "qwen":
|
|
67
|
+
return geminiApprovalArgs(tier);
|
|
68
|
+
default:
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Claude Agent SDK permissionMode equivalent of a tier (used by the SDK adapter). */
|
|
73
|
+
export function claudePermissionModeFor(tier) {
|
|
74
|
+
if (tier === "read-only")
|
|
75
|
+
return "plan";
|
|
76
|
+
if (tier === "danger-full-access")
|
|
77
|
+
return "bypassPermissions";
|
|
78
|
+
return "default"; // guardian's canUseTool still gates risky tools
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Codex app-server containment for a tier (the governed "Codex approvals"
|
|
82
|
+
* runtime, whose shim drives `codex app-server`). Codex's `thread/start` takes
|
|
83
|
+
* two knobs: `sandbox` (its native OS jail — the tier values ARE Codex's
|
|
84
|
+
* `--sandbox` modes) and `approvalPolicy` (whether it escalates a proposed
|
|
85
|
+
* action before running it). The restrictive tiers stay on "untrusted" so every
|
|
86
|
+
* command/patch surfaces as a Bivy Approve/Deny card through the guardian, on
|
|
87
|
+
* top of the sandbox. "Full access" (danger-full-access) is an explicit opt-out:
|
|
88
|
+
* no sandbox AND "never" ask, so the agent runs unrestricted — otherwise
|
|
89
|
+
* "allow all" would still gate every action behind an approval card, which is
|
|
90
|
+
* exactly the mismatch this fixes.
|
|
91
|
+
*/
|
|
92
|
+
export function codexSandboxPolicy(tier) {
|
|
93
|
+
return tier === "danger-full-access"
|
|
94
|
+
? { sandbox: tier, approvalPolicy: "never" }
|
|
95
|
+
: { sandbox: tier, approvalPolicy: "untrusted" };
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
function hashMessages(messages) {
|
|
5
|
+
return createHash("sha256").update(JSON.stringify(messages)).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Decide whether to send the whole transcript or just the new tail, given what
|
|
9
|
+
* the client says it already has.
|
|
10
|
+
*/
|
|
11
|
+
export function historyDelta(messages, cursor = {}) {
|
|
12
|
+
const count = messages.length;
|
|
13
|
+
const historyHash = hashMessages(messages);
|
|
14
|
+
const { have, haveToken } = cursor;
|
|
15
|
+
if (typeof have === "number" &&
|
|
16
|
+
Number.isInteger(have) &&
|
|
17
|
+
have > 0 &&
|
|
18
|
+
have <= count &&
|
|
19
|
+
typeof haveToken === "string" &&
|
|
20
|
+
haveToken.length > 0 &&
|
|
21
|
+
hashMessages(messages.slice(0, have)) === haveToken) {
|
|
22
|
+
// Prefix matches — send only what's new (possibly nothing, if have === count).
|
|
23
|
+
return { mode: "append", baseCount: have, messages: messages.slice(have), count, historyHash };
|
|
24
|
+
}
|
|
25
|
+
return { mode: "full", baseCount: 0, messages, count, historyHash };
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
export declare const DEFAULT_HOSTED_DOMAIN: string;
|
|
4
|
+
|
|
5
|
+
export interface HostedEndpoints {
|
|
6
|
+
domain: string;
|
|
7
|
+
controlPlane: string;
|
|
8
|
+
relay: string;
|
|
9
|
+
clientBaseUrl: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export declare function hostedEndpoints(
|
|
13
|
+
env?: Record<string, string | undefined>,
|
|
14
|
+
): HostedEndpoints;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Hosted endpoint defaults — single source of truth (step 1).
|
|
5
|
+
*
|
|
6
|
+
* The node derives its app/control-plane, relay, and remote-client URLs from ONE
|
|
7
|
+
* hosted domain, so a user never has to type or even know these URLs. Every
|
|
8
|
+
* value is overridable by environment variable for self-hosting / staging:
|
|
9
|
+
*
|
|
10
|
+
* BIVY_HOSTED_DOMAIN base domain (e.g. "bivy.sh")
|
|
11
|
+
* BIVY_CONTROL_PLANE_URL full app/control-plane URL override
|
|
12
|
+
* BIVY_RELAY_URL full relay ws(s):// URL override
|
|
13
|
+
* BIVY_CLIENT_BASE_URL remote web-client base URL override
|
|
14
|
+
*
|
|
15
|
+
* Imported by both bin/bivy.mjs (plain Node) and src/relay-setup.ts (via tsx),
|
|
16
|
+
* which is why this is a dependency-free .mjs module.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// The hosted domain baked into builds. app.<domain> and relay.<domain> are
|
|
20
|
+
// derived from it so users do not need to enter hosted URLs during setup.
|
|
21
|
+
export const DEFAULT_HOSTED_DOMAIN = "bivy.sh";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the hosted endpoints from env (falling back to the baked-in domain).
|
|
25
|
+
* @param {Record<string, string | undefined>} [env]
|
|
26
|
+
*/
|
|
27
|
+
export function hostedEndpoints(env = process.env) {
|
|
28
|
+
const domain = (env.BIVY_HOSTED_DOMAIN || DEFAULT_HOSTED_DOMAIN)
|
|
29
|
+
.replace(/^https?:\/\//, "")
|
|
30
|
+
.replace(/\/+$/, "");
|
|
31
|
+
const controlPlane = (env.BIVY_CONTROL_PLANE_URL || `https://app.${domain}`).replace(/\/+$/, "");
|
|
32
|
+
const relay = (env.BIVY_RELAY_URL || `wss://relay.${domain}`).replace(/\/+$/, "");
|
|
33
|
+
const clientBaseUrl = (env.BIVY_CLIENT_BASE_URL || controlPlane).replace(/\/+$/, "");
|
|
34
|
+
return { domain, controlPlane, relay, clientBaseUrl };
|
|
35
|
+
}
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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 os from "node:os";
|
|
6
|
+
import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto";
|
|
7
|
+
function defaultNodeName() {
|
|
8
|
+
return os.hostname();
|
|
9
|
+
}
|
|
10
|
+
function migrateNodeName(name) {
|
|
11
|
+
return name.replace(/\s+local node$/i, "");
|
|
12
|
+
}
|
|
13
|
+
function hashToken(token) {
|
|
14
|
+
return createHash("sha256").update(token).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
export class NodeIdentity {
|
|
17
|
+
configPath;
|
|
18
|
+
config;
|
|
19
|
+
constructor(configPath, config) {
|
|
20
|
+
this.configPath = configPath;
|
|
21
|
+
this.config = config;
|
|
22
|
+
}
|
|
23
|
+
static load(appDir) {
|
|
24
|
+
const configPath = path.join(appDir, "node.json");
|
|
25
|
+
fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = fs.readFileSync(configPath, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
// Only a missing file means "no identity yet". Any other read failure
|
|
32
|
+
// (permission denied, I/O error, etc.) must not mint a fresh identity.
|
|
33
|
+
if (error?.code !== "ENOENT")
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
let config;
|
|
37
|
+
if (raw === undefined) {
|
|
38
|
+
config = {
|
|
39
|
+
nodeId: `node_${randomUUID()}`,
|
|
40
|
+
name: defaultNodeName(),
|
|
41
|
+
createdAt: new Date().toISOString(),
|
|
42
|
+
devices: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// The file exists but is corrupt/truncated. Minting a fresh nodeId here
|
|
52
|
+
// would silently invalidate every device paired with this node, so fail
|
|
53
|
+
// loudly instead and let the operator decide how to recover.
|
|
54
|
+
throw new Error(`Node identity file at ${configPath} is corrupt and could not be parsed. Refusing to generate a ` +
|
|
55
|
+
`new node identity (this would invalidate every paired device). Restore it from a backup, or ` +
|
|
56
|
+
`remove the file manually if you intend to start fresh.`);
|
|
57
|
+
}
|
|
58
|
+
config = {
|
|
59
|
+
nodeId: typeof parsed.nodeId === "string" && parsed.nodeId ? parsed.nodeId : `node_${randomUUID()}`,
|
|
60
|
+
name: typeof parsed.name === "string" && parsed.name.trim()
|
|
61
|
+
? migrateNodeName(parsed.name.trim())
|
|
62
|
+
: defaultNodeName(),
|
|
63
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
|
|
64
|
+
devices: Array.isArray(parsed.devices) ? parsed.devices : [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const identity = new NodeIdentity(configPath, config);
|
|
68
|
+
identity.persist();
|
|
69
|
+
return identity;
|
|
70
|
+
}
|
|
71
|
+
persist() {
|
|
72
|
+
// Atomic write: write to a tmp file in the same directory, then rename over
|
|
73
|
+
// the target. This guarantees readers never observe a partially-written or
|
|
74
|
+
// truncated node.json, even if the process is killed mid-write.
|
|
75
|
+
const tmp = `${this.configPath}.tmp`;
|
|
76
|
+
fs.writeFileSync(tmp, `${JSON.stringify(this.config, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
try {
|
|
78
|
+
fs.chmodSync(tmp, 0o600);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Best effort on platforms without chmod.
|
|
82
|
+
}
|
|
83
|
+
fs.renameSync(tmp, this.configPath);
|
|
84
|
+
try {
|
|
85
|
+
fs.chmodSync(this.configPath, 0o600);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Best effort on platforms without chmod.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
get nodeId() {
|
|
92
|
+
return this.config.nodeId;
|
|
93
|
+
}
|
|
94
|
+
get name() {
|
|
95
|
+
return this.config.name;
|
|
96
|
+
}
|
|
97
|
+
setName(name) {
|
|
98
|
+
const clean = name.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
99
|
+
if (!clean)
|
|
100
|
+
throw new Error("Node name cannot be empty");
|
|
101
|
+
this.config.name = clean;
|
|
102
|
+
this.persist();
|
|
103
|
+
return clean;
|
|
104
|
+
}
|
|
105
|
+
listDevices() {
|
|
106
|
+
return this.config.devices.map(({ tokenHash: _hash, ...rest }) => rest);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create a new device token. Returns the raw token, which is shown to the
|
|
110
|
+
* user exactly once and never recoverable afterwards.
|
|
111
|
+
*/
|
|
112
|
+
createDevice(name) {
|
|
113
|
+
const clean = name.trim().slice(0, 80) || "Unnamed device";
|
|
114
|
+
const token = `mesh_${randomBytes(32).toString("base64url")}`;
|
|
115
|
+
const record = {
|
|
116
|
+
id: `dev_${randomUUID()}`,
|
|
117
|
+
name: clean,
|
|
118
|
+
tokenHash: hashToken(token),
|
|
119
|
+
createdAt: new Date().toISOString(),
|
|
120
|
+
lastSeenAt: null,
|
|
121
|
+
};
|
|
122
|
+
this.config.devices.push(record);
|
|
123
|
+
this.persist();
|
|
124
|
+
const { tokenHash: _hash, ...device } = record;
|
|
125
|
+
return { device, token };
|
|
126
|
+
}
|
|
127
|
+
revokeDevice(id) {
|
|
128
|
+
const before = this.config.devices.length;
|
|
129
|
+
this.config.devices = this.config.devices.filter((device) => device.id !== id);
|
|
130
|
+
const removed = this.config.devices.length < before;
|
|
131
|
+
if (removed)
|
|
132
|
+
this.persist();
|
|
133
|
+
return removed;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Verify a raw token against stored hashes using a constant-time compare.
|
|
137
|
+
* Updates lastSeenAt on a match. Returns the matched device id, or null.
|
|
138
|
+
*/
|
|
139
|
+
verifyToken(token) {
|
|
140
|
+
if (!token)
|
|
141
|
+
return null;
|
|
142
|
+
const candidateBuf = Buffer.from(hashToken(token), "hex");
|
|
143
|
+
for (const device of this.config.devices) {
|
|
144
|
+
const storedBuf = Buffer.from(device.tokenHash, "hex");
|
|
145
|
+
if (storedBuf.length === candidateBuf.length && timingSafeEqual(storedBuf, candidateBuf)) {
|
|
146
|
+
device.lastSeenAt = new Date().toISOString();
|
|
147
|
+
this.persist();
|
|
148
|
+
return device.id;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|