@bivy/bivy 0.8.2 → 0.8.3-staging.117
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/bin/bivy.mjs +75 -18
- package/dist/runtime/auth-errors.js +1 -1
- package/dist/runtime/credential-store.js +98 -22
- package/dist/runtime/pi-auth.js +6 -2
- package/dist/server.js +67 -16
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -388,11 +388,11 @@ function hasModelConfig(config) {
|
|
|
388
388
|
|
|
389
389
|
const SETUP_AGENT_CHOICES = [
|
|
390
390
|
{ key: "p", label: "Pi (default, sign in to ChatGPT/Claude/Copilot or paste a model key)", runtimeId: "pi", needsBivyModel: true },
|
|
391
|
-
{ key: "c", label: "Claude Code", runtimeId: "claude-code-sdk",
|
|
392
|
-
{ key: "x", label: "Codex", runtimeId: "codex",
|
|
393
|
-
{ key: "o", label: "OpenCode", runtimeId: "opencode", needsBivyModel: false },
|
|
394
|
-
{ key: "g", label: "Gemini CLI", runtimeId: "gemini", needsBivyModel: false, loginHint: "
|
|
395
|
-
{ key: "q", label: "Qwen Code", runtimeId: "qwen", needsBivyModel: false, loginHint: "
|
|
391
|
+
{ key: "c", label: "Claude Code", runtimeId: "claude-code-sdk", command: "claude", authProbe: ["auth", "status"], needsBivyModel: false, loginHint: "Sign in through Claude Code" },
|
|
392
|
+
{ key: "x", label: "Codex", runtimeId: "codex", command: "codex", authProbe: ["login", "status"], needsBivyModel: false, loginHint: "Sign in through Codex" },
|
|
393
|
+
{ key: "o", label: "OpenCode", runtimeId: "opencode", command: "opencode", needsBivyModel: false },
|
|
394
|
+
{ key: "g", label: "Gemini CLI", runtimeId: "gemini", command: "gemini", needsBivyModel: false, loginHint: "Sign in through Gemini" },
|
|
395
|
+
{ key: "q", label: "Qwen Code", runtimeId: "qwen", command: "qwen", needsBivyModel: false, loginHint: "Sign in through Qwen" },
|
|
396
396
|
{ key: "a", label: "Aider", runtimeId: "aider", needsBivyModel: true },
|
|
397
397
|
{ key: "l", label: "Cline", runtimeId: "cline", needsBivyModel: false },
|
|
398
398
|
{ key: "r", label: "Crush", runtimeId: "crush", needsBivyModel: false },
|
|
@@ -402,6 +402,25 @@ function setupAgentByRuntime(runtimeId) {
|
|
|
402
402
|
return SETUP_AGENT_CHOICES.find((choice) => choice.runtimeId === runtimeId);
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
function setupAgentDefaultKey(config) {
|
|
406
|
+
const saved = setupAgentByRuntime(String(config?.env?.BIVY_RUNTIME || ""));
|
|
407
|
+
if (saved) return saved.key;
|
|
408
|
+
const installed = SETUP_AGENT_CHOICES.find((choice) => choice.command && commandExists(choice.command));
|
|
409
|
+
return installed?.key || "p";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function nativeAgentAuthDetected(choice) {
|
|
413
|
+
if (!choice?.command || !commandExists(choice.command)) return false;
|
|
414
|
+
if (Array.isArray(choice.authProbe)) {
|
|
415
|
+
const result = runQuiet(choice.command, choice.authProbe, { timeout: 10_000 });
|
|
416
|
+
if (result.code === 0) return true;
|
|
417
|
+
}
|
|
418
|
+
// Conservative file fallbacks for older CLI versions without a status command.
|
|
419
|
+
if (choice.command === "codex") return fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
|
|
420
|
+
if (choice.command === "claude") return fs.existsSync(path.join(os.homedir(), ".claude", ".credentials.json"));
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
405
424
|
function url(config) {
|
|
406
425
|
return `http://localhost:${config.port}`;
|
|
407
426
|
}
|
|
@@ -3205,11 +3224,20 @@ async function cmdSetup(args = []) {
|
|
|
3205
3224
|
}
|
|
3206
3225
|
console.log(c.dim(`Workspace: ${config.workspace} · local port: ${config.port} (change both in Settings)`));
|
|
3207
3226
|
|
|
3208
|
-
// 2.
|
|
3209
|
-
//
|
|
3210
|
-
//
|
|
3211
|
-
|
|
3212
|
-
|
|
3227
|
+
// 2. Agent first: authentication depends on who owns the selected agent's
|
|
3228
|
+
// credentials. Prefer an already-installed native agent on a fresh machine,
|
|
3229
|
+
// while retaining the saved choice when setup is re-run.
|
|
3230
|
+
console.log(c.bold("\n Agent\n"));
|
|
3231
|
+
const agentChoice = await askChoice(
|
|
3232
|
+
"Which agent do you want to try first?",
|
|
3233
|
+
SETUP_AGENT_CHOICES.map((choice) => ({
|
|
3234
|
+
key: choice.key,
|
|
3235
|
+
label: `${choice.label}${choice.command && commandExists(choice.command) ? " (installed)" : ""}`,
|
|
3236
|
+
})),
|
|
3237
|
+
setupAgentDefaultKey(config),
|
|
3238
|
+
);
|
|
3239
|
+
const setupAgent = SETUP_AGENT_CHOICES.find((choice) => choice.key === agentChoice) || setupAgentByRuntime("pi");
|
|
3240
|
+
if (setupAgent) {
|
|
3213
3241
|
config.env = { ...config.env, BIVY_RUNTIME: setupAgent.runtimeId };
|
|
3214
3242
|
saveConfig(config);
|
|
3215
3243
|
}
|
|
@@ -3218,7 +3246,7 @@ async function cmdSetup(args = []) {
|
|
|
3218
3246
|
agentReady = await ensureSetupAgent(setupAgent);
|
|
3219
3247
|
if (!agentReady) console.log(c.yellow(`${setupAgent.label} was not fully installed. Install it later from the app or with 'bivy agents:install'.`));
|
|
3220
3248
|
}
|
|
3221
|
-
console.log(c.dim(`Default agent: ${setupAgent?.label || "Pi"} (change
|
|
3249
|
+
console.log(c.dim(`Default agent: ${setupAgent?.label || "Pi"} (change any time in Settings)`));
|
|
3222
3250
|
|
|
3223
3251
|
// 3. Secure remote web/PWA access is what makes a Bivy-managed CLI useful:
|
|
3224
3252
|
// without a relay/control plane it adds nothing over running the agent
|
|
@@ -3306,7 +3334,9 @@ async function cmdSetup(args = []) {
|
|
|
3306
3334
|
// Bivy's provider login; offer it inline so setup cannot imply the first task
|
|
3307
3335
|
// is ready while the required credential is still absent. Agent-native auth is
|
|
3308
3336
|
// explained in the readiness checklist below because those CLIs own the flow.
|
|
3309
|
-
|
|
3337
|
+
let agentAuthReady = setupAgent?.needsBivyModel ? hasModelConfig(config) : nativeAgentAuthDetected(setupAgent);
|
|
3338
|
+
if (setupAgent?.needsBivyModel && !agentAuthReady) {
|
|
3339
|
+
console.log("\nBivy stores this credential encrypted on your machine, reuses it with compatible agents, and syncs it E2E-encrypted to your other Bivy nodes. Bivy Cloud never receives it in plaintext.");
|
|
3310
3340
|
const signInNow = await askYesNo("Sign in to a model now so your first task can run?", true);
|
|
3311
3341
|
if (signInNow) {
|
|
3312
3342
|
rl.pause();
|
|
@@ -3315,13 +3345,33 @@ async function cmdSetup(args = []) {
|
|
|
3315
3345
|
if (loginCode !== 0 || !hasModelConfig(loadConfig())) {
|
|
3316
3346
|
console.log(c.yellow("Model sign-in did not complete. The node can start, but an agent reply still requires 'bivy login'."));
|
|
3317
3347
|
}
|
|
3348
|
+
agentAuthReady = hasModelConfig(loadConfig());
|
|
3349
|
+
}
|
|
3350
|
+
} else if (setupAgent && !setupAgent.needsBivyModel) {
|
|
3351
|
+
if (agentAuthReady) {
|
|
3352
|
+
console.log(c.green(`\n ✓ Existing ${setupAgent.label} login detected — Bivy will reuse it in the terminal and PWA.`));
|
|
3353
|
+
} else if (setupAgent.command) {
|
|
3354
|
+
console.log(`\n${setupAgent.label} owns its login; Bivy reuses that native login and does not copy it into the shared vault.`);
|
|
3355
|
+
const signInNow = await askYesNo(`Open ${setupAgent.label} now to sign in? (Exit it when sign-in is complete.)`, true);
|
|
3356
|
+
if (signInNow) {
|
|
3357
|
+
rl.pause();
|
|
3358
|
+
const loginCode = await run(setupAgent.command, [], { cwd: config.workspace, env: startEnv(config) });
|
|
3359
|
+
rl.resume();
|
|
3360
|
+
agentAuthReady = loginCode === 0 || nativeAgentAuthDetected(setupAgent);
|
|
3361
|
+
}
|
|
3318
3362
|
}
|
|
3319
3363
|
}
|
|
3320
3364
|
|
|
3321
3365
|
// 4. Background service — always installed so the node keeps running (and stays
|
|
3322
3366
|
// reachable remotely) after you close this terminal. No prompt.
|
|
3323
3367
|
let started = false;
|
|
3324
|
-
if (
|
|
3368
|
+
if (process.env.BIVY_SETUP_SKIP_SERVICE === "1") {
|
|
3369
|
+
// Isolation seam for disposable/container smoke tests: the caller starts a
|
|
3370
|
+
// node with this BIVY_DATA_DIR/port and setup exercises the real wizard
|
|
3371
|
+
// without installing or replacing the host user's system service.
|
|
3372
|
+
started = await isReachable(config);
|
|
3373
|
+
console.log(c.dim(`\nBackground-service install skipped; using the isolated node already running at ${url(config)}.`));
|
|
3374
|
+
} else if (config.service) {
|
|
3325
3375
|
console.log(c.dim("\nBackground service already configured; restarting it."));
|
|
3326
3376
|
started = restartService();
|
|
3327
3377
|
} else {
|
|
@@ -3337,18 +3387,20 @@ async function cmdSetup(args = []) {
|
|
|
3337
3387
|
}
|
|
3338
3388
|
|
|
3339
3389
|
const finalConfig = loadConfig();
|
|
3340
|
-
const modelReady =
|
|
3390
|
+
const modelReady = setupAgent?.needsBivyModel ? hasModelConfig(finalConfig) : agentAuthReady;
|
|
3341
3391
|
console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
|
|
3342
3392
|
console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
|
|
3343
3393
|
console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
|
|
3344
|
-
console.log(` ${modelReady ?
|
|
3394
|
+
console.log(` ${modelReady ? c.green("✓") : c.yellow("!")} model ${modelReady ? (setupAgent?.needsBivyModel ? "credential configured" : "native agent login ready") : (setupAgent?.needsBivyModel ? "not configured — run 'bivy login'" : `${setupAgent?.loginHint || "sign in through the selected agent"}`)}`);
|
|
3345
3395
|
console.log(` ${c.dim("○")} repository chosen from the directory where you start Bivy`);
|
|
3346
3396
|
const ghReady = githubConnected(finalConfig);
|
|
3347
3397
|
console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("not connected — 'bivy github:connect' to list repos (optional)")}`);
|
|
3348
3398
|
console.log(` ${agentReady && modelReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady ? "ready to try" : "blocked by the stage above"}`);
|
|
3349
3399
|
console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
|
|
3350
|
-
|
|
3400
|
+
// Get the user into the product immediately; terminal commands are the
|
|
3401
|
+
// fallback/next-step checklist after the remote app has been opened or linked.
|
|
3351
3402
|
await finishSetupRemote(finalConfig, setupSession);
|
|
3403
|
+
printFirstRunSteps(modelReady, finalConfig, setupAgent);
|
|
3352
3404
|
}
|
|
3353
3405
|
|
|
3354
3406
|
// Read and delete the one-time account-session handoff written by relay:setup
|
|
@@ -3448,10 +3500,15 @@ function githubConnected(config = null) {
|
|
|
3448
3500
|
return Boolean(token);
|
|
3449
3501
|
}
|
|
3450
3502
|
|
|
3451
|
-
function printFirstRunSteps(modelReady = false, config = null) {
|
|
3503
|
+
function printFirstRunSteps(modelReady = false, config = null, setupAgent = null) {
|
|
3452
3504
|
console.log(" Run your first task:");
|
|
3453
3505
|
let n = 0;
|
|
3454
|
-
if (!modelReady)
|
|
3506
|
+
if (!modelReady) {
|
|
3507
|
+
const login = setupAgent?.needsBivyModel
|
|
3508
|
+
? `${c.cyan("bivy login")} ${c.dim("(stored in Bivy's encrypted vault)")}`
|
|
3509
|
+
: c.cyan(setupAgent?.command || "the selected agent's native CLI");
|
|
3510
|
+
console.log(` ${++n}. Model access: ${login}`);
|
|
3511
|
+
}
|
|
3455
3512
|
// GitHub is optional — "No repo" sessions work without it — so this only shows
|
|
3456
3513
|
// when nothing is connected yet, and never blocks the flow.
|
|
3457
3514
|
if (!githubConnected(config)) {
|
|
@@ -21,7 +21,7 @@ export function isModelAuthError(raw) {
|
|
|
21
21
|
const text = String(raw || "");
|
|
22
22
|
// Generic: an explicit 401, "unauthorized"/"authentication", or a
|
|
23
23
|
// missing/invalid bearer/api-key/token phrase.
|
|
24
|
-
if (/\b401\b|unauthorized|authentication|invalid x-api-key|(missing|invalid)[\s\S]*(bearer|api[\s_-]?key|token)/i.test(text))
|
|
24
|
+
if (/\b401\b|unauthorized|authentication|invalid x-api-key|(missing|no|invalid)[\s\S]*(bearer|api[\s_-]?key|token)/i.test(text))
|
|
25
25
|
return true;
|
|
26
26
|
// Codex app-server: websocket connect rejected with an HTTP 401/403.
|
|
27
27
|
if (/failed to connect to websocket[\s\S]*http error:\s*40[13]/i.test(text))
|
|
@@ -34,6 +34,17 @@ function isStoredCredential(value) {
|
|
|
34
34
|
function providerId(id) {
|
|
35
35
|
return String(id ?? "").trim().toLowerCase();
|
|
36
36
|
}
|
|
37
|
+
function sameCredentialContent(a, b) {
|
|
38
|
+
if (!a)
|
|
39
|
+
return false;
|
|
40
|
+
const { updatedAt: _a, ...left } = a;
|
|
41
|
+
const { updatedAt: _b, ...right } = b;
|
|
42
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
43
|
+
}
|
|
44
|
+
function withoutStoreMetadata(credential) {
|
|
45
|
+
const { updatedAt: _updatedAt, ...projected } = credential;
|
|
46
|
+
return projected;
|
|
47
|
+
}
|
|
37
48
|
/**
|
|
38
49
|
* Should an `incoming` credential replace the `local` one during a non-destructive
|
|
39
50
|
* `importAll` merge? Pure and exported so the convergence rule is unit-testable
|
|
@@ -65,6 +76,19 @@ export function preferIncomingCredential(local, incoming) {
|
|
|
65
76
|
return it > lt;
|
|
66
77
|
return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
|
|
67
78
|
}
|
|
79
|
+
/** A tombstone wins only when it is newer than the credential it would remove. */
|
|
80
|
+
export function tombstoneWins(credential, deletedAt) {
|
|
81
|
+
if (!Number.isFinite(deletedAt) || deletedAt <= 0)
|
|
82
|
+
return false;
|
|
83
|
+
if (!credential)
|
|
84
|
+
return true;
|
|
85
|
+
const updatedAt = Number(credential.updatedAt);
|
|
86
|
+
const refreshedAt = credential.type === "oauth" ? Number(credential.refreshedAt) : 0;
|
|
87
|
+
const credentialTime = Number.isFinite(updatedAt) && updatedAt > 0
|
|
88
|
+
? updatedAt
|
|
89
|
+
: Number.isFinite(refreshedAt) && refreshedAt > 0 ? refreshedAt : 0;
|
|
90
|
+
return deletedAt > credentialTime;
|
|
91
|
+
}
|
|
68
92
|
/**
|
|
69
93
|
* Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
|
|
70
94
|
*
|
|
@@ -104,10 +128,11 @@ export class BivyCredentialStore {
|
|
|
104
128
|
const id = providerId(provider);
|
|
105
129
|
if (!id)
|
|
106
130
|
return undefined;
|
|
107
|
-
|
|
131
|
+
const credential = this.readDocument().providers[id];
|
|
132
|
+
return credential ? withoutStoreMetadata(credential) : undefined;
|
|
108
133
|
}
|
|
109
134
|
async list() {
|
|
110
|
-
return Object.entries(this.
|
|
135
|
+
return Object.entries(this.readDocument().providers).map(([id, cred]) => ({
|
|
111
136
|
providerId: id,
|
|
112
137
|
type: cred.type,
|
|
113
138
|
...(cred.type === "oauth" ? { expiresAt: cred.expires } : {}),
|
|
@@ -126,15 +151,18 @@ export class BivyCredentialStore {
|
|
|
126
151
|
return this.enqueue(id, async () => {
|
|
127
152
|
await this.acquireLock();
|
|
128
153
|
try {
|
|
129
|
-
const
|
|
154
|
+
const document = this.readDocument();
|
|
155
|
+
const vault = document.providers;
|
|
130
156
|
const next = await fn(vault[id]);
|
|
131
157
|
if (next === undefined)
|
|
132
158
|
return vault[id];
|
|
133
159
|
if (!isStoredCredential(next))
|
|
134
160
|
throw new Error(`Invalid credential for "${id}"`);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
const stamped = { ...next, updatedAt: Date.now() };
|
|
162
|
+
vault[id] = stamped;
|
|
163
|
+
delete document.deletedAt[id];
|
|
164
|
+
this.writeDocument(document);
|
|
165
|
+
return stamped;
|
|
138
166
|
}
|
|
139
167
|
finally {
|
|
140
168
|
await this.releaseLock();
|
|
@@ -148,11 +176,14 @@ export class BivyCredentialStore {
|
|
|
148
176
|
await this.enqueue(id, async () => {
|
|
149
177
|
await this.acquireLock();
|
|
150
178
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
179
|
+
const document = this.readDocument();
|
|
180
|
+
const hadCredential = id in document.providers;
|
|
181
|
+
delete document.providers[id];
|
|
182
|
+
const deletedAt = Date.now();
|
|
183
|
+
if (!hadCredential && (document.deletedAt[id] ?? 0) >= deletedAt)
|
|
153
184
|
return;
|
|
154
|
-
|
|
155
|
-
this.
|
|
185
|
+
document.deletedAt[id] = deletedAt;
|
|
186
|
+
this.writeDocument(document);
|
|
156
187
|
}
|
|
157
188
|
finally {
|
|
158
189
|
await this.releaseLock();
|
|
@@ -169,7 +200,11 @@ export class BivyCredentialStore {
|
|
|
169
200
|
}
|
|
170
201
|
/** Every stored credential, keyed by provider id — the cross-node snapshot. */
|
|
171
202
|
async exportAll() {
|
|
172
|
-
return this.
|
|
203
|
+
return this.readDocument().providers;
|
|
204
|
+
}
|
|
205
|
+
/** Provider deletions retained for cross-node convergence. */
|
|
206
|
+
async exportTombstones() {
|
|
207
|
+
return this.readDocument().deletedAt;
|
|
173
208
|
}
|
|
174
209
|
/** The plaintext `auth.json` path an agent's own CLI/TUI reads (`<plaintextDir>/auth.json`). */
|
|
175
210
|
get legacyAuthPath() {
|
|
@@ -184,7 +219,7 @@ export class BivyCredentialStore {
|
|
|
184
219
|
* `ingestPlaintext()` to fold TUI-time logins back into the vault.
|
|
185
220
|
*/
|
|
186
221
|
materializePlaintext() {
|
|
187
|
-
const vault = this.
|
|
222
|
+
const vault = Object.fromEntries(Object.entries(this.readDocument().providers).map(([id, credential]) => [id, withoutStoreMetadata(credential)]));
|
|
188
223
|
const next = `${JSON.stringify(vault, null, 2)}\n`;
|
|
189
224
|
// Write only when the projection actually changes. This keeps the file's
|
|
190
225
|
// mtime stable so a live re-materialize (on a vault change while a native Pi
|
|
@@ -222,17 +257,25 @@ export class BivyCredentialStore {
|
|
|
222
257
|
* (rotated refresh tokens are single-use — importing a stale one breaks the
|
|
223
258
|
* next refresh). Runs under the lock so it can't race a refresh.
|
|
224
259
|
*/
|
|
225
|
-
async importAll(snapshot) {
|
|
260
|
+
async importAll(snapshot, deletedAt = {}) {
|
|
226
261
|
await this.acquireLock();
|
|
227
262
|
try {
|
|
228
|
-
const
|
|
263
|
+
const document = this.readDocument();
|
|
264
|
+
const vault = document.providers;
|
|
229
265
|
let imported = 0;
|
|
230
266
|
let changed = false;
|
|
231
267
|
for (const [rawId, incoming] of Object.entries(snapshot ?? {})) {
|
|
232
268
|
const id = providerId(rawId);
|
|
233
269
|
if (!id || !isStoredCredential(incoming))
|
|
234
270
|
continue;
|
|
271
|
+
if (tombstoneWins(incoming, document.deletedAt[id] ?? 0))
|
|
272
|
+
continue;
|
|
235
273
|
const local = vault[id];
|
|
274
|
+
// Store-owned timestamps are intentionally not part of credential
|
|
275
|
+
// content. An older sender may re-state the same key without updatedAt;
|
|
276
|
+
// preserve the local stamp and avoid a pointless re-encrypt/watch loop.
|
|
277
|
+
if (sameCredentialContent(local, incoming))
|
|
278
|
+
continue;
|
|
236
279
|
// Freshest-wins, rotation-safe (see preferIncomingCredential): a lagging
|
|
237
280
|
// or refresh-less snapshot must not overwrite a fresher local login.
|
|
238
281
|
if (!preferIncomingCredential(local, incoming))
|
|
@@ -245,11 +288,31 @@ export class BivyCredentialStore {
|
|
|
245
288
|
// vault watcher (materialize → ingest → import loop protection).
|
|
246
289
|
if (JSON.stringify(local) !== JSON.stringify(incoming)) {
|
|
247
290
|
vault[id] = incoming;
|
|
291
|
+
const incomingUpdatedAt = Number(incoming.updatedAt);
|
|
292
|
+
if (!Number.isFinite(document.deletedAt[id]) || incomingUpdatedAt > document.deletedAt[id]) {
|
|
293
|
+
delete document.deletedAt[id];
|
|
294
|
+
}
|
|
248
295
|
changed = true;
|
|
249
296
|
}
|
|
250
297
|
}
|
|
298
|
+
for (const [rawId, rawDeletedAt] of Object.entries(deletedAt ?? {})) {
|
|
299
|
+
const id = providerId(rawId);
|
|
300
|
+
const deletionTime = Number(rawDeletedAt);
|
|
301
|
+
if (!id || !Number.isFinite(deletionTime) || deletionTime <= 0)
|
|
302
|
+
continue;
|
|
303
|
+
if ((document.deletedAt[id] ?? 0) >= deletionTime)
|
|
304
|
+
continue;
|
|
305
|
+
// A later re-login makes an older tombstone obsolete; do not retain and
|
|
306
|
+
// re-export it alongside the live credential.
|
|
307
|
+
if (vault[id] && !tombstoneWins(vault[id], deletionTime))
|
|
308
|
+
continue;
|
|
309
|
+
document.deletedAt[id] = deletionTime;
|
|
310
|
+
if (tombstoneWins(vault[id], deletionTime))
|
|
311
|
+
delete vault[id];
|
|
312
|
+
changed = true;
|
|
313
|
+
}
|
|
251
314
|
if (changed)
|
|
252
|
-
this.
|
|
315
|
+
this.writeDocument(document);
|
|
253
316
|
return imported;
|
|
254
317
|
}
|
|
255
318
|
finally {
|
|
@@ -281,14 +344,14 @@ export class BivyCredentialStore {
|
|
|
281
344
|
catch { /* best effort */ }
|
|
282
345
|
return key;
|
|
283
346
|
}
|
|
284
|
-
|
|
347
|
+
readDocument() {
|
|
285
348
|
this.ensureMigrated();
|
|
286
349
|
let raw;
|
|
287
350
|
try {
|
|
288
351
|
raw = fs.readFileSync(this.blobFile, "utf8");
|
|
289
352
|
}
|
|
290
353
|
catch {
|
|
291
|
-
return {};
|
|
354
|
+
return { v: 2, providers: {}, deletedAt: {} };
|
|
292
355
|
}
|
|
293
356
|
let parsed;
|
|
294
357
|
try {
|
|
@@ -297,13 +360,26 @@ export class BivyCredentialStore {
|
|
|
297
360
|
catch {
|
|
298
361
|
// A truncated/corrupt/undecryptable vault is treated as empty rather than
|
|
299
362
|
// taking the node down: every caller already handles "no credential".
|
|
300
|
-
return {};
|
|
363
|
+
return { v: 2, providers: {}, deletedAt: {} };
|
|
364
|
+
}
|
|
365
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.v === 2) {
|
|
366
|
+
const raw = parsed;
|
|
367
|
+
const tombstones = {};
|
|
368
|
+
if (raw.deletedAt && typeof raw.deletedAt === "object" && !Array.isArray(raw.deletedAt)) {
|
|
369
|
+
for (const [id, value] of Object.entries(raw.deletedAt)) {
|
|
370
|
+
const normalized = providerId(id);
|
|
371
|
+
const stamp = Number(value);
|
|
372
|
+
if (normalized && Number.isFinite(stamp) && stamp > 0)
|
|
373
|
+
tombstones[normalized] = stamp;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { v: 2, providers: normalizeMap(raw.providers), deletedAt: tombstones };
|
|
301
377
|
}
|
|
302
|
-
return normalizeMap(parsed);
|
|
378
|
+
return { v: 2, providers: normalizeMap(parsed), deletedAt: {} };
|
|
303
379
|
}
|
|
304
|
-
|
|
380
|
+
writeDocument(document) {
|
|
305
381
|
fs.mkdirSync(this.vaultDir, { recursive: true, mode: 0o700 });
|
|
306
|
-
const ciphertext = seal(this.key(), JSON.stringify(
|
|
382
|
+
const ciphertext = seal(this.key(), JSON.stringify(document));
|
|
307
383
|
const tmp = `${this.blobFile}.${process.pid}.tmp`;
|
|
308
384
|
fs.writeFileSync(tmp, `${ciphertext}\n`, { mode: 0o600 });
|
|
309
385
|
fs.renameSync(tmp, this.blobFile);
|
|
@@ -335,7 +411,7 @@ export class BivyCredentialStore {
|
|
|
335
411
|
if (Object.keys(map).length === 0)
|
|
336
412
|
return;
|
|
337
413
|
try {
|
|
338
|
-
this.
|
|
414
|
+
this.writeDocument({ v: 2, providers: map, deletedAt: {} });
|
|
339
415
|
}
|
|
340
416
|
catch {
|
|
341
417
|
// If we can't write the encrypted vault, fall back to reading legacy on
|
package/dist/runtime/pi-auth.js
CHANGED
|
@@ -31,6 +31,10 @@ export async function listProviders(credsDir, piDir) {
|
|
|
31
31
|
export async function exportProviderAuth(credsDir) {
|
|
32
32
|
return createCredentialVault(credsDir).exportAll();
|
|
33
33
|
}
|
|
34
|
+
/** Export provider revocations for cross-node convergence. */
|
|
35
|
+
export async function exportProviderAuthTombstones(credsDir) {
|
|
36
|
+
return createCredentialVault(credsDir).exportTombstones();
|
|
37
|
+
}
|
|
34
38
|
/**
|
|
35
39
|
* Import a cross-node provider auth snapshot into the local vault.
|
|
36
40
|
*
|
|
@@ -40,8 +44,8 @@ export async function exportProviderAuth(credsDir) {
|
|
|
40
44
|
* snapshot (rotated refresh tokens are single-use). Provider removal propagates
|
|
41
45
|
* via removeProvider() re-pushing, not destructive imports.
|
|
42
46
|
*/
|
|
43
|
-
export async function importProviderAuth(credsDir, providers) {
|
|
44
|
-
await createCredentialVault(credsDir).importAll(providers);
|
|
47
|
+
export async function importProviderAuth(credsDir, providers, deletedAt = {}) {
|
|
48
|
+
await createCredentialVault(credsDir).importAll(providers, deletedAt);
|
|
45
49
|
}
|
|
46
50
|
/** Store an API key for a model provider (shared by every agent via the vault). */
|
|
47
51
|
export async function setProviderApiKey(credsDir, provider, key) {
|
package/dist/server.js
CHANGED
|
@@ -32,7 +32,7 @@ import { decideOAuthLoginSweep } from "./runtime/oauth/oauth-login-sweep.js";
|
|
|
32
32
|
import { listCodexSessions, loadCodexTranscript, discoverCodexSessionForCwd } from "./runtime/codex-sessions.js";
|
|
33
33
|
import { dedupeSessionSummaries } from "./session-identity.js";
|
|
34
34
|
import { discoverPiSessionForCwd } from "./runtime/pi-session-discovery.js";
|
|
35
|
-
import { exportProviderAuth, importProviderAuth, listProviders, removeProvider, setProviderApiKey, setProviderCredential } from "./runtime/pi-auth.js";
|
|
35
|
+
import { exportProviderAuth, exportProviderAuthTombstones, importProviderAuth, listProviders, removeProvider, setProviderApiKey, setProviderCredential } from "./runtime/pi-auth.js";
|
|
36
36
|
import { loadLocalModels, upsertLocalProvider, removeLocalProviderEntry, listLocalProviderSummaries, exportLocalModels, importLocalModels, toPiModelsConfig, normalizeProviderId, } from "./runtime/local-model-store.js";
|
|
37
37
|
import { execEphemeralRequest } from "./ephemeral-exec.js";
|
|
38
38
|
import { ApprovalManager } from "./approval.js";
|
|
@@ -3577,7 +3577,7 @@ const RELAY_COMMANDS = {
|
|
|
3577
3577
|
// this the relay client (PWA) is stranded on "Working…" forever with
|
|
3578
3578
|
// only a session.error toast. Clear working so a terminal state reaches it.
|
|
3579
3579
|
clearSessionWorking(record);
|
|
3580
|
-
broadcast({ type: "session.error", sessionId: record.id, error:
|
|
3580
|
+
broadcast({ type: "session.error", sessionId: record.id, error: actionableAgentError(record.runtimeId, error) });
|
|
3581
3581
|
});
|
|
3582
3582
|
},
|
|
3583
3583
|
async "session.fork.export"(msg) {
|
|
@@ -3901,6 +3901,12 @@ function writeLocalModelAuthVaultKey(vaultKeyB64) {
|
|
|
3901
3901
|
}
|
|
3902
3902
|
catch { /* best effort */ }
|
|
3903
3903
|
}
|
|
3904
|
+
function forgetLocalModelAuthVaultKey() {
|
|
3905
|
+
try {
|
|
3906
|
+
fs.rmSync(modelAuthVaultKeyPath, { force: true });
|
|
3907
|
+
}
|
|
3908
|
+
catch { /* best effort */ }
|
|
3909
|
+
}
|
|
3904
3910
|
function ensureLocalModelAuthVaultKey() {
|
|
3905
3911
|
const existing = readLocalModelAuthVaultKey();
|
|
3906
3912
|
if (existing)
|
|
@@ -3914,13 +3920,13 @@ function ensureLocalModelAuthVaultKey() {
|
|
|
3914
3920
|
// internal credential shape, so nodes on different agent/pi versions can't
|
|
3915
3921
|
// silently exchange an incompatible structure. `decrypt` tolerates a bare map
|
|
3916
3922
|
// for forward-safety.
|
|
3917
|
-
const MODEL_AUTH_ENVELOPE_VERSION =
|
|
3918
|
-
function encryptModelAuthProviders(providers, localModels, vaultKeyB64) {
|
|
3919
|
-
const envelope = { v: MODEL_AUTH_ENVELOPE_VERSION, providers, localModels };
|
|
3923
|
+
const MODEL_AUTH_ENVELOPE_VERSION = 2;
|
|
3924
|
+
function encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64) {
|
|
3925
|
+
const envelope = { v: MODEL_AUTH_ENVELOPE_VERSION, providers, deletedAt, localModels };
|
|
3920
3926
|
return seal(Buffer.from(vaultKeyB64, "base64"), JSON.stringify(envelope));
|
|
3921
3927
|
}
|
|
3922
3928
|
function decryptModelAuthEnvelope(ciphertext, vaultKeyB64) {
|
|
3923
|
-
const empty = { providers: {}, localModels: {} };
|
|
3929
|
+
const empty = { providers: {}, deletedAt: {}, localModels: {} };
|
|
3924
3930
|
const parsed = JSON.parse(open(Buffer.from(vaultKeyB64, "base64"), ciphertext));
|
|
3925
3931
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
3926
3932
|
return empty;
|
|
@@ -3929,11 +3935,12 @@ function decryptModelAuthEnvelope(ciphertext, vaultKeyB64) {
|
|
|
3929
3935
|
if (typeof envelope.v === "number" && envelope.providers && typeof envelope.providers === "object") {
|
|
3930
3936
|
return {
|
|
3931
3937
|
providers: envelope.providers,
|
|
3938
|
+
deletedAt: envelope.deletedAt && typeof envelope.deletedAt === "object" ? envelope.deletedAt : {},
|
|
3932
3939
|
localModels: (envelope.localModels && typeof envelope.localModels === "object" ? envelope.localModels : {}),
|
|
3933
3940
|
};
|
|
3934
3941
|
}
|
|
3935
3942
|
// Back-compat: a bare `{ [id]: Credential }` map (pre-envelope / other sender).
|
|
3936
|
-
return { providers: parsed, localModels: {} };
|
|
3943
|
+
return { providers: parsed, deletedAt: {}, localModels: {} };
|
|
3937
3944
|
}
|
|
3938
3945
|
async function modelAuthFetch(pathname, init = {}) {
|
|
3939
3946
|
if (!sessionAdvertiseTarget)
|
|
@@ -4023,8 +4030,23 @@ async function syncModelAuthFromControlPlane() {
|
|
|
4023
4030
|
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
4024
4031
|
}
|
|
4025
4032
|
if (data.vault?.ciphertext && vaultKeyB64) {
|
|
4026
|
-
|
|
4027
|
-
|
|
4033
|
+
let decrypted;
|
|
4034
|
+
try {
|
|
4035
|
+
decrypted = decryptModelAuthEnvelope(data.vault.ciphertext, vaultKeyB64);
|
|
4036
|
+
}
|
|
4037
|
+
catch (error) {
|
|
4038
|
+
// Most commonly this node cached the previous generation while another
|
|
4039
|
+
// survivor completed a revoke-triggered re-key. Forget it and request a
|
|
4040
|
+
// wrap of the current key; retaining it would make every poll fail forever.
|
|
4041
|
+
forgetLocalModelAuthVaultKey();
|
|
4042
|
+
lastPushedModelAuthCiphertext = "";
|
|
4043
|
+
await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
|
|
4044
|
+
ensureModelAuthColdStart();
|
|
4045
|
+
console.warn("[auth-sync] cached vault key is stale; requested the rotated key:", error.message);
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4048
|
+
const { providers, deletedAt, localModels } = decrypted;
|
|
4049
|
+
await importProviderAuth(credsDir, providers, deletedAt);
|
|
4028
4050
|
importLocalModels(localModelsDir, localModels);
|
|
4029
4051
|
// A synced key or config change can both alter the projection, so always
|
|
4030
4052
|
// regenerate it (and refresh the panel) after importing the vault.
|
|
@@ -4035,6 +4057,8 @@ async function syncModelAuthFromControlPlane() {
|
|
|
4035
4057
|
// the cold-start race is over.
|
|
4036
4058
|
stopModelAuthColdStart();
|
|
4037
4059
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
4060
|
+
if (data.vault.needsRotation)
|
|
4061
|
+
await pushModelAuthToControlPlane(true);
|
|
4038
4062
|
}
|
|
4039
4063
|
else if (data.vault?.ciphertext && !vaultKeyB64) {
|
|
4040
4064
|
await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
|
|
@@ -4067,7 +4091,7 @@ async function processModelAuthKeyRequests(requests) {
|
|
|
4067
4091
|
});
|
|
4068
4092
|
}
|
|
4069
4093
|
}
|
|
4070
|
-
async function pushModelAuthToControlPlane() {
|
|
4094
|
+
async function pushModelAuthToControlPlane(rotateKey = false) {
|
|
4071
4095
|
if (!sessionAdvertiseTarget)
|
|
4072
4096
|
return;
|
|
4073
4097
|
// Piggyback the (plaintext, non-secret) provider status summary on every
|
|
@@ -4078,12 +4102,25 @@ async function pushModelAuthToControlPlane() {
|
|
|
4078
4102
|
await pushProviderSummaryToControlPlane();
|
|
4079
4103
|
try {
|
|
4080
4104
|
const providers = await exportProviderAuth(credsDir);
|
|
4105
|
+
const deletedAt = await exportProviderAuthTombstones(credsDir);
|
|
4081
4106
|
const localModels = exportLocalModels(localModelsDir);
|
|
4082
|
-
const
|
|
4083
|
-
const
|
|
4084
|
-
if (
|
|
4107
|
+
const previousKey = readLocalModelAuthVaultKey();
|
|
4108
|
+
const vaultKeyB64 = rotateKey ? randomBytes(32).toString("base64") : ensureLocalModelAuthVaultKey();
|
|
4109
|
+
if (rotateKey)
|
|
4110
|
+
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
4111
|
+
const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64);
|
|
4112
|
+
if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext)
|
|
4085
4113
|
return;
|
|
4086
|
-
await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext }) });
|
|
4114
|
+
const push = await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext, rotated: rotateKey }) });
|
|
4115
|
+
if (!push?.ok) {
|
|
4116
|
+
if (rotateKey) {
|
|
4117
|
+
if (previousKey)
|
|
4118
|
+
writeLocalModelAuthVaultKey(previousKey);
|
|
4119
|
+
else
|
|
4120
|
+
forgetLocalModelAuthVaultKey();
|
|
4121
|
+
}
|
|
4122
|
+
throw new Error(`model-auth vault push failed (${push?.status ?? "offline"})`);
|
|
4123
|
+
}
|
|
4087
4124
|
await modelAuthFetch("/node/model-auth-key/wrapped", {
|
|
4088
4125
|
method: "PUT",
|
|
4089
4126
|
body: JSON.stringify({ targetNodeId: identity.nodeId, wrappedByPublicKey: pairingStore.nodePublicKeyB64(), wrappedKey: pairingStore.wrapForNodePublicKey(pairingStore.nodePublicKeyB64(), vaultKeyB64) }),
|
|
@@ -7353,6 +7390,20 @@ function humanizeAgentError(raw) {
|
|
|
7353
7390
|
}
|
|
7354
7391
|
return text;
|
|
7355
7392
|
}
|
|
7393
|
+
function actionableAgentError(runtimeId, error) {
|
|
7394
|
+
const raw = humanizeAgentError(error instanceof Error ? error.message : String(error));
|
|
7395
|
+
const id = String(runtimeId || "").toLowerCase();
|
|
7396
|
+
if (isModelAuthError(raw) || /reading ['"]provider['"]|no api key found/i.test(raw)) {
|
|
7397
|
+
if (id.includes("claude"))
|
|
7398
|
+
return "Claude Code is not signed in. Run `claude` once, complete sign-in, then retry; the same login works from Bivy and the PWA.";
|
|
7399
|
+
if (id.startsWith("codex"))
|
|
7400
|
+
return "Codex is not signed in. Run `codex login`, then retry; the same login works from Bivy and the PWA.";
|
|
7401
|
+
if (id === "pi" || id === "aider")
|
|
7402
|
+
return "No model credential is configured. Run `bivy login`, then retry. This is only required once and compatible credentials sync E2E-encrypted to your other Bivy nodes.";
|
|
7403
|
+
return "The selected agent needs model authentication. Sign in through its native CLI, then retry.";
|
|
7404
|
+
}
|
|
7405
|
+
return raw;
|
|
7406
|
+
}
|
|
7356
7407
|
/**
|
|
7357
7408
|
* A turn that ended in a *terminal* model/provider failure the runtime would
|
|
7358
7409
|
* otherwise swallow. `agent_end` carries the turn's messages and whether the
|
|
@@ -7579,7 +7630,7 @@ function attachSessionListeners(record) {
|
|
|
7579
7630
|
record.lastFailureAt = Date.now();
|
|
7580
7631
|
metadata.touchSession(record.id, "failed");
|
|
7581
7632
|
scheduleAdvertise();
|
|
7582
|
-
broadcast({ type: "session.error", sessionId: record.id, error: messageError });
|
|
7633
|
+
broadcast({ type: "session.error", sessionId: record.id, error: actionableAgentError(record.runtimeId, messageError) });
|
|
7583
7634
|
// If the terminal error is an auth failure (expired key/token → 4xx),
|
|
7584
7635
|
// also raise the sign-in sheet for the failing provider.
|
|
7585
7636
|
maybeSignalAuthRequired(record, messageError);
|
|
@@ -10041,7 +10092,7 @@ app.post("/api/session", async (req, res, next) => {
|
|
|
10041
10092
|
res.json({ id: session.id, workspace: session.workspace, source: session.source, branch: session.worktree?.branch, prUrl: session.prUrl, sessionFile: session.sessionFile, name: session.session.getName(), runtimeId: session.runtimeId, agentName: getRuntime(session.runtimeId).displayName, model: publicModel(session.session.getCurrentModel(), session.session.getCurrentModel()) });
|
|
10042
10093
|
}
|
|
10043
10094
|
catch (error) {
|
|
10044
|
-
res.status(400).json({ error:
|
|
10095
|
+
res.status(400).json({ error: actionableAgentError(agentFrom(req.body ?? {}) ?? defaultRuntimeId, error) });
|
|
10045
10096
|
}
|
|
10046
10097
|
});
|
|
10047
10098
|
app.get("/api/github/issues", async (_req, res, next) => {
|