@bivy/bivy 0.3.0-staging.42 → 0.3.0-staging.44

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.
@@ -6,6 +6,26 @@ import { randomUUID } from "node:crypto";
6
6
  import { generatePairingKeypair, generateRoomKey, generatePairSecret, deriveWrapKey, verifyPairingProof, wrapRoomKey, } from "./pairing-crypto.js";
7
7
  import { seal, open } from "./e2e.js";
8
8
  const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
9
+ const ROOM_KEY_BYTES = 32;
10
+ /**
11
+ * Validate + canonicalize a pre-shared room-key seed. Returns the canonical
12
+ * base64 of a well-formed 32-byte key, or undefined for anything malformed
13
+ * (missing, wrong length, non-base64) so the caller falls back to a fresh key
14
+ * rather than adopting a bad seed that would make every device unable to decrypt.
15
+ */
16
+ function normalizeSeedRoomKey(seedRoomKeyB64) {
17
+ if (!seedRoomKeyB64)
18
+ return undefined;
19
+ try {
20
+ const buf = Buffer.from(seedRoomKeyB64, "base64");
21
+ if (buf.length !== ROOM_KEY_BYTES)
22
+ return undefined;
23
+ return buf.toString("base64");
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
9
29
  export class PairingStore {
10
30
  filePath;
11
31
  data;
@@ -18,8 +38,17 @@ export class PairingStore {
18
38
  * Load (or create) the pairing state. A fresh state gets a randomly generated
19
39
  * room key; devices receive it (and every later rotation) over the X25519
20
40
  * pairing handshake, so there is no static seed to carry forward.
41
+ *
42
+ * `seedRoomKeyB64` (base64 32-byte key) is the ONE exception: an ephemeral
43
+ * REBUILD reuses a torn-down session's node id and must decrypt a snapshot
44
+ * sealed under that session's original room key. The launching device/CP bakes
45
+ * that key into relay.json (`e2eKey`) and the daemon passes it here, so a
46
+ * brand-new pairing state adopts it instead of minting a fresh one. It is used
47
+ * ONLY when there is no existing pairing.json — an already-paired node never
48
+ * has its room key overwritten. An absent/malformed seed falls back to a fresh
49
+ * random key (the ordinary first-run path).
21
50
  */
22
- static load(appDir) {
51
+ static load(appDir, seedRoomKeyB64) {
23
52
  const filePath = path.join(appDir, "pairing.json");
24
53
  let raw;
25
54
  try {
@@ -55,9 +84,12 @@ export class PairingStore {
55
84
  devices: Array.isArray(parsed.devices) ? parsed.devices : [],
56
85
  });
57
86
  }
87
+ // No pairing.json yet — first run. Adopt a valid pre-shared seed (ephemeral
88
+ // rebuild) if one was supplied; otherwise mint a fresh random room key.
89
+ const seededRoomKey = normalizeSeedRoomKey(seedRoomKeyB64);
58
90
  const data = {
59
91
  nodeKeypair: generatePairingKeypair(),
60
- roomKeyB64: generateRoomKey().toString("base64"),
92
+ roomKeyB64: seededRoomKey ?? generateRoomKey().toString("base64"),
61
93
  devices: [],
62
94
  };
63
95
  const store = new PairingStore(filePath, data);
@@ -26,7 +26,8 @@ export function loadRelayConfig(appDir) {
26
26
  return null;
27
27
  const controlPlaneUrl = process.env.BIVY_CONTROL_PLANE_URL ?? raw.controlPlaneUrl;
28
28
  const clientBaseUrl = process.env.BIVY_CLIENT_BASE_URL ?? raw.clientBaseUrl ?? controlPlaneUrl;
29
- return { url, enrollmentToken, controlPlaneUrl, clientBaseUrl };
29
+ const e2eKey = process.env.BIVY_ROOM_KEY ?? raw.e2eKey;
30
+ return { url, enrollmentToken, controlPlaneUrl, clientBaseUrl, e2eKey };
30
31
  }
31
32
  // Application-level keepalive. On flaky/mobile links the node→relay TCP socket
32
33
  // can half-open silently: the node still thinks it is connected and buffers
package/dist/server.js CHANGED
@@ -740,8 +740,11 @@ function getRuntime(requested, sandbox) {
740
740
  }
741
741
  // Holds the node's X25519 identity key, the rotating room key, and the linked
742
742
  // device registry. The room key is generated fresh on first load and delivered
743
- // to devices via the X25519 pairing handshake — there is no static seed.
744
- const pairingStore = PairingStore.load(appDir);
743
+ // to devices via the X25519 pairing handshake — there is no static seed, EXCEPT
744
+ // on an ephemeral rebuild: relay.json carries the reused session's room key
745
+ // (`e2eKey`) so a brand-new pairing state adopts it and can decrypt the restored
746
+ // snapshot. Only seeds a first-run node; an existing pairing.json always wins.
747
+ const pairingStore = PairingStore.load(appDir, loadRelayConfig(appDir)?.e2eKey);
745
748
  function syncPairingMetadata() {
746
749
  for (const device of pairingStore.listDevices()) {
747
750
  metadata.upsertDevice({ id: device.id, label: device.label, publicKeyB64: device.publicKeyB64, firstSeenAt: device.createdAt, lastSeenAt: device.lastSeenAt ?? undefined });
@@ -3420,7 +3423,14 @@ function startRelayIfConfigured() {
3420
3423
  terminals.dropClient(RELAY_CLIENT_ID);
3421
3424
  relay = new RelayConnector(config, (msg) => void handleRelayMessage(msg), {
3422
3425
  pairing: pairingStore,
3423
- onWorkAvailable: () => controlPlanePoller?.poke(),
3426
+ onWorkAvailable: () => {
3427
+ controlPlanePoller?.poke();
3428
+ // A relay wake also means "something changed for this account" — kick a
3429
+ // (debounced) model-auth sync so a peer node answers any pending vault-key
3430
+ // request from a freshly-launched ephemeral runner without waiting for its
3431
+ // 30s poll. Cheap and idempotent; coalesced to at most one sync per burst.
3432
+ triggerModelAuthSyncSoon();
3433
+ },
3424
3434
  });
3425
3435
  relay.start();
3426
3436
  if (config.controlPlaneUrl && config.enrollmentToken) {
@@ -3520,6 +3530,62 @@ async function modelAuthFetch(pathname, init = {}) {
3520
3530
  headers.set("content-type", "application/json");
3521
3531
  return fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}${pathname}`, { ...init, headers });
3522
3532
  }
3533
+ // Debounced model-auth sync trigger. A relay wake (`work.available`) fires this
3534
+ // so peers answer a new node's vault-key request promptly (event-driven) instead
3535
+ // of on the steady 30s poll. Coalesces a burst of wakes into one sync.
3536
+ let modelAuthSyncSoonTimer;
3537
+ function triggerModelAuthSyncSoon() {
3538
+ if (modelAuthSyncSoonTimer)
3539
+ return;
3540
+ modelAuthSyncSoonTimer = setTimeout(() => {
3541
+ modelAuthSyncSoonTimer = undefined;
3542
+ void syncModelAuthFromControlPlane();
3543
+ }, 250);
3544
+ modelAuthSyncSoonTimer.unref?.();
3545
+ }
3546
+ // Cold-start fast-retry. A freshly-launched node (typically a short-lived
3547
+ // ephemeral runner) that holds vault ciphertext but no wrapped key yet must wait
3548
+ // for a peer node to answer its key request. The 30s steady poll is too slow for
3549
+ // a machine that may only live a minute, so once we've requested the key we
3550
+ // re-sync on a brief bounded cadence until the wrapped key arrives (a peer
3551
+ // answered) or we give up and let the steady poll continue. Peer-only by design:
3552
+ // the key is always answered by another node over the E2E wrap — nothing ever
3553
+ // transits the device or control plane in the clear.
3554
+ const MODEL_AUTH_COLDSTART_INTERVAL_MS = 2_000;
3555
+ const MODEL_AUTH_COLDSTART_MAX_ATTEMPTS = 30; // ~60s bounded
3556
+ let modelAuthColdStartActive = false;
3557
+ let modelAuthColdStartAttempts = 0;
3558
+ let modelAuthColdStartTimer;
3559
+ function stopModelAuthColdStart() {
3560
+ modelAuthColdStartActive = false;
3561
+ modelAuthColdStartAttempts = 0;
3562
+ if (modelAuthColdStartTimer) {
3563
+ clearTimeout(modelAuthColdStartTimer);
3564
+ modelAuthColdStartTimer = undefined;
3565
+ }
3566
+ }
3567
+ function ensureModelAuthColdStart() {
3568
+ if (modelAuthColdStartActive)
3569
+ return; // already retrying
3570
+ modelAuthColdStartActive = true;
3571
+ modelAuthColdStartAttempts = 0;
3572
+ const tick = () => {
3573
+ modelAuthColdStartTimer = undefined;
3574
+ if (!modelAuthColdStartActive)
3575
+ return;
3576
+ // A concurrent sync may have already landed the key — stop as soon as we have it.
3577
+ if (readLocalModelAuthVaultKey() || modelAuthColdStartAttempts >= MODEL_AUTH_COLDSTART_MAX_ATTEMPTS) {
3578
+ stopModelAuthColdStart();
3579
+ return;
3580
+ }
3581
+ modelAuthColdStartAttempts++;
3582
+ void syncModelAuthFromControlPlane();
3583
+ modelAuthColdStartTimer = setTimeout(tick, MODEL_AUTH_COLDSTART_INTERVAL_MS);
3584
+ modelAuthColdStartTimer.unref?.();
3585
+ };
3586
+ modelAuthColdStartTimer = setTimeout(tick, MODEL_AUTH_COLDSTART_INTERVAL_MS);
3587
+ modelAuthColdStartTimer.unref?.();
3588
+ }
3523
3589
  async function syncModelAuthFromControlPlane() {
3524
3590
  if (!sessionAdvertiseTarget)
3525
3591
  return;
@@ -3543,10 +3609,17 @@ async function syncModelAuthFromControlPlane() {
3543
3609
  await writePiModelsProjection();
3544
3610
  await broadcastLocalModels();
3545
3611
  lastPushedModelAuthCiphertext = data.vault.ciphertext;
3612
+ // Got the key and imported the vault (incl. any subscription-OAuth logins) —
3613
+ // the cold-start race is over.
3614
+ stopModelAuthColdStart();
3546
3615
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
3547
3616
  }
3548
3617
  else if (data.vault?.ciphertext && !vaultKeyB64) {
3549
3618
  await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
3619
+ // No peer has wrapped our key yet. Fast-retry (bounded) so a short-lived
3620
+ // ephemeral runner picks up the key within seconds of a peer answering,
3621
+ // rather than waiting for its next 30s poll.
3622
+ ensureModelAuthColdStart();
3550
3623
  }
3551
3624
  else if (Object.keys(await exportProviderAuth(credsDir)).length > 0 ||
3552
3625
  Object.keys(exportLocalModels(localModelsDir)).length > 0) {
@@ -4727,6 +4800,19 @@ async function runWorkItem(item, report) {
4727
4800
  if (item.url)
4728
4801
  issue.url = item.url;
4729
4802
  }
4803
+ // Case B: the control plane asked us to CONTINUE an existing session for this
4804
+ // issue (an inbound comment/issue on a thread that already has one). If that
4805
+ // session isn't live on this node — e.g. its ephemeral machine was torn down —
4806
+ // best-effort restore its snapshot first so its transcript + branch state are
4807
+ // rebuilt and the work continues the thread instead of starting cold. On
4808
+ // failure we fall through to the normal idempotent, remote-branch-adopting
4809
+ // pickup, so this can only help, never break.
4810
+ const issueSource = `issue:${parsed.owner}/${parsed.repo}#${item.issueNumber}`;
4811
+ if (item.targetKind === "existing_session" && item.targetSessionId && !findIssueSession(issueSource)) {
4812
+ await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => {
4813
+ console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message);
4814
+ });
4815
+ }
4730
4816
  await runIssueTask(cfg, issue, { runtimeId: item.runtimeId, model: item.model, onEvidence: report });
4731
4817
  return;
4732
4818
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.42",
3
+ "version": "0.3.0-staging.44",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",