@bivy/bivy 0.8.3-staging.116 → 0.8.3-staging.118

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
@@ -131,6 +131,7 @@ const packaged = fs.existsSync(path.join(repoRoot, "dist", "server.js"));
131
131
  const serverEntry = path.join(repoRoot, packaged ? "dist/server.js" : "src/server.ts");
132
132
  const nativePiEntry = path.join(repoRoot, packaged ? "dist/native-pi.js" : "src/native-pi.ts");
133
133
  const bivyLoginEntry = path.join(repoRoot, packaged ? "dist/bivy-login.js" : "src/bivy-login.ts");
134
+ const credentialIngestEntry = path.join(repoRoot, packaged ? "dist/credential-ingest-cli.js" : "src/credential-ingest-cli.ts");
134
135
  const relaySetupEntry = path.join(repoRoot, packaged ? "dist/relay-setup.js" : "src/relay-setup.ts");
135
136
  // Dependency-free hosted-endpoint helper. Shipped to dist/ in the release
136
137
  // artifact (src/ is not packaged), so resolve it the same packaged-aware way as
@@ -388,11 +389,11 @@ function hasModelConfig(config) {
388
389
 
389
390
  const SETUP_AGENT_CHOICES = [
390
391
  { 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" },
392
+ { key: "c", label: "Claude Code", runtimeId: "claude-code-sdk", command: "claude", authProbe: ["auth", "status"], needsBivyModel: false, loginHint: "Sign in through Claude Code" },
393
+ { key: "x", label: "Codex", runtimeId: "codex", command: "codex", authProbe: ["login", "status"], needsBivyModel: false, loginHint: "Sign in through Codex" },
394
+ { key: "o", label: "OpenCode", runtimeId: "opencode", command: "opencode", needsBivyModel: false },
395
+ { key: "g", label: "Gemini CLI", runtimeId: "gemini", command: "gemini", needsBivyModel: false, loginHint: "Sign in through Gemini" },
396
+ { key: "q", label: "Qwen Code", runtimeId: "qwen", command: "qwen", needsBivyModel: false, loginHint: "Sign in through Qwen" },
396
397
  { key: "a", label: "Aider", runtimeId: "aider", needsBivyModel: true },
397
398
  { key: "l", label: "Cline", runtimeId: "cline", needsBivyModel: false },
398
399
  { key: "r", label: "Crush", runtimeId: "crush", needsBivyModel: false },
@@ -402,6 +403,36 @@ function setupAgentByRuntime(runtimeId) {
402
403
  return SETUP_AGENT_CHOICES.find((choice) => choice.runtimeId === runtimeId);
403
404
  }
404
405
 
406
+ function setupAgentDefaultKey(config) {
407
+ const saved = setupAgentByRuntime(String(config?.env?.BIVY_RUNTIME || ""));
408
+ if (saved) return saved.key;
409
+ const installed = SETUP_AGENT_CHOICES.find((choice) => choice.command && commandExists(choice.command));
410
+ return installed?.key || "p";
411
+ }
412
+
413
+ function nativeAgentAuthDetected(choice) {
414
+ if (!choice?.command || !commandExists(choice.command)) return false;
415
+ if (Array.isArray(choice.authProbe)) {
416
+ const result = runQuiet(choice.command, choice.authProbe, { timeout: 10_000 });
417
+ if (result.code === 0) return true;
418
+ }
419
+ // Conservative file fallbacks for older CLI versions without a status command.
420
+ if (choice.command === "codex") return fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
421
+ if (choice.command === "claude") return fs.existsSync(path.join(os.homedir(), ".claude", ".credentials.json"));
422
+ return false;
423
+ }
424
+
425
+ async function ingestSetupAgentLogin(choice) {
426
+ if (!choice || !["claude-code-sdk", "codex"].includes(choice.runtimeId)) return false;
427
+ const code = await run(nodeBin, [
428
+ ...nodeScriptArgs(credentialIngestEntry),
429
+ choice.runtimeId,
430
+ path.join(appDir, "credentials"),
431
+ path.join(appDir, "pi"),
432
+ ], { cwd: repoRoot, env: process.env, stdio: "ignore" });
433
+ return code === 0;
434
+ }
435
+
405
436
  function url(config) {
406
437
  return `http://localhost:${config.port}`;
407
438
  }
@@ -2636,9 +2667,11 @@ function createPrompter() {
2636
2667
  };
2637
2668
 
2638
2669
  const askChoice = async (question, choices, fallback) => {
2639
- const labels = choices.map((choice) => `${choice.key}=${choice.label}`).join(", ");
2640
2670
  for (;;) {
2641
- const answer = (await ask(`${question} (${labels})`, fallback)).toLowerCase();
2671
+ const menu = choices.map((choice) => ` ${c.cyan(choice.key)} ${choice.label}`).join("\n");
2672
+ const suffix = fallback ? c.dim(` [default: ${fallback}]`) : "";
2673
+ process.stdout.write(`\n${c.cyan("›")} ${question}\n${menu}\n >${suffix} `);
2674
+ const answer = String(await nextLine()).trim().toLowerCase() || fallback || "";
2642
2675
  const match = choices.find((choice) => answer === choice.key || answer === choice.label.toLowerCase());
2643
2676
  if (match) return match.key;
2644
2677
  console.log(c.yellow(`Please choose one of: ${choices.map((choice) => choice.key).join(", ")}`));
@@ -3205,11 +3238,20 @@ async function cmdSetup(args = []) {
3205
3238
  }
3206
3239
  console.log(c.dim(`Workspace: ${config.workspace} · local port: ${config.port} (change both in Settings)`));
3207
3240
 
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) {
3241
+ // 2. Agent first: authentication depends on who owns the selected agent's
3242
+ // credentials. Prefer an already-installed native agent on a fresh machine,
3243
+ // while retaining the saved choice when setup is re-run.
3244
+ console.log(c.bold("\n Agent\n"));
3245
+ const agentChoice = await askChoice(
3246
+ "Which agent do you want to try first?",
3247
+ SETUP_AGENT_CHOICES.map((choice) => ({
3248
+ key: choice.key,
3249
+ label: `${choice.label}${choice.command && commandExists(choice.command) ? " (installed)" : ""}`,
3250
+ })),
3251
+ setupAgentDefaultKey(config),
3252
+ );
3253
+ const setupAgent = SETUP_AGENT_CHOICES.find((choice) => choice.key === agentChoice) || setupAgentByRuntime("pi");
3254
+ if (setupAgent) {
3213
3255
  config.env = { ...config.env, BIVY_RUNTIME: setupAgent.runtimeId };
3214
3256
  saveConfig(config);
3215
3257
  }
@@ -3218,7 +3260,7 @@ async function cmdSetup(args = []) {
3218
3260
  agentReady = await ensureSetupAgent(setupAgent);
3219
3261
  if (!agentReady) console.log(c.yellow(`${setupAgent.label} was not fully installed. Install it later from the app or with 'bivy agents:install'.`));
3220
3262
  }
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)`));
3263
+ console.log(c.dim(`Default agent: ${setupAgent?.label || "Pi"} (change any time in Settings)`));
3222
3264
 
3223
3265
  // 3. Secure remote web/PWA access is what makes a Bivy-managed CLI useful:
3224
3266
  // without a relay/control plane it adds nothing over running the agent
@@ -3238,7 +3280,7 @@ async function cmdSetup(args = []) {
3238
3280
  const syncChoice = await askChoice(
3239
3281
  "Remote access",
3240
3282
  [
3241
- { key: "h", label: "hosted (recommended — one node is free)" },
3283
+ { key: "h", label: "hosted (recommended — first 25 remotely accessible sessions are free; nothing caps your local usage)" },
3242
3284
  { key: "s", label: "self-hosted (your own control plane + relay)" },
3243
3285
  ],
3244
3286
  selfHostEnv ? "s" : "h",
@@ -3306,7 +3348,9 @@ async function cmdSetup(args = []) {
3306
3348
  // Bivy's provider login; offer it inline so setup cannot imply the first task
3307
3349
  // is ready while the required credential is still absent. Agent-native auth is
3308
3350
  // explained in the readiness checklist below because those CLIs own the flow.
3309
- if (setupAgent?.needsBivyModel && !hasModelConfig(config)) {
3351
+ let agentAuthReady = setupAgent?.needsBivyModel ? hasModelConfig(config) : nativeAgentAuthDetected(setupAgent);
3352
+ if (setupAgent?.needsBivyModel && !agentAuthReady) {
3353
+ 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
3354
  const signInNow = await askYesNo("Sign in to a model now so your first task can run?", true);
3311
3355
  if (signInNow) {
3312
3356
  rl.pause();
@@ -3315,13 +3359,35 @@ async function cmdSetup(args = []) {
3315
3359
  if (loginCode !== 0 || !hasModelConfig(loadConfig())) {
3316
3360
  console.log(c.yellow("Model sign-in did not complete. The node can start, but an agent reply still requires 'bivy login'."));
3317
3361
  }
3362
+ agentAuthReady = hasModelConfig(loadConfig());
3363
+ }
3364
+ } else if (setupAgent && !setupAgent.needsBivyModel) {
3365
+ if (agentAuthReady) {
3366
+ const imported = await ingestSetupAgentLogin(setupAgent);
3367
+ console.log(c.green(`\n ✓ Existing ${setupAgent.label} login detected — Bivy will reuse it in the terminal and PWA${imported ? " and stored it in the encrypted vault" : ""}.`));
3368
+ } else if (setupAgent.command) {
3369
+ console.log(`\n${setupAgent.label} owns its login. After sign-in, Bivy stores compatible credential fields in its encrypted vault so the terminal, PWA, and your other Bivy nodes can reuse them.`);
3370
+ const signInNow = await askYesNo(`Open ${setupAgent.label} now to sign in? (Exit it when sign-in is complete.)`, true);
3371
+ if (signInNow) {
3372
+ rl.pause();
3373
+ const loginCode = await run(setupAgent.command, [], { cwd: config.workspace, env: startEnv(config) });
3374
+ rl.resume();
3375
+ agentAuthReady = loginCode === 0 || nativeAgentAuthDetected(setupAgent);
3376
+ if (agentAuthReady) await ingestSetupAgentLogin(setupAgent);
3377
+ }
3318
3378
  }
3319
3379
  }
3320
3380
 
3321
3381
  // 4. Background service — always installed so the node keeps running (and stays
3322
3382
  // reachable remotely) after you close this terminal. No prompt.
3323
3383
  let started = false;
3324
- if (config.service) {
3384
+ if (process.env.BIVY_SETUP_SKIP_SERVICE === "1") {
3385
+ // Isolation seam for disposable/container smoke tests: the caller starts a
3386
+ // node with this BIVY_DATA_DIR/port and setup exercises the real wizard
3387
+ // without installing or replacing the host user's system service.
3388
+ started = await isReachable(config);
3389
+ console.log(c.dim(`\nBackground-service install skipped; using the isolated node already running at ${url(config)}.`));
3390
+ } else if (config.service) {
3325
3391
  console.log(c.dim("\nBackground service already configured; restarting it."));
3326
3392
  started = restartService();
3327
3393
  } else {
@@ -3337,18 +3403,20 @@ async function cmdSetup(args = []) {
3337
3403
  }
3338
3404
 
3339
3405
  const finalConfig = loadConfig();
3340
- const modelReady = !setupAgent?.needsBivyModel || hasModelConfig(finalConfig);
3406
+ const modelReady = setupAgent?.needsBivyModel ? hasModelConfig(finalConfig) : agentAuthReady;
3341
3407
  console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
3342
3408
  console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
3343
3409
  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'"}`);
3410
+ 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
3411
  console.log(` ${c.dim("○")} repository chosen from the directory where you start Bivy`);
3346
3412
  const ghReady = githubConnected(finalConfig);
3347
- 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)")}`);
3413
+ console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("optional — connect later in the app under Settings → GitHub App")}`);
3348
3414
  console.log(` ${agentReady && modelReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady ? "ready to try" : "blocked by the stage above"}`);
3349
3415
  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);
3416
+ // Get the user into the product immediately; terminal commands are the
3417
+ // fallback/next-step checklist after the remote app has been opened or linked.
3351
3418
  await finishSetupRemote(finalConfig, setupSession);
3419
+ printFirstRunSteps(modelReady, setupAgent);
3352
3420
  }
3353
3421
 
3354
3422
  // Read and delete the one-time account-session handoff written by relay:setup
@@ -3377,20 +3445,18 @@ function consumeSetupSession() {
3377
3445
  // `/nodes` return only this one node, so a user with other nodes appeared to land
3378
3446
  // on a different/empty account until they signed out and back in.
3379
3447
  //
3380
- // The separately minted node-scoped paired link (E2E key embedded) is reserved
3381
- // for the QR below, which is meant to be scanned by *another* device to pair it
3382
- // with this node — there, node-scoping is the right least-privilege choice.
3383
- // Everything is also printed as text so servers without a browser can copy it.
3384
3448
  // Open this node's REMOTE control-plane app in a local browser (best effort) and
3385
- // return the URLs involved. The node no longer hosts a UI, so the web/PWA app
3449
+ // return the URL involved. The node no longer hosts a UI, so the web/PWA app
3386
3450
  // always comes from the hosted or self-hosted control plane. Prefers, in order:
3387
3451
  // an account sign-in URL (only available right after `relay:setup`, when a
3388
- // setupSession is supplied) → a freshly minted node-scoped paired link → the
3389
- // plain remote base URL. Prints a clean "Opening <base>" line — never the
3390
- // tokenized fragment, so no session/pairing secret lands in terminal scrollback.
3452
+ // setupSession is supplied) → the plain remote base URL, where the user signs
3453
+ // in normally. Pairing is deliberately reserved for the explicit `bivy link`
3454
+ // command; ordinary setup/open should establish the full account experience.
3455
+ // Prints a clean "Opening <base>" line — never the tokenized fragment, so no
3456
+ // account secret lands in terminal scrollback.
3391
3457
  // Returns null when no relay is configured (caller should send the user to
3392
3458
  // `bivy relay:setup`).
3393
- async function openRemoteApp(config, { setupSession = null, open = true } = {}) {
3459
+ async function openRemoteApp({ setupSession = null, open = true, remotePath = "" } = {}) {
3394
3460
  const relay = loadRelayConfig();
3395
3461
  if (!relay) return null;
3396
3462
 
@@ -3408,31 +3474,15 @@ async function openRemoteApp(config, { setupSession = null, open = true } = {})
3408
3474
  session: setupSession.session,
3409
3475
  ...(setupSession.nodeId ? { node: { id: setupSession.nodeId } } : {}),
3410
3476
  };
3411
- accountUrl = `${remoteBase}/#${Buffer.from(JSON.stringify(payload)).toString("base64url")}`;
3412
- }
3413
-
3414
- // Try to mint a node-scoped paired link (safe to embed in a QR another device
3415
- // scans). Needs the node reachable, so wait briefly first.
3416
- let pairedUrl = "";
3417
- await waitForNode(config).catch(() => {});
3418
- try {
3419
- const token = await localDeviceToken(config);
3420
- const data = await localApi(config, "/api/relay/link", {
3421
- method: "POST",
3422
- headers: { authorization: `Bearer ${token}` },
3423
- body: "{}",
3424
- });
3425
- if (data?.url) pairedUrl = data.url;
3426
- } catch {
3427
- // fall back to the plain remote app URL below
3477
+ accountUrl = `${remoteBase}${remotePath}/#${Buffer.from(JSON.stringify(payload)).toString("base64url")}`;
3428
3478
  }
3429
3479
 
3430
- const openUrl = accountUrl || pairedUrl || remoteBase;
3480
+ const openUrl = accountUrl || `${remoteBase}${remotePath}`;
3431
3481
  if (open && canOpenBrowser() && openUrl) {
3432
3482
  console.log(` Opening ${c.cyan(remoteBase || openUrl)} …`);
3433
3483
  openBrowser(openUrl);
3434
3484
  }
3435
- return { relay, remoteBase, accountUrl, pairedUrl, openUrl };
3485
+ return { relay, remoteBase, accountUrl, openUrl };
3436
3486
  }
3437
3487
 
3438
3488
  // Whether GitHub is connected for repo listing/cloning. Bivy's own connect flow
@@ -3448,22 +3498,24 @@ function githubConnected(config = null) {
3448
3498
  return Boolean(token);
3449
3499
  }
3450
3500
 
3451
- function printFirstRunSteps(modelReady = false, config = null) {
3452
- console.log(" Run your first task:");
3453
- 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)")}`);
3455
- // GitHub is optional — "No repo" sessions work without it — so this only shows
3456
- // when nothing is connected yet, and never blocks the flow.
3457
- if (!githubConnected(config)) {
3458
- console.log(` ${++n}. GitHub ${c.dim("(optional)")}: ${c.cyan("bivy github:connect")} ${c.dim("— lets the app list your repos; required for private ones")}`);
3501
+ function printFirstRunSteps(modelReady = false, setupAgent = null) {
3502
+ console.log(" Start your first session:");
3503
+ if (!modelReady) {
3504
+ const login = setupAgent?.needsBivyModel
3505
+ ? `${c.cyan("bivy login")} ${c.dim("(stored in Bivy's encrypted vault)")}`
3506
+ : c.cyan(setupAgent?.command || "the selected agent's native CLI");
3507
+ console.log(` Model access: ${login}`);
3459
3508
  }
3460
- console.log(` ${++n}. Start chatting: ${c.cyan("bivy")}`);
3461
- console.log(` Starter task: ${c.cyan('bivy exec "explain this repository and identify one low-risk improvement"')}\n`);
3509
+ const agent = setupAgent?.command || setupAgent?.runtimeId || resolveDefaultAgent();
3510
+ const remoteApp = String(loadRelayConfig()?.clientBaseUrl || "https://app.bivy.sh").replace(/\/+$/, "");
3511
+ console.log(` • In the terminal: ${c.cyan(`bivy run ${agent}`)}`);
3512
+ console.log(` Then use the remote app to watch the session or take over in chat.`);
3513
+ console.log(` • Or start in chat: ${c.cyan(remoteApp)}\n`);
3462
3514
  }
3463
3515
 
3464
3516
  async function finishSetupRemote(config, setupSession = null) {
3465
3517
  const openable = canOpenBrowser();
3466
- const remote = await openRemoteApp(config, { setupSession });
3518
+ const remote = await openRemoteApp({ setupSession });
3467
3519
 
3468
3520
  if (!remote) {
3469
3521
  console.log("\n Almost there — enable remote access to open the Bivy app:");
@@ -3472,19 +3524,14 @@ async function finishSetupRemote(config, setupSession = null) {
3472
3524
  return;
3473
3525
  }
3474
3526
 
3475
- const { remoteBase, pairedUrl } = remote;
3476
- if (pairedUrl) {
3477
- const qr = terminalQr(pairedUrl);
3478
- if (qr) console.log(qr + "\n");
3479
- }
3527
+ const { remoteBase } = remote;
3480
3528
 
3481
3529
  console.log("\n Access Bivy from anywhere:");
3482
3530
  if (remoteBase) console.log(` • Remote app: ${c.cyan(remoteBase)} (sign in with the same GitHub/email you just used)`);
3483
- console.log(` • Link a device: ${c.cyan("bivy link")} (prints a QR to pair a phone/laptop with this node)`);
3484
3531
  console.log(` • Check status: ${c.cyan("bivy status")}`);
3485
3532
  if (!openable) {
3486
3533
  console.log(c.dim("\n No browser on this machine (headless server)? Open the Remote app URL above"));
3487
- console.log(c.dim(" on your phone or laptop and sign in, or scan the QR above to pair a device with this node."));
3534
+ console.log(c.dim(" on your phone or laptop and sign in with the same GitHub account or email."));
3488
3535
  }
3489
3536
  console.log("");
3490
3537
  }
@@ -4205,7 +4252,7 @@ ${c.bold("bivy")} — Bivy node CLI
4205
4252
  ${c.cyan("bivy kill <id>")} Stop a session/terminal (--delete also removes a saved session)
4206
4253
  ${c.cyan("bivy prune")} Delete old sessions/workspaces/worktrees (--keep N, --older-than 7d, --dry-run)
4207
4254
  ${c.cyan("bivy exec")} "<prompt>" One-shot headless run: prints the answer to stdout (pipe-friendly)
4208
- ${c.cyan("bivy")} Launch the default agent (pi) as a managed, relay-visible session
4255
+ ${c.cyan("bivy")} Show this help
4209
4256
  ${c.cyan("bivy setup")} First-run wizard: workspace, remote access + sign-in, background service
4210
4257
  ${c.cyan("bivy start")} Run the daemon in the foreground
4211
4258
  ${c.cyan("bivy stop")} Stop the background service
@@ -4225,7 +4272,7 @@ ${c.bold("bivy")} — Bivy node CLI
4225
4272
  ${c.cyan("bivy github:app-create")} One-click: create + connect a GitHub App
4226
4273
  ${c.cyan("bivy github:app-connect")} Connect an existing GitHub App (--app-id --key)
4227
4274
  ${c.cyan("bivy github:app-sync")} [on|off] Sync connected GitHub App keys to this account's other opted-in nodes
4228
- ${c.cyan("bivy github:connect")} [owner/repo] Authorize repo access for the repo picker (device flow)
4275
+ ${c.cyan("bivy github:connect")} [owner/repo] Connect GitHub in the app (or use a configured self-hosted device flow)
4229
4276
  ${c.cyan("bivy secrets")} list | set | ref | delete | doctor | resolve
4230
4277
  ${c.cyan("bivy voice")} Configure speech-to-text: provider | key | remove | status
4231
4278
  ${c.cyan("bivy completions")} <bash|zsh|fish> Print a shell completion script
@@ -4238,10 +4285,7 @@ async function main() {
4238
4285
  const [command, ...args] = argv;
4239
4286
  switch (command) {
4240
4287
  case undefined:
4241
- // First run opens the guided setup; after setup, bare `bivy` launches the
4242
- // default agent's native CLI/TUI as a managed, relay-visible `bivy run`.
4243
- if (fs.existsSync(cliConfigPath)) await cmdRun([]);
4244
- else await cmdSetup();
4288
+ printHelp();
4245
4289
  break;
4246
4290
  case "setup":
4247
4291
  case "init":
@@ -4378,8 +4422,7 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4378
4422
  console.log("Usage: bivy open\n\nOpen the remote web/PWA app in your browser (requires 'bivy relay:setup' first).");
4379
4423
  break;
4380
4424
  }
4381
- const config = loadConfig();
4382
- const remote = await openRemoteApp(config);
4425
+ const remote = await openRemoteApp();
4383
4426
  if (!remote) {
4384
4427
  console.log("No remote access configured yet.");
4385
4428
  console.log(`Run ${c.cyan("bivy relay:setup")} to enable the web/PWA app, then ${c.cyan("bivy open")}.`);
@@ -4403,7 +4446,17 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4403
4446
  case "github:connect":
4404
4447
  case "connect-repo":
4405
4448
  if (args.includes("-h") || args.includes("--help")) {
4406
- console.log("Usage: bivy github:connect [owner/repo]\n\nAuthorize repo access for the repo picker via GitHub's device flow. The resulting repo-scoped token is stored in this node's encrypted local vault.");
4449
+ console.log("Usage: bivy github:connect [owner/repo]\n\nOpen Settings → GitHub App in the remote app. Self-hosted deployments with BIVY_GITHUB_OAUTH_CLIENT_ID configured use GitHub's device flow instead.");
4450
+ break;
4451
+ }
4452
+ if (!String(process.env.BIVY_GITHUB_OAUTH_CLIENT_ID || loadConfig().env?.BIVY_GITHUB_OAUTH_CLIENT_ID || "").trim()) {
4453
+ const remote = await openRemoteApp({ remotePath: "/settings/github" });
4454
+ if (!remote) {
4455
+ console.log("Remote access is not configured yet.");
4456
+ console.log(`Run ${c.cyan("bivy relay:setup")}, then connect GitHub in the app under Settings → GitHub App.`);
4457
+ } else if (!canOpenBrowser()) {
4458
+ console.log(`Open GitHub setup in the Bivy app: ${c.cyan(remote.openUrl)}`);
4459
+ }
4407
4460
  break;
4408
4461
  }
4409
4462
  if (!(await ensureDeps())) process.exit(1);
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Small setup-time entry point: normalize a native agent login into Bivy's
4
+ // encrypted model credential vault without passing credential material on argv.
5
+ import { ingestAgentCredentials } from "./runtime/credential-ingest.js";
6
+ const [agentId, credsDir, piDir] = process.argv.slice(2);
7
+ if (!agentId || !credsDir || !piDir)
8
+ process.exit(2);
9
+ try {
10
+ const imported = await ingestAgentCredentials(agentId, credsDir, piDir);
11
+ process.exit(imported > 0 ? 0 : 3);
12
+ }
13
+ catch (error) {
14
+ if (process.env.BIVY_DEBUG)
15
+ console.error(error);
16
+ process.exit(1);
17
+ }
@@ -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.3-staging.116",
3
+ "version": "0.8.3-staging.118",
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.",