@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.
- package/README.md +15 -6
- package/bin/agent-manifest.json +38 -0
- package/bin/bivy.mjs +99 -13
- package/bin/patch-pi-dependencies.mjs +22 -16
- package/dist/automation-checks.js +68 -0
- package/dist/bivy-login.js +13 -0
- package/dist/control-plane-tasks.js +39 -8
- package/dist/diagnostics.js +75 -0
- package/dist/github-tasks.js +27 -7
- package/dist/guard.js +51 -8
- package/dist/harness/egress.js +64 -1
- package/dist/harness/net-proxy.js +28 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/anthropic-preflight.js +41 -0
- package/dist/runtime/codex-sessions.js +10 -1
- package/dist/runtime/credential-store.js +35 -6
- package/dist/runtime/index.js +83 -3
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +49 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +587 -93
- package/dist/session/attachment-store.js +99 -11
- package/dist/session/event-log.js +75 -11
- package/dist/session/fork-dirty.js +41 -3
- package/dist/session/revert-file.js +44 -0
- package/dist/session/turn-watchdog.js +19 -0
- package/package.json +6 -3
|
@@ -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
|
+
}
|
package/dist/github-tasks.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
8
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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:
|
|
146
|
+
return { decision: isShell || isWrite ? "ask" : "allow" };
|
|
104
147
|
}
|
|
105
148
|
// "risky"
|
|
106
|
-
if (
|
|
149
|
+
if (isShell)
|
|
107
150
|
return { decision: looksRiskyBash(bashCommand(input)) ? "ask" : "allow" };
|
|
108
151
|
return { decision: isWrite ? "ask" : "allow" };
|
|
109
152
|
}
|
package/dist/harness/egress.js
CHANGED
|
@@ -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
|
package/dist/repo-workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|