@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 +23 -3
- package/dist/cli.js +108 -20
- package/dist/pairing-config.js +20 -3
- package/dist/runtime.js +83 -12
- package/package.json +1 -1
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
|
|
236
|
-
// everything else about the agent (mode, MCP servers,
|
|
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,
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) ? {
|
|
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
|
-
|
|
15743
|
-
|
|
15744
|
-
|
|
15745
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|
package/dist/pairing-config.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) ? {
|
|
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
|
-
|
|
13637
|
-
|
|
13638
|
-
|
|
13639
|
-
|
|
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.
|
|
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.
|
|
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",
|