@bivy/bivy 0.6.0 → 0.7.0

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
+ }
@@ -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,
@@ -34,6 +34,37 @@ function isStoredCredential(value) {
34
34
  function providerId(id) {
35
35
  return String(id ?? "").trim().toLowerCase();
36
36
  }
37
+ /**
38
+ * Should an `incoming` credential replace the `local` one during a non-destructive
39
+ * `importAll` merge? Pure and exported so the convergence rule is unit-testable
40
+ * without a vault. Rules:
41
+ * - No local entry → take the incoming one.
42
+ * - Only OAuth-vs-OAuth needs freshness arbitration (an api-key set/replace, or a
43
+ * type switch, keeps the existing "incoming wins on a real content change").
44
+ * - A snapshot that omits the refresh token must never clobber a usable one —
45
+ * rotated refresh tokens are single-use, so an incoming with a blank refresh is
46
+ * strictly worse than a local one that still has it.
47
+ * - Prefer the token minted LATER by `refreshedAt` (monotonic mint order) when
48
+ * both carry it; otherwise fall back to the access-token `expires`. In both
49
+ * cases a tie KEEPS the local credential (strictly-greater wins), so an equal
50
+ * stamp can't needlessly churn/rotate the vault, and clock skew can't let an
51
+ * equal-`expires` stale token win.
52
+ */
53
+ export function preferIncomingCredential(local, incoming) {
54
+ if (!local)
55
+ return true;
56
+ if (local.type !== "oauth" || incoming.type !== "oauth")
57
+ return true;
58
+ const localRefresh = String(local.refresh ?? "").trim();
59
+ const incomingRefresh = String(incoming.refresh ?? "").trim();
60
+ if (!incomingRefresh && localRefresh)
61
+ return false;
62
+ const lt = Number(local.refreshedAt);
63
+ const it = Number(incoming.refreshedAt);
64
+ if (Number.isFinite(lt) && Number.isFinite(it))
65
+ return it > lt;
66
+ return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
67
+ }
37
68
  /**
38
69
  * Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
39
70
  *
@@ -202,12 +233,10 @@ export class BivyCredentialStore {
202
233
  if (!id || !isStoredCredential(incoming))
203
234
  continue;
204
235
  const local = vault[id];
205
- if (incoming.type === "oauth" && local?.type === "oauth") {
206
- const localExpires = Number(local.expires) || 0;
207
- const incomingExpires = Number(incoming.expires) || 0;
208
- if (localExpires > incomingExpires)
209
- continue;
210
- }
236
+ // Freshest-wins, rotation-safe (see preferIncomingCredential): a lagging
237
+ // or refresh-less snapshot must not overwrite a fresher local login.
238
+ if (!preferIncomingCredential(local, incoming))
239
+ continue;
211
240
  if (!(id in vault))
212
241
  imported += 1;
213
242
  // Only mark dirty on a real content change, so a snapshot that merely
@@ -40,6 +40,21 @@ import { ensureCodexAuth } from "./codex-auth.js";
40
40
  import { parserFactoryFor } from "./cli-parsers.js";
41
41
  import { sandboxTier, sandboxArgsFor, codexSandboxPolicy } from "../harness/sandbox.js";
42
42
  import { ProtocolRuntime, protocolRuntimeFromEnv, protocolCommandsFromEnv } from "./protocol.js";
43
+ import { codexSlashCommands, opencodeSlashCommands } from "./slash-commands.js";
44
+ /**
45
+ * On-disk slash commands (custom prompts/commands) for the CLI agents that keep
46
+ * them as markdown on the node — Codex's `$CODEX_HOME/prompts`, opencode's
47
+ * global + project `command` dirs. Populates their composer menu and makes an
48
+ * invoked `/name` actually run (see SlashCommandProvider). Any other agent has no
49
+ * such directory convention, so it returns undefined (no agent-native commands).
50
+ */
51
+ function cliSlashCommands(id) {
52
+ if (id === "codex")
53
+ return codexSlashCommands();
54
+ if (id === "opencode")
55
+ return opencodeSlashCommands();
56
+ return undefined;
57
+ }
43
58
  export * from "./types.js";
44
59
  export { NodeCredentialResolver, createCredentialStore } from "./credentials.js";
45
60
  const PI_CAPABILITIES = {
@@ -697,6 +712,9 @@ export function cliAgentManifest() {
697
712
  label: spec.displayName,
698
713
  command: spec.command,
699
714
  hidden: Boolean(spec.hidden),
715
+ supportTier: spec.supportTier ?? "beta",
716
+ certification: spec.testedVersion ? "release-tested" : (spec.supportTier ?? "beta") === "beta" ? "adapter-tested" : "unverified",
717
+ ...(spec.testedVersion ? { testedVersion: spec.testedVersion } : {}),
700
718
  headlessFlags: [...headless].filter((a) => !a.includes("{")),
701
719
  install: spec.install ?? null,
702
720
  };
@@ -979,6 +997,11 @@ function codexApprovalsInfo() {
979
997
  packages: false,
980
998
  fork: false,
981
999
  sessionDiscovery: true,
1000
+ // getUsage() returns the shim's real token/cost snapshot, and `codex resume
1001
+ // <id>` reopens the thread in Codex's TUI — advertise both so the catalog
1002
+ // (and the pre-session picker) match what the session actually backs.
1003
+ usageReporting: true,
1004
+ interactiveTui: installed,
982
1005
  // The governed/resumable Codex variant is the one that owns native
983
1006
  // discovery+adoption (issue #156) — not the plain exec runtime below —
984
1007
  // so an adopted session gets per-tool approvals from the moment it's
@@ -1095,7 +1118,12 @@ function codexAppServerRuntime(credsDir, tier) {
1095
1118
  ],
1096
1119
  },
1097
1120
  ],
1098
- capabilities: { toolInterception: true, modelSelection: true, resume: true, nativeSessionDiscovery: true, nativeSessionAdoption: true },
1121
+ // usageReporting: ProtocolSession.getUsage() already returns the shim's real
1122
+ // token/cost snapshot — advertise it so the catalog matches what's backed.
1123
+ // interactiveTui: `codex resume <rolloutId>` reopens the exact thread in
1124
+ // Codex's own TUI (the same verified command as native discovery/takeover),
1125
+ // gated on the codex binary being present — mirrors Claude's interactiveTui.
1126
+ capabilities: { toolInterception: true, modelSelection: true, resume: true, usageReporting: true, interactiveTui: commandAvailable("codex"), nativeSessionDiscovery: true, nativeSessionAdoption: true },
1099
1127
  // Resume: the shim reconnects a prior thread via thread/resume by its rollout
1100
1128
  // id, and history preloads from the same on-disk rollout the exec path reads —
1101
1129
  // so takeover/reopen continues a governed session. (Validated on codex-cli
@@ -1112,6 +1140,15 @@ function codexAppServerRuntime(credsDir, tier) {
1112
1140
  // Bivy didn't start, so a pre-existing `codex` session can be adopted here
1113
1141
  // (the governed variant), never the plain exec runtime below.
1114
1142
  discoverNativeSessions: () => discoverNativeCodexSessions(),
1143
+ // Codex custom prompts ($CODEX_HOME/prompts/*.md) → composer slash menu; an
1144
+ // invoked one is expanded and sent as the turn (the app-server doesn't expand
1145
+ // /prompt names itself). resolveCodexHome() matches the prepare'd CODEX_HOME.
1146
+ slashCommands: codexSlashCommands(),
1147
+ // "Continue in terminal": resume this exact thread in Codex's TUI by its
1148
+ // rollout id. `codex resume <id>` is the same command native discovery and
1149
+ // takeover already use (server.ts RESUME/NATIVE_RESUME maps); `env` carries
1150
+ // the minted CODEX_HOME so the TUI reads the same auth.json chat did.
1151
+ interactiveTui: ({ sessionRef, env }) => (sessionRef ? { command: "codex", args: ["resume", sessionRef], env } : null),
1115
1152
  });
1116
1153
  }
1117
1154
  // --- #2: the GENERAL ACP adapter (Agent Client Protocol) --------------------
@@ -1132,6 +1169,7 @@ function acpShimPath() {
1132
1169
  * ACP promotion path so both wrap agents identically.
1133
1170
  */
1134
1171
  function acpRuntimeOptions(opts) {
1172
+ const slashCommands = cliSlashCommands(opts.id);
1135
1173
  return {
1136
1174
  id: opts.id,
1137
1175
  displayName: opts.displayName,
@@ -1141,6 +1179,9 @@ function acpRuntimeOptions(opts) {
1141
1179
  // the FIRST session (before the shim's hello lands); the hello confirms them.
1142
1180
  capabilities: { toolInterception: true, resume: true },
1143
1181
  resumable: true,
1182
+ // An ACP-promoted opencode still surfaces/expands its on-disk commands (the
1183
+ // ACP handshake doesn't carry them); a bare ACP agent has none.
1184
+ ...(slashCommands ? { slashCommands } : {}),
1144
1185
  ...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
1145
1186
  };
1146
1187
  }
@@ -1450,6 +1491,45 @@ const PICKER_RUNTIME_IDS = new Set([
1450
1491
  ...NON_CLI_PICKER_IDS,
1451
1492
  ...CLI_AGENT_IDS.filter((id) => !CLI_AGENT_SPECS[id].hidden),
1452
1493
  ]);
1494
+ function runtimeCertification(runtime) {
1495
+ if (runtime.id === "pi")
1496
+ return { certification: "release-tested", testedVersion: "0.83.0" };
1497
+ if (runtime.id === "claude-code-sdk")
1498
+ return { certification: "release-tested", testedVersion: "0.3.220" };
1499
+ if (runtime.testedVersion)
1500
+ return { certification: "release-tested", testedVersion: runtime.testedVersion };
1501
+ return { certification: runtime.supportTier === "beta" ? "adapter-tested" : "unverified" };
1502
+ }
1503
+ function runtimeProtection(runtime) {
1504
+ // Native SDK/CLI sandboxes receive the requested read-only/workspace/full tier
1505
+ // in their own process boundary. The governed Codex path has both native
1506
+ // sandbox flags and Bivy interception; label the stronger containment source.
1507
+ const nativeSandbox = runtime.id === "claude-code-sdk" || runtime.id === "codex-approvals"
1508
+ || (isCliAgentId(runtime.id) && Boolean(CLI_AGENT_SPECS[runtime.id].composeArgs));
1509
+ if (nativeSandbox)
1510
+ return {
1511
+ protectionLevel: "native-sandbox",
1512
+ protectionLabel: "Native sandbox",
1513
+ protectionDetail: "This agent enforces Bivy's selected access tier in its native sandbox. Bivy tool controls may add approvals, but are not an OS jail of their own.",
1514
+ };
1515
+ if (runtime.capabilities.toolInterception)
1516
+ return {
1517
+ protectionLevel: "tool-controls",
1518
+ protectionLabel: "Bivy tool controls",
1519
+ protectionDetail: "Structured tool calls pass through Bivy policy and approvals. Shell heuristics prevent accidents, not adversarial escape.",
1520
+ };
1521
+ if (runtime.capabilities.mcpToolApprovals)
1522
+ return {
1523
+ protectionLevel: "mcp-controls",
1524
+ protectionLabel: "MCP tools only",
1525
+ protectionDetail: "Bivy governs MCP tool calls, but the agent's built-in shell and file operations still run with your user permissions.",
1526
+ };
1527
+ return {
1528
+ protectionLevel: "user-permissions",
1529
+ protectionLabel: "Runs as your user",
1530
+ protectionDetail: "No Bivy-owned isolation or complete tool interception. Use a container/VM for unattended or untrusted work.",
1531
+ };
1532
+ }
1453
1533
  export function listRuntimes(currentId) {
1454
1534
  return RUNTIME_CATALOG
1455
1535
  // Keep the current runtime visible even if hidden, so a session pinned to a
@@ -1472,7 +1552,7 @@ export function listRuntimes(currentId) {
1472
1552
  if (runtime.id === "acp")
1473
1553
  return acpInfo();
1474
1554
  return runtime;
1475
- }).map((runtime) => ({ ...runtime, current: runtime.id === currentId }));
1555
+ }).map((runtime) => ({ ...runtime, ...runtimeProtection(runtime), ...runtimeCertification(runtime), current: runtime.id === currentId }));
1476
1556
  }
1477
1557
  export function makeRuntime(options) {
1478
1558
  const id = (options.runtime ?? process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
@@ -1616,5 +1696,5 @@ function makeCliRuntime(id, options) {
1616
1696
  : [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
1617
1697
  }
1618
1698
  : {};
1619
- return new ProcessRuntime({ id, displayName: spec.displayName, command: spec.command, args: runArgs, promptMode: spec.promptMode, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(parserId), preflight, prepare, model: cliModelConfig(id), thinking: cliThinkingConfig(id), usageReporting: cliUsageReporting(id), ...resumeOpts });
1699
+ return new ProcessRuntime({ id, displayName: spec.displayName, command: spec.command, args: runArgs, promptMode: spec.promptMode, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(parserId), preflight, prepare, model: cliModelConfig(id), thinking: cliThinkingConfig(id), usageReporting: cliUsageReporting(id), slashCommands: cliSlashCommands(id), ...resumeOpts });
1620
1700
  }