@cabane/companion 0.6.128 → 0.6.130

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
@@ -310,15 +310,17 @@ What the sandbox allows is `runners.policy` in `~/.cabane/config.json`. Every ke
310
310
  }
311
311
  ```
312
312
 
313
- | Setting | Default |
314
- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
315
- | **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it. |
316
- | **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list. |
317
- | **Reads**, `blocklist` mode (the default) | Everything is readable except `~/.cabane`, `~/.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
- | **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
313
+ | Setting | Default |
314
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
315
+ | **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it. |
316
+ | **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list. |
317
+ | **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
+ | **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
319
319
 
320
320
  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
321
 
322
+ **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.
323
+
322
324
  **`.env` files in other projects.** Only the working directory's `.env` files are denied by default. Another project's `.env` stays readable by its absolute path. If your other projects hold secrets, list their roots, for example `"denyRead": ["~/work/**/.env", "~/work/**/.env.*"]`. Each glob is expanded when the turn starts, which costs a walk of that tree on every turn and misses files created during the turn. When other projects' secrets really matter, strict mode is the better answer, because it hides them all without a walk.
323
325
 
324
326
  **Strict mode.** `"filesystem": { "mode": "strict" }` hides your whole home directory except the working directory, the harness state (`~/.claude`, `~/.codex`), what the Companion and its harnesses run from, and the paths you list in `allowRead`. Toolchains live all over a home directory (`~/.nvm`, `~/.local/share/pnpm`, `~/.cargo`, `~/.pyenv`, mise or asdf shims), so list yours, or builds will fail with "No such file or directory" on a path you didn't expect. On Linux a hidden path looks missing rather than denied, so the Companion log prints the readable list at the start of each strict turn. Compare it against the path the build couldn't find.
package/dist/cli.js CHANGED
@@ -2592,8 +2592,8 @@ var init_policy = __esm({
2592
2592
  // packages/agent-runtime/src/claude-code/model.ts
2593
2593
  function parseClaudeCodeModel(model) {
2594
2594
  if (!model) return null;
2595
- const sep2 = model.indexOf("/");
2596
- const bare = sep2 === -1 || model.slice(0, sep2) !== "anthropic" ? model : model.slice(sep2 + 1);
2595
+ const sep3 = model.indexOf("/");
2596
+ const bare = sep3 === -1 || model.slice(0, sep3) !== "anthropic" ? model : model.slice(sep3 + 1);
2597
2597
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
2598
2598
  }
2599
2599
  var CLAUDE_CODE_DEFAULT_MODEL_BARE;
@@ -4040,9 +4040,9 @@ var init_policy2 = __esm({
4040
4040
 
4041
4041
  // packages/agent-runtime/src/opencode/model.ts
4042
4042
  function parseOpencodeModel(model) {
4043
- const sep2 = model.indexOf("/");
4044
- if (sep2 === -1) return { providerID: "", modelID: model };
4045
- return { providerID: model.slice(0, sep2), modelID: model.slice(sep2 + 1) };
4043
+ const sep3 = model.indexOf("/");
4044
+ if (sep3 === -1) return { providerID: "", modelID: model };
4045
+ return { providerID: model.slice(0, sep3), modelID: model.slice(sep3 + 1) };
4046
4046
  }
4047
4047
  var init_model2 = __esm({
4048
4048
  "packages/agent-runtime/src/opencode/model.ts"() {
@@ -4311,10 +4311,10 @@ async function* parseSseStream(body, sessionId, signal) {
4311
4311
  const { value, done } = await reader.read();
4312
4312
  if (done) return;
4313
4313
  buf += decoder.decode(value, { stream: true });
4314
- let sep2;
4315
- while ((sep2 = buf.indexOf("\n\n")) !== -1) {
4316
- const frame = buf.slice(0, sep2);
4317
- buf = buf.slice(sep2 + 2);
4314
+ let sep3;
4315
+ while ((sep3 = buf.indexOf("\n\n")) !== -1) {
4316
+ const frame = buf.slice(0, sep3);
4317
+ buf = buf.slice(sep3 + 2);
4318
4318
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
4319
4319
  if (!data) continue;
4320
4320
  let ev;
@@ -5340,8 +5340,8 @@ var init_policy3 = __esm({
5340
5340
 
5341
5341
  // packages/agent-runtime/src/codex/model.ts
5342
5342
  function parseCodexModel(model) {
5343
- const sep2 = model.indexOf("/");
5344
- const bare = sep2 === -1 || model.slice(0, sep2) !== "openai" ? model : model.slice(sep2 + 1);
5343
+ const sep3 = model.indexOf("/");
5344
+ const bare = sep3 === -1 || model.slice(0, sep3) !== "openai" ? model : model.slice(sep3 + 1);
5345
5345
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
5346
5346
  }
5347
5347
  var CODEX_DEFAULT_MODEL_BARES;
@@ -7486,6 +7486,9 @@ var init_protocol = __esm({
7486
7486
  // CT1684: the device's sandbox policy, validated by the launcher. Absent
7487
7487
  // when the sandbox is off.
7488
7488
  sandboxPolicy: sandboxPolicySchema.optional(),
7489
+ // CT1691: every companion store the launcher's latest walk found, denied
7490
+ // to the harness. Absent when the sandbox is off.
7491
+ companionStores: z16.array(z16.string()).optional(),
7489
7492
  logLevel: z16.string(),
7490
7493
  // The launcher's connector-health observations, by runtime, as of the start.
7491
7494
  connectorHealth: z16.record(z16.string(), runtimeHealthSchema),
@@ -7549,7 +7552,7 @@ function healthSnapshot(store) {
7549
7552
  }
7550
7553
  return out;
7551
7554
  }
7552
- function runnerSpec(opts, launch, duties, runnerId) {
7555
+ function runnerSpec(opts, launch, duties, runnerId, companionStores) {
7553
7556
  return {
7554
7557
  runnerId,
7555
7558
  ...opts.deviceId ? { deviceId: opts.deviceId } : {},
@@ -7574,6 +7577,7 @@ function runnerSpec(opts, launch, duties, runnerId) {
7574
7577
  ...launch.provider.bindAttempt ? { attemptState: true } : {},
7575
7578
  ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
7576
7579
  ...launch.sandbox === "os" && launch.policy?.ok ? { sandboxPolicy: launch.policy.policy } : {},
7580
+ ...launch.sandbox === "os" && companionStores ? { companionStores: [...companionStores] } : {},
7577
7581
  logLevel: opts.log.level,
7578
7582
  connectorHealth: healthSnapshot(launch.connectorHealth),
7579
7583
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -7772,6 +7776,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7772
7776
  if (v !== void 0) env[k] = v;
7773
7777
  }
7774
7778
  }
7779
+ const companionStores = launch.sandbox === "os" ? await launch.companionStores?.() : void 0;
7775
7780
  const input = {
7776
7781
  baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
7777
7782
  credential: minted.credential,
@@ -7779,7 +7784,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7779
7784
  dispatch: payload,
7780
7785
  turnId,
7781
7786
  resumed: handleOpts.resumed,
7782
- spec: runnerSpec(opts, launch, duties, minted.runnerId)
7787
+ spec: runnerSpec(opts, launch, duties, minted.runnerId, companionStores)
7783
7788
  };
7784
7789
  Object.assign(env, runnerEnv(input));
7785
7790
  const running = launch.provider.exec(handle, launch.command, env, {
@@ -10266,6 +10271,7 @@ var init_git_credential = __esm({
10266
10271
  });
10267
10272
 
10268
10273
  // src/runner/harness-sandbox.ts
10274
+ import { execFile as execFile2 } from "child_process";
10269
10275
  import { lookup } from "dns/promises";
10270
10276
  import {
10271
10277
  chmodSync as chmodSync6,
@@ -10279,7 +10285,7 @@ import {
10279
10285
  import { createRequire as createRequire2 } from "module";
10280
10286
  import { isIP } from "net";
10281
10287
  import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
10282
- import { dirname as dirname8, join as join18 } from "path";
10288
+ import { dirname as dirname8, join as join18, sep as sep2 } from "path";
10283
10289
  import { fileURLToPath as fileURLToPath4 } from "url";
10284
10290
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
10285
10291
  function accountHome() {
@@ -10345,6 +10351,74 @@ function harnessRuntimePaths() {
10345
10351
  function ownAddresses() {
10346
10352
  return Object.values(networkInterfaces()).flat().flatMap((i) => i ? [i.address] : []);
10347
10353
  }
10354
+ function storeWalkRoots(homes, cabaneDir2) {
10355
+ const companionHome = dirname8(cabaneDir2);
10356
+ const parent = dirname8(companionHome);
10357
+ const beside = homes.includes(companionHome) || dirname8(parent) === parent ? [] : [parent];
10358
+ const candidates = [.../* @__PURE__ */ new Set([...homes, ...beside])];
10359
+ return candidates.filter(
10360
+ (root) => !candidates.some((other) => other !== root && isAtOrUnder(root, other))
10361
+ );
10362
+ }
10363
+ async function walkCompanionStores(roots, opts = {}) {
10364
+ const started = Date.now();
10365
+ const { stdout, error } = await new Promise((resolve2) => {
10366
+ execFile2(
10367
+ opts.command ?? "find",
10368
+ // `-prune`: nothing inside a store needs looking at. `-print0`: a
10369
+ // directory name may hold a newline.
10370
+ [...roots, "-type", "d", "-name", ".cabane", "-prune", "-print0"],
10371
+ {
10372
+ encoding: "buffer",
10373
+ timeout: opts.timeoutMs ?? STORE_WALK_TIMEOUT_MS,
10374
+ maxBuffer: 16 * 1024 * 1024
10375
+ },
10376
+ (err, out) => resolve2({
10377
+ stdout: Buffer.isBuffer(out) ? out : Buffer.alloc(0),
10378
+ ...err ? { error: err.message } : {}
10379
+ })
10380
+ );
10381
+ });
10382
+ const ms = Date.now() - started;
10383
+ const stores = [];
10384
+ let complete = error === void 0;
10385
+ for (let at = 0, end = stdout.indexOf(0); end !== -1; at = end + 1, end = stdout.indexOf(0, at)) {
10386
+ const raw = stdout.subarray(at, end);
10387
+ const path = raw.toString("utf8");
10388
+ if (Buffer.from(path, "utf8").equals(raw)) stores.push(path);
10389
+ else complete = false;
10390
+ }
10391
+ return { stores: [...new Set(stores)].sort(), complete, ms, ...error ? { error } : {} };
10392
+ }
10393
+ function companionStoreDenies(input) {
10394
+ const { homes, cabaneDir: cabaneDir2, foundStores } = input;
10395
+ const parent = dirname8(dirname8(cabaneDir2));
10396
+ const always = [
10397
+ cabaneDir2,
10398
+ ...homes.flatMap((home) => [
10399
+ join18(home, ".cabane"),
10400
+ join18(home, "*", ".cabane"),
10401
+ join18(home, "*", "*", ".cabane")
10402
+ ]),
10403
+ // A glob anchored at the root has no directory to start from.
10404
+ ...dirname8(parent) === parent ? [] : [join18(parent, "*", ".cabane")]
10405
+ ];
10406
+ const companionHome = dirname8(cabaneDir2);
10407
+ const otherLogins = foundStores.map((store) => dirname8(store)).filter((home) => home !== companionHome).flatMap((home) => [join18(home, ".claude"), join18(home, ".codex")]).filter((path) => !input.ownState.some((own) => isAtOrUnder(own, path)));
10408
+ return [.../* @__PURE__ */ new Set([...always, ...foundStores, ...otherLogins])];
10409
+ }
10410
+ function companionStoreWatch(log) {
10411
+ return new CompanionStoreWatch({
10412
+ log,
10413
+ roots: () => {
10414
+ const home = homedir3();
10415
+ return storeWalkRoots([.../* @__PURE__ */ new Set([home, accountHome() ?? home])], cabaneDir());
10416
+ }
10417
+ });
10418
+ }
10419
+ function isAtOrUnder(path, dir2) {
10420
+ return path === dir2 || path.startsWith(dir2 + sep2);
10421
+ }
10348
10422
  function expandHome(path, home) {
10349
10423
  if (path === "~") return home;
10350
10424
  return path.startsWith("~/") ? join18(home, path.slice(2)) : path;
@@ -10378,7 +10452,12 @@ function harnessSandboxConfig(input) {
10378
10452
  const allowedDomains = [.../* @__PURE__ */ new Set([...cabane, ...models, ...reachable])];
10379
10453
  const envGlobs = input.cwd ? [join18(input.cwd, "**/.env"), join18(input.cwd, "**/.env.*")] : [];
10380
10454
  const secrets = [
10381
- input.cabaneDir,
10455
+ ...companionStoreDenies({
10456
+ homes,
10457
+ cabaneDir: input.cabaneDir,
10458
+ foundStores: input.foundStores,
10459
+ ownState: [claudeDir, codexDir]
10460
+ }),
10382
10461
  ...homes.flatMap((home) => SECRET_PATHS_UNDER_HOME.map((p) => join18(home, p))),
10383
10462
  ...envGlobs,
10384
10463
  ...expand(policy.filesystem?.denyRead)
@@ -10441,11 +10520,12 @@ function gitCredentialExports(credentials) {
10441
10520
  const git = credentials.filter((c) => c.gitHost);
10442
10521
  if (!git.length) return "";
10443
10522
  const lines2 = ["n=${GIT_CONFIG_COUNT:-0}"];
10523
+ const set = (key, value) => lines2.push(`export "GIT_CONFIG_KEY_$n=${key}" "GIT_CONFIG_VALUE_$n=${value}"`, "n=$((n+1))");
10444
10524
  for (const c of git) {
10445
- lines2.push(
10446
- `export "GIT_CONFIG_KEY_$n=http.https://${c.gitHost}/.extraHeader" "GIT_CONFIG_VALUE_$n=Authorization: Basic $${c.name}"`,
10447
- "n=$((n+1))"
10448
- );
10525
+ const h = c.gitHost;
10526
+ set(`http.https://${h}/.extraHeader`, `Authorization: Basic $${c.name}`);
10527
+ set(`url.https://${h}/.insteadOf`, `git@${h}:`);
10528
+ set(`url.https://${h}/.insteadOf`, `ssh://git@${h}/`);
10449
10529
  }
10450
10530
  lines2.push("export GIT_CONFIG_COUNT=$n");
10451
10531
  return lines2.join("; ") + "; ";
@@ -10487,7 +10567,8 @@ async function prepareHarnessSandbox(input) {
10487
10567
  runtime: input.runtime,
10488
10568
  policy: input.policy,
10489
10569
  credentials: input.credentials,
10490
- runtimePaths: harnessRuntimePaths()
10570
+ runtimePaths: harnessRuntimePaths(),
10571
+ foundStores: input.companionStores ?? []
10491
10572
  });
10492
10573
  await SandboxManager.initialize(config);
10493
10574
  if (input.policy.filesystem?.mode === "strict") {
@@ -10559,7 +10640,7 @@ exec ${shellQuote(script)} ${shellQuote(real)} "$@"
10559
10640
  }
10560
10641
  };
10561
10642
  }
10562
- var SECRET_PATHS_UNDER_HOME;
10643
+ var SECRET_PATHS_UNDER_HOME, STORE_WALK_TIMEOUT_MS, STORE_WALK_MAX_AGE_MS, CompanionStoreWatch;
10563
10644
  var init_harness_sandbox = __esm({
10564
10645
  "src/runner/harness-sandbox.ts"() {
10565
10646
  "use strict";
@@ -10582,6 +10663,50 @@ var init_harness_sandbox = __esm({
10582
10663
  ".config/op",
10583
10664
  ".password-store"
10584
10665
  ];
10666
+ STORE_WALK_TIMEOUT_MS = 6e4;
10667
+ STORE_WALK_MAX_AGE_MS = 5 * 6e4;
10668
+ CompanionStoreWatch = class {
10669
+ constructor(opts) {
10670
+ this.opts = opts;
10671
+ }
10672
+ opts;
10673
+ latest;
10674
+ running;
10675
+ // Start a walk unless one is running; resolves with the stores held after it.
10676
+ refresh() {
10677
+ this.running ??= (this.opts.walk ?? walkCompanionStores)(this.opts.roots()).then((walk2) => {
10678
+ const stores = walk2.complete ? walk2.stores : [.../* @__PURE__ */ new Set([...this.latest?.stores ?? [], ...walk2.stores])].sort();
10679
+ this.latest = { stores, at: this.now() };
10680
+ if (!walk2.complete) {
10681
+ this.opts.log.warn(
10682
+ { err: walk2.error, ms: walk2.ms, stores: walk2.stores.length },
10683
+ "runners: couldn't search all of this machine for other companions' stores; the ones found and those matched by name are denied"
10684
+ );
10685
+ } else {
10686
+ this.opts.log.debug(
10687
+ { ms: walk2.ms, stores: walk2.stores.length },
10688
+ "runners: companion stores found"
10689
+ );
10690
+ }
10691
+ return stores;
10692
+ }).finally(() => {
10693
+ this.running = void 0;
10694
+ });
10695
+ return this.running;
10696
+ }
10697
+ // The stores a turn starting now denies.
10698
+ async stores() {
10699
+ const latest = this.latest;
10700
+ if (!latest) return this.refresh();
10701
+ if (this.now() - latest.at > (this.opts.maxAgeMs ?? STORE_WALK_MAX_AGE_MS)) {
10702
+ void this.refresh();
10703
+ }
10704
+ return latest.stores;
10705
+ }
10706
+ now() {
10707
+ return (this.opts.now ?? Date.now)();
10708
+ }
10709
+ };
10585
10710
  }
10586
10711
  });
10587
10712
 
@@ -11013,6 +11138,7 @@ function runnerDispatcherOptions(input, log, emit, extras = {}) {
11013
11138
  runtime,
11014
11139
  policy: spec.sandboxPolicy ?? {},
11015
11140
  credentials: input.sandboxCredentials ?? [],
11141
+ ...spec.companionStores ? { companionStores: spec.companionStores } : {},
11016
11142
  log: turnLog
11017
11143
  });
11018
11144
  if (sandbox) onSandbox?.(sandbox);
@@ -15087,6 +15213,7 @@ var CompanionSupervisor = class {
15087
15213
  const summary = posture.join("; ");
15088
15214
  if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
15089
15215
  else this.log.error({ posture }, `runners: ${summary}`);
15216
+ void this.companionStores().refresh();
15090
15217
  }
15091
15218
  const startupHarnessRefresh = this.refreshHarnessStatuses();
15092
15219
  this.trackHeartbeat(startupHarnessRefresh);
@@ -15536,6 +15663,12 @@ var CompanionSupervisor = class {
15536
15663
  this.parsedSandboxPolicy ??= parseSandboxPolicy(this.config.runners?.policy);
15537
15664
  return this.parsedSandboxPolicy;
15538
15665
  }
15666
+ // CT1691: the walk for other companions' stores, run off the turn path.
15667
+ storeWatch;
15668
+ companionStores() {
15669
+ this.storeWatch ??= companionStoreWatch(this.log);
15670
+ return this.storeWatch;
15671
+ }
15539
15672
  runnerSandbox() {
15540
15673
  if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
15541
15674
  this.sandboxUsable ??= osSandboxUsable(this.log);
@@ -15656,7 +15789,8 @@ var CompanionSupervisor = class {
15656
15789
  connectorHealth: this.connectorHealth,
15657
15790
  locks: this.runnerLocks,
15658
15791
  sandbox: this.runnerSandbox(),
15659
- policy: this.sandboxPolicy()
15792
+ policy: this.sandboxPolicy(),
15793
+ companionStores: () => this.companionStores().stores()
15660
15794
  }
15661
15795
  } : {}
15662
15796
  });
package/dist/runtime.js CHANGED
@@ -4011,8 +4011,8 @@ function readThinking(runtimeOptions) {
4011
4011
  var CLAUDE_CODE_DEFAULT_MODEL_BARE = "default";
4012
4012
  function parseClaudeCodeModel(model) {
4013
4013
  if (!model) return null;
4014
- const sep2 = model.indexOf("/");
4015
- const bare = sep2 === -1 || model.slice(0, sep2) !== "anthropic" ? model : model.slice(sep2 + 1);
4014
+ const sep3 = model.indexOf("/");
4015
+ const bare = sep3 === -1 || model.slice(0, sep3) !== "anthropic" ? model : model.slice(sep3 + 1);
4016
4016
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
4017
4017
  }
4018
4018
 
@@ -5363,9 +5363,9 @@ function readOpencodeDialect(runtimeOptions) {
5363
5363
 
5364
5364
  // packages/agent-runtime/src/opencode/model.ts
5365
5365
  function parseOpencodeModel(model) {
5366
- const sep2 = model.indexOf("/");
5367
- if (sep2 === -1) return { providerID: "", modelID: model };
5368
- return { providerID: model.slice(0, sep2), modelID: model.slice(sep2 + 1) };
5366
+ const sep3 = model.indexOf("/");
5367
+ if (sep3 === -1) return { providerID: "", modelID: model };
5368
+ return { providerID: model.slice(0, sep3), modelID: model.slice(sep3 + 1) };
5369
5369
  }
5370
5370
 
5371
5371
  // packages/agent-runtime/src/opencode/run-spec.ts
@@ -5617,10 +5617,10 @@ async function* parseSseStream(body, sessionId, signal) {
5617
5617
  const { value, done } = await reader.read();
5618
5618
  if (done) return;
5619
5619
  buf += decoder.decode(value, { stream: true });
5620
- let sep2;
5621
- while ((sep2 = buf.indexOf("\n\n")) !== -1) {
5622
- const frame = buf.slice(0, sep2);
5623
- buf = buf.slice(sep2 + 2);
5620
+ let sep3;
5621
+ while ((sep3 = buf.indexOf("\n\n")) !== -1) {
5622
+ const frame = buf.slice(0, sep3);
5623
+ buf = buf.slice(sep3 + 2);
5624
5624
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
5625
5625
  if (!data) continue;
5626
5626
  let ev;
@@ -6580,8 +6580,8 @@ function readCodexDialect(runtimeOptions) {
6580
6580
  // packages/agent-runtime/src/codex/model.ts
6581
6581
  var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
6582
6582
  function parseCodexModel(model) {
6583
- const sep2 = model.indexOf("/");
6584
- const bare = sep2 === -1 || model.slice(0, sep2) !== "openai" ? model : model.slice(sep2 + 1);
6583
+ const sep3 = model.indexOf("/");
6584
+ const bare = sep3 === -1 || model.slice(0, sep3) !== "openai" ? model : model.slice(sep3 + 1);
6585
6585
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
6586
6586
  }
6587
6587
 
@@ -8543,6 +8543,9 @@ var runnerSpecSchema = z16.object({
8543
8543
  // CT1684: the device's sandbox policy, validated by the launcher. Absent
8544
8544
  // when the sandbox is off.
8545
8545
  sandboxPolicy: sandboxPolicySchema.optional(),
8546
+ // CT1691: every companion store the launcher's latest walk found, denied
8547
+ // to the harness. Absent when the sandbox is off.
8548
+ companionStores: z16.array(z16.string()).optional(),
8546
8549
  logLevel: z16.string(),
8547
8550
  // The launcher's connector-health observations, by runtime, as of the start.
8548
8551
  connectorHealth: z16.record(z16.string(), runtimeHealthSchema),
@@ -8634,7 +8637,7 @@ function healthSnapshot(store) {
8634
8637
  }
8635
8638
  return out;
8636
8639
  }
8637
- function runnerSpec(opts, launch, duties, runnerId) {
8640
+ function runnerSpec(opts, launch, duties, runnerId, companionStores) {
8638
8641
  return {
8639
8642
  runnerId,
8640
8643
  ...opts.deviceId ? { deviceId: opts.deviceId } : {},
@@ -8659,6 +8662,7 @@ function runnerSpec(opts, launch, duties, runnerId) {
8659
8662
  ...launch.provider.bindAttempt ? { attemptState: true } : {},
8660
8663
  ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
8661
8664
  ...launch.sandbox === "os" && launch.policy?.ok ? { sandboxPolicy: launch.policy.policy } : {},
8665
+ ...launch.sandbox === "os" && companionStores ? { companionStores: [...companionStores] } : {},
8662
8666
  logLevel: opts.log.level,
8663
8667
  connectorHealth: healthSnapshot(launch.connectorHealth),
8664
8668
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -8865,6 +8869,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8865
8869
  if (v !== void 0) env[k] = v;
8866
8870
  }
8867
8871
  }
8872
+ const companionStores = launch.sandbox === "os" ? await launch.companionStores?.() : void 0;
8868
8873
  const input = {
8869
8874
  baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
8870
8875
  credential: minted.credential,
@@ -8872,7 +8877,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8872
8877
  dispatch: payload,
8873
8878
  turnId,
8874
8879
  resumed: handleOpts.resumed,
8875
- spec: runnerSpec(opts, launch, duties, minted.runnerId)
8880
+ spec: runnerSpec(opts, launch, duties, minted.runnerId, companionStores)
8876
8881
  };
8877
8882
  Object.assign(env, runnerEnv(input));
8878
8883
  const running = launch.provider.exec(handle, launch.command, env, {
@@ -12451,6 +12456,7 @@ var DeviceLocks = class {
12451
12456
  };
12452
12457
 
12453
12458
  // src/runner/harness-sandbox.ts
12459
+ import { execFile as execFile2 } from "child_process";
12454
12460
  import { lookup } from "dns/promises";
12455
12461
  import {
12456
12462
  chmodSync as chmodSync6,
@@ -12464,9 +12470,111 @@ import {
12464
12470
  import { createRequire as createRequire2 } from "module";
12465
12471
  import { isIP } from "net";
12466
12472
  import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
12467
- import { dirname as dirname8, join as join18 } from "path";
12473
+ import { dirname as dirname8, join as join18, sep as sep2 } from "path";
12468
12474
  import { fileURLToPath as fileURLToPath4 } from "url";
12469
12475
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
12476
+ function accountHome() {
12477
+ try {
12478
+ return userInfo2().homedir;
12479
+ } catch {
12480
+ return null;
12481
+ }
12482
+ }
12483
+ var STORE_WALK_TIMEOUT_MS = 6e4;
12484
+ var STORE_WALK_MAX_AGE_MS = 5 * 6e4;
12485
+ function storeWalkRoots(homes, cabaneDir2) {
12486
+ const companionHome = dirname8(cabaneDir2);
12487
+ const parent = dirname8(companionHome);
12488
+ const beside = homes.includes(companionHome) || dirname8(parent) === parent ? [] : [parent];
12489
+ const candidates = [.../* @__PURE__ */ new Set([...homes, ...beside])];
12490
+ return candidates.filter(
12491
+ (root) => !candidates.some((other) => other !== root && isAtOrUnder(root, other))
12492
+ );
12493
+ }
12494
+ async function walkCompanionStores(roots, opts = {}) {
12495
+ const started = Date.now();
12496
+ const { stdout, error } = await new Promise((resolve2) => {
12497
+ execFile2(
12498
+ opts.command ?? "find",
12499
+ // `-prune`: nothing inside a store needs looking at. `-print0`: a
12500
+ // directory name may hold a newline.
12501
+ [...roots, "-type", "d", "-name", ".cabane", "-prune", "-print0"],
12502
+ {
12503
+ encoding: "buffer",
12504
+ timeout: opts.timeoutMs ?? STORE_WALK_TIMEOUT_MS,
12505
+ maxBuffer: 16 * 1024 * 1024
12506
+ },
12507
+ (err, out) => resolve2({
12508
+ stdout: Buffer.isBuffer(out) ? out : Buffer.alloc(0),
12509
+ ...err ? { error: err.message } : {}
12510
+ })
12511
+ );
12512
+ });
12513
+ const ms = Date.now() - started;
12514
+ const stores = [];
12515
+ let complete = error === void 0;
12516
+ for (let at = 0, end = stdout.indexOf(0); end !== -1; at = end + 1, end = stdout.indexOf(0, at)) {
12517
+ const raw = stdout.subarray(at, end);
12518
+ const path = raw.toString("utf8");
12519
+ if (Buffer.from(path, "utf8").equals(raw)) stores.push(path);
12520
+ else complete = false;
12521
+ }
12522
+ return { stores: [...new Set(stores)].sort(), complete, ms, ...error ? { error } : {} };
12523
+ }
12524
+ var CompanionStoreWatch = class {
12525
+ constructor(opts) {
12526
+ this.opts = opts;
12527
+ }
12528
+ opts;
12529
+ latest;
12530
+ running;
12531
+ // Start a walk unless one is running; resolves with the stores held after it.
12532
+ refresh() {
12533
+ this.running ??= (this.opts.walk ?? walkCompanionStores)(this.opts.roots()).then((walk2) => {
12534
+ const stores = walk2.complete ? walk2.stores : [.../* @__PURE__ */ new Set([...this.latest?.stores ?? [], ...walk2.stores])].sort();
12535
+ this.latest = { stores, at: this.now() };
12536
+ if (!walk2.complete) {
12537
+ this.opts.log.warn(
12538
+ { err: walk2.error, ms: walk2.ms, stores: walk2.stores.length },
12539
+ "runners: couldn't search all of this machine for other companions' stores; the ones found and those matched by name are denied"
12540
+ );
12541
+ } else {
12542
+ this.opts.log.debug(
12543
+ { ms: walk2.ms, stores: walk2.stores.length },
12544
+ "runners: companion stores found"
12545
+ );
12546
+ }
12547
+ return stores;
12548
+ }).finally(() => {
12549
+ this.running = void 0;
12550
+ });
12551
+ return this.running;
12552
+ }
12553
+ // The stores a turn starting now denies.
12554
+ async stores() {
12555
+ const latest = this.latest;
12556
+ if (!latest) return this.refresh();
12557
+ if (this.now() - latest.at > (this.opts.maxAgeMs ?? STORE_WALK_MAX_AGE_MS)) {
12558
+ void this.refresh();
12559
+ }
12560
+ return latest.stores;
12561
+ }
12562
+ now() {
12563
+ return (this.opts.now ?? Date.now)();
12564
+ }
12565
+ };
12566
+ function companionStoreWatch(log) {
12567
+ return new CompanionStoreWatch({
12568
+ log,
12569
+ roots: () => {
12570
+ const home = homedir3();
12571
+ return storeWalkRoots([.../* @__PURE__ */ new Set([home, accountHome() ?? home])], cabaneDir());
12572
+ }
12573
+ });
12574
+ }
12575
+ function isAtOrUnder(path, dir2) {
12576
+ return path === dir2 || path.startsWith(dir2 + sep2);
12577
+ }
12470
12578
  function osSandboxUsable(log) {
12471
12579
  if (!SandboxManager.isSupportedPlatform()) {
12472
12580
  log.warn(
@@ -12999,6 +13107,7 @@ var CompanionSupervisor = class {
12999
13107
  const summary = posture.join("; ");
13000
13108
  if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
13001
13109
  else this.log.error({ posture }, `runners: ${summary}`);
13110
+ void this.companionStores().refresh();
13002
13111
  }
13003
13112
  const startupHarnessRefresh = this.refreshHarnessStatuses();
13004
13113
  this.trackHeartbeat(startupHarnessRefresh);
@@ -13448,6 +13557,12 @@ var CompanionSupervisor = class {
13448
13557
  this.parsedSandboxPolicy ??= parseSandboxPolicy(this.config.runners?.policy);
13449
13558
  return this.parsedSandboxPolicy;
13450
13559
  }
13560
+ // CT1691: the walk for other companions' stores, run off the turn path.
13561
+ storeWatch;
13562
+ companionStores() {
13563
+ this.storeWatch ??= companionStoreWatch(this.log);
13564
+ return this.storeWatch;
13565
+ }
13451
13566
  runnerSandbox() {
13452
13567
  if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
13453
13568
  this.sandboxUsable ??= osSandboxUsable(this.log);
@@ -13568,7 +13683,8 @@ var CompanionSupervisor = class {
13568
13683
  connectorHealth: this.connectorHealth,
13569
13684
  locks: this.runnerLocks,
13570
13685
  sandbox: this.runnerSandbox(),
13571
- policy: this.sandboxPolicy()
13686
+ policy: this.sandboxPolicy(),
13687
+ companionStores: () => this.companionStores().stores()
13572
13688
  }
13573
13689
  } : {}
13574
13690
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.128",
3
+ "version": "0.6.130",
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",