@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,146 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { randomUUID } from "node:crypto";
4
+ // Bivy owns the AskUserQuestion → interactive-question-card feature at the
5
+ // governance layer, NOT inside any single runtime adapter. A `user_question`
6
+ // is a blocking, multiple-choice clarifying question an agent raises mid-turn
7
+ // (Claude Code's AskUserQuestion tool, and any future agent that emits the same
8
+ // tool). It is intercepted in the guardian tool-interceptor — the one seam every
9
+ // runtime with capabilities.toolInterception already implements — so pi and the
10
+ // Claude Agent SDK both light up with zero per-runtime question code. This is
11
+ // the deliberate sibling of ApprovalManager: same block-and-wait shape, but it
12
+ // returns the user's structured answers instead of a yes/no.
13
+ /** How long a pending question waits for an answer before auto-cancelling.
14
+ * Without this a client that never renders/answers the card (a stale client,
15
+ * a UI bug, nobody looking) would park the agent's tool call — and the turn —
16
+ * indefinitely. Mirrors the old runtime-side QUESTION_TIMEOUT_MS. */
17
+ const QUESTION_TIMEOUT_MS = 10 * 60 * 1000;
18
+ export class QuestionManager {
19
+ pending = new Map();
20
+ requestListeners = new Set();
21
+ resolvedListeners = new Set();
22
+ /** Notified when a new question is raised — the server broadcasts
23
+ * `session.question` from here (mirrors approvals.onRequest). */
24
+ onRequest(listener) {
25
+ this.requestListeners.add(listener);
26
+ return () => this.requestListeners.delete(listener);
27
+ }
28
+ /** Notified every time a question settles — answered, cancelled, timed out,
29
+ * or aborted — exactly once. The server broadcasts `session.question.resolved`
30
+ * from here, so it fires precisely when the card should close. */
31
+ onResolved(listener) {
32
+ this.resolvedListeners.add(listener);
33
+ return () => this.resolvedListeners.delete(listener);
34
+ }
35
+ list() {
36
+ return [...this.pending.values()].map((p) => p.request);
37
+ }
38
+ hasPendingForSession(sessionId) {
39
+ for (const p of this.pending.values())
40
+ if (p.request.sessionId === sessionId)
41
+ return true;
42
+ return false;
43
+ }
44
+ /**
45
+ * Raise a blocking question and resolve once the user answers/skips (or it
46
+ * times out / the turn aborts). Called from the guardian interceptor; the
47
+ * returned answer is formatted back to the agent as the tool result.
48
+ */
49
+ request(input) {
50
+ const request = {
51
+ id: randomUUID(),
52
+ sessionId: input.sessionId,
53
+ questions: input.questions,
54
+ createdAt: Date.now(),
55
+ status: "pending",
56
+ };
57
+ return new Promise((resolve) => {
58
+ // Already-aborted turn: settle immediately, never register.
59
+ if (input.signal?.aborted) {
60
+ request.status = "cancelled";
61
+ resolve({ behavior: "cancelled" });
62
+ for (const l of this.resolvedListeners)
63
+ l(request);
64
+ return;
65
+ }
66
+ const timeout = setTimeout(() => this.settle(request.id, { behavior: "cancelled" }, "expired"), input.timeoutMs ?? QUESTION_TIMEOUT_MS);
67
+ const onAbort = () => this.settle(request.id, { behavior: "cancelled" }, "cancelled");
68
+ input.signal?.addEventListener("abort", onAbort, { once: true });
69
+ this.pending.set(request.id, { request, resolve, timeout, onAbort, signal: input.signal });
70
+ for (const l of this.requestListeners)
71
+ l(request);
72
+ });
73
+ }
74
+ /** Deliver the user's answer (or a skip/cancel) to a pending question. A
75
+ * stale/unknown id is a silent no-op — same tolerance as ApprovalManager. */
76
+ resolve(id, answer) {
77
+ return this.settle(id, answer, answer.behavior === "completed" ? "answered" : "cancelled");
78
+ }
79
+ /** Cancel every question outstanding for a session — called when the session
80
+ * is disposed/killed so cards close and the awaiting guardian promise (and
81
+ * the turn behind it) doesn't hang until timeout. */
82
+ cancelForSession(sessionId) {
83
+ for (const id of [...this.pending.keys()]) {
84
+ if (this.pending.get(id)?.request.sessionId === sessionId)
85
+ this.settle(id, { behavior: "cancelled" }, "cancelled");
86
+ }
87
+ }
88
+ settle(id, answer, status) {
89
+ const pending = this.pending.get(id);
90
+ if (!pending)
91
+ return false;
92
+ clearTimeout(pending.timeout);
93
+ if (pending.onAbort)
94
+ pending.signal?.removeEventListener("abort", pending.onAbort);
95
+ pending.request.status = status;
96
+ this.pending.delete(id);
97
+ pending.resolve(answer);
98
+ for (const l of this.resolvedListeners)
99
+ l(pending.request);
100
+ return true;
101
+ }
102
+ }
103
+ /**
104
+ * Defensive shape-check for an AskUserQuestion tool input's `questions` array.
105
+ * Returns null (rather than throwing or forwarding a partial item) on anything
106
+ * that doesn't look like a real UserQuestionItem[], so a malformed call degrades
107
+ * to "let the tool run un-intercepted" instead of reaching QuestionCard (which
108
+ * has no ErrorBoundary above it) with e.g. a missing `options` array.
109
+ */
110
+ export function validQuestions(value) {
111
+ if (!Array.isArray(value) || !value.length)
112
+ return null;
113
+ for (const q of value) {
114
+ if (typeof q?.question !== "string" || typeof q?.header !== "string")
115
+ return null;
116
+ if (!Array.isArray(q.options) || q.options.length < 2)
117
+ return null;
118
+ for (const opt of q.options) {
119
+ if (typeof opt?.label !== "string")
120
+ return null;
121
+ }
122
+ }
123
+ return value;
124
+ }
125
+ /** Case-insensitive match for the AskUserQuestion tool across runtimes. Pi's
126
+ * "stealth" naming mirrors Claude Code exactly ("AskUserQuestion"); other
127
+ * agents may vary case, so normalize. */
128
+ export function isAskUserQuestionTool(toolName) {
129
+ return toolName.toLowerCase() === "askuserquestion";
130
+ }
131
+ /**
132
+ * Format the user's answer into the tool result text handed back to the agent.
133
+ * The interceptor delivers this through each runtime's block/deny channel (the
134
+ * only host-supplied-result mechanism both pi and the Claude SDK expose), so it
135
+ * reads as a plain statement of what the user chose rather than an error.
136
+ */
137
+ export function formatQuestionResult(questions, answer) {
138
+ if (answer.behavior === "cancelled") {
139
+ return "The user dismissed the question(s) without answering. Proceed using your best judgment, or ask again if you truly cannot continue.";
140
+ }
141
+ const lines = questions.map((q) => {
142
+ const a = answer.answers[q.question];
143
+ return `- ${q.header}: ${a && a.trim() ? a : "(no answer)"}`;
144
+ });
145
+ return `The user answered your question(s):\n${lines.join("\n")}`;
146
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Redact credentials from text before it is persisted to disk. Bivy stores
4
+ // session transcripts and tool-activity sidecars as JSON under `.bivy/`, and
5
+ // those files sync to the web/PWA — so any secret an agent happens to print
6
+ // (e.g. a tool running `git remote -v`, which surfaces the token baked into a
7
+ // clone's origin URL) would otherwise land in cleartext in a synced log.
8
+ //
9
+ // Applied at the single persistence choke point (EventLog.flush), so every
10
+ // base-transcript / tool-activity / intermediate-message write is scrubbed. Pattern-
11
+ // based and structure-preserving: it only ever shortens string *values*, so the
12
+ // surrounding JSON stays valid.
13
+ const REDACTED = "***REDACTED***";
14
+ // GitHub tokens in all current shapes:
15
+ // ghp_ (classic PAT), gho_ (OAuth), ghu_/ghs_ (GitHub App user/installation),
16
+ // ghr_ (refresh), and github_pat_ (fine-grained PAT).
17
+ const GH_TOKEN = /\bgh[posur]_[A-Za-z0-9]{16,255}\b/g;
18
+ const GH_FINE_PAT = /\bgithub_pat_[A-Za-z0-9_]{20,255}\b/g;
19
+ // The password half of any URL userinfo — `scheme://user:SECRET@host`. Covers
20
+ // the `https://x-access-token:<token>@github.com/...` form Bivy writes into a
21
+ // clone's remote, plus any other basic-auth URL. Keeps the user, masks the rest.
22
+ const URL_CREDENTIAL = /([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+):[^\s/@]+@/gi;
23
+ // Model/provider and common SaaS API keys with distinctive, high-entropy
24
+ // prefixes — the shapes an agent most often prints via `env`/`cat .env`. These
25
+ // prefixes are specific enough that the minimum-length suffix guards keep false
26
+ // positives (redacting innocuous transcript text) negligible.
27
+ // sk-… / sk-ant-… / sk-proj-… OpenAI + Anthropic
28
+ // sk_live_… / sk_test_… / rk_live_… / rk_test_… Stripe secret + restricted
29
+ // gsk_… Groq
30
+ // xai-… xAI
31
+ // AIza… Google API keys
32
+ // AKIA… AWS access key id
33
+ // xox[baprs]-… Slack tokens
34
+ const PROVIDER_KEYS = [
35
+ /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/g,
36
+ /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
37
+ /\bgsk_[A-Za-z0-9]{20,}\b/g,
38
+ /\bxai-[A-Za-z0-9]{20,}\b/g,
39
+ /\bAIza[0-9A-Za-z_-]{35}\b/g,
40
+ /\bAKIA[0-9A-Z]{16}\b/g,
41
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
42
+ ];
43
+ // Bivy's own bearer tokens — all minted as `<prefix>_` + `randomBytes(...).
44
+ // toString("base64url")` (see identity.ts's `mesh_` device tokens and the
45
+ // control-plane's `postgres-store.ts`):
46
+ // mesh_… device access token (identity.ts, `.bivy/node.json` creation flow)
47
+ // enr_… node enrollment token (relay-setup.ts, written to `.bivy/relay.json`)
48
+ // sess_… account session token (the bearer used while enrolling/pairing)
49
+ // mlt_… magic-link sign-in token
50
+ // lnk_… remote-device link grant
51
+ // tkt_… single-use relay ticket
52
+ const BIVY_TOKEN = /\b(?:mesh|enr|sess|mlt|lnk|tkt)_[A-Za-z0-9_-]{20,}\b/g;
53
+ // JWTs (three dot-separated base64url segments, header always decodes to `{"`
54
+ // so it always starts `eyJ`). Covers GitHub App JWTs (server.ts's
55
+ // `createAppJwt`) and any other JWT an agent might print.
56
+ const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
57
+ // Generic `Authorization: Bearer <token>` — catches bearer credentials that
58
+ // don't match one of the specific shapes above (e.g. a third-party bearer an
59
+ // agent echoes while debugging a request). Case-insensitive on the scheme
60
+ // name; keeps "Bearer"/"bearer" as written and only masks the credential.
61
+ const BEARER_HEADER = /\b(bearer\s+)[A-Za-z0-9._-]{16,}\b/gi;
62
+ // Pairing secrets / room keys / private keys: high-entropy string *values*
63
+ // serialized right next to one of Bivy's own known secret-bearing field
64
+ // names (device-registry.ts, pairing-crypto.ts, server.ts's GitHub App vault).
65
+ // These are raw random bytes with no distinctive prefix of their own, so we
66
+ // key off the surrounding JSON field name instead — e.g.
67
+ // `"roomKeyB64":"<...>"` in a synced device-registry / vault write, or the
68
+ // account session forwarded as `"sessionToken":"<...>"` during pairing.
69
+ const KNOWN_SECRET_FIELDS = [
70
+ "roomKeyB64",
71
+ "privateKeyB64",
72
+ "pairSecretB64",
73
+ "pairSecret",
74
+ "vaultKeyB64",
75
+ "deviceSecret",
76
+ "webhookSecret",
77
+ "sessionToken",
78
+ "enrollmentToken",
79
+ "privateKeyPem",
80
+ ];
81
+ const KNOWN_SECRET_FIELD_VALUE = new RegExp(`("(?:${KNOWN_SECRET_FIELDS.join("|")})"\\s*:\\s*")[^"\\\\]*(?:\\\\.[^"\\\\]*)*(")`, "g");
82
+ /** Mask credentials in `text`. Safe on non-string / empty input. */
83
+ export function redactSecrets(text) {
84
+ if (!text)
85
+ return text;
86
+ let out = text
87
+ .replace(URL_CREDENTIAL, `$1:${REDACTED}@`)
88
+ .replace(GH_TOKEN, REDACTED)
89
+ .replace(GH_FINE_PAT, REDACTED)
90
+ .replace(BIVY_TOKEN, REDACTED)
91
+ .replace(JWT, REDACTED)
92
+ .replace(BEARER_HEADER, `$1${REDACTED}`)
93
+ .replace(KNOWN_SECRET_FIELD_VALUE, `$1${REDACTED}$2`);
94
+ for (const re of PROVIDER_KEYS)
95
+ out = out.replace(re, REDACTED);
96
+ return out;
97
+ }
@@ -0,0 +1,345 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Relay-tunnelled attach bridge for `bivy run --node <account-node>`.
5
+ //
6
+ // Bivy has no node↔node link; a node reaches a sibling it co-owns exactly the
7
+ // way a phone reaches a node — as a CLIENT in the sibling's relay room. This
8
+ // process is that client. It:
9
+ //
10
+ // 1. Mints a client-scoped grant for the target node from THIS node's
11
+ // enrollment token (POST /node/sibling-link-grant),
12
+ // 2. Exchanges it for a single-use relay ticket
13
+ // (POST /client/relay-ticket),
14
+ // 3. Opens the relay `/client` socket in the target's room, and
15
+ // 4. Pairs with `pair.account` to recover the target's rotating room key.
16
+ //
17
+ // After pairing it stands up a LOOPBACK WebSocket server that speaks the node's
18
+ // plain `/ws` protocol, and spawns the ordinary `attach` client against it. The
19
+ // two ends share ONE identical `terminal.*` message vocabulary — the only
20
+ // difference between a local `/ws` and the relay is the encrypted frame
21
+ // envelope — so the bridge is a pure translation layer and `attach` needs no
22
+ // relay awareness:
23
+ //
24
+ // attach → local /ws (bare terminal.* command JSON)
25
+ // → seal + chunk → relay frames → target node
26
+ // target node → relay frames → open + reassemble
27
+ // → bare terminal.* event JSON → local /ws → attach
28
+ //
29
+ // The crypto + framing are the already-unit-tested node core
30
+ // (relay-cli-crypto.ts + relay-chunk.ts); the credential handshake mirrors
31
+ // SiblingClient (src/session/sibling-client.ts).
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ import { spawn } from "node:child_process";
35
+ import { WebSocket, WebSocketServer } from "ws";
36
+ import { newDeviceKeypair, acceptWelcome, RoomCipher } from "./relay-cli-crypto.js";
37
+ import { frameMessages, FrameReassembler } from "./relay-chunk.js";
38
+ const c = {
39
+ dim: (s) => `\x1b[2m${s}\x1b[22m`,
40
+ red: (s) => `\x1b[31m${s}\x1b[39m`,
41
+ cyan: (s) => `\x1b[36m${s}\x1b[39m`,
42
+ };
43
+ // All progress goes to STDERR: once `attach` binds the PTY it owns stdout in raw
44
+ // mode, so anything we print there would corrupt the terminal.
45
+ const note = (s) => process.stderr.write(s + "\n");
46
+ function parseArgs(argv) {
47
+ const get = (flag) => {
48
+ const i = argv.indexOf(flag);
49
+ return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined;
50
+ };
51
+ const nodeId = get("--node-id");
52
+ const relayConfigPath = get("--relay-config");
53
+ const attachCmdRaw = get("--attach-cmd");
54
+ if (!nodeId || !relayConfigPath || !attachCmdRaw) {
55
+ note("relay-attach: missing --node-id / --relay-config / --attach-cmd");
56
+ process.exit(2);
57
+ }
58
+ let attachCmd;
59
+ try {
60
+ attachCmd = JSON.parse(attachCmdRaw);
61
+ if (!Array.isArray(attachCmd))
62
+ throw new Error("not an array");
63
+ }
64
+ catch {
65
+ note("relay-attach: --attach-cmd must be a JSON array");
66
+ process.exit(2);
67
+ }
68
+ return {
69
+ nodeId: nodeId,
70
+ nodeName: get("--node-name") || nodeId,
71
+ relayConfigPath: relayConfigPath,
72
+ attachCmd,
73
+ run: get("--run"),
74
+ attachTermId: get("--attach"),
75
+ label: get("--label") || "Bivy CLI (run --node)",
76
+ };
77
+ }
78
+ function loadRelay(relayConfigPath) {
79
+ let raw = {};
80
+ try {
81
+ raw = JSON.parse(fs.readFileSync(relayConfigPath, "utf8"));
82
+ }
83
+ catch {
84
+ /* handled below */
85
+ }
86
+ const controlPlaneUrl = process.env.BIVY_CONTROL_PLANE_URL || raw.controlPlaneUrl;
87
+ const enrollmentToken = process.env.BIVY_RELAY_TOKEN || raw.enrollmentToken;
88
+ if (!controlPlaneUrl || !enrollmentToken) {
89
+ note(c.red("relay-attach: relay is not configured (missing controlPlaneUrl/enrollmentToken). Run 'bivy relay:setup'."));
90
+ process.exit(1);
91
+ }
92
+ return { controlPlaneUrl, enrollmentToken, url: process.env.BIVY_RELAY_URL || raw.url };
93
+ }
94
+ // Reuse ONE client device identity across runs so we don't spam the target
95
+ // node's device list with a fresh pairing on every `bivy run --node`.
96
+ function loadOrCreateDeviceKeypair(relayConfigPath) {
97
+ const file = path.join(path.dirname(relayConfigPath), "relay-cli-device.json");
98
+ try {
99
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
100
+ if (data?.publicKeyB64 && data?.privateKeyB64)
101
+ return data;
102
+ }
103
+ catch {
104
+ /* create below */
105
+ }
106
+ const kp = newDeviceKeypair();
107
+ try {
108
+ fs.writeFileSync(file, JSON.stringify(kp), { mode: 0o600 });
109
+ }
110
+ catch {
111
+ /* non-fatal: fall back to an ephemeral identity */
112
+ }
113
+ return kp;
114
+ }
115
+ async function postJson(url, bearer, body) {
116
+ const res = await fetch(url, {
117
+ method: "POST",
118
+ headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
119
+ body: JSON.stringify(body),
120
+ signal: AbortSignal.timeout(15_000),
121
+ });
122
+ const text = await res.text();
123
+ let data;
124
+ try {
125
+ data = JSON.parse(text);
126
+ }
127
+ catch {
128
+ data = { raw: text };
129
+ }
130
+ if (!res.ok)
131
+ throw new Error(`${url} → ${res.status} ${String(data.error || text).slice(0, 160)}`);
132
+ return data;
133
+ }
134
+ async function main() {
135
+ const args = parseArgs(process.argv.slice(2));
136
+ const relay = loadRelay(args.relayConfigPath);
137
+ const cp = relay.controlPlaneUrl.replace(/\/$/, "");
138
+ const keypair = loadOrCreateDeviceKeypair(args.relayConfigPath);
139
+ // 1. grant (enrollment-scoped) → 2. single-use relay ticket.
140
+ note(c.dim(`Linking to ${c.cyan(args.nodeName)} over the relay…`));
141
+ const grantRes = await postJson(`${cp}/node/sibling-link-grant`, relay.enrollmentToken, { nodeId: args.nodeId });
142
+ const grant = String(grantRes.grant ?? "");
143
+ if (!grant)
144
+ throw new Error("sibling-link-grant returned no grant");
145
+ const ticketRes = await postJson(`${cp}/client/relay-ticket`, grant, { nodeId: args.nodeId });
146
+ const ticket = String(ticketRes.ticket ?? "");
147
+ if (!ticket)
148
+ throw new Error("relay-ticket returned no ticket");
149
+ const relayBase = String((typeof ticketRes.relayUrl === "string" && ticketRes.relayUrl) ||
150
+ (typeof grantRes.relayUrl === "string" && grantRes.relayUrl) ||
151
+ relay.url ||
152
+ "").replace(/\/$/, "");
153
+ if (!relayBase)
154
+ throw new Error("no relay URL available for the target node");
155
+ // 3. open the relay /client socket in the target's room and pair.
156
+ const rly = new WebSocket(`${relayBase}/client?ticket=${encodeURIComponent(ticket)}&nodeId=${encodeURIComponent(args.nodeId)}`);
157
+ const reassembler = new FrameReassembler();
158
+ let cipher = null;
159
+ let local = null;
160
+ let paired = false;
161
+ let pairSent = false;
162
+ const fail = (message, code = 1) => {
163
+ note(c.red(`\n${message}`));
164
+ try {
165
+ rly.close();
166
+ }
167
+ catch {
168
+ /* ignore */
169
+ }
170
+ process.exit(code);
171
+ };
172
+ const pairTimer = setTimeout(() => {
173
+ if (!paired)
174
+ fail(`Node "${args.nodeName}" did not respond to pairing — it may be offline.`, 1);
175
+ }, 20_000);
176
+ pairTimer.unref?.();
177
+ const sendPair = () => {
178
+ if (pairSent)
179
+ return;
180
+ pairSent = true;
181
+ rly.send(JSON.stringify({
182
+ t: "pair",
183
+ // `ephemeral`: this is a transient CLI bridge, not a user device — the
184
+ // control plane authorizes it but keeps it out of the account's
185
+ // "Signed-in devices" list.
186
+ p: JSON.stringify({ k: "pair.account", sessionToken: grant, devicePublicKeyB64: keypair.publicKeyB64, label: args.label, ephemeral: true }),
187
+ }));
188
+ };
189
+ rly.on("open", () => note(c.dim("Relay connected; pairing…")));
190
+ rly.on("error", (err) => fail(`Relay connection error: ${err.message}`, 1));
191
+ rly.on("close", () => {
192
+ if (!paired)
193
+ fail("Relay closed before the session was established.", 1);
194
+ // After pairing, a relay drop ends the remote session bridge; let the child exit.
195
+ try {
196
+ local?.close();
197
+ }
198
+ catch {
199
+ /* ignore */
200
+ }
201
+ });
202
+ rly.on("message", (data) => {
203
+ let msg;
204
+ try {
205
+ msg = JSON.parse(String(data));
206
+ }
207
+ catch {
208
+ return;
209
+ }
210
+ if (msg.t === "ready" || msg.t === "peer.online") {
211
+ sendPair();
212
+ return;
213
+ }
214
+ if (msg.t === "peer.offline") {
215
+ if (!paired)
216
+ fail(`Node "${args.nodeName}" is offline.`, 1);
217
+ return;
218
+ }
219
+ if (msg.t === "error") {
220
+ fail(`Relay error: ${String(msg.message || msg.p || "unknown")}`, 1);
221
+ return;
222
+ }
223
+ if (msg.t === "pair") {
224
+ let p;
225
+ try {
226
+ p = JSON.parse(String(msg.p));
227
+ }
228
+ catch {
229
+ return;
230
+ }
231
+ if (p.k === "pair.welcome") {
232
+ try {
233
+ const roomKey = acceptWelcome(keypair, { nodePublicKeyB64: String(p.nodePublicKeyB64), wrapped: String(p.wrapped) });
234
+ cipher = new RoomCipher(roomKey);
235
+ paired = true;
236
+ clearTimeout(pairTimer);
237
+ startBridge();
238
+ }
239
+ catch (err) {
240
+ fail(`Pairing failed: ${err.message}`, 1);
241
+ }
242
+ }
243
+ else if (p.k === "pair.error") {
244
+ fail(`Pairing rejected by ${args.nodeName}: ${p.error || "unknown reason"}`, 1);
245
+ }
246
+ return;
247
+ }
248
+ if (msg.t === "frame" && typeof msg.p === "string" && cipher) {
249
+ const full = reassembler.accept(msg);
250
+ if (!full)
251
+ return;
252
+ let event;
253
+ try {
254
+ event = cipher.open(full).data;
255
+ }
256
+ catch {
257
+ return; // undecryptable frame — ignore
258
+ }
259
+ // Forward the bare terminal.* event to the local attach socket verbatim.
260
+ if (local && local.readyState === WebSocket.OPEN)
261
+ local.send(JSON.stringify(event));
262
+ }
263
+ });
264
+ // Stand up the loopback /ws server + spawn attach, only once paired.
265
+ function startBridge() {
266
+ const wss = new WebSocketServer({ host: "127.0.0.1", port: 0, path: "/ws" });
267
+ wss.on("connection", (sock) => {
268
+ // A single attach client owns the bridge. Reject extras.
269
+ if (local) {
270
+ try {
271
+ sock.close();
272
+ }
273
+ catch {
274
+ /* ignore */
275
+ }
276
+ return;
277
+ }
278
+ local = sock;
279
+ sock.on("message", (data) => {
280
+ if (!cipher)
281
+ return;
282
+ let command;
283
+ try {
284
+ command = JSON.parse(String(data));
285
+ }
286
+ catch {
287
+ return;
288
+ }
289
+ try {
290
+ for (const frame of frameMessages(cipher.seal(command))) {
291
+ if (rly.readyState === WebSocket.OPEN)
292
+ rly.send(frame);
293
+ }
294
+ }
295
+ catch {
296
+ /* seal/send failure — the relay close handler will surface it */
297
+ }
298
+ });
299
+ sock.on("close", () => {
300
+ // attach detached or the remote session ended; tear down.
301
+ try {
302
+ rly.close();
303
+ }
304
+ catch {
305
+ /* ignore */
306
+ }
307
+ });
308
+ });
309
+ wss.on("listening", () => {
310
+ const addr = wss.address();
311
+ const port = typeof addr === "object" && addr ? addr.port : 0;
312
+ const localUrl = `http://127.0.0.1:${port}`;
313
+ const childArgs = [
314
+ ...args.attachCmd,
315
+ "--url",
316
+ localUrl,
317
+ ...(args.run ? ["--run", args.run] : []),
318
+ ...(args.attachTermId ? ["--attach", args.attachTermId] : []),
319
+ ];
320
+ note(c.dim(`Paired ✓ starting session on ${c.cyan(args.nodeName)}\n`));
321
+ const child = spawn(process.execPath, childArgs, { stdio: "inherit", env: process.env });
322
+ child.on("exit", (code, signal) => {
323
+ try {
324
+ rly.close();
325
+ }
326
+ catch {
327
+ /* ignore */
328
+ }
329
+ try {
330
+ wss.close();
331
+ }
332
+ catch {
333
+ /* ignore */
334
+ }
335
+ process.exit(signal ? 1 : code ?? 0);
336
+ });
337
+ child.on("error", (err) => fail(`Could not start the attach client: ${err.message}`, 1));
338
+ });
339
+ wss.on("error", (err) => fail(`Local bridge error: ${err.message}`, 1));
340
+ }
341
+ }
342
+ main().catch((err) => {
343
+ note(c.red(`relay-attach: ${err?.message || String(err)}`));
344
+ process.exit(1);
345
+ });
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { randomBytes } from "node:crypto";
4
+ import { FRAME_CHUNK_BYTES, MAX_REASSEMBLY_BYTES, MAX_FRAME_CHUNKS, MAX_REASSEMBLY_GROUPS, } from "./wire-format.js";
5
+ // Re-exported so existing importers keep using `./relay-chunk.js`; the value
6
+ // itself is owned by the shared wire-format spec.
7
+ export { FRAME_CHUNK_BYTES };
8
+ /**
9
+ * Turn a sealed payload string into one or more wire messages (JSON strings).
10
+ * Returns a single `{ t: "frame", p }` for small payloads, or an ordered list of
11
+ * chunk messages for large ones.
12
+ */
13
+ export function frameMessages(payload) {
14
+ if (payload.length <= FRAME_CHUNK_BYTES) {
15
+ return [JSON.stringify({ t: "frame", p: payload })];
16
+ }
17
+ const id = randomBytes(8).toString("hex");
18
+ const total = Math.ceil(payload.length / FRAME_CHUNK_BYTES);
19
+ const out = [];
20
+ for (let i = 0; i < total; i++) {
21
+ const slice = payload.slice(i * FRAME_CHUNK_BYTES, (i + 1) * FRAME_CHUNK_BYTES);
22
+ out.push(JSON.stringify({ t: "frame", p: slice, fc: id, fi: i, fn: total }));
23
+ }
24
+ return out;
25
+ }
26
+ /**
27
+ * Buffers inbound frame chunks and yields the full payload once a group is
28
+ * complete. A non-chunked frame passes straight through.
29
+ */
30
+ export class FrameReassembler {
31
+ groups = new Map();
32
+ /**
33
+ * Feed one inbound frame envelope. Returns the reassembled payload string when
34
+ * complete (or the payload itself when the frame was not chunked), or null
35
+ * while more chunks are still outstanding / the frame was invalid.
36
+ */
37
+ accept(env) {
38
+ if (typeof env.p !== "string")
39
+ return null;
40
+ if (env.fc === undefined)
41
+ return env.p; // not chunked
42
+ const id = String(env.fc);
43
+ const index = Number(env.fi);
44
+ const total = Number(env.fn);
45
+ if (!Number.isInteger(index) || !Number.isInteger(total) || total <= 0 || total > MAX_FRAME_CHUNKS || index < 0 || index >= total) {
46
+ return null;
47
+ }
48
+ let group = this.groups.get(id);
49
+ if (!group) {
50
+ // Bound concurrent groups; evict the oldest (insertion order) if needed.
51
+ if (this.groups.size >= MAX_REASSEMBLY_GROUPS) {
52
+ const oldest = this.groups.keys().next().value;
53
+ if (oldest !== undefined)
54
+ this.groups.delete(oldest);
55
+ }
56
+ group = { total, parts: new Array(total), have: 0, bytes: 0 };
57
+ this.groups.set(id, group);
58
+ }
59
+ if (group.parts[index] !== undefined)
60
+ return null; // duplicate chunk
61
+ group.parts[index] = env.p;
62
+ group.have += 1;
63
+ group.bytes += env.p.length;
64
+ if (group.bytes > MAX_REASSEMBLY_BYTES) {
65
+ this.groups.delete(id);
66
+ return null;
67
+ }
68
+ if (group.have < group.total)
69
+ return null;
70
+ this.groups.delete(id);
71
+ return group.parts.join("");
72
+ }
73
+ }