@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
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