@bivy/bivy 0.10.1-staging.329 → 0.10.1-staging.331

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.
@@ -950,7 +950,7 @@ class ClaudeSession {
950
950
  const provider = this.runtimeOptions.credentialProvider?.trim() || "anthropic";
951
951
  let cred;
952
952
  try {
953
- cred = await store.getCredential(provider);
953
+ cred = await store.getCredential(provider, { workspace: this.cwd });
954
954
  }
955
955
  catch {
956
956
  return {};
@@ -6,10 +6,9 @@
6
6
  //
7
7
  // Deliberately Pi-FREE: every function here operates on Bivy's own encrypted
8
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
9
+ // and tests without Pi. Provider catalog data is likewise injected:
10
+ // `joinProviderCatalog` receives Bivy's baseline overlaid with optional live
11
+ // runtime metadata by the thin `runtime/provider-catalog.ts` bridge. See
13
12
  // docs/credentials-service-plan.md §3.1.
14
13
  import { createCredentialVault } from "./store.js";
15
14
  import { inferReferenceBackend, normalizeLabel, defaultSyncFor, DEFAULT_LABEL, } from "./records.js";
@@ -27,9 +26,9 @@ export async function joinProviderCatalog(credsDir, catalog) {
27
26
  id: provider.id,
28
27
  name: provider.name,
29
28
  oauth: provider.oauth,
30
- configured: provider.configured,
29
+ configured: provider.configured || infoById.has(provider.id),
31
30
  kind: infoById.get(provider.id)?.type,
32
- source: provider.source,
31
+ source: provider.source ?? (infoById.has(provider.id) ? "stored" : undefined),
33
32
  expiresAt: infoById.get(provider.id)?.expiresAt,
34
33
  }));
35
34
  }
@@ -56,16 +55,17 @@ export async function exportSyncableRecords(credsDir) {
56
55
  /**
57
56
  * Browser-safe account API-key projection. Used to converge a node-less PWA's
58
57
  * E2E account vault with an enrolled node. OAuth refresh tokens, references,
59
- * non-default labels, and node-local records are deliberately excluded.
58
+ * and node-local records are deliberately excluded; labeled API-key accounts
59
+ * are included as distinct vault items.
60
60
  */
61
61
  export async function exportAccountApiKeys(credsDir) {
62
62
  return (await createCredentialVault(credsDir).listRecords())
63
63
  .flatMap((record) => {
64
- if (record.label !== DEFAULT_LABEL || record.sync !== "account" || record.source.kind !== "stored")
64
+ if (record.sync !== "account" || record.source.kind !== "stored")
65
65
  return [];
66
66
  const credential = record.source.cred;
67
67
  return credential.type === "api_key" && typeof credential.key === "string"
68
- ? [{ provider: record.provider, key: credential.key, updatedAt: record.updatedAt }]
68
+ ? [{ provider: record.provider, label: record.label, key: credential.key, updatedAt: record.updatedAt }]
69
69
  : [];
70
70
  });
71
71
  }
@@ -88,6 +88,18 @@ export async function importCredentialRecords(credsDir, records, deletedAt = {})
88
88
  }
89
89
  return createCredentialVault(credsDir).importRecords(list, tombstones);
90
90
  }
91
+ /** Apply a hosted custody snapshot as an authoritative filtered set. */
92
+ export async function reconcileHostedCredentialRecords(credsDir, records, previous) {
93
+ const current = Object.values(records ?? {}).filter((record) => Boolean(record) && typeof record === "object" && typeof record.provider === "string" && typeof record.label === "string");
94
+ const currentIds = new Set(current.map((record) => `${record.provider}\u0000${record.label}`));
95
+ const store = createCredentialVault(credsDir);
96
+ for (const prior of previous) {
97
+ if (!currentIds.has(`${prior.provider}\u0000${prior.label}`))
98
+ await store.deleteRecord(prior.provider, prior.label);
99
+ }
100
+ await importCredentialRecords(credsDir, records);
101
+ return current.map((record) => ({ provider: record.provider, label: record.label }));
102
+ }
91
103
  /** Export provider revocations for cross-node convergence. */
92
104
  export async function exportProviderAuthTombstones(credsDir) {
93
105
  return createCredentialVault(credsDir).exportTombstones();
@@ -169,7 +181,7 @@ export async function listCredentialRecords(credsDir) {
169
181
  const verification = await vault.readVerification(record.provider, record.label).catch(() => undefined);
170
182
  const verified = verification ? { lastVerifiedAt: verification.at, lastVerifiedOk: verification.ok } : {};
171
183
  if (source.kind === "reference") {
172
- return { provider: record.provider, label: record.label, kind: "reference", sync: record.sync, origin: record.origin, ref: source.ref, testable: false, ...verified };
184
+ return { provider: record.provider, label: record.label, kind: "reference", sync: record.sync, origin: record.origin, unattended: record.unattended === true, ref: source.ref, testable: false, ...verified };
173
185
  }
174
186
  const cred = source.cred;
175
187
  const summary = {
@@ -178,6 +190,7 @@ export async function listCredentialRecords(credsDir) {
178
190
  kind: cred.type,
179
191
  sync: record.sync,
180
192
  origin: record.origin,
193
+ unattended: record.unattended === true,
181
194
  testable: isTestableProvider(record.provider),
182
195
  ...verified,
183
196
  };
@@ -193,10 +206,14 @@ export async function listCredentialRecords(credsDir) {
193
206
  */
194
207
  async function labeledMeta(credsDir, provider, label) {
195
208
  const existing = await createCredentialVault(credsDir).readRecord(provider, label);
196
- return { sync: existing?.sync ?? defaultSyncFor("bivy"), origin: existing?.origin ?? "bivy" };
209
+ return {
210
+ sync: existing?.sync ?? defaultSyncFor("bivy"),
211
+ origin: existing?.origin ?? "bivy",
212
+ ...(existing?.unattended === true ? { unattended: true } : {}),
213
+ };
197
214
  }
198
215
  /** Store an API key under a specific label (multi-account). */
199
- export async function setProviderApiKeyLabeled(credsDir, provider, label, key) {
216
+ export async function setProviderApiKeyLabeled(credsDir, provider, label, key, sync) {
200
217
  const id = provider.trim().toLowerCase();
201
218
  if (!id)
202
219
  throw new Error("Provider is required");
@@ -209,10 +226,11 @@ export async function setProviderApiKeyLabeled(credsDir, provider, label, key) {
209
226
  label: normalizeLabel(label),
210
227
  source: { kind: "stored", cred: { type: "api_key", key: apiKey } },
211
228
  ...meta,
229
+ ...(sync ? { sync } : {}),
212
230
  });
213
231
  }
214
232
  /** Store a reference (op:// / env://) under a specific label (multi-account). */
215
- export async function setProviderReferenceLabeled(credsDir, provider, label, ref) {
233
+ export async function setProviderReferenceLabeled(credsDir, provider, label, ref, sync) {
216
234
  const id = provider.trim().toLowerCase();
217
235
  if (!id)
218
236
  throw new Error("Provider is required");
@@ -226,6 +244,7 @@ export async function setProviderReferenceLabeled(credsDir, provider, label, ref
226
244
  label: normalizeLabel(label),
227
245
  source: { kind: "reference", ref: pointer, backend },
228
246
  ...meta,
247
+ ...(sync ? { sync } : {}),
229
248
  });
230
249
  }
231
250
  /** Forget a single labeled credential (`provider:label`). */
@@ -244,12 +263,20 @@ export async function removeProviderCredential(credsDir, provider, label = DEFAU
244
263
  * check — chosen for being cheap (no completion/generation billed) and not
245
264
  * mutating anything provider-side. A provider absent here is honestly
246
265
  * reported as `testable: false` rather than guessing at a result. */
247
- const PROVIDER_PING = {
248
- anthropic: (token) => ({ url: "https://api.anthropic.com/v1/models", headers: { "x-api-key": token, "anthropic-version": "2023-06-01" } }),
249
- openai: (token) => ({ url: "https://api.openai.com/v1/models", headers: { authorization: `Bearer ${token}` } }),
250
- };
266
+ function providerPing(provider, secret) {
267
+ // Keep dispatch explicit: provider ids originate at the API boundary and
268
+ // must never select an arbitrary callable property.
269
+ switch (provider) {
270
+ case "anthropic":
271
+ return { url: "https://api.anthropic.com/v1/models", headers: { "x-api-key": secret, "anthropic-version": "2023-06-01" } };
272
+ case "openai":
273
+ return { url: "https://api.openai.com/v1/models", headers: { authorization: `Bearer ${secret}` } };
274
+ default:
275
+ return undefined;
276
+ }
277
+ }
251
278
  function isTestableProvider(provider) {
252
- return provider.trim().toLowerCase() in PROVIDER_PING;
279
+ return providerPing(provider.trim().toLowerCase(), "") !== undefined;
253
280
  }
254
281
  /**
255
282
  * Probe whether a stored credential actually works, and persist the result
@@ -290,10 +317,10 @@ export async function testCredential(credsDir, provider, label, oauth, fetchImpl
290
317
  if (!token)
291
318
  return { ok: false, at, reason: "not_found" };
292
319
  }
293
- const ping = PROVIDER_PING[id];
320
+ const ping = providerPing(id, token);
294
321
  if (!ping)
295
322
  return { ok: false, at, reason: "not_supported" };
296
- const { url, headers } = ping(token);
323
+ const { url, headers } = ping;
297
324
  try {
298
325
  const controller = new AbortController();
299
326
  const timeout = setTimeout(() => controller.abort(), 8_000);
@@ -333,6 +360,33 @@ export function getCredentialIngestPolicy(credsDir) {
333
360
  export function setCredentialIngestPolicy(credsDir, policy) {
334
361
  setIngestPolicyFile(defaultPresetsPath(credsDir), policy);
335
362
  }
363
+ /** Explicitly grant/revoke use by separately escrowed unattended runners. */
364
+ export async function setCredentialUnattended(credsDir, provider, label, unattended) {
365
+ const id = provider.trim().toLowerCase();
366
+ if (!id)
367
+ throw new Error("Provider is required");
368
+ const store = createCredentialVault(credsDir);
369
+ const existing = await store.readRecord(id, label);
370
+ if (!existing)
371
+ throw new Error(`No credential for ${id}:${normalizeLabel(label)}`);
372
+ if (existing.sync !== "account" && unattended)
373
+ throw new Error("A machine-only credential cannot be granted to unattended runners");
374
+ if (existing.source.kind === "reference" && unattended)
375
+ throw new Error("Password-manager references cannot be resolved by unattended runners");
376
+ await store.putRecord({ ...existing, unattended });
377
+ }
378
+ /** Credentials included in the explicit hosted custody snapshot. */
379
+ export async function exportUnattendedRecords(credsDir) {
380
+ const records = await createCredentialVault(credsDir).exportSyncableRecords();
381
+ return Object.fromEntries(Object.entries(records).filter(([, record]) => record.unattended === true && record.source.kind === "stored"));
382
+ }
383
+ /** Monotonic logical revision for the filtered custody projection. */
384
+ export async function unattendedCredentialRevision(credsDir) {
385
+ const store = createCredentialVault(credsDir);
386
+ const records = await store.listRecords();
387
+ const deletedAt = await store.exportRecordTombstones();
388
+ return Math.max(0, ...records.map((record) => Number(record.updatedAt) || 0), ...Object.values(deletedAt).map((stamp) => Number(stamp) || 0));
389
+ }
336
390
  /** Set a labeled credential's sync tier — the per-credential opt-out toggle. */
337
391
  export async function setCredentialSync(credsDir, provider, label, sync) {
338
392
  const id = provider.trim().toLowerCase();
@@ -347,5 +401,5 @@ export async function setCredentialSync(credsDir, provider, label, sync) {
347
401
  if (existing.source.kind === "reference" && existing.source.backend === "command" && sync === "account") {
348
402
  throw new Error("A cmd:// reference is node-local (it runs a command on this machine) and cannot be synced.");
349
403
  }
350
- await store.putRecord({ ...existing, sync });
404
+ await store.putRecord({ ...existing, sync, ...(sync === "node" ? { unattended: false } : {}) });
351
405
  }
@@ -10,11 +10,23 @@
10
10
  //
11
11
  // This keeps Bivy's hot credential path decoupled from Pi: Pi is just another
12
12
  // agent that reads the same store.
13
+ import path from "node:path";
13
14
  import { createCredentialVault } from "./store.js";
14
15
  import { resolveCredential } from "./records.js";
15
16
  import { loadPresets, defaultPresetsPath } from "./presets.js";
16
17
  /** Refresh an OAuth token this many ms before it expires (clock-skew guard). */
17
18
  const OAUTH_REFRESH_SKEW_MS = 60_000;
19
+ /** Stable project identifiers discoverable without importing repo/session code. */
20
+ export function projectIdsFromWorkspace(workspace) {
21
+ const resolved = path.resolve(workspace);
22
+ const ids = new Set([resolved, path.basename(resolved)]);
23
+ for (const part of resolved.split(path.sep)) {
24
+ const split = part.indexOf("__");
25
+ if (split > 0 && split < part.length - 2)
26
+ ids.add(`${part.slice(0, split)}/${part.slice(split + 2)}`);
27
+ }
28
+ return [...ids];
29
+ }
18
30
  /** Resolver over Bivy's credential store, with OAuth refresh-on-read via the bridge. */
19
31
  export class NodeCredentialResolver {
20
32
  credsDir;
@@ -22,7 +34,6 @@ export class NodeCredentialResolver {
22
34
  oauth;
23
35
  store;
24
36
  presetsPath;
25
- presetsCache;
26
37
  // Reference resolution (op:///env:///cmd://) and OAuth refresh are injected
27
38
  // capabilities (ports) — the resolver stays free of secrets.ts and the Pi
28
39
  // OAuth bridge. The node adapters are bound by the runtime shim.
@@ -33,13 +44,12 @@ export class NodeCredentialResolver {
33
44
  this.store = createCredentialVault(credsDir);
34
45
  this.presetsPath = defaultPresetsPath(credsDir);
35
46
  }
36
- /** The node's selection presets, read once per resolver (see presets.ts). */
47
+ /** Read assignments on each resolution so a vault UI change applies to an
48
+ * already-running agent on its next turn, not only after a daemon restart. */
37
49
  presets() {
38
- if (!this.presetsCache)
39
- this.presetsCache = loadPresets(this.presetsPath);
40
- return this.presetsCache;
50
+ return loadPresets(this.presetsPath);
41
51
  }
42
- async getCredential(provider) {
52
+ async getCredential(provider, context) {
43
53
  const id = provider.trim().toLowerCase();
44
54
  if (!id)
45
55
  return undefined;
@@ -48,7 +58,15 @@ export class NodeCredentialResolver {
48
58
  // credential; ambiguity (multiple accounts, no preset) returns nothing rather
49
59
  // than guessing.
50
60
  const records = await this.store.listRecords().catch(() => []);
51
- const selection = resolveCredential(id, records, this.presets());
61
+ const presets = this.presets();
62
+ // Project assignments are ordinary preset mappings named `project:<id>`.
63
+ // Bivy-managed clones encode owner/repo as owner__repo in their workspace
64
+ // path; direct local workspaces also match their absolute path/basename.
65
+ const explicitProject = context?.project?.trim();
66
+ const workspace = context?.workspace?.trim();
67
+ const projectCandidates = [explicitProject, ...(workspace ? projectIdsFromWorkspace(workspace) : [])].filter((value) => Boolean(value));
68
+ const projectPreset = projectCandidates.map((value) => `project:${value}`).find((name) => presets.presets?.[name]?.[id]);
69
+ const selection = resolveCredential(id, records, presets, { ...(projectPreset ? { preset: projectPreset } : {}), ...(context?.preferLabel ? { preferLabel: context.preferLabel } : {}) });
52
70
  if (!selection)
53
71
  return undefined;
54
72
  const source = selection.record.source;
@@ -64,7 +82,10 @@ export class NodeCredentialResolver {
64
82
  }
65
83
  const cred = source.cred;
66
84
  if (cred.type === "api_key") {
67
- const token = typeof cred.key === "string" ? cred.key : "";
85
+ // Keyless local endpoints are valid. They still need a harmless value in
86
+ // the conventional API-key variable because several OpenAI-compatible
87
+ // clients require the variable even when the server ignores auth.
88
+ const token = typeof cred.key === "string" && cred.key ? cred.key : cred.env ? "local" : "";
68
89
  if (!token)
69
90
  return undefined;
70
91
  return { provider: id, kind: "api_key", token, ...(cred.env ? { env: cred.env } : {}) };
@@ -148,7 +169,7 @@ const BASE_URL_TO_KEY_VAR = {
148
169
  * non-active provider would silently redirect the real provider. Best-effort: a
149
170
  * provider that fails to resolve is skipped rather than failing the session.
150
171
  */
151
- export async function buildAgentCredentialEnv(store, providers, activeProvider) {
172
+ export async function buildAgentCredentialEnv(store, providers, activeProvider, workspace) {
152
173
  const ids = providers ?? (store.listConfigured ? await store.listConfigured().catch(() => []) : []);
153
174
  const active = activeProvider?.trim().toLowerCase() || undefined;
154
175
  // Process the active provider LAST so its base URL and standard-key alias win
@@ -159,7 +180,7 @@ export async function buildAgentCredentialEnv(store, providers, activeProvider)
159
180
  for (const id of ordered) {
160
181
  let cred;
161
182
  try {
162
- cred = await store.getCredential(id);
183
+ cred = await store.getCredential(id, workspace ? { workspace } : undefined);
163
184
  }
164
185
  catch {
165
186
  continue;
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * Bivy-owned, browser-safe provider registry and baseline model catalog.
5
+ *
6
+ * This is versioned product metadata, not runtime state. It intentionally has
7
+ * no Node, Pi, agent, or network dependencies, so clients can show providers,
8
+ * authentication choices, help links, and a useful model baseline before they
9
+ * connect to a node. Runtime discovery may augment this snapshot but does not
10
+ * own these provider identities.
11
+ */
12
+ export const BIVY_PROVIDER_CATALOG_VERSION = "2026-08-14";
13
+ const apiKey = (label, helpUrl) => ({ kind: "api_key", label, ...(helpUrl ? { helpUrl } : {}) });
14
+ const oauth = (label, oauthProviderId) => ({ kind: "oauth", label, ...(oauthProviderId ? { oauthProviderId } : {}) });
15
+ const reference = { kind: "reference", label: "Password manager or environment" };
16
+ /** Authoritative common providers and the offline baseline models Bivy ships. */
17
+ export const BIVY_PROVIDER_CATALOG = [
18
+ { id: "anthropic", name: "Anthropic", authMethods: [oauth("Claude Pro / Max", "anthropic"), apiKey("Anthropic API key", "https://console.anthropic.com/settings/keys"), reference], env: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], compatibility: "anthropic", helpUrl: "https://docs.anthropic.com/", models: [
19
+ { id: "claude-opus-4-1", name: "Claude Opus 4.1", reasoning: true },
20
+ { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true },
21
+ { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
22
+ ] },
23
+ { id: "openai", name: "OpenAI API", aliases: ["openai-api"], authMethods: [apiKey("OpenAI API key", "https://platform.openai.com/api-keys"), reference], env: ["OPENAI_API_KEY"], compatibility: "openai", helpUrl: "https://platform.openai.com/docs/", models: [
24
+ { id: "gpt-5.4", name: "GPT-5.4", reasoning: true },
25
+ { id: "gpt-5.4-mini", name: "GPT-5.4 mini", reasoning: true },
26
+ { id: "gpt-4o", name: "GPT-4o" },
27
+ ] },
28
+ { id: "openai-codex", name: "OpenAI — ChatGPT subscription", aliases: ["codex"], authMethods: [oauth("ChatGPT Plus / Pro", "openai-codex")], compatibility: "openai", models: [
29
+ { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", reasoning: true },
30
+ { id: "gpt-5-codex", name: "GPT-5 Codex", reasoning: true },
31
+ ] },
32
+ { id: "google", name: "Google Gemini", aliases: ["gemini"], authMethods: [apiKey("Gemini API key", "https://aistudio.google.com/app/apikey"), reference], env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], compatibility: "google", helpUrl: "https://ai.google.dev/gemini-api/docs", models: [
33
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", reasoning: true },
34
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", reasoning: true },
35
+ ] },
36
+ { id: "xai", name: "xAI", authMethods: [oauth("Grok / X subscription", "xai"), apiKey("xAI API key", "https://console.x.ai"), reference], env: ["XAI_API_KEY", "GROK_API_KEY"], compatibility: "openai", models: [] },
37
+ { id: "openrouter", name: "OpenRouter", authMethods: [apiKey("OpenRouter API key", "https://openrouter.ai/keys"), reference], env: ["OPENROUTER_API_KEY"], compatibility: "openai", models: [] },
38
+ { id: "groq", name: "Groq", authMethods: [apiKey("Groq API key", "https://console.groq.com/keys"), reference], env: ["GROQ_API_KEY"], compatibility: "openai", models: [] },
39
+ { id: "mistral", name: "Mistral", authMethods: [apiKey("Mistral API key", "https://console.mistral.ai/api-keys"), reference], env: ["MISTRAL_API_KEY"], compatibility: "openai", models: [] },
40
+ { id: "deepseek", name: "DeepSeek", authMethods: [apiKey("DeepSeek API key", "https://platform.deepseek.com/api_keys"), reference], env: ["DEEPSEEK_API_KEY"], compatibility: "openai", models: [] },
41
+ { id: "together", name: "Together AI", authMethods: [apiKey("Together API key", "https://api.together.ai/settings/api-keys"), reference], env: ["TOGETHER_API_KEY"], compatibility: "openai", models: [] },
42
+ { id: "fireworks", name: "Fireworks AI", authMethods: [apiKey("Fireworks API key", "https://fireworks.ai/account/api-keys"), reference], env: ["FIREWORKS_API_KEY"], compatibility: "openai", models: [] },
43
+ { id: "cohere", name: "Cohere", authMethods: [apiKey("Cohere API key", "https://dashboard.cohere.com/api-keys"), reference], env: ["COHERE_API_KEY"], models: [] },
44
+ { id: "perplexity", name: "Perplexity", authMethods: [apiKey("Perplexity API key", "https://www.perplexity.ai/settings/api"), reference], env: ["PERPLEXITY_API_KEY"], compatibility: "openai", models: [] },
45
+ ];
46
+ /** Common providers that accept API keys, for browser pickers and validation. */
47
+ export const BIVY_API_KEY_PROVIDER_IDS = BIVY_PROVIDER_CATALOG
48
+ .filter((provider) => provider.authMethods.some((method) => method.kind === "api_key"))
49
+ .map((provider) => provider.id);
50
+ const providersById = new Map();
51
+ for (const provider of BIVY_PROVIDER_CATALOG) {
52
+ providersById.set(provider.id, provider);
53
+ for (const alias of provider.aliases ?? [])
54
+ providersById.set(alias, provider);
55
+ }
56
+ /** Resolve a canonical provider id or a declared alias. */
57
+ export function bivyProvider(id) {
58
+ return providersById.get(String(id || "").trim().toLowerCase());
59
+ }
60
+ /** Search the static registry without requiring a node or network request. */
61
+ export function searchBivyProviders(query) {
62
+ const needle = query.trim().toLowerCase();
63
+ return BIVY_PROVIDER_CATALOG.filter((provider) => !needle || `${provider.name} ${provider.id} ${(provider.aliases ?? []).join(" ")}`.toLowerCase().includes(needle));
64
+ }
@@ -9,6 +9,7 @@
9
9
  // run, with pi as just one (rich) contributor. New api-key providers are
10
10
  // supported dynamically (the provider→env mapping is generic); OAuth providers
11
11
  // are those in Bivy's native registry.
12
+ import { BIVY_PROVIDER_CATALOG, BIVY_PROVIDER_CATALOG_VERSION, bivyProvider } from "./bivy-provider-catalog.js";
12
13
  import { createCredentialVault } from "./credential-store.js";
13
14
  import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
14
15
  async function catalogOf(runtime) {
@@ -33,17 +34,44 @@ export async function aggregateModelCatalog(runtimes, credsDir) {
33
34
  Promise.all(runtimes.map(async (runtime) => ({ agent: runtime.id, providers: await catalogOf(runtime) }))),
34
35
  createCredentialVault(credsDir).list().catch(() => []),
35
36
  ]);
36
- const configured = new Set(stored.map((info) => info.providerId));
37
+ const configured = new Set(stored.map((info) => bivyProvider(info.providerId)?.id ?? info.providerId));
37
38
  const byProvider = new Map();
38
39
  const seenModel = new Map();
40
+ // The baseline makes this useful with zero installed agents and offline. Live
41
+ // catalogs below add availability and replace same-id model metadata with the
42
+ // fresher runtime row while retaining models absent from runtime discovery.
43
+ for (const provider of BIVY_PROVIDER_CATALOG) {
44
+ const models = provider.models.map((model) => ({ provider: provider.id, ...model }));
45
+ byProvider.set(provider.id, {
46
+ id: provider.id,
47
+ name: provider.name,
48
+ oauth: provider.authMethods.some((method) => method.kind === "oauth"),
49
+ configured: configured.has(provider.id),
50
+ agents: [],
51
+ models,
52
+ provenance: { baselineVersion: BIVY_PROVIDER_CATALOG_VERSION, runtimeIds: [] },
53
+ });
54
+ seenModel.set(provider.id, new Set(models.map((model) => model.id)));
55
+ }
56
+ const refreshedAt = Date.now();
39
57
  for (const { agent, providers } of perAgent) {
40
58
  for (const provider of providers) {
41
- const id = provider.id.trim().toLowerCase();
42
- if (!id)
59
+ const rawId = provider.id.trim().toLowerCase();
60
+ if (!rawId)
43
61
  continue;
62
+ const definition = bivyProvider(rawId);
63
+ const id = definition?.id ?? rawId;
44
64
  let entry = byProvider.get(id);
45
65
  if (!entry) {
46
- entry = { id, name: provider.name || id, oauth: isNativeOAuthProvider(id), configured: configured.has(id), agents: [], models: [] };
66
+ entry = {
67
+ id,
68
+ name: provider.name || id,
69
+ oauth: isNativeOAuthProvider(id),
70
+ configured: configured.has(id),
71
+ agents: [],
72
+ models: [],
73
+ provenance: { runtimeIds: [], refreshedAt },
74
+ };
47
75
  byProvider.set(id, entry);
48
76
  seenModel.set(id, new Set());
49
77
  }
@@ -51,12 +79,21 @@ export async function aggregateModelCatalog(runtimes, credsDir) {
51
79
  entry.oauth = true;
52
80
  if (!entry.agents.includes(agent))
53
81
  entry.agents.push(agent);
82
+ const provenance = entry.provenance ??= { runtimeIds: [] };
83
+ if (!provenance.runtimeIds.includes(agent))
84
+ provenance.runtimeIds.push(agent);
85
+ provenance.refreshedAt = refreshedAt;
54
86
  const models = seenModel.get(id);
55
87
  for (const model of provider.models) {
56
- if (models.has(model.id))
88
+ const normalized = { ...model, provider: id };
89
+ if (models.has(model.id)) {
90
+ const index = entry.models.findIndex((candidate) => candidate.id === model.id);
91
+ if (index >= 0)
92
+ entry.models[index] = normalized;
57
93
  continue;
94
+ }
58
95
  models.add(model.id);
59
- entry.models.push(model);
96
+ entry.models.push(normalized);
60
97
  }
61
98
  }
62
99
  }
@@ -79,6 +116,7 @@ export function mergeProviderCatalog(base, catalog) {
79
116
  if (existing) {
80
117
  existing.agents = provider.agents;
81
118
  existing.models = provider.models;
119
+ existing.provenance = provider.provenance;
82
120
  if (provider.oauth)
83
121
  existing.oauth = true;
84
122
  }
@@ -90,6 +128,7 @@ export function mergeProviderCatalog(base, catalog) {
90
128
  configured: provider.configured,
91
129
  agents: provider.agents,
92
130
  models: provider.models,
131
+ provenance: provider.provenance,
93
132
  });
94
133
  }
95
134
  }
@@ -308,13 +308,13 @@ export { isNativeOAuthProvider, nativeOAuthProviderIds } from "./model-oauth-pro
308
308
  * store. The interaction (prompt/notify) is caller-supplied; the whole flow —
309
309
  * PKCE, callback server, token exchange — is Bivy's.
310
310
  */
311
- export async function loginModelOAuth(credsDir, providerId, interaction) {
311
+ export async function loginModelOAuth(credsDir, providerId, interaction, label = DEFAULT_LABEL) {
312
312
  const provider = getModelOAuthProvider(providerId);
313
313
  if (!provider)
314
314
  throw new Error(`Provider "${providerId}" does not support subscription login`);
315
315
  const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction) : await loginAuthCode(provider, interaction);
316
316
  const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, refreshedAt: tokens.refreshedAt, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
317
- await createCredentialVault(credsDir).modify(providerId, async () => credential);
317
+ await createCredentialVault(credsDir).modifyRecord(providerId, label, async () => credential);
318
318
  }
319
319
  /** Exchange the refresh token for a fresh credential (network call; throws on failure). */
320
320
  async function refreshTokens(provider, current) {
@@ -299,7 +299,7 @@ class ProcessSession {
299
299
  // added after this session started) reach the agent. The vault wins over any
300
300
  // ambient key so Bivy's shared sign-in is authoritative.
301
301
  const credentialEnv = this.runtimeOptions.credentials
302
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
302
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
303
303
  : {};
304
304
  // Optional prepare step (e.g. Codex materializes its auth.json from the vault
305
305
  // and pins CODEX_HOME). Runs after credentials, before preflight/spawn; its
@@ -299,7 +299,7 @@ class ProtocolSession {
299
299
  if (!hook)
300
300
  return null;
301
301
  const credentialEnv = this.runtimeOptions.credentials
302
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
302
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
303
303
  : {};
304
304
  let prepareEnv = this.prepareEnv;
305
305
  if (this.runtimeOptions.prepare) {
@@ -321,7 +321,7 @@ class ProtocolSession {
321
321
  if (this.child)
322
322
  return;
323
323
  const credentialEnv = this.runtimeOptions.credentials
324
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
324
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
325
325
  : {};
326
326
  // Optional prepare step, run before the child spawns because a shim reads its
327
327
  // credential at launch (e.g. Codex mints ~/.codex/auth.json from the vault and
@@ -778,7 +778,7 @@ class ProtocolSession {
778
778
  // idempotent (it no-ops once auth.json exists), so the repeat is cheap.
779
779
  if (this.runtimeOptions.prepare || this.runtimeOptions.preflight) {
780
780
  const credentialEnv = this.runtimeOptions.credentials
781
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
781
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
782
782
  : {};
783
783
  if (this.runtimeOptions.prepare) {
784
784
  this.prepareEnv =
@@ -1,15 +1,43 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
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.
3
+ // Bivy's provider catalog joined with live Pi status and Bivy credentials.
4
+ // Static product metadata comes from @bivy/core; Pi is an optional freshness
5
+ // overlay rather than the authority or a prerequisite for listing providers.
6
+ import { BIVY_PROVIDER_CATALOG, bivyProvider } from "./bivy-provider-catalog.js";
10
7
  import { listPiProviders } from "./pi-oauth.js";
11
8
  import { joinProviderCatalog } from "../credentials/api.js";
12
- /** Enumerate model providers with their current auth status (catalog from Pi). */
9
+ /** Pure baseline/live merge, exported for alternate runtime bridges and tests. */
10
+ export function overlayProviderCatalog(live) {
11
+ const byId = new Map();
12
+ for (const provider of BIVY_PROVIDER_CATALOG) {
13
+ byId.set(provider.id, {
14
+ id: provider.id,
15
+ name: provider.name,
16
+ oauth: provider.authMethods.some((method) => method.kind === "oauth"),
17
+ configured: false,
18
+ });
19
+ }
20
+ for (const row of live) {
21
+ const rawId = row.id.trim().toLowerCase();
22
+ if (!rawId)
23
+ continue;
24
+ const definition = bivyProvider(rawId);
25
+ const id = definition?.id ?? rawId;
26
+ const previous = byId.get(id);
27
+ byId.set(id, {
28
+ id,
29
+ // Bivy owns names for registered identities; live catalogs name unknowns.
30
+ name: definition?.name ?? (row.name || previous?.name || id),
31
+ oauth: previous?.oauth === true || row.oauth,
32
+ configured: previous?.configured === true || row.configured,
33
+ // Preserve the live source/freshness signal (stored, environment, etc.).
34
+ ...(row.source ? { source: row.source } : previous?.source ? { source: previous.source } : {}),
35
+ });
36
+ }
37
+ return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
38
+ }
39
+ /** Enumerate Bivy's baseline providers, overlaid with Pi's live auth status. */
13
40
  export async function listProviders(credsDir, piDir) {
14
- return joinProviderCatalog(credsDir, await listPiProviders(credsDir, piDir));
41
+ const live = await listPiProviders(credsDir, piDir).catch(() => []);
42
+ return joinProviderCatalog(credsDir, overlayProviderCatalog(live));
15
43
  }
package/dist/server.js CHANGED
@@ -45,7 +45,7 @@ import { dedupeSessionSummaries } from "./session-identity.js";
45
45
  import { deriveSessionState } from "./session/session-state.js";
46
46
  import { resolveStreamingBehavior } from "./session/record.js";
47
47
  import { createSessionEngine } from "./session/engine.js";
48
- import { exportProviderAuth, exportAccountApiKeys, exportSyncableProviderAuth, exportProviderAuthTombstones, importProviderAuth, removeProvider, setProviderApiKey, listCredentialRecords, setProviderApiKeyLabeled, setProviderReferenceLabeled, removeProviderCredential, setCredentialSync, getCredentialPresets, setActiveCredentialPreset, setCredentialPresetMapping, exportSyncableRecords, exportRecordTombstones, importCredentialRecords } from "./credentials/api.js";
48
+ import { exportProviderAuth, exportAccountApiKeys, exportSyncableProviderAuth, exportProviderAuthTombstones, importProviderAuth, removeProvider, setProviderApiKey, listCredentialRecords, setProviderApiKeyLabeled, setProviderReferenceLabeled, removeProviderCredential, setCredentialSync, setCredentialUnattended, exportUnattendedRecords, unattendedCredentialRevision, getCredentialPresets, setActiveCredentialPreset, setCredentialPresetMapping, exportSyncableRecords, exportRecordTombstones, importCredentialRecords, reconcileHostedCredentialRecords } from "./credentials/api.js";
49
49
  import { listProviders } from "./runtime/provider-catalog.js";
50
50
  import { exportLocalModels, importLocalModels } from "./runtime/local-model-store.js";
51
51
  import { execEphemeralRequest } from "./ephemeral-exec.js";
@@ -2599,17 +2599,20 @@ const RELAY_COMMANDS = {
2599
2599
  async "credentials.account.export"(_msg, ctx) {
2600
2600
  // This reply travels inside the already-paired E2E node channel. It contains
2601
2601
  // API keys only; OAuth/ref/node-local material is excluded by the API.
2602
- ctx.reply({ type: "credentials.account.export", requestId: _msg.requestId, entries: await exportAccountApiKeys(credsDir) });
2602
+ ctx.reply({ type: "credentials.account.export", requestId: _msg.requestId, entries: await exportAccountApiKeys(credsDir), records: await listCredentialRecords(credsDir), deletedAt: await exportRecordTombstones(credsDir) });
2603
2603
  },
2604
2604
  async "credential.set"(msg, ctx) {
2605
2605
  try {
2606
2606
  const provider = String(msg.provider ?? "").trim().toLowerCase();
2607
2607
  const label = String(msg.label ?? "");
2608
2608
  const ref = typeof msg.ref === "string" ? msg.ref.trim() : "";
2609
+ const requestedSync = msg.sync === "account" || msg.sync === "node" ? msg.sync : undefined;
2610
+ // Persist the secret and its requested tier atomically. Creating a
2611
+ // machine-only credential must never leave an account-tier crash window.
2609
2612
  if (ref)
2610
- await setProviderReferenceLabeled(credsDir, provider, label, ref);
2613
+ await setProviderReferenceLabeled(credsDir, provider, label, ref, requestedSync);
2611
2614
  else
2612
- await setProviderApiKeyLabeled(credsDir, provider, label, String(msg.key ?? ""));
2615
+ await setProviderApiKeyLabeled(credsDir, provider, label, String(msg.key ?? ""), requestedSync);
2613
2616
  await pushModelAuthToControlPlane();
2614
2617
  await refreshSessionAfterAuth();
2615
2618
  relay?.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
@@ -2622,27 +2625,45 @@ const RELAY_COMMANDS = {
2622
2625
  ctx.reply({ type: "credential.set.error", requestId: msg.requestId, error: message });
2623
2626
  }
2624
2627
  },
2625
- async "credential.remove"(msg) {
2628
+ async "credential.remove"(msg, ctx) {
2626
2629
  try {
2627
2630
  await removeProviderCredential(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""));
2628
2631
  await pushModelAuthToControlPlane();
2629
2632
  await refreshSessionAfterAuth();
2630
2633
  relay?.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
2631
2634
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
2635
+ ctx.reply({ type: "credential.remove.ok", requestId: msg.requestId });
2632
2636
  }
2633
2637
  catch (error) {
2634
- relay?.sendEvent({ type: "session.error", error: error instanceof Error ? error.message : String(error) });
2638
+ const message = error instanceof Error ? error.message : String(error);
2639
+ relay?.sendEvent({ type: "session.error", error: message });
2640
+ ctx.reply({ type: "credential.remove.error", requestId: msg.requestId, error: message });
2635
2641
  }
2636
2642
  },
2637
- async "credential.sync.set"(msg) {
2643
+ async "credential.sync.set"(msg, ctx) {
2638
2644
  try {
2639
2645
  const sync = msg.sync === "node" ? "node" : "account";
2640
2646
  await setCredentialSync(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), sync);
2641
2647
  await pushModelAuthToControlPlane();
2642
2648
  relay?.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
2649
+ ctx.reply({ type: "credential.sync.set.ok", requestId: msg.requestId });
2643
2650
  }
2644
2651
  catch (error) {
2645
- relay?.sendEvent({ type: "session.error", error: error instanceof Error ? error.message : String(error) });
2652
+ const message = error instanceof Error ? error.message : String(error);
2653
+ relay?.sendEvent({ type: "session.error", error: message });
2654
+ ctx.reply({ type: "credential.sync.set.error", requestId: msg.requestId, error: message });
2655
+ }
2656
+ },
2657
+ async "credential.unattended.set"(msg, ctx) {
2658
+ try {
2659
+ await setCredentialUnattended(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), msg.unattended === true);
2660
+ await pushModelAuthToControlPlane();
2661
+ relay?.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
2662
+ ctx.reply({ type: "credential.unattended.set.ok", requestId: msg.requestId });
2663
+ }
2664
+ catch (error) {
2665
+ const message = error instanceof Error ? error.message : String(error);
2666
+ ctx.reply({ type: "credential.unattended.set.error", requestId: msg.requestId, error: message });
2646
2667
  }
2647
2668
  },
2648
2669
  // "Test connection": a bounded, non-secret liveness probe for one credential
@@ -2674,14 +2695,16 @@ const RELAY_COMMANDS = {
2674
2695
  relay?.sendEvent({ type: "session.error", error: error instanceof Error ? error.message : String(error) });
2675
2696
  }
2676
2697
  },
2677
- async "credentials.presets.setMapping"(msg) {
2698
+ async "credentials.presets.setMapping"(msg, ctx) {
2678
2699
  try {
2679
2700
  setCredentialPresetMapping(credsDir, String(msg.preset ?? ""), String(msg.provider ?? ""), String(msg.label ?? ""));
2680
2701
  await refreshSessionAfterAuth();
2681
2702
  relay?.sendEvent({ type: "credentials.presets", presets: getCredentialPresets(credsDir) });
2703
+ ctx.reply({ type: "credentials.presets.setMapping.ok", requestId: msg.requestId });
2682
2704
  }
2683
2705
  catch (error) {
2684
- relay?.sendEvent({ type: "session.error", error: error instanceof Error ? error.message : String(error) });
2706
+ const message = error instanceof Error ? error.message : String(error);
2707
+ ctx.reply({ type: "credentials.presets.setMapping.error", requestId: msg.requestId, error: message });
2685
2708
  }
2686
2709
  },
2687
2710
  // --- Local / custom models (Bivy-owned registry; Pi is a projection). ---
@@ -2850,7 +2873,7 @@ const RELAY_COMMANDS = {
2850
2873
  // `auth.oauth.progress|done|error` already mirror to the relay via broadcast.
2851
2874
  async "provider.oauth.start"(msg) {
2852
2875
  try {
2853
- const state = await startOAuthLogin(String(msg.provider ?? msg.id ?? ""));
2876
+ const state = await startOAuthLogin(String(msg.provider ?? msg.id ?? ""), String(msg.label ?? "default"));
2854
2877
  relay?.sendEvent({
2855
2878
  type: "provider.oauth.started",
2856
2879
  id: state.id,
@@ -3303,7 +3326,12 @@ function startRelayIfConfigured() {
3303
3326
  return true;
3304
3327
  }
3305
3328
  const modelAuthVaultKeyPath = path.join(appDir, "model-auth-vault.json");
3329
+ const hostedModelAuthVaultKeyPath = path.join(appDir, "model-auth-hosted-vault.json");
3330
+ const hostedImportedRecordsPath = path.join(appDir, "model-auth-hosted-records.json");
3306
3331
  let lastPushedModelAuthCiphertext = "";
3332
+ let lastPushedHostedModelAuthCiphertext = "";
3333
+ let lastPushedHostedModelAuthRevision = -1;
3334
+ const isHostedCustodyNode = () => Boolean(process.env.BIVY_GITHUB_HOSTED_TASKS);
3307
3335
  function readLocalModelAuthVaultKey() {
3308
3336
  try {
3309
3337
  const parsed = JSON.parse(fs.readFileSync(modelAuthVaultKeyPath, "utf8"));
@@ -3329,6 +3357,40 @@ function forgetLocalModelAuthVaultKey() {
3329
3357
  }
3330
3358
  catch { /* best effort */ }
3331
3359
  }
3360
+ function readHostedModelAuthVaultKey() {
3361
+ try {
3362
+ const parsed = JSON.parse(fs.readFileSync(hostedModelAuthVaultKeyPath, "utf8"));
3363
+ if (parsed.vaultKeyB64 && Buffer.from(parsed.vaultKeyB64, "base64").length === 32)
3364
+ return parsed.vaultKeyB64;
3365
+ }
3366
+ catch { /* no hosted key */ }
3367
+ return undefined;
3368
+ }
3369
+ function writeHostedModelAuthVaultKey(vaultKeyB64) {
3370
+ fs.writeFileSync(hostedModelAuthVaultKeyPath, `${JSON.stringify({ vaultKeyB64, createdAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
3371
+ }
3372
+ function readHostedImportedRecords() {
3373
+ try {
3374
+ const parsed = JSON.parse(fs.readFileSync(hostedImportedRecordsPath, "utf8"));
3375
+ return Array.isArray(parsed?.records)
3376
+ ? parsed.records.filter((record) => Boolean(record) && typeof record === "object" && typeof record.provider === "string" && typeof record.label === "string")
3377
+ : [];
3378
+ }
3379
+ catch {
3380
+ return [];
3381
+ }
3382
+ }
3383
+ function writeHostedImportedRecords(records) {
3384
+ fs.writeFileSync(hostedImportedRecordsPath, `${JSON.stringify({ records }, null, 2)}\n`, { mode: 0o600 });
3385
+ }
3386
+ function ensureHostedModelAuthVaultKey() {
3387
+ const existing = readHostedModelAuthVaultKey();
3388
+ if (existing)
3389
+ return existing;
3390
+ const created = randomBytes(32).toString("base64");
3391
+ writeHostedModelAuthVaultKey(created);
3392
+ return created;
3393
+ }
3332
3394
  function ensureLocalModelAuthVaultKey() {
3333
3395
  const existing = readLocalModelAuthVaultKey();
3334
3396
  if (existing)
@@ -3442,32 +3504,47 @@ async function syncModelAuthFromControlPlane() {
3442
3504
  if (!res?.ok)
3443
3505
  return;
3444
3506
  const data = (await res.json().catch(() => ({})));
3445
- let vaultKeyB64 = readLocalModelAuthVaultKey();
3446
- if (!vaultKeyB64 && data.wrappedKey?.wrappedKey) {
3507
+ const hostedCustody = isHostedCustodyNode();
3508
+ let hostedData = {};
3509
+ if (hostedCustody) {
3510
+ const hostedResponse = await modelAuthFetch("/node/model-auth-hosted-vault");
3511
+ if (hostedResponse?.ok)
3512
+ hostedData = (await hostedResponse.json().catch(() => ({})));
3513
+ }
3514
+ const targetVault = hostedCustody ? hostedData.hostedVault : data.vault;
3515
+ let vaultKeyB64 = hostedCustody ? readHostedModelAuthVaultKey() : readLocalModelAuthVaultKey();
3516
+ if (!hostedCustody && !vaultKeyB64 && data.wrappedKey?.wrappedKey) {
3447
3517
  vaultKeyB64 = pairingStore.unwrapFromNodePublicKey(data.wrappedKey.wrappedByPublicKey, data.wrappedKey.wrappedKey);
3448
3518
  writeLocalModelAuthVaultKey(vaultKeyB64);
3449
3519
  }
3450
- // Node-less inheritance: a lone HOSTED ephemeral (no peer to wrap the key)
3451
- // adopts the vault key the control plane escrowed for this hosted account, so
3452
- // it can decrypt the synced vault (incl. subscription OAuth) on cold start. The
3453
- // control plane serves `hostedKey` only for hosted-provisioning accounts.
3454
- if (!vaultKeyB64 && data.hostedKey && Buffer.from(data.hostedKey, "base64").length === 32) {
3455
- vaultKeyB64 = data.hostedKey;
3456
- writeLocalModelAuthVaultKey(vaultKeyB64);
3520
+ // Hosted runners may decrypt ONLY the separately encrypted snapshot of
3521
+ // records that the user explicitly granted to unattended execution.
3522
+ if (hostedCustody && !vaultKeyB64 && hostedData.hostedKey && hostedData.hostedVault && Buffer.from(hostedData.hostedKey, "base64").length === 32) {
3523
+ vaultKeyB64 = hostedData.hostedKey;
3524
+ writeHostedModelAuthVaultKey(vaultKeyB64);
3457
3525
  }
3458
- if (data.vault?.ciphertext && vaultKeyB64) {
3526
+ if (targetVault?.ciphertext && vaultKeyB64) {
3459
3527
  let decrypted;
3460
3528
  try {
3461
- decrypted = decryptModelAuthEnvelope(data.vault.ciphertext, vaultKeyB64);
3529
+ decrypted = decryptModelAuthEnvelope(targetVault.ciphertext, vaultKeyB64);
3462
3530
  }
3463
3531
  catch (error) {
3464
3532
  // Most commonly this node cached the previous generation while another
3465
3533
  // survivor completed a revoke-triggered re-key. Forget it and request a
3466
3534
  // wrap of the current key; retaining it would make every poll fail forever.
3467
- forgetLocalModelAuthVaultKey();
3468
- lastPushedModelAuthCiphertext = "";
3469
- await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
3470
- ensureModelAuthColdStart();
3535
+ if (hostedCustody) {
3536
+ try {
3537
+ fs.rmSync(hostedModelAuthVaultKeyPath, { force: true });
3538
+ }
3539
+ catch { /* best effort */ }
3540
+ lastPushedHostedModelAuthCiphertext = "";
3541
+ }
3542
+ else {
3543
+ forgetLocalModelAuthVaultKey();
3544
+ lastPushedModelAuthCiphertext = "";
3545
+ await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
3546
+ ensureModelAuthColdStart();
3547
+ }
3471
3548
  console.warn("[auth-sync] cached vault key is stale; requested the rotated key:", error.message);
3472
3549
  return;
3473
3550
  }
@@ -3477,7 +3554,16 @@ async function syncModelAuthFromControlPlane() {
3477
3554
  // so importing records alone (plus its record-keyed tombstones) is complete.
3478
3555
  // Fall back to the provider-keyed fields for a v2 peer.
3479
3556
  if (v >= 3) {
3480
- await importCredentialRecords(credsDir, records, recordsDeletedAt);
3557
+ if (hostedCustody) {
3558
+ // Hosted snapshots are authoritative filtered sets, not additive peer
3559
+ // merges. Remove custody-derived records omitted by a later snapshot
3560
+ // so a revoke takes effect on already-running hosted nodes.
3561
+ const current = await reconcileHostedCredentialRecords(credsDir, records, readHostedImportedRecords());
3562
+ writeHostedImportedRecords(current);
3563
+ }
3564
+ else {
3565
+ await importCredentialRecords(credsDir, records, recordsDeletedAt);
3566
+ }
3481
3567
  }
3482
3568
  else {
3483
3569
  await importProviderAuth(credsDir, providers, deletedAt);
@@ -3487,15 +3573,18 @@ async function syncModelAuthFromControlPlane() {
3487
3573
  // regenerate it (and refresh the panel) after importing the vault.
3488
3574
  await writePiModelsProjection();
3489
3575
  await broadcastLocalModels();
3490
- lastPushedModelAuthCiphertext = data.vault.ciphertext;
3576
+ if (hostedCustody)
3577
+ lastPushedHostedModelAuthCiphertext = targetVault.ciphertext;
3578
+ else
3579
+ lastPushedModelAuthCiphertext = targetVault.ciphertext;
3491
3580
  // Got the key and imported the vault (incl. any subscription-OAuth logins) —
3492
3581
  // the cold-start race is over.
3493
3582
  stopModelAuthColdStart();
3494
3583
  broadcast({ type: "providers.list", providers: await listProvidersUnified() });
3495
- if (data.vault.needsRotation)
3584
+ if (!hostedCustody && data.vault?.needsRotation)
3496
3585
  await pushModelAuthToControlPlane(true);
3497
3586
  }
3498
- else if (data.vault?.ciphertext && !vaultKeyB64) {
3587
+ else if (targetVault?.ciphertext && !vaultKeyB64 && !hostedCustody) {
3499
3588
  await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
3500
3589
  // No peer has wrapped our key yet. Fast-retry (bounded) so a short-lived
3501
3590
  // ephemeral runner picks up the key within seconds of a peer answering,
@@ -3509,7 +3598,7 @@ async function syncModelAuthFromControlPlane() {
3509
3598
  await processModelAuthKeyRequests(data.requests ?? []);
3510
3599
  // Ready means there is no encrypted vault to hydrate, or this node has the
3511
3600
  // key needed to consume it. Ciphertext with no key remains not-ready.
3512
- if (!data.vault?.ciphertext || readLocalModelAuthVaultKey())
3601
+ if (!targetVault?.ciphertext || (hostedCustody ? readHostedModelAuthVaultKey() : readLocalModelAuthVaultKey()))
3513
3602
  void reportEphemeralMilestone("credentialsReadyAt");
3514
3603
  }
3515
3604
  catch (error) {
@@ -3530,6 +3619,38 @@ async function processModelAuthKeyRequests(requests) {
3530
3619
  });
3531
3620
  }
3532
3621
  }
3622
+ async function pushHostedModelAuthToControlPlane() {
3623
+ const [records, revision] = await Promise.all([exportUnattendedRecords(credsDir), unattendedCredentialRevision(credsDir)]);
3624
+ if (revision === lastPushedHostedModelAuthRevision)
3625
+ return;
3626
+ const key = ensureHostedModelAuthVaultKey();
3627
+ const ciphertext = encryptModelAuthProviders({}, {}, {}, key, records, {});
3628
+ const publish = async (expectedGeneration) => modelAuthFetch("/node/model-auth-hosted-vault", {
3629
+ method: "PUT",
3630
+ body: JSON.stringify({ ciphertext, vaultKeyB64: key, expectedGeneration, revision }),
3631
+ });
3632
+ const currentResponse = await modelAuthFetch("/node/model-auth-hosted-vault");
3633
+ if (currentResponse?.status === 403)
3634
+ return; // hosted provisioning is disabled
3635
+ const current = currentResponse?.ok
3636
+ ? (await currentResponse.json().catch(() => ({})))
3637
+ : {};
3638
+ let response = await publish(current.hostedVault?.generation ?? 0);
3639
+ if (response?.status === 409) {
3640
+ const conflict = await response.json().catch(() => ({}));
3641
+ // Retry only when our logical state is at least as new; the store also
3642
+ // enforces this revision check atomically with the generation CAS.
3643
+ if (revision >= Number(conflict.revision ?? 0))
3644
+ response = await publish(Number(conflict.generation ?? 0));
3645
+ }
3646
+ if (response?.ok) {
3647
+ lastPushedHostedModelAuthCiphertext = ciphertext;
3648
+ lastPushedHostedModelAuthRevision = revision;
3649
+ }
3650
+ else if (response?.status !== 403 && response?.status !== 409) {
3651
+ throw new Error(`hosted model-auth push failed (${response?.status ?? "offline"})`);
3652
+ }
3653
+ }
3533
3654
  async function pushModelAuthToControlPlane(rotateKey = false) {
3534
3655
  if (!sessionAdvertiseTarget)
3535
3656
  return;
@@ -3540,6 +3661,13 @@ async function pushModelAuthToControlPlane(rotateKey = false) {
3540
3661
  // versa.
3541
3662
  await pushProviderSummaryToControlPlane();
3542
3663
  try {
3664
+ // A hosted runner holds only the explicitly granted snapshot and must never
3665
+ // overwrite the peer-to-peer account vault with that filtered subset.
3666
+ if (isHostedCustodyNode()) {
3667
+ // Hosted runners are recipients, never authorities for the custody set.
3668
+ // Letting one republish its stale filtered copy could undo a revocation.
3669
+ return;
3670
+ }
3543
3671
  // Only push credentials on the account-sync tier; a `sync: "node"` credential
3544
3672
  // stays local (per-credential opt-out). Tombstones still converge for all.
3545
3673
  const providers = await exportSyncableProviderAuth(credsDir);
@@ -3554,8 +3682,10 @@ async function pushModelAuthToControlPlane(rotateKey = false) {
3554
3682
  if (rotateKey)
3555
3683
  writeLocalModelAuthVaultKey(vaultKeyB64);
3556
3684
  const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt);
3557
- if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext)
3685
+ if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext) {
3686
+ await pushHostedModelAuthToControlPlane();
3558
3687
  return;
3688
+ }
3559
3689
  const push = await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext, rotated: rotateKey }) });
3560
3690
  if (!push?.ok) {
3561
3691
  if (rotateKey) {
@@ -3570,14 +3700,11 @@ async function pushModelAuthToControlPlane(rotateKey = false) {
3570
3700
  method: "PUT",
3571
3701
  body: JSON.stringify({ targetNodeId: identity.nodeId, wrappedByPublicKey: pairingStore.nodePublicKeyB64(), wrappedKey: pairingStore.wrapForNodePublicKey(pairingStore.nodePublicKeyB64(), vaultKeyB64) }),
3572
3702
  });
3573
- // Node-less inheritance: a HOSTED node also escrows the vault key to the control
3574
- // plane (sealed at rest, hosted-only) so the account's NEXT hosted ephemeral —
3575
- // possibly the only node — can decrypt this vault without a peer to wrap the key.
3576
- // Gated to hosted nodes; the CP double-checks the account is hosted. Best effort.
3577
- if (process.env.BIVY_GITHUB_HOSTED_TASKS) {
3578
- await modelAuthFetch("/node/model-auth-key/hosted-escrow", { method: "PUT", body: JSON.stringify({ vaultKeyB64 }) }).catch(() => { });
3579
- }
3580
3703
  lastPushedModelAuthCiphertext = ciphertext;
3704
+ // Publish a DIFFERENT ciphertext under a DIFFERENT key containing only
3705
+ // records with `unattended:true`. Escrowing this key cannot decrypt the E2E
3706
+ // account vault, which is the critical custody separation.
3707
+ await pushHostedModelAuthToControlPlane();
3581
3708
  }
3582
3709
  catch (error) {
3583
3710
  console.warn("[auth-sync] could not push model auth:", error.message);
@@ -5480,7 +5607,7 @@ function chooseHeadlessOAuthOption(prompt) {
5480
5607
  return (options.find((option) => /device|headless/i.test(`${option?.id ?? ""} ${option?.label ?? ""}`))?.id
5481
5608
  ?? options[0]?.id);
5482
5609
  }
5483
- async function startOAuthLogin(provider) {
5610
+ async function startOAuthLogin(provider, label = "default") {
5484
5611
  if (!isNativeOAuthProvider(provider)) {
5485
5612
  throw new Error(`Provider ${provider} does not support browser/subscription login`);
5486
5613
  }
@@ -5534,7 +5661,7 @@ async function startOAuthLogin(provider) {
5534
5661
  // Free-text/secret steps can't be answered from a remote device.
5535
5662
  throw new Error(`${input.message} Use terminal login for this provider step.`);
5536
5663
  };
5537
- loginModelOAuth(credsDir, provider, { signal: abort.signal, notify, prompt })
5664
+ loginModelOAuth(credsDir, provider, { signal: abort.signal, notify, prompt }, label)
5538
5665
  .then(() => {
5539
5666
  state.status = "done";
5540
5667
  settleInitial();
@@ -5544,8 +5671,11 @@ async function startOAuthLogin(provider) {
5544
5671
  // list. Push a fresh list to every connected client; otherwise Settings
5545
5672
  // clears the login form but continues rendering its stale "Not connected"
5546
5673
  // snapshot until a reload or a later manual refresh.
5547
- void listProvidersUnified()
5548
- .then((providers) => broadcast({ type: "providers.list", providers }))
5674
+ void Promise.all([listProvidersUnified(), listCredentialRecords(credsDir)])
5675
+ .then(([providers, records]) => {
5676
+ broadcast({ type: "providers.list", providers });
5677
+ broadcast({ type: "credentials.records", records });
5678
+ })
5549
5679
  .catch(() => { });
5550
5680
  void pushModelAuthToControlPlane();
5551
5681
  void refreshSessionAfterAuth();
@@ -9404,6 +9534,108 @@ app.post("/api/auth/api-key", async (req, res, next) => {
9404
9534
  next(error);
9405
9535
  }
9406
9536
  });
9537
+ // Item-addressed credential-vault REST API. This mirrors the relay commands so
9538
+ // direct/self-hosted and hosted clients use the same records and behavior.
9539
+ app.get("/api/auth/credentials", async (_req, res, next) => {
9540
+ try {
9541
+ res.json({ records: await listCredentialRecords(credsDir) });
9542
+ }
9543
+ catch (error) {
9544
+ next(error);
9545
+ }
9546
+ });
9547
+ app.get("/api/auth/credentials/account-export", async (_req, res, next) => {
9548
+ try {
9549
+ res.json({ entries: await exportAccountApiKeys(credsDir), records: await listCredentialRecords(credsDir), deletedAt: await exportRecordTombstones(credsDir) });
9550
+ }
9551
+ catch (error) {
9552
+ next(error);
9553
+ }
9554
+ });
9555
+ app.post("/api/auth/credentials", async (req, res, next) => {
9556
+ try {
9557
+ const provider = String(req.body?.provider ?? "").trim().toLowerCase();
9558
+ const label = String(req.body?.label ?? "default");
9559
+ const ref = typeof req.body?.ref === "string" ? req.body.ref.trim() : "";
9560
+ const requestedSync = req.body?.sync === "account" || req.body?.sync === "node" ? req.body.sync : undefined;
9561
+ if (ref)
9562
+ await setProviderReferenceLabeled(credsDir, provider, label, ref, requestedSync);
9563
+ else
9564
+ await setProviderApiKeyLabeled(credsDir, provider, label, String(req.body?.key ?? ""), requestedSync);
9565
+ await pushModelAuthToControlPlane();
9566
+ await refreshSessionAfterAuth();
9567
+ res.json({ ok: true, records: await listCredentialRecords(credsDir), providers: await listProvidersUnified() });
9568
+ }
9569
+ catch (error) {
9570
+ next(error);
9571
+ }
9572
+ });
9573
+ app.delete("/api/auth/credentials/:provider/:label", async (req, res, next) => {
9574
+ try {
9575
+ await removeProviderCredential(credsDir, String(req.params.provider), String(req.params.label));
9576
+ await pushModelAuthToControlPlane();
9577
+ await refreshSessionAfterAuth();
9578
+ res.json({ ok: true, records: await listCredentialRecords(credsDir), providers: await listProvidersUnified() });
9579
+ }
9580
+ catch (error) {
9581
+ next(error);
9582
+ }
9583
+ });
9584
+ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res, next) => {
9585
+ try {
9586
+ const sync = req.body?.sync === "node" ? "node" : "account";
9587
+ await setCredentialSync(credsDir, String(req.params.provider), String(req.params.label), sync);
9588
+ await pushModelAuthToControlPlane();
9589
+ res.json({ ok: true, records: await listCredentialRecords(credsDir) });
9590
+ }
9591
+ catch (error) {
9592
+ next(error);
9593
+ }
9594
+ });
9595
+ app.post("/api/auth/credentials/:provider/:label/unattended", async (req, res, next) => {
9596
+ try {
9597
+ await setCredentialUnattended(credsDir, String(req.params.provider), String(req.params.label), req.body?.unattended === true);
9598
+ await pushModelAuthToControlPlane();
9599
+ res.json({ ok: true, records: await listCredentialRecords(credsDir) });
9600
+ }
9601
+ catch (error) {
9602
+ next(error);
9603
+ }
9604
+ });
9605
+ app.post("/api/auth/credentials/:provider/:label/test", async (req, res, next) => {
9606
+ try {
9607
+ const provider = String(req.params.provider).trim().toLowerCase();
9608
+ const label = String(req.params.label);
9609
+ const result = await testProviderCredential(credsDir, provider, label);
9610
+ res.json({ provider, label, ...result, records: await listCredentialRecords(credsDir) });
9611
+ }
9612
+ catch (error) {
9613
+ next(error);
9614
+ }
9615
+ });
9616
+ app.get("/api/auth/credential-assignments", (_req, res) => {
9617
+ res.json({ presets: getCredentialPresets(credsDir) });
9618
+ });
9619
+ app.post("/api/auth/credential-assignments/active", async (req, res, next) => {
9620
+ try {
9621
+ setActiveCredentialPreset(credsDir, String(req.body?.active ?? ""));
9622
+ await refreshSessionAfterAuth();
9623
+ res.json({ ok: true, presets: getCredentialPresets(credsDir) });
9624
+ }
9625
+ catch (error) {
9626
+ next(error);
9627
+ }
9628
+ });
9629
+ app.post("/api/auth/credential-assignments", async (req, res, next) => {
9630
+ try {
9631
+ setCredentialPresetMapping(credsDir, String(req.body?.preset ?? "default"), String(req.body?.provider ?? ""), String(req.body?.label ?? ""));
9632
+ await refreshSessionAfterAuth();
9633
+ res.json({ ok: true, presets: getCredentialPresets(credsDir) });
9634
+ }
9635
+ catch (error) {
9636
+ next(error);
9637
+ }
9638
+ });
9407
9639
  // List model providers + auth status (the "Models & providers" screen). Shared by
9408
9640
  // every agent runtime via the credential vault, so one login here serves all.
9409
9641
  app.get("/api/auth/providers", async (_req, res, next) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.329",
3
+ "version": "0.10.1-staging.331",
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.",