@bivy/bivy 0.10.1-staging.185 → 0.10.1-staging.187
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/dist/{runtime/pi-auth.js → credentials/api.js} +27 -15
- package/dist/credentials/index.js +4 -4
- package/dist/runtime/credential-store.js +29 -4
- package/dist/runtime/credentials.js +3 -1
- package/dist/runtime/oauth/model-oauth.js +5 -2
- package/dist/runtime/provider-catalog.js +15 -0
- package/dist/server.js +5 -2
- package/package.json +1 -1
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
-
//
|
|
3
|
+
// The daemon credential API — Bivy's OWN model-provider auth surface (the
|
|
4
|
+
// "Models & providers" screen). This is the one entry every mutation flows
|
|
5
|
+
// through, so the CLI and PWA can't drift.
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// that legitimately comes from Pi
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
7
|
+
// Deliberately Pi-FREE: every function here operates on Bivy's own encrypted
|
|
8
|
+
// vault (credential-store.ts) with zero Pi involvement, so the service compiles
|
|
9
|
+
// and tests without Pi. The one thing that legitimately comes from Pi — the
|
|
10
|
+
// model-provider *catalog* (Bivy has none of its own) — is INJECTED:
|
|
11
|
+
// `joinProviderCatalog` takes the catalog as a parameter, and the thin
|
|
12
|
+
// `runtime/provider-catalog.ts` bridge supplies Pi's. See
|
|
13
|
+
// docs/credentials-service-plan.md §3.1.
|
|
14
|
+
import { createCredentialVault } from "../runtime/credential-store.js";
|
|
15
|
+
import { inferReferenceBackend, normalizeLabel, defaultSyncFor, DEFAULT_LABEL, } from "./records.js";
|
|
13
16
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Join a provider catalog with the vault's stored auth status → the "Models &
|
|
18
|
+
* providers" rows. Pure w.r.t. Pi: the catalog is passed in (see
|
|
19
|
+
* `runtime/provider-catalog.ts`, which supplies Pi's). The stored credential
|
|
20
|
+
* `kind`/`expiresAt` come from Bivy's own vault.
|
|
17
21
|
*/
|
|
18
|
-
export async function
|
|
19
|
-
const
|
|
22
|
+
export async function joinProviderCatalog(credsDir, catalog) {
|
|
23
|
+
const stored = await createCredentialVault(credsDir).list();
|
|
20
24
|
const infoById = new Map(stored.map((info) => [info.providerId, info]));
|
|
21
25
|
return catalog.map((provider) => ({
|
|
22
26
|
id: provider.id,
|
|
@@ -28,10 +32,18 @@ export async function listProviders(credsDir, piDir) {
|
|
|
28
32
|
expiresAt: infoById.get(provider.id)?.expiresAt,
|
|
29
33
|
}));
|
|
30
34
|
}
|
|
31
|
-
/** Export configured model-provider credentials, keyed by provider id. */
|
|
35
|
+
/** Export configured model-provider credentials, keyed by provider id (all local). */
|
|
32
36
|
export async function exportProviderAuth(credsDir) {
|
|
33
37
|
return createCredentialVault(credsDir).exportAll();
|
|
34
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Export only the credentials eligible for cross-node sync (`sync: "account"`) —
|
|
41
|
+
* the snapshot pushed to peers. A credential the user opted to `sync: "node"`
|
|
42
|
+
* stays local. Use this for the sync push; use `exportProviderAuth` for local reads.
|
|
43
|
+
*/
|
|
44
|
+
export async function exportSyncableProviderAuth(credsDir) {
|
|
45
|
+
return createCredentialVault(credsDir).exportSyncable();
|
|
46
|
+
}
|
|
35
47
|
/** Export provider revocations for cross-node convergence. */
|
|
36
48
|
export async function exportProviderAuthTombstones(credsDir) {
|
|
37
49
|
return createCredentialVault(credsDir).exportTombstones();
|
|
@@ -22,7 +22,7 @@ export { parsePresets, loadPresets, defaultPresetsPath, PRESETS_FILENAME, parseI
|
|
|
22
22
|
export { createCredentialVault, migrateVaultDir, preferIncomingCredential, tombstoneWins, BivyCredentialStore, } from "../runtime/credential-store.js";
|
|
23
23
|
// --- The node resolver + agent env representation ---------------------------
|
|
24
24
|
export { createCredentialStore, buildAgentCredentialEnv, apiKeyEnvVar, NodeCredentialResolver, } from "../runtime/credentials.js";
|
|
25
|
-
// --- The daemon credential API (ours —
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
export { listProviders
|
|
25
|
+
// --- The daemon credential API (ours — Pi-free) -----------------------------
|
|
26
|
+
export { exportProviderAuth, exportProviderAuthTombstones, importProviderAuth, setProviderApiKey, setProviderCredential, setProviderReference, removeProvider, listCredentialRecords, setProviderApiKeyLabeled, setProviderReferenceLabeled, removeProviderCredential, joinProviderCatalog, } from "./api.js";
|
|
27
|
+
// --- The Pi-catalog bridge (the one Pi-coupled provider listing) ------------
|
|
28
|
+
export { listProviders } from "../runtime/provider-catalog.js";
|
|
@@ -157,10 +157,21 @@ export class BivyCredentialStore {
|
|
|
157
157
|
* credential; `fn` returning undefined leaves the entry unchanged.
|
|
158
158
|
*/
|
|
159
159
|
async modify(provider, fn) {
|
|
160
|
+
return this.modifyRecord(provider, DEFAULT_LABEL, fn);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Record-addressed read-modify-write over a specific `provider:label` slot —
|
|
164
|
+
* `modify()` is the `label="default"` case. This is what makes OAuth refresh
|
|
165
|
+
* safe with multiple accounts per provider: a refresh rotates the *selected*
|
|
166
|
+
* record under its own lock, so refreshing `anthropic:work` can't clobber
|
|
167
|
+
* `anthropic:personal` (rotated refresh tokens are single-use). `fn` operates on
|
|
168
|
+
* the record's stored credential; the record's label/sync/origin are preserved.
|
|
169
|
+
*/
|
|
170
|
+
async modifyRecord(provider, label, fn) {
|
|
160
171
|
const id = providerId(provider);
|
|
161
172
|
if (!id)
|
|
162
173
|
throw new Error("Provider is required");
|
|
163
|
-
const key =
|
|
174
|
+
const key = credKey(id, label);
|
|
164
175
|
return this.enqueue(id, async () => {
|
|
165
176
|
await this.acquireLock();
|
|
166
177
|
try {
|
|
@@ -174,11 +185,11 @@ export class BivyCredentialStore {
|
|
|
174
185
|
throw new Error(`Invalid credential for "${id}"`);
|
|
175
186
|
const now = Date.now();
|
|
176
187
|
// Store the credential clean; the store-owned stamp lives on the record.
|
|
177
|
-
// Preserve the record's sync/origin
|
|
188
|
+
// Preserve the record's label/sync/origin when it already exists.
|
|
178
189
|
const clean = withoutStoreMetadata(next);
|
|
179
190
|
const record = existing
|
|
180
191
|
? { ...existing, source: { kind: "stored", cred: clean }, updatedAt: now }
|
|
181
|
-
: { ...recordFromStored(id, clean), updatedAt: now };
|
|
192
|
+
: { ...recordFromStored(id, clean), label: normalizeLabel(label), updatedAt: now };
|
|
182
193
|
document.credentials[key] = record;
|
|
183
194
|
delete document.deletedAt[key];
|
|
184
195
|
this.writeDocument(document);
|
|
@@ -267,9 +278,23 @@ export class BivyCredentialStore {
|
|
|
267
278
|
* a peer's merge can order it. (Non-default labels are not yet synced — phase 6.)
|
|
268
279
|
*/
|
|
269
280
|
async exportAll() {
|
|
281
|
+
return this.projectDefaults(() => true);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The cross-node sync snapshot: only `provider:default` credentials the user
|
|
285
|
+
* has left on the account-sync tier (`sync: "account"`). A credential opted to
|
|
286
|
+
* `sync: "node"` is kept local — this is the per-credential opt-out. Reference
|
|
287
|
+
* records carry no syncable secret and are skipped either way. (Local reads that
|
|
288
|
+
* must see every credential — e.g. `provider.auth.get` — use `exportAll`.)
|
|
289
|
+
*/
|
|
290
|
+
async exportSyncable() {
|
|
291
|
+
return this.projectDefaults((record) => record.sync === "account");
|
|
292
|
+
}
|
|
293
|
+
/** Shared projection of default-slot stored credentials to the provider-keyed wire. */
|
|
294
|
+
projectDefaults(include) {
|
|
270
295
|
const out = {};
|
|
271
296
|
for (const record of Object.values(this.readDocument().credentials)) {
|
|
272
|
-
if (record.label !== DEFAULT_LABEL)
|
|
297
|
+
if (record.label !== DEFAULT_LABEL || !include(record))
|
|
273
298
|
continue;
|
|
274
299
|
const cred = storedOf(record);
|
|
275
300
|
if (!cred)
|
|
@@ -75,7 +75,9 @@ export class NodeCredentialResolver {
|
|
|
75
75
|
let token = typeof cred.access === "string" ? cred.access : "";
|
|
76
76
|
const expires = Number(cred.expires) || 0;
|
|
77
77
|
if (!token || expires <= Date.now() + OAUTH_REFRESH_SKEW_MS) {
|
|
78
|
-
|
|
78
|
+
// Refresh the SELECTED record (its label), not just the provider default —
|
|
79
|
+
// so a second account on the same provider is left untouched.
|
|
80
|
+
const refreshed = await refreshModelOAuth(this.credsDir, id, selection.record.label).catch(() => undefined);
|
|
79
81
|
if (refreshed)
|
|
80
82
|
token = refreshed;
|
|
81
83
|
}
|
|
@@ -13,6 +13,7 @@ import http from "node:http";
|
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { createPkce } from "../../integrations/oauth.js";
|
|
15
15
|
import { createCredentialVault } from "../credential-store.js";
|
|
16
|
+
import { DEFAULT_LABEL } from "../../credentials/records.js";
|
|
16
17
|
import { getModelOAuthProvider } from "./model-oauth-providers.js";
|
|
17
18
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
19
|
// --- HTTP helpers ------------------------------------------------------------
|
|
@@ -342,11 +343,13 @@ async function refreshTokens(provider, current) {
|
|
|
342
343
|
* token — the single-flight guarantee. Returns undefined if the provider has no
|
|
343
344
|
* stored OAuth credential or isn't natively supported.
|
|
344
345
|
*/
|
|
345
|
-
export async function refreshModelOAuth(credsDir, providerId) {
|
|
346
|
+
export async function refreshModelOAuth(credsDir, providerId, label = DEFAULT_LABEL) {
|
|
346
347
|
const provider = getModelOAuthProvider(providerId);
|
|
347
348
|
if (!provider)
|
|
348
349
|
return undefined;
|
|
349
|
-
|
|
350
|
+
// Record-addressed: refresh the SELECTED account's record, so a second account
|
|
351
|
+
// on the same provider (a different label) is never touched by this rotation.
|
|
352
|
+
const result = await createCredentialVault(credsDir).modifyRecord(providerId, label, async (current) => {
|
|
350
353
|
if (!current || current.type !== "oauth")
|
|
351
354
|
return current;
|
|
352
355
|
// Someone else already refreshed while we waited for the lock — use theirs.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// The Pi-catalog bridge: the single place the credential API's provider listing
|
|
4
|
+
// is joined with Pi's model-provider catalog.
|
|
5
|
+
//
|
|
6
|
+
// The credential API (src/credentials/api.ts) is deliberately Pi-free and takes
|
|
7
|
+
// the catalog as data (`joinProviderCatalog`). This thin module is the injection
|
|
8
|
+
// point — it fetches Pi's catalog and hands it in. Swapping Pi for a Bivy-owned
|
|
9
|
+
// catalog later is a change here and nowhere else.
|
|
10
|
+
import { listPiProviders } from "./pi-oauth.js";
|
|
11
|
+
import { joinProviderCatalog } from "../credentials/api.js";
|
|
12
|
+
/** Enumerate model providers with their current auth status (catalog from Pi). */
|
|
13
|
+
export async function listProviders(credsDir, piDir) {
|
|
14
|
+
return joinProviderCatalog(credsDir, await listPiProviders(credsDir, piDir));
|
|
15
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -35,7 +35,8 @@ import { decideOAuthLoginSweep } from "./runtime/oauth/oauth-login-sweep.js";
|
|
|
35
35
|
import { listCodexSessions, loadCodexTranscript } from "./runtime/codex-sessions.js";
|
|
36
36
|
import { dedupeSessionSummaries } from "./session-identity.js";
|
|
37
37
|
import { deriveSessionState } from "./session/session-state.js";
|
|
38
|
-
import { exportProviderAuth, exportProviderAuthTombstones, importProviderAuth,
|
|
38
|
+
import { exportProviderAuth, exportSyncableProviderAuth, exportProviderAuthTombstones, importProviderAuth, removeProvider, setProviderApiKey, setProviderCredential } from "./credentials/api.js";
|
|
39
|
+
import { listProviders } from "./runtime/provider-catalog.js";
|
|
39
40
|
import { loadLocalModels, upsertLocalProvider, removeLocalProviderEntry, listLocalProviderSummaries, exportLocalModels, importLocalModels, toPiModelsConfig, normalizeProviderId, } from "./runtime/local-model-store.js";
|
|
40
41
|
import { execEphemeralRequest } from "./ephemeral-exec.js";
|
|
41
42
|
import { ApprovalManager } from "./approval.js";
|
|
@@ -3364,7 +3365,9 @@ async function pushModelAuthToControlPlane(rotateKey = false) {
|
|
|
3364
3365
|
// versa.
|
|
3365
3366
|
await pushProviderSummaryToControlPlane();
|
|
3366
3367
|
try {
|
|
3367
|
-
|
|
3368
|
+
// Only push credentials on the account-sync tier; a `sync: "node"` credential
|
|
3369
|
+
// stays local (per-credential opt-out). Tombstones still converge for all.
|
|
3370
|
+
const providers = await exportSyncableProviderAuth(credsDir);
|
|
3368
3371
|
const deletedAt = await exportProviderAuthTombstones(credsDir);
|
|
3369
3372
|
const localModels = exportLocalModels(localModelsDir);
|
|
3370
3373
|
const previousKey = readLocalModelAuthVaultKey();
|
package/package.json
CHANGED