@cabane/companion 0.6.134 → 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.
Files changed (3) hide show
  1. package/dist/cli.js +170 -38
  2. package/dist/runtime.js +161 -36
  3. package/package.json +1 -1
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
- ...req.local.mcpServers ?? {},
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
- ...server.headers ? { headers: server.headers } : {},
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(async ([name, config]) => {
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 asRecord2(v) {
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 asRecord2(ev.item);
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 = asRecord2(ev.error);
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 = asRecord2(ev.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 = asRecord2(result);
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 = asRecord2(raw);
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 = asRecord2(error);
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 ("headers" in next && next.headers) next.headers = mapBag(next.headers);
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 { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
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.
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
- ...req.local.mcpServers ?? {},
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
- ...server.headers ? { headers: server.headers } : {},
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(async ([name, config]) => {
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 asRecord2(v) {
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 asRecord2(ev.item);
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 = asRecord2(ev.error);
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 = asRecord2(ev.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 = asRecord2(result);
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 = asRecord2(raw);
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 = asRecord2(error);
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
- function resolveMcpSecrets(mcpServers, store) {
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 ("headers" in next && next.headers) next.headers = mapBag(next.headers);
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 { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
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.134",
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",