@cabane/companion 0.6.133 → 0.6.135
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 +7 -7
- package/dist/cli.js +170 -40
- package/dist/runtime.js +161 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -311,12 +311,12 @@ What the sandbox allows is `runners.policy` in `~/.cabane/config.json`. Every ke
|
|
|
311
311
|
}
|
|
312
312
|
```
|
|
313
313
|
|
|
314
|
-
| Setting | Default
|
|
315
|
-
| ----------------------------------------- |
|
|
316
|
-
| **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it.
|
|
317
|
-
| **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list.
|
|
318
|
-
| **Reads**, `blocklist` mode (the default) | Everything is readable except every companion's store (`~/.cabane`, and any `.cabane` under your home or beside this companion's home; see below), the `.claude` and `.codex` of the other companion homes those stores sit in, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gcloud`, `~/.azure`, `~/.config/op`, `~/.password-store`,
|
|
319
|
-
| **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`.
|
|
314
|
+
| Setting | Default |
|
|
315
|
+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
316
|
+
| **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it. |
|
|
317
|
+
| **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list. |
|
|
318
|
+
| **Reads**, `blocklist` mode (the default) | Everything is readable except every companion's store (`~/.cabane`, and any `.cabane` under your home or beside this companion's home; see below), the `.claude` and `.codex` of the other companion homes those stores sit in, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gcloud`, `~/.azure`, `~/.config/op`, `~/.password-store`, and your `denyRead` paths. `allowRead` reopens a path inside a denied one. |
|
|
319
|
+
| **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
|
|
320
320
|
|
|
321
321
|
**One agent can be excepted.** The sandbox is set per device, and one agent's row in `agents` can say otherwise — for example an ops agent whose job is the machine itself, on a device whose other agents stay sandboxed:
|
|
322
322
|
|
|
@@ -341,7 +341,7 @@ Paths may start with `~/` and may be globs. An invalid `policy` doesn't stop the
|
|
|
341
341
|
|
|
342
342
|
**Other companions on the same machine.** A second companion (another account, a test device) keeps its own secrets in its own `.cabane`, so a turn can't read any companion's store, not only this device's. Every turn denies this companion's store and `~/.cabane` (both, when the Companion runs from a home set with `CABANE_COMPANION_HOME`), plus any `.cabane` up to two levels under your home or beside this companion's home, where companion homes conventionally sit. Stores anywhere deeper are found by a search of your home with `find`, along with the `.claude` and `.codex` beside each one, which hold that companion's harness logins. That search takes seconds over a large home, so the Companion runs it in the background: when it starts, and again whenever a turn starts more than 5 minutes after the last search finished. Only the first turn after a start waits for it. **A store created deeper than two levels is denied within about 5 minutes plus one search, not instantly.** If a search can't finish (`find` is missing, runs past 60 seconds, or hits a directory it can't list), the stores it did find are still denied, and the log says so. The search runs in strict mode too, because strict mode reopens the working directory, and a store inside it must stay hidden.
|
|
343
343
|
|
|
344
|
-
**`.env` files
|
|
344
|
+
**`.env` files are readable.** Beyond the credential locations above, nothing guesses which of your files hold secrets, so `.env` files, in the working directory or any other project, are readable unless you deny them. To hide them, list the roots that hold them, for example `"denyRead": ["~/work/**/.env", "~/work/**/.env.*"]`. Each glob is expanded when the turn starts, which costs a walk of that tree on every turn and misses files created during the turn, so keep the roots narrow. When other projects' secrets really matter, strict mode is the better answer, because it hides them all without a walk.
|
|
345
345
|
|
|
346
346
|
**Strict mode.** `"filesystem": { "mode": "strict" }` hides your whole home directory except the working directory, the harness state (`~/.claude`, `~/.codex`), what the Companion and its harnesses run from, and the paths you list in `allowRead`. Toolchains live all over a home directory (`~/.nvm`, `~/.local/share/pnpm`, `~/.cargo`, `~/.pyenv`, mise or asdf shims), so list yours, or builds will fail with "No such file or directory" on a path you didn't expect. On Linux a hidden path looks missing rather than denied, so the Companion log prints the readable list at the start of each strict turn. Compare it against the path the build couldn't find.
|
|
347
347
|
|
package/dist/cli.js
CHANGED
|
@@ -2024,12 +2024,14 @@ var init_turn_request = __esm({
|
|
|
2024
2024
|
z8.object({
|
|
2025
2025
|
type: z8.literal("http"),
|
|
2026
2026
|
url: z8.string(),
|
|
2027
|
-
headers: z8.record(z8.string(), z8.string()).optional()
|
|
2027
|
+
headers: z8.record(z8.string(), z8.string()).optional(),
|
|
2028
|
+
headersFromEnv: z8.record(z8.string(), z8.string()).optional()
|
|
2028
2029
|
}),
|
|
2029
2030
|
z8.object({
|
|
2030
2031
|
type: z8.literal("sse"),
|
|
2031
2032
|
url: z8.string(),
|
|
2032
|
-
headers: z8.record(z8.string(), z8.string()).optional()
|
|
2033
|
+
headers: z8.record(z8.string(), z8.string()).optional(),
|
|
2034
|
+
headersFromEnv: z8.record(z8.string(), z8.string()).optional()
|
|
2033
2035
|
})
|
|
2034
2036
|
]);
|
|
2035
2037
|
resolvedMcpServersSchema = z8.record(z8.string(), resolvedMcpServerSchema);
|
|
@@ -2070,6 +2072,11 @@ var init_turn_request = __esm({
|
|
|
2070
2072
|
cwd: z8.string().optional(),
|
|
2071
2073
|
env: z8.record(z8.string(), z8.string()).optional(),
|
|
2072
2074
|
mcpServers: resolvedMcpServersSchema.optional(),
|
|
2075
|
+
// CT1698: the values `headersFromEnv` names, per turn (`CABANE_MCP_<n>` →
|
|
2076
|
+
// complete header value). The adapter merges them into the harness
|
|
2077
|
+
// process's environment and nowhere else; the host never merges them into
|
|
2078
|
+
// its own. Absent when no server references one.
|
|
2079
|
+
secretEnv: z8.record(z8.string(), z8.string()).optional(),
|
|
2073
2080
|
// CT289: machine-local claude-code adapter knobs the operator sets on a
|
|
2074
2081
|
// companion they run themselves — the auto-memory escape hatch. `autoMemory:
|
|
2075
2082
|
// true` opts back into Claude Code's auto-memory (governed by the operator's
|
|
@@ -2565,6 +2572,29 @@ var init_addendum = __esm({
|
|
|
2565
2572
|
}
|
|
2566
2573
|
});
|
|
2567
2574
|
|
|
2575
|
+
// packages/agent-runtime/src/mcp-secret-env.ts
|
|
2576
|
+
function headersAsEnvReferences(server) {
|
|
2577
|
+
const refs = Object.entries(server.headersFromEnv ?? {}).map(
|
|
2578
|
+
([header, envName]) => [header, `\${${envName}}`]
|
|
2579
|
+
);
|
|
2580
|
+
if (!server.headers && refs.length === 0) return void 0;
|
|
2581
|
+
return { ...server.headers, ...Object.fromEntries(refs) };
|
|
2582
|
+
}
|
|
2583
|
+
function headersResolvedFrom(server, secretEnv) {
|
|
2584
|
+
const resolved = {};
|
|
2585
|
+
for (const [header, envName] of Object.entries(server.headersFromEnv ?? {})) {
|
|
2586
|
+
const value = secretEnv?.[envName];
|
|
2587
|
+
if (value !== void 0) resolved[header] = value;
|
|
2588
|
+
}
|
|
2589
|
+
if (!server.headers && Object.keys(resolved).length === 0) return void 0;
|
|
2590
|
+
return { ...server.headers, ...resolved };
|
|
2591
|
+
}
|
|
2592
|
+
var init_mcp_secret_env = __esm({
|
|
2593
|
+
"packages/agent-runtime/src/mcp-secret-env.ts"() {
|
|
2594
|
+
"use strict";
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2568
2598
|
// packages/agent-runtime/src/claude-code/policy.ts
|
|
2569
2599
|
import { z as z10 } from "zod";
|
|
2570
2600
|
function disallowedToolsFor(policy) {
|
|
@@ -2713,8 +2743,18 @@ async function preToolUseHook(input) {
|
|
|
2713
2743
|
function buildClaudeCodeOptions(req, augment) {
|
|
2714
2744
|
const { policy, config } = req;
|
|
2715
2745
|
const cwd = req.local.cwd;
|
|
2746
|
+
const userServers = {};
|
|
2747
|
+
for (const [name, server] of Object.entries(req.local.mcpServers ?? {})) {
|
|
2748
|
+
if ("url" in server) {
|
|
2749
|
+
const { headersFromEnv: _refs, headers: _headers, ...rest } = server;
|
|
2750
|
+
const headers = headersAsEnvReferences(server);
|
|
2751
|
+
userServers[name] = { ...rest, ...headers ? { headers } : {} };
|
|
2752
|
+
} else {
|
|
2753
|
+
userServers[name] = server;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2716
2756
|
const mcpServers = {
|
|
2717
|
-
...
|
|
2757
|
+
...userServers,
|
|
2718
2758
|
...req.extra.mcpServers
|
|
2719
2759
|
};
|
|
2720
2760
|
if (!(CABANE_MCP_SERVER in mcpServers)) {
|
|
@@ -2796,9 +2836,13 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2796
2836
|
// SDK's own doc on `Options.env`), which is why `process.env` is spread here
|
|
2797
2837
|
// and why this key is now always present — previously it appeared only when
|
|
2798
2838
|
// `req.local.env` was set, and an absent `env` inherits `process.env`.
|
|
2839
|
+
//
|
|
2840
|
+
// CT1698: `secretEnv` joins here, and only here — the values the MCP
|
|
2841
|
+
// headers above reference. It is this child's environment, not the host's.
|
|
2799
2842
|
env: {
|
|
2800
2843
|
...process.env,
|
|
2801
2844
|
...req.local.env,
|
|
2845
|
+
...req.local.secretEnv,
|
|
2802
2846
|
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
|
|
2803
2847
|
},
|
|
2804
2848
|
...resume ? { resume } : {}
|
|
@@ -2832,6 +2876,7 @@ var CABANE_MCP_SERVER, ACTIVE_CONVERSATION_HEADER;
|
|
|
2832
2876
|
var init_options = __esm({
|
|
2833
2877
|
"packages/agent-runtime/src/claude-code/options.ts"() {
|
|
2834
2878
|
"use strict";
|
|
2879
|
+
init_mcp_secret_env();
|
|
2835
2880
|
init_policy();
|
|
2836
2881
|
init_model();
|
|
2837
2882
|
init_session();
|
|
@@ -4121,7 +4166,10 @@ function buildRunSpec(req, resumeSessionId) {
|
|
|
4121
4166
|
// Fresh session → mint + send a client id; resumed → let opencode mint it.
|
|
4122
4167
|
sendClientMessageId: resumeSessionId == null,
|
|
4123
4168
|
...dialect.agent ? { agent: dialect.agent } : {},
|
|
4124
|
-
mcp: buildMcp(req)
|
|
4169
|
+
mcp: buildMcp(req),
|
|
4170
|
+
credentialServers: Object.entries(req.local.mcpServers ?? {}).filter(
|
|
4171
|
+
([, server]) => "url" in server && Object.keys(server.headersFromEnv ?? {}).length > 0
|
|
4172
|
+
).map(([name]) => name)
|
|
4125
4173
|
};
|
|
4126
4174
|
}
|
|
4127
4175
|
function buildParts(req) {
|
|
@@ -4140,10 +4188,11 @@ function buildMcp(req) {
|
|
|
4140
4188
|
const mcp = {};
|
|
4141
4189
|
for (const [name, server] of Object.entries(req.local.mcpServers ?? {})) {
|
|
4142
4190
|
if ("url" in server) {
|
|
4191
|
+
const headers = headersResolvedFrom(server, req.local.secretEnv);
|
|
4143
4192
|
mcp[name] = {
|
|
4144
4193
|
type: "remote",
|
|
4145
4194
|
url: server.url,
|
|
4146
|
-
...
|
|
4195
|
+
...headers ? { headers } : {},
|
|
4147
4196
|
enabled: true
|
|
4148
4197
|
};
|
|
4149
4198
|
} else if ("command" in server) {
|
|
@@ -4202,6 +4251,7 @@ var CABANE_MCP_SERVER2, TURN_CONTROL_MCP_SERVER, ACTIVE_CONVERSATION_HEADER2;
|
|
|
4202
4251
|
var init_run_spec = __esm({
|
|
4203
4252
|
"packages/agent-runtime/src/opencode/run-spec.ts"() {
|
|
4204
4253
|
"use strict";
|
|
4254
|
+
init_mcp_secret_env();
|
|
4205
4255
|
init_policy2();
|
|
4206
4256
|
init_model2();
|
|
4207
4257
|
CABANE_MCP_SERVER2 = "cabane";
|
|
@@ -4338,23 +4388,47 @@ async function* releaseAfter(events, release) {
|
|
|
4338
4388
|
}
|
|
4339
4389
|
}
|
|
4340
4390
|
async function installMcp(doFetch, base, spec, signal) {
|
|
4391
|
+
const [live, configured] = await Promise.all([
|
|
4392
|
+
getJson(doFetch, `${base}/mcp`, signal),
|
|
4393
|
+
getJson(doFetch, `${base}/config`, signal)
|
|
4394
|
+
]);
|
|
4395
|
+
const operatorOwned = new Set(Object.keys(asRecord2(asRecord2(configured).mcp)));
|
|
4396
|
+
const stale = Object.keys(asRecord2(live)).filter(
|
|
4397
|
+
(name) => !(name in spec.mcp) && !operatorOwned.has(name)
|
|
4398
|
+
);
|
|
4399
|
+
await Promise.all(stale.map((name) => postMcp(doFetch, base, name, SCRUBBED_MCP_CONFIG, signal)));
|
|
4400
|
+
const shadowing = (spec.credentialServers ?? []).filter((name) => operatorOwned.has(name));
|
|
4401
|
+
if (shadowing.length > 0) {
|
|
4402
|
+
throw new Error(
|
|
4403
|
+
`opencode: ${shadowing.map((n) => `"${n}"`).join(", ")} would replace an MCP server this opencode instance configures itself; rename that server in the opencode config to use this connection here`
|
|
4404
|
+
);
|
|
4405
|
+
}
|
|
4341
4406
|
await Promise.all(
|
|
4342
|
-
Object.entries(spec.mcp).map(
|
|
4343
|
-
const res = await doFetch(`${base}/mcp`, {
|
|
4344
|
-
method: "POST",
|
|
4345
|
-
headers: { "content-type": "application/json" },
|
|
4346
|
-
body: JSON.stringify({ name, config }),
|
|
4347
|
-
signal
|
|
4348
|
-
});
|
|
4349
|
-
if (!res.ok) {
|
|
4350
|
-
const detail = await res.text().catch(() => "");
|
|
4351
|
-
throw new Error(
|
|
4352
|
-
`opencode: MCP config install failed for "${name}" (HTTP ${res.status}${detail ? `: ${detail}` : ""})`
|
|
4353
|
-
);
|
|
4354
|
-
}
|
|
4355
|
-
})
|
|
4407
|
+
Object.entries(spec.mcp).map(([name, config]) => postMcp(doFetch, base, name, config, signal))
|
|
4356
4408
|
);
|
|
4357
4409
|
}
|
|
4410
|
+
async function postMcp(doFetch, base, name, config, signal) {
|
|
4411
|
+
const res = await doFetch(`${base}/mcp`, {
|
|
4412
|
+
method: "POST",
|
|
4413
|
+
headers: { "content-type": "application/json" },
|
|
4414
|
+
body: JSON.stringify({ name, config }),
|
|
4415
|
+
signal
|
|
4416
|
+
});
|
|
4417
|
+
if (!res.ok) {
|
|
4418
|
+
const detail = await res.text().catch(() => "");
|
|
4419
|
+
throw new Error(
|
|
4420
|
+
`opencode: MCP config install failed for "${name}" (HTTP ${res.status}${detail ? `: ${detail}` : ""})`
|
|
4421
|
+
);
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
async function getJson(doFetch, url, signal) {
|
|
4425
|
+
const res = await doFetch(url, { signal });
|
|
4426
|
+
if (!res.ok) throw new Error(`opencode: ${url} unavailable (HTTP ${res.status})`);
|
|
4427
|
+
return res.json();
|
|
4428
|
+
}
|
|
4429
|
+
function asRecord2(value) {
|
|
4430
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
4431
|
+
}
|
|
4358
4432
|
async function* parseSseStream(body, sessionId, signal) {
|
|
4359
4433
|
const reader = body.getReader();
|
|
4360
4434
|
const decoder = new TextDecoder();
|
|
@@ -4404,11 +4478,16 @@ function newOpencodeMessageId() {
|
|
|
4404
4478
|
function trimSlash(s) {
|
|
4405
4479
|
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
4406
4480
|
}
|
|
4407
|
-
var serverTurnTails;
|
|
4481
|
+
var serverTurnTails, SCRUBBED_MCP_CONFIG;
|
|
4408
4482
|
var init_transport = __esm({
|
|
4409
4483
|
"packages/agent-runtime/src/opencode/transport.ts"() {
|
|
4410
4484
|
"use strict";
|
|
4411
4485
|
serverTurnTails = /* @__PURE__ */ new Map();
|
|
4486
|
+
SCRUBBED_MCP_CONFIG = {
|
|
4487
|
+
type: "remote",
|
|
4488
|
+
url: "http://127.0.0.1:9/cabane-removed",
|
|
4489
|
+
enabled: false
|
|
4490
|
+
};
|
|
4412
4491
|
}
|
|
4413
4492
|
});
|
|
4414
4493
|
|
|
@@ -4972,7 +5051,7 @@ var init_addendum3 = __esm({
|
|
|
4972
5051
|
});
|
|
4973
5052
|
|
|
4974
5053
|
// packages/agent-runtime/src/codex/events.ts
|
|
4975
|
-
function
|
|
5054
|
+
function asRecord3(v) {
|
|
4976
5055
|
return v && typeof v === "object" ? v : null;
|
|
4977
5056
|
}
|
|
4978
5057
|
function str(v) {
|
|
@@ -4982,7 +5061,7 @@ function readThreadId(ev) {
|
|
|
4982
5061
|
return str(ev.thread_id);
|
|
4983
5062
|
}
|
|
4984
5063
|
function readItem(ev) {
|
|
4985
|
-
return
|
|
5064
|
+
return asRecord3(ev.item);
|
|
4986
5065
|
}
|
|
4987
5066
|
function readItemText(item) {
|
|
4988
5067
|
return str(item.text);
|
|
@@ -5053,7 +5132,7 @@ function readItemType(item) {
|
|
|
5053
5132
|
function readErrorMessage(ev) {
|
|
5054
5133
|
const direct = str(ev.message);
|
|
5055
5134
|
if (direct) return direct;
|
|
5056
|
-
const err =
|
|
5135
|
+
const err = asRecord3(ev.error);
|
|
5057
5136
|
if (err) {
|
|
5058
5137
|
const m = str(err.message);
|
|
5059
5138
|
if (m) return m;
|
|
@@ -5061,7 +5140,7 @@ function readErrorMessage(ev) {
|
|
|
5061
5140
|
return "unknown";
|
|
5062
5141
|
}
|
|
5063
5142
|
function readUsage(ev) {
|
|
5064
|
-
const usage =
|
|
5143
|
+
const usage = asRecord3(ev.usage);
|
|
5065
5144
|
if (!usage) return void 0;
|
|
5066
5145
|
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
5067
5146
|
return {
|
|
@@ -5085,7 +5164,7 @@ function toStatus(v) {
|
|
|
5085
5164
|
return v === "completed" || v === "failed" ? v : "in_progress";
|
|
5086
5165
|
}
|
|
5087
5166
|
function readMcpResult(result) {
|
|
5088
|
-
const rec2 =
|
|
5167
|
+
const rec2 = asRecord3(result);
|
|
5089
5168
|
if (!rec2) return result;
|
|
5090
5169
|
if (hasOnlyNonTextContentBlocks(rec2.content)) return rec2.content;
|
|
5091
5170
|
if (rec2.structured_content !== void 0 && rec2.structured_content !== null) {
|
|
@@ -5101,7 +5180,7 @@ function hasOnlyNonTextContentBlocks(content) {
|
|
|
5101
5180
|
if (!Array.isArray(content) || content.length === 0) return false;
|
|
5102
5181
|
let sawTypedBlock = false;
|
|
5103
5182
|
for (const raw of content) {
|
|
5104
|
-
const block =
|
|
5183
|
+
const block = asRecord3(raw);
|
|
5105
5184
|
const type = block && str(block.type);
|
|
5106
5185
|
if (!type) continue;
|
|
5107
5186
|
sawTypedBlock = true;
|
|
@@ -5119,7 +5198,7 @@ function hasMcpReceipt(result) {
|
|
|
5119
5198
|
return true;
|
|
5120
5199
|
}
|
|
5121
5200
|
function readMcpError(error) {
|
|
5122
|
-
const rec2 =
|
|
5201
|
+
const rec2 = asRecord3(error);
|
|
5123
5202
|
return rec2 && str(rec2.message) || "error";
|
|
5124
5203
|
}
|
|
5125
5204
|
var init_events2 = __esm({
|
|
@@ -5427,7 +5506,8 @@ function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromp
|
|
|
5427
5506
|
input: buildInput(req, promptRidesInput),
|
|
5428
5507
|
promptRidesInput,
|
|
5429
5508
|
promptFingerprint,
|
|
5430
|
-
config: buildConfig(req, baseInstructionsFile)
|
|
5509
|
+
config: buildConfig(req, baseInstructionsFile),
|
|
5510
|
+
secretEnv: req.local.secretEnv && Object.keys(req.local.secretEnv).length > 0 ? req.local.secretEnv : null
|
|
5431
5511
|
};
|
|
5432
5512
|
}
|
|
5433
5513
|
function buildInput(req, promptRidesInput) {
|
|
@@ -5443,9 +5523,11 @@ function buildConfig(req, baseInstructionsFile) {
|
|
|
5443
5523
|
const mcp_servers = {};
|
|
5444
5524
|
for (const [name, server] of Object.entries(req.local.mcpServers ?? {})) {
|
|
5445
5525
|
if ("url" in server) {
|
|
5526
|
+
const fromEnv = server.headersFromEnv ?? {};
|
|
5446
5527
|
mcp_servers[name] = {
|
|
5447
5528
|
url: server.url,
|
|
5448
5529
|
...server.headers ? { http_headers: server.headers } : {},
|
|
5530
|
+
...Object.keys(fromEnv).length > 0 ? { env_http_headers: fromEnv } : {},
|
|
5449
5531
|
default_tools_approval_mode: "approve"
|
|
5450
5532
|
};
|
|
5451
5533
|
} else if ("command" in server) {
|
|
@@ -5611,6 +5693,13 @@ function buildSdkThreadOptions(spec) {
|
|
|
5611
5693
|
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5612
5694
|
};
|
|
5613
5695
|
}
|
|
5696
|
+
function codexProcessEnv(secretEnv) {
|
|
5697
|
+
const inherited = {};
|
|
5698
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
5699
|
+
if (value !== void 0) inherited[key] = value;
|
|
5700
|
+
}
|
|
5701
|
+
return { ...inherited, ...secretEnv };
|
|
5702
|
+
}
|
|
5614
5703
|
function createSdkCodexTransport(opts = {}) {
|
|
5615
5704
|
return {
|
|
5616
5705
|
async run(spec, signal) {
|
|
@@ -5627,7 +5716,8 @@ function createSdkCodexTransport(opts = {}) {
|
|
|
5627
5716
|
// also what makes the PUBLISHED companion correct, where the vendored
|
|
5628
5717
|
// patch never reached — and why that patch is gone. See
|
|
5629
5718
|
// `config-overrides.ts`.
|
|
5630
|
-
configOverrides: buildConfigOverrides(spec.config)
|
|
5719
|
+
configOverrides: buildConfigOverrides(spec.config),
|
|
5720
|
+
...spec.secretEnv ? { env: codexProcessEnv(spec.secretEnv) } : {}
|
|
5631
5721
|
};
|
|
5632
5722
|
const codex = new Codex(codexOptions);
|
|
5633
5723
|
const threadOptions = buildSdkThreadOptions(spec);
|
|
@@ -7324,8 +7414,14 @@ function requiredSecretNames(mcpServers) {
|
|
|
7324
7414
|
}
|
|
7325
7415
|
return [...names].sort();
|
|
7326
7416
|
}
|
|
7327
|
-
function resolveMcpSecrets(mcpServers, store) {
|
|
7417
|
+
function resolveMcpSecrets(mcpServers, store, serverSecretEnv = {}) {
|
|
7328
7418
|
const missing = /* @__PURE__ */ new Set();
|
|
7419
|
+
const secretEnv = { ...serverSecretEnv };
|
|
7420
|
+
let counter = 0;
|
|
7421
|
+
for (const name of Object.keys(serverSecretEnv)) {
|
|
7422
|
+
const n = SECRET_ENV_NAME.exec(name);
|
|
7423
|
+
if (n) counter = Math.max(counter, Number(n[1]));
|
|
7424
|
+
}
|
|
7329
7425
|
const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
|
|
7330
7426
|
const resolved = store.get(name);
|
|
7331
7427
|
if (resolved === void 0) {
|
|
@@ -7343,10 +7439,25 @@ function resolveMcpSecrets(mcpServers, store) {
|
|
|
7343
7439
|
for (const [name, server] of Object.entries(mcpServers)) {
|
|
7344
7440
|
const next = { ...server };
|
|
7345
7441
|
if ("env" in next && next.env) next.env = mapBag(next.env);
|
|
7346
|
-
if ("
|
|
7442
|
+
if ("url" in next && next.headers) {
|
|
7443
|
+
const headers = {};
|
|
7444
|
+
const fromEnv = { ...next.headersFromEnv };
|
|
7445
|
+
for (const [header, value] of Object.entries(next.headers)) {
|
|
7446
|
+
if (!value.match(PLACEHOLDER_RE)) {
|
|
7447
|
+
headers[header] = value;
|
|
7448
|
+
continue;
|
|
7449
|
+
}
|
|
7450
|
+
const envName = `CABANE_MCP_${++counter}`;
|
|
7451
|
+
secretEnv[envName] = interpolate(value);
|
|
7452
|
+
fromEnv[header] = envName;
|
|
7453
|
+
}
|
|
7454
|
+
next.headers = headers;
|
|
7455
|
+
if (Object.keys(headers).length === 0) delete next.headers;
|
|
7456
|
+
if (Object.keys(fromEnv).length > 0) next.headersFromEnv = fromEnv;
|
|
7457
|
+
}
|
|
7347
7458
|
servers[name] = next;
|
|
7348
7459
|
}
|
|
7349
|
-
return { servers, missing: [...missing].sort() };
|
|
7460
|
+
return { servers, secretEnv, missing: [...missing].sort() };
|
|
7350
7461
|
}
|
|
7351
7462
|
function missingSecretReason(missing) {
|
|
7352
7463
|
const prefix = "missing_secret: ";
|
|
@@ -7358,7 +7469,7 @@ function missingSecretReason(missing) {
|
|
|
7358
7469
|
}
|
|
7359
7470
|
return `${prefix}+${missing.length}`;
|
|
7360
7471
|
}
|
|
7361
|
-
var PLACEHOLDER_RE, secretStoreSchema, ERROR_REASON_MAX;
|
|
7472
|
+
var PLACEHOLDER_RE, secretStoreSchema, SECRET_ENV_NAME, ERROR_REASON_MAX;
|
|
7362
7473
|
var init_secrets = __esm({
|
|
7363
7474
|
"src/secrets.ts"() {
|
|
7364
7475
|
"use strict";
|
|
@@ -7366,6 +7477,7 @@ var init_secrets = __esm({
|
|
|
7366
7477
|
init_errors();
|
|
7367
7478
|
PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
7368
7479
|
secretStoreSchema = z14.record(z14.string(), z14.string());
|
|
7480
|
+
SECRET_ENV_NAME = /^CABANE_MCP_(\d+)$/;
|
|
7369
7481
|
ERROR_REASON_MAX = 200;
|
|
7370
7482
|
}
|
|
7371
7483
|
});
|
|
@@ -7393,12 +7505,20 @@ var init_run_config = __esm({
|
|
|
7393
7505
|
mcpHttpServerSchema = z15.object({
|
|
7394
7506
|
type: z15.literal("http"),
|
|
7395
7507
|
url: z15.string().url(),
|
|
7396
|
-
headers: z15.record(z15.string(), z15.string()).optional()
|
|
7508
|
+
headers: z15.record(z15.string(), z15.string()).optional(),
|
|
7509
|
+
// CT1698: header → the name of a `secretEnv` entry holding its value. Set by
|
|
7510
|
+
// the server on a room connection's entry, and by `resolveMcpSecrets` on a
|
|
7511
|
+
// header whose `${VAR}` it resolved.
|
|
7512
|
+
headersFromEnv: z15.record(z15.string(), z15.string()).optional()
|
|
7397
7513
|
});
|
|
7398
7514
|
mcpSseServerSchema = z15.object({
|
|
7399
7515
|
type: z15.literal("sse"),
|
|
7400
7516
|
url: z15.string().url(),
|
|
7401
|
-
headers: z15.record(z15.string(), z15.string()).optional()
|
|
7517
|
+
headers: z15.record(z15.string(), z15.string()).optional(),
|
|
7518
|
+
// CT1698: header → the name of a `secretEnv` entry holding its value. Set by
|
|
7519
|
+
// the server on a room connection's entry, and by `resolveMcpSecrets` on a
|
|
7520
|
+
// header whose `${VAR}` it resolved.
|
|
7521
|
+
headersFromEnv: z15.record(z15.string(), z15.string()).optional()
|
|
7402
7522
|
});
|
|
7403
7523
|
mcpServerDefSchema = z15.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
|
|
7404
7524
|
thinkingConfigSchema = z15.discriminatedUnion("type", [
|
|
@@ -8280,6 +8400,7 @@ function buildCompanionTurnRequest(params) {
|
|
|
8280
8400
|
// User MCP servers (already `${PLACEHOLDER}`-resolved). Structurally the
|
|
8281
8401
|
// adapter's `ResolvedMcpServers`.
|
|
8282
8402
|
...Object.keys(params.mcpServers).length > 0 ? { mcpServers: params.mcpServers } : {},
|
|
8403
|
+
...params.secretEnv && Object.keys(params.secretEnv).length > 0 ? { secretEnv: params.secretEnv } : {},
|
|
8283
8404
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
8284
8405
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
8285
8406
|
},
|
|
@@ -8925,6 +9046,9 @@ var init_turn_execution = __esm({
|
|
|
8925
9046
|
resumedSpoke = false;
|
|
8926
9047
|
turnContext;
|
|
8927
9048
|
resolvedMcpServers;
|
|
9049
|
+
// CT1698: the harness's secret environment for this turn. Only ever handed
|
|
9050
|
+
// to the adapter; never logged, never merged into `process.env`.
|
|
9051
|
+
secretEnv = {};
|
|
8928
9052
|
effectiveCwd;
|
|
8929
9053
|
hookEnv;
|
|
8930
9054
|
turnEnv;
|
|
@@ -9212,11 +9336,17 @@ var init_turn_execution = __esm({
|
|
|
9212
9336
|
const secretStore = this.opts.secretStore ?? (fromLauncher ? await fromLauncher(requiredSecretNames(this.turnContext.mcpServers)) : null) ?? loadSecretStoreTolerant(
|
|
9213
9337
|
(m) => turnLog.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
|
|
9214
9338
|
);
|
|
9215
|
-
const {
|
|
9339
|
+
const {
|
|
9340
|
+
servers: resolvedMcpServers,
|
|
9341
|
+
secretEnv,
|
|
9342
|
+
missing
|
|
9343
|
+
} = resolveMcpSecrets(
|
|
9216
9344
|
// CT262: the user MCP DEFINITIONS (placeholder form) come from the turn
|
|
9217
|
-
// context now, not a separate `getAgentSelf` run-config fetch.
|
|
9345
|
+
// context now, not a separate `getAgentSelf` run-config fetch. CT1698: and
|
|
9346
|
+
// the room connections' entries, whose values arrive in `secretEnv`.
|
|
9218
9347
|
this.turnContext.mcpServers ?? {},
|
|
9219
|
-
secretStore
|
|
9348
|
+
secretStore,
|
|
9349
|
+
this.turnContext.secretEnv
|
|
9220
9350
|
);
|
|
9221
9351
|
if (missing.length > 0) {
|
|
9222
9352
|
turnLog.debug({ missing }, "dispatcher: turn needs secrets not declared on this companion");
|
|
@@ -9224,6 +9354,7 @@ var init_turn_execution = __esm({
|
|
|
9224
9354
|
throw this.concluded(reason, reason);
|
|
9225
9355
|
}
|
|
9226
9356
|
this.resolvedMcpServers = resolvedMcpServers;
|
|
9357
|
+
this.secretEnv = secretEnv;
|
|
9227
9358
|
}
|
|
9228
9359
|
async prepareEnvironment() {
|
|
9229
9360
|
const { payload, workspaceId, turnId, turnLog } = this;
|
|
@@ -9456,6 +9587,7 @@ var init_turn_execution = __esm({
|
|
|
9456
9587
|
// CT1103: the prepare-hook env plus the authoritative host-access token.
|
|
9457
9588
|
env: this.turnEnv,
|
|
9458
9589
|
mcpServers: this.resolvedMcpServers,
|
|
9590
|
+
secretEnv: this.secretEnv,
|
|
9459
9591
|
turnControlServer,
|
|
9460
9592
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
9461
9593
|
// header so a cross-thread post/spawn stamps its origin.
|
|
@@ -10559,7 +10691,6 @@ function harnessSandboxConfig(input) {
|
|
|
10559
10691
|
...policy.hosts?.allow ?? []
|
|
10560
10692
|
];
|
|
10561
10693
|
const allowedDomains = [.../* @__PURE__ */ new Set([...cabane, ...models, ...reachable])];
|
|
10562
|
-
const envGlobs = input.cwd ? [join18(input.cwd, "**/.env"), join18(input.cwd, "**/.env.*")] : [];
|
|
10563
10694
|
const secrets = [
|
|
10564
10695
|
...companionStoreDenies({
|
|
10565
10696
|
homes,
|
|
@@ -10568,7 +10699,6 @@ function harnessSandboxConfig(input) {
|
|
|
10568
10699
|
ownState: [claudeDir, codexDir]
|
|
10569
10700
|
}),
|
|
10570
10701
|
...homes.flatMap((home) => SECRET_PATHS_UNDER_HOME.map((p) => join18(home, p))),
|
|
10571
|
-
...envGlobs,
|
|
10572
10702
|
...expand(policy.filesystem?.denyRead)
|
|
10573
10703
|
];
|
|
10574
10704
|
const strict = policy.filesystem?.mode === "strict";
|
package/dist/runtime.js
CHANGED
|
@@ -3493,12 +3493,14 @@ var resolvedMcpServerSchema = z8.union([
|
|
|
3493
3493
|
z8.object({
|
|
3494
3494
|
type: z8.literal("http"),
|
|
3495
3495
|
url: z8.string(),
|
|
3496
|
-
headers: z8.record(z8.string(), z8.string()).optional()
|
|
3496
|
+
headers: z8.record(z8.string(), z8.string()).optional(),
|
|
3497
|
+
headersFromEnv: z8.record(z8.string(), z8.string()).optional()
|
|
3497
3498
|
}),
|
|
3498
3499
|
z8.object({
|
|
3499
3500
|
type: z8.literal("sse"),
|
|
3500
3501
|
url: z8.string(),
|
|
3501
|
-
headers: z8.record(z8.string(), z8.string()).optional()
|
|
3502
|
+
headers: z8.record(z8.string(), z8.string()).optional(),
|
|
3503
|
+
headersFromEnv: z8.record(z8.string(), z8.string()).optional()
|
|
3502
3504
|
})
|
|
3503
3505
|
]);
|
|
3504
3506
|
var resolvedMcpServersSchema = z8.record(z8.string(), resolvedMcpServerSchema);
|
|
@@ -3539,6 +3541,11 @@ var turnRequestSchema = z8.object({
|
|
|
3539
3541
|
cwd: z8.string().optional(),
|
|
3540
3542
|
env: z8.record(z8.string(), z8.string()).optional(),
|
|
3541
3543
|
mcpServers: resolvedMcpServersSchema.optional(),
|
|
3544
|
+
// CT1698: the values `headersFromEnv` names, per turn (`CABANE_MCP_<n>` →
|
|
3545
|
+
// complete header value). The adapter merges them into the harness
|
|
3546
|
+
// process's environment and nowhere else; the host never merges them into
|
|
3547
|
+
// its own. Absent when no server references one.
|
|
3548
|
+
secretEnv: z8.record(z8.string(), z8.string()).optional(),
|
|
3542
3549
|
// CT289: machine-local claude-code adapter knobs the operator sets on a
|
|
3543
3550
|
// companion they run themselves — the auto-memory escape hatch. `autoMemory:
|
|
3544
3551
|
// true` opts back into Claude Code's auto-memory (governed by the operator's
|
|
@@ -3989,6 +3996,24 @@ var CLAUDE_CODE_ADDENDUM = [
|
|
|
3989
3996
|
"read/edit) keep their ordinary names and act on the local machine only."
|
|
3990
3997
|
].join(" ");
|
|
3991
3998
|
|
|
3999
|
+
// packages/agent-runtime/src/mcp-secret-env.ts
|
|
4000
|
+
function headersAsEnvReferences(server) {
|
|
4001
|
+
const refs = Object.entries(server.headersFromEnv ?? {}).map(
|
|
4002
|
+
([header, envName]) => [header, `\${${envName}}`]
|
|
4003
|
+
);
|
|
4004
|
+
if (!server.headers && refs.length === 0) return void 0;
|
|
4005
|
+
return { ...server.headers, ...Object.fromEntries(refs) };
|
|
4006
|
+
}
|
|
4007
|
+
function headersResolvedFrom(server, secretEnv) {
|
|
4008
|
+
const resolved = {};
|
|
4009
|
+
for (const [header, envName] of Object.entries(server.headersFromEnv ?? {})) {
|
|
4010
|
+
const value = secretEnv?.[envName];
|
|
4011
|
+
if (value !== void 0) resolved[header] = value;
|
|
4012
|
+
}
|
|
4013
|
+
if (!server.headers && Object.keys(resolved).length === 0) return void 0;
|
|
4014
|
+
return { ...server.headers, ...resolved };
|
|
4015
|
+
}
|
|
4016
|
+
|
|
3992
4017
|
// packages/agent-runtime/src/claude-code/policy.ts
|
|
3993
4018
|
import { z as z10 } from "zod";
|
|
3994
4019
|
var HOST_FS_TOOLS = [
|
|
@@ -4122,8 +4147,18 @@ async function preToolUseHook(input) {
|
|
|
4122
4147
|
function buildClaudeCodeOptions(req, augment) {
|
|
4123
4148
|
const { policy, config } = req;
|
|
4124
4149
|
const cwd = req.local.cwd;
|
|
4150
|
+
const userServers = {};
|
|
4151
|
+
for (const [name, server] of Object.entries(req.local.mcpServers ?? {})) {
|
|
4152
|
+
if ("url" in server) {
|
|
4153
|
+
const { headersFromEnv: _refs, headers: _headers, ...rest } = server;
|
|
4154
|
+
const headers = headersAsEnvReferences(server);
|
|
4155
|
+
userServers[name] = { ...rest, ...headers ? { headers } : {} };
|
|
4156
|
+
} else {
|
|
4157
|
+
userServers[name] = server;
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4125
4160
|
const mcpServers = {
|
|
4126
|
-
...
|
|
4161
|
+
...userServers,
|
|
4127
4162
|
...req.extra.mcpServers
|
|
4128
4163
|
};
|
|
4129
4164
|
if (!(CABANE_MCP_SERVER in mcpServers)) {
|
|
@@ -4205,9 +4240,13 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
4205
4240
|
// SDK's own doc on `Options.env`), which is why `process.env` is spread here
|
|
4206
4241
|
// and why this key is now always present — previously it appeared only when
|
|
4207
4242
|
// `req.local.env` was set, and an absent `env` inherits `process.env`.
|
|
4243
|
+
//
|
|
4244
|
+
// CT1698: `secretEnv` joins here, and only here — the values the MCP
|
|
4245
|
+
// headers above reference. It is this child's environment, not the host's.
|
|
4208
4246
|
env: {
|
|
4209
4247
|
...process.env,
|
|
4210
4248
|
...req.local.env,
|
|
4249
|
+
...req.local.secretEnv,
|
|
4211
4250
|
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
|
|
4212
4251
|
},
|
|
4213
4252
|
...resume ? { resume } : {}
|
|
@@ -5442,7 +5481,10 @@ function buildRunSpec(req, resumeSessionId) {
|
|
|
5442
5481
|
// Fresh session → mint + send a client id; resumed → let opencode mint it.
|
|
5443
5482
|
sendClientMessageId: resumeSessionId == null,
|
|
5444
5483
|
...dialect.agent ? { agent: dialect.agent } : {},
|
|
5445
|
-
mcp: buildMcp(req)
|
|
5484
|
+
mcp: buildMcp(req),
|
|
5485
|
+
credentialServers: Object.entries(req.local.mcpServers ?? {}).filter(
|
|
5486
|
+
([, server]) => "url" in server && Object.keys(server.headersFromEnv ?? {}).length > 0
|
|
5487
|
+
).map(([name]) => name)
|
|
5446
5488
|
};
|
|
5447
5489
|
}
|
|
5448
5490
|
function buildParts(req) {
|
|
@@ -5461,10 +5503,11 @@ function buildMcp(req) {
|
|
|
5461
5503
|
const mcp = {};
|
|
5462
5504
|
for (const [name, server] of Object.entries(req.local.mcpServers ?? {})) {
|
|
5463
5505
|
if ("url" in server) {
|
|
5506
|
+
const headers = headersResolvedFrom(server, req.local.secretEnv);
|
|
5464
5507
|
mcp[name] = {
|
|
5465
5508
|
type: "remote",
|
|
5466
5509
|
url: server.url,
|
|
5467
|
-
...
|
|
5510
|
+
...headers ? { headers } : {},
|
|
5468
5511
|
enabled: true
|
|
5469
5512
|
};
|
|
5470
5513
|
} else if ("command" in server) {
|
|
@@ -5643,24 +5686,53 @@ async function* releaseAfter(events, release) {
|
|
|
5643
5686
|
release();
|
|
5644
5687
|
}
|
|
5645
5688
|
}
|
|
5689
|
+
var SCRUBBED_MCP_CONFIG = {
|
|
5690
|
+
type: "remote",
|
|
5691
|
+
url: "http://127.0.0.1:9/cabane-removed",
|
|
5692
|
+
enabled: false
|
|
5693
|
+
};
|
|
5646
5694
|
async function installMcp(doFetch, base, spec, signal) {
|
|
5695
|
+
const [live, configured] = await Promise.all([
|
|
5696
|
+
getJson(doFetch, `${base}/mcp`, signal),
|
|
5697
|
+
getJson(doFetch, `${base}/config`, signal)
|
|
5698
|
+
]);
|
|
5699
|
+
const operatorOwned = new Set(Object.keys(asRecord2(asRecord2(configured).mcp)));
|
|
5700
|
+
const stale = Object.keys(asRecord2(live)).filter(
|
|
5701
|
+
(name) => !(name in spec.mcp) && !operatorOwned.has(name)
|
|
5702
|
+
);
|
|
5703
|
+
await Promise.all(stale.map((name) => postMcp(doFetch, base, name, SCRUBBED_MCP_CONFIG, signal)));
|
|
5704
|
+
const shadowing = (spec.credentialServers ?? []).filter((name) => operatorOwned.has(name));
|
|
5705
|
+
if (shadowing.length > 0) {
|
|
5706
|
+
throw new Error(
|
|
5707
|
+
`opencode: ${shadowing.map((n) => `"${n}"`).join(", ")} would replace an MCP server this opencode instance configures itself; rename that server in the opencode config to use this connection here`
|
|
5708
|
+
);
|
|
5709
|
+
}
|
|
5647
5710
|
await Promise.all(
|
|
5648
|
-
Object.entries(spec.mcp).map(
|
|
5649
|
-
const res = await doFetch(`${base}/mcp`, {
|
|
5650
|
-
method: "POST",
|
|
5651
|
-
headers: { "content-type": "application/json" },
|
|
5652
|
-
body: JSON.stringify({ name, config }),
|
|
5653
|
-
signal
|
|
5654
|
-
});
|
|
5655
|
-
if (!res.ok) {
|
|
5656
|
-
const detail = await res.text().catch(() => "");
|
|
5657
|
-
throw new Error(
|
|
5658
|
-
`opencode: MCP config install failed for "${name}" (HTTP ${res.status}${detail ? `: ${detail}` : ""})`
|
|
5659
|
-
);
|
|
5660
|
-
}
|
|
5661
|
-
})
|
|
5711
|
+
Object.entries(spec.mcp).map(([name, config]) => postMcp(doFetch, base, name, config, signal))
|
|
5662
5712
|
);
|
|
5663
5713
|
}
|
|
5714
|
+
async function postMcp(doFetch, base, name, config, signal) {
|
|
5715
|
+
const res = await doFetch(`${base}/mcp`, {
|
|
5716
|
+
method: "POST",
|
|
5717
|
+
headers: { "content-type": "application/json" },
|
|
5718
|
+
body: JSON.stringify({ name, config }),
|
|
5719
|
+
signal
|
|
5720
|
+
});
|
|
5721
|
+
if (!res.ok) {
|
|
5722
|
+
const detail = await res.text().catch(() => "");
|
|
5723
|
+
throw new Error(
|
|
5724
|
+
`opencode: MCP config install failed for "${name}" (HTTP ${res.status}${detail ? `: ${detail}` : ""})`
|
|
5725
|
+
);
|
|
5726
|
+
}
|
|
5727
|
+
}
|
|
5728
|
+
async function getJson(doFetch, url, signal) {
|
|
5729
|
+
const res = await doFetch(url, { signal });
|
|
5730
|
+
if (!res.ok) throw new Error(`opencode: ${url} unavailable (HTTP ${res.status})`);
|
|
5731
|
+
return res.json();
|
|
5732
|
+
}
|
|
5733
|
+
function asRecord2(value) {
|
|
5734
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
5735
|
+
}
|
|
5664
5736
|
async function* parseSseStream(body, sessionId, signal) {
|
|
5665
5737
|
const reader = body.getReader();
|
|
5666
5738
|
const decoder = new TextDecoder();
|
|
@@ -6236,7 +6308,7 @@ var CODEX_ADDENDUM = [
|
|
|
6236
6308
|
].join(" ");
|
|
6237
6309
|
|
|
6238
6310
|
// packages/agent-runtime/src/codex/events.ts
|
|
6239
|
-
function
|
|
6311
|
+
function asRecord3(v) {
|
|
6240
6312
|
return v && typeof v === "object" ? v : null;
|
|
6241
6313
|
}
|
|
6242
6314
|
function str(v) {
|
|
@@ -6246,7 +6318,7 @@ function readThreadId(ev) {
|
|
|
6246
6318
|
return str(ev.thread_id);
|
|
6247
6319
|
}
|
|
6248
6320
|
function readItem(ev) {
|
|
6249
|
-
return
|
|
6321
|
+
return asRecord3(ev.item);
|
|
6250
6322
|
}
|
|
6251
6323
|
function readItemText(item) {
|
|
6252
6324
|
return str(item.text);
|
|
@@ -6317,7 +6389,7 @@ function readItemType(item) {
|
|
|
6317
6389
|
function readErrorMessage(ev) {
|
|
6318
6390
|
const direct = str(ev.message);
|
|
6319
6391
|
if (direct) return direct;
|
|
6320
|
-
const err =
|
|
6392
|
+
const err = asRecord3(ev.error);
|
|
6321
6393
|
if (err) {
|
|
6322
6394
|
const m = str(err.message);
|
|
6323
6395
|
if (m) return m;
|
|
@@ -6325,7 +6397,7 @@ function readErrorMessage(ev) {
|
|
|
6325
6397
|
return "unknown";
|
|
6326
6398
|
}
|
|
6327
6399
|
function readUsage(ev) {
|
|
6328
|
-
const usage =
|
|
6400
|
+
const usage = asRecord3(ev.usage);
|
|
6329
6401
|
if (!usage) return void 0;
|
|
6330
6402
|
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
6331
6403
|
return {
|
|
@@ -6349,7 +6421,7 @@ function toStatus(v) {
|
|
|
6349
6421
|
return v === "completed" || v === "failed" ? v : "in_progress";
|
|
6350
6422
|
}
|
|
6351
6423
|
function readMcpResult(result) {
|
|
6352
|
-
const rec =
|
|
6424
|
+
const rec = asRecord3(result);
|
|
6353
6425
|
if (!rec) return result;
|
|
6354
6426
|
if (hasOnlyNonTextContentBlocks(rec.content)) return rec.content;
|
|
6355
6427
|
if (rec.structured_content !== void 0 && rec.structured_content !== null) {
|
|
@@ -6365,7 +6437,7 @@ function hasOnlyNonTextContentBlocks(content) {
|
|
|
6365
6437
|
if (!Array.isArray(content) || content.length === 0) return false;
|
|
6366
6438
|
let sawTypedBlock = false;
|
|
6367
6439
|
for (const raw of content) {
|
|
6368
|
-
const block =
|
|
6440
|
+
const block = asRecord3(raw);
|
|
6369
6441
|
const type = block && str(block.type);
|
|
6370
6442
|
if (!type) continue;
|
|
6371
6443
|
sawTypedBlock = true;
|
|
@@ -6383,7 +6455,7 @@ function hasMcpReceipt(result) {
|
|
|
6383
6455
|
return true;
|
|
6384
6456
|
}
|
|
6385
6457
|
function readMcpError(error) {
|
|
6386
|
-
const rec =
|
|
6458
|
+
const rec = asRecord3(error);
|
|
6387
6459
|
return rec && str(rec.message) || "error";
|
|
6388
6460
|
}
|
|
6389
6461
|
|
|
@@ -6671,7 +6743,8 @@ function buildRunSpec2(req, resumeThreadId, instructionsFile = null, threadPromp
|
|
|
6671
6743
|
input: buildInput(req, promptRidesInput),
|
|
6672
6744
|
promptRidesInput,
|
|
6673
6745
|
promptFingerprint,
|
|
6674
|
-
config: buildConfig(req, baseInstructionsFile)
|
|
6746
|
+
config: buildConfig(req, baseInstructionsFile),
|
|
6747
|
+
secretEnv: req.local.secretEnv && Object.keys(req.local.secretEnv).length > 0 ? req.local.secretEnv : null
|
|
6675
6748
|
};
|
|
6676
6749
|
}
|
|
6677
6750
|
function buildInput(req, promptRidesInput) {
|
|
@@ -6687,9 +6760,11 @@ function buildConfig(req, baseInstructionsFile) {
|
|
|
6687
6760
|
const mcp_servers = {};
|
|
6688
6761
|
for (const [name, server] of Object.entries(req.local.mcpServers ?? {})) {
|
|
6689
6762
|
if ("url" in server) {
|
|
6763
|
+
const fromEnv = server.headersFromEnv ?? {};
|
|
6690
6764
|
mcp_servers[name] = {
|
|
6691
6765
|
url: server.url,
|
|
6692
6766
|
...server.headers ? { http_headers: server.headers } : {},
|
|
6767
|
+
...Object.keys(fromEnv).length > 0 ? { env_http_headers: fromEnv } : {},
|
|
6693
6768
|
default_tools_approval_mode: "approve"
|
|
6694
6769
|
};
|
|
6695
6770
|
} else if ("command" in server) {
|
|
@@ -6831,6 +6906,13 @@ function buildSdkThreadOptions(spec) {
|
|
|
6831
6906
|
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
6832
6907
|
};
|
|
6833
6908
|
}
|
|
6909
|
+
function codexProcessEnv(secretEnv) {
|
|
6910
|
+
const inherited = {};
|
|
6911
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
6912
|
+
if (value !== void 0) inherited[key] = value;
|
|
6913
|
+
}
|
|
6914
|
+
return { ...inherited, ...secretEnv };
|
|
6915
|
+
}
|
|
6834
6916
|
function createSdkCodexTransport(opts = {}) {
|
|
6835
6917
|
return {
|
|
6836
6918
|
async run(spec, signal) {
|
|
@@ -6847,7 +6929,8 @@ function createSdkCodexTransport(opts = {}) {
|
|
|
6847
6929
|
// also what makes the PUBLISHED companion correct, where the vendored
|
|
6848
6930
|
// patch never reached — and why that patch is gone. See
|
|
6849
6931
|
// `config-overrides.ts`.
|
|
6850
|
-
configOverrides: buildConfigOverrides(spec.config)
|
|
6932
|
+
configOverrides: buildConfigOverrides(spec.config),
|
|
6933
|
+
...spec.secretEnv ? { env: codexProcessEnv(spec.secretEnv) } : {}
|
|
6851
6934
|
};
|
|
6852
6935
|
const codex = new Codex(codexOptions);
|
|
6853
6936
|
const threadOptions = buildSdkThreadOptions(spec);
|
|
@@ -8464,8 +8547,15 @@ function requiredSecretNames(mcpServers) {
|
|
|
8464
8547
|
}
|
|
8465
8548
|
return [...names].sort();
|
|
8466
8549
|
}
|
|
8467
|
-
|
|
8550
|
+
var SECRET_ENV_NAME = /^CABANE_MCP_(\d+)$/;
|
|
8551
|
+
function resolveMcpSecrets(mcpServers, store, serverSecretEnv = {}) {
|
|
8468
8552
|
const missing = /* @__PURE__ */ new Set();
|
|
8553
|
+
const secretEnv = { ...serverSecretEnv };
|
|
8554
|
+
let counter = 0;
|
|
8555
|
+
for (const name of Object.keys(serverSecretEnv)) {
|
|
8556
|
+
const n = SECRET_ENV_NAME.exec(name);
|
|
8557
|
+
if (n) counter = Math.max(counter, Number(n[1]));
|
|
8558
|
+
}
|
|
8469
8559
|
const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
|
|
8470
8560
|
const resolved = store.get(name);
|
|
8471
8561
|
if (resolved === void 0) {
|
|
@@ -8483,10 +8573,25 @@ function resolveMcpSecrets(mcpServers, store) {
|
|
|
8483
8573
|
for (const [name, server] of Object.entries(mcpServers)) {
|
|
8484
8574
|
const next = { ...server };
|
|
8485
8575
|
if ("env" in next && next.env) next.env = mapBag(next.env);
|
|
8486
|
-
if ("
|
|
8576
|
+
if ("url" in next && next.headers) {
|
|
8577
|
+
const headers = {};
|
|
8578
|
+
const fromEnv = { ...next.headersFromEnv };
|
|
8579
|
+
for (const [header, value] of Object.entries(next.headers)) {
|
|
8580
|
+
if (!value.match(PLACEHOLDER_RE)) {
|
|
8581
|
+
headers[header] = value;
|
|
8582
|
+
continue;
|
|
8583
|
+
}
|
|
8584
|
+
const envName = `CABANE_MCP_${++counter}`;
|
|
8585
|
+
secretEnv[envName] = interpolate(value);
|
|
8586
|
+
fromEnv[header] = envName;
|
|
8587
|
+
}
|
|
8588
|
+
next.headers = headers;
|
|
8589
|
+
if (Object.keys(headers).length === 0) delete next.headers;
|
|
8590
|
+
if (Object.keys(fromEnv).length > 0) next.headersFromEnv = fromEnv;
|
|
8591
|
+
}
|
|
8487
8592
|
servers[name] = next;
|
|
8488
8593
|
}
|
|
8489
|
-
return { servers, missing: [...missing].sort() };
|
|
8594
|
+
return { servers, secretEnv, missing: [...missing].sort() };
|
|
8490
8595
|
}
|
|
8491
8596
|
var ERROR_REASON_MAX = 200;
|
|
8492
8597
|
function missingSecretReason(missing) {
|
|
@@ -8514,12 +8619,20 @@ var mcpStdioServerSchema = z15.object({
|
|
|
8514
8619
|
var mcpHttpServerSchema = z15.object({
|
|
8515
8620
|
type: z15.literal("http"),
|
|
8516
8621
|
url: z15.string().url(),
|
|
8517
|
-
headers: z15.record(z15.string(), z15.string()).optional()
|
|
8622
|
+
headers: z15.record(z15.string(), z15.string()).optional(),
|
|
8623
|
+
// CT1698: header → the name of a `secretEnv` entry holding its value. Set by
|
|
8624
|
+
// the server on a room connection's entry, and by `resolveMcpSecrets` on a
|
|
8625
|
+
// header whose `${VAR}` it resolved.
|
|
8626
|
+
headersFromEnv: z15.record(z15.string(), z15.string()).optional()
|
|
8518
8627
|
});
|
|
8519
8628
|
var mcpSseServerSchema = z15.object({
|
|
8520
8629
|
type: z15.literal("sse"),
|
|
8521
8630
|
url: z15.string().url(),
|
|
8522
|
-
headers: z15.record(z15.string(), z15.string()).optional()
|
|
8631
|
+
headers: z15.record(z15.string(), z15.string()).optional(),
|
|
8632
|
+
// CT1698: header → the name of a `secretEnv` entry holding its value. Set by
|
|
8633
|
+
// the server on a room connection's entry, and by `resolveMcpSecrets` on a
|
|
8634
|
+
// header whose `${VAR}` it resolved.
|
|
8635
|
+
headersFromEnv: z15.record(z15.string(), z15.string()).optional()
|
|
8523
8636
|
});
|
|
8524
8637
|
var mcpServerDefSchema = z15.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
|
|
8525
8638
|
var thinkingConfigSchema = z15.discriminatedUnion("type", [
|
|
@@ -9355,6 +9468,7 @@ function buildCompanionTurnRequest(params) {
|
|
|
9355
9468
|
// User MCP servers (already `${PLACEHOLDER}`-resolved). Structurally the
|
|
9356
9469
|
// adapter's `ResolvedMcpServers`.
|
|
9357
9470
|
...Object.keys(params.mcpServers).length > 0 ? { mcpServers: params.mcpServers } : {},
|
|
9471
|
+
...params.secretEnv && Object.keys(params.secretEnv).length > 0 ? { secretEnv: params.secretEnv } : {},
|
|
9358
9472
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
9359
9473
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
9360
9474
|
},
|
|
@@ -9911,6 +10025,9 @@ var TurnExecution = class {
|
|
|
9911
10025
|
resumedSpoke = false;
|
|
9912
10026
|
turnContext;
|
|
9913
10027
|
resolvedMcpServers;
|
|
10028
|
+
// CT1698: the harness's secret environment for this turn. Only ever handed
|
|
10029
|
+
// to the adapter; never logged, never merged into `process.env`.
|
|
10030
|
+
secretEnv = {};
|
|
9914
10031
|
effectiveCwd;
|
|
9915
10032
|
hookEnv;
|
|
9916
10033
|
turnEnv;
|
|
@@ -10198,11 +10315,17 @@ var TurnExecution = class {
|
|
|
10198
10315
|
const secretStore = this.opts.secretStore ?? (fromLauncher ? await fromLauncher(requiredSecretNames(this.turnContext.mcpServers)) : null) ?? loadSecretStoreTolerant(
|
|
10199
10316
|
(m) => turnLog.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
|
|
10200
10317
|
);
|
|
10201
|
-
const {
|
|
10318
|
+
const {
|
|
10319
|
+
servers: resolvedMcpServers,
|
|
10320
|
+
secretEnv,
|
|
10321
|
+
missing
|
|
10322
|
+
} = resolveMcpSecrets(
|
|
10202
10323
|
// CT262: the user MCP DEFINITIONS (placeholder form) come from the turn
|
|
10203
|
-
// context now, not a separate `getAgentSelf` run-config fetch.
|
|
10324
|
+
// context now, not a separate `getAgentSelf` run-config fetch. CT1698: and
|
|
10325
|
+
// the room connections' entries, whose values arrive in `secretEnv`.
|
|
10204
10326
|
this.turnContext.mcpServers ?? {},
|
|
10205
|
-
secretStore
|
|
10327
|
+
secretStore,
|
|
10328
|
+
this.turnContext.secretEnv
|
|
10206
10329
|
);
|
|
10207
10330
|
if (missing.length > 0) {
|
|
10208
10331
|
turnLog.debug({ missing }, "dispatcher: turn needs secrets not declared on this companion");
|
|
@@ -10210,6 +10333,7 @@ var TurnExecution = class {
|
|
|
10210
10333
|
throw this.concluded(reason, reason);
|
|
10211
10334
|
}
|
|
10212
10335
|
this.resolvedMcpServers = resolvedMcpServers;
|
|
10336
|
+
this.secretEnv = secretEnv;
|
|
10213
10337
|
}
|
|
10214
10338
|
async prepareEnvironment() {
|
|
10215
10339
|
const { payload, workspaceId, turnId, turnLog } = this;
|
|
@@ -10442,6 +10566,7 @@ var TurnExecution = class {
|
|
|
10442
10566
|
// CT1103: the prepare-hook env plus the authoritative host-access token.
|
|
10443
10567
|
env: this.turnEnv,
|
|
10444
10568
|
mcpServers: this.resolvedMcpServers,
|
|
10569
|
+
secretEnv: this.secretEnv,
|
|
10445
10570
|
turnControlServer,
|
|
10446
10571
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
10447
10572
|
// header so a cross-thread post/spawn stamps its origin.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.135",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
|
|
6
6
|
"license": "UNLICENSED",
|