@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.
@@ -1,22 +1,26 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
- // Model-provider auth surface for the daemon (the "Models & providers" screen).
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
- // Storage is Bivy's own (credential-store.ts): set / remove / export / import all
6
- // operate directly on the encrypted vault with no Pi involvement. The one thing
7
- // that legitimately comes from Pi is the model-provider *catalog* — the list of
8
- // providers and their display names / OAuth capability — which the isolated
9
- // pi-oauth bridge supplies (Bivy has no model catalog of its own).
10
- import { createCredentialVault } from "./credential-store.js";
11
- import { listPiProviders } from "./pi-oauth.js";
12
- import { inferReferenceBackend, normalizeLabel, defaultSyncFor, DEFAULT_LABEL, } from "../credentials/records.js";
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
- * Enumerate model providers with their current auth status. The catalog + status
15
- * come from Pi's provider list (joined with our injected store); the stored
16
- * credential `kind` comes from Bivy's vault.
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 listProviders(credsDir, piDir) {
19
- const [catalog, stored] = await Promise.all([listPiProviders(credsDir, piDir), createCredentialVault(credsDir).list()]);
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 — currently named pi-auth.ts) ----------
26
- // Phase 5 renames the file to `src/credentials/api.ts`; the facade keeps the
27
- // import path stable so call sites don't churn when it moves.
28
- export { listProviders, exportProviderAuth, exportProviderAuthTombstones, importProviderAuth, setProviderApiKey, setProviderCredential, setProviderReference, removeProvider, listCredentialRecords, setProviderApiKeyLabeled, setProviderReferenceLabeled, removeProviderCredential, } from "../runtime/pi-auth.js";
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 = defaultKey(id);
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/label when it already exists.
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
- const refreshed = await refreshModelOAuth(this.credsDir, id).catch(() => undefined);
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
- const result = await createCredentialVault(credsDir).modify(providerId, async (current) => {
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, listProviders, removeProvider, setProviderApiKey, setProviderCredential } from "./runtime/pi-auth.js";
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
- const providers = await exportProviderAuth(credsDir);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.185",
3
+ "version": "0.10.1-staging.187",
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.",