@bivy/bivy 0.6.0 → 0.7.0-staging.101
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/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +39 -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/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/harness/net-proxy.js +28 -0
- package/dist/metadata.js +18 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +52 -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 +177 -20
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +64 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +789 -108
- 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
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 = {
|
|
@@ -164,7 +179,11 @@ const CLI_AGENT_SPECS = {
|
|
|
164
179
|
// reply to stdout (the TUI needs a real TTY and would hang over a pipe).
|
|
165
180
|
args: ["run"],
|
|
166
181
|
promptMode: "argv",
|
|
167
|
-
|
|
182
|
+
// Supported tier: OpenCode runs on the governed ACP path by default (per-tool
|
|
183
|
+
// Approve/Deny + session/load resume + a real model picker), the same bar Pi,
|
|
184
|
+
// Claude Code, and Codex clear. See `acp` below for the version fallback.
|
|
185
|
+
supportTier: "supported",
|
|
186
|
+
testedVersion: "1.18.13",
|
|
168
187
|
blurb: "The most widely used open-source coding harness (OpenCode CLI).",
|
|
169
188
|
// `opencode run -s <id> "<prompt>"` continues a prior session by its own id
|
|
170
189
|
// (`-s, --session session id to continue`, per `opencode run --help`).
|
|
@@ -180,11 +199,14 @@ const CLI_AGENT_SPECS = {
|
|
|
180
199
|
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" },
|
|
181
200
|
],
|
|
182
201
|
},
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
202
|
+
// `opencode acp` ("start ACP (Agent Client Protocol) server") drives OpenCode
|
|
203
|
+
// through the governed ProtocolRuntime instead of the one-shot pipe: per-tool
|
|
204
|
+
// Approve/Deny, streaming, `session/load` resume, and `session/set_model`.
|
|
205
|
+
// Validated against opencode 1.18.13, so it is ON by default (`preferred`) —
|
|
206
|
+
// gated on the binary actually listing the `acp` subcommand, so an older
|
|
207
|
+
// OpenCode falls back to the pipe path rather than opening a dead session.
|
|
208
|
+
// Force the pipe path back with BIVY_OPENCODE_ACP=0.
|
|
209
|
+
acp: { args: ["acp"], helpToken: "acp", preferred: true },
|
|
188
210
|
install: { kind: "npm", pkg: "opencode-ai" },
|
|
189
211
|
},
|
|
190
212
|
aider: {
|
|
@@ -697,6 +719,9 @@ export function cliAgentManifest() {
|
|
|
697
719
|
label: spec.displayName,
|
|
698
720
|
command: spec.command,
|
|
699
721
|
hidden: Boolean(spec.hidden),
|
|
722
|
+
supportTier: spec.supportTier ?? "beta",
|
|
723
|
+
certification: spec.testedVersion ? "release-tested" : (spec.supportTier ?? "beta") === "beta" ? "adapter-tested" : "unverified",
|
|
724
|
+
...(spec.testedVersion ? { testedVersion: spec.testedVersion } : {}),
|
|
700
725
|
headlessFlags: [...headless].filter((a) => !a.includes("{")),
|
|
701
726
|
install: spec.install ?? null,
|
|
702
727
|
};
|
|
@@ -839,10 +864,28 @@ function cliThinkingConfig(id) {
|
|
|
839
864
|
// installed binary doesn't actually mention. It never UPGRADES — adding a
|
|
840
865
|
// capability needs the exact arg template, which help text can't safely supply — so
|
|
841
866
|
// probing can only make the catalog MORE honest, never invent a no-op control.
|
|
867
|
+
/**
|
|
868
|
+
* Absolute path of a command on the current PATH, or null when it isn't there.
|
|
869
|
+
* Used to key the help-probe cache: caching by the bare NAME would keep serving a
|
|
870
|
+
* stale answer after the binary behind that name changed (a CLI upgraded or
|
|
871
|
+
* installed while the daemon is running, or a different PATH entry winning).
|
|
872
|
+
*/
|
|
873
|
+
function resolveCommandPath(command) {
|
|
874
|
+
if (!command.trim())
|
|
875
|
+
return null;
|
|
876
|
+
const res = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
|
|
877
|
+
shell: process.platform !== "win32",
|
|
878
|
+
encoding: "utf8",
|
|
879
|
+
});
|
|
880
|
+
if (res.status !== 0)
|
|
881
|
+
return null;
|
|
882
|
+
return (res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null;
|
|
883
|
+
}
|
|
842
884
|
const HELP_PROBE_CACHE = new Map();
|
|
843
885
|
function probeHelpText(command) {
|
|
844
|
-
|
|
845
|
-
|
|
886
|
+
const key = resolveCommandPath(command) ?? command;
|
|
887
|
+
if (HELP_PROBE_CACHE.has(key))
|
|
888
|
+
return HELP_PROBE_CACHE.get(key) ?? null;
|
|
846
889
|
let text = null;
|
|
847
890
|
try {
|
|
848
891
|
const res = spawnSync(command, ["--help"], { encoding: "utf8", timeout: 4000 });
|
|
@@ -852,7 +895,7 @@ function probeHelpText(command) {
|
|
|
852
895
|
catch {
|
|
853
896
|
text = null;
|
|
854
897
|
}
|
|
855
|
-
HELP_PROBE_CACHE.set(
|
|
898
|
+
HELP_PROBE_CACHE.set(key, text);
|
|
856
899
|
return text;
|
|
857
900
|
}
|
|
858
901
|
// A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
|
|
@@ -945,9 +988,14 @@ function cliAgentInfo(id) {
|
|
|
945
988
|
// src/harness/mcp-inject.ts + governMcpCall in src/server.ts.
|
|
946
989
|
capabilities: { toolInterception: acpActive, mcpToolApprovals: acpActive || Boolean(process.env.BIVY_MCP_PROXY), modelSelection, resume, packages: false, fork: false, usageReporting, sessionDiscovery: id === "codex" },
|
|
947
990
|
supportTier: spec.supportTier ?? (id === "codex" ? "supported" : "experimental"),
|
|
991
|
+
testedVersion: spec.testedVersion,
|
|
948
992
|
authOwner: spec.authOwner ?? "agent",
|
|
949
993
|
notes: installed
|
|
950
|
-
?
|
|
994
|
+
? acpActive
|
|
995
|
+
// Promoted to ACP: the description must match the governed path actually in
|
|
996
|
+
// use, not the pipe path this agent would otherwise take.
|
|
997
|
+
? `Available on PATH, driven through its Agent Client Protocol server (\`${spec.command} ${spec.acp?.args.join(" ")}\`): each tool call is gated by Bivy's Approve/Deny before it runs, and sessions resume natively. Force the plain stdout pipe with BIVY_${id.toUpperCase()}_ACP=0.`
|
|
998
|
+
: `Available on PATH. This process adapter ${spec.parserId && !spec.parserUnverified ? "parses its native JSON stream into a structured transcript" : spec.parserId ? "streams stdout/stderr (a structured JSON parser is available; opt in with BIVY_AGENT_STRUCTURED=1 once validated for your version)" : "streams stdout/stderr"}; Bivy governs its filesystem/exec/MCP effects at the sandbox tier rather than intercepting each tool call. Override its launch flags with BIVY_${id.toUpperCase()}_ARGS if your CLI version differs.`
|
|
951
999
|
: `${spec.command} was not found on PATH. Install it on this node, then select this agent again.`,
|
|
952
1000
|
install: installed || !installCommand ? undefined : {
|
|
953
1001
|
label: `Install ${spec.displayName}`,
|
|
@@ -962,6 +1010,13 @@ function cliAgentInfo(id) {
|
|
|
962
1010
|
// Approve/Deny card via guardianInterceptor, AND it resumes a prior thread by its
|
|
963
1011
|
// rollout id (thread/resume). Governed + resumable in one runtime supersedes the
|
|
964
1012
|
// exec path, which stays runnable via `BIVY_RUNTIME=codex` for a no-approval flow.
|
|
1013
|
+
/**
|
|
1014
|
+
* The Codex CLI release this adapter was last certified against. Unlike Pi and the
|
|
1015
|
+
* Claude Agent SDK, Codex is an external binary rather than a pinned npm dependency,
|
|
1016
|
+
* so there is no lockfile entry to derive this from — it is bumped deliberately when
|
|
1017
|
+
* the app-server shim is re-validated against a new Codex release.
|
|
1018
|
+
*/
|
|
1019
|
+
const CODEX_TESTED_VERSION = "0.145.0";
|
|
965
1020
|
function codexApprovalsInfo() {
|
|
966
1021
|
const installed = commandAvailable("codex");
|
|
967
1022
|
return {
|
|
@@ -979,6 +1034,11 @@ function codexApprovalsInfo() {
|
|
|
979
1034
|
packages: false,
|
|
980
1035
|
fork: false,
|
|
981
1036
|
sessionDiscovery: true,
|
|
1037
|
+
// getUsage() returns the shim's real token/cost snapshot, and `codex resume
|
|
1038
|
+
// <id>` reopens the thread in Codex's TUI — advertise both so the catalog
|
|
1039
|
+
// (and the pre-session picker) match what the session actually backs.
|
|
1040
|
+
usageReporting: true,
|
|
1041
|
+
interactiveTui: installed,
|
|
982
1042
|
// The governed/resumable Codex variant is the one that owns native
|
|
983
1043
|
// discovery+adoption (issue #156) — not the plain exec runtime below —
|
|
984
1044
|
// so an adopted session gets per-tool approvals from the moment it's
|
|
@@ -986,7 +1046,12 @@ function codexApprovalsInfo() {
|
|
|
986
1046
|
nativeSessionDiscovery: true,
|
|
987
1047
|
nativeSessionAdoption: true,
|
|
988
1048
|
},
|
|
989
|
-
|
|
1049
|
+
// Supported tier: the app-server shim already clears the same bar as Pi and
|
|
1050
|
+
// Claude Code — per-tool Approve/Deny, model selection, thread resume, usage
|
|
1051
|
+
// reporting, and native session discovery/adoption — all over a bidirectional
|
|
1052
|
+
// protocol rather than a one-shot pipe.
|
|
1053
|
+
supportTier: "supported",
|
|
1054
|
+
testedVersion: CODEX_TESTED_VERSION,
|
|
990
1055
|
authOwner: "agent",
|
|
991
1056
|
notes: installed
|
|
992
1057
|
? "Drives Codex's experimental app-server so tool calls surface as in-chat approval cards, and resumes a prior thread by its rollout id (thread/resume). Governance AND resume in one runtime."
|
|
@@ -1095,7 +1160,12 @@ function codexAppServerRuntime(credsDir, tier) {
|
|
|
1095
1160
|
],
|
|
1096
1161
|
},
|
|
1097
1162
|
],
|
|
1098
|
-
|
|
1163
|
+
// usageReporting: ProtocolSession.getUsage() already returns the shim's real
|
|
1164
|
+
// token/cost snapshot — advertise it so the catalog matches what's backed.
|
|
1165
|
+
// interactiveTui: `codex resume <rolloutId>` reopens the exact thread in
|
|
1166
|
+
// Codex's own TUI (the same verified command as native discovery/takeover),
|
|
1167
|
+
// gated on the codex binary being present — mirrors Claude's interactiveTui.
|
|
1168
|
+
capabilities: { toolInterception: true, modelSelection: true, resume: true, usageReporting: true, interactiveTui: commandAvailable("codex"), nativeSessionDiscovery: true, nativeSessionAdoption: true },
|
|
1099
1169
|
// Resume: the shim reconnects a prior thread via thread/resume by its rollout
|
|
1100
1170
|
// id, and history preloads from the same on-disk rollout the exec path reads —
|
|
1101
1171
|
// so takeover/reopen continues a governed session. (Validated on codex-cli
|
|
@@ -1112,6 +1182,15 @@ function codexAppServerRuntime(credsDir, tier) {
|
|
|
1112
1182
|
// Bivy didn't start, so a pre-existing `codex` session can be adopted here
|
|
1113
1183
|
// (the governed variant), never the plain exec runtime below.
|
|
1114
1184
|
discoverNativeSessions: () => discoverNativeCodexSessions(),
|
|
1185
|
+
// Codex custom prompts ($CODEX_HOME/prompts/*.md) → composer slash menu; an
|
|
1186
|
+
// invoked one is expanded and sent as the turn (the app-server doesn't expand
|
|
1187
|
+
// /prompt names itself). resolveCodexHome() matches the prepare'd CODEX_HOME.
|
|
1188
|
+
slashCommands: codexSlashCommands(),
|
|
1189
|
+
// "Continue in terminal": resume this exact thread in Codex's TUI by its
|
|
1190
|
+
// rollout id. `codex resume <id>` is the same command native discovery and
|
|
1191
|
+
// takeover already use (server.ts RESUME/NATIVE_RESUME maps); `env` carries
|
|
1192
|
+
// the minted CODEX_HOME so the TUI reads the same auth.json chat did.
|
|
1193
|
+
interactiveTui: ({ sessionRef, env }) => (sessionRef ? { command: "codex", args: ["resume", sessionRef], env } : null),
|
|
1115
1194
|
});
|
|
1116
1195
|
}
|
|
1117
1196
|
// --- #2: the GENERAL ACP adapter (Agent Client Protocol) --------------------
|
|
@@ -1132,6 +1211,7 @@ function acpShimPath() {
|
|
|
1132
1211
|
* ACP promotion path so both wrap agents identically.
|
|
1133
1212
|
*/
|
|
1134
1213
|
function acpRuntimeOptions(opts) {
|
|
1214
|
+
const slashCommands = cliSlashCommands(opts.id);
|
|
1135
1215
|
return {
|
|
1136
1216
|
id: opts.id,
|
|
1137
1217
|
displayName: opts.displayName,
|
|
@@ -1141,6 +1221,9 @@ function acpRuntimeOptions(opts) {
|
|
|
1141
1221
|
// the FIRST session (before the shim's hello lands); the hello confirms them.
|
|
1142
1222
|
capabilities: { toolInterception: true, resume: true },
|
|
1143
1223
|
resumable: true,
|
|
1224
|
+
// An ACP-promoted opencode still surfaces/expands its on-disk commands (the
|
|
1225
|
+
// ACP handshake doesn't carry them); a bare ACP agent has none.
|
|
1226
|
+
...(slashCommands ? { slashCommands } : {}),
|
|
1144
1227
|
...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
|
|
1145
1228
|
};
|
|
1146
1229
|
}
|
|
@@ -1161,15 +1244,50 @@ function acpRuntimeFromEnv(credsDir) {
|
|
|
1161
1244
|
return acpRuntimeOptions({ id: "acp", displayName: process.env.BIVY_ACP_NAME?.trim() || "ACP Agent", command, agentArgs, credsDir });
|
|
1162
1245
|
}
|
|
1163
1246
|
/**
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1247
|
+
* Does the INSTALLED binary actually evidence the agent's ACP mode? A default-on
|
|
1248
|
+
* promotion must never be taken on faith: ACP is a hard switch (the pipe path is
|
|
1249
|
+
* unreachable once a session opens), so a CLI too old to have the subcommand would
|
|
1250
|
+
* otherwise hang and die instead of degrading. We reuse the same cached `--help`
|
|
1251
|
+
* probe the opt-in capability refinement uses, and fail CLOSED — a missing binary
|
|
1252
|
+
* or unreadable help keeps the agent on the honest pipe path.
|
|
1253
|
+
*/
|
|
1254
|
+
function acpSupportedByBinary(id) {
|
|
1255
|
+
const spec = CLI_AGENT_SPECS[id];
|
|
1256
|
+
if (!spec.acp)
|
|
1257
|
+
return false;
|
|
1258
|
+
if (!commandAvailable(spec.command))
|
|
1259
|
+
return false;
|
|
1260
|
+
const help = probeHelpText(spec.command);
|
|
1261
|
+
if (!help)
|
|
1262
|
+
return false;
|
|
1263
|
+
const token = (spec.acp.helpToken ?? spec.acp.args[0] ?? "acp").toLowerCase();
|
|
1264
|
+
return help.includes(token);
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether a CLI agent should be driven through ACP rather than the one-shot pipe.
|
|
1268
|
+
* Three ways in, in precedence order:
|
|
1269
|
+
* - `BIVY_<ID>_ACP=0` — operator forces the pipe path back (escape hatch).
|
|
1270
|
+
* - `BIVY_<ID>_ACP=1` / `BIVY_PREFER_ACP=1` — operator forces ACP, no probe (they
|
|
1271
|
+
* know their binary; an explicit request shouldn't be second-guessed).
|
|
1272
|
+
* - `spec.acp.preferred` — validated agents are promoted by DEFAULT, but only
|
|
1273
|
+
* when the installed binary evidences the ACP mode (see acpSupportedByBinary).
|
|
1274
|
+
* Still no per-agent code: a spec field plus a flag.
|
|
1275
|
+
*
|
|
1276
|
+
* Both the catalog (cliAgentInfo) and the launch path (makeCliRuntime) call this,
|
|
1277
|
+
* so what the picker advertises and what actually starts cannot disagree.
|
|
1168
1278
|
*/
|
|
1169
1279
|
function prefersAcp(id) {
|
|
1170
|
-
|
|
1280
|
+
const spec = CLI_AGENT_SPECS[id];
|
|
1281
|
+
if (!spec.acp)
|
|
1282
|
+
return false;
|
|
1283
|
+
const override = process.env[`BIVY_${id.toUpperCase()}_ACP`];
|
|
1284
|
+
if (override === "0")
|
|
1285
|
+
return false;
|
|
1286
|
+
if (override === "1" || process.env.BIVY_PREFER_ACP === "1")
|
|
1287
|
+
return true;
|
|
1288
|
+
if (!spec.acp.preferred)
|
|
1171
1289
|
return false;
|
|
1172
|
-
return
|
|
1290
|
+
return acpSupportedByBinary(id);
|
|
1173
1291
|
}
|
|
1174
1292
|
/**
|
|
1175
1293
|
* Resolve the communication mode for a CLI agent. This is deliberately pure so
|
|
@@ -1450,6 +1568,45 @@ const PICKER_RUNTIME_IDS = new Set([
|
|
|
1450
1568
|
...NON_CLI_PICKER_IDS,
|
|
1451
1569
|
...CLI_AGENT_IDS.filter((id) => !CLI_AGENT_SPECS[id].hidden),
|
|
1452
1570
|
]);
|
|
1571
|
+
function runtimeCertification(runtime) {
|
|
1572
|
+
if (runtime.id === "pi")
|
|
1573
|
+
return { certification: "release-tested", testedVersion: "0.83.0" };
|
|
1574
|
+
if (runtime.id === "claude-code-sdk")
|
|
1575
|
+
return { certification: "release-tested", testedVersion: "0.3.220" };
|
|
1576
|
+
if (runtime.testedVersion)
|
|
1577
|
+
return { certification: "release-tested", testedVersion: runtime.testedVersion };
|
|
1578
|
+
return { certification: runtime.supportTier === "beta" ? "adapter-tested" : "unverified" };
|
|
1579
|
+
}
|
|
1580
|
+
function runtimeProtection(runtime) {
|
|
1581
|
+
// Native SDK/CLI sandboxes receive the requested read-only/workspace/full tier
|
|
1582
|
+
// in their own process boundary. The governed Codex path has both native
|
|
1583
|
+
// sandbox flags and Bivy interception; label the stronger containment source.
|
|
1584
|
+
const nativeSandbox = runtime.id === "claude-code-sdk" || runtime.id === "codex-approvals"
|
|
1585
|
+
|| (isCliAgentId(runtime.id) && Boolean(CLI_AGENT_SPECS[runtime.id].composeArgs));
|
|
1586
|
+
if (nativeSandbox)
|
|
1587
|
+
return {
|
|
1588
|
+
protectionLevel: "native-sandbox",
|
|
1589
|
+
protectionLabel: "Native sandbox",
|
|
1590
|
+
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.",
|
|
1591
|
+
};
|
|
1592
|
+
if (runtime.capabilities.toolInterception)
|
|
1593
|
+
return {
|
|
1594
|
+
protectionLevel: "tool-controls",
|
|
1595
|
+
protectionLabel: "Bivy tool controls",
|
|
1596
|
+
protectionDetail: "Structured tool calls pass through Bivy policy and approvals. Shell heuristics prevent accidents, not adversarial escape.",
|
|
1597
|
+
};
|
|
1598
|
+
if (runtime.capabilities.mcpToolApprovals)
|
|
1599
|
+
return {
|
|
1600
|
+
protectionLevel: "mcp-controls",
|
|
1601
|
+
protectionLabel: "MCP tools only",
|
|
1602
|
+
protectionDetail: "Bivy governs MCP tool calls, but the agent's built-in shell and file operations still run with your user permissions.",
|
|
1603
|
+
};
|
|
1604
|
+
return {
|
|
1605
|
+
protectionLevel: "user-permissions",
|
|
1606
|
+
protectionLabel: "Runs as your user",
|
|
1607
|
+
protectionDetail: "No Bivy-owned isolation or complete tool interception. Use a container/VM for unattended or untrusted work.",
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1453
1610
|
export function listRuntimes(currentId) {
|
|
1454
1611
|
return RUNTIME_CATALOG
|
|
1455
1612
|
// Keep the current runtime visible even if hidden, so a session pinned to a
|
|
@@ -1472,7 +1629,7 @@ export function listRuntimes(currentId) {
|
|
|
1472
1629
|
if (runtime.id === "acp")
|
|
1473
1630
|
return acpInfo();
|
|
1474
1631
|
return runtime;
|
|
1475
|
-
}).map((runtime) => ({ ...runtime, current: runtime.id === currentId }));
|
|
1632
|
+
}).map((runtime) => ({ ...runtime, ...runtimeProtection(runtime), ...runtimeCertification(runtime), current: runtime.id === currentId }));
|
|
1476
1633
|
}
|
|
1477
1634
|
export function makeRuntime(options) {
|
|
1478
1635
|
const id = (options.runtime ?? process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
|
|
@@ -1616,5 +1773,5 @@ function makeCliRuntime(id, options) {
|
|
|
1616
1773
|
: [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
|
|
1617
1774
|
}
|
|
1618
1775
|
: {};
|
|
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 });
|
|
1776
|
+
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
1777
|
}
|
|
@@ -63,14 +63,15 @@ function tokensFrom(provider, payload, prev) {
|
|
|
63
63
|
const rotated = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
|
|
64
64
|
const refresh = rotated || prev?.refresh || "";
|
|
65
65
|
const expiresIn = Number(payload.expires_in) || 3600;
|
|
66
|
-
const
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const expires = now + expiresIn * 1000 - provider.refreshSkewMs;
|
|
67
68
|
let accountId = prev?.accountId;
|
|
68
69
|
if (provider.accountIdClaim) {
|
|
69
70
|
accountId = jwtClaim(access, provider.accountIdClaim.path, provider.accountIdClaim.field) ?? accountId;
|
|
70
71
|
if (!accountId)
|
|
71
72
|
throw new Error(`Could not extract account id for "${provider.id}" from the OAuth token`);
|
|
72
73
|
}
|
|
73
|
-
return { access, refresh, expires, ...(accountId ? { accountId } : {}) };
|
|
74
|
+
return { access, refresh, expires, refreshedAt: now, ...(accountId ? { accountId } : {}) };
|
|
74
75
|
}
|
|
75
76
|
// --- Authorization-code flow (browser + callback server + manual paste) ------
|
|
76
77
|
function buildAuthorizeUrl(provider, opts) {
|
|
@@ -282,7 +283,7 @@ export async function loginModelOAuth(credsDir, providerId, interaction) {
|
|
|
282
283
|
if (!provider)
|
|
283
284
|
throw new Error(`Provider "${providerId}" does not support subscription login`);
|
|
284
285
|
const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction) : await loginAuthCode(provider, interaction);
|
|
285
|
-
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
286
|
+
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, refreshedAt: tokens.refreshedAt, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
286
287
|
await createCredentialVault(credsDir).modify(providerId, async () => credential);
|
|
287
288
|
}
|
|
288
289
|
/** Exchange the refresh token for a fresh credential (network call; throws on failure). */
|
|
@@ -318,7 +319,7 @@ export async function refreshModelOAuth(credsDir, providerId) {
|
|
|
318
319
|
if (Number(current.expires) > Date.now())
|
|
319
320
|
return current;
|
|
320
321
|
const fresh = await refreshTokens(provider, current);
|
|
321
|
-
return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
|
|
322
|
+
return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, refreshedAt: fresh.refreshedAt, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
|
|
322
323
|
});
|
|
323
324
|
return result?.type === "oauth" ? result.access : undefined;
|
|
324
325
|
}
|
package/dist/runtime/process.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
6
|
import { stripAnsi } from "./ansi.js";
|
|
7
7
|
import { buildAgentCredentialEnv } from "./credentials.js";
|
|
8
|
-
import { egressEnv } from "../harness/egress.js";
|
|
8
|
+
import { egressEnv, sessionEgressEnv } from "../harness/egress.js";
|
|
9
9
|
import { depCacheEnv } from "../harness/dep-cache.js";
|
|
10
10
|
import { bivySessionEnv } from "./session-env.js";
|
|
11
11
|
/**
|
|
@@ -193,6 +193,17 @@ class ProcessSession {
|
|
|
193
193
|
setName(name) {
|
|
194
194
|
this.name = name;
|
|
195
195
|
}
|
|
196
|
+
/** The agent's on-disk slash commands for this workspace (Codex prompts,
|
|
197
|
+
* opencode commands). Best-effort and display-only: any read failure yields an
|
|
198
|
+
* empty menu, never a throw. */
|
|
199
|
+
getCommands() {
|
|
200
|
+
try {
|
|
201
|
+
return this.runtimeOptions.slashCommands?.list(this.cwd) ?? [];
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
196
207
|
async suggestName() {
|
|
197
208
|
// The generic CLI "dumb-pipe" runtime has no model of its own to name a
|
|
198
209
|
// session with. Returning a raw 60-char truncation of the first message here
|
|
@@ -216,6 +227,17 @@ class ProcessSession {
|
|
|
216
227
|
const prompt = text.trim();
|
|
217
228
|
if (!prompt)
|
|
218
229
|
return;
|
|
230
|
+
// A `/name args` line that matches an on-disk command runs the command by
|
|
231
|
+
// sending its expanded body to the agent; the transcript still shows what the
|
|
232
|
+
// user typed. Any non-command line (incl. a leading slash that isn't one)
|
|
233
|
+
// passes through untouched. Best-effort — a read failure sends the raw line.
|
|
234
|
+
let promptToSend;
|
|
235
|
+
try {
|
|
236
|
+
promptToSend = this.runtimeOptions.slashCommands?.expand(this.cwd, prompt) ?? prompt;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
promptToSend = prompt;
|
|
240
|
+
}
|
|
219
241
|
this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
|
|
220
242
|
this.streaming = true;
|
|
221
243
|
this.emit({ type: "agent_start" });
|
|
@@ -244,7 +266,7 @@ class ProcessSession {
|
|
|
244
266
|
const idx = Math.min(Math.max(at, 0), argsWithFlags.length);
|
|
245
267
|
argsWithFlags = [...argsWithFlags.slice(0, idx), ...inject, ...argsWithFlags.slice(idx)];
|
|
246
268
|
}
|
|
247
|
-
const args = this.runtimeOptions.promptMode === "argv" ? [...argsWithFlags,
|
|
269
|
+
const args = this.runtimeOptions.promptMode === "argv" ? [...argsWithFlags, promptToSend] : argsWithFlags;
|
|
248
270
|
// Resolve credentials per prompt so freshly-refreshed OAuth tokens (and keys
|
|
249
271
|
// added after this session started) reach the agent. The vault wins over any
|
|
250
272
|
// ambient key so Bivy's shared sign-in is authoritative.
|
|
@@ -278,12 +300,14 @@ class ProcessSession {
|
|
|
278
300
|
// src/harness/sandbox.ts). Bivy no longer wraps the process in an OS jail.
|
|
279
301
|
const child = spawn(this.runtimeOptions.command, args, {
|
|
280
302
|
cwd: this.cwd,
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
env
|
|
303
|
+
// Route this agent's outbound traffic through an egress proxy: this
|
|
304
|
+
// session's OWN proxy if it has one (a per-session sandbox/workflow network
|
|
305
|
+
// policy — sessionEgressEnv), else the node-global broker when
|
|
306
|
+
// BIVY_EGRESS_PROXY is enabled (else {}). bivySessionEnv() lets the agent's
|
|
307
|
+
// own shell resolve its session for `bivy attach <path>` (see
|
|
308
|
+
// session-env.ts); spread last so it can never be shadowed by an operator-
|
|
309
|
+
// configured env var of the same name.
|
|
310
|
+
env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...(sessionEgressEnv(this.id) ?? egressEnv()), ...bivySessionEnv(this.id) },
|
|
287
311
|
stdio: "pipe",
|
|
288
312
|
// Detached so the child becomes the leader of its own process group
|
|
289
313
|
// (POSIX) — see killProcessGroup() / abort() below, which kill that whole
|
|
@@ -380,7 +404,7 @@ class ProcessSession {
|
|
|
380
404
|
this.emit({ type: "agent_end", code, signal });
|
|
381
405
|
});
|
|
382
406
|
if (this.runtimeOptions.promptMode !== "argv") {
|
|
383
|
-
child.stdin.end(`${
|
|
407
|
+
child.stdin.end(`${promptToSend}\n`);
|
|
384
408
|
}
|
|
385
409
|
else {
|
|
386
410
|
// Prompt is already in argv. Still close stdin so agents that also read it
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
6
|
import { buildAgentCredentialEnv } from "./credentials.js";
|
|
7
7
|
import { bivySessionEnv } from "./session-env.js";
|
|
8
|
+
import { mergeAgentCommands } from "./slash-commands.js";
|
|
8
9
|
import { extractTokenUsage } from "./cli-parsers.js";
|
|
9
10
|
/** A protocol `usage` message → UsageSnapshot (reuses the CLI token-key scan). */
|
|
10
11
|
function parseProtocolUsage(raw) {
|
|
@@ -247,6 +248,42 @@ class ProtocolSession {
|
|
|
247
248
|
await this.open();
|
|
248
249
|
await this.command("command.invoke", { sessionId: this.id, runtimeSessionRef: this.runtimeSessionRef, name, args: args ?? "" });
|
|
249
250
|
}
|
|
251
|
+
/** The session's slash commands: on-disk custom prompts (Codex/opencode) merged
|
|
252
|
+
* with whatever the shim advertised in its hello, disk winning a collision.
|
|
253
|
+
* Best-effort and display-only — a read failure just drops the on-disk set. */
|
|
254
|
+
getCommands() {
|
|
255
|
+
let disk;
|
|
256
|
+
try {
|
|
257
|
+
disk = this.runtimeOptions.slashCommands?.list(this.cwd);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
disk = undefined;
|
|
261
|
+
}
|
|
262
|
+
return mergeAgentCommands(disk, this.capabilitiesRef.commands);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Resume this session in the agent's own interactive TUI (see the runtimeOptions
|
|
266
|
+
* hook). Resolves the same launch env a turn would — `prepare` (e.g. Codex
|
|
267
|
+
* mints CODEX_HOME + auth.json) then credentials — so the TUI opens with the
|
|
268
|
+
* identical auth as chat. Returns null when the runtime has no TUI hook or there
|
|
269
|
+
* is no session ref to resume yet (the daemon then surfaces "no TUI available").
|
|
270
|
+
*/
|
|
271
|
+
async interactiveTuiCommand() {
|
|
272
|
+
const hook = this.runtimeOptions.interactiveTui;
|
|
273
|
+
if (!hook)
|
|
274
|
+
return null;
|
|
275
|
+
const credentialEnv = this.runtimeOptions.credentials
|
|
276
|
+
? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
|
|
277
|
+
: {};
|
|
278
|
+
let prepareEnv = this.prepareEnv;
|
|
279
|
+
if (this.runtimeOptions.prepare) {
|
|
280
|
+
prepareEnv =
|
|
281
|
+
(await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ??
|
|
282
|
+
prepareEnv;
|
|
283
|
+
}
|
|
284
|
+
const env = { ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv };
|
|
285
|
+
return hook({ sessionRef: this.runtimeSessionRef ?? this.resumeRef, cwd: this.cwd, env });
|
|
286
|
+
}
|
|
250
287
|
getName() { return this.name; }
|
|
251
288
|
setName(name) { this.name = name; }
|
|
252
289
|
async suggestName(firstPrompt) {
|
|
@@ -373,6 +410,21 @@ class ProtocolSession {
|
|
|
373
410
|
this.runtimeSessionRef = msg.runtimeSessionRef;
|
|
374
411
|
return;
|
|
375
412
|
}
|
|
413
|
+
// Late-arriving model registry. A shim that knows its models up front puts them
|
|
414
|
+
// in `hello`; one whose list is only knowable per session — an ACP agent's
|
|
415
|
+
// models depend on which providers the user has authenticated, and arrive with
|
|
416
|
+
// session/new — publishes them here instead. Same contract as the hello path: a
|
|
417
|
+
// picker backed by a real `model.set` the shim answers, never a claimed one.
|
|
418
|
+
if (type === "runtime.models") {
|
|
419
|
+
const models = parseModels(msg.models);
|
|
420
|
+
if (models.length) {
|
|
421
|
+
this.models = models;
|
|
422
|
+
this.capabilitiesRef.modelSelection = true;
|
|
423
|
+
if (typeof msg.currentModel === "string")
|
|
424
|
+
this.currentModelId = msg.currentModel;
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
376
428
|
if (type === "message.delta") {
|
|
377
429
|
const text = String(msg.text ?? "");
|
|
378
430
|
if (!this.assistantText)
|
|
@@ -556,6 +608,17 @@ class ProtocolSession {
|
|
|
556
608
|
const images = (options?.images ?? []).map((img) => ({ type: "image", data: img.data, mimeType: img.mimeType }));
|
|
557
609
|
if (!prompt && !images.length)
|
|
558
610
|
return;
|
|
611
|
+
// A `/name args` line matching an on-disk custom prompt runs the command by
|
|
612
|
+
// sending its expanded body; the transcript still shows what the user typed.
|
|
613
|
+
// Non-command lines pass through untouched. Best-effort — a read failure sends
|
|
614
|
+
// the raw line.
|
|
615
|
+
let textToSend;
|
|
616
|
+
try {
|
|
617
|
+
textToSend = this.runtimeOptions.slashCommands?.expand(this.cwd, prompt) ?? prompt;
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
textToSend = prompt;
|
|
621
|
+
}
|
|
559
622
|
this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
|
|
560
623
|
this.streaming = true;
|
|
561
624
|
this.assistantText = "";
|
|
@@ -566,7 +629,7 @@ class ProtocolSession {
|
|
|
566
629
|
await this.command("chat.send", {
|
|
567
630
|
sessionId: this.id,
|
|
568
631
|
runtimeSessionRef: this.runtimeSessionRef,
|
|
569
|
-
text:
|
|
632
|
+
text: textToSend,
|
|
570
633
|
// Optional multimodal + streaming hints. Present only when the caller
|
|
571
634
|
// supplied them, so a text-only turn keeps the exact payload it always had.
|
|
572
635
|
...(images.length ? { images } : {}),
|