@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 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", needsBivyModel: false, loginHint: "If Claude asks you to sign in, run: claude" },
392
- { key: "x", label: "Codex", runtimeId: "codex", needsBivyModel: false, loginHint: "If Codex asks you to sign in, run: codex" },
393
- { key: "o", label: "OpenCode", runtimeId: "opencode", needsBivyModel: false },
394
- { key: "g", label: "Gemini CLI", runtimeId: "gemini", needsBivyModel: false, loginHint: "If Gemini asks you to sign in, run: gemini" },
395
- { key: "q", label: "Qwen Code", runtimeId: "qwen", needsBivyModel: false, loginHint: "If Qwen asks you to sign in, run: qwen" },
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. Default agent stays Pi unless one was already chosen. Pi is the built-in
3209
- // default and is changeable per-session or in Settings, so setup doesn't ask.
3210
- // Model/provider sign-in is left to the agent's own CLI/TUI or Settings.
3211
- const setupAgent = setupAgentByRuntime(String(config.env.BIVY_RUNTIME || "pi")) || setupAgentByRuntime("pi");
3212
- if (!config.env.BIVY_RUNTIME && setupAgent) {
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 in Settings; sign into your model from the agent's CLI/TUI or Settings → Keys & OAuth)`));
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
- if (setupAgent?.needsBivyModel && !hasModelConfig(config)) {
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 (config.service) {
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 = !setupAgent?.needsBivyModel || hasModelConfig(finalConfig);
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 ? (setupAgent?.needsBivyModel ? c.green("✓") : c.dim("○")) : c.yellow("!")} model ${modelReady ? (setupAgent?.needsBivyModel ? "credential configured" : "agent-managed verified by the first task") : "not configured — run 'bivy login'"}`);
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
- printFirstRunSteps(modelReady, finalConfig);
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) console.log(` ${++n}. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
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
- return this.readBlob()[id];
131
+ const credential = this.readDocument().providers[id];
132
+ return credential ? withoutStoreMetadata(credential) : undefined;
108
133
  }
109
134
  async list() {
110
- return Object.entries(this.readBlob()).map(([id, cred]) => ({
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 vault = this.readBlob();
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
- vault[id] = next;
136
- this.writeBlob(vault);
137
- return next;
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 vault = this.readBlob();
152
- if (!(id in vault))
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
- delete vault[id];
155
- this.writeBlob(vault);
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.readBlob();
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.readBlob();
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 vault = this.readBlob();
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.writeBlob(vault);
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
- readBlob() {
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
- writeBlob(vault) {
380
+ writeDocument(document) {
305
381
  fs.mkdirSync(this.vaultDir, { recursive: true, mode: 0o700 });
306
- const ciphertext = seal(this.key(), JSON.stringify(vault));
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.writeBlob(map);
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
@@ -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: String(error?.stack ?? 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 = 1;
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
- const { providers, localModels } = decryptModelAuthEnvelope(data.vault.ciphertext, vaultKeyB64);
4027
- await importProviderAuth(credsDir, providers);
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 vaultKeyB64 = ensureLocalModelAuthVaultKey();
4083
- const ciphertext = encryptModelAuthProviders(providers, localModels, vaultKeyB64);
4084
- if (ciphertext === lastPushedModelAuthCiphertext)
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: error instanceof Error ? error.message : String(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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.8.2",
3
+ "version": "0.8.3-staging.117",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",