@bivy/bivy 0.6.0 → 0.7.0-staging.95

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.
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Redacted diagnostics export + privacy-safe activation instrumentation (B4d).
5
+ //
6
+ // A support bundle a user can share without leaking secrets, prompts, transcripts,
7
+ // diffs, or repo contents: versions, health counters, a whitelisted set of config
8
+ // flags (values still passed through redactSecrets defensively), and the activation
9
+ // stage record. Everything user-authored stays on the node.
10
+ import { redactSecrets } from "./redact.js";
11
+ /** Ordered activation stages — the golden path from install to first useful task. */
12
+ export const ACTIVATION_STAGES = ["install", "node_online", "runtime", "credential", "repo", "first_task"];
13
+ /**
14
+ * Map the setup/doctor readiness booleans to a privacy-safe activation record —
15
+ * where the golden path is blocked, and where it flowed. No content, only the
16
+ * stage and its status. `null` for an unknown stage means "pending" (not reached).
17
+ */
18
+ export function activationRecord(readiness) {
19
+ const status = (ok, skipWhenNull = false) => {
20
+ if (ok === null || ok === undefined)
21
+ return skipWhenNull ? "skipped" : "pending";
22
+ return ok ? "ok" : "blocked";
23
+ };
24
+ return [
25
+ { stage: "install", status: "ok" }, // reaching this code means the CLI installed
26
+ { stage: "node_online", status: status(readiness.nodeOnline) },
27
+ { stage: "runtime", status: status(readiness.runtimeReady) },
28
+ { stage: "credential", status: status(readiness.credentialReady, true) },
29
+ { stage: "repo", status: status(readiness.repoChosen) },
30
+ { stage: "first_task", status: status(readiness.firstTaskReady) },
31
+ ];
32
+ }
33
+ /** Env vars safe to include in a diagnostics bundle: non-secret config knobs only.
34
+ * Anything not matching is dropped entirely (not just redacted). */
35
+ const SAFE_ENV_KEYS = new Set([
36
+ "BIVY_APPROVAL_MODE", "BIVY_SANDBOX", "BIVY_RUNTIME", "BIVY_MULTI_USER_HOST",
37
+ "BIVY_REQUIRE_LOCAL_AUTH", "BIVY_TURN_TIMEOUT_MS", "BIVY_AUTOMATION_CHECKS",
38
+ "PORT", "BIVY_PUBLIC_URL",
39
+ ]);
40
+ /** Deep-redact any string leaf in a JSON-ish value. */
41
+ function redactDeep(value) {
42
+ if (typeof value === "string")
43
+ return redactSecrets(value);
44
+ if (Array.isArray(value))
45
+ return value.map(redactDeep);
46
+ if (value && typeof value === "object") {
47
+ const out = {};
48
+ for (const [k, v] of Object.entries(value))
49
+ out[k] = redactDeep(v);
50
+ return out;
51
+ }
52
+ return value;
53
+ }
54
+ /**
55
+ * Assemble a shareable diagnostics report. Only whitelisted config keys are
56
+ * included; every string leaf is passed through redactSecrets as a backstop so a
57
+ * value that slips in (a URL with an embedded token, say) is still masked.
58
+ */
59
+ export function buildDiagnosticsReport(input) {
60
+ const config = {};
61
+ for (const [k, v] of Object.entries(input.env ?? {})) {
62
+ if (SAFE_ENV_KEYS.has(k) && typeof v === "string" && v.length)
63
+ config[k] = redactSecrets(v);
64
+ }
65
+ return {
66
+ version: input.version ?? "unknown",
67
+ platform: input.platform ?? "unknown",
68
+ nodeVersion: input.nodeVersion ?? "unknown",
69
+ relayConfigured: Boolean(input.relayConfigured),
70
+ health: redactDeep(input.health ?? {}),
71
+ config,
72
+ activation: input.activation ?? [],
73
+ generatedAt: input.generatedAt ?? "",
74
+ };
75
+ }
@@ -308,11 +308,15 @@ export function pickupMessage(nodeName) {
308
308
  * the control plane, not a GitHub label — touches the issue's labels at all.
309
309
  */
310
310
  export async function announcePickup(cfg, issueNumber, nodeName) {
311
- await addLabel(cfg, issueNumber, cfg.claimLabel).catch(() => { });
311
+ // Best-effort and idempotent, but not silent (A4): a failed claim label can let
312
+ // another node pick up the same issue, and a failed comment hides the pickup
313
+ // from the reporter — both are worth a warning in the node log/diagnostics.
314
+ const warn = (what, error) => console.warn(`[github-tasks] issue #${issueNumber}: could not ${what}:`, error instanceof Error ? error.message : error);
315
+ await addLabel(cfg, issueNumber, cfg.claimLabel).catch((error) => warn(`apply claim label "${cfg.claimLabel}"`, error));
312
316
  if (cfg.label && cfg.label !== cfg.claimLabel) {
313
- await removeLabel(cfg, issueNumber, cfg.label).catch(() => { });
317
+ await removeLabel(cfg, issueNumber, cfg.label).catch((error) => warn(`remove routing label "${cfg.label}"`, error));
314
318
  }
315
- await commentIssue(cfg, issueNumber, pickupMessage(nodeName)).catch(() => { });
319
+ await commentIssue(cfg, issueNumber, pickupMessage(nodeName)).catch((error) => warn("post pickup comment", error));
316
320
  }
317
321
  export async function defaultBranch(cfg) {
318
322
  const res = await gh(cfg, "GET", "");
@@ -322,11 +326,27 @@ export async function defaultBranch(cfg) {
322
326
  return data.default_branch || "main";
323
327
  }
324
328
  export async function openPullRequest(cfg, input) {
329
+ // Idempotent across retry/reclaim (C4a). Creating a PR is an external effect
330
+ // that must not duplicate when the same item is retried on this node or
331
+ // reclaimed by another. A PR for this head may already exist — from a prior
332
+ // attempt or the agent's own `gh pr create` — so reuse it instead of POSTing
333
+ // blindly (GitHub 422s "a pull request already exists", which the old code
334
+ // reported as failure, silently losing the PR reference).
335
+ const branch = input.head.includes(":") ? input.head.slice(input.head.indexOf(":") + 1) : input.head;
336
+ const existing = await findOpenPullRequestForBranch(cfg, branch);
337
+ if (existing)
338
+ return existing;
325
339
  const res = await gh(cfg, "POST", "/pulls", input);
326
- if (!res.ok)
327
- return undefined;
328
- const data = (await res.json().catch(() => ({})));
329
- return data.html_url ? { url: data.html_url, number: Number(data.number) } : undefined;
340
+ if (res.ok) {
341
+ const data = (await res.json().catch(() => ({})));
342
+ return data.html_url ? { url: data.html_url, number: Number(data.number) } : undefined;
343
+ }
344
+ // 422 = a PR for this head/base already exists (opened concurrently, e.g. by a
345
+ // node that reclaimed the item mid-flight). Recover its reference rather than
346
+ // reporting failure and re-attempting.
347
+ if (res.status === 422)
348
+ return findOpenPullRequestForBranch(cfg, branch);
349
+ return undefined;
330
350
  }
331
351
  /**
332
352
  * Update an existing pull request's title and/or body. Used to refresh the
package/dist/guard.js CHANGED
@@ -4,8 +4,14 @@ import path from "node:path";
4
4
  export function bashCommand(input) {
5
5
  if (!input || typeof input !== "object")
6
6
  return "";
7
- const command = input.command;
8
- return typeof command === "string" ? command : "";
7
+ const value = input;
8
+ // Structured agents use different names for their shell surface. Keep this
9
+ // intentionally small and factual; unknown tools are not magically governed.
10
+ for (const key of ["command", "cmd", "script"]) {
11
+ if (typeof value[key] === "string")
12
+ return value[key];
13
+ }
14
+ return "";
9
15
  }
10
16
  /** Heuristic for the legacy "risky" mode (prompt-heavy). */
11
17
  export function looksRiskyBash(command) {
@@ -15,15 +21,51 @@ export function looksRiskyBash(command) {
15
21
  return (/(^|[;&|()\s])(rm|rmdir|mv|cp|chmod|chown|dd|mkfs|sudo|su|kill|pkill|curl|wget|git\s+(commit|push|reset|clean|checkout|switch|merge|rebase)|npm\s+(install|update|publish)|pnpm\s+(install|update|publish)|yarn\s+(add|install|upgrade|publish))([;&|()\s]|$)/.test(normalized) ||
16
22
  /(^|\s)(>|>>|2>|&>|tee\s+)/.test(normalized));
17
23
  }
24
+ /** Parse dangerous recursive rm targets without a nested-quantifier regex. The
25
+ * input is user/agent controlled, so this intentionally stays linear-time. */
26
+ function catastrophicRm(command) {
27
+ const systemRoots = ["/etc", "/usr", "/home", "/var", "/opt", "/boot", "/root", "/bin", "/sbin", "/lib", "/lib64"];
28
+ for (const segment of command.split(/[;\n|&]+/)) {
29
+ const tokens = segment.trim().split(/\s+/).map((token) => token.replace(/^["']|["']$/g, ""));
30
+ const rmIndex = tokens.lastIndexOf("rm");
31
+ if (rmIndex < 0)
32
+ continue;
33
+ let recursive = false;
34
+ let force = false;
35
+ const targets = [];
36
+ for (const token of tokens.slice(rmIndex + 1)) {
37
+ if (token === "--recursive")
38
+ recursive = true;
39
+ else if (token === "--force")
40
+ force = true;
41
+ else if (token.startsWith("-") && !token.startsWith("--")) {
42
+ recursive ||= token.includes("r") || token.includes("R");
43
+ force ||= token.includes("f");
44
+ }
45
+ else if (!token.startsWith("--"))
46
+ targets.push(token);
47
+ }
48
+ if (!recursive || !force)
49
+ continue;
50
+ for (const rawTarget of targets) {
51
+ const target = rawTarget.length > 1 ? rawTarget.replace(/\/+$/, "") : rawTarget;
52
+ if (target === "/" || target === "~" || target === "$home" || target === "/*")
53
+ return true;
54
+ if (systemRoots.some((root) => target === root || target.startsWith(`${root}/`)))
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
18
60
  /**
19
61
  * Catastrophic, irreversible, system-wide actions. Blocked OUTRIGHT in every mode
20
62
  * — the boundary that makes unattended autonomy safe rather than reckless.
21
63
  */
22
64
  export function looksCatastrophic(command) {
23
- const c = command.trim().toLowerCase();
65
+ const c = command.trim().toLowerCase().slice(0, 100_000);
24
66
  if (!c)
25
67
  return false;
26
- return (/\brm\s+(-\S*[rf]\S*\s+)+(\/|~|\$home|\/\*)(\s|$|\/)/.test(c) || // rm -rf / | ~ | /*
68
+ return (catastrophicRm(c) || // rm -rf / | ~ | system roots
27
69
  /\bmkfs(\.\w+)?\b/.test(c) ||
28
70
  /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/.test(c) ||
29
71
  />\s*\/dev\/(sd|nvme|hd|disk)/.test(c) ||
@@ -76,11 +118,12 @@ export function guardToolCall(workspace, toolName, input, mode, isRiskyIntegrati
76
118
  // case-sensitive compare silently disabled it for claude-code. Integration
77
119
  // tool names (MCP, etc.) keep their original casing via `isRiskyIntegration`.
78
120
  const tool = toolName.toLowerCase();
121
+ const isShell = tool === "bash" || tool === "shell" || tool === "execute" || tool === "run_command";
79
122
  // Tools that write to the filesystem and so must respect the workspace
80
123
  // boundary. MultiEdit/NotebookEdit also take a `file_path` (see toolPath).
81
124
  const isWrite = tool === "write" || tool === "edit" || tool === "multiedit" || tool === "notebookedit";
82
125
  // --- Hard floor: blocked in every mode ---
83
- if (tool === "bash" && looksCatastrophic(bashCommand(input))) {
126
+ if (isShell && looksCatastrophic(bashCommand(input))) {
84
127
  return { decision: "deny", reason: "Blocked: catastrophic command (outside the safety boundary)" };
85
128
  }
86
129
  if (isWrite) {
@@ -95,15 +138,15 @@ export function guardToolCall(workspace, toolName, input, mode, isRiskyIntegrati
95
138
  if (isRiskyIntegration(toolName))
96
139
  return { decision: "ask" };
97
140
  if (mode === "autonomous") {
98
- if (tool === "bash" && looksBackstop(bashCommand(input)))
141
+ if (isShell && looksBackstop(bashCommand(input)))
99
142
  return { decision: "ask" };
100
143
  return { decision: "allow" };
101
144
  }
102
145
  if (mode === "always") {
103
- return { decision: tool === "bash" || isWrite ? "ask" : "allow" };
146
+ return { decision: isShell || isWrite ? "ask" : "allow" };
104
147
  }
105
148
  // "risky"
106
- if (tool === "bash")
149
+ if (isShell)
107
150
  return { decision: looksRiskyBash(bashCommand(input)) ? "ask" : "allow" };
108
151
  return { decision: isWrite ? "ask" : "allow" };
109
152
  }
@@ -7,8 +7,16 @@
7
7
  // subprocess — without process.ts importing the server. Opt-in via the
8
8
  // BIVY_EGRESS_PROXY env var, so routing all agent traffic through the broker is
9
9
  // an explicit choice (it adds a hop and logs destinations).
10
- import { EgressProxy } from "./net-proxy.js";
10
+ import { EgressProxy, denyAllDecider } from "./net-proxy.js";
11
11
  let proxy;
12
+ // Per-session egress proxies, keyed by session id. This is the plan's
13
+ // "per-workflow proxy/decider, never the singleton": a session that needs its own
14
+ // network policy (e.g. a read-only sandbox that must actually block egress, or a
15
+ // workflow with an allowlist) gets its OWN EgressProxy with its OWN decider,
16
+ // injected into just that session's subprocess — the node-global `proxy` above and
17
+ // every other session are untouched. Empty by default, so nothing here changes the
18
+ // default path.
19
+ const sessionProxies = new Map();
12
20
  /** Start the egress proxy if BIVY_EGRESS_PROXY is set. Idempotent. */
13
21
  export async function startEgressProxyIfEnabled(onEvent) {
14
22
  if (proxy)
@@ -28,3 +36,58 @@ export async function stopEgressProxy() {
28
36
  await proxy.stop();
29
37
  proxy = undefined;
30
38
  }
39
+ // --- Per-session egress (the per-workflow proxy/decider) --------------------
40
+ /**
41
+ * Start a per-session egress proxy governed by `decide`, keyed to `sessionId`.
42
+ * Its `env()` is what `sessionEgressEnv(sessionId)` returns, so the runtime
43
+ * injects it into that session's subprocess *instead of* the node-global proxy.
44
+ * Idempotent per session. Best-effort — a listen failure leaves the session on the
45
+ * default path rather than blocking it.
46
+ */
47
+ export async function startSessionEgress(sessionId, decide, onEvent) {
48
+ if (sessionProxies.has(sessionId))
49
+ return;
50
+ try {
51
+ const p = await EgressProxy.start({ decide, onEvent });
52
+ // A concurrent start for the same id won the race — keep the first, stop this.
53
+ if (sessionProxies.has(sessionId)) {
54
+ await p.stop();
55
+ return;
56
+ }
57
+ sessionProxies.set(sessionId, p);
58
+ }
59
+ catch {
60
+ // Leave the session on the default egress path (global proxy or none).
61
+ }
62
+ }
63
+ /** The per-session proxy env to inject for `sessionId`, or undefined when it has
64
+ * none (the caller then falls back to the node-global `egressEnv()`). */
65
+ export function sessionEgressEnv(sessionId) {
66
+ return sessionProxies.get(sessionId)?.env();
67
+ }
68
+ /** Tear down a session's own egress proxy (call on session close). Idempotent. */
69
+ export async function stopSessionEgress(sessionId) {
70
+ const p = sessionProxies.get(sessionId);
71
+ if (!p)
72
+ return;
73
+ sessionProxies.delete(sessionId);
74
+ await p.stop().catch(() => { });
75
+ }
76
+ /**
77
+ * Apply the sandbox tier's network policy to a session as a per-session proxy.
78
+ * `read-only` means "no writes, no network" (see sandbox.ts), but only agents with
79
+ * a native sandbox enforce the network half — a CLI agent without one (opencode,
80
+ * aider, goose) would still reach the internet. When enforcement is opted in
81
+ * (`BIVY_SANDBOX_NET`), a read-only session gets a deny-all egress proxy so the
82
+ * contract holds for every agent. Other tiers (workspace-write, danger-full-access)
83
+ * allow network and get no per-session proxy. No-op unless opted in, so the default
84
+ * path is unchanged. Node-local traffic (the daemon's MCP/API) is exempt via the
85
+ * proxy env's NO_PROXY, so read-only sessions keep working against localhost.
86
+ */
87
+ export async function applySessionSandboxEgress(sessionId, tier, onEvent) {
88
+ if (!process.env.BIVY_SANDBOX_NET)
89
+ return;
90
+ if (tier !== "read-only")
91
+ return;
92
+ await startSessionEgress(sessionId, denyAllDecider(), onEvent);
93
+ }
@@ -3,12 +3,13 @@
3
3
  // Universal Agent Harness — MCP config rewriting.
4
4
  //
5
5
  // The one piece of MCP governance that is unavoidably per-agent is *where* the
6
- // config lives — but the shape is near-universal. Claude Code, Codex, Cursor,
7
- // Windsurf, and most MCP hosts use the same `{ mcpServers: { name: { command,
8
- // args, env } } }` object (stdio servers) plus optional remote (url) servers.
9
- // This module rewrites that object so every stdio server launches through the
10
- // Bivy MCP proxy instead of directly — turning each agent's own MCP config into
11
- // the injection point, with no agent-specific code beyond the file location.
6
+ // config lives — and, for a couple of hosts, the shape. Claude Code, Cursor,
7
+ // Windsurf, and most MCP hosts use `{ mcpServers: { name: { command, args, env
8
+ // } } }` (stdio) plus optional remote (url) servers. OpenCode is the JSON
9
+ // outlier: `{ mcp: { name: { type: "local", command: [bin, ...args],
10
+ // environment } } }` (see opencode.ai/config.json). This module rewrites both
11
+ // shapes so every stdio server launches through the Bivy MCP proxy instead of
12
+ // directly — turning each agent's own MCP config into the injection point.
12
13
  //
13
14
  // Pure functions, no I/O — unit-tested in test/harness-mcp-config.test.ts. The
14
15
  // file-location table for each agent is data (see agentMcpConfigTargets) that
@@ -109,6 +110,88 @@ export function withBivyToolsServer(config, spec, name = "bivy") {
109
110
  return { config, added: false };
110
111
  return { config: { ...config, mcpServers: { ...servers, [name]: spec } }, added: true };
111
112
  }
113
+ /** Convert a universal stdio server spec into OpenCode's local-server shape. */
114
+ export function toOpenCodeLocalServer(spec) {
115
+ const command = [spec.command ?? "", ...(spec.args ?? [])].filter((s, i) => i === 0 || s !== undefined);
116
+ // Drop a leading empty command if somehow absent — callers always pass one.
117
+ const argv = command[0] ? command : command.slice(1);
118
+ const out = { type: "local", command: argv };
119
+ if (spec.env && Object.keys(spec.env).length)
120
+ out.environment = { ...spec.env };
121
+ return out;
122
+ }
123
+ /** True when an OpenCode local server already launches through the Bivy proxy. */
124
+ export function isOpenCodeProxied(spec, launcher) {
125
+ if (!Array.isArray(spec.command) || spec.command.length === 0)
126
+ return false;
127
+ if (spec.command[0] !== launcher.command)
128
+ return false;
129
+ return spec.command.includes(PROXY_MARKER);
130
+ }
131
+ /**
132
+ * Rewrite every local stdio server in `config.mcp` to launch through the proxy:
133
+ *
134
+ * original: { type: "local", command: ["mcp-fs", "--root", "/w"], environment: {...} }
135
+ * rewritten: { type: "local",
136
+ * command: ["bivy", "mcp-proxy", "--bivy-mcp", "--server", "<name>", "--",
137
+ * "mcp-fs", "--root", "/w"],
138
+ * environment: {...} }
139
+ *
140
+ * Remote servers and already-proxied locals are left untouched (reported in
141
+ * `skipped`). Idempotent. Does not mutate the input.
142
+ */
143
+ export function routeOpenCodeThroughProxy(config, launcher) {
144
+ const rewritten = [];
145
+ const skipped = [];
146
+ const servers = config.mcp ?? {};
147
+ const nextServers = {};
148
+ for (const [name, spec] of Object.entries(servers)) {
149
+ if (!spec || typeof spec !== "object") {
150
+ nextServers[name] = spec;
151
+ skipped.push(name);
152
+ continue;
153
+ }
154
+ if (!Array.isArray(spec.command) || spec.command.length === 0 || typeof spec.command[0] !== "string" || !spec.command[0]) {
155
+ // Remote/url server, enabled-only stub, or malformed — can't wrap via stdio proxy.
156
+ nextServers[name] = spec;
157
+ skipped.push(name);
158
+ continue;
159
+ }
160
+ if (isOpenCodeProxied(spec, launcher)) {
161
+ nextServers[name] = spec;
162
+ skipped.push(name);
163
+ continue;
164
+ }
165
+ const prefix = launcher.argsPrefix ?? [];
166
+ const orig = spec.command;
167
+ nextServers[name] = {
168
+ ...spec,
169
+ type: "local",
170
+ command: [launcher.command, ...prefix, PROXY_MARKER, "--server", name, "--", ...orig],
171
+ };
172
+ rewritten.push(name);
173
+ }
174
+ return {
175
+ config: { ...config, mcp: nextServers },
176
+ rewritten,
177
+ skipped,
178
+ };
179
+ }
180
+ /**
181
+ * Insert the Bivy tools server under OpenCode's `mcp.<name>` (default "bivy").
182
+ * Idempotent: an existing entry of that name is left untouched.
183
+ */
184
+ export function withOpenCodeBivyToolsServer(config, spec, name = "bivy") {
185
+ const servers = config.mcp ?? {};
186
+ if (servers[name])
187
+ return { config, added: false };
188
+ return { config: { ...config, mcp: { ...servers, [name]: spec } }, added: true };
189
+ }
190
+ /** Basename check for OpenCode project config files we inject into. */
191
+ export function isOpenCodeConfigFile(filePath) {
192
+ const base = nodePath.basename(filePath).toLowerCase();
193
+ return base === "opencode.json" || base === ".opencode.json" || base === "opencode.jsonc" || base === ".opencode.jsonc";
194
+ }
112
195
  /** JSON MCP-config file candidates for an agent, most-specific (safest) first. */
113
196
  export function agentMcpConfigTargets(agentId, ctx) {
114
197
  const ws = (...parts) => nodePath.join(ctx.workspace, ...parts);
@@ -9,12 +9,13 @@
9
9
  // session-scoped (workspace-local files preferred) so a failure or a concurrent
10
10
  // session can't corrupt config: we snapshot the exact bytes and restore them.
11
11
  //
12
- // Only JSON configs are handled (Claude, Gemini, OpenCode, generic .mcp.json).
13
- // TOML/YAML-config agents are skipped — they still run and are governed by the
14
- // FS + network channels. Unit-tested in test/harness-mcp-inject.test.ts.
12
+ // JSON configs (Claude, Gemini, generic .mcp.json) use the universal
13
+ // `mcpServers` shape; OpenCode's project `opencode.json` uses its own `mcp`
14
+ // shape (see routeOpenCodeThroughProxy). TOML/YAML (Codex, Goose) go through
15
+ // the format-specific writers. Unit-tested in test/harness-mcp-inject.test.ts.
15
16
  import fs from "node:fs";
16
17
  import path from "node:path";
17
- import { agentMcpConfigTargets, bivyToolsServerSpec, routeThroughProxy, withBivyToolsServer, } from "./mcp-config.js";
18
+ import { agentMcpConfigTargets, bivyToolsServerSpec, isOpenCodeConfigFile, routeOpenCodeThroughProxy, routeThroughProxy, toOpenCodeLocalServer, withBivyToolsServer, withOpenCodeBivyToolsServer, } from "./mcp-config.js";
18
19
  import { injectTomlMcp, injectYamlMcp, insertTomlServer } from "./mcp-config-formats.js";
19
20
  /** The proxy launcher Bivy injects — `bivy mcp-proxy …`. */
20
21
  export function bivyProxyLauncher(bivyCommand = "bivy") {
@@ -23,7 +24,9 @@ export function bivyProxyLauncher(bivyCommand = "bivy") {
23
24
  /**
24
25
  * Inject the proxy into a single JSON config file. Returns a restore thunk
25
26
  * (a no-op if the file was absent, unreadable, non-JSON, or had no stdio
26
- * servers to route). Never throws.
27
+ * servers to route). OpenCode project configs (`opencode.json`) use the
28
+ * OpenCode `mcp` shape; everything else uses the universal `mcpServers` shape.
29
+ * Never throws.
27
30
  */
28
31
  export function injectJsonMcpConfig(filePath, launcher) {
29
32
  let original;
@@ -40,7 +43,9 @@ export function injectJsonMcpConfig(filePath, launcher) {
40
43
  catch {
41
44
  return { injected: false, restore: () => { } };
42
45
  }
43
- const result = routeThroughProxy(parsed, launcher);
46
+ const result = isOpenCodeConfigFile(filePath)
47
+ ? routeOpenCodeThroughProxy(parsed, launcher)
48
+ : routeThroughProxy(parsed, launcher);
44
49
  if (result.rewritten.length === 0)
45
50
  return { injected: false, restore: () => { } };
46
51
  // Preserve the file's indentation feel by re-serializing with 2 spaces; the
@@ -116,7 +121,9 @@ export function injectMcpConfigFile(filePath, launcher) {
116
121
  * servers a file already has), this CREATES the config when absent so an agent
117
122
  * that ships no MCP config still gets the tool. Handles the most-specific JSON
118
123
  * config (session-local for claude/gemini/opencode/generic) and Codex's TOML
119
- * (`~/.codex/config.toml` — Codex has no project-local option). restore() deletes
124
+ * (`~/.codex/config.toml` — Codex has no project-local option). OpenCode gets
125
+ * its native `{ mcp: { bivy: { type: "local", command: [...] } } }` shape — the
126
+ * universal `mcpServers` key is rejected by OpenCode's schema. restore() deletes
120
127
  * a file it created and rewrites the exact original bytes of one it modified.
121
128
  * Idempotent (a `bivy` server already present is a no-op, so concurrent sessions
122
129
  * sharing a global config don't double up). Best-effort; never throws. Goose YAML
@@ -131,6 +138,7 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
131
138
  return { injected: [], restore: () => { } };
132
139
  const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand });
133
140
  const ext = path.extname(target).toLowerCase();
141
+ const openCode = agentId === "opencode" || isOpenCodeConfigFile(target);
134
142
  const existed = fs.existsSync(target);
135
143
  let original;
136
144
  if (existed) {
@@ -142,7 +150,22 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
142
150
  }
143
151
  }
144
152
  let nextContent;
145
- if (ext === ".json") {
153
+ if (ext === ".json" && openCode) {
154
+ let parsed = {};
155
+ if (original !== undefined) {
156
+ try {
157
+ parsed = JSON.parse(original);
158
+ }
159
+ catch {
160
+ return { injected: [], restore: () => { } };
161
+ }
162
+ }
163
+ const { config, added } = withOpenCodeBivyToolsServer(parsed, toOpenCodeLocalServer(spec));
164
+ if (!added)
165
+ return { injected: [], restore: () => { } };
166
+ nextContent = `${JSON.stringify(config, null, 2)}\n`;
167
+ }
168
+ else if (ext === ".json") {
146
169
  let parsed = {};
147
170
  if (original !== undefined) {
148
171
  try {
@@ -18,6 +18,34 @@
18
18
  // networking, unit-tested in test/harness-net-proxy.test.ts.
19
19
  import http from "node:http";
20
20
  import net from "node:net";
21
+ /** Allow every destination (the proxy's default — pure observe-and-log). */
22
+ export const allowAllDecider = () => ({ allow: true });
23
+ /**
24
+ * Deny every destination. Used for a per-session egress proxy that enforces the
25
+ * `read-only` sandbox tier's "no network" contract for agents whose own sandbox
26
+ * doesn't (see egress.ts). Node-local traffic never reaches here — the proxy env's
27
+ * NO_PROXY exempts localhost — so the agent can still reach the daemon's own MCP/API.
28
+ */
29
+ export function denyAllDecider(reason = "read-only sandbox: outbound network is disabled") {
30
+ return () => ({ allow: false, reason });
31
+ }
32
+ /**
33
+ * Allow only hosts in `hosts` (exact, or a subdomain of a listed apex — "api.x.com"
34
+ * matches an entry "x.com"), denying everything else. The building block for a
35
+ * per-workflow egress allowlist that never touches the node-global decider. Host
36
+ * matching is case-insensitive; an empty list denies all.
37
+ */
38
+ export function allowlistDecider(hosts, reason = "not on this session's egress allowlist") {
39
+ const allow = new Set(hosts.map((h) => h.trim().toLowerCase()).filter(Boolean));
40
+ return (host) => {
41
+ const h = host.trim().toLowerCase();
42
+ for (const entry of allow) {
43
+ if (h === entry || h.endsWith(`.${entry}`))
44
+ return { allow: true };
45
+ }
46
+ return { allow: false, reason };
47
+ };
48
+ }
21
49
  /** Split "host:port" (CONNECT target) into parts, defaulting the port. */
22
50
  export function parseHostPort(authority, defaultPort) {
23
51
  // IPv6 literal like [::1]:443
@@ -164,6 +164,25 @@ export async function resolveBranchBaseRef(repoDir, branch) {
164
164
  throw new Error(`Branch "${branch}" was not found on the remote.`);
165
165
  }
166
166
  }
167
+ /**
168
+ * Base ref for ADOPTING a source branch onto a fresh clone on another node (a
169
+ * cross-node fork). Prefers the pushed `origin/<branch>` so the source's
170
+ * committed work travels; falls back to the repo's default branch when the
171
+ * source branch was never pushed (best-effort — any uncommitted work still
172
+ * arrives via the fork's dirty patch). Fetches first so `origin/<branch>` is
173
+ * current. Contrast with `resolveBranchBaseRef`, which is user-facing and throws
174
+ * on a missing branch; a fork must degrade rather than fail.
175
+ */
176
+ export async function resolveAdoptBaseRef(repoDir, branch) {
177
+ await fetchOrigin(repoDir);
178
+ try {
179
+ await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd: repoDir });
180
+ return `origin/${branch}`;
181
+ }
182
+ catch {
183
+ return resolveDefaultBaseRef(repoDir);
184
+ }
185
+ }
167
186
  /**
168
187
  * Whether an existing Bivy-owned checkout at `dest` can be reused as-is, i.e. it
169
188
  * has a `.git` entry AND `git rev-parse` accepts it as a real repository. A
@@ -71,6 +71,47 @@ export function anthropicCredentialPreflight(env, deps = {}) {
71
71
  export function isAnthropicAuthError(raw) {
72
72
  return isModelAuthError(raw);
73
73
  }
74
+ /**
75
+ * Safely validate that an Anthropic API key actually grants access, rather than
76
+ * trusting mere presence (B1). Uses `GET /v1/models` — an authenticated, read-only,
77
+ * zero-token endpoint — so it never spends inference budget or mutates anything.
78
+ *
79
+ * Only API keys (`sk-…`) are probed: OAuth subscription tokens and the `claude`
80
+ * CLI's on-disk/Keychain login have no comparably safe check, so for those we
81
+ * return `{ probed: false, ok: true }` and let presence stand. Any non-auth
82
+ * failure (network down, 5xx, timeout) is also `probed: false` — we only report
83
+ * `ok: false` when the provider affirmatively rejects the credential (401/403).
84
+ */
85
+ export async function probeAnthropicAccess(apiKey, deps = {}) {
86
+ const key = apiKey?.trim();
87
+ // Subscription/OAuth tokens are not API keys; there is no safe read probe.
88
+ if (!key || !key.startsWith("sk-"))
89
+ return { probed: false, ok: true, reason: "no API key to probe" };
90
+ const doFetch = deps.fetch ?? fetch;
91
+ const base = (deps.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
92
+ const controller = new AbortController();
93
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 4000);
94
+ try {
95
+ const res = await doFetch(`${base}/v1/models?limit=1`, {
96
+ method: "GET",
97
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
98
+ signal: controller.signal,
99
+ });
100
+ if (res.ok)
101
+ return { probed: true, ok: true, status: res.status };
102
+ if (res.status === 401 || res.status === 403) {
103
+ return { probed: true, ok: false, status: res.status, reason: `Anthropic rejected the credential (${res.status})` };
104
+ }
105
+ // 429/5xx/etc. — the key may be fine; don't falsely fail readiness.
106
+ return { probed: false, ok: true, status: res.status, reason: `inconclusive (${res.status})` };
107
+ }
108
+ catch (error) {
109
+ return { probed: false, ok: true, reason: error instanceof Error ? error.message : "probe failed" };
110
+ }
111
+ finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
74
115
  /**
75
116
  * Phrase an SDK error for the user: an auth failure gets the sign-in guidance
76
117
  * appended; anything else is returned unchanged.
@@ -168,7 +168,16 @@ export function writeCodexRollout(history, cwd) {
168
168
  const stamp = iso.replace(/[:.]/g, "-").replace(/Z$/, "");
169
169
  const file = path.join(dir, `rollout-${stamp}-${id}.jsonl`);
170
170
  const records = [
171
- { type: "session_meta", timestamp: iso, payload: { id, timestamp: iso, cwd, cli_version: "bivy-fork" } },
171
+ // Codex's SessionMeta parser requires `originator`. Without it the first
172
+ // record is discarded as malformed; `thread/resume` then reaches the first
173
+ // response_item and fails with "does not start with session metadata".
174
+ // Keep both ids: current Codex accepts legacy `id`-only records, but writing
175
+ // the canonical `session_id` makes the synthetic rollout valid directly.
176
+ {
177
+ type: "session_meta",
178
+ timestamp: iso,
179
+ payload: { session_id: id, id, timestamp: iso, cwd, originator: "bivy", cli_version: "bivy-fork" },
180
+ },
172
181
  ...history.map((message) => ({
173
182
  type: "response_item",
174
183
  timestamp: iso,