@bivy/bivy 0.0.0 → 0.1.0-staging.10
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 +4215 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/port-picker.mjs +40 -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 +372 -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 +1408 -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 +279 -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 +1552 -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 +539 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +646 -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 +9435 -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 +90 -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 +195 -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,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Client half of the Bivy relay E2E handshake, for a CLI that reaches a node
|
|
4
|
+
// through the hosted relay (the `bivy run --node <account-node>` path).
|
|
5
|
+
//
|
|
6
|
+
// This is the SECURITY-CRITICAL core of relay tunnelling, deliberately isolated
|
|
7
|
+
// from the (network) transport so it can be unit-tested in-process against the
|
|
8
|
+
// real node-side PairingStore (see test/relay-cli-crypto.test.ts). It mirrors the
|
|
9
|
+
// browser client's crypto (packages/core transport-relay) using the same primitives
|
|
10
|
+
// the node uses, so a CLI device pairs and exchanges frames exactly like a phone.
|
|
11
|
+
//
|
|
12
|
+
// Flow (transport carries these opaque):
|
|
13
|
+
// 1. device keypair = newDeviceKeypair()
|
|
14
|
+
// 2. hello = buildHello(pairSecret, keypair) → send as t:"pair"
|
|
15
|
+
// 3. node.handleHello(hello) → welcome (node side)
|
|
16
|
+
// 4. roomKey = acceptWelcome(keypair, welcome) ← recover room key
|
|
17
|
+
// 5. sealFrame(roomKey, msg) / openFrame(roomKey, payload) ↔ bulk frames
|
|
18
|
+
//
|
|
19
|
+
// The pairSecret comes OUT OF BAND (a `bivy link`-style QR/token from the target
|
|
20
|
+
// node); the relay never sees it, so it can route frames but never derive the
|
|
21
|
+
// room key. See docs/relay-node-cli.md for the full transport design.
|
|
22
|
+
import { generatePairingKeypair, pairingProof, deriveWrapKey, unwrapRoomKey, } from "./pairing-crypto.js";
|
|
23
|
+
import { sealFrame, openFrame } from "./e2e.js";
|
|
24
|
+
/** A fresh device identity for this CLI (persist privateKeyB64 to reuse a pairing). */
|
|
25
|
+
export function newDeviceKeypair() {
|
|
26
|
+
return generatePairingKeypair();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build the `pair.hello` payload proving knowledge of the out-of-band pairing
|
|
30
|
+
* secret without ever sending it. `label` shows up in the node's device list.
|
|
31
|
+
*/
|
|
32
|
+
export function buildHello(pairSecretB64, keypair, label = "Bivy CLI") {
|
|
33
|
+
return {
|
|
34
|
+
k: "pair.hello",
|
|
35
|
+
devicePublicKeyB64: keypair.publicKeyB64,
|
|
36
|
+
proofB64: pairingProof(pairSecretB64, keypair.publicKeyB64),
|
|
37
|
+
label,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Recover the shared room key from the node's `pair.welcome`. Derives the same
|
|
42
|
+
* ECDH wrap key the node used (ECDH is symmetric) and unwraps the room key.
|
|
43
|
+
* Throws if the welcome is malformed or the wrap doesn't open (wrong node/key).
|
|
44
|
+
*/
|
|
45
|
+
export function acceptWelcome(keypair, welcome) {
|
|
46
|
+
if (!welcome?.nodePublicKeyB64 || !welcome?.wrapped)
|
|
47
|
+
throw new Error("Malformed pair.welcome");
|
|
48
|
+
const wrapKey = deriveWrapKey(keypair.privateKeyB64, welcome.nodePublicKeyB64, "pair");
|
|
49
|
+
return unwrapRoomKey(wrapKey, welcome.wrapped);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A tiny stateful helper wrapping the room key for a session: seal outbound
|
|
53
|
+
* client messages and open inbound node frames. Transport-agnostic — feed it the
|
|
54
|
+
* relay's decrypted frame payloads and send its sealed output over the relay.
|
|
55
|
+
*/
|
|
56
|
+
export class RoomCipher {
|
|
57
|
+
roomKey;
|
|
58
|
+
constructor(roomKey) {
|
|
59
|
+
this.roomKey = roomKey;
|
|
60
|
+
}
|
|
61
|
+
seal(message) {
|
|
62
|
+
return sealFrame(this.roomKey, message);
|
|
63
|
+
}
|
|
64
|
+
open(payload) {
|
|
65
|
+
return openFrame(this.roomKey, payload);
|
|
66
|
+
}
|
|
67
|
+
key() {
|
|
68
|
+
return this.roomKey;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
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 { WebSocket } from "ws";
|
|
6
|
+
import { seal, sealFrame, openFrame, ReplayGuard } from "./e2e.js";
|
|
7
|
+
import { frameMessages, FrameReassembler } from "./relay-chunk.js";
|
|
8
|
+
function isFatalRelayError(message) {
|
|
9
|
+
return /hosted relay is not enabled|unauthorized|missing token/i.test(message);
|
|
10
|
+
}
|
|
11
|
+
export function loadRelayConfig(appDir) {
|
|
12
|
+
const envUrl = process.env.BIVY_RELAY_URL;
|
|
13
|
+
const filePath = path.join(appDir, "relay.json");
|
|
14
|
+
let raw = {};
|
|
15
|
+
if (fs.existsSync(filePath)) {
|
|
16
|
+
try {
|
|
17
|
+
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
raw = {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const url = envUrl ?? raw.url;
|
|
24
|
+
const enrollmentToken = process.env.BIVY_RELAY_TOKEN ?? raw.enrollmentToken;
|
|
25
|
+
if (!url || !enrollmentToken)
|
|
26
|
+
return null;
|
|
27
|
+
const controlPlaneUrl = process.env.BIVY_CONTROL_PLANE_URL ?? raw.controlPlaneUrl;
|
|
28
|
+
const clientBaseUrl = process.env.BIVY_CLIENT_BASE_URL ?? raw.clientBaseUrl ?? controlPlaneUrl;
|
|
29
|
+
return { url, enrollmentToken, controlPlaneUrl, clientBaseUrl };
|
|
30
|
+
}
|
|
31
|
+
// Application-level keepalive. On flaky/mobile links the node→relay TCP socket
|
|
32
|
+
// can half-open silently: the node still thinks it is connected and buffers
|
|
33
|
+
// sends into a dead socket while clients see "node offline". The relay's own
|
|
34
|
+
// heartbeat eventually reaps the dead socket, but on a lenient interval (up to a
|
|
35
|
+
// few minutes). Pinging from the node and reconnecting when pongs stop cuts that
|
|
36
|
+
// detection window to ~1 minute so the node redials (and clients recover) fast.
|
|
37
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
38
|
+
const HEARTBEAT_TIMEOUT_MS = 75_000;
|
|
39
|
+
// Only reset reconnect backoff after a connection has held this long. The relay
|
|
40
|
+
// accepts the WS upgrade and sends `ready` BEFORE its rate limiter may reject
|
|
41
|
+
// and close the socket a moment later, so resetting on `ready` alone let a
|
|
42
|
+
// rate-limited node reconnect every ~1s indefinitely — a storm that itself keeps
|
|
43
|
+
// tripping the limiter. Requiring a stable window lets backoff escalate normally.
|
|
44
|
+
const BACKOFF_STABLE_RESET_MS = 30_000;
|
|
45
|
+
export class RelayConnector {
|
|
46
|
+
config;
|
|
47
|
+
onClientMessage;
|
|
48
|
+
ws;
|
|
49
|
+
closed = false;
|
|
50
|
+
backoff = 1000;
|
|
51
|
+
heartbeatTimer;
|
|
52
|
+
stableTimer;
|
|
53
|
+
lastPongAt = 0;
|
|
54
|
+
// True only between the relay's `ready` message (auth + entitlement passed)
|
|
55
|
+
// and the socket closing. This — not "a connector object exists" — is what
|
|
56
|
+
// "connected" means to the control plane, so it's what `bivy status` reports.
|
|
57
|
+
ready = false;
|
|
58
|
+
// Most recent relay-side failure (ticket mint, socket error, or an `error`
|
|
59
|
+
// frame), surfaced by `bivy status`/`doctor` so a node that never connects
|
|
60
|
+
// explains why instead of silently showing "configured".
|
|
61
|
+
lastErrorMessage;
|
|
62
|
+
replay = new ReplayGuard();
|
|
63
|
+
reassembler = new FrameReassembler();
|
|
64
|
+
pairing;
|
|
65
|
+
onWorkAvailable;
|
|
66
|
+
constructor(config, onClientMessage, options) {
|
|
67
|
+
this.config = config;
|
|
68
|
+
this.onClientMessage = onClientMessage;
|
|
69
|
+
this.pairing = options.pairing;
|
|
70
|
+
this.onWorkAvailable = options.onWorkAvailable;
|
|
71
|
+
}
|
|
72
|
+
/** Current bulk-encryption key: the pairing store's rotating room key. */
|
|
73
|
+
roomKey() {
|
|
74
|
+
return this.pairing.roomKey();
|
|
75
|
+
}
|
|
76
|
+
/** Push a room-key rotation (after a device revoke) to connected devices. */
|
|
77
|
+
pushRotate(deliveries) {
|
|
78
|
+
if (this.ws?.readyState !== WebSocket.OPEN || deliveries.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
this.ws.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "key.rotate", deliveries }) }));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* True only while the relay link is live AND the relay has sent `ready`
|
|
84
|
+
* (auth/entitlement checks passed) — i.e. the node is actually reachable from
|
|
85
|
+
* the control plane. A socket that opened but was rejected, or one still
|
|
86
|
+
* reconnecting, reads false.
|
|
87
|
+
*/
|
|
88
|
+
get connected() {
|
|
89
|
+
return this.ready && this.ws?.readyState === WebSocket.OPEN;
|
|
90
|
+
}
|
|
91
|
+
/** Most recent relay-side failure, if any — for status/diagnostics. */
|
|
92
|
+
get lastError() {
|
|
93
|
+
return this.lastErrorMessage;
|
|
94
|
+
}
|
|
95
|
+
start() {
|
|
96
|
+
this.closed = false;
|
|
97
|
+
void this.connect();
|
|
98
|
+
}
|
|
99
|
+
stop() {
|
|
100
|
+
this.closed = true;
|
|
101
|
+
this.ready = false;
|
|
102
|
+
this.stopHeartbeat();
|
|
103
|
+
this.clearBackoffReset();
|
|
104
|
+
this.ws?.close();
|
|
105
|
+
}
|
|
106
|
+
startHeartbeat(ws) {
|
|
107
|
+
this.stopHeartbeat();
|
|
108
|
+
this.lastPongAt = Date.now();
|
|
109
|
+
this.heartbeatTimer = setInterval(() => {
|
|
110
|
+
if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
111
|
+
this.stopHeartbeat();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) {
|
|
115
|
+
// No pong since the last few pings — treat the link as half-open and
|
|
116
|
+
// force a reconnect rather than streaming into a dead socket.
|
|
117
|
+
console.warn("[relay] keepalive timed out; reconnecting");
|
|
118
|
+
this.stopHeartbeat();
|
|
119
|
+
ws.terminate();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
ws.ping();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// A failed ping means the socket is already gone; close will follow.
|
|
127
|
+
}
|
|
128
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
129
|
+
this.heartbeatTimer.unref?.();
|
|
130
|
+
}
|
|
131
|
+
stopHeartbeat() {
|
|
132
|
+
if (this.heartbeatTimer) {
|
|
133
|
+
clearInterval(this.heartbeatTimer);
|
|
134
|
+
this.heartbeatTimer = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Reset reconnect backoff only once this connection has stayed up for a stable
|
|
138
|
+
// window (see BACKOFF_STABLE_RESET_MS). Armed on `ready`, disarmed on close, so
|
|
139
|
+
// a connection that the relay rate-limits and drops seconds later never counts
|
|
140
|
+
// as healthy and backoff keeps escalating instead of hot-looping.
|
|
141
|
+
scheduleBackoffReset(ws) {
|
|
142
|
+
this.clearBackoffReset();
|
|
143
|
+
this.stableTimer = setTimeout(() => {
|
|
144
|
+
if (!this.closed && this.ws === ws && ws.readyState === WebSocket.OPEN)
|
|
145
|
+
this.backoff = 1000;
|
|
146
|
+
}, BACKOFF_STABLE_RESET_MS);
|
|
147
|
+
this.stableTimer.unref?.();
|
|
148
|
+
}
|
|
149
|
+
clearBackoffReset() {
|
|
150
|
+
if (this.stableTimer) {
|
|
151
|
+
clearTimeout(this.stableTimer);
|
|
152
|
+
this.stableTimer = undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Encrypt a string with the current room key for AT-REST storage off-node
|
|
157
|
+
* (e.g. a session title in the control-plane index). Clients holding the room
|
|
158
|
+
* key decrypt it; the control plane only ever sees ciphertext.
|
|
159
|
+
*/
|
|
160
|
+
sealString(value) {
|
|
161
|
+
return seal(this.roomKey(), value);
|
|
162
|
+
}
|
|
163
|
+
/** Push a local session event to remote clients (encrypted). */
|
|
164
|
+
sendEvent(event) {
|
|
165
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
166
|
+
return;
|
|
167
|
+
const payload = sealFrame(this.roomKey(), event);
|
|
168
|
+
// Large events (big file reads, long diffs, image attachments) are split
|
|
169
|
+
// into multiple relay frames so none exceeds the relay's max-frame limit;
|
|
170
|
+
// the client reassembles them before decrypting.
|
|
171
|
+
for (const msg of frameMessages(payload)) {
|
|
172
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
173
|
+
return;
|
|
174
|
+
this.ws.send(msg);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Handle a pairing control frame from a device (X25519 handshake). These are
|
|
179
|
+
* forwarded verbatim by the relay (t === "pair") and never contain the room
|
|
180
|
+
* key in the clear. Only `pair.hello` is acted on; the node replies with
|
|
181
|
+
* `pair.welcome` carrying the ECDH-wrapped room key.
|
|
182
|
+
*/
|
|
183
|
+
async handlePairFrame(payload) {
|
|
184
|
+
let msg;
|
|
185
|
+
try {
|
|
186
|
+
msg = JSON.parse(payload);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let welcome = null;
|
|
192
|
+
let reason = "Pairing failed";
|
|
193
|
+
if (msg.k === "pair.hello" && msg.devicePublicKeyB64 && msg.proofB64) {
|
|
194
|
+
welcome = this.pairing.handleHello({
|
|
195
|
+
devicePublicKeyB64: msg.devicePublicKeyB64,
|
|
196
|
+
proofB64: msg.proofB64,
|
|
197
|
+
label: msg.label,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else if (msg.k === "pair.account" && msg.devicePublicKeyB64 && msg.sessionToken) {
|
|
201
|
+
const auth = await this.authorizeAccountPairing(msg.sessionToken, msg.devicePublicKeyB64, msg.label, msg.ephemeral === true);
|
|
202
|
+
if (auth.ok)
|
|
203
|
+
welcome = this.pairing.trustDevice({ devicePublicKeyB64: msg.devicePublicKeyB64, label: msg.label });
|
|
204
|
+
else
|
|
205
|
+
reason = auth.reason;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!welcome) {
|
|
211
|
+
this.ws?.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "pair.error", error: reason }) }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.ws?.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "pair.welcome", ...welcome }) }));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Verify that an account-session client is allowed to link this node. Returns
|
|
218
|
+
* the control plane's concrete failure reason (e.g. "Device limit reached")
|
|
219
|
+
* so the node can relay it to the client instead of a bare "pairing rejected".
|
|
220
|
+
*/
|
|
221
|
+
async authorizeAccountPairing(sessionToken, devicePublicKeyB64, label, ephemeral = false) {
|
|
222
|
+
if (!this.config.controlPlaneUrl)
|
|
223
|
+
return { ok: false, reason: "Node is not linked to a control plane." };
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch(`${this.config.controlPlaneUrl.replace(/\/$/, "")}/node/authorize-client`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.config.enrollmentToken}` },
|
|
228
|
+
body: JSON.stringify({ sessionToken, devicePublicKeyB64, label, ephemeral }),
|
|
229
|
+
// Node's fetch has no default timeout. Without this, a control plane that
|
|
230
|
+
// accepts the TCP connection but never responds would hang pairing (and,
|
|
231
|
+
// on the mintTicket path, the whole relay reconnect) indefinitely.
|
|
232
|
+
signal: AbortSignal.timeout(10_000),
|
|
233
|
+
});
|
|
234
|
+
const data = (await res.json().catch(() => ({})));
|
|
235
|
+
if (res.ok && data.ok === true)
|
|
236
|
+
return { ok: true };
|
|
237
|
+
return { ok: false, reason: data.error || `Pairing rejected (${res.status}).` };
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return { ok: false, reason: err?.message || "Could not reach the control plane to pair." };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Exchange the long-lived enrollment token for a short-lived, single-use
|
|
245
|
+
* relay ticket — directly against the control plane (over TLS). Only the
|
|
246
|
+
* ticket is presented to the relay, so the relay never sees a reusable
|
|
247
|
+
* credential. A fresh ticket is minted on every (re)connect.
|
|
248
|
+
*/
|
|
249
|
+
async mintTicket() {
|
|
250
|
+
if (!this.config.controlPlaneUrl) {
|
|
251
|
+
throw new Error("relay.json missing controlPlaneUrl (required for relay tickets)");
|
|
252
|
+
}
|
|
253
|
+
const res = await fetch(`${this.config.controlPlaneUrl.replace(/\/$/, "")}/node/relay-ticket`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.config.enrollmentToken}` },
|
|
256
|
+
body: "{}",
|
|
257
|
+
// connect() awaits this on every (re)connect; a hung control plane must
|
|
258
|
+
// fail fast into scheduleReconnect() rather than wedge remote access.
|
|
259
|
+
signal: AbortSignal.timeout(10_000),
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok)
|
|
262
|
+
throw new Error(`ticket request failed: ${res.status}`);
|
|
263
|
+
const data = (await res.json());
|
|
264
|
+
if (!data?.ticket)
|
|
265
|
+
throw new Error("control plane returned no ticket");
|
|
266
|
+
return { ticket: data.ticket, relayUrl: typeof data.relayUrl === "string" ? data.relayUrl : undefined };
|
|
267
|
+
}
|
|
268
|
+
async connect() {
|
|
269
|
+
let ticket;
|
|
270
|
+
let relayUrl;
|
|
271
|
+
try {
|
|
272
|
+
({ ticket, relayUrl } = await this.mintTicket());
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
this.lastErrorMessage = `ticket mint failed: ${error.message}`;
|
|
276
|
+
console.warn("[relay] could not mint relay ticket:", error.message);
|
|
277
|
+
this.scheduleReconnect();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (this.closed)
|
|
281
|
+
return;
|
|
282
|
+
// Connect to the shard the control plane assigned this node (docs/scaling.md),
|
|
283
|
+
// falling back to the statically configured relay for older control planes.
|
|
284
|
+
const relayBase = (relayUrl ?? this.config.url).replace(/\/$/, "");
|
|
285
|
+
const target = `${relayBase}/node?ticket=${encodeURIComponent(ticket)}`;
|
|
286
|
+
const ws = new WebSocket(target);
|
|
287
|
+
this.ws = ws;
|
|
288
|
+
ws.on("open", () => {
|
|
289
|
+
// The TCP/WebSocket handshake succeeded, but relay auth/entitlement
|
|
290
|
+
// checks happen after upgrade. Wait for the relay's `ready` message
|
|
291
|
+
// before declaring the connector usable or resetting reconnect backoff.
|
|
292
|
+
});
|
|
293
|
+
ws.on("message", (data) => {
|
|
294
|
+
let env;
|
|
295
|
+
try {
|
|
296
|
+
env = JSON.parse(data.toString());
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (env.t === "ready") {
|
|
302
|
+
this.ready = true;
|
|
303
|
+
this.lastErrorMessage = undefined;
|
|
304
|
+
this.startHeartbeat(ws);
|
|
305
|
+
this.scheduleBackoffReset(ws);
|
|
306
|
+
console.log("[relay] connected");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (env.t === "pair" && typeof env.p === "string") {
|
|
310
|
+
void this.handlePairFrame(env.p);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (env.t === "work.available") {
|
|
314
|
+
const hint = env;
|
|
315
|
+
this.onWorkAvailable?.({ id: typeof hint.id === "string" ? hint.id : undefined, label: typeof hint.label === "string" ? hint.label : undefined });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (env.t === "frame" && typeof env.p === "string") {
|
|
319
|
+
// Reassemble chunked frames (large client uploads) before decrypting;
|
|
320
|
+
// returns null while more chunks are still in flight.
|
|
321
|
+
const payload = this.reassembler.accept(env);
|
|
322
|
+
if (payload === null)
|
|
323
|
+
return;
|
|
324
|
+
try {
|
|
325
|
+
const frame = openFrame(this.roomKey(), payload);
|
|
326
|
+
if (!this.replay.accept(frame)) {
|
|
327
|
+
console.warn("[relay] dropped stale or replayed client frame");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.onClientMessage(frame.data);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
console.warn("[relay] failed to decrypt client frame");
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (env.t === "error") {
|
|
338
|
+
const message = env.error || "Relay error";
|
|
339
|
+
this.lastErrorMessage = message;
|
|
340
|
+
console.warn("[relay] error:", message);
|
|
341
|
+
if (isFatalRelayError(message)) {
|
|
342
|
+
console.warn("[relay] disabling connector; fix relay setup/plan and restart the node dev server");
|
|
343
|
+
this.closed = true;
|
|
344
|
+
ws.close();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
ws.on("pong", () => {
|
|
349
|
+
this.lastPongAt = Date.now();
|
|
350
|
+
});
|
|
351
|
+
ws.on("close", () => {
|
|
352
|
+
this.ready = false;
|
|
353
|
+
this.stopHeartbeat();
|
|
354
|
+
this.clearBackoffReset();
|
|
355
|
+
this.scheduleReconnect();
|
|
356
|
+
});
|
|
357
|
+
ws.on("error", (error) => {
|
|
358
|
+
this.lastErrorMessage = error.message;
|
|
359
|
+
console.warn("[relay] socket error:", error.message);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
scheduleReconnect() {
|
|
363
|
+
if (this.closed)
|
|
364
|
+
return;
|
|
365
|
+
const wait = this.backoff;
|
|
366
|
+
this.backoff = Math.min(this.backoff * 2, 30_000);
|
|
367
|
+
setTimeout(() => {
|
|
368
|
+
if (!this.closed)
|
|
369
|
+
void this.connect();
|
|
370
|
+
}, wait);
|
|
371
|
+
}
|
|
372
|
+
}
|