@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — MCP config writers for non-JSON formats.
|
|
4
|
+
//
|
|
5
|
+
// Codex stores MCP servers in TOML (~/.codex/config.toml, `[mcp_servers.<name>]`)
|
|
6
|
+
// and Goose in YAML (~/.config/goose/config.yaml, `extensions:` with `cmd`/`args`).
|
|
7
|
+
// routeThroughProxy handles the universal JSON shape; these are the two
|
|
8
|
+
// format-specific writers that reroute stdio servers through `bivy mcp-proxy` in
|
|
9
|
+
// TOML/YAML, so Codex and Goose join MCP governance too.
|
|
10
|
+
//
|
|
11
|
+
// These are deliberately focused, line-oriented transforms for the common
|
|
12
|
+
// single-line `command`/`args` (TOML) and `cmd`/`args: [..]` (YAML) shapes — not
|
|
13
|
+
// full TOML/YAML parsers. Anything they don't recognize is left untouched
|
|
14
|
+
// (rewritten: []), which is safe: injection is opt-in and the caller restores the
|
|
15
|
+
// exact original bytes on session end regardless. Pure string→string, unit-tested
|
|
16
|
+
// in test/harness-mcp-formats.test.ts.
|
|
17
|
+
import { PROXY_MARKER } from "./mcp-config.js";
|
|
18
|
+
function proxyArgs(launcher, server, command, origArgs) {
|
|
19
|
+
return [...(launcher.argsPrefix ?? []), PROXY_MARKER, "--server", server, "--", command, ...origArgs];
|
|
20
|
+
}
|
|
21
|
+
function tomlStringArray(values) {
|
|
22
|
+
return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
23
|
+
}
|
|
24
|
+
/** Parse a TOML inline string array `["a", "b"]` (best-effort) into JS strings. */
|
|
25
|
+
function parseTomlArray(literal) {
|
|
26
|
+
const inner = literal.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
27
|
+
if (!inner.trim())
|
|
28
|
+
return [];
|
|
29
|
+
const out = [];
|
|
30
|
+
const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'/g;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(inner)))
|
|
33
|
+
out.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : (m[2] ?? ""));
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Rewrite `[mcp_servers.<name>]` TOML tables so each stdio server launches
|
|
38
|
+
* through the proxy. Handles single-line `command = "…"` and `args = [ … ]`.
|
|
39
|
+
*/
|
|
40
|
+
export function injectTomlMcp(content, launcher) {
|
|
41
|
+
const lines = content.split("\n");
|
|
42
|
+
const rewritten = [];
|
|
43
|
+
// First pass: find each [mcp_servers.NAME] section's line range + command/args.
|
|
44
|
+
let i = 0;
|
|
45
|
+
const proxyCmd = launcher.command;
|
|
46
|
+
while (i < lines.length) {
|
|
47
|
+
const header = /^\s*\[mcp_servers\.([A-Za-z0-9_.-]+)\]\s*$/.exec(lines[i]);
|
|
48
|
+
if (!header) {
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const name = header[1];
|
|
53
|
+
let end = i + 1;
|
|
54
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
55
|
+
end++;
|
|
56
|
+
// Locate command/args lines in [i+1, end).
|
|
57
|
+
let cmdIdx = -1;
|
|
58
|
+
let argsIdx = -1;
|
|
59
|
+
for (let j = i + 1; j < end; j++) {
|
|
60
|
+
if (cmdIdx < 0 && /^\s*command\s*=/.test(lines[j]))
|
|
61
|
+
cmdIdx = j;
|
|
62
|
+
if (argsIdx < 0 && /^\s*args\s*=\s*\[/.test(lines[j]))
|
|
63
|
+
argsIdx = j;
|
|
64
|
+
}
|
|
65
|
+
if (cmdIdx >= 0) {
|
|
66
|
+
const cmdVal = /=\s*"((?:[^"\\]|\\.)*)"/.exec(lines[cmdIdx]) || /=\s*'([^']*)'/.exec(lines[cmdIdx]);
|
|
67
|
+
const command = cmdVal ? (cmdVal[1] ?? "") : "";
|
|
68
|
+
if (command && command !== proxyCmd) {
|
|
69
|
+
const origArgs = argsIdx >= 0 ? parseTomlArray(lines[argsIdx].slice(lines[argsIdx].indexOf("["))) : [];
|
|
70
|
+
lines[cmdIdx] = lines[cmdIdx].replace(/=\s*("(?:[^"\\]|\\.)*"|'[^']*')/, `= ${JSON.stringify(proxyCmd)}`);
|
|
71
|
+
const newArgsLine = `args = ${tomlStringArray(proxyArgs(launcher, name, command, origArgs))}`;
|
|
72
|
+
if (argsIdx >= 0)
|
|
73
|
+
lines[argsIdx] = lines[argsIdx].replace(/args\s*=\s*\[.*\]/, newArgsLine);
|
|
74
|
+
else
|
|
75
|
+
lines.splice(cmdIdx + 1, 0, newArgsLine);
|
|
76
|
+
rewritten.push(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
i = end;
|
|
80
|
+
}
|
|
81
|
+
return { content: lines.join("\n"), rewritten };
|
|
82
|
+
}
|
|
83
|
+
/** Parse a YAML inline flow array `[a, "b"]` into JS strings (best-effort). */
|
|
84
|
+
function parseYamlFlowArray(literal) {
|
|
85
|
+
const inner = literal.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
86
|
+
if (!inner.trim())
|
|
87
|
+
return [];
|
|
88
|
+
return inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter((s) => s.length > 0);
|
|
89
|
+
}
|
|
90
|
+
function yamlFlowArray(values) {
|
|
91
|
+
return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Rewrite Goose `extensions:` YAML so each stdio extension launches through the
|
|
95
|
+
* proxy. Handles the common shape with `cmd:` and an inline `args: [ … ]` at a
|
|
96
|
+
* deeper indent than the extension name. Block-sequence args (`- item` lines) are
|
|
97
|
+
* left untouched (safe no-op) rather than risk a malformed rewrite.
|
|
98
|
+
*/
|
|
99
|
+
export function injectYamlMcp(content, launcher) {
|
|
100
|
+
const lines = content.split("\n");
|
|
101
|
+
const rewritten = [];
|
|
102
|
+
let inExtensions = false;
|
|
103
|
+
let extName = null;
|
|
104
|
+
let extIndent = -1;
|
|
105
|
+
let cmdIdx = -1;
|
|
106
|
+
let cmdValue = "";
|
|
107
|
+
let argsIdx = -1;
|
|
108
|
+
const flush = () => {
|
|
109
|
+
if (extName && cmdIdx >= 0 && cmdValue && cmdValue !== launcher.command) {
|
|
110
|
+
const origArgs = argsIdx >= 0 ? parseYamlFlowArray(lines[argsIdx].slice(lines[argsIdx].indexOf("["))) : [];
|
|
111
|
+
const indent = lines[cmdIdx].slice(0, lines[cmdIdx].length - lines[cmdIdx].trimStart().length);
|
|
112
|
+
lines[cmdIdx] = `${indent}cmd: ${JSON.stringify(launcher.command)}`;
|
|
113
|
+
const newArgs = `${indent}args: ${yamlFlowArray(proxyArgs(launcher, extName, cmdValue, origArgs))}`;
|
|
114
|
+
if (argsIdx >= 0)
|
|
115
|
+
lines[argsIdx] = newArgs;
|
|
116
|
+
else
|
|
117
|
+
lines.splice(cmdIdx + 1, 0, newArgs);
|
|
118
|
+
rewritten.push(extName);
|
|
119
|
+
}
|
|
120
|
+
extName = null;
|
|
121
|
+
cmdIdx = -1;
|
|
122
|
+
cmdValue = "";
|
|
123
|
+
argsIdx = -1;
|
|
124
|
+
};
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
if (/^\s*extensions\s*:/.test(line)) {
|
|
128
|
+
inExtensions = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!inExtensions)
|
|
132
|
+
continue;
|
|
133
|
+
const indent = line.length - line.trimStart().length;
|
|
134
|
+
if (line.trim() && indent === 0) {
|
|
135
|
+
flush();
|
|
136
|
+
inExtensions = false;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// An extension name: ` <name>:` directly under extensions.
|
|
140
|
+
const nameMatch = /^(\s+)([A-Za-z0-9_.-]+)\s*:\s*$/.exec(line);
|
|
141
|
+
if (nameMatch && (extIndent < 0 || nameMatch[1].length <= extIndent || extName === null)) {
|
|
142
|
+
// New extension block starts.
|
|
143
|
+
flush();
|
|
144
|
+
extName = nameMatch[2];
|
|
145
|
+
extIndent = nameMatch[1].length;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (extName) {
|
|
149
|
+
const cmd = /^\s+cmd\s*:\s*(.+)$/.exec(line);
|
|
150
|
+
if (cmd) {
|
|
151
|
+
cmdIdx = i;
|
|
152
|
+
cmdValue = cmd[1].trim().replace(/^["']|["']$/g, "");
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const args = /^\s+args\s*:\s*(\[.*\])\s*$/.exec(line);
|
|
156
|
+
if (args) {
|
|
157
|
+
argsIdx = i;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
flush();
|
|
163
|
+
return { content: lines.join("\n"), rewritten };
|
|
164
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — MCP config rewriting.
|
|
4
|
+
//
|
|
5
|
+
// The one piece of MCP governance that is unavoidably per-agent is *where* the
|
|
6
|
+
// config lives — but the shape is near-universal. Claude Code, Codex, Cursor,
|
|
7
|
+
// Windsurf, and most MCP hosts use the same `{ mcpServers: { name: { command,
|
|
8
|
+
// args, env } } }` object (stdio servers) plus optional remote (url) servers.
|
|
9
|
+
// This module rewrites that object so every stdio server launches through the
|
|
10
|
+
// Bivy MCP proxy instead of directly — turning each agent's own MCP config into
|
|
11
|
+
// the injection point, with no agent-specific code beyond the file location.
|
|
12
|
+
//
|
|
13
|
+
// Pure functions, no I/O — unit-tested in test/harness-mcp-config.test.ts. The
|
|
14
|
+
// file-location table for each agent is data (see agentMcpConfigTargets) that
|
|
15
|
+
// the fs inject/restore step (mcp-inject.ts) reads/writes; the transform itself
|
|
16
|
+
// is shared.
|
|
17
|
+
import nodePath from "node:path";
|
|
18
|
+
/** Marker arg the proxy CLI recognizes, so a config can be detected as already routed. */
|
|
19
|
+
export const PROXY_MARKER = "--bivy-mcp";
|
|
20
|
+
/** True when a server spec already launches through the Bivy proxy. */
|
|
21
|
+
export function isProxied(spec, launcher) {
|
|
22
|
+
if (spec.command !== launcher.command)
|
|
23
|
+
return false;
|
|
24
|
+
return Array.isArray(spec.args) && spec.args.includes(PROXY_MARKER);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Rewrite every stdio server in `config.mcpServers` to launch through the proxy:
|
|
28
|
+
*
|
|
29
|
+
* original: { command: "mcp-fs", args: ["--root", "/w"], env: {...} }
|
|
30
|
+
* rewritten: { command: "bivy",
|
|
31
|
+
* args: ["mcp-proxy", "--bivy-mcp", "--server", "<name>", "--",
|
|
32
|
+
* "mcp-fs", "--root", "/w"],
|
|
33
|
+
* env: {...} }
|
|
34
|
+
*
|
|
35
|
+
* Remote (url) servers are left untouched and reported in `skipped`. Idempotent:
|
|
36
|
+
* an already-proxied server is left as-is (also reported in `skipped`).
|
|
37
|
+
*/
|
|
38
|
+
export function routeThroughProxy(config, launcher) {
|
|
39
|
+
const rewritten = [];
|
|
40
|
+
const skipped = [];
|
|
41
|
+
const servers = config.mcpServers ?? {};
|
|
42
|
+
const nextServers = {};
|
|
43
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
44
|
+
if (!spec || typeof spec !== "object") {
|
|
45
|
+
nextServers[name] = spec;
|
|
46
|
+
skipped.push(name);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (typeof spec.command !== "string" || !spec.command) {
|
|
50
|
+
// Remote/url server (or malformed) — the stdio proxy can't wrap it yet.
|
|
51
|
+
nextServers[name] = spec;
|
|
52
|
+
skipped.push(name);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (isProxied(spec, launcher)) {
|
|
56
|
+
nextServers[name] = spec;
|
|
57
|
+
skipped.push(name);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const prefix = launcher.argsPrefix ?? [];
|
|
61
|
+
nextServers[name] = {
|
|
62
|
+
...spec,
|
|
63
|
+
command: launcher.command,
|
|
64
|
+
args: [...prefix, PROXY_MARKER, "--server", name, "--", spec.command, ...(spec.args ?? [])],
|
|
65
|
+
};
|
|
66
|
+
rewritten.push(name);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
config: { ...config, mcpServers: nextServers },
|
|
70
|
+
rewritten,
|
|
71
|
+
skipped,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Recover the original server command/args from a proxied spec's args — the
|
|
76
|
+
* inverse of routeThroughProxy, used by the proxy CLI to know what to spawn.
|
|
77
|
+
* Returns null if the args don't carry a `-- <command> ...` tail.
|
|
78
|
+
*/
|
|
79
|
+
export function parseProxiedArgs(args) {
|
|
80
|
+
const sep = args.indexOf("--");
|
|
81
|
+
if (sep < 0 || sep + 1 >= args.length)
|
|
82
|
+
return null;
|
|
83
|
+
const serverFlag = args.indexOf("--server");
|
|
84
|
+
const server = serverFlag >= 0 && serverFlag + 1 < sep ? args[serverFlag + 1] : undefined;
|
|
85
|
+
const tail = args.slice(sep + 1);
|
|
86
|
+
return { server, command: tail[0], args: tail.slice(1) };
|
|
87
|
+
}
|
|
88
|
+
/** JSON MCP-config file candidates for an agent, most-specific (safest) first. */
|
|
89
|
+
export function agentMcpConfigTargets(agentId, ctx) {
|
|
90
|
+
const ws = (...parts) => nodePath.join(ctx.workspace, ...parts);
|
|
91
|
+
const home = (...parts) => nodePath.join(ctx.home, ...parts);
|
|
92
|
+
switch (agentId) {
|
|
93
|
+
case "claude":
|
|
94
|
+
case "claude-code":
|
|
95
|
+
case "claude-code-sdk":
|
|
96
|
+
return [ws(".mcp.json")];
|
|
97
|
+
case "gemini":
|
|
98
|
+
return [ws(".gemini", "settings.json"), home(".gemini", "settings.json")];
|
|
99
|
+
case "opencode":
|
|
100
|
+
return [ws("opencode.json"), ws(".opencode.json")];
|
|
101
|
+
case "codex":
|
|
102
|
+
// Codex stores MCP servers in TOML.
|
|
103
|
+
return [home(".codex", "config.toml")];
|
|
104
|
+
case "goose":
|
|
105
|
+
// Goose stores MCP "extensions" in YAML.
|
|
106
|
+
return [home(".config", "goose", "config.yaml")];
|
|
107
|
+
default:
|
|
108
|
+
// A generic project-local MCP config many hosts honor.
|
|
109
|
+
return [ws(".mcp.json")];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — MCP config auto-injection (fs side).
|
|
4
|
+
//
|
|
5
|
+
// Phase 2b: at session start, rewrite the agent's on-disk JSON MCP config so its
|
|
6
|
+
// stdio servers launch through `bivy mcp-proxy` (routeThroughProxy), and hand
|
|
7
|
+
// back a restore() that puts the file back exactly as it was on session end.
|
|
8
|
+
// Opt-in via BIVY_MCP_PROXY so it never surprises a user. Best-effort and
|
|
9
|
+
// session-scoped (workspace-local files preferred) so a failure or a concurrent
|
|
10
|
+
// session can't corrupt config: we snapshot the exact bytes and restore them.
|
|
11
|
+
//
|
|
12
|
+
// Only JSON configs are handled (Claude, Gemini, OpenCode, generic .mcp.json).
|
|
13
|
+
// TOML/YAML-config agents are skipped — they still run and are governed by the
|
|
14
|
+
// FS + network channels. Unit-tested in test/harness-mcp-inject.test.ts.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { agentMcpConfigTargets, routeThroughProxy, } from "./mcp-config.js";
|
|
18
|
+
import { injectTomlMcp, injectYamlMcp } from "./mcp-config-formats.js";
|
|
19
|
+
/** The proxy launcher Bivy injects — `bivy mcp-proxy …`. */
|
|
20
|
+
export function bivyProxyLauncher(bivyCommand = "bivy") {
|
|
21
|
+
return { command: bivyCommand, argsPrefix: ["mcp-proxy"] };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Inject the proxy into a single JSON config file. Returns a restore thunk
|
|
25
|
+
* (a no-op if the file was absent, unreadable, non-JSON, or had no stdio
|
|
26
|
+
* servers to route). Never throws.
|
|
27
|
+
*/
|
|
28
|
+
export function injectJsonMcpConfig(filePath, launcher) {
|
|
29
|
+
let original;
|
|
30
|
+
try {
|
|
31
|
+
original = fs.readFileSync(filePath, "utf8");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { injected: false, restore: () => { } };
|
|
35
|
+
}
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(original);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { injected: false, restore: () => { } };
|
|
42
|
+
}
|
|
43
|
+
const result = routeThroughProxy(parsed, launcher);
|
|
44
|
+
if (result.rewritten.length === 0)
|
|
45
|
+
return { injected: false, restore: () => { } };
|
|
46
|
+
// Preserve the file's indentation feel by re-serializing with 2 spaces; the
|
|
47
|
+
// restore path writes back the exact original bytes regardless.
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(filePath, `${JSON.stringify(result.config, null, 2)}\n`);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { injected: false, restore: () => { } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
injected: true,
|
|
56
|
+
restore: () => {
|
|
57
|
+
try {
|
|
58
|
+
fs.writeFileSync(filePath, original);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Restore is best-effort; the original bytes are all we can offer.
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Inject the proxy into a single MCP config file, dispatching on extension:
|
|
68
|
+
* .json (universal), .toml (Codex), .yaml/.yml (Goose). Reads → transforms →
|
|
69
|
+
* writes, returning an exact-bytes restore(). Never throws; no-op when the file
|
|
70
|
+
* is absent, unparseable, or has no stdio server to route.
|
|
71
|
+
*/
|
|
72
|
+
export function injectMcpConfigFile(filePath, launcher) {
|
|
73
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
74
|
+
if (ext === ".json")
|
|
75
|
+
return injectJsonMcpConfig(filePath, launcher);
|
|
76
|
+
let original;
|
|
77
|
+
try {
|
|
78
|
+
original = fs.readFileSync(filePath, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { injected: false, restore: () => { } };
|
|
82
|
+
}
|
|
83
|
+
let out;
|
|
84
|
+
try {
|
|
85
|
+
if (ext === ".toml")
|
|
86
|
+
out = injectTomlMcp(original, launcher);
|
|
87
|
+
else if (ext === ".yaml" || ext === ".yml")
|
|
88
|
+
out = injectYamlMcp(original, launcher);
|
|
89
|
+
else
|
|
90
|
+
return { injected: false, restore: () => { } };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { injected: false, restore: () => { } };
|
|
94
|
+
}
|
|
95
|
+
if (out.rewritten.length === 0)
|
|
96
|
+
return { injected: false, restore: () => { } };
|
|
97
|
+
try {
|
|
98
|
+
fs.writeFileSync(filePath, out.content);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { injected: false, restore: () => { } };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
injected: true,
|
|
105
|
+
restore: () => {
|
|
106
|
+
try {
|
|
107
|
+
fs.writeFileSync(filePath, original);
|
|
108
|
+
}
|
|
109
|
+
catch { /* best-effort */ }
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Inject the proxy into every MCP config an agent reads for this session.
|
|
115
|
+
* Returns the injected files and a single restore() covering all of them.
|
|
116
|
+
*/
|
|
117
|
+
export function injectMcpProxyForSession(agentId, ctx, launcher = bivyProxyLauncher()) {
|
|
118
|
+
const restores = [];
|
|
119
|
+
const injected = [];
|
|
120
|
+
for (const target of agentMcpConfigTargets(agentId, ctx)) {
|
|
121
|
+
const r = injectMcpConfigFile(target, launcher);
|
|
122
|
+
if (r.injected) {
|
|
123
|
+
injected.push(target);
|
|
124
|
+
restores.push(r.restore);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
injected,
|
|
129
|
+
restore: () => {
|
|
130
|
+
for (const r of restores)
|
|
131
|
+
r();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — `bivy mcp-proxy` entry point.
|
|
4
|
+
//
|
|
5
|
+
// The agent launches its MCP servers through this (via routeThroughProxy config
|
|
6
|
+
// rewriting): `bivy mcp-proxy --bivy-mcp --server <name> -- <real cmd> <args>`.
|
|
7
|
+
// This process spawns the real server and mediates its JSON-RPC, asking the
|
|
8
|
+
// local Bivy daemon to decide each `tools/call` — which runs the SAME guardian /
|
|
9
|
+
// PolicyEngine / ApprovalCard path as native tool calls, so MCP tools are
|
|
10
|
+
// governed identically for every agent.
|
|
11
|
+
//
|
|
12
|
+
// Fail-open by design: if the daemon is unreachable, the proxy must never break
|
|
13
|
+
// the agent — it allows the call and logs to stderr. Governance is a safety net,
|
|
14
|
+
// not a hard dependency that can wedge a user's agent when the node restarts.
|
|
15
|
+
//
|
|
16
|
+
// The HTTP decider is factored out and unit-tested (test/harness-mcp-cli.test.ts)
|
|
17
|
+
// with an injected fetch; the process wiring itself is thin.
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { runMcpProxy } from "./mcp-proxy.js";
|
|
21
|
+
import { parseProxiedArgs } from "./mcp-config.js";
|
|
22
|
+
const DEFAULT_ENDPOINT = "http://127.0.0.1:4317";
|
|
23
|
+
/**
|
|
24
|
+
* Build a decider that asks the daemon whether an MCP `tools/call` may run.
|
|
25
|
+
* Fail-open on any transport/HTTP error so a down node never wedges the agent.
|
|
26
|
+
*/
|
|
27
|
+
export function buildHttpDecider(endpoint, sessionId, server, fetchImpl) {
|
|
28
|
+
return async (tool, args) => {
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetchImpl(`${endpoint}/api/mcp/decide`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ sessionId, server, tool, args }),
|
|
34
|
+
});
|
|
35
|
+
if (!r.ok)
|
|
36
|
+
return { allow: true };
|
|
37
|
+
const j = (await r.json());
|
|
38
|
+
return { allow: j.allow !== false, reason: typeof j.reason === "string" ? j.reason : undefined };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { allow: true }; // fail-open
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Best-effort event post (inventory / results). Never throws. */
|
|
46
|
+
function reportEvent(endpoint, sessionId, event, fetchImpl) {
|
|
47
|
+
void fetchImpl(`${endpoint}/api/mcp/event`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "content-type": "application/json" },
|
|
50
|
+
body: JSON.stringify({ sessionId, event }),
|
|
51
|
+
}).catch(() => { });
|
|
52
|
+
}
|
|
53
|
+
/** Parse `bivy mcp-proxy` args and run the proxy. Returns the child exit code. */
|
|
54
|
+
export async function runMcpProxyCli(argv, deps = {}) {
|
|
55
|
+
const parsed = parseProxiedArgs(argv);
|
|
56
|
+
if (!parsed || !parsed.command) {
|
|
57
|
+
process.stderr.write("bivy mcp-proxy: expected `--server <name> -- <command> [args…]`\n");
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
const endpoint = deps.endpoint ?? process.env.BIVY_MCP_ENDPOINT ?? DEFAULT_ENDPOINT;
|
|
61
|
+
const sessionId = deps.sessionId ?? process.env.BIVY_MCP_SESSION ?? "";
|
|
62
|
+
const server = parsed.server ?? "mcp";
|
|
63
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
64
|
+
return await runMcpProxy({
|
|
65
|
+
command: parsed.command,
|
|
66
|
+
args: parsed.args,
|
|
67
|
+
server,
|
|
68
|
+
decide: buildHttpDecider(endpoint, sessionId, server, fetchImpl),
|
|
69
|
+
// The daemon already records the call via /api/mcp/decide; only forward the
|
|
70
|
+
// inventory and result events here to avoid double-counting.
|
|
71
|
+
onEvent: (event) => {
|
|
72
|
+
if (event.type !== "call")
|
|
73
|
+
reportEvent(endpoint, sessionId, event, fetchImpl);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Run when invoked directly (via `bivy mcp-proxy …` → tsx this file).
|
|
78
|
+
const invokedDirectly = (() => {
|
|
79
|
+
try {
|
|
80
|
+
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
if (invokedDirectly) {
|
|
87
|
+
void runMcpProxyCli(process.argv.slice(2)).then((code) => process.exit(code));
|
|
88
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Universal Agent Harness — MCP effect boundary (governing tool calls).
|
|
4
|
+
//
|
|
5
|
+
// Second pillar of "govern the substrate, not the agent". MCP (Model Context
|
|
6
|
+
// Protocol) is a wire standard every serious agent now speaks — Claude Code,
|
|
7
|
+
// Codex, Goose, Gemini, OpenCode — so instead of teaching Bivy each agent's
|
|
8
|
+
// chat protocol, we sit in front of the agent's MCP servers and mediate the
|
|
9
|
+
// JSON-RPC. Rewriting an agent's MCP config so each server launches as
|
|
10
|
+
// `bivy mcp-proxy -- <real server cmd>` means EVERY MCP tool call flows through
|
|
11
|
+
// Bivy for inventory, policy, and logging — with zero agent-specific code.
|
|
12
|
+
//
|
|
13
|
+
// MCP stdio transport is newline-delimited JSON-RPC 2.0 (one message per line).
|
|
14
|
+
// The mediator below is transport-free and fully unit-testable: it takes decoded
|
|
15
|
+
// messages and says whether to forward them or short-circuit a denied tool call
|
|
16
|
+
// back to the agent. runMcpProxy() wraps it around a real child process's stdio.
|
|
17
|
+
//
|
|
18
|
+
// Unit-tested in test/harness-mcp-proxy.test.ts.
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
/**
|
|
22
|
+
* Transport-free MCP JSON-RPC mediator. Feed it decoded messages in each
|
|
23
|
+
* direction; it enforces policy on `tools/call`, inventories `tools/list`
|
|
24
|
+
* results, and otherwise passes everything through untouched.
|
|
25
|
+
*/
|
|
26
|
+
export class McpMediator {
|
|
27
|
+
decide;
|
|
28
|
+
onEvent;
|
|
29
|
+
server;
|
|
30
|
+
/** Request ids we short-circuited (denied) — never expect a server reply for them. */
|
|
31
|
+
denied = new Set();
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.decide = options.decide;
|
|
34
|
+
this.onEvent = options.onEvent ?? (() => { });
|
|
35
|
+
this.server = options.server ?? "mcp";
|
|
36
|
+
}
|
|
37
|
+
/** Mediate one message coming from the agent (client) toward the server. */
|
|
38
|
+
async handleClientMessage(msg) {
|
|
39
|
+
if (msg.method !== "tools/call")
|
|
40
|
+
return { forward: msg };
|
|
41
|
+
const params = (msg.params ?? {});
|
|
42
|
+
const tool = typeof params.name === "string" ? params.name : "";
|
|
43
|
+
const args = params.arguments;
|
|
44
|
+
let decision;
|
|
45
|
+
try {
|
|
46
|
+
decision = await this.decide(tool, args);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A failing policy check must fail safe (deny), not crash the proxy.
|
|
50
|
+
decision = { allow: false, reason: "Policy check failed." };
|
|
51
|
+
}
|
|
52
|
+
this.onEvent({ type: "call", server: this.server, tool, args, allowed: decision.allow, reason: decision.reason });
|
|
53
|
+
if (decision.allow)
|
|
54
|
+
return { forward: msg };
|
|
55
|
+
// Denied: reply to the agent with an MCP tool *result* carrying isError,
|
|
56
|
+
// so the agent sees a normal tool failure rather than a transport crash.
|
|
57
|
+
if (msg.id !== undefined && msg.id !== null)
|
|
58
|
+
this.denied.add(msg.id);
|
|
59
|
+
return {
|
|
60
|
+
reply: {
|
|
61
|
+
jsonrpc: "2.0",
|
|
62
|
+
id: msg.id ?? null,
|
|
63
|
+
result: {
|
|
64
|
+
isError: true,
|
|
65
|
+
content: [{ type: "text", text: decision.reason || `Blocked by Bivy policy: ${tool}` }],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Observe one message coming from the server toward the agent (client). */
|
|
71
|
+
handleServerMessage(msg) {
|
|
72
|
+
const result = msg.result;
|
|
73
|
+
if (result && Array.isArray(result.tools)) {
|
|
74
|
+
const tools = result.tools
|
|
75
|
+
.map((t) => (t && typeof t === "object" ? t : {}))
|
|
76
|
+
.filter((t) => typeof t.name === "string")
|
|
77
|
+
.map((t) => ({ name: t.name, description: typeof t.description === "string" ? t.description : undefined }));
|
|
78
|
+
this.onEvent({ type: "tools", server: this.server, tools });
|
|
79
|
+
}
|
|
80
|
+
else if (msg.id !== undefined && msg.id !== null && (msg.result !== undefined || msg.error !== undefined)) {
|
|
81
|
+
const isError = Boolean(msg.result?.isError) || msg.error !== undefined;
|
|
82
|
+
this.onEvent({ type: "result", server: this.server, id: msg.id, isError });
|
|
83
|
+
}
|
|
84
|
+
return { forward: msg };
|
|
85
|
+
}
|
|
86
|
+
wasDenied(id) {
|
|
87
|
+
return this.denied.has(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Wrap the mediator around a real MCP server subprocess: agent ⇄ (this process's
|
|
92
|
+
* stdio) ⇄ mediator ⇄ child stdio ⇄ real server. Newline-delimited JSON-RPC in
|
|
93
|
+
* both directions. Resolves with the child's exit code.
|
|
94
|
+
*/
|
|
95
|
+
export function runMcpProxy(options) {
|
|
96
|
+
const mediator = new McpMediator(options);
|
|
97
|
+
const agentIn = options.agentInput ?? process.stdin;
|
|
98
|
+
const agentOut = options.agentOutput ?? process.stdout;
|
|
99
|
+
const child = spawn(options.command, options.args ?? [], {
|
|
100
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
101
|
+
env: { ...process.env, ...options.env },
|
|
102
|
+
cwd: options.cwd,
|
|
103
|
+
});
|
|
104
|
+
const writeToAgent = (msg) => agentOut.write(`${JSON.stringify(msg)}\n`);
|
|
105
|
+
const writeToServer = (msg) => child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
106
|
+
// Agent → (mediate) → server, or short-circuit back to the agent.
|
|
107
|
+
const fromAgent = createInterface({ input: agentIn });
|
|
108
|
+
fromAgent.on("line", (line) => {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed)
|
|
111
|
+
return;
|
|
112
|
+
let msg;
|
|
113
|
+
try {
|
|
114
|
+
msg = JSON.parse(trimmed);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Not valid JSON-RPC — drop it rather than forward a malformed frame.
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
void mediator.handleClientMessage(msg).then((outcome) => {
|
|
121
|
+
if (outcome.reply)
|
|
122
|
+
writeToAgent(outcome.reply);
|
|
123
|
+
else if (outcome.forward)
|
|
124
|
+
writeToServer(outcome.forward);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
// When the agent closes its side, close the server's stdin so it sees EOF and
|
|
128
|
+
// shuts down (otherwise the child — and this promise — would hang forever).
|
|
129
|
+
fromAgent.on("close", () => child.stdin.end());
|
|
130
|
+
// Server → (observe) → agent.
|
|
131
|
+
const fromServer = createInterface({ input: child.stdout });
|
|
132
|
+
fromServer.on("line", (line) => {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (!trimmed)
|
|
135
|
+
return;
|
|
136
|
+
let msg;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(trimmed);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
process.stdout.write(`${line}\n`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
writeToAgent(mediator.handleServerMessage(msg).forward);
|
|
145
|
+
});
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
148
|
+
child.on("error", () => resolve(1));
|
|
149
|
+
});
|
|
150
|
+
}
|