@cabane/companion 0.6.127 → 0.6.129

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
@@ -1037,6 +1037,11 @@ function leaseRefusal(err) {
1037
1037
  function isWriteFenceRefusal(err) {
1038
1038
  return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
1039
1039
  }
1040
+ function isTurnEndedRefusal(err) {
1041
+ if (!(err instanceof ApiError) || err.status !== 401) return false;
1042
+ const body = err.body;
1043
+ return typeof body === "object" && body !== null && body.reason === "turn_ended";
1044
+ }
1040
1045
  function describeErrorBody(body) {
1041
1046
  if (body === void 0 || body === null) return void 0;
1042
1047
  let text;
@@ -1286,6 +1291,15 @@ var init_api = __esm({
1286
1291
  progressed = true;
1287
1292
  } catch (err) {
1288
1293
  if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
1294
+ if (entry.kind === "active-run" && isTurnEndedRefusal(err)) {
1295
+ this.opts.log?.debug(
1296
+ { kind: entry.kind, turnId: entry.turnId, ...apiErrorLogFields(err) },
1297
+ "companion: queued settle refused; the turn had already ended"
1298
+ );
1299
+ outbox.remove(entry.turnId, entry.seq);
1300
+ progressed = true;
1301
+ continue;
1302
+ }
1289
1303
  this.opts.log?.warn(
1290
1304
  { kind: entry.kind, turnId: entry.turnId, seq: entry.seq, ...apiErrorLogFields(err) },
1291
1305
  "Cabane rejected a saved update; it could not be delivered. This companion may need an update: npm i -g @cabane/companion@latest"
@@ -2578,8 +2592,8 @@ var init_policy = __esm({
2578
2592
  // packages/agent-runtime/src/claude-code/model.ts
2579
2593
  function parseClaudeCodeModel(model) {
2580
2594
  if (!model) return null;
2581
- const sep2 = model.indexOf("/");
2582
- 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);
2583
2597
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
2584
2598
  }
2585
2599
  var CLAUDE_CODE_DEFAULT_MODEL_BARE;
@@ -4026,9 +4040,9 @@ var init_policy2 = __esm({
4026
4040
 
4027
4041
  // packages/agent-runtime/src/opencode/model.ts
4028
4042
  function parseOpencodeModel(model) {
4029
- const sep2 = model.indexOf("/");
4030
- if (sep2 === -1) return { providerID: "", modelID: model };
4031
- 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) };
4032
4046
  }
4033
4047
  var init_model2 = __esm({
4034
4048
  "packages/agent-runtime/src/opencode/model.ts"() {
@@ -4297,10 +4311,10 @@ async function* parseSseStream(body, sessionId, signal) {
4297
4311
  const { value, done } = await reader.read();
4298
4312
  if (done) return;
4299
4313
  buf += decoder.decode(value, { stream: true });
4300
- let sep2;
4301
- while ((sep2 = buf.indexOf("\n\n")) !== -1) {
4302
- const frame = buf.slice(0, sep2);
4303
- 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);
4304
4318
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
4305
4319
  if (!data) continue;
4306
4320
  let ev;
@@ -5326,8 +5340,8 @@ var init_policy3 = __esm({
5326
5340
 
5327
5341
  // packages/agent-runtime/src/codex/model.ts
5328
5342
  function parseCodexModel(model) {
5329
- const sep2 = model.indexOf("/");
5330
- 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);
5331
5345
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
5332
5346
  }
5333
5347
  var CODEX_DEFAULT_MODEL_BARES;
@@ -7472,6 +7486,9 @@ var init_protocol = __esm({
7472
7486
  // CT1684: the device's sandbox policy, validated by the launcher. Absent
7473
7487
  // when the sandbox is off.
7474
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(),
7475
7492
  logLevel: z16.string(),
7476
7493
  // The launcher's connector-health observations, by runtime, as of the start.
7477
7494
  connectorHealth: z16.record(z16.string(), runtimeHealthSchema),
@@ -7535,7 +7552,7 @@ function healthSnapshot(store) {
7535
7552
  }
7536
7553
  return out;
7537
7554
  }
7538
- function runnerSpec(opts, launch, duties, runnerId) {
7555
+ function runnerSpec(opts, launch, duties, runnerId, companionStores) {
7539
7556
  return {
7540
7557
  runnerId,
7541
7558
  ...opts.deviceId ? { deviceId: opts.deviceId } : {},
@@ -7560,6 +7577,7 @@ function runnerSpec(opts, launch, duties, runnerId) {
7560
7577
  ...launch.provider.bindAttempt ? { attemptState: true } : {},
7561
7578
  ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
7562
7579
  ...launch.sandbox === "os" && launch.policy?.ok ? { sandboxPolicy: launch.policy.policy } : {},
7580
+ ...launch.sandbox === "os" && companionStores ? { companionStores: [...companionStores] } : {},
7563
7581
  logLevel: opts.log.level,
7564
7582
  connectorHealth: healthSnapshot(launch.connectorHealth),
7565
7583
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -7758,6 +7776,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7758
7776
  if (v !== void 0) env[k] = v;
7759
7777
  }
7760
7778
  }
7779
+ const companionStores = launch.sandbox === "os" ? await launch.companionStores?.() : void 0;
7761
7780
  const input = {
7762
7781
  baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
7763
7782
  credential: minted.credential,
@@ -7765,7 +7784,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7765
7784
  dispatch: payload,
7766
7785
  turnId,
7767
7786
  resumed: handleOpts.resumed,
7768
- spec: runnerSpec(opts, launch, duties, minted.runnerId)
7787
+ spec: runnerSpec(opts, launch, duties, minted.runnerId, companionStores)
7769
7788
  };
7770
7789
  Object.assign(env, runnerEnv(input));
7771
7790
  const running = launch.provider.exec(handle, launch.command, env, {
@@ -8998,10 +9017,17 @@ var init_turn_execution = __esm({
8998
9017
  body
8999
9018
  );
9000
9019
  } catch (err) {
9001
- turnLog.warn(
9002
- { ...apiErrorLogFields(err), turnId },
9003
- "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
9004
- );
9020
+ if (isTurnEndedRefusal(err)) {
9021
+ turnLog.debug(
9022
+ { ...apiErrorLogFields(err), turnId },
9023
+ "companion: settle refused; the turn had already ended"
9024
+ );
9025
+ } else {
9026
+ turnLog.warn(
9027
+ { ...apiErrorLogFields(err), turnId },
9028
+ "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
9029
+ );
9030
+ }
9005
9031
  }
9006
9032
  const durationMs = Date.now() - startedAt;
9007
9033
  this.supervisor.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
@@ -9809,10 +9835,17 @@ var init_turn_execution = __esm({
9809
9835
  body
9810
9836
  );
9811
9837
  } catch (err) {
9812
- turnLog.warn(
9813
- { ...apiErrorLogFields(err) },
9814
- "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
9815
- );
9838
+ if (isTurnEndedRefusal(err)) {
9839
+ turnLog.debug(
9840
+ { ...apiErrorLogFields(err) },
9841
+ "companion: settle refused; the turn had already ended"
9842
+ );
9843
+ } else {
9844
+ turnLog.warn(
9845
+ { ...apiErrorLogFields(err) },
9846
+ "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
9847
+ );
9848
+ }
9816
9849
  }
9817
9850
  this.supervisor.releaseAbort(turnId, abortController);
9818
9851
  }
@@ -10238,6 +10271,7 @@ var init_git_credential = __esm({
10238
10271
  });
10239
10272
 
10240
10273
  // src/runner/harness-sandbox.ts
10274
+ import { execFile as execFile2 } from "child_process";
10241
10275
  import { lookup } from "dns/promises";
10242
10276
  import {
10243
10277
  chmodSync as chmodSync6,
@@ -10251,7 +10285,7 @@ import {
10251
10285
  import { createRequire as createRequire2 } from "module";
10252
10286
  import { isIP } from "net";
10253
10287
  import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
10254
- import { dirname as dirname8, join as join18 } from "path";
10288
+ import { dirname as dirname8, join as join18, sep as sep2 } from "path";
10255
10289
  import { fileURLToPath as fileURLToPath4 } from "url";
10256
10290
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
10257
10291
  function accountHome() {
@@ -10317,6 +10351,74 @@ function harnessRuntimePaths() {
10317
10351
  function ownAddresses() {
10318
10352
  return Object.values(networkInterfaces()).flat().flatMap((i) => i ? [i.address] : []);
10319
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
+ }
10320
10422
  function expandHome(path, home) {
10321
10423
  if (path === "~") return home;
10322
10424
  return path.startsWith("~/") ? join18(home, path.slice(2)) : path;
@@ -10350,7 +10452,12 @@ function harnessSandboxConfig(input) {
10350
10452
  const allowedDomains = [.../* @__PURE__ */ new Set([...cabane, ...models, ...reachable])];
10351
10453
  const envGlobs = input.cwd ? [join18(input.cwd, "**/.env"), join18(input.cwd, "**/.env.*")] : [];
10352
10454
  const secrets = [
10353
- input.cabaneDir,
10455
+ ...companionStoreDenies({
10456
+ homes,
10457
+ cabaneDir: input.cabaneDir,
10458
+ foundStores: input.foundStores,
10459
+ ownState: [claudeDir, codexDir]
10460
+ }),
10354
10461
  ...homes.flatMap((home) => SECRET_PATHS_UNDER_HOME.map((p) => join18(home, p))),
10355
10462
  ...envGlobs,
10356
10463
  ...expand(policy.filesystem?.denyRead)
@@ -10459,7 +10566,8 @@ async function prepareHarnessSandbox(input) {
10459
10566
  runtime: input.runtime,
10460
10567
  policy: input.policy,
10461
10568
  credentials: input.credentials,
10462
- runtimePaths: harnessRuntimePaths()
10569
+ runtimePaths: harnessRuntimePaths(),
10570
+ foundStores: input.companionStores ?? []
10463
10571
  });
10464
10572
  await SandboxManager.initialize(config);
10465
10573
  if (input.policy.filesystem?.mode === "strict") {
@@ -10531,7 +10639,7 @@ exec ${shellQuote(script)} ${shellQuote(real)} "$@"
10531
10639
  }
10532
10640
  };
10533
10641
  }
10534
- var SECRET_PATHS_UNDER_HOME;
10642
+ var SECRET_PATHS_UNDER_HOME, STORE_WALK_TIMEOUT_MS, STORE_WALK_MAX_AGE_MS, CompanionStoreWatch;
10535
10643
  var init_harness_sandbox = __esm({
10536
10644
  "src/runner/harness-sandbox.ts"() {
10537
10645
  "use strict";
@@ -10554,6 +10662,50 @@ var init_harness_sandbox = __esm({
10554
10662
  ".config/op",
10555
10663
  ".password-store"
10556
10664
  ];
10665
+ STORE_WALK_TIMEOUT_MS = 6e4;
10666
+ STORE_WALK_MAX_AGE_MS = 5 * 6e4;
10667
+ CompanionStoreWatch = class {
10668
+ constructor(opts) {
10669
+ this.opts = opts;
10670
+ }
10671
+ opts;
10672
+ latest;
10673
+ running;
10674
+ // Start a walk unless one is running; resolves with the stores held after it.
10675
+ refresh() {
10676
+ this.running ??= (this.opts.walk ?? walkCompanionStores)(this.opts.roots()).then((walk2) => {
10677
+ const stores = walk2.complete ? walk2.stores : [.../* @__PURE__ */ new Set([...this.latest?.stores ?? [], ...walk2.stores])].sort();
10678
+ this.latest = { stores, at: this.now() };
10679
+ if (!walk2.complete) {
10680
+ this.opts.log.warn(
10681
+ { err: walk2.error, ms: walk2.ms, stores: walk2.stores.length },
10682
+ "runners: couldn't search all of this machine for other companions' stores; the ones found and those matched by name are denied"
10683
+ );
10684
+ } else {
10685
+ this.opts.log.debug(
10686
+ { ms: walk2.ms, stores: walk2.stores.length },
10687
+ "runners: companion stores found"
10688
+ );
10689
+ }
10690
+ return stores;
10691
+ }).finally(() => {
10692
+ this.running = void 0;
10693
+ });
10694
+ return this.running;
10695
+ }
10696
+ // The stores a turn starting now denies.
10697
+ async stores() {
10698
+ const latest = this.latest;
10699
+ if (!latest) return this.refresh();
10700
+ if (this.now() - latest.at > (this.opts.maxAgeMs ?? STORE_WALK_MAX_AGE_MS)) {
10701
+ void this.refresh();
10702
+ }
10703
+ return latest.stores;
10704
+ }
10705
+ now() {
10706
+ return (this.opts.now ?? Date.now)();
10707
+ }
10708
+ };
10557
10709
  }
10558
10710
  });
10559
10711
 
@@ -10985,6 +11137,7 @@ function runnerDispatcherOptions(input, log, emit, extras = {}) {
10985
11137
  runtime,
10986
11138
  policy: spec.sandboxPolicy ?? {},
10987
11139
  credentials: input.sandboxCredentials ?? [],
11140
+ ...spec.companionStores ? { companionStores: spec.companionStores } : {},
10988
11141
  log: turnLog
10989
11142
  });
10990
11143
  if (sandbox) onSandbox?.(sandbox);
@@ -15059,6 +15212,7 @@ var CompanionSupervisor = class {
15059
15212
  const summary = posture.join("; ");
15060
15213
  if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
15061
15214
  else this.log.error({ posture }, `runners: ${summary}`);
15215
+ void this.companionStores().refresh();
15062
15216
  }
15063
15217
  const startupHarnessRefresh = this.refreshHarnessStatuses();
15064
15218
  this.trackHeartbeat(startupHarnessRefresh);
@@ -15508,6 +15662,12 @@ var CompanionSupervisor = class {
15508
15662
  this.parsedSandboxPolicy ??= parseSandboxPolicy(this.config.runners?.policy);
15509
15663
  return this.parsedSandboxPolicy;
15510
15664
  }
15665
+ // CT1691: the walk for other companions' stores, run off the turn path.
15666
+ storeWatch;
15667
+ companionStores() {
15668
+ this.storeWatch ??= companionStoreWatch(this.log);
15669
+ return this.storeWatch;
15670
+ }
15511
15671
  runnerSandbox() {
15512
15672
  if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
15513
15673
  this.sandboxUsable ??= osSandboxUsable(this.log);
@@ -15628,7 +15788,8 @@ var CompanionSupervisor = class {
15628
15788
  connectorHealth: this.connectorHealth,
15629
15789
  locks: this.runnerLocks,
15630
15790
  sandbox: this.runnerSandbox(),
15631
- policy: this.sandboxPolicy()
15791
+ policy: this.sandboxPolicy(),
15792
+ companionStores: () => this.companionStores().stores()
15632
15793
  }
15633
15794
  } : {}
15634
15795
  });
package/dist/runtime.js CHANGED
@@ -2279,6 +2279,11 @@ function leaseRefusal(err) {
2279
2279
  function isWriteFenceRefusal(err) {
2280
2280
  return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "not_running";
2281
2281
  }
2282
+ function isTurnEndedRefusal(err) {
2283
+ if (!(err instanceof ApiError) || err.status !== 401) return false;
2284
+ const body = err.body;
2285
+ return typeof body === "object" && body !== null && body.reason === "turn_ended";
2286
+ }
2282
2287
  var ERROR_BODY_LOG_CAP = 2e3;
2283
2288
  function describeErrorBody(body) {
2284
2289
  if (body === void 0 || body === null) return void 0;
@@ -2469,6 +2474,15 @@ var CabaneApi = class {
2469
2474
  progressed = true;
2470
2475
  } catch (err) {
2471
2476
  if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
2477
+ if (entry.kind === "active-run" && isTurnEndedRefusal(err)) {
2478
+ this.opts.log?.debug(
2479
+ { kind: entry.kind, turnId: entry.turnId, ...apiErrorLogFields(err) },
2480
+ "companion: queued settle refused; the turn had already ended"
2481
+ );
2482
+ outbox.remove(entry.turnId, entry.seq);
2483
+ progressed = true;
2484
+ continue;
2485
+ }
2472
2486
  this.opts.log?.warn(
2473
2487
  { kind: entry.kind, turnId: entry.turnId, seq: entry.seq, ...apiErrorLogFields(err) },
2474
2488
  "Cabane rejected a saved update; it could not be delivered. This companion may need an update: npm i -g @cabane/companion@latest"
@@ -3997,8 +4011,8 @@ function readThinking(runtimeOptions) {
3997
4011
  var CLAUDE_CODE_DEFAULT_MODEL_BARE = "default";
3998
4012
  function parseClaudeCodeModel(model) {
3999
4013
  if (!model) return null;
4000
- const sep2 = model.indexOf("/");
4001
- 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);
4002
4016
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
4003
4017
  }
4004
4018
 
@@ -5349,9 +5363,9 @@ function readOpencodeDialect(runtimeOptions) {
5349
5363
 
5350
5364
  // packages/agent-runtime/src/opencode/model.ts
5351
5365
  function parseOpencodeModel(model) {
5352
- const sep2 = model.indexOf("/");
5353
- if (sep2 === -1) return { providerID: "", modelID: model };
5354
- 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) };
5355
5369
  }
5356
5370
 
5357
5371
  // packages/agent-runtime/src/opencode/run-spec.ts
@@ -5603,10 +5617,10 @@ async function* parseSseStream(body, sessionId, signal) {
5603
5617
  const { value, done } = await reader.read();
5604
5618
  if (done) return;
5605
5619
  buf += decoder.decode(value, { stream: true });
5606
- let sep2;
5607
- while ((sep2 = buf.indexOf("\n\n")) !== -1) {
5608
- const frame = buf.slice(0, sep2);
5609
- 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);
5610
5624
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
5611
5625
  if (!data) continue;
5612
5626
  let ev;
@@ -6566,8 +6580,8 @@ function readCodexDialect(runtimeOptions) {
6566
6580
  // packages/agent-runtime/src/codex/model.ts
6567
6581
  var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
6568
6582
  function parseCodexModel(model) {
6569
- const sep2 = model.indexOf("/");
6570
- 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);
6571
6585
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
6572
6586
  }
6573
6587
 
@@ -8529,6 +8543,9 @@ var runnerSpecSchema = z16.object({
8529
8543
  // CT1684: the device's sandbox policy, validated by the launcher. Absent
8530
8544
  // when the sandbox is off.
8531
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(),
8532
8549
  logLevel: z16.string(),
8533
8550
  // The launcher's connector-health observations, by runtime, as of the start.
8534
8551
  connectorHealth: z16.record(z16.string(), runtimeHealthSchema),
@@ -8620,7 +8637,7 @@ function healthSnapshot(store) {
8620
8637
  }
8621
8638
  return out;
8622
8639
  }
8623
- function runnerSpec(opts, launch, duties, runnerId) {
8640
+ function runnerSpec(opts, launch, duties, runnerId, companionStores) {
8624
8641
  return {
8625
8642
  runnerId,
8626
8643
  ...opts.deviceId ? { deviceId: opts.deviceId } : {},
@@ -8645,6 +8662,7 @@ function runnerSpec(opts, launch, duties, runnerId) {
8645
8662
  ...launch.provider.bindAttempt ? { attemptState: true } : {},
8646
8663
  ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
8647
8664
  ...launch.sandbox === "os" && launch.policy?.ok ? { sandboxPolicy: launch.policy.policy } : {},
8665
+ ...launch.sandbox === "os" && companionStores ? { companionStores: [...companionStores] } : {},
8648
8666
  logLevel: opts.log.level,
8649
8667
  connectorHealth: healthSnapshot(launch.connectorHealth),
8650
8668
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -8851,6 +8869,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8851
8869
  if (v !== void 0) env[k] = v;
8852
8870
  }
8853
8871
  }
8872
+ const companionStores = launch.sandbox === "os" ? await launch.companionStores?.() : void 0;
8854
8873
  const input = {
8855
8874
  baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
8856
8875
  credential: minted.credential,
@@ -8858,7 +8877,7 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8858
8877
  dispatch: payload,
8859
8878
  turnId,
8860
8879
  resumed: handleOpts.resumed,
8861
- spec: runnerSpec(opts, launch, duties, minted.runnerId)
8880
+ spec: runnerSpec(opts, launch, duties, minted.runnerId, companionStores)
8862
8881
  };
8863
8882
  Object.assign(env, runnerEnv(input));
8864
8883
  const running = launch.provider.exec(handle, launch.command, env, {
@@ -9984,10 +10003,17 @@ var TurnExecution = class {
9984
10003
  body
9985
10004
  );
9986
10005
  } catch (err) {
9987
- turnLog.warn(
9988
- { ...apiErrorLogFields(err), turnId },
9989
- "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
9990
- );
10006
+ if (isTurnEndedRefusal(err)) {
10007
+ turnLog.debug(
10008
+ { ...apiErrorLogFields(err), turnId },
10009
+ "companion: settle refused; the turn had already ended"
10010
+ );
10011
+ } else {
10012
+ turnLog.warn(
10013
+ { ...apiErrorLogFields(err), turnId },
10014
+ "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
10015
+ );
10016
+ }
9991
10017
  }
9992
10018
  const durationMs = Date.now() - startedAt;
9993
10019
  this.supervisor.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
@@ -10795,10 +10821,17 @@ var TurnExecution = class {
10795
10821
  body
10796
10822
  );
10797
10823
  } catch (err) {
10798
- turnLog.warn(
10799
- { ...apiErrorLogFields(err) },
10800
- "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
10801
- );
10824
+ if (isTurnEndedRefusal(err)) {
10825
+ turnLog.debug(
10826
+ { ...apiErrorLogFields(err) },
10827
+ "companion: settle refused; the turn had already ended"
10828
+ );
10829
+ } else {
10830
+ turnLog.warn(
10831
+ { ...apiErrorLogFields(err) },
10832
+ "Couldn't save the turn's result to Cabane. This companion may need an update: npm i -g @cabane/companion@latest"
10833
+ );
10834
+ }
10802
10835
  }
10803
10836
  this.supervisor.releaseAbort(turnId, abortController);
10804
10837
  }
@@ -12423,6 +12456,7 @@ var DeviceLocks = class {
12423
12456
  };
12424
12457
 
12425
12458
  // src/runner/harness-sandbox.ts
12459
+ import { execFile as execFile2 } from "child_process";
12426
12460
  import { lookup } from "dns/promises";
12427
12461
  import {
12428
12462
  chmodSync as chmodSync6,
@@ -12436,9 +12470,111 @@ import {
12436
12470
  import { createRequire as createRequire2 } from "module";
12437
12471
  import { isIP } from "net";
12438
12472
  import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
12439
- import { dirname as dirname8, join as join18 } from "path";
12473
+ import { dirname as dirname8, join as join18, sep as sep2 } from "path";
12440
12474
  import { fileURLToPath as fileURLToPath4 } from "url";
12441
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
+ }
12442
12578
  function osSandboxUsable(log) {
12443
12579
  if (!SandboxManager.isSupportedPlatform()) {
12444
12580
  log.warn(
@@ -12971,6 +13107,7 @@ var CompanionSupervisor = class {
12971
13107
  const summary = posture.join("; ");
12972
13108
  if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
12973
13109
  else this.log.error({ posture }, `runners: ${summary}`);
13110
+ void this.companionStores().refresh();
12974
13111
  }
12975
13112
  const startupHarnessRefresh = this.refreshHarnessStatuses();
12976
13113
  this.trackHeartbeat(startupHarnessRefresh);
@@ -13420,6 +13557,12 @@ var CompanionSupervisor = class {
13420
13557
  this.parsedSandboxPolicy ??= parseSandboxPolicy(this.config.runners?.policy);
13421
13558
  return this.parsedSandboxPolicy;
13422
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
+ }
13423
13566
  runnerSandbox() {
13424
13567
  if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
13425
13568
  this.sandboxUsable ??= osSandboxUsable(this.log);
@@ -13540,7 +13683,8 @@ var CompanionSupervisor = class {
13540
13683
  connectorHealth: this.connectorHealth,
13541
13684
  locks: this.runnerLocks,
13542
13685
  sandbox: this.runnerSandbox(),
13543
- policy: this.sandboxPolicy()
13686
+ policy: this.sandboxPolicy(),
13687
+ companionStores: () => this.companionStores().stores()
13544
13688
  }
13545
13689
  } : {}
13546
13690
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.127",
3
+ "version": "0.6.129",
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",