@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.
- package/dist/agents/claude-code/runtime.js +1 -1
- package/dist/credentials/api.js +75 -21
- package/dist/credentials/resolver.js +31 -10
- package/dist/runtime/bivy-provider-catalog.js +64 -0
- package/dist/runtime/model-catalog.js +45 -6
- package/dist/runtime/oauth/model-oauth.js +2 -2
- package/dist/runtime/process.js +1 -1
- package/dist/runtime/protocol.js +3 -3
- package/dist/runtime/provider-catalog.js +37 -9
- package/dist/server.js +275 -43
- package/package.json +1 -1
|
@@ -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 {};
|
package/dist/credentials/api.js
CHANGED
|
@@ -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.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
*
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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()
|
|
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 =
|
|
320
|
+
const ping = providerPing(id, token);
|
|
294
321
|
if (!ping)
|
|
295
322
|
return { ok: false, at, reason: "not_supported" };
|
|
296
|
-
const { url, headers } = ping
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
42
|
-
if (!
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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).
|
|
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) {
|
package/dist/runtime/process.js
CHANGED
|
@@ -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
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3446
|
-
|
|
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
|
-
//
|
|
3451
|
-
//
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
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 (
|
|
3526
|
+
if (targetVault?.ciphertext && vaultKeyB64) {
|
|
3459
3527
|
let decrypted;
|
|
3460
3528
|
try {
|
|
3461
|
-
decrypted = decryptModelAuthEnvelope(
|
|
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
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3584
|
+
if (!hostedCustody && data.vault?.needsRotation)
|
|
3496
3585
|
await pushModelAuthToControlPlane(true);
|
|
3497
3586
|
}
|
|
3498
|
-
else if (
|
|
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 (!
|
|
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) =>
|
|
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