@bivy/bivy 0.6.0 → 0.7.0-staging.93

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/README.md CHANGED
@@ -199,24 +199,33 @@ Every environment variable, config file, and precedence rule:
199
199
  ## Approvals and sandboxing
200
200
 
201
201
  The default approval mode is **`autonomous`**: agents act without per-action
202
- prompts. Safety comes from a floor that applies in *every* mode — catastrophic
203
- commands and writes outside the workspace are refused outright, and a backstop
204
- set (force-push, publish, deploy, sudo) always pauses for a human.
202
+ prompts. The actual protection depends on the selected runtime. Native-sandbox
203
+ agents enforce the chosen access tier; structured runtimes also pass tool calls
204
+ through Bivy's policy and approval layer. Process agents that Bivy cannot
205
+ intercept run with your OS user permissions. The picker shows this distinction
206
+ and requires confirmation before selecting that limited path.
207
+
208
+ Where Bivy receives structured shell/file calls, a heuristic floor blocks known
209
+ catastrophic commands and structured writes outside the workspace, and a
210
+ backstop set (force-push, publish, deploy, sudo) pauses for a human. This catches
211
+ accidents; it is not an adversarial isolation boundary.
205
212
 
206
213
  If you want to be asked about more, set the mode explicitly:
207
214
 
208
215
  ```bash
209
216
  BIVY_APPROVAL_MODE=risky # prompt on risky shell commands and file edits
210
217
  BIVY_APPROVAL_MODE=always # prompt on all shell commands and file edits
211
- BIVY_APPROVAL_MODE=never # no prompts beyond the hard floor
218
+ BIVY_APPROVAL_MODE=never # no prompts; structured-tool heuristic blocks still apply where available
212
219
  ```
213
220
 
214
221
  Approve from the terminal, browser, or phone.
215
222
 
216
223
  Sandbox tiers (`read-only`, `workspace-write`, `danger-full-access`) are enforced
217
224
  natively by agents that support them — Codex, Claude Code, Gemini CLI, Qwen Code.
218
- Agents without a native sandbox are governed at the filesystem, MCP, and network
219
- layer. **Bivy does not ship its own OS-level jail in 0.1.**
225
+ Agents without a native sandbox may expose structured tool or MCP controls, but
226
+ those controls do not cover activity the agent performs outside those channels;
227
+ some process adapters run entirely with your user permissions. Check the
228
+ picker's Protection label. **Bivy does not currently ship its own OS-level jail.**
220
229
 
221
230
  ## Credentials
222
231
 
@@ -5,6 +5,8 @@
5
5
  "label": "Codex",
6
6
  "command": "codex",
7
7
  "hidden": true,
8
+ "supportTier": "beta",
9
+ "certification": "adapter-tested",
8
10
  "headlessFlags": [
9
11
  "exec"
10
12
  ],
@@ -18,6 +20,8 @@
18
20
  "label": "OpenCode",
19
21
  "command": "opencode",
20
22
  "hidden": false,
23
+ "supportTier": "beta",
24
+ "certification": "adapter-tested",
21
25
  "headlessFlags": [
22
26
  "run",
23
27
  "-s"
@@ -32,6 +36,8 @@
32
36
  "label": "Aider",
33
37
  "command": "aider",
34
38
  "hidden": false,
39
+ "supportTier": "beta",
40
+ "certification": "adapter-tested",
35
41
  "headlessFlags": [
36
42
  "--yes-always",
37
43
  "--message"
@@ -46,6 +52,8 @@
46
52
  "label": "Hermes",
47
53
  "command": "hermes",
48
54
  "hidden": true,
55
+ "supportTier": "beta",
56
+ "certification": "adapter-tested",
49
57
  "headlessFlags": [],
50
58
  "install": {
51
59
  "kind": "npm",
@@ -57,6 +65,8 @@
57
65
  "label": "Goose",
58
66
  "command": "goose",
59
67
  "hidden": false,
68
+ "supportTier": "beta",
69
+ "certification": "adapter-tested",
60
70
  "headlessFlags": [
61
71
  "run",
62
72
  "-t",
@@ -76,6 +86,8 @@
76
86
  "label": "Gemini CLI",
77
87
  "command": "gemini",
78
88
  "hidden": false,
89
+ "supportTier": "beta",
90
+ "certification": "adapter-tested",
79
91
  "headlessFlags": [
80
92
  "-p",
81
93
  "-o",
@@ -92,6 +104,8 @@
92
104
  "label": "Qwen Code",
93
105
  "command": "qwen",
94
106
  "hidden": false,
107
+ "supportTier": "beta",
108
+ "certification": "adapter-tested",
95
109
  "headlessFlags": [
96
110
  "-p",
97
111
  "--output-format",
@@ -108,6 +122,8 @@
108
122
  "label": "Cline",
109
123
  "command": "cline",
110
124
  "hidden": false,
125
+ "supportTier": "beta",
126
+ "certification": "adapter-tested",
111
127
  "headlessFlags": [
112
128
  "-y",
113
129
  "--id"
@@ -122,6 +138,8 @@
122
138
  "label": "Crush",
123
139
  "command": "crush",
124
140
  "hidden": false,
141
+ "supportTier": "beta",
142
+ "certification": "adapter-tested",
125
143
  "headlessFlags": [
126
144
  "run",
127
145
  "-q"
@@ -136,6 +154,8 @@
136
154
  "label": "Cursor",
137
155
  "command": "cursor-agent",
138
156
  "hidden": false,
157
+ "supportTier": "beta",
158
+ "certification": "adapter-tested",
139
159
  "headlessFlags": [
140
160
  "--force",
141
161
  "-p"
@@ -151,6 +171,8 @@
151
171
  "label": "GitHub Copilot",
152
172
  "command": "copilot",
153
173
  "hidden": false,
174
+ "supportTier": "beta",
175
+ "certification": "adapter-tested",
154
176
  "headlessFlags": [
155
177
  "--allow-all-tools",
156
178
  "-p"
@@ -165,6 +187,8 @@
165
187
  "label": "Grok",
166
188
  "command": "grok",
167
189
  "hidden": false,
190
+ "supportTier": "beta",
191
+ "certification": "adapter-tested",
168
192
  "headlessFlags": [
169
193
  "-p"
170
194
  ],
@@ -178,6 +202,8 @@
178
202
  "label": "Amp",
179
203
  "command": "amp",
180
204
  "hidden": false,
205
+ "supportTier": "beta",
206
+ "certification": "adapter-tested",
181
207
  "headlessFlags": [
182
208
  "-x",
183
209
  "threads",
@@ -193,6 +219,8 @@
193
219
  "label": "Auggie",
194
220
  "command": "auggie",
195
221
  "hidden": false,
222
+ "supportTier": "beta",
223
+ "certification": "adapter-tested",
196
224
  "headlessFlags": [
197
225
  "--quiet",
198
226
  "--print"
@@ -207,6 +235,8 @@
207
235
  "label": "Droid",
208
236
  "command": "droid",
209
237
  "hidden": false,
238
+ "supportTier": "beta",
239
+ "certification": "adapter-tested",
210
240
  "headlessFlags": [
211
241
  "exec",
212
242
  "--auto",
@@ -223,6 +253,8 @@
223
253
  "label": "Continue",
224
254
  "command": "cn",
225
255
  "hidden": false,
256
+ "supportTier": "beta",
257
+ "certification": "adapter-tested",
226
258
  "headlessFlags": [
227
259
  "--auto",
228
260
  "-p"
@@ -237,6 +269,8 @@
237
269
  "label": "Kilo Code",
238
270
  "command": "kilo",
239
271
  "hidden": false,
272
+ "supportTier": "beta",
273
+ "certification": "adapter-tested",
240
274
  "headlessFlags": [
241
275
  "run",
242
276
  "--auto",
@@ -252,6 +286,8 @@
252
286
  "label": "Rovo Dev",
253
287
  "command": "acli",
254
288
  "hidden": false,
289
+ "supportTier": "beta",
290
+ "certification": "adapter-tested",
255
291
  "headlessFlags": [
256
292
  "rovodev",
257
293
  "run",
@@ -265,6 +301,8 @@
265
301
  "label": "Codebuff",
266
302
  "command": "codebuff",
267
303
  "hidden": true,
304
+ "supportTier": "experimental",
305
+ "certification": "unverified",
268
306
  "headlessFlags": [
269
307
  "--continue"
270
308
  ],
package/bin/bivy.mjs CHANGED
@@ -425,6 +425,25 @@ function run(cmd, args, opts = {}) {
425
425
  });
426
426
  }
427
427
 
428
+ /** Fixed executable + fixed entry point for setup's inline model-auth stage.
429
+ * Keep this separate from the generic CLI forwarding helper: no user-provided
430
+ * command or argv value reaches this process boundary. */
431
+ function runSetupModelLogin(config) {
432
+ return new Promise((resolve) => {
433
+ const child = spawn(process.execPath, nodeScriptArgs(bivyLoginEntry), {
434
+ stdio: "inherit",
435
+ cwd: repoRoot,
436
+ env: startEnv(config),
437
+ shell: false,
438
+ });
439
+ child.on("exit", (code) => resolve(code ?? 0));
440
+ child.on("error", (error) => {
441
+ console.error(c.red(`Failed to start model login: ${error.message}`));
442
+ resolve(1);
443
+ });
444
+ });
445
+ }
446
+
428
447
  function runQuiet(cmd, args, opts = {}) {
429
448
  const res = spawnSync(cmd, args, { encoding: "utf8", ...opts });
430
449
  return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
@@ -1530,7 +1549,7 @@ function cmdCompletions(args = []) {
1530
1549
  const shell = (args[0] || "").toLowerCase();
1531
1550
  const commands = [
1532
1551
  "run", "sessions", "ls", "resume", "promote", "rename", "nodes", "agents", "agents:install", "shim", "takeover", "token", "exec",
1533
- "send", "attach", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
1552
+ "send", "attach", "kill", "setup", "start", "stop", "restart", "status", "doctor", "diagnostics", "logs", "login",
1534
1553
  "update", "update:log", "open", "service", "secrets", "voice", "link", "relay:setup",
1535
1554
  "github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
1536
1555
  ];
@@ -3194,9 +3213,10 @@ async function cmdSetup(args = []) {
3194
3213
  config.env = { ...config.env, BIVY_RUNTIME: setupAgent.runtimeId };
3195
3214
  saveConfig(config);
3196
3215
  }
3216
+ let agentReady = true;
3197
3217
  if (setupAgent && setupAgent.runtimeId !== "pi") {
3198
- const installed = await ensureSetupAgent(setupAgent);
3199
- if (!installed) console.log(c.yellow(`${setupAgent.label} was not fully installed. Install it later from the app or with 'bivy agents:install'.`));
3218
+ agentReady = await ensureSetupAgent(setupAgent);
3219
+ if (!agentReady) console.log(c.yellow(`${setupAgent.label} was not fully installed. Install it later from the app or with 'bivy agents:install'.`));
3200
3220
  }
3201
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)`));
3202
3222
 
@@ -3211,13 +3231,17 @@ async function cmdSetup(args = []) {
3211
3231
  console.log(c.bold("\n Remote access\n"));
3212
3232
 
3213
3233
  console.log("Bivy uses remote access to make agent sessions visible and steerable from your other devices.");
3234
+ // If self-host endpoints are already provided via the environment, default to
3235
+ // self-hosted so a scripted or self-hosted install doesn't have to re-pick it
3236
+ // (BIVY_CONTROL_PLANE_URL / BIVY_RELAY_URL then pre-fill the URL prompts below).
3237
+ const selfHostEnv = Boolean((process.env.BIVY_CONTROL_PLANE_URL || "").trim() || (process.env.BIVY_RELAY_URL || "").trim());
3214
3238
  const syncChoice = await askChoice(
3215
3239
  "Remote access",
3216
3240
  [
3217
3241
  { key: "h", label: "hosted (recommended — one node is free)" },
3218
3242
  { key: "s", label: "self-hosted (your own control plane + relay)" },
3219
3243
  ],
3220
- "h",
3244
+ selfHostEnv ? "s" : "h",
3221
3245
  );
3222
3246
  const relayArgs = [];
3223
3247
  if (syncChoice === "s") {
@@ -3278,6 +3302,22 @@ async function cmdSetup(args = []) {
3278
3302
  // `bivy github:app-create` / `github:app-connect`. One app covers every repo,
3279
3303
  // and the node mints its own tokens, so there's no per-repo token to set up here.
3280
3304
 
3305
+ // Model access is part of activation, not a post-success footnote. Pi/Aider use
3306
+ // Bivy's provider login; offer it inline so setup cannot imply the first task
3307
+ // is ready while the required credential is still absent. Agent-native auth is
3308
+ // explained in the readiness checklist below because those CLIs own the flow.
3309
+ if (setupAgent?.needsBivyModel && !hasModelConfig(config)) {
3310
+ const signInNow = await askYesNo("Sign in to a model now so your first task can run?", true);
3311
+ if (signInNow) {
3312
+ rl.pause();
3313
+ const loginCode = await runSetupModelLogin(config);
3314
+ rl.resume();
3315
+ if (loginCode !== 0 || !hasModelConfig(loadConfig())) {
3316
+ console.log(c.yellow("Model sign-in did not complete. The node can start, but an agent reply still requires 'bivy login'."));
3317
+ }
3318
+ }
3319
+ }
3320
+
3281
3321
  // 4. Background service — always installed so the node keeps running (and stays
3282
3322
  // reachable remotely) after you close this terminal. No prompt.
3283
3323
  let started = false;
@@ -3296,9 +3336,17 @@ async function cmdSetup(args = []) {
3296
3336
  return;
3297
3337
  }
3298
3338
 
3299
- console.log(c.bold(c.green("\n ✓ Your node is running.\n")));
3300
- printFirstRunSteps();
3301
- await finishSetupRemote(config, setupSession);
3339
+ const finalConfig = loadConfig();
3340
+ const modelReady = !setupAgent?.needsBivyModel || hasModelConfig(finalConfig);
3341
+ console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
3342
+ console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
3343
+ 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'"}`);
3345
+ console.log(` ${c.dim("○")} repository chosen from the directory where you start Bivy`);
3346
+ console.log(` ${agentReady && modelReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady ? "ready to try" : "blocked by the stage above"}`);
3347
+ console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
3348
+ printFirstRunSteps(modelReady);
3349
+ await finishSetupRemote(finalConfig, setupSession);
3302
3350
  }
3303
3351
 
3304
3352
  // Read and delete the one-time account-session handoff written by relay:setup
@@ -3385,11 +3433,11 @@ async function openRemoteApp(config, { setupSession = null, open = true } = {})
3385
3433
  return { relay, remoteBase, accountUrl, pairedUrl, openUrl };
3386
3434
  }
3387
3435
 
3388
- function printFirstRunSteps() {
3436
+ function printFirstRunSteps(modelReady = false) {
3389
3437
  console.log(" Run your first task:");
3390
- console.log(` 1. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3391
- console.log(` 2. Start chatting: ${c.cyan("bivy")}`);
3392
- console.log(` One-shot task: ${c.cyan('bivy exec "explain this repository"')}\n`);
3438
+ if (!modelReady) console.log(` 1. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3439
+ console.log(` ${modelReady ? "1" : "2"}. Start chatting: ${c.cyan("bivy")}`);
3440
+ console.log(` Starter task: ${c.cyan('bivy exec "explain this repository and identify one low-risk improvement"')}\n`);
3393
3441
  }
3394
3442
 
3395
3443
  async function finishSetupRemote(config, setupSession = null) {
@@ -3506,7 +3554,7 @@ async function cmdStatus(args = []) {
3506
3554
  console.log(` sessions: ${status.sessions?.open ?? 0} open, ${status.sessions?.indexed ?? 0} indexed${status.sessions?.active ? `, active ${status.sessions.active}` : ""}`);
3507
3555
  console.log(` devices: ${status.devices?.paired ?? 0} paired remote, ${status.devices?.localTokens ?? 0} local token(s)`);
3508
3556
  console.log(` approvals: ${status.approvals?.pending ?? 0} pending`);
3509
- console.log(` guard: ${status.approvalMode || "autonomous"} (${status.guardrails?.workspaceBoundary ? "workspace boundary on" : "boundary unknown"})`);
3557
+ console.log(` guard: ${status.approvalMode || "autonomous"} · ${status.guardrails?.protection || (status.guardrails?.workspaceBoundary ? "structured workspace controls" : "runs with user permissions")}`);
3510
3558
  if (status.updatedAt) {
3511
3559
  const when = new Date(status.updatedAt);
3512
3560
  console.log(` updated: ${Number.isNaN(when.getTime()) ? status.updatedAt : when.toLocaleString()}`);
@@ -3517,9 +3565,34 @@ async function cmdStatus(args = []) {
3517
3565
 
3518
3566
  // `bivy doctor` — one health screen: runtime deps, node reachability, model auth,
3519
3567
  // remote/relay, and agents on PATH.
3568
+ // `bivy diagnostics [--out <file>]` — fetch the node's redacted diagnostics
3569
+ // bundle (versions, health counters, whitelisted config, activation record — no
3570
+ // secrets/prompts/transcripts) and print it, or write it to a file to attach to a
3571
+ // support request. See src/diagnostics.ts for exactly what is (and isn't) included.
3572
+ async function cmdDiagnostics(args = []) {
3573
+ if (args.includes("-h") || args.includes("--help")) {
3574
+ console.log('Usage: bivy diagnostics [--out <file>]\n\nPrint a redacted, shareable diagnostics bundle (no secrets, prompts, transcripts, or repo content). --out writes it to a file instead of stdout.');
3575
+ return;
3576
+ }
3577
+ const config = loadConfig();
3578
+ if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
3579
+ let report;
3580
+ try { report = await localApi(config, "/api/diagnostics"); }
3581
+ catch (error) { console.error(c.red(`Could not fetch diagnostics: ${error?.message || String(error)}`)); process.exit(1); return; }
3582
+ const json = JSON.stringify(report, null, 2);
3583
+ const outIdx = args.indexOf("--out");
3584
+ const out = outIdx >= 0 && outIdx + 1 < args.length ? args[outIdx + 1] : undefined;
3585
+ if (out) {
3586
+ fs.writeFileSync(out, json + "\n");
3587
+ console.log(c.green(`Wrote redacted diagnostics to ${out}`));
3588
+ } else {
3589
+ console.log(json);
3590
+ }
3591
+ }
3592
+
3520
3593
  async function cmdDoctor(args = []) {
3521
3594
  if (args.includes("-h") || args.includes("--help")) {
3522
- console.log("Usage: bivy doctor\n\nHealth check: runtime deps, node reachability, model auth, remote/relay, and agents on PATH. Exits non-zero if Node is unsupported or the node is unreachable, so it can gate CI/monitoring.");
3595
+ console.log("Usage: bivy doctor\n\nHealth check: runtime deps, node reachability, model auth, remote/relay, and agents on PATH. Exits non-zero if Node is unsupported or the node is unreachable, so it can gate CI/monitoring. See also 'bivy diagnostics' for a shareable redacted bundle.");
3523
3596
  return;
3524
3597
  }
3525
3598
  if (!(await ensureDeps())) process.exit(1);
@@ -3565,6 +3638,16 @@ async function cmdDoctor(args = []) {
3565
3638
  const agentCommands = [...BUILTIN_TERMINAL_AGENTS.values()].filter((a) => a.type === "command").map((a) => a.command);
3566
3639
  const agents = agentCommands.filter((a) => commandExists(a));
3567
3640
  console.log(` ${mark(agents.length > 0, true)} agents on PATH: ${agents.length ? c.cyan(agents.join(", ")) : c.dim("none (built-in Pi still works; 'bivy agents:install')")}`);
3641
+ if (status?.eventLog) {
3642
+ const healthy = status.eventLog.ok !== false;
3643
+ const mib = Number(status.eventLog.bytes || 0) / (1024 * 1024);
3644
+ console.log(` ${mark(healthy, true)} event log ${healthy ? "writable" : `${status.eventLog.affectedSessions ?? 0} session(s) need attention`} · ${mib.toFixed(1)} MiB`);
3645
+ }
3646
+ if (status?.attachments) {
3647
+ const mib = Number(status.attachments.bytes || 0) / (1024 * 1024);
3648
+ const over = Number(status.attachments.overCapBytes || 0);
3649
+ console.log(` ${over > 0 ? warn : ok} attachments ${status.attachments.blobs ?? 0} blob(s), ${mib.toFixed(1)} MiB${over > 0 ? c.dim(" (over cap; referenced history retained)") : ""}`);
3650
+ }
3568
3651
  console.log("");
3569
3652
 
3570
3653
  // Fail the command when a hard check is red (unsupported Node or an
@@ -4234,6 +4317,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4234
4317
  case "doctor":
4235
4318
  await cmdDoctor(args);
4236
4319
  break;
4320
+ case "diagnostics":
4321
+ await cmdDiagnostics(args);
4322
+ break;
4237
4323
  case "logs":
4238
4324
  await cmdLogs(args);
4239
4325
  break;
@@ -2,11 +2,11 @@
2
2
  // SPDX-License-Identifier: FSL-1.1-ALv2
3
3
  // Copyright (c) 2026 Petter André Sjulstad
4
4
  /**
5
- * pi-coding-agent 0.82.1 publishes an npm-shrinkwrap that pins the vulnerable
6
- * brace-expansion 5.0.7 below its own node_modules. npm overrides update the
7
- * outer lockfile/audit result but do not replace the shrinkwrapped files during
8
- * installation. Replace that one nested package with our direct, exact 5.0.9
9
- * dependency until pi publishes a corrected shrinkwrap.
5
+ * pi-coding-agent 0.82.1 publishes an npm-shrinkwrap that pins vulnerable
6
+ * transitive packages below its own node_modules. npm overrides update the outer
7
+ * lockfile/audit result but do not replace those shrinkwrapped files during
8
+ * installation. Replace the affected nested packages with direct, exact patched
9
+ * dependencies until pi publishes a corrected shrinkwrap.
10
10
  */
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
@@ -27,18 +27,24 @@ function findDependency(relativePath) {
27
27
  }
28
28
  }
29
29
 
30
- const source = findDependency("brace-expansion");
31
30
  const piPackage = findDependency(path.join("@earendil-works", "pi-coding-agent"));
32
- const target = piPackage && path.join(piPackage, "node_modules", "brace-expansion");
31
+ if (!piPackage) process.exit(0);
33
32
 
34
- if (!target || !fs.existsSync(target)) process.exit(0);
35
- if (!source) throw new Error("Security patch source brace-expansion@5.0.9 is missing");
33
+ const patches = [
34
+ { name: "brace-expansion", version: "5.0.9" },
35
+ { name: "undici", version: "8.9.0" },
36
+ ];
36
37
 
37
- const sourcePackage = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
38
- if (sourcePackage.version !== "5.0.9") {
39
- throw new Error(`Refusing dependency patch from unexpected brace-expansion ${sourcePackage.version}`);
38
+ for (const patch of patches) {
39
+ const source = findDependency(patch.name);
40
+ const target = path.join(piPackage, "node_modules", patch.name);
41
+ if (!fs.existsSync(target)) continue;
42
+ if (!source) throw new Error(`Security patch source ${patch.name}@${patch.version} is missing`);
43
+ const sourcePackage = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
44
+ if (sourcePackage.version !== patch.version) {
45
+ throw new Error(`Refusing dependency patch from unexpected ${patch.name} ${sourcePackage.version}`);
46
+ }
47
+ fs.rmSync(target, { recursive: true, force: true });
48
+ fs.cpSync(source, target, { recursive: true });
49
+ console.log(`Patched pi-coding-agent's nested ${patch.name} to ${patch.version}`);
40
50
  }
41
-
42
- fs.rmSync(target, { recursive: true, force: true });
43
- fs.cpSync(source, target, { recursive: true });
44
- console.log("Patched pi-coding-agent's nested brace-expansion to 5.0.9");
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { spawnSync } from "node:child_process";
7
+ const DEFAULT_SCRIPT_NAMES = ["test", "lint", "typecheck"];
8
+ const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
9
+ const MAX_CHECK_TIMEOUT_MS = 30 * 60 * 1000;
10
+ function configuredScriptNames(env) {
11
+ const raw = env.BIVY_AUTOMATION_CHECKS?.trim();
12
+ if (!raw)
13
+ return DEFAULT_SCRIPT_NAMES;
14
+ try {
15
+ const parsed = JSON.parse(raw);
16
+ if (Array.isArray(parsed))
17
+ return [...new Set(parsed.map(String).map((s) => s.trim()).filter((s) => /^[\w:.-]+$/.test(s)))].slice(0, 10);
18
+ }
19
+ catch {
20
+ // Fall through to a comma list; malformed entries are discarded rather than
21
+ // interpreted as shell. Only package-script names are accepted.
22
+ }
23
+ return [...new Set(raw.split(",").map((s) => s.trim()).filter((s) => /^[\w:.-]+$/.test(s)))].slice(0, 10);
24
+ }
25
+ function checkTimeoutMs(env) {
26
+ const parsed = Number(env.BIVY_AUTOMATION_CHECK_TIMEOUT_MS);
27
+ if (!Number.isFinite(parsed) || parsed <= 0)
28
+ return DEFAULT_CHECK_TIMEOUT_MS;
29
+ return Math.min(MAX_CHECK_TIMEOUT_MS, Math.max(1_000, Math.floor(parsed)));
30
+ }
31
+ function packageManager(cwd) {
32
+ if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml")))
33
+ return { command: "pnpm", args: (script) => ["run", script] };
34
+ if (fs.existsSync(path.join(cwd, "yarn.lock")))
35
+ return { command: "yarn", args: (script) => ["run", script] };
36
+ return { command: "npm", args: (script) => ["run", script] };
37
+ }
38
+ /** Discover and run only declared package scripts. Command text and output stay
39
+ * on the node; hosted evidence receives a name, hash, pass/fail, and exit code. */
40
+ export function runRequiredAutomationChecks(cwd, env = process.env, run = spawnSync) {
41
+ let pkg;
42
+ try {
43
+ pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ const manager = packageManager(cwd);
49
+ const timeout = checkTimeoutMs(env);
50
+ const results = [];
51
+ for (const name of configuredScriptNames(env)) {
52
+ if (typeof pkg.scripts?.[name] !== "string")
53
+ continue;
54
+ const args = manager.args(name);
55
+ const commandHash = `sha256:${createHash("sha256").update(JSON.stringify([manager.command, ...args])).digest("hex")}`;
56
+ const startedAt = Date.now();
57
+ const result = run(manager.command, args, {
58
+ cwd,
59
+ env,
60
+ stdio: "ignore",
61
+ timeout,
62
+ killSignal: "SIGTERM",
63
+ });
64
+ const exitCode = typeof result.status === "number" ? result.status : 1;
65
+ results.push({ name, commandHash, status: exitCode === 0 ? "passed" : "failed", exitCode, durationMs: Math.max(0, Date.now() - startedAt) });
66
+ }
67
+ return results;
68
+ }
@@ -5,6 +5,7 @@ import { createInterface } from "node:readline/promises";
5
5
  import { stdin as input, stdout as output } from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createCredentialVault } from "./runtime/credential-store.js";
8
+ import { probeAnthropicAccess } from "./runtime/anthropic-preflight.js";
8
9
  import { listPiProviders } from "./runtime/pi-oauth.js";
9
10
  import { loginModelOAuth } from "./runtime/oauth/model-oauth.js";
10
11
  import { openBrowser } from "./browser-open.js";
@@ -95,6 +96,18 @@ async function loginApiKey(provider) {
95
96
  throw new Error("API key cannot be empty.");
96
97
  await createCredentialVault(credsDir).setApiKey(provider.id, apiKey);
97
98
  console.log(`Saved API key for ${provider.name} to Bivy's credential vault.`);
99
+ // B1: validate real access, not just that a key was typed, where a safe probe
100
+ // exists. A rejected key is reported now instead of surfacing later as an
101
+ // opaque 401 on the user's first task.
102
+ if (provider.id === "anthropic") {
103
+ const probe = await probeAnthropicAccess(apiKey);
104
+ if (probe.probed && !probe.ok) {
105
+ console.log(`⚠ ${probe.reason || "The key was saved but Anthropic rejected it."} Double-check the key; re-run 'bivy login' to replace it.`);
106
+ }
107
+ else if (probe.probed) {
108
+ console.log("✓ Verified: the key can reach the Anthropic API.");
109
+ }
110
+ }
98
111
  }
99
112
  /** Bridge Pi's AuthInteraction to the terminal (prompt/notify). */
100
113
  function terminalInteraction(provider, signal) {
@@ -53,7 +53,19 @@ async function cp(cfg, method, path) {
53
53
  });
54
54
  }
55
55
  async function transitionWork(cfg, id, action) {
56
- await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/${action}`).catch(() => { });
56
+ // Best-effort a dropped transition never loses the run itself — but NOT
57
+ // silent: a swallowed `complete`/`fail`/`needs-attention` leaves the control
58
+ // plane's view of the item stale (stuck "running", or re-dispatched), so the
59
+ // failure must be visible in node logs/diagnostics rather than discarded (A4).
60
+ try {
61
+ const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/${action}`);
62
+ if (!res.ok) {
63
+ console.warn(`[control-plane-tasks] work ${id} "${action}" rejected by control plane (${res.status}); its status may be stale`);
64
+ }
65
+ }
66
+ catch (error) {
67
+ console.warn(`[control-plane-tasks] work ${id} "${action}" could not reach control plane:`, error instanceof Error ? error.message : error);
68
+ }
57
69
  }
58
70
  export async function fetchPendingWork(cfg) {
59
71
  const res = await cp(cfg, "GET", `/node/work?labels=${encodeURIComponent(cfg.labels.join(","))}`);
@@ -67,6 +79,10 @@ export async function claimWork(cfg, id) {
67
79
  const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`);
68
80
  return res.ok;
69
81
  }
82
+ export async function renewWorkLease(cfg, id) {
83
+ const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/heartbeat`);
84
+ return res.ok;
85
+ }
70
86
  export async function completeWork(cfg, id) {
71
87
  await transitionWork(cfg, id, "complete");
72
88
  }
@@ -80,13 +96,22 @@ export async function needsAttentionWork(cfg, id) {
80
96
  /** Report privacy-safe run evidence (issue #153) — routing reason, output refs
81
97
  * (branch/PR/checkpoint/commit/...), check results, and new timeline events.
82
98
  * Best-effort: a dropped report loses one evidence update, never the run
83
- * itself, so failures here are swallowed like the other transition calls. */
99
+ * itself. It is not throwing, but the failure is logged (A4) so a persistently
100
+ * failing evidence channel is visible in diagnostics instead of silent. */
84
101
  export async function reportEvidence(cfg, id, patch) {
85
- await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
86
- method: "POST",
87
- headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
88
- body: JSON.stringify(patch),
89
- }).catch(() => { });
102
+ try {
103
+ const res = await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
104
+ method: "POST",
105
+ headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
106
+ body: JSON.stringify(patch),
107
+ });
108
+ if (!res.ok) {
109
+ console.warn(`[control-plane-tasks] work ${id} evidence report rejected (${res.status})`);
110
+ }
111
+ }
112
+ catch (error) {
113
+ console.warn(`[control-plane-tasks] work ${id} evidence report could not reach control plane:`, error instanceof Error ? error.message : error);
114
+ }
90
115
  }
91
116
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
92
117
  export class ControlPlaneTaskPoller {
@@ -174,11 +199,15 @@ export class ControlPlaneTaskPoller {
174
199
  await Promise.all(running);
175
200
  }
176
201
  async runOne(item) {
202
+ let leaseHeartbeat;
177
203
  try {
178
204
  // Claim first so only one node runs it; skip if another node won (no
179
- // claim → not ours → don't run or complete it).
205
+ // claim → not ours → don't run or complete it). A heartbeat keeps the
206
+ // finite lease alive; process death stops it and makes the item reclaimable.
180
207
  if (!(await claimWork(this.cfg, item.id)))
181
208
  return;
209
+ leaseHeartbeat = setInterval(() => void renewWorkLease(this.cfg, item.id), 30_000);
210
+ leaseHeartbeat.unref?.();
182
211
  const report = (patch) => reportEvidence(this.cfg, item.id, patch);
183
212
  await transitionWork(this.cfg, item.id, "running");
184
213
  console.log(`[control-plane-tasks] running ${item.source} item ${item.id}: ${item.title}`);
@@ -190,6 +219,8 @@ export class ControlPlaneTaskPoller {
190
219
  await this.runWithPolicy(item, report);
191
220
  }
192
221
  finally {
222
+ if (leaseHeartbeat)
223
+ clearInterval(leaseHeartbeat);
193
224
  this.inFlight.delete(item.id);
194
225
  }
195
226
  }