@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 +128 -75
- package/dist/credential-ingest-cli.js +17 -0
- 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
|
@@ -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",
|
|
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: "
|
|
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
|
|
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.
|
|
3209
|
-
//
|
|
3210
|
-
//
|
|
3211
|
-
|
|
3212
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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 ?
|
|
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("
|
|
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
|
-
|
|
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
|
|
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) →
|
|
3389
|
-
//
|
|
3390
|
-
//
|
|
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(
|
|
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 ||
|
|
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,
|
|
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,
|
|
3452
|
-
console.log("
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
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
|
-
|
|
3461
|
-
|
|
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(
|
|
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
|
|
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
|
|
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")}
|
|
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]
|
|
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
|
-
|
|
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
|
|
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\
|
|
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
|
-
|
|
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) => {
|
package/package.json
CHANGED