@bivy/bivy 0.16.25-staging.17 → 0.16.25-staging.19

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.
@@ -95,7 +95,8 @@ export function createCredentialCommands(deps) {
95
95
  async "credential.remove"(msg, ctx) {
96
96
  try {
97
97
  await removeProviderCredential(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""));
98
- await deps.pushModelAuthToControlPlane();
98
+ // Deleting a granted credential must also remove its escrowed cloud copy.
99
+ await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: true });
99
100
  await deps.refreshSessionAfterAuth();
100
101
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
101
102
  deps.broadcast({ type: "providers.list", providers: await deps.listProvidersUnified() });
@@ -111,7 +112,8 @@ export function createCredentialCommands(deps) {
111
112
  try {
112
113
  const sync = msg.sync === "node" ? "node" : "account";
113
114
  await setCredentialSync(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), sync);
114
- await deps.pushModelAuthToControlPlane();
115
+ // Demoting to machine-only revokes any custody grant; let it clear the escrow.
116
+ await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: sync === "node" });
115
117
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
116
118
  ctx.reply({ type: "credential.sync.set.ok", requestId: msg.requestId });
117
119
  }
@@ -127,7 +129,10 @@ export function createCredentialCommands(deps) {
127
129
  const previous = (await listCredentialRecords(credsDir)).find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
128
130
  try {
129
131
  await setCredentialUnattended(credsDir, provider, label, msg.unattended === true);
130
- await deps.pushModelAuthToControlPlane();
132
+ // Fail loud: the grant reply must reflect whether the encrypted cloud
133
+ // copy actually reached the control plane. An explicit disable is the
134
+ // one caller allowed to publish an empty escrow snapshot.
135
+ await deps.pushModelAuthToControlPlane({ throwOnFailure: true, allowEmptyHostedEscrow: msg.unattended !== true });
131
136
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
132
137
  ctx.reply({ type: "credential.unattended.set.ok", requestId: msg.requestId });
133
138
  }
@@ -87,17 +87,22 @@ function sameRecordContent(a, b) {
87
87
  const strip = ({ updatedAt: _drop, ...rest }) => rest;
88
88
  return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
89
89
  }
90
+ const stable = (value) => {
91
+ if (Array.isArray(value))
92
+ return value.map(stable);
93
+ if (!value || typeof value !== "object")
94
+ return value;
95
+ return Object.fromEntries(Object.entries(value)
96
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
97
+ .map(([key, child]) => [key, stable(child)]));
98
+ };
90
99
  function canonicalCredential(credential) {
91
100
  const { updatedAt: _drop, ...content } = credential;
92
- const stable = (value) => {
93
- if (Array.isArray(value))
94
- return value.map(stable);
95
- if (!value || typeof value !== "object")
96
- return value;
97
- return Object.fromEntries(Object.entries(value)
98
- .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
99
- .map(([key, child]) => [key, stable(child)]));
100
- };
101
+ return JSON.stringify(stable(content));
102
+ }
103
+ /** Stable record content sans the store-owned stamp — the final merge tie-break. */
104
+ function canonicalRecord(record) {
105
+ const { updatedAt: _drop, ...content } = record;
101
106
  return JSON.stringify(stable(content));
102
107
  }
103
108
  /**
@@ -132,6 +137,19 @@ export function preferIncomingRecord(local, incoming) {
132
137
  const incomingOauth = oauthOf(incoming);
133
138
  if (!localOauth || !incomingOauth)
134
139
  return true;
140
+ // Identical token content means this is a metadata-only change (an
141
+ // unattended-runs grant/revoke, a sync-tier move). Token freshness cannot
142
+ // order those, so the newer record write wins — without this, a grant made
143
+ // on one machine never reaches its peers, and a peer's next hosted-escrow
144
+ // push would read as "nothing granted". Equal stamps fall back to canonical
145
+ // record content so both merge directions still converge.
146
+ if (canonicalCredential(localOauth) === canonicalCredential(incomingOauth)) {
147
+ const localAt = Number(local.updatedAt) || 0;
148
+ const incomingAt = Number(incoming.updatedAt) || 0;
149
+ if (incomingAt !== localAt)
150
+ return incomingAt > localAt;
151
+ return canonicalRecord(incoming) > canonicalRecord(local);
152
+ }
135
153
  return preferIncomingOAuthCredential(localOauth, incomingOauth);
136
154
  }
137
155
  /** A tombstone wins only when it is newer than the record it would remove. */
@@ -251,7 +269,14 @@ export function mergeDocuments(local, incomingCredentials, incomingDeletedAt = {
251
269
  continue;
252
270
  if (!(key in credentials))
253
271
  imported += 1;
254
- credentials[key] = incoming;
272
+ // An incoming record with no opinion on unattended custody (undefined — the
273
+ // v2 wire and plaintext ingest never carry the flag) must not strip an
274
+ // explicit local grant/revoke: agent-side token refreshes flow through this
275
+ // merge, and dropping the flag would make the node's next escrow push
276
+ // silently revoke the encrypted cloud copy the user still relies on.
277
+ credentials[key] = localRecord && incoming.unattended === undefined && localRecord.unattended !== undefined
278
+ ? { ...incoming, unattended: localRecord.unattended }
279
+ : incoming;
255
280
  const incomingUpdatedAt = Number(incoming.updatedAt);
256
281
  if (!Number.isFinite(deletedAt[key]) || incomingUpdatedAt > deletedAt[key])
257
282
  delete deletedAt[key];
@@ -554,6 +554,23 @@ export class BivyCredentialStore {
554
554
  // engine (merge-never-destroy, freshest-OAuth-wins, rotation-safe,
555
555
  // tombstone-newer-wins — see document.ts).
556
556
  const incoming = migrateToV3({ v: 2, providers: snapshot, deletedAt });
557
+ // The v2 wire carries no record metadata, so migrateToV3 synthesizes
558
+ // defaults (sync: "account", origin: "bivy", no unattended flag). Those
559
+ // defaults must not clobber local intent — a machine-only sync tier or an
560
+ // unattended-runs custody grant — when only the token content is newer
561
+ // (e.g. an agent's own TUI refreshed the OAuth token set). Inherit the
562
+ // existing record's metadata; the merge engine still decides freshness.
563
+ for (const [key, record] of Object.entries(incoming.credentials)) {
564
+ const existing = document.credentials[key];
565
+ if (!existing)
566
+ continue;
567
+ incoming.credentials[key] = {
568
+ ...record,
569
+ sync: existing.sync,
570
+ origin: existing.origin,
571
+ ...(existing.unattended !== undefined ? { unattended: existing.unattended } : {}),
572
+ };
573
+ }
557
574
  const result = mergeDocuments(document, incoming.credentials, incoming.deletedAt);
558
575
  if (result.changed)
559
576
  this.writeDocument(result.document);
package/dist/server.js CHANGED
@@ -1966,7 +1966,7 @@ const RELAY_COMMANDS = {
1966
1966
  credsDir,
1967
1967
  sendEvent: (event) => relay?.sendEvent(event),
1968
1968
  broadcast,
1969
- pushModelAuthToControlPlane,
1969
+ pushModelAuthToControlPlane: (opts) => pushModelAuthToControlPlane(false, opts?.throwOnFailure === true, opts?.allowEmptyHostedEscrow === true),
1970
1970
  refreshSessionAfterAuth,
1971
1971
  listProvidersUnified,
1972
1972
  }),
@@ -2581,7 +2581,8 @@ const RELAY_COMMANDS = {
2581
2581
  }
2582
2582
  }
2583
2583
  await removeProvider(credsDir, id);
2584
- await pushModelAuthToControlPlane();
2584
+ // Deleting a granted credential must also remove its escrowed cloud copy.
2585
+ await pushModelAuthToControlPlane(false, false, true);
2585
2586
  await refreshSessionAfterAuth();
2586
2587
  broadcast({ type: "provider.oauth.reset", provider: id, ok: true, providers: await listProvidersUnified() });
2587
2588
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
@@ -2593,7 +2594,8 @@ const RELAY_COMMANDS = {
2593
2594
  async "provider.remove"(msg) {
2594
2595
  try {
2595
2596
  await removeProvider(credsDir, String(msg.provider ?? msg.id ?? ""));
2596
- await pushModelAuthToControlPlane();
2597
+ // Deleting a granted credential must also remove its escrowed cloud copy.
2598
+ await pushModelAuthToControlPlane(false, false, true);
2597
2599
  await refreshSessionAfterAuth();
2598
2600
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
2599
2601
  }
@@ -3388,12 +3390,19 @@ async function processModelAuthKeyRequests(requests) {
3388
3390
  });
3389
3391
  }
3390
3392
  }
3391
- async function pushHostedModelAuthToControlPlane() {
3393
+ async function pushHostedModelAuthToControlPlane(allowEmpty = false) {
3392
3394
  const [records, revision] = await Promise.all([exportUnattendedRecords(credsDir), unattendedCredentialRevision(credsDir)]);
3393
3395
  // A setup guest must not establish an empty snapshot when the credential is
3394
3396
  // first saved (before the explicit grant command follows). Otherwise its
3395
3397
  // one allowed initial write would be consumed by an unusable vault.
3396
- if (isHostedCustodyNode() && Object.keys(records).length === 0)
3398
+ //
3399
+ // And an IMPLICIT empty push must never revoke the escrow: an empty snapshot
3400
+ // is how "disable unattended runs" removes the cloud copy, but every vault
3401
+ // change funnels through here — so a node whose copy simply lacks the grant
3402
+ // (not yet synced, or granted elsewhere) would otherwise wipe the encrypted
3403
+ // cloud copy the user's other machines just published. Only an explicit
3404
+ // local revoke/delete (allowEmpty) may publish an empty snapshot.
3405
+ if (Object.keys(records).length === 0 && (!allowEmpty || isHostedCustodyNode()))
3397
3406
  return;
3398
3407
  if (revision === lastPushedHostedModelAuthRevision)
3399
3408
  return;
@@ -3425,7 +3434,7 @@ async function pushHostedModelAuthToControlPlane() {
3425
3434
  throw new Error(`hosted model-auth push failed (${response?.status ?? "offline"})`);
3426
3435
  }
3427
3436
  }
3428
- async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false) {
3437
+ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false, allowEmptyHostedEscrow = false) {
3429
3438
  if (!sessionAdvertiseTarget)
3430
3439
  return;
3431
3440
  // Piggyback the (plaintext, non-secret) provider status summary on every
@@ -3460,7 +3469,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
3460
3469
  writeLocalModelAuthVaultKey(vaultKeyB64);
3461
3470
  const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt);
3462
3471
  if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext) {
3463
- await pushHostedModelAuthToControlPlane();
3472
+ await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
3464
3473
  return;
3465
3474
  }
3466
3475
  const push = await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext, rotated: rotateKey }) });
@@ -3481,7 +3490,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
3481
3490
  // Publish a DIFFERENT ciphertext under a DIFFERENT key containing only
3482
3491
  // records with `unattended:true`. Escrowing this key cannot decrypt the E2E
3483
3492
  // account vault, which is the critical custody separation.
3484
- await pushHostedModelAuthToControlPlane();
3493
+ await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
3485
3494
  }
3486
3495
  catch (error) {
3487
3496
  console.warn("[auth-sync] could not push model auth:", error.message);
@@ -9690,7 +9699,8 @@ app.post("/api/auth/credentials", async (req, res, next) => {
9690
9699
  app.delete("/api/auth/credentials/:provider/:label", async (req, res, next) => {
9691
9700
  try {
9692
9701
  await removeProviderCredential(credsDir, String(req.params.provider), String(req.params.label));
9693
- await pushModelAuthToControlPlane();
9702
+ // Deleting a granted credential must also remove its escrowed cloud copy.
9703
+ await pushModelAuthToControlPlane(false, false, true);
9694
9704
  await refreshSessionAfterAuth();
9695
9705
  res.json({ ok: true, records: await listCredentialRecords(credsDir), providers: await listProvidersUnified() });
9696
9706
  }
@@ -9702,7 +9712,8 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
9702
9712
  try {
9703
9713
  const sync = req.body?.sync === "node" ? "node" : "account";
9704
9714
  await setCredentialSync(credsDir, String(req.params.provider), String(req.params.label), sync);
9705
- await pushModelAuthToControlPlane();
9715
+ // Demoting to machine-only revokes any custody grant; let that clear the escrow.
9716
+ await pushModelAuthToControlPlane(false, false, sync === "node");
9706
9717
  res.json({ ok: true, records: await listCredentialRecords(credsDir) });
9707
9718
  }
9708
9719
  catch (error) {
@@ -9710,12 +9721,21 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
9710
9721
  }
9711
9722
  });
9712
9723
  app.post("/api/auth/credentials/:provider/:label/unattended", async (req, res, next) => {
9713
- try {
9714
- await setCredentialUnattended(credsDir, String(req.params.provider), String(req.params.label), req.body?.unattended === true);
9715
- await pushModelAuthToControlPlane();
9724
+ const provider = String(req.params.provider);
9725
+ const label = String(req.params.label);
9726
+ const enable = req.body?.unattended === true;
9727
+ const previous = (await listCredentialRecords(credsDir).catch(() => []))
9728
+ .find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
9729
+ try {
9730
+ await setCredentialUnattended(credsDir, provider, label, enable);
9731
+ // Fail loud (mirrors the relay command): the UI must never claim an
9732
+ // encrypted cloud copy exists when custody publication failed. An explicit
9733
+ // disable is the one caller allowed to publish an empty escrow snapshot.
9734
+ await pushModelAuthToControlPlane(false, true, !enable);
9716
9735
  res.json({ ok: true, records: await listCredentialRecords(credsDir) });
9717
9736
  }
9718
9737
  catch (error) {
9738
+ await setCredentialUnattended(credsDir, provider, label, previous).catch(() => { });
9719
9739
  next(error);
9720
9740
  }
9721
9741
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.25-staging.17",
3
+ "version": "0.16.25-staging.19",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",