@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
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ export { IntegrationManager } from "./manager.js";
4
+ export { BUILT_IN_INTEGRATIONS } from "./registry.js";