@bivy/bivy 0.3.0-staging.43 → 0.3.0-staging.45

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 });
@@ -3597,6 +3600,14 @@ async function syncModelAuthFromControlPlane() {
3597
3600
  vaultKeyB64 = pairingStore.unwrapFromNodePublicKey(data.wrappedKey.wrappedByPublicKey, data.wrappedKey.wrappedKey);
3598
3601
  writeLocalModelAuthVaultKey(vaultKeyB64);
3599
3602
  }
3603
+ // Node-less inheritance: a lone HOSTED ephemeral (no peer to wrap the key)
3604
+ // adopts the vault key the control plane escrowed for this hosted account, so
3605
+ // it can decrypt the synced vault (incl. subscription OAuth) on cold start. The
3606
+ // control plane serves `hostedKey` only for hosted-provisioning accounts.
3607
+ if (!vaultKeyB64 && data.hostedKey && Buffer.from(data.hostedKey, "base64").length === 32) {
3608
+ vaultKeyB64 = data.hostedKey;
3609
+ writeLocalModelAuthVaultKey(vaultKeyB64);
3610
+ }
3600
3611
  if (data.vault?.ciphertext && vaultKeyB64) {
3601
3612
  const { providers, localModels } = decryptModelAuthEnvelope(data.vault.ciphertext, vaultKeyB64);
3602
3613
  await importProviderAuth(credsDir, providers);
@@ -3663,6 +3674,13 @@ async function pushModelAuthToControlPlane() {
3663
3674
  method: "PUT",
3664
3675
  body: JSON.stringify({ targetNodeId: identity.nodeId, wrappedByPublicKey: pairingStore.nodePublicKeyB64(), wrappedKey: pairingStore.wrapForNodePublicKey(pairingStore.nodePublicKeyB64(), vaultKeyB64) }),
3665
3676
  });
3677
+ // Node-less inheritance: a HOSTED node also escrows the vault key to the control
3678
+ // plane (sealed at rest, hosted-only) so the account's NEXT hosted ephemeral —
3679
+ // possibly the only node — can decrypt this vault without a peer to wrap the key.
3680
+ // Gated to hosted nodes; the CP double-checks the account is hosted. Best effort.
3681
+ if (process.env.BIVY_GITHUB_HOSTED_TASKS) {
3682
+ await modelAuthFetch("/node/model-auth-key/hosted-escrow", { method: "PUT", body: JSON.stringify({ vaultKeyB64 }) }).catch(() => { });
3683
+ }
3666
3684
  lastPushedModelAuthCiphertext = ciphertext;
3667
3685
  }
3668
3686
  catch (error) {
@@ -4797,6 +4815,19 @@ async function runWorkItem(item, report) {
4797
4815
  if (item.url)
4798
4816
  issue.url = item.url;
4799
4817
  }
4818
+ // Case B: the control plane asked us to CONTINUE an existing session for this
4819
+ // issue (an inbound comment/issue on a thread that already has one). If that
4820
+ // session isn't live on this node — e.g. its ephemeral machine was torn down —
4821
+ // best-effort restore its snapshot first so its transcript + branch state are
4822
+ // rebuilt and the work continues the thread instead of starting cold. On
4823
+ // failure we fall through to the normal idempotent, remote-branch-adopting
4824
+ // pickup, so this can only help, never break.
4825
+ const issueSource = `issue:${parsed.owner}/${parsed.repo}#${item.issueNumber}`;
4826
+ if (item.targetKind === "existing_session" && item.targetSessionId && !findIssueSession(issueSource)) {
4827
+ await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => {
4828
+ console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message);
4829
+ });
4830
+ }
4800
4831
  await runIssueTask(cfg, issue, { runtimeId: item.runtimeId, model: item.model, onEvidence: report });
4801
4832
  return;
4802
4833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.43",
3
+ "version": "0.3.0-staging.45",
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.",