@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,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,344 @@
|
|
|
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
|
+
replay = new ReplayGuard();
|
|
55
|
+
reassembler = new FrameReassembler();
|
|
56
|
+
pairing;
|
|
57
|
+
onWorkAvailable;
|
|
58
|
+
constructor(config, onClientMessage, options) {
|
|
59
|
+
this.config = config;
|
|
60
|
+
this.onClientMessage = onClientMessage;
|
|
61
|
+
this.pairing = options.pairing;
|
|
62
|
+
this.onWorkAvailable = options.onWorkAvailable;
|
|
63
|
+
}
|
|
64
|
+
/** Current bulk-encryption key: the pairing store's rotating room key. */
|
|
65
|
+
roomKey() {
|
|
66
|
+
return this.pairing.roomKey();
|
|
67
|
+
}
|
|
68
|
+
/** Push a room-key rotation (after a device revoke) to connected devices. */
|
|
69
|
+
pushRotate(deliveries) {
|
|
70
|
+
if (this.ws?.readyState !== WebSocket.OPEN || deliveries.length === 0)
|
|
71
|
+
return;
|
|
72
|
+
this.ws.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "key.rotate", deliveries }) }));
|
|
73
|
+
}
|
|
74
|
+
start() {
|
|
75
|
+
this.closed = false;
|
|
76
|
+
void this.connect();
|
|
77
|
+
}
|
|
78
|
+
stop() {
|
|
79
|
+
this.closed = true;
|
|
80
|
+
this.stopHeartbeat();
|
|
81
|
+
this.clearBackoffReset();
|
|
82
|
+
this.ws?.close();
|
|
83
|
+
}
|
|
84
|
+
startHeartbeat(ws) {
|
|
85
|
+
this.stopHeartbeat();
|
|
86
|
+
this.lastPongAt = Date.now();
|
|
87
|
+
this.heartbeatTimer = setInterval(() => {
|
|
88
|
+
if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
89
|
+
this.stopHeartbeat();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) {
|
|
93
|
+
// No pong since the last few pings — treat the link as half-open and
|
|
94
|
+
// force a reconnect rather than streaming into a dead socket.
|
|
95
|
+
console.warn("[relay] keepalive timed out; reconnecting");
|
|
96
|
+
this.stopHeartbeat();
|
|
97
|
+
ws.terminate();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
ws.ping();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// A failed ping means the socket is already gone; close will follow.
|
|
105
|
+
}
|
|
106
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
107
|
+
this.heartbeatTimer.unref?.();
|
|
108
|
+
}
|
|
109
|
+
stopHeartbeat() {
|
|
110
|
+
if (this.heartbeatTimer) {
|
|
111
|
+
clearInterval(this.heartbeatTimer);
|
|
112
|
+
this.heartbeatTimer = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Reset reconnect backoff only once this connection has stayed up for a stable
|
|
116
|
+
// window (see BACKOFF_STABLE_RESET_MS). Armed on `ready`, disarmed on close, so
|
|
117
|
+
// a connection that the relay rate-limits and drops seconds later never counts
|
|
118
|
+
// as healthy and backoff keeps escalating instead of hot-looping.
|
|
119
|
+
scheduleBackoffReset(ws) {
|
|
120
|
+
this.clearBackoffReset();
|
|
121
|
+
this.stableTimer = setTimeout(() => {
|
|
122
|
+
if (!this.closed && this.ws === ws && ws.readyState === WebSocket.OPEN)
|
|
123
|
+
this.backoff = 1000;
|
|
124
|
+
}, BACKOFF_STABLE_RESET_MS);
|
|
125
|
+
this.stableTimer.unref?.();
|
|
126
|
+
}
|
|
127
|
+
clearBackoffReset() {
|
|
128
|
+
if (this.stableTimer) {
|
|
129
|
+
clearTimeout(this.stableTimer);
|
|
130
|
+
this.stableTimer = undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Encrypt a string with the current room key for AT-REST storage off-node
|
|
135
|
+
* (e.g. a session title in the control-plane index). Clients holding the room
|
|
136
|
+
* key decrypt it; the control plane only ever sees ciphertext.
|
|
137
|
+
*/
|
|
138
|
+
sealString(value) {
|
|
139
|
+
return seal(this.roomKey(), value);
|
|
140
|
+
}
|
|
141
|
+
/** Push a local session event to remote clients (encrypted). */
|
|
142
|
+
sendEvent(event) {
|
|
143
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
144
|
+
return;
|
|
145
|
+
const payload = sealFrame(this.roomKey(), event);
|
|
146
|
+
// Large events (big file reads, long diffs, image attachments) are split
|
|
147
|
+
// into multiple relay frames so none exceeds the relay's max-frame limit;
|
|
148
|
+
// the client reassembles them before decrypting.
|
|
149
|
+
for (const msg of frameMessages(payload)) {
|
|
150
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
151
|
+
return;
|
|
152
|
+
this.ws.send(msg);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Handle a pairing control frame from a device (X25519 handshake). These are
|
|
157
|
+
* forwarded verbatim by the relay (t === "pair") and never contain the room
|
|
158
|
+
* key in the clear. Only `pair.hello` is acted on; the node replies with
|
|
159
|
+
* `pair.welcome` carrying the ECDH-wrapped room key.
|
|
160
|
+
*/
|
|
161
|
+
async handlePairFrame(payload) {
|
|
162
|
+
let msg;
|
|
163
|
+
try {
|
|
164
|
+
msg = JSON.parse(payload);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let welcome = null;
|
|
170
|
+
let reason = "Pairing failed";
|
|
171
|
+
if (msg.k === "pair.hello" && msg.devicePublicKeyB64 && msg.proofB64) {
|
|
172
|
+
welcome = this.pairing.handleHello({
|
|
173
|
+
devicePublicKeyB64: msg.devicePublicKeyB64,
|
|
174
|
+
proofB64: msg.proofB64,
|
|
175
|
+
label: msg.label,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
else if (msg.k === "pair.account" && msg.devicePublicKeyB64 && msg.sessionToken) {
|
|
179
|
+
const auth = await this.authorizeAccountPairing(msg.sessionToken, msg.devicePublicKeyB64, msg.label, msg.ephemeral === true);
|
|
180
|
+
if (auth.ok)
|
|
181
|
+
welcome = this.pairing.trustDevice({ devicePublicKeyB64: msg.devicePublicKeyB64, label: msg.label });
|
|
182
|
+
else
|
|
183
|
+
reason = auth.reason;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!welcome) {
|
|
189
|
+
this.ws?.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "pair.error", error: reason }) }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.ws?.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "pair.welcome", ...welcome }) }));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Verify that an account-session client is allowed to link this node. Returns
|
|
196
|
+
* the control plane's concrete failure reason (e.g. "Device limit reached")
|
|
197
|
+
* so the node can relay it to the client instead of a bare "pairing rejected".
|
|
198
|
+
*/
|
|
199
|
+
async authorizeAccountPairing(sessionToken, devicePublicKeyB64, label, ephemeral = false) {
|
|
200
|
+
if (!this.config.controlPlaneUrl)
|
|
201
|
+
return { ok: false, reason: "Node is not linked to a control plane." };
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(`${this.config.controlPlaneUrl.replace(/\/$/, "")}/node/authorize-client`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.config.enrollmentToken}` },
|
|
206
|
+
body: JSON.stringify({ sessionToken, devicePublicKeyB64, label, ephemeral }),
|
|
207
|
+
// Node's fetch has no default timeout. Without this, a control plane that
|
|
208
|
+
// accepts the TCP connection but never responds would hang pairing (and,
|
|
209
|
+
// on the mintTicket path, the whole relay reconnect) indefinitely.
|
|
210
|
+
signal: AbortSignal.timeout(10_000),
|
|
211
|
+
});
|
|
212
|
+
const data = (await res.json().catch(() => ({})));
|
|
213
|
+
if (res.ok && data.ok === true)
|
|
214
|
+
return { ok: true };
|
|
215
|
+
return { ok: false, reason: data.error || `Pairing rejected (${res.status}).` };
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
return { ok: false, reason: err?.message || "Could not reach the control plane to pair." };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Exchange the long-lived enrollment token for a short-lived, single-use
|
|
223
|
+
* relay ticket — directly against the control plane (over TLS). Only the
|
|
224
|
+
* ticket is presented to the relay, so the relay never sees a reusable
|
|
225
|
+
* credential. A fresh ticket is minted on every (re)connect.
|
|
226
|
+
*/
|
|
227
|
+
async mintTicket() {
|
|
228
|
+
if (!this.config.controlPlaneUrl) {
|
|
229
|
+
throw new Error("relay.json missing controlPlaneUrl (required for relay tickets)");
|
|
230
|
+
}
|
|
231
|
+
const res = await fetch(`${this.config.controlPlaneUrl.replace(/\/$/, "")}/node/relay-ticket`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.config.enrollmentToken}` },
|
|
234
|
+
body: "{}",
|
|
235
|
+
// connect() awaits this on every (re)connect; a hung control plane must
|
|
236
|
+
// fail fast into scheduleReconnect() rather than wedge remote access.
|
|
237
|
+
signal: AbortSignal.timeout(10_000),
|
|
238
|
+
});
|
|
239
|
+
if (!res.ok)
|
|
240
|
+
throw new Error(`ticket request failed: ${res.status}`);
|
|
241
|
+
const data = (await res.json());
|
|
242
|
+
if (!data?.ticket)
|
|
243
|
+
throw new Error("control plane returned no ticket");
|
|
244
|
+
return { ticket: data.ticket, relayUrl: typeof data.relayUrl === "string" ? data.relayUrl : undefined };
|
|
245
|
+
}
|
|
246
|
+
async connect() {
|
|
247
|
+
let ticket;
|
|
248
|
+
let relayUrl;
|
|
249
|
+
try {
|
|
250
|
+
({ ticket, relayUrl } = await this.mintTicket());
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
console.warn("[relay] could not mint relay ticket:", error.message);
|
|
254
|
+
this.scheduleReconnect();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (this.closed)
|
|
258
|
+
return;
|
|
259
|
+
// Connect to the shard the control plane assigned this node (docs/scaling.md),
|
|
260
|
+
// falling back to the statically configured relay for older control planes.
|
|
261
|
+
const relayBase = (relayUrl ?? this.config.url).replace(/\/$/, "");
|
|
262
|
+
const target = `${relayBase}/node?ticket=${encodeURIComponent(ticket)}`;
|
|
263
|
+
const ws = new WebSocket(target);
|
|
264
|
+
this.ws = ws;
|
|
265
|
+
ws.on("open", () => {
|
|
266
|
+
// The TCP/WebSocket handshake succeeded, but relay auth/entitlement
|
|
267
|
+
// checks happen after upgrade. Wait for the relay's `ready` message
|
|
268
|
+
// before declaring the connector usable or resetting reconnect backoff.
|
|
269
|
+
});
|
|
270
|
+
ws.on("message", (data) => {
|
|
271
|
+
let env;
|
|
272
|
+
try {
|
|
273
|
+
env = JSON.parse(data.toString());
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (env.t === "ready") {
|
|
279
|
+
this.startHeartbeat(ws);
|
|
280
|
+
this.scheduleBackoffReset(ws);
|
|
281
|
+
console.log("[relay] connected");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (env.t === "pair" && typeof env.p === "string") {
|
|
285
|
+
void this.handlePairFrame(env.p);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (env.t === "work.available") {
|
|
289
|
+
const hint = env;
|
|
290
|
+
this.onWorkAvailable?.({ id: typeof hint.id === "string" ? hint.id : undefined, label: typeof hint.label === "string" ? hint.label : undefined });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (env.t === "frame" && typeof env.p === "string") {
|
|
294
|
+
// Reassemble chunked frames (large client uploads) before decrypting;
|
|
295
|
+
// returns null while more chunks are still in flight.
|
|
296
|
+
const payload = this.reassembler.accept(env);
|
|
297
|
+
if (payload === null)
|
|
298
|
+
return;
|
|
299
|
+
try {
|
|
300
|
+
const frame = openFrame(this.roomKey(), payload);
|
|
301
|
+
if (!this.replay.accept(frame)) {
|
|
302
|
+
console.warn("[relay] dropped stale or replayed client frame");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.onClientMessage(frame.data);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
console.warn("[relay] failed to decrypt client frame");
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (env.t === "error") {
|
|
313
|
+
const message = env.error || "Relay error";
|
|
314
|
+
console.warn("[relay] error:", message);
|
|
315
|
+
if (isFatalRelayError(message)) {
|
|
316
|
+
console.warn("[relay] disabling connector; fix relay setup/plan and restart the node dev server");
|
|
317
|
+
this.closed = true;
|
|
318
|
+
ws.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
ws.on("pong", () => {
|
|
323
|
+
this.lastPongAt = Date.now();
|
|
324
|
+
});
|
|
325
|
+
ws.on("close", () => {
|
|
326
|
+
this.stopHeartbeat();
|
|
327
|
+
this.clearBackoffReset();
|
|
328
|
+
this.scheduleReconnect();
|
|
329
|
+
});
|
|
330
|
+
ws.on("error", (error) => {
|
|
331
|
+
console.warn("[relay] socket error:", error.message);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
scheduleReconnect() {
|
|
335
|
+
if (this.closed)
|
|
336
|
+
return;
|
|
337
|
+
const wait = this.backoff;
|
|
338
|
+
this.backoff = Math.min(this.backoff * 2, 30_000);
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
if (!this.closed)
|
|
341
|
+
void this.connect();
|
|
342
|
+
}, wait);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
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 readline from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { NodeIdentity } from "./identity.js";
|
|
8
|
+
import { hostedEndpoints } from "./hosted-endpoints.mjs";
|
|
9
|
+
import { openBrowser } from "./browser-open.js";
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
/**
|
|
12
|
+
* One-time node relay setup.
|
|
13
|
+
*
|
|
14
|
+
* Signs into the control plane (hands-free magic-link by default), enrolls THIS
|
|
15
|
+
* node (using its stable nodeId), generates an E2E key, and writes
|
|
16
|
+
* `.bivy/relay.json`. After this the daemon dials the relay automatically
|
|
17
|
+
* on next start.
|
|
18
|
+
*
|
|
19
|
+
* Endpoints default to the baked-in hosted service (see hosted-endpoints.mjs);
|
|
20
|
+
* a user never has to type a URL. Everything is overridable for self-hosting:
|
|
21
|
+
*
|
|
22
|
+
* npm run relay:setup -- --email you@example.com
|
|
23
|
+
* BIVY_HOSTED_DOMAIN=bivy.sh npm run relay:setup -- --email you@…
|
|
24
|
+
* npm run relay:setup -- --control-plane https://app.x --relay wss://relay.x \
|
|
25
|
+
* --email you@example.com
|
|
26
|
+
*
|
|
27
|
+
* Sign-in is hands-free: we email a link, you click it in a browser, and this
|
|
28
|
+
* command detects completion by polling. Pass `--session-token` to skip sign-in
|
|
29
|
+
* when you already have an account session.
|
|
30
|
+
*/
|
|
31
|
+
function arg(name, fallback) {
|
|
32
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
33
|
+
if (i !== -1 && process.argv[i + 1])
|
|
34
|
+
return process.argv[i + 1];
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
function sleep(ms) {
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
|
40
|
+
async function fetchJson(url, init) {
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetch(url, init);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
+
throw new Error(`Could not reach ${url}: ${message}`);
|
|
48
|
+
}
|
|
49
|
+
const data = await response.json().catch(() => ({}));
|
|
50
|
+
return { ok: response.ok, status: response.status, data };
|
|
51
|
+
}
|
|
52
|
+
async function askYesNo(question, defaultYes) {
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const answer = await new Promise((resolve) => rl.question(`\n${question} (${hint})\n > `, resolve));
|
|
58
|
+
const v = answer.trim().toLowerCase();
|
|
59
|
+
if (!v)
|
|
60
|
+
return defaultYes;
|
|
61
|
+
if (v === "y" || v === "yes")
|
|
62
|
+
return true;
|
|
63
|
+
if (v === "n" || v === "no")
|
|
64
|
+
return false;
|
|
65
|
+
console.log("Please answer yes or no.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
rl.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function checkControlPlane(controlPlaneUrl) {
|
|
73
|
+
// /me returns 401 when healthy and unauthenticated. That is good enough to
|
|
74
|
+
// catch DNS/TLS/reverse-proxy mistakes before the user waits for email.
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(`${controlPlaneUrl}/me`);
|
|
77
|
+
if (response.status === 401 || response.ok)
|
|
78
|
+
return;
|
|
79
|
+
throw new Error(`health check returned HTTP ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
throw new Error(`Hosted control plane is not reachable at ${controlPlaneUrl}. ${message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Poll a started device login until it completes; returns the session token. */
|
|
87
|
+
async function pollDevice(controlPlaneUrl, start) {
|
|
88
|
+
const intervalMs = Number(start.intervalMs) || 2000;
|
|
89
|
+
const deadline = Date.now() + (Number(start.expiresInMs) || 15 * 60_000);
|
|
90
|
+
process.stdout.write("Waiting for you to finish sign-in");
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await sleep(intervalMs);
|
|
93
|
+
process.stdout.write(".");
|
|
94
|
+
const pollResult = await fetchJson(`${controlPlaneUrl}/auth/device/poll`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ deviceId: start.deviceId, deviceSecret: start.deviceSecret }),
|
|
98
|
+
}).catch(() => null);
|
|
99
|
+
const poll = pollResult?.data;
|
|
100
|
+
if (poll?.status === "complete" && poll.token) {
|
|
101
|
+
process.stdout.write("\n");
|
|
102
|
+
return poll.token;
|
|
103
|
+
}
|
|
104
|
+
if (poll?.status === "expired")
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
process.stdout.write("\n");
|
|
108
|
+
throw new Error("Sign-in timed out or expired. Run setup again.");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Hands-free magic-link sign-in. Emails a link, opens/prints it, polls until
|
|
112
|
+
* clicked. Returns an account session token.
|
|
113
|
+
*/
|
|
114
|
+
async function deviceLogin(controlPlaneUrl, email) {
|
|
115
|
+
const started = await fetchJson(`${controlPlaneUrl}/auth/device/start`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ email }),
|
|
119
|
+
});
|
|
120
|
+
const start = started.data;
|
|
121
|
+
if (!started.ok || !start?.deviceId || !start?.deviceSecret) {
|
|
122
|
+
throw new Error(`Sign-in could not start (${started.status}): ${JSON.stringify(start)}`);
|
|
123
|
+
}
|
|
124
|
+
if (start.devLink) {
|
|
125
|
+
const opened = openBrowser(start.devLink);
|
|
126
|
+
console.log(opened
|
|
127
|
+
? `\nOpening sign-in link (no email configured on the server):\n ${start.devLink}`
|
|
128
|
+
: `\nNo email configured on the server — open this sign-in link in a browser (this machine has none):\n ${start.devLink}`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
console.log(`\nWe emailed a sign-in link to ${email}. Open it in your browser to continue.`);
|
|
132
|
+
}
|
|
133
|
+
return pollDevice(controlPlaneUrl, start);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Hands-free GitHub sign-in (primary). Opens the GitHub authorize URL and polls
|
|
137
|
+
* until the OAuth callback completes the device login. Returns a session token.
|
|
138
|
+
*/
|
|
139
|
+
async function githubDeviceLogin(controlPlaneUrl) {
|
|
140
|
+
const started = await fetchJson(`${controlPlaneUrl}/auth/device/github/start`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/json" },
|
|
143
|
+
body: "{}",
|
|
144
|
+
});
|
|
145
|
+
const start = started.data;
|
|
146
|
+
if (started.status === 501)
|
|
147
|
+
throw new Error("GitHub sign-in is not enabled on this control plane. Use --email instead.");
|
|
148
|
+
if (!started.ok || !start?.deviceId || !start?.authorizeUrl) {
|
|
149
|
+
throw new Error(`GitHub sign-in could not start (${started.status}): ${JSON.stringify(start)}`);
|
|
150
|
+
}
|
|
151
|
+
const opened = openBrowser(start.authorizeUrl);
|
|
152
|
+
console.log(opened
|
|
153
|
+
? `\nSign in with GitHub in your browser:\n ${start.authorizeUrl}`
|
|
154
|
+
: `\nSign in with GitHub — open this link in a browser on any device (this machine has none):\n ${start.authorizeUrl}`);
|
|
155
|
+
return pollDevice(controlPlaneUrl, start);
|
|
156
|
+
}
|
|
157
|
+
async function main() {
|
|
158
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
159
|
+
// Honor the same override as every other entry point (server.ts, native-pi.ts,
|
|
160
|
+
// bivy-login.ts, secrets-cli.ts, …) so a global/packaged install writes
|
|
161
|
+
// relay.json and the node identity into the real data dir instead of a
|
|
162
|
+
// package directory that gets wiped on update. See issue #2.
|
|
163
|
+
const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
|
|
164
|
+
const endpoints = hostedEndpoints();
|
|
165
|
+
const controlPlaneUrl = (arg("control-plane", process.env.BIVY_CONTROL_PLANE_URL) ?? endpoints.controlPlane).replace(/\/$/, "");
|
|
166
|
+
const relayUrl = (arg("relay", process.env.BIVY_RELAY_URL) ?? endpoints.relay).replace(/\/$/, "");
|
|
167
|
+
const clientBaseUrl = (arg("client", process.env.BIVY_CLIENT_BASE_URL) ?? endpoints.clientBaseUrl).replace(/\/$/, "") || controlPlaneUrl;
|
|
168
|
+
const email = arg("email", process.env.BIVY_EMAIL);
|
|
169
|
+
const sessionToken = arg("session-token", process.env.BIVY_SESSION_TOKEN);
|
|
170
|
+
// GitHub is the primary sign-in: used when --github is passed, or by default
|
|
171
|
+
// when neither an email nor an existing session token is supplied.
|
|
172
|
+
const useGithub = process.argv.includes("--github") || process.env.BIVY_AUTH === "github" || (!email && !sessionToken);
|
|
173
|
+
const identity = NodeIdentity.load(appDir);
|
|
174
|
+
console.log(`Node: ${identity.name} (${identity.nodeId})`);
|
|
175
|
+
console.log(`Control plane: ${controlPlaneUrl}`);
|
|
176
|
+
console.log(`Relay: ${relayUrl}`);
|
|
177
|
+
await checkControlPlane(controlPlaneUrl);
|
|
178
|
+
const token = sessionToken ?? (useGithub ? await githubDeviceLogin(controlPlaneUrl) : await deviceLogin(controlPlaneUrl, email));
|
|
179
|
+
async function enrollNode() {
|
|
180
|
+
return fetchJson(`${controlPlaneUrl}/nodes/enroll`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
183
|
+
body: JSON.stringify({ nodeId: identity.nodeId, name: identity.name }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
let enrolled = await enrollNode();
|
|
187
|
+
let enroll = enrolled.data;
|
|
188
|
+
if (!enrolled.ok && enrolled.status === 402 && /node limit/i.test(String(enroll?.error ?? ""))) {
|
|
189
|
+
const listed = await fetchJson(`${controlPlaneUrl}/nodes`, { headers: { authorization: `Bearer ${token}` } });
|
|
190
|
+
const nodes = Array.isArray(listed.data) ? listed.data : [];
|
|
191
|
+
if (nodes.length) {
|
|
192
|
+
// Lead with the upgrade paths — subscribing or increasing the plan is the
|
|
193
|
+
// intended way to add a node. Removing an existing node is the fallback.
|
|
194
|
+
const accountUrl = `${clientBaseUrl}/?account=1`;
|
|
195
|
+
console.log("\nYour plan's node limit is reached.");
|
|
196
|
+
console.log(`To connect more nodes, subscribe or increase your plan:\n ${accountUrl}`);
|
|
197
|
+
console.log("\nOr free a slot by removing an existing node:");
|
|
198
|
+
for (const [i, node] of nodes.entries()) {
|
|
199
|
+
console.log(` ${i + 1}. ${node.name ?? "Node"} — ${node.online ? "online" : "offline"} — ${node.id}`);
|
|
200
|
+
}
|
|
201
|
+
const replace = await askYesNo(`Remove ${nodes[0].name ?? nodes[0].id} and enroll this node instead?`, !nodes[0].online);
|
|
202
|
+
if (replace) {
|
|
203
|
+
const removed = await fetchJson(`${controlPlaneUrl}/nodes/${encodeURIComponent(nodes[0].id)}`, {
|
|
204
|
+
method: "DELETE",
|
|
205
|
+
headers: { authorization: `Bearer ${token}` },
|
|
206
|
+
});
|
|
207
|
+
if (!removed.ok)
|
|
208
|
+
throw new Error(`Could not remove old node (${removed.status}): ${JSON.stringify(removed.data)}`);
|
|
209
|
+
enrolled = await enrollNode();
|
|
210
|
+
enroll = enrolled.data;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!enrolled.ok || !enroll?.enrollmentToken)
|
|
215
|
+
throw new Error(`Enroll failed (${enrolled.status}): ${JSON.stringify(enroll)}`);
|
|
216
|
+
// The control plane keeps node names unique per account, so a colliding name may
|
|
217
|
+
// have been auto-suffixed (e.g. "Mac-2"). Adopt the assigned name locally so the
|
|
218
|
+
// node serves the matching `bivy/<name>` label (and the UI agrees on the name).
|
|
219
|
+
const assignedName = typeof enroll?.node?.name === "string" ? enroll.node.name : undefined;
|
|
220
|
+
if (assignedName && assignedName !== identity.name) {
|
|
221
|
+
try {
|
|
222
|
+
identity.setName(assignedName);
|
|
223
|
+
console.log(`Node name adjusted to "${assignedName}" (kept unique on your account).`);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* non-fatal — routing still works via the shared bivy label */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const config = {
|
|
230
|
+
url: relayUrl,
|
|
231
|
+
controlPlaneUrl,
|
|
232
|
+
clientBaseUrl,
|
|
233
|
+
enrollmentToken: enroll.enrollmentToken,
|
|
234
|
+
};
|
|
235
|
+
const filePath = path.join(appDir, "relay.json");
|
|
236
|
+
fs.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
237
|
+
fs.chmodSync(filePath, 0o600);
|
|
238
|
+
// Optional handoff: when `bivy setup` runs us with --emit-session, drop the
|
|
239
|
+
// account session we just obtained (plus this node's id) into a 0600 file so
|
|
240
|
+
// setup can open the remote app signed into the whole account — not a
|
|
241
|
+
// node-scoped grant that would show only this node. The account bearer never
|
|
242
|
+
// goes into relay.json (the node keeps only its enrollment token); this file is
|
|
243
|
+
// read once and deleted by setup. We skip it when the caller supplied a session
|
|
244
|
+
// token only via --session-token/env with no --emit-session, i.e. non-setup use.
|
|
245
|
+
const emitSession = arg("emit-session");
|
|
246
|
+
if (emitSession) {
|
|
247
|
+
try {
|
|
248
|
+
const handoff = JSON.stringify({ session: token, nodeId: identity.nodeId });
|
|
249
|
+
fs.writeFileSync(emitSession, `${handoff}\n`, { mode: 0o600 });
|
|
250
|
+
fs.chmodSync(emitSession, 0o600);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// best effort — setup falls back to opening the plain remote app URL
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
console.log(`\n✓ Signed in and enrolled this node. Wrote ${filePath}`);
|
|
257
|
+
console.log('Run "bivy link" to pair a phone, or use "Link remote device" in the app (bivy open).');
|
|
258
|
+
}
|
|
259
|
+
main().catch((error) => {
|
|
260
|
+
console.error(error);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|