@cabane/companion 0.6.131 → 0.6.133

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 CHANGED
@@ -232,8 +232,9 @@ A `coding` agent needs a directory to run in — a path that only exists on your
232
232
  {
233
233
  // …device identity, managed by `pair`; don't hand-edit those fields…
234
234
 
235
- // Per-agent machine-local overrides. Only `cwd` and `prepareHook` live here —
236
- // everything else about the agent (mode, MCP servers, model) is set in Cabane.
235
+ // Per-agent machine-local overrides. Only `cwd`, `prepareHook`, `claudeCode` and
236
+ // `sandbox` live here — everything else about the agent (mode, MCP servers,
237
+ // model) is set in Cabane.
237
238
  "agents": {
238
239
  "my-coding-agent": {
239
240
  "cwd": "/Users/you/code/project", // working directory the agent runs in
@@ -317,6 +318,25 @@ What the sandbox allows is `runners.policy` in `~/.cabane/config.json`. Every ke
317
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`, any `.env` or `.env.*` in the working directory, and your `denyRead` paths. `allowRead` reopens a path inside a denied one. |
318
319
  | **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
319
320
 
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
+
323
+ ```jsonc
324
+ {
325
+ "runners": {
326
+ "enabled": true,
327
+ "sandbox": "os",
328
+ "policy": {
329
+ /* unchanged */
330
+ },
331
+ },
332
+ "agents": {
333
+ "014d18a7-6395-48e3-a2f2-313d5baa2a5e": { "sandbox": "none" }, // the ops agent, by its id: it runs with the machine; everyone else stays sandboxed
334
+ },
335
+ }
336
+ ```
337
+
338
+ It works the other way too: `"agents": { "<agent id>": { "sandbox": "os" } }` sandboxes one agent on a device that otherwise isn't. **A sandbox row is keyed by the agent's id**, the last part of the agent's settings page URL in Cabane (`/<workspace>/settings/agents/<agent id>`). A username, or `workspace-slug/username`, can match more than one agent: slugs are unique only per workspace owner, so two workspaces this device serves can share one. A `sandbox` row keyed any other way is refused at start, with the field named. `cwd` and `prepareHook` rows still take all three keys. The agent's row wins, then `runners.sandbox`, then no sandbox. The policy stays the device's. Under the `docker` provider the container is the sandbox, so a row's `sandbox` is ignored, and the log says so at start. The exception is set only here: Cabane shows it on the device page and can't change it.
339
+
320
340
  Paths may start with `~/` and may be globs. An invalid `policy` doesn't stop the Companion from starting. Instead, every sandboxed turn is refused and the conversation names the field that's wrong, for example `runners.policy.filesystem.mode`.
321
341
 
322
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.
@@ -336,7 +356,7 @@ Paths may start with `~/` and may be globs. An invalid `policy` doesn't stop the
336
356
 
337
357
  A `gh auth login` session on the machine isn't used: `~/.config/gh` is unreadable in the sandbox. Only the token in `secrets.json` is.
338
358
 
339
- `cabane-companion status` prints the effective posture: the filesystem mode, extra hosts, denied hosts and reads, and the **names** of masked credentials, never their values. The Companion also logs it once at start.
359
+ `cabane-companion status` prints the effective posture: the filesystem mode, extra hosts, denied hosts and reads, the **names** of masked credentials, never their values, and each agent excepted on its row. The Companion also logs it once at start.
340
360
 
341
361
  ## Harnesses
342
362
 
package/dist/cli.js CHANGED
@@ -295,6 +295,28 @@ function localAgentConfig(cfg, agent) {
295
295
  if (!cfg.prepareHook || row.prepareHook) return row;
296
296
  return { ...row, prepareHook: cfg.prepareHook };
297
297
  }
298
+ function agentSandbox(cfg, agent) {
299
+ const sandbox = cfg.agents?.[agent.agentId]?.sandbox;
300
+ return sandbox ? { sandbox } : {};
301
+ }
302
+ function runnerSandboxFor(cfg, row, host) {
303
+ const asked = row.sandbox ?? cfg.runners?.sandbox ?? "none";
304
+ if (asked !== "os" || cfg.runners?.provider === "docker") return "none";
305
+ return host.osSandboxUsable() ? "os" : "none";
306
+ }
307
+ function runnerSandboxReport(cfg, agents, host) {
308
+ const device = runnerSandboxFor(cfg, {}, host);
309
+ const sandboxOverrides = agents.flatMap((agent) => {
310
+ const sandbox = runnerSandboxFor(cfg, agentSandbox(cfg, agent), host);
311
+ return sandbox === device ? [] : [{ agentId: agent.agentId, sandbox }];
312
+ });
313
+ return { sandbox: device, sandboxOverrides };
314
+ }
315
+ function agentSandboxRows(cfg) {
316
+ return Object.entries(cfg.agents ?? {}).flatMap(
317
+ ([key, row]) => row.sandbox ? [[key, row.sandbox]] : []
318
+ );
319
+ }
298
320
  function loadConfig() {
299
321
  const path = configPath();
300
322
  if (!existsSync(path)) return null;
@@ -370,7 +392,7 @@ function loadConfigTolerant() {
370
392
  }
371
393
  const obj = parsed && typeof parsed === "object" ? parsed : {};
372
394
  const local = {};
373
- const agents = z2.record(z2.string(), localAgentConfigSchema).safeParse(obj.agents);
395
+ const agents = agentsConfigSchema.safeParse(obj.agents);
374
396
  if (agents.success) local.agents = agents.data;
375
397
  const prepareHook = prepareHookSchema.safeParse(obj.prepareHook);
376
398
  if (prepareHook.success) local.prepareHook = prepareHook.data;
@@ -426,7 +448,7 @@ function deleteConfig() {
426
448
  writeFileSync(path, "", { mode: 384 });
427
449
  }
428
450
  }
429
- var realHomeCache, localAgentConfigSchema, companionConfigSchema;
451
+ var realHomeCache, localAgentConfigSchema, AGENT_ID_KEY, agentsConfigSchema, companionConfigSchema;
430
452
  var init_config = __esm({
431
453
  "src/config.ts"() {
432
454
  "use strict";
@@ -443,8 +465,25 @@ var init_config = __esm({
443
465
  // auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
444
466
  // injecting the off switch and your normal Claude Code memory workflow applies
445
467
  // (in coding mode, where the checkout's project settings are read).
446
- claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
468
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
469
+ // CT1693: this agent's exception to `runners.sandbox` — an ops agent that
470
+ // runs with the machine on a sandboxed device, or one sandboxed agent on a
471
+ // device that isn't. Absent is "inherit"; see `runnerSandboxFor`. Only on a
472
+ // row keyed by the agent's id (`agentsConfigSchema`).
473
+ sandbox: z2.enum(["os", "none"]).optional()
447
474
  }).strict();
475
+ AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
476
+ agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
477
+ for (const [key, row] of Object.entries(agents)) {
478
+ if (row.sandbox === void 0) continue;
479
+ if (AGENT_ID_KEY.test(key)) continue;
480
+ ctx.addIssue({
481
+ code: z2.ZodIssueCode.custom,
482
+ path: [key, "sandbox"],
483
+ message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
484
+ });
485
+ }
486
+ });
448
487
  companionConfigSchema = z2.object({
449
488
  // The cabane instance this device is paired with. SJ515: https-enforced
450
489
  // (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
@@ -465,7 +504,7 @@ var init_config = __esm({
465
504
  // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
466
505
  // agentId / username / `slug/username`. Hand-added by the operator; the companion
467
506
  // never writes this (it only persists credentials + the device, elsewhere).
468
- agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
507
+ agents: agentsConfigSchema.optional(),
469
508
  // CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
470
509
  // assigned to this device, unless that agent's own `agents` row names a hook of
471
510
  // its own.
@@ -11652,7 +11691,7 @@ function buildCompanionManifest(opts) {
11652
11691
  runtimes,
11653
11692
  capabilities: { ...DEVICE_MANIFEST.capabilities },
11654
11693
  runtimeIssues: opts.runtimeIssues ?? [],
11655
- ...opts.runnerProvider ? { runner: { provider: opts.runnerProvider } } : {}
11694
+ ...opts.runner ? { runner: opts.runner } : {}
11656
11695
  };
11657
11696
  }
11658
11697
  function runtimeIssuesFor(gates) {
@@ -14034,7 +14073,9 @@ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14034
14073
  function containerName(attempt) {
14035
14074
  return `cabane-runner-${attempt.conversationId}-${attempt.agentId}`;
14036
14075
  }
14037
- var networkName = containerName;
14076
+ function networkName(attempt) {
14077
+ return containerName(attempt);
14078
+ }
14038
14079
  function networkCreateError(stderr) {
14039
14080
  const said = stderr.trim();
14040
14081
  if (/address pool|fully subnetted/i.test(said))
@@ -15277,10 +15318,21 @@ var CompanionSupervisor = class {
15277
15318
  }
15278
15319
  this.latchBootBundledState();
15279
15320
  }
15280
- if (this.runnerSandbox() === "os") {
15321
+ const sandboxRows = agentSandboxRows(this.config);
15322
+ if (sandboxRows.length > 0 && this.dockerRunners()) {
15323
+ this.log.warn(
15324
+ { agents: sandboxRows.map(([key]) => key) },
15325
+ `runners: ${sandboxRows.map(([key]) => `agents.${key}.sandbox`).join(", ")} ignored \u2014 under Docker the container is the sandbox`
15326
+ );
15327
+ }
15328
+ const anySandboxed = [{}, ...sandboxRows.map(([, sandbox]) => ({ sandbox }))].some(
15329
+ (row) => this.runnerSandboxFor(row) === "os"
15330
+ );
15331
+ if (anySandboxed) {
15281
15332
  const policy = this.sandboxPolicy();
15282
15333
  const posture = postureLines(policy);
15283
- const summary = posture.join("; ");
15334
+ const perAgent = sandboxRows.map(([key, sandbox]) => `${key} \u2192 ${sandbox}`).join(", ");
15335
+ const summary = posture.join("; ") + (perAgent ? `; per-agent: ${perAgent}` : "");
15284
15336
  if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
15285
15337
  else this.log.error({ posture }, `runners: ${summary}`);
15286
15338
  void this.companionStores().refresh();
@@ -15394,7 +15446,7 @@ var CompanionSupervisor = class {
15394
15446
  // (fail-soft to null). Informational only — the server matches on name.
15395
15447
  versions: this.harnessVersions,
15396
15448
  // CT1681: shown on the device page.
15397
- ...runnersEnabled(this.config) ? { runnerProvider: this.config.runners?.provider ?? "local-process" } : {}
15449
+ ...runnersEnabled(this.config) ? { runner: this.runnerManifest() } : {}
15398
15450
  }),
15399
15451
  // CT566: echo the last classified credential state per runtime, when the
15400
15452
  // companion has seen any. Omitted (undefined) until the first observed
@@ -15726,6 +15778,9 @@ var CompanionSupervisor = class {
15726
15778
  // CT1679: `runners.sandbox`, checked against this host once. A host that
15727
15779
  // can't make the sandbox says so once and runs its turns unwrapped.
15728
15780
  sandboxUsable;
15781
+ sandboxHost = {
15782
+ osSandboxUsable: () => this.sandboxUsable ??= osSandboxUsable(this.log)
15783
+ };
15729
15784
  // CT1684: `runners.policy`, parsed once. An invalid one is kept as the
15730
15785
  // error, which every sandboxed turn is refused with.
15731
15786
  parsedSandboxPolicy;
@@ -15739,10 +15794,26 @@ var CompanionSupervisor = class {
15739
15794
  this.storeWatch ??= companionStoreWatch(this.log);
15740
15795
  return this.storeWatch;
15741
15796
  }
15742
- runnerSandbox() {
15743
- if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
15744
- this.sandboxUsable ??= osSandboxUsable(this.log);
15745
- return this.sandboxUsable ? "os" : "none";
15797
+ // CT1693: the sandbox one agent's turns run under — its `agents` row, then
15798
+ // the device's. Resolved from the current config every time it's asked, so
15799
+ // a reload changes it; only the host probe is kept.
15800
+ runnerSandboxFor(row) {
15801
+ return runnerSandboxFor(this.config, row, this.sandboxHost);
15802
+ }
15803
+ // CT1693: what the heartbeat says about this device's runners, resolved
15804
+ // against the agents it runs right now.
15805
+ runnerManifest() {
15806
+ const agents = [...this.workspaces.values()].flatMap(
15807
+ (wr) => [...wr.agents.values()].map((agent) => ({
15808
+ agentId: agent.agentId,
15809
+ agentUsername: agent.username,
15810
+ workspaceSlug: wr.slug
15811
+ }))
15812
+ );
15813
+ return {
15814
+ provider: this.config.runners?.provider ?? "local-process",
15815
+ ...runnerSandboxReport(this.config, agents, this.sandboxHost)
15816
+ };
15746
15817
  }
15747
15818
  // CT1679: one provider for the device, built on first use.
15748
15819
  runnerProvider() {
@@ -15858,7 +15929,7 @@ var CompanionSupervisor = class {
15858
15929
  reportRunnerGone: (report) => this.reportRunnerGone(report),
15859
15930
  connectorHealth: this.connectorHealth,
15860
15931
  locks: this.runnerLocks,
15861
- sandbox: this.runnerSandbox(),
15932
+ sandbox: this.runnerSandboxFor(agentSandbox(this.config, ctx)),
15862
15933
  policy: this.sandboxPolicy(),
15863
15934
  companionStores: () => this.companionStores().stores()
15864
15935
  }
@@ -17203,19 +17274,36 @@ async function status() {
17203
17274
  process.stdout.write(`prepare hook: ${cfg.prepareHook.command} (device-level, all agents)
17204
17275
  `);
17205
17276
  }
17206
- if (runnersEnabled(cfg) && cfg.runners?.sandbox === "os") {
17207
- process.stdout.write("sandbox: os\n");
17208
- for (const line of postureLines(parseSandboxPolicy(cfg.runners.policy))) {
17209
- process.stdout.write(` ${line}
17277
+ for (const line of sandboxStatusLines(cfg)) process.stdout.write(`${line}
17210
17278
  `);
17211
- }
17212
- }
17213
17279
  const overrides2 = Object.keys(cfg.agents ?? {});
17214
17280
  if (overrides2.length > 0) {
17215
17281
  process.stdout.write(`local overrides for: ${overrides2.join(", ")}
17216
17282
  `);
17217
17283
  }
17218
17284
  }
17285
+ function sandboxStatusLines(cfg) {
17286
+ const rows = agentSandboxRows(cfg);
17287
+ const device = cfg.runners?.sandbox ?? "none";
17288
+ if (!runnersEnabled(cfg)) {
17289
+ if (rows.length === 0) return [];
17290
+ const fields = rows.map(([key]) => `agents.${key}.sandbox`).join(", ");
17291
+ return [`sandbox: off (runners.enabled is false; ${fields} has no effect)`];
17292
+ }
17293
+ if (cfg.runners?.provider === "docker") {
17294
+ if (rows.length === 0) return [];
17295
+ const fields = rows.map(([key]) => `agents.${key}.sandbox`).join(", ");
17296
+ return [`sandbox: docker (the container is the sandbox; ${fields} is ignored)`];
17297
+ }
17298
+ if (device !== "os" && !rows.some(([, sandbox]) => sandbox === "os")) return [];
17299
+ return [
17300
+ `sandbox: ${device}`,
17301
+ ...postureLines(parseSandboxPolicy(cfg.runners?.policy)).map((line) => ` ${line}`),
17302
+ ...rows.map(
17303
+ ([key, sandbox]) => ` ${key}: ${sandbox} (this agent runs ${sandbox === "os" ? "sandboxed" : "unsandboxed"})`
17304
+ )
17305
+ ];
17306
+ }
17219
17307
  function transcriptsLine() {
17220
17308
  const dir2 = transcriptsDir();
17221
17309
  let count = 0;
@@ -129,8 +129,25 @@ var localAgentConfigSchema = z2.object({
129
129
  // auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
130
130
  // injecting the off switch and your normal Claude Code memory workflow applies
131
131
  // (in coding mode, where the checkout's project settings are read).
132
- claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
132
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
133
+ // CT1693: this agent's exception to `runners.sandbox` — an ops agent that
134
+ // runs with the machine on a sandboxed device, or one sandboxed agent on a
135
+ // device that isn't. Absent is "inherit"; see `runnerSandboxFor`. Only on a
136
+ // row keyed by the agent's id (`agentsConfigSchema`).
137
+ sandbox: z2.enum(["os", "none"]).optional()
133
138
  }).strict();
139
+ var AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
140
+ var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
141
+ for (const [key, row] of Object.entries(agents)) {
142
+ if (row.sandbox === void 0) continue;
143
+ if (AGENT_ID_KEY.test(key)) continue;
144
+ ctx.addIssue({
145
+ code: z2.ZodIssueCode.custom,
146
+ path: [key, "sandbox"],
147
+ message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
148
+ });
149
+ }
150
+ });
134
151
  var companionConfigSchema = z2.object({
135
152
  // The cabane instance this device is paired with. SJ515: https-enforced
136
153
  // (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
@@ -151,7 +168,7 @@ var companionConfigSchema = z2.object({
151
168
  // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
152
169
  // agentId / username / `slug/username`. Hand-added by the operator; the companion
153
170
  // never writes this (it only persists credentials + the device, elsewhere).
154
- agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
171
+ agents: agentsConfigSchema.optional(),
155
172
  // CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
156
173
  // assigned to this device, unless that agent's own `agents` row names a hook of
157
174
  // its own.
@@ -343,7 +360,7 @@ function loadConfigTolerant() {
343
360
  }
344
361
  const obj = parsed && typeof parsed === "object" ? parsed : {};
345
362
  const local = {};
346
- const agents = z2.record(z2.string(), localAgentConfigSchema).safeParse(obj.agents);
363
+ const agents = agentsConfigSchema.safeParse(obj.agents);
347
364
  if (agents.success) local.agents = agents.data;
348
365
  const prepareHook = prepareHookSchema.safeParse(obj.prepareHook);
349
366
  if (prepareHook.success) local.prepareHook = prepareHook.data;
package/dist/runtime.js CHANGED
@@ -260,8 +260,25 @@ var localAgentConfigSchema = z2.object({
260
260
  // auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
261
261
  // injecting the off switch and your normal Claude Code memory workflow applies
262
262
  // (in coding mode, where the checkout's project settings are read).
263
- claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
263
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
264
+ // CT1693: this agent's exception to `runners.sandbox` — an ops agent that
265
+ // runs with the machine on a sandboxed device, or one sandboxed agent on a
266
+ // device that isn't. Absent is "inherit"; see `runnerSandboxFor`. Only on a
267
+ // row keyed by the agent's id (`agentsConfigSchema`).
268
+ sandbox: z2.enum(["os", "none"]).optional()
264
269
  }).strict();
270
+ var AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
271
+ var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
272
+ for (const [key, row] of Object.entries(agents)) {
273
+ if (row.sandbox === void 0) continue;
274
+ if (AGENT_ID_KEY.test(key)) continue;
275
+ ctx.addIssue({
276
+ code: z2.ZodIssueCode.custom,
277
+ path: [key, "sandbox"],
278
+ message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
279
+ });
280
+ }
281
+ });
265
282
  var companionConfigSchema = z2.object({
266
283
  // The cabane instance this device is paired with. SJ515: https-enforced
267
284
  // (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
@@ -282,7 +299,7 @@ var companionConfigSchema = z2.object({
282
299
  // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
283
300
  // agentId / username / `slug/username`. Hand-added by the operator; the companion
284
301
  // never writes this (it only persists credentials + the device, elsewhere).
285
- agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
302
+ agents: agentsConfigSchema.optional(),
286
303
  // CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
287
304
  // assigned to this device, unless that agent's own `agents` row names a hook of
288
305
  // its own.
@@ -421,6 +438,28 @@ function localAgentConfig(cfg, agent) {
421
438
  if (!cfg.prepareHook || row.prepareHook) return row;
422
439
  return { ...row, prepareHook: cfg.prepareHook };
423
440
  }
441
+ function agentSandbox(cfg, agent) {
442
+ const sandbox = cfg.agents?.[agent.agentId]?.sandbox;
443
+ return sandbox ? { sandbox } : {};
444
+ }
445
+ function runnerSandboxFor(cfg, row, host) {
446
+ const asked = row.sandbox ?? cfg.runners?.sandbox ?? "none";
447
+ if (asked !== "os" || cfg.runners?.provider === "docker") return "none";
448
+ return host.osSandboxUsable() ? "os" : "none";
449
+ }
450
+ function runnerSandboxReport(cfg, agents, host) {
451
+ const device = runnerSandboxFor(cfg, {}, host);
452
+ const sandboxOverrides = agents.flatMap((agent) => {
453
+ const sandbox = runnerSandboxFor(cfg, agentSandbox(cfg, agent), host);
454
+ return sandbox === device ? [] : [{ agentId: agent.agentId, sandbox }];
455
+ });
456
+ return { sandbox: device, sandboxOverrides };
457
+ }
458
+ function agentSandboxRows(cfg) {
459
+ return Object.entries(cfg.agents ?? {}).flatMap(
460
+ ([key, row]) => row.sandbox ? [[key, row.sandbox]] : []
461
+ );
462
+ }
424
463
  function loadConfig() {
425
464
  const path = configPath();
426
465
  if (!existsSync(path)) return null;
@@ -814,7 +853,7 @@ function buildCompanionManifest(opts) {
814
853
  runtimes,
815
854
  capabilities: { ...DEVICE_MANIFEST.capabilities },
816
855
  runtimeIssues: opts.runtimeIssues ?? [],
817
- ...opts.runnerProvider ? { runner: { provider: opts.runnerProvider } } : {}
856
+ ...opts.runner ? { runner: opts.runner } : {}
818
857
  };
819
858
  }
820
859
  function runtimeIssuesFor(gates) {
@@ -11630,7 +11669,9 @@ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11630
11669
  function containerName(attempt) {
11631
11670
  return `cabane-runner-${attempt.conversationId}-${attempt.agentId}`;
11632
11671
  }
11633
- var networkName = containerName;
11672
+ function networkName(attempt) {
11673
+ return containerName(attempt);
11674
+ }
11634
11675
  function networkCreateError(stderr) {
11635
11676
  const said = stderr.trim();
11636
11677
  if (/address pool|fully subnetted/i.test(said))
@@ -13171,10 +13212,21 @@ var CompanionSupervisor = class {
13171
13212
  }
13172
13213
  this.latchBootBundledState();
13173
13214
  }
13174
- if (this.runnerSandbox() === "os") {
13215
+ const sandboxRows = agentSandboxRows(this.config);
13216
+ if (sandboxRows.length > 0 && this.dockerRunners()) {
13217
+ this.log.warn(
13218
+ { agents: sandboxRows.map(([key]) => key) },
13219
+ `runners: ${sandboxRows.map(([key]) => `agents.${key}.sandbox`).join(", ")} ignored \u2014 under Docker the container is the sandbox`
13220
+ );
13221
+ }
13222
+ const anySandboxed = [{}, ...sandboxRows.map(([, sandbox]) => ({ sandbox }))].some(
13223
+ (row) => this.runnerSandboxFor(row) === "os"
13224
+ );
13225
+ if (anySandboxed) {
13175
13226
  const policy = this.sandboxPolicy();
13176
13227
  const posture = postureLines(policy);
13177
- const summary = posture.join("; ");
13228
+ const perAgent = sandboxRows.map(([key, sandbox]) => `${key} \u2192 ${sandbox}`).join(", ");
13229
+ const summary = posture.join("; ") + (perAgent ? `; per-agent: ${perAgent}` : "");
13178
13230
  if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
13179
13231
  else this.log.error({ posture }, `runners: ${summary}`);
13180
13232
  void this.companionStores().refresh();
@@ -13288,7 +13340,7 @@ var CompanionSupervisor = class {
13288
13340
  // (fail-soft to null). Informational only — the server matches on name.
13289
13341
  versions: this.harnessVersions,
13290
13342
  // CT1681: shown on the device page.
13291
- ...runnersEnabled(this.config) ? { runnerProvider: this.config.runners?.provider ?? "local-process" } : {}
13343
+ ...runnersEnabled(this.config) ? { runner: this.runnerManifest() } : {}
13292
13344
  }),
13293
13345
  // CT566: echo the last classified credential state per runtime, when the
13294
13346
  // companion has seen any. Omitted (undefined) until the first observed
@@ -13620,6 +13672,9 @@ var CompanionSupervisor = class {
13620
13672
  // CT1679: `runners.sandbox`, checked against this host once. A host that
13621
13673
  // can't make the sandbox says so once and runs its turns unwrapped.
13622
13674
  sandboxUsable;
13675
+ sandboxHost = {
13676
+ osSandboxUsable: () => this.sandboxUsable ??= osSandboxUsable(this.log)
13677
+ };
13623
13678
  // CT1684: `runners.policy`, parsed once. An invalid one is kept as the
13624
13679
  // error, which every sandboxed turn is refused with.
13625
13680
  parsedSandboxPolicy;
@@ -13633,10 +13688,26 @@ var CompanionSupervisor = class {
13633
13688
  this.storeWatch ??= companionStoreWatch(this.log);
13634
13689
  return this.storeWatch;
13635
13690
  }
13636
- runnerSandbox() {
13637
- if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
13638
- this.sandboxUsable ??= osSandboxUsable(this.log);
13639
- return this.sandboxUsable ? "os" : "none";
13691
+ // CT1693: the sandbox one agent's turns run under — its `agents` row, then
13692
+ // the device's. Resolved from the current config every time it's asked, so
13693
+ // a reload changes it; only the host probe is kept.
13694
+ runnerSandboxFor(row) {
13695
+ return runnerSandboxFor(this.config, row, this.sandboxHost);
13696
+ }
13697
+ // CT1693: what the heartbeat says about this device's runners, resolved
13698
+ // against the agents it runs right now.
13699
+ runnerManifest() {
13700
+ const agents = [...this.workspaces.values()].flatMap(
13701
+ (wr) => [...wr.agents.values()].map((agent) => ({
13702
+ agentId: agent.agentId,
13703
+ agentUsername: agent.username,
13704
+ workspaceSlug: wr.slug
13705
+ }))
13706
+ );
13707
+ return {
13708
+ provider: this.config.runners?.provider ?? "local-process",
13709
+ ...runnerSandboxReport(this.config, agents, this.sandboxHost)
13710
+ };
13640
13711
  }
13641
13712
  // CT1679: one provider for the device, built on first use.
13642
13713
  runnerProvider() {
@@ -13752,7 +13823,7 @@ var CompanionSupervisor = class {
13752
13823
  reportRunnerGone: (report) => this.reportRunnerGone(report),
13753
13824
  connectorHealth: this.connectorHealth,
13754
13825
  locks: this.runnerLocks,
13755
- sandbox: this.runnerSandbox(),
13826
+ sandbox: this.runnerSandboxFor(agentSandbox(this.config, ctx)),
13756
13827
  policy: this.sandboxPolicy(),
13757
13828
  companionStores: () => this.companionStores().stores()
13758
13829
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.131",
3
+ "version": "0.6.133",
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",