@cabane/companion 0.6.124 → 0.6.126

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/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/runtime.ts
2
- import { randomUUID as randomUUID6 } from "crypto";
2
+ import { randomUUID as randomUUID7 } from "crypto";
3
3
 
4
4
  // src/config.ts
5
5
  import {
@@ -358,10 +358,45 @@ var companionConfigSchema = z2.object({
358
358
  // wraps the runner in an OS sandbox where the host supports one; `'none'`
359
359
  // (the default) runs it as a plain child beside this process, a supported
360
360
  // configuration in its own right rather than a degraded one.
361
+ // CT1684: `policy` is what the OS sandbox lets a turn reach, read and hold
362
+ // (`runner/sandbox-policy.ts`). It is validated there rather than here, so a
363
+ // bad policy refuses the turns it governs, with the field named, instead of
364
+ // keeping the companion from starting at all.
361
365
  runners: z2.object({
362
366
  enabled: z2.boolean().optional(),
363
- provider: z2.literal("local-process").optional(),
364
- sandbox: z2.enum(["os", "none"]).optional()
367
+ provider: z2.enum(["local-process", "docker"]).optional(),
368
+ sandbox: z2.enum(["os", "none"]).optional(),
369
+ // CT1681: the `docker` provider — a container per conversation and agent,
370
+ // from one image, on a network whose only way out is an allow-listed proxy.
371
+ docker: z2.object({
372
+ // The base image a container runs. Default: the one this companion
373
+ // builds from its own Dockerfile on first use. One named here must
374
+ // have that one's layout (the `runner` user, `cabane-companion` on
375
+ // PATH reading `CABANE_COMPANION_ROOT`).
376
+ image: z2.string().min(1).optional(),
377
+ // The companion install mounted into every container. Default, on
378
+ // Linux: this companion's own. Needed for one run from a checkout
379
+ // (a `pnpm --filter @cabane/companion deploy --prod <dir>`).
380
+ companionDir: z2.string().min(1).optional(),
381
+ // Hosts a turn may reach besides the Cabane API, the model providers
382
+ // and the git host. `*.example.com` matches any subdomain.
383
+ egressAllow: z2.array(z2.string().min(1)).optional(),
384
+ // Keep a turn's container between turns and start it again for the
385
+ // next one. Off by default: the container goes after every turn and
386
+ // the runtime's session files are restored into the next.
387
+ keepContainers: z2.boolean().optional(),
388
+ // The Cabane URL as a container reaches it, for a device whose own
389
+ // `baseUrl` is a loopback address the container can't see.
390
+ apiUrl: z2.string().url().refine(isAllowedBaseUrl).optional(),
391
+ // Env set on each turn's runner, `${NAME}` resolved from secrets.json
392
+ // at exec: the runtime's own auth, which the image never carries.
393
+ env: z2.record(z2.string(), z2.string()).optional(),
394
+ // A git credential helper run on this device to answer the
395
+ // container's git for an allowed host, e.g. `!gh auth git-credential`.
396
+ // Absent: git's own configured helpers on this device.
397
+ gitCredentialHelper: z2.string().min(1).optional()
398
+ }).strict().optional(),
399
+ policy: z2.unknown().optional()
365
400
  }).strict().optional()
366
401
  });
367
402
  function runnersEnabled(cfg) {
@@ -778,7 +813,8 @@ function buildCompanionManifest(opts) {
778
813
  return {
779
814
  runtimes,
780
815
  capabilities: { ...DEVICE_MANIFEST.capabilities },
781
- runtimeIssues: opts.runtimeIssues ?? []
816
+ runtimeIssues: opts.runtimeIssues ?? [],
817
+ ...opts.runnerProvider ? { runner: { provider: opts.runnerProvider } } : {}
782
818
  };
783
819
  }
784
820
  function runtimeIssuesFor(gates) {
@@ -2294,7 +2330,7 @@ import { randomUUID } from "crypto";
2294
2330
  var bootId = randomUUID();
2295
2331
 
2296
2332
  // src/supervisor.ts
2297
- import { randomUUID as randomUUID5 } from "crypto";
2333
+ import { randomUUID as randomUUID6 } from "crypto";
2298
2334
 
2299
2335
  // src/api.ts
2300
2336
  var DEVICE_HEADER = "x-cabane-device";
@@ -3961,8 +3997,8 @@ function readThinking(runtimeOptions) {
3961
3997
  var CLAUDE_CODE_DEFAULT_MODEL_BARE = "default";
3962
3998
  function parseClaudeCodeModel(model) {
3963
3999
  if (!model) return null;
3964
- const sep = model.indexOf("/");
3965
- const bare = sep === -1 || model.slice(0, sep) !== "anthropic" ? model : model.slice(sep + 1);
4000
+ const sep2 = model.indexOf("/");
4001
+ const bare = sep2 === -1 || model.slice(0, sep2) !== "anthropic" ? model : model.slice(sep2 + 1);
3966
4002
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
3967
4003
  }
3968
4004
 
@@ -5313,9 +5349,9 @@ function readOpencodeDialect(runtimeOptions) {
5313
5349
 
5314
5350
  // packages/agent-runtime/src/opencode/model.ts
5315
5351
  function parseOpencodeModel(model) {
5316
- const sep = model.indexOf("/");
5317
- if (sep === -1) return { providerID: "", modelID: model };
5318
- return { providerID: model.slice(0, sep), modelID: model.slice(sep + 1) };
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) };
5319
5355
  }
5320
5356
 
5321
5357
  // packages/agent-runtime/src/opencode/run-spec.ts
@@ -5567,10 +5603,10 @@ async function* parseSseStream(body, sessionId, signal) {
5567
5603
  const { value, done } = await reader.read();
5568
5604
  if (done) return;
5569
5605
  buf += decoder.decode(value, { stream: true });
5570
- let sep;
5571
- while ((sep = buf.indexOf("\n\n")) !== -1) {
5572
- const frame = buf.slice(0, sep);
5573
- buf = buf.slice(sep + 2);
5606
+ let sep2;
5607
+ while ((sep2 = buf.indexOf("\n\n")) !== -1) {
5608
+ const frame = buf.slice(0, sep2);
5609
+ buf = buf.slice(sep2 + 2);
5574
5610
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
5575
5611
  if (!data) continue;
5576
5612
  let ev;
@@ -6530,8 +6566,8 @@ function readCodexDialect(runtimeOptions) {
6530
6566
  // packages/agent-runtime/src/codex/model.ts
6531
6567
  var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
6532
6568
  function parseCodexModel(model) {
6533
- const sep = model.indexOf("/");
6534
- const bare = sep === -1 || model.slice(0, sep) !== "openai" ? model : model.slice(sep + 1);
6569
+ const sep2 = model.indexOf("/");
6570
+ const bare = sep2 === -1 || model.slice(0, sep2) !== "openai" ? model : model.slice(sep2 + 1);
6535
6571
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
6536
6572
  }
6537
6573
 
@@ -7841,6 +7877,149 @@ import { join as join14 } from "path";
7841
7877
  // src/runner/launch.ts
7842
7878
  import { createInterface } from "readline";
7843
7879
 
7880
+ // src/runner/sandbox-policy.ts
7881
+ import { NetworkConfigSchema } from "@anthropic-ai/sandbox-runtime";
7882
+ import { globToRegex } from "@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js";
7883
+ import { z as z13 } from "zod";
7884
+ var hostPattern = z13.string().min(1);
7885
+ var secretRef = z13.string().regex(/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/, "must be a ${NAME} reference into secrets.json");
7886
+ var envCredentialSchema = z13.object({ secret: secretRef, hosts: z13.array(hostPattern).min(1) }).strict();
7887
+ var gitCredentialSchema = z13.object({
7888
+ host: z13.string().regex(/^[A-Za-z0-9.-]+(:\d+)?$/, "must be a host name, like github.com"),
7889
+ username: z13.string().min(1).optional(),
7890
+ secret: secretRef
7891
+ }).strict();
7892
+ var sandboxPolicySchema = z13.object({
7893
+ hosts: z13.object({
7894
+ allow: z13.array(hostPattern).optional(),
7895
+ deny: z13.array(hostPattern).optional(),
7896
+ defaults: z13.boolean().optional()
7897
+ }).strict().optional(),
7898
+ filesystem: z13.object({
7899
+ mode: z13.enum(["blocklist", "strict"]).optional(),
7900
+ denyRead: z13.array(z13.string().min(1)).optional(),
7901
+ allowRead: z13.array(z13.string().min(1)).optional(),
7902
+ allowWrite: z13.array(z13.string().min(1)).optional()
7903
+ }).strict().optional(),
7904
+ credentials: z13.object({
7905
+ env: z13.record(z13.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), envCredentialSchema).optional(),
7906
+ git: z13.array(gitCredentialSchema).optional()
7907
+ }).strict().optional()
7908
+ }).strict();
7909
+ function parseSandboxPolicy(raw) {
7910
+ if (raw === void 0) return { ok: true, policy: {} };
7911
+ const result = sandboxPolicySchema.safeParse(raw);
7912
+ if (!result.success) {
7913
+ const issue = result.error.issues[0];
7914
+ return { ok: false, error: `${fieldPath(issue.path)}: ${issue.message}` };
7915
+ }
7916
+ const refused = unenforceable(result.data);
7917
+ return refused ? { ok: false, error: refused } : { ok: true, policy: result.data };
7918
+ }
7919
+ function fieldPath(path) {
7920
+ return ["runners", "policy", ...path.map(String)].join(".");
7921
+ }
7922
+ function unenforceable(policy) {
7923
+ const hostLists = [
7924
+ ["hosts.allow", "allowedDomains", policy.hosts?.allow],
7925
+ ["hosts.deny", "deniedDomains", policy.hosts?.deny],
7926
+ ...Object.entries(policy.credentials?.env ?? {}).map(
7927
+ ([name, entry]) => [
7928
+ `credentials.env.${name}.hosts`,
7929
+ "allowedDomains",
7930
+ entry.hosts
7931
+ ]
7932
+ )
7933
+ ];
7934
+ for (const [field, as, hosts] of hostLists) {
7935
+ for (const [i, host] of (hosts ?? []).entries()) {
7936
+ const check = NetworkConfigSchema.safeParse({
7937
+ allowedDomains: as === "allowedDomains" ? [host] : [],
7938
+ deniedDomains: as === "deniedDomains" ? [host] : []
7939
+ });
7940
+ if (!check.success) {
7941
+ const message = check.error.issues[0]?.message ?? "not a host pattern the sandbox accepts";
7942
+ return `runners.policy.${field}.${i}: ${message}`;
7943
+ }
7944
+ }
7945
+ }
7946
+ const pathLists = [
7947
+ ["filesystem.denyRead", policy.filesystem?.denyRead],
7948
+ ["filesystem.allowRead", policy.filesystem?.allowRead],
7949
+ ["filesystem.allowWrite", policy.filesystem?.allowWrite]
7950
+ ];
7951
+ for (const [field, paths] of pathLists) {
7952
+ for (const [i, path] of (paths ?? []).entries()) {
7953
+ try {
7954
+ new RegExp(globToRegex(path));
7955
+ } catch (err) {
7956
+ const message = err instanceof Error ? err.message : String(err);
7957
+ return `runners.policy.${field}.${i}: not a pattern the sandbox can match (${message})`;
7958
+ }
7959
+ }
7960
+ }
7961
+ return null;
7962
+ }
7963
+ var placeholderName = (ref) => ref.slice(2, -1);
7964
+ function gitAuthVar(index) {
7965
+ return `CABANE_GIT_AUTH_${index}`;
7966
+ }
7967
+ function resolvePolicyCredentials(policy, store) {
7968
+ const missing = /* @__PURE__ */ new Set();
7969
+ const credentials = [];
7970
+ const lookup2 = (ref) => {
7971
+ const name = placeholderName(ref);
7972
+ const value = store.get(name);
7973
+ if (value === void 0) missing.add(name);
7974
+ return value;
7975
+ };
7976
+ for (const [name, entry] of Object.entries(policy.credentials?.env ?? {})) {
7977
+ const value = lookup2(entry.secret);
7978
+ if (value === void 0) continue;
7979
+ credentials.push({ name, value, hosts: entry.hosts });
7980
+ }
7981
+ (policy.credentials?.git ?? []).forEach((entry, i) => {
7982
+ const secret = lookup2(entry.secret);
7983
+ if (secret === void 0) return;
7984
+ const user = entry.username ?? "x-access-token";
7985
+ credentials.push({
7986
+ name: gitAuthVar(i),
7987
+ value: Buffer.from(`${user}:${secret}`).toString("base64"),
7988
+ hosts: [entry.host.replace(/:\d+$/, "")],
7989
+ gitHost: entry.host
7990
+ });
7991
+ });
7992
+ return { credentials, missing: [...missing].sort() };
7993
+ }
7994
+ var HarnessSandboxRefused = class extends Error {
7995
+ };
7996
+ function policyHasCredentials(policy) {
7997
+ return Object.keys(policy.credentials?.env ?? {}).length > 0 || (policy.credentials?.git ?? []).length > 0;
7998
+ }
7999
+ function postureLines(parsed) {
8000
+ if (!parsed.ok) return [`policy invalid \u2014 turns are refused until it's fixed: ${parsed.error}`];
8001
+ const { policy } = parsed;
8002
+ const mode = policy.filesystem?.mode ?? "blocklist";
8003
+ const defaults = policy.hosts?.defaults !== false;
8004
+ const lines2 = [
8005
+ `filesystem: ${mode}${mode === "strict" ? " (home unreadable except the checkout, harness state and allowRead)" : ""}`,
8006
+ `hosts: Cabane + the turn's model provider${defaults ? " + defaults (GitHub, npm, PyPI, crates.io)" : ""}${policy.hosts?.allow?.length ? ` + ${policy.hosts.allow.join(", ")}` : ""}`
8007
+ ];
8008
+ if (policy.hosts?.deny?.length) lines2.push(`hosts denied: ${policy.hosts.deny.join(", ")}`);
8009
+ const extraDeny = policy.filesystem?.denyRead ?? [];
8010
+ if (extraDeny.length) lines2.push(`denied reads (extra): ${extraDeny.join(", ")}`);
8011
+ const allowRead = policy.filesystem?.allowRead ?? [];
8012
+ if (allowRead.length) lines2.push(`reads allowed back: ${allowRead.join(", ")}`);
8013
+ const allowWrite = policy.filesystem?.allowWrite ?? [];
8014
+ if (allowWrite.length) lines2.push(`writes allowed (extra): ${allowWrite.join(", ")}`);
8015
+ const env = Object.keys(policy.credentials?.env ?? {});
8016
+ const git = (policy.credentials?.git ?? []).map((g) => `git@${g.host}`);
8017
+ if (env.length || git.length) {
8018
+ lines2.push(`masked credentials: ${[...env, ...git].join(", ")}`);
8019
+ }
8020
+ return lines2;
8021
+ }
8022
+
7844
8023
  // src/runner/opencode-lock.ts
7845
8024
  function opencodeLockKey(serverUrl) {
7846
8025
  return `opencode:${serverUrl}`;
@@ -8151,43 +8330,145 @@ function createTurnContainment(turnId, deps = {}) {
8151
8330
  };
8152
8331
  }
8153
8332
 
8154
- // src/runner/protocol.ts
8333
+ // src/secrets.ts
8334
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
8335
+ import { join as join10 } from "path";
8155
8336
  import { z as z14 } from "zod";
8337
+ var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8338
+ function secretsPath() {
8339
+ return join10(cabaneDir(), "secrets.json");
8340
+ }
8341
+ var secretStoreSchema = z14.record(z14.string(), z14.string());
8342
+ function loadSecretStore() {
8343
+ const path = secretsPath();
8344
+ if (!existsSync7(path)) return makeStore({});
8345
+ let raw;
8346
+ try {
8347
+ raw = readFileSync6(path, "utf8");
8348
+ } catch (err) {
8349
+ throw new ConfigError(
8350
+ `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
8351
+ );
8352
+ }
8353
+ if (raw.trim().length === 0) return makeStore({});
8354
+ let parsed;
8355
+ try {
8356
+ parsed = JSON.parse(raw);
8357
+ } catch (err) {
8358
+ throw new ConfigError(
8359
+ `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
8360
+ );
8361
+ }
8362
+ const result = secretStoreSchema.safeParse(parsed);
8363
+ if (!result.success) {
8364
+ throw new ConfigError(
8365
+ `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
8366
+ );
8367
+ }
8368
+ return makeStore(result.data);
8369
+ }
8370
+ function loadSecretStoreTolerant(onWarn) {
8371
+ try {
8372
+ return loadSecretStore();
8373
+ } catch (err) {
8374
+ onWarn?.(err instanceof Error ? err.message : String(err));
8375
+ return makeStore({});
8376
+ }
8377
+ }
8378
+ function makeStore(map) {
8379
+ return {
8380
+ get: (name) => map[name],
8381
+ names: () => Object.keys(map).sort()
8382
+ };
8383
+ }
8384
+ function requiredSecretNames(mcpServers) {
8385
+ const names = /* @__PURE__ */ new Set();
8386
+ for (const server of Object.values(mcpServers ?? {})) {
8387
+ const bags = [
8388
+ "env" in server ? server.env : void 0,
8389
+ "headers" in server ? server.headers : void 0
8390
+ ];
8391
+ for (const bag of bags) {
8392
+ for (const value of Object.values(bag ?? {})) {
8393
+ for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
8394
+ }
8395
+ }
8396
+ }
8397
+ return [...names].sort();
8398
+ }
8399
+ function resolveMcpSecrets(mcpServers, store) {
8400
+ const missing = /* @__PURE__ */ new Set();
8401
+ const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
8402
+ const resolved = store.get(name);
8403
+ if (resolved === void 0) {
8404
+ missing.add(name);
8405
+ return _match;
8406
+ }
8407
+ return resolved;
8408
+ });
8409
+ const mapBag = (bag) => {
8410
+ const out = {};
8411
+ for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
8412
+ return out;
8413
+ };
8414
+ const servers = {};
8415
+ for (const [name, server] of Object.entries(mcpServers)) {
8416
+ const next = { ...server };
8417
+ if ("env" in next && next.env) next.env = mapBag(next.env);
8418
+ if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
8419
+ servers[name] = next;
8420
+ }
8421
+ return { servers, missing: [...missing].sort() };
8422
+ }
8423
+ var ERROR_REASON_MAX = 200;
8424
+ function missingSecretReason(missing) {
8425
+ const prefix = "missing_secret: ";
8426
+ for (let shown = missing.length; shown >= 0; shown--) {
8427
+ const omitted = missing.length - shown;
8428
+ const parts = [...missing.slice(0, shown), ...omitted > 0 ? [`+${omitted}`] : []];
8429
+ const reason = prefix + parts.join(",");
8430
+ if (reason.length <= ERROR_REASON_MAX) return reason;
8431
+ }
8432
+ return `${prefix}+${missing.length}`;
8433
+ }
8434
+
8435
+ // src/runner/protocol.ts
8436
+ import { z as z16 } from "zod";
8156
8437
 
8157
8438
  // src/run-config.ts
8158
- import { z as z13 } from "zod";
8159
- var mcpStdioServerSchema = z13.object({
8160
- type: z13.literal("stdio").optional(),
8161
- command: z13.string().min(1),
8162
- args: z13.array(z13.string()).optional(),
8163
- env: z13.record(z13.string(), z13.string()).optional()
8439
+ import { z as z15 } from "zod";
8440
+ var mcpStdioServerSchema = z15.object({
8441
+ type: z15.literal("stdio").optional(),
8442
+ command: z15.string().min(1),
8443
+ args: z15.array(z15.string()).optional(),
8444
+ env: z15.record(z15.string(), z15.string()).optional()
8164
8445
  });
8165
- var mcpHttpServerSchema = z13.object({
8166
- type: z13.literal("http"),
8167
- url: z13.string().url(),
8168
- headers: z13.record(z13.string(), z13.string()).optional()
8446
+ var mcpHttpServerSchema = z15.object({
8447
+ type: z15.literal("http"),
8448
+ url: z15.string().url(),
8449
+ headers: z15.record(z15.string(), z15.string()).optional()
8169
8450
  });
8170
- var mcpSseServerSchema = z13.object({
8171
- type: z13.literal("sse"),
8172
- url: z13.string().url(),
8173
- headers: z13.record(z13.string(), z13.string()).optional()
8451
+ var mcpSseServerSchema = z15.object({
8452
+ type: z15.literal("sse"),
8453
+ url: z15.string().url(),
8454
+ headers: z15.record(z15.string(), z15.string()).optional()
8174
8455
  });
8175
- var mcpServerDefSchema = z13.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
8176
- var thinkingConfigSchema = z13.discriminatedUnion("type", [
8177
- z13.object({ type: z13.literal("adaptive") }),
8178
- z13.object({ type: z13.literal("enabled"), budgetTokens: z13.number().int().positive().optional() }),
8179
- z13.object({ type: z13.literal("disabled") })
8456
+ var mcpServerDefSchema = z15.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
8457
+ var thinkingConfigSchema = z15.discriminatedUnion("type", [
8458
+ z15.object({ type: z15.literal("adaptive") }),
8459
+ z15.object({ type: z15.literal("enabled"), budgetTokens: z15.number().int().positive().optional() }),
8460
+ z15.object({ type: z15.literal("disabled") })
8180
8461
  ]);
8181
- var effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
8182
- var runConfigSchema = z13.object({
8462
+ var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
8463
+ var runConfigSchema = z15.object({
8183
8464
  // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8184
8465
  // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8185
8466
  // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8186
8467
  // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8187
8468
  // stripped, so an older companion riding a newer server never rejects the config).
8188
- hostAccess: z13.boolean().optional(),
8189
- mcpServers: z13.record(z13.string(), mcpServerDefSchema).optional(),
8190
- model: z13.string().min(1).optional(),
8469
+ hostAccess: z15.boolean().optional(),
8470
+ mcpServers: z15.record(z15.string(), mcpServerDefSchema).optional(),
8471
+ model: z15.string().min(1).optional(),
8191
8472
  thinking: thinkingConfigSchema.optional(),
8192
8473
  effort: effortSchema.optional()
8193
8474
  });
@@ -8215,56 +8496,75 @@ var RUNNER_ENV = {
8215
8496
  // Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
8216
8497
  spec: "CABANE_RUNNER_SPEC"
8217
8498
  };
8218
- var runtimeHealthSchema = z14.object({
8219
- quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
8220
- limitedUntil: z14.string().nullable(),
8221
- lastError: z14.string().nullable()
8499
+ var runtimeHealthSchema = z16.object({
8500
+ quotaState: z16.enum(["ok", "limited", "auth_expired", "unknown"]),
8501
+ limitedUntil: z16.string().nullable(),
8502
+ lastError: z16.string().nullable()
8222
8503
  });
8223
- var runnerSpecSchema = z14.object({
8224
- deviceId: z14.string().optional(),
8504
+ var runnerSpecSchema = z16.object({
8505
+ deviceId: z16.string().optional(),
8225
8506
  // The launcher's boot id: the one its heartbeat carries, so a runner's lease
8226
8507
  // matches the device that holds it.
8227
- bootId: z14.string(),
8228
- workspaceId: z14.string(),
8229
- workspaceSlug: z14.string(),
8230
- agentId: z14.string(),
8231
- agentUsername: z14.string(),
8232
- agentName: z14.string(),
8508
+ bootId: z16.string(),
8509
+ workspaceId: z16.string(),
8510
+ workspaceSlug: z16.string(),
8511
+ agentId: z16.string(),
8512
+ agentUsername: z16.string(),
8513
+ agentName: z16.string(),
8233
8514
  local: localAgentConfigSchema,
8234
8515
  runConfig: runConfigSchema,
8235
- opencodeServerUrl: z14.string().optional(),
8236
- claudeCodeAvailable: z14.boolean(),
8237
- codexAvailable: z14.boolean(),
8238
- idleTimeoutMs: z14.number().int().positive().optional(),
8239
- totalTimeoutMs: z14.number().int().positive().optional(),
8240
- leaseRenewalMs: z14.number().int().positive().optional(),
8241
- preparingRowDelayMs: z14.number().int().nonnegative().optional(),
8516
+ opencodeServerUrl: z16.string().optional(),
8517
+ claudeCodeAvailable: z16.boolean(),
8518
+ codexAvailable: z16.boolean(),
8519
+ idleTimeoutMs: z16.number().int().positive().optional(),
8520
+ totalTimeoutMs: z16.number().int().positive().optional(),
8521
+ leaseRenewalMs: z16.number().int().positive().optional(),
8522
+ preparingRowDelayMs: z16.number().int().nonnegative().optional(),
8242
8523
  // Whether the turn writes a transcript. The directory is the runner's own
8243
8524
  // (`transcriptsDir()`), so `CABANE_RUNNER_STATE_DIR` moves it with the outbox.
8244
- transcripts: z14.boolean(),
8525
+ transcripts: z16.boolean(),
8245
8526
  // Whether the harness starts under the OS sandbox. The launcher sends 'os'
8246
8527
  // only when this host can make one.
8247
- sandbox: z14.enum(["os", "none"]),
8248
- logLevel: z14.string(),
8528
+ sandbox: z16.enum(["os", "none"]),
8529
+ // CT1684: the device's sandbox policy, validated by the launcher. Absent
8530
+ // when the sandbox is off.
8531
+ sandboxPolicy: sandboxPolicySchema.optional(),
8532
+ logLevel: z16.string(),
8249
8533
  // The launcher's connector-health observations, by runtime, as of the start.
8250
- connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
8534
+ connectorHealth: z16.record(z16.string(), runtimeHealthSchema),
8251
8535
  // True when this (conversation, agent) pair was already told its session
8252
8536
  // could not be saved: the notice is once per pair, not once per process.
8253
- sessionWriteNoticeClaimed: z14.boolean(),
8537
+ sessionWriteNoticeClaimed: z16.boolean(),
8254
8538
  // CT1680: the runner's identity, minted with its credential. The start
8255
8539
  // PATCH carries it so the lease knows which runner holds the span.
8256
- runnerId: z14.string()
8540
+ runnerId: z16.string(),
8541
+ // CT1681: the launcher keeps the runtime's session files beside the attempt
8542
+ // and restores them into this runner's home, so the runner names the attempt
8543
+ // each context was composed for and waits for the launcher to say it's in
8544
+ // place. Absent: nothing to wait for (a runner on the launcher's own disk).
8545
+ attemptState: z16.boolean().optional(),
8546
+ // CT1681: MCP `${NAME}` secrets come from the launcher, by name, instead of
8547
+ // this machine's secrets.json. Absent: the runner reads the device's file.
8548
+ secretsFromLauncher: z16.boolean().optional()
8257
8549
  });
8258
- var dispatchSchema = z14.object({
8259
- type: z14.literal("device:dispatch_requested"),
8260
- agentId: z14.string(),
8261
- deviceId: z14.string(),
8262
- conversationId: z14.string(),
8263
- messageId: z14.string(),
8264
- episode: z14.number().int().optional()
8550
+ var dispatchSchema = z16.object({
8551
+ type: z16.literal("device:dispatch_requested"),
8552
+ agentId: z16.string(),
8553
+ deviceId: z16.string(),
8554
+ conversationId: z16.string(),
8555
+ messageId: z16.string(),
8556
+ episode: z16.number().int().optional()
8265
8557
  });
8266
- var credentialsSchema = z14.object({
8267
- credential: z14.string().startsWith("cabrn_")
8558
+ var credentialsSchema = z16.object({
8559
+ credential: z16.string().startsWith("cabrn_"),
8560
+ sandboxCredentials: z16.array(
8561
+ z16.object({
8562
+ name: z16.string(),
8563
+ value: z16.string(),
8564
+ hosts: z16.array(z16.string()),
8565
+ gitHost: z16.string().optional()
8566
+ })
8567
+ ).optional()
8268
8568
  });
8269
8569
  function runnerEnv(input) {
8270
8570
  return {
@@ -8276,7 +8576,10 @@ function runnerEnv(input) {
8276
8576
  };
8277
8577
  }
8278
8578
  function runnerStdin(input) {
8279
- return `${JSON.stringify({ credential: input.credential })}
8579
+ return `${JSON.stringify({
8580
+ credential: input.credential,
8581
+ ...input.sandboxCredentials?.length ? { sandboxCredentials: input.sandboxCredentials } : {}
8582
+ })}
8280
8583
  `;
8281
8584
  }
8282
8585
  function encodeLauncherMessage(message) {
@@ -8301,6 +8604,11 @@ function selfRunnerCommand() {
8301
8604
  if (!script) throw new Error("runners: cannot tell which script started this companion");
8302
8605
  return [process.execPath, ...process.execArgv, script, "run-turn"];
8303
8606
  }
8607
+ function defaultSecretStore(log) {
8608
+ return loadSecretStoreTolerant(
8609
+ (m) => log.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
8610
+ );
8611
+ }
8304
8612
  function healthSnapshot(store) {
8305
8613
  const out = {};
8306
8614
  for (const r of store?.reports() ?? []) {
@@ -8334,6 +8642,9 @@ function runnerSpec(opts, launch, duties, runnerId) {
8334
8642
  ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
8335
8643
  transcripts: opts.transcriptDir !== void 0,
8336
8644
  sandbox: launch.sandbox ?? "none",
8645
+ ...launch.provider.bindAttempt ? { attemptState: true } : {},
8646
+ ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
8647
+ ...launch.sandbox === "os" && launch.policy?.ok ? { sandboxPolicy: launch.policy.policy } : {},
8337
8648
  logLevel: opts.log.level,
8338
8649
  connectorHealth: healthSnapshot(launch.connectorHealth),
8339
8650
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -8440,6 +8751,32 @@ async function settleUnstarted(opts, payload, turnId, errorReason, log) {
8440
8751
  );
8441
8752
  }
8442
8753
  }
8754
+ function sandboxInputs(launch, log) {
8755
+ const policy = launch.policy ?? { ok: true, policy: {} };
8756
+ if (launch.sandbox !== "os") {
8757
+ if (policy.ok && policyHasCredentials(policy.policy)) {
8758
+ log.debug(
8759
+ "runners: runners.policy names credentials, but turns here run without the OS sandbox; none are given to the harness"
8760
+ );
8761
+ }
8762
+ return { ok: true, credentials: [] };
8763
+ }
8764
+ if (!policy.ok) {
8765
+ log.warn({ error: policy.error }, "runners: the sandbox policy is invalid; refusing the turn");
8766
+ return { ok: false, reason: `setup_failed: ${policy.error}`.slice(0, 200) };
8767
+ }
8768
+ if (!policyHasCredentials(policy.policy)) return { ok: true, credentials: [] };
8769
+ const store = (launch.secretStore ?? defaultSecretStore)(log);
8770
+ const { credentials, missing } = resolvePolicyCredentials(policy.policy, store);
8771
+ if (missing.length) {
8772
+ log.debug(
8773
+ { missing },
8774
+ "runners: the sandbox policy names secrets this device does not declare"
8775
+ );
8776
+ return { ok: false, reason: missingSecretReason(missing) };
8777
+ }
8778
+ return { ok: true, credentials };
8779
+ }
8443
8780
  async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8444
8781
  const startedAt = Date.now();
8445
8782
  const { turnId } = handleOpts;
@@ -8463,7 +8800,8 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8463
8800
  agentId: payload.agentId,
8464
8801
  turnId
8465
8802
  };
8466
- const minted = await mintForRunner(opts, payload, handleOpts, controller.signal, log);
8803
+ const sandboxed = sandboxInputs(launch, log);
8804
+ const minted = sandboxed.ok ? await mintForRunner(opts, payload, handleOpts, controller.signal, log) : { ok: false, reason: sandboxed.reason, errorReason: sandboxed.reason };
8467
8805
  if (!minted.ok) {
8468
8806
  controller.signal.removeEventListener("abort", forwardStop);
8469
8807
  duties.releaseAbort(turnId, controller);
@@ -8481,15 +8819,42 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8481
8819
  const remote = [];
8482
8820
  const lockOwner = (id) => `${turnId}#${id}`;
8483
8821
  const locks = launch.locks;
8822
+ let leasedAttemptId = null;
8484
8823
  try {
8485
8824
  handle = await launch.provider.acquire(attempt);
8825
+ } catch (err) {
8826
+ const reason2 = `runner_unavailable: ${describeError(err)}`;
8827
+ log.warn(
8828
+ { turnId, provider: launch.provider.name, err: describeError(err) },
8829
+ "runner: no place to run the turn"
8830
+ );
8831
+ controller.signal.removeEventListener("abort", forwardStop);
8832
+ duties.releaseAbort(turnId, controller);
8833
+ await settleUnstarted(opts, payload, turnId, reason2, log);
8834
+ await launch.reportRunnerGone?.({
8835
+ workspaceId: opts.workspaceId,
8836
+ conversationId: payload.conversationId,
8837
+ agentId: payload.agentId,
8838
+ messageId: payload.messageId,
8839
+ runnerId: minted.runnerId,
8840
+ reason: "provider_failed"
8841
+ });
8842
+ const durationMs2 = Date.now() - startedAt;
8843
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: reason2 });
8844
+ return { ok: false, durationMs: durationMs2, reason: reason2 };
8845
+ }
8846
+ try {
8847
+ const place = handle;
8486
8848
  const env = {};
8487
- for (const [k, v] of Object.entries(launch.env ?? process.env)) {
8488
- if (v !== void 0) env[k] = v;
8849
+ if (launch.provider.inheritsEnvironment !== false) {
8850
+ for (const [k, v] of Object.entries(launch.env ?? process.env)) {
8851
+ if (v !== void 0) env[k] = v;
8852
+ }
8489
8853
  }
8490
8854
  const input = {
8491
- baseUrl: opts.baseUrl,
8855
+ baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
8492
8856
  credential: minted.credential,
8857
+ sandboxCredentials: sandboxed.ok ? sandboxed.credentials : [],
8493
8858
  dispatch: payload,
8494
8859
  turnId,
8495
8860
  resumed: handleOpts.resumed,
@@ -8566,6 +8931,38 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8566
8931
  case "remote_session":
8567
8932
  remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
8568
8933
  return;
8934
+ case "attempt": {
8935
+ const bind = launch.provider.bindAttempt?.(place, event.attemptId) ?? Promise.resolve();
8936
+ void bind.then(
8937
+ () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "bound", id: event.id })),
8938
+ (err) => {
8939
+ const refused = describeError(err);
8940
+ log.warn(
8941
+ { attemptId: event.attemptId, err: refused },
8942
+ "runner: couldn't make the place ready for the turn's attempt; not running it there"
8943
+ );
8944
+ running.stdin.write(
8945
+ encodeLauncherMessage({ cabaneLauncher: "bound", id: event.id, refused })
8946
+ );
8947
+ }
8948
+ );
8949
+ return;
8950
+ }
8951
+ case "leased":
8952
+ leasedAttemptId = event.attemptId;
8953
+ return;
8954
+ case "secrets": {
8955
+ const store = (launch.secretStore ?? defaultSecretStore)(log);
8956
+ const values = {};
8957
+ for (const name of event.names) {
8958
+ const value = store.get(name);
8959
+ if (value !== void 0) values[name] = value;
8960
+ }
8961
+ running.stdin.write(
8962
+ encodeLauncherMessage({ cabaneLauncher: "secrets", id: event.id, values })
8963
+ );
8964
+ return;
8965
+ }
8569
8966
  }
8570
8967
  });
8571
8968
  const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
@@ -8576,7 +8973,14 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8576
8973
  child?.stdin.end();
8577
8974
  controller.signal.removeEventListener("abort", forwardStop);
8578
8975
  duties.releaseAbort(turnId, controller);
8579
- if (handle) await launch.provider.release(handle, { destroy: false });
8976
+ if (handle) {
8977
+ await launch.provider.collect?.(handle, { attemptId: leasedAttemptId }).catch(
8978
+ (err) => log.warn({ err: describeError(err) }, "runner: couldn't collect what the runner left")
8979
+ );
8980
+ await launch.provider.release(handle, { destroy: false }).catch(
8981
+ (err) => log.warn({ err: describeError(err) }, "runner: couldn't give its place back")
8982
+ );
8983
+ }
8580
8984
  }
8581
8985
  if (result) return result;
8582
8986
  const durationMs = Date.now() - startedAt;
@@ -8635,7 +9039,7 @@ import { existsSync as existsSync9 } from "fs";
8635
9039
 
8636
9040
  // src/turn-control-tools.ts
8637
9041
  import { randomUUID as randomUUID2 } from "crypto";
8638
- import { z as z15 } from "zod";
9042
+ import { z as z17 } from "zod";
8639
9043
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
8640
9044
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8641
9045
  var SKIP_TURN_TOOL = "skip_turn";
@@ -8698,7 +9102,7 @@ function createTurnControlMcpServer(acts, skipState) {
8698
9102
  SKIP_TURN_TOOL,
8699
9103
  TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
8700
9104
  {
8701
- reason: z15.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
9105
+ reason: z17.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
8702
9106
  },
8703
9107
  async (args) => {
8704
9108
  skipState.skipped = true;
@@ -8714,42 +9118,42 @@ function createTurnControlMcpServer(acts, skipState) {
8714
9118
  ASK_TOOL,
8715
9119
  TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
8716
9120
  {
8717
- targetUserId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
8718
- question: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
8719
- headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
8720
- options: z15.array(
8721
- z15.union([
8722
- z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
8723
- z15.object({
8724
- label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
8725
- move: z15.object({
8726
- task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
8727
- status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
9121
+ targetUserId: z17.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
9122
+ question: z17.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
9123
+ headline: z17.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
9124
+ options: z17.array(
9125
+ z17.union([
9126
+ z17.string().min(1).max(MAX_ASK_OPTION_BYTES),
9127
+ z17.object({
9128
+ label: z17.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
9129
+ move: z17.object({
9130
+ task: z17.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
9131
+ status: z17.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
8728
9132
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
8729
9133
  })
8730
9134
  ])
8731
9135
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
8732
- questions: z15.array(
8733
- z15.object({
8734
- headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
8735
- body: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
8736
- options: z15.array(
8737
- z15.union([
8738
- z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
8739
- z15.object({
8740
- label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
8741
- move: z15.object({
8742
- task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
8743
- status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
9136
+ questions: z17.array(
9137
+ z17.object({
9138
+ headline: z17.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
9139
+ body: z17.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
9140
+ options: z17.array(
9141
+ z17.union([
9142
+ z17.string().min(1).max(MAX_ASK_OPTION_BYTES),
9143
+ z17.object({
9144
+ label: z17.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
9145
+ move: z17.object({
9146
+ task: z17.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
9147
+ status: z17.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
8744
9148
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
8745
9149
  })
8746
9150
  ])
8747
9151
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
8748
9152
  })
8749
9153
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
8750
- replaces: z15.object({
8751
- askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
8752
- reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
9154
+ replaces: z17.object({
9155
+ askId: z17.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
9156
+ reason: z17.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
8753
9157
  }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
8754
9158
  },
8755
9159
  async (args) => {
@@ -8790,9 +9194,9 @@ function createTurnControlMcpServer(acts, skipState) {
8790
9194
  WAKE_ME_TOOL,
8791
9195
  TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
8792
9196
  {
8793
- afterSeconds: z15.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
8794
- at: z15.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
8795
- note: z15.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
9197
+ afterSeconds: z17.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
9198
+ at: z17.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
9199
+ note: z17.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
8796
9200
  },
8797
9201
  async (args) => {
8798
9202
  const hasAfter = args.afterSeconds !== void 0;
@@ -8826,8 +9230,8 @@ function createTurnControlMcpServer(acts, skipState) {
8826
9230
  WITHDRAW_ASK_TOOL,
8827
9231
  TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
8828
9232
  {
8829
- askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
8830
- reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
9233
+ askId: z17.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
9234
+ reason: z17.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
8831
9235
  },
8832
9236
  async (args) => performAct(acts, {
8833
9237
  kind: "withdraw_ask",
@@ -8894,11 +9298,11 @@ function trimSlash2(s) {
8894
9298
  // src/codex-instructions.ts
8895
9299
  import { mkdtemp, rm, writeFile } from "fs/promises";
8896
9300
  import { tmpdir } from "os";
8897
- import { join as join10 } from "path";
9301
+ import { join as join11 } from "path";
8898
9302
  var PREFIX = "cabane-codex-instructions-";
8899
9303
  async function writeCodexInstructionsFile(contents) {
8900
- const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
8901
- const path = join10(dir2, "instructions.md");
9304
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
9305
+ const path = join11(dir2, "instructions.md");
8902
9306
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
8903
9307
  return {
8904
9308
  path,
@@ -9004,22 +9408,22 @@ function reportFailure(config, body) {
9004
9408
  }
9005
9409
 
9006
9410
  // src/prepared.ts
9007
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
9008
- import { join as join11 } from "path";
9411
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "fs";
9412
+ import { join as join12 } from "path";
9009
9413
  function dirFor(workspaceId) {
9010
- return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
9414
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
9011
9415
  }
9012
9416
  function conversationDir(workspaceId, conversationId) {
9013
- return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
9417
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
9014
9418
  }
9015
9419
  function pathFor2(workspaceId, conversationId, agentId) {
9016
- return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
9420
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
9017
9421
  }
9018
9422
  function readPrepared(workspaceId, conversationId, agentId) {
9019
9423
  const path = pathFor2(workspaceId, conversationId, agentId);
9020
- if (!existsSync7(path)) return null;
9424
+ if (!existsSync8(path)) return null;
9021
9425
  try {
9022
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
9426
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
9023
9427
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
9024
9428
  return {
9025
9429
  cwd: parsed.cwd,
@@ -9043,123 +9447,32 @@ function clearPrepared(workspaceId, conversationId, agentId) {
9043
9447
  rmSync5(pathFor2(workspaceId, conversationId, agentId), { force: true });
9044
9448
  }
9045
9449
 
9046
- // src/secrets.ts
9047
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
9048
- import { join as join12 } from "path";
9049
- import { z as z16 } from "zod";
9050
- var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
9051
- function secretsPath() {
9052
- return join12(cabaneDir(), "secrets.json");
9053
- }
9054
- var secretStoreSchema = z16.record(z16.string(), z16.string());
9055
- function loadSecretStore() {
9056
- const path = secretsPath();
9057
- if (!existsSync8(path)) return makeStore({});
9058
- let raw;
9059
- try {
9060
- raw = readFileSync7(path, "utf8");
9061
- } catch (err) {
9062
- throw new ConfigError(
9063
- `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
9064
- );
9065
- }
9066
- if (raw.trim().length === 0) return makeStore({});
9067
- let parsed;
9068
- try {
9069
- parsed = JSON.parse(raw);
9070
- } catch (err) {
9071
- throw new ConfigError(
9072
- `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
9073
- );
9074
- }
9075
- const result = secretStoreSchema.safeParse(parsed);
9076
- if (!result.success) {
9077
- throw new ConfigError(
9078
- `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
9079
- );
9080
- }
9081
- return makeStore(result.data);
9082
- }
9083
- function loadSecretStoreTolerant(onWarn) {
9084
- try {
9085
- return loadSecretStore();
9086
- } catch (err) {
9087
- onWarn?.(err instanceof Error ? err.message : String(err));
9088
- return makeStore({});
9089
- }
9090
- }
9091
- function makeStore(map) {
9092
- return {
9093
- get: (name) => map[name],
9094
- names: () => Object.keys(map).sort()
9095
- };
9450
+ // src/transcript-writer.ts
9451
+ import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
9452
+ import { basename, dirname as dirname6, join as join13 } from "path";
9453
+ function transcriptsDir() {
9454
+ return join13(runnerStateDir(), "transcripts");
9096
9455
  }
9097
- function requiredSecretNames(mcpServers) {
9098
- const names = /* @__PURE__ */ new Set();
9099
- for (const server of Object.values(mcpServers ?? {})) {
9100
- const bags = [
9101
- "env" in server ? server.env : void 0,
9102
- "headers" in server ? server.headers : void 0
9103
- ];
9104
- for (const bag of bags) {
9105
- for (const value of Object.values(bag ?? {})) {
9106
- for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
9456
+ var RETAIN = 200;
9457
+ var ANOMALY_RETAIN = 50;
9458
+ var TranscriptWriter = class {
9459
+ path;
9460
+ broken = false;
9461
+ onWarn;
9462
+ constructor(dir2, meta, onWarn) {
9463
+ this.onWarn = onWarn;
9464
+ this.path = join13(dir2, fileName(meta));
9465
+ try {
9466
+ mkdirSync8(dir2, { recursive: true });
9467
+ try {
9468
+ chmodSync4(dir2, 448);
9469
+ } catch {
9107
9470
  }
9471
+ pruneOld(dir2, RETAIN);
9472
+ } catch (err) {
9473
+ this.fail(err);
9108
9474
  }
9109
- }
9110
- return [...names].sort();
9111
- }
9112
- function resolveMcpSecrets(mcpServers, store) {
9113
- const missing = /* @__PURE__ */ new Set();
9114
- const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
9115
- const resolved = store.get(name);
9116
- if (resolved === void 0) {
9117
- missing.add(name);
9118
- return _match;
9119
- }
9120
- return resolved;
9121
- });
9122
- const mapBag = (bag) => {
9123
- const out = {};
9124
- for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
9125
- return out;
9126
- };
9127
- const servers = {};
9128
- for (const [name, server] of Object.entries(mcpServers)) {
9129
- const next = { ...server };
9130
- if ("env" in next && next.env) next.env = mapBag(next.env);
9131
- if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
9132
- servers[name] = next;
9133
- }
9134
- return { servers, missing: [...missing].sort() };
9135
- }
9136
-
9137
- // src/transcript-writer.ts
9138
- import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
9139
- import { basename, dirname as dirname6, join as join13 } from "path";
9140
- function transcriptsDir() {
9141
- return join13(runnerStateDir(), "transcripts");
9142
- }
9143
- var RETAIN = 200;
9144
- var ANOMALY_RETAIN = 50;
9145
- var TranscriptWriter = class {
9146
- path;
9147
- broken = false;
9148
- onWarn;
9149
- constructor(dir2, meta, onWarn) {
9150
- this.onWarn = onWarn;
9151
- this.path = join13(dir2, fileName(meta));
9152
- try {
9153
- mkdirSync8(dir2, { recursive: true });
9154
- try {
9155
- chmodSync4(dir2, 448);
9156
- } catch {
9157
- }
9158
- pruneOld(dir2, RETAIN);
9159
- } catch (err) {
9160
- this.fail(err);
9161
- }
9162
- this.line({ type: "_meta", ...meta });
9475
+ this.line({ type: "_meta", ...meta });
9163
9476
  }
9164
9477
  // Append a raw SDK message verbatim.
9165
9478
  write(msg) {
@@ -9437,17 +9750,6 @@ async function absorbBundledLoss(ctx, aborted, outcome) {
9437
9750
 
9438
9751
  // src/turn-execution.ts
9439
9752
  var PREPARING_TOOL_NAME = "preparing";
9440
- var ERROR_REASON_MAX = 200;
9441
- function missingSecretReason(missing) {
9442
- const prefix = "missing_secret: ";
9443
- for (let shown = missing.length; shown >= 0; shown--) {
9444
- const omitted = missing.length - shown;
9445
- const parts = [...missing.slice(0, shown), ...omitted > 0 ? [`+${omitted}`] : []];
9446
- const reason = prefix + parts.join(",");
9447
- if (reason.length <= ERROR_REASON_MAX) return reason;
9448
- }
9449
- return `${prefix}+${missing.length}`;
9450
- }
9451
9753
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
9452
9754
  var NO_TURN_TOKEN_REASON = "no_turn_token: the server issued no turn credential for this turn";
9453
9755
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
@@ -9741,6 +10043,12 @@ var TurnExecution = class {
9741
10043
  this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
9742
10044
  if (this.resumedSpoke) turnLog.debug({ turnId }, "dispatcher: resumed span already spoke");
9743
10045
  }
10046
+ try {
10047
+ await this.opts.inRunner?.onContext?.(turnContext.attemptId ?? null);
10048
+ } catch (err) {
10049
+ const reason = `runner_unavailable: ${err instanceof Error ? err.message : String(err)}`;
10050
+ throw this.concluded(reason, reason);
10051
+ }
9744
10052
  }
9745
10053
  gateTrigger() {
9746
10054
  const { payload, turnLog } = this;
@@ -9758,7 +10066,8 @@ var TurnExecution = class {
9758
10066
  }
9759
10067
  async resolveSecrets() {
9760
10068
  const { payload, workspaceId, turnLog } = this;
9761
- const secretStore = this.opts.secretStore ?? loadSecretStoreTolerant(
10069
+ const fromLauncher = this.opts.inRunner?.secrets;
10070
+ const secretStore = this.opts.secretStore ?? (fromLauncher ? await fromLauncher(requiredSecretNames(this.turnContext.mcpServers)) : null) ?? loadSecretStoreTolerant(
9762
10071
  (m) => turnLog.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
9763
10072
  );
9764
10073
  const { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
@@ -9918,42 +10227,48 @@ var TurnExecution = class {
9918
10227
  const { payload, workspaceId, turnId, turnLog } = this;
9919
10228
  const abortController = this.abortController;
9920
10229
  this.supervisor.registerAbort(turnId, abortController);
10230
+ let started;
9921
10231
  try {
9922
- await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
9923
- activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
9924
- // CT1679: a runner leases under its launcher's boot id — the one the
9925
- // device heartbeats — or the lost-run sweep reads it as a restart.
9926
- bootId: this.opts.inRunner?.bootId ?? bootId,
9927
- // CT1680: the runner holding the span, beside the device's boot id.
9928
- ...this.opts.inRunner ? { runnerId: this.opts.inRunner.runnerId } : {},
9929
- // CT1380: the durable record that this turn resumed, and from where.
9930
- ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
9931
- // CT1454: the issuance the context THIS attempt runs on came from. Read
9932
- // off `this.turnContext`, which is the fetch that composed this request —
9933
- // so a retransmitted start echoes the same id and reuses the same record,
9934
- // and a resumed attempt echoes its own, newer one. The post-admission
9935
- // fresh-session recompose (`freshRequest`) deliberately does not touch it:
9936
- // it adopts only the prompt, never the run-config, so its issuance must
9937
- // not become this attempt's evidence.
9938
- ...this.turnContext.issuanceId ? { issuanceId: this.turnContext.issuanceId } : {},
9939
- // CT33: hand the server this turn's id so the new-run chokepoint's
9940
- // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
9941
- // already emitted a "preparing" activity row for this turn above (it
9942
- // runs before this flip), which would otherwise make the sweep mistake
9943
- // this live turn for an abandoned one and close it with a `stopped`.
9944
- turnId,
9945
- // The newest message this turn's read covered: the lease records it,
9946
- // and settle moves the cursor to it. Omitted when the server sent
9947
- // none (an older API), so the cursor stays where it was.
9948
- ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
9949
- ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
9950
- // Rooms: the attempt this request's session belongs to (null = fresh).
9951
- ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {},
9952
- // CT1543: the frame this start acts on. The turn-context fetch already
9953
- // checked it, but that was before an assembly this turn awaited — the
9954
- // lease checks it again where the stamp is actually taken.
9955
- ...payload.episode !== void 0 ? { dispatchMessageId: payload.messageId, dispatchEpisode: payload.episode } : {}
9956
- });
10232
+ started = await this.opts.api.setActiveRun(
10233
+ workspaceId,
10234
+ payload.conversationId,
10235
+ payload.agentId,
10236
+ {
10237
+ activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
10238
+ // CT1679: a runner leases under its launcher's boot id — the one the
10239
+ // device heartbeats — or the lost-run sweep reads it as a restart.
10240
+ bootId: this.opts.inRunner?.bootId ?? bootId,
10241
+ // CT1680: the runner holding the span, beside the device's boot id.
10242
+ ...this.opts.inRunner ? { runnerId: this.opts.inRunner.runnerId } : {},
10243
+ // CT1380: the durable record that this turn resumed, and from where.
10244
+ ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
10245
+ // CT1454: the issuance the context THIS attempt runs on came from. Read
10246
+ // off `this.turnContext`, which is the fetch that composed this request —
10247
+ // so a retransmitted start echoes the same id and reuses the same record,
10248
+ // and a resumed attempt echoes its own, newer one. The post-admission
10249
+ // fresh-session recompose (`freshRequest`) deliberately does not touch it:
10250
+ // it adopts only the prompt, never the run-config, so its issuance must
10251
+ // not become this attempt's evidence.
10252
+ ...this.turnContext.issuanceId ? { issuanceId: this.turnContext.issuanceId } : {},
10253
+ // CT33: hand the server this turn's id so the new-run chokepoint's
10254
+ // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
10255
+ // already emitted a "preparing" activity row for this turn above (it
10256
+ // runs before this flip), which would otherwise make the sweep mistake
10257
+ // this live turn for an abandoned one and close it with a `stopped`.
10258
+ turnId,
10259
+ // The newest message this turn's read covered: the lease records it,
10260
+ // and settle moves the cursor to it. Omitted when the server sent
10261
+ // none (an older API), so the cursor stays where it was.
10262
+ ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
10263
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
10264
+ // Rooms: the attempt this request's session belongs to (null = fresh).
10265
+ ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {},
10266
+ // CT1543: the frame this start acts on. The turn-context fetch already
10267
+ // checked it, but that was before an assembly this turn awaited — the
10268
+ // lease checks it again where the stamp is actually taken.
10269
+ ...payload.episode !== void 0 ? { dispatchMessageId: payload.messageId, dispatchEpisode: payload.episode } : {}
10270
+ }
10271
+ );
9957
10272
  } catch (err) {
9958
10273
  if (leaseRefusal(err) === "dispatch_context_stale" && !this.recomposedForLease) {
9959
10274
  this.recomposedForLease = true;
@@ -9978,6 +10293,7 @@ var TurnExecution = class {
9978
10293
  throw this.concluded(reason, reason);
9979
10294
  }
9980
10295
  this.admitted = true;
10296
+ if (started?.attemptId) this.opts.inRunner?.onLeased?.(started.attemptId);
9981
10297
  }
9982
10298
  buildRequest() {
9983
10299
  const { payload, workspaceId } = this;
@@ -10022,7 +10338,18 @@ var TurnExecution = class {
10022
10338
  const { payload, workspaceId, turnId, turnLog } = this;
10023
10339
  await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
10024
10340
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
10025
- const sandbox = this.opts.harnessSandbox ? await this.opts.harnessSandbox({ cwd: this.effectiveCwd, log: turnLog }) : null;
10341
+ let sandbox = null;
10342
+ try {
10343
+ sandbox = this.opts.harnessSandbox ? await this.opts.harnessSandbox({
10344
+ cwd: this.effectiveCwd,
10345
+ runtime: this.turnContext.runtime,
10346
+ log: turnLog
10347
+ }) : null;
10348
+ } catch (err) {
10349
+ if (!(err instanceof HarnessSandboxRefused)) throw err;
10350
+ const reason = `setup_failed: ${err.message}`.slice(0, 200);
10351
+ throw this.concluded(reason, reason);
10352
+ }
10026
10353
  const adapters = [];
10027
10354
  if (this.opts.claudeCodeAvailable?.() ?? true) {
10028
10355
  this.containment = createTurnContainment(this.turnId, {
@@ -10741,6 +11068,1254 @@ function createLocalProcessProvider(opts = {}) {
10741
11068
  };
10742
11069
  }
10743
11070
 
11071
+ // src/providers/docker.ts
11072
+ import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
11073
+ import { constants as constants2, tmpdir as tmpdir2 } from "os";
11074
+ import {
11075
+ createReadStream,
11076
+ createWriteStream as createWriteStream2,
11077
+ existsSync as existsSync13,
11078
+ mkdirSync as mkdirSync10,
11079
+ readdirSync as readdirSync4,
11080
+ readFileSync as readFileSync9,
11081
+ renameSync as renameSync5,
11082
+ rmSync as rmSync9,
11083
+ statSync as statSync3,
11084
+ writeFileSync as writeFileSync6
11085
+ } from "fs";
11086
+ import { join as join17 } from "path";
11087
+ import { Writable } from "stream";
11088
+
11089
+ // src/providers/docker-cli.ts
11090
+ import { spawn as spawn7 } from "child_process";
11091
+ function cliEnv(extra) {
11092
+ const keep = [
11093
+ "PATH",
11094
+ "HOME",
11095
+ "DOCKER_HOST",
11096
+ "DOCKER_CONFIG",
11097
+ "DOCKER_CONTEXT",
11098
+ "DOCKER_CERT_PATH"
11099
+ ];
11100
+ const env = {};
11101
+ for (const k of keep) if (process.env[k] !== void 0) env[k] = process.env[k];
11102
+ return { ...env, ...extra };
11103
+ }
11104
+ function dockerCli(bin = "docker") {
11105
+ return {
11106
+ run(args, opts = {}) {
11107
+ return new Promise((resolve2) => {
11108
+ let child;
11109
+ try {
11110
+ child = spawn7(bin, [...args], {
11111
+ env: cliEnv(opts.env),
11112
+ stdio: ["pipe", "pipe", "pipe"]
11113
+ });
11114
+ } catch (err) {
11115
+ resolve2({ code: 127, stdout: "", stderr: String(err) });
11116
+ return;
11117
+ }
11118
+ let stdout = "";
11119
+ let stderr = "";
11120
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : void 0;
11121
+ if (opts.stdout) child.stdout?.pipe(opts.stdout);
11122
+ else child.stdout?.on("data", (chunk) => stdout += String(chunk));
11123
+ child.stderr?.on("data", (chunk) => stderr += String(chunk));
11124
+ child.stdin?.on("error", () => {
11125
+ });
11126
+ child.once("error", (err) => {
11127
+ if (timer) clearTimeout(timer);
11128
+ resolve2({ code: 127, stdout, stderr: stderr || err.message });
11129
+ });
11130
+ child.once("close", (code, signal) => {
11131
+ if (timer) clearTimeout(timer);
11132
+ resolve2({ code: code ?? (signal ? 137 : 1), stdout, stderr });
11133
+ });
11134
+ const input = opts.input;
11135
+ if (input === void 0) child.stdin?.end();
11136
+ else if (typeof input === "string" || Buffer.isBuffer(input)) child.stdin?.end(input);
11137
+ else input.pipe(child.stdin);
11138
+ });
11139
+ },
11140
+ spawn(args, env) {
11141
+ return spawn7(bin, [...args], { env: cliEnv(env), stdio: ["pipe", "pipe", "pipe"] });
11142
+ }
11143
+ };
11144
+ }
11145
+
11146
+ // src/runner/git-credential.ts
11147
+ import { spawn as spawn8 } from "child_process";
11148
+ import { chmodSync as chmodSync5, rmSync as rmSync7 } from "fs";
11149
+ import { connect as connect3, createServer as createServer3 } from "net";
11150
+
11151
+ // src/runner/egress-proxy.ts
11152
+ import { createServer as createServer2, request as httpRequest } from "http";
11153
+ import { connect as connect2 } from "net";
11154
+ function egressAllowed(host, allow, port, defaultPort = 443) {
11155
+ const h = host.toLowerCase().replace(/\.$/, "");
11156
+ if (!h) return false;
11157
+ const p = port ?? defaultPort;
11158
+ return allow.some((entry) => {
11159
+ const raw = entry.toLowerCase().trim();
11160
+ const m = /^(.*):(\d{1,5})$/.exec(raw);
11161
+ const name = m ? m[1] : raw;
11162
+ const wanted = m ? Number(m[2]) : defaultPort;
11163
+ if (p !== wanted) return false;
11164
+ if (name.startsWith("*.")) return h.endsWith(name.slice(1));
11165
+ return h === name;
11166
+ });
11167
+ }
11168
+
11169
+ // src/runner/git-credential.ts
11170
+ function parseCredential(text) {
11171
+ const out = {};
11172
+ for (const line of text.split("\n")) {
11173
+ const eq = line.indexOf("=");
11174
+ if (eq <= 0) continue;
11175
+ out[line.slice(0, eq)] = line.slice(eq + 1);
11176
+ }
11177
+ return out;
11178
+ }
11179
+ function formatCredential(fields) {
11180
+ return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join("\n") + "\n\n";
11181
+ }
11182
+ function deviceGitFill(helper, timeoutMs = 15e3) {
11183
+ return (request) => new Promise((resolve2) => {
11184
+ const args = [
11185
+ ...helper ? ["-c", "credential.helper=", "-c", `credential.helper=${helper}`] : [],
11186
+ "credential",
11187
+ "fill"
11188
+ ];
11189
+ const child = spawn8("git", args, {
11190
+ stdio: ["pipe", "pipe", "ignore"],
11191
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" }
11192
+ });
11193
+ let out = "";
11194
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
11195
+ child.stdout.on("data", (chunk) => out += String(chunk));
11196
+ child.once("error", () => resolve2(null));
11197
+ child.once("close", (code) => {
11198
+ clearTimeout(timer);
11199
+ resolve2(code === 0 ? out : null);
11200
+ });
11201
+ child.stdin.end(formatCredential(request));
11202
+ });
11203
+ }
11204
+ async function serveGitCredentials(opts) {
11205
+ const { socketPath, hosts, fill, log } = opts;
11206
+ rmSync7(socketPath, { force: true });
11207
+ const server = createServer3({ allowHalfOpen: true }, (socket) => {
11208
+ let input = "";
11209
+ socket.setEncoding("utf8");
11210
+ socket.on("data", (chunk) => input += chunk);
11211
+ socket.on("error", () => socket.destroy());
11212
+ socket.on("end", () => {
11213
+ void (async () => {
11214
+ const [op = "", ...rest] = input.split("\n");
11215
+ const request = parseCredential(rest.join("\n"));
11216
+ if (op !== "get" || request.protocol !== "https" || !request.host) {
11217
+ socket.end();
11218
+ return;
11219
+ }
11220
+ if (!egressAllowed(request.host.replace(/:\d+$/, ""), hosts)) {
11221
+ log?.warn(
11222
+ { host: request.host },
11223
+ "runner: git asked for a credential for a host it may not use"
11224
+ );
11225
+ socket.end();
11226
+ return;
11227
+ }
11228
+ const answer = await fill({
11229
+ protocol: "https",
11230
+ host: request.host,
11231
+ ...request.path ? { path: request.path } : {},
11232
+ ...request.username ? { username: request.username } : {}
11233
+ });
11234
+ if (!answer) {
11235
+ log?.warn(
11236
+ { host: request.host },
11237
+ "runner: this device's git has no credential for the host"
11238
+ );
11239
+ socket.end();
11240
+ return;
11241
+ }
11242
+ const filled = parseCredential(answer);
11243
+ socket.end(
11244
+ formatCredential({
11245
+ protocol: "https",
11246
+ host: request.host,
11247
+ ...filled.username ? { username: filled.username } : {},
11248
+ ...filled.password ? { password: filled.password } : {}
11249
+ })
11250
+ );
11251
+ })();
11252
+ });
11253
+ });
11254
+ await new Promise((resolve2, reject) => {
11255
+ server.once("error", reject);
11256
+ server.listen(socketPath, () => {
11257
+ server.off("error", reject);
11258
+ resolve2();
11259
+ });
11260
+ });
11261
+ chmodSync5(socketPath, 438);
11262
+ return {
11263
+ close: () => new Promise((resolve2) => {
11264
+ server.close(() => resolve2());
11265
+ rmSync7(socketPath, { force: true });
11266
+ })
11267
+ };
11268
+ }
11269
+
11270
+ // src/runner/collected-outbox.ts
11271
+ import {
11272
+ copyFileSync as copyFileSync2,
11273
+ existsSync as existsSync11,
11274
+ mkdirSync as mkdirSync9,
11275
+ readdirSync as readdirSync3,
11276
+ readFileSync as readFileSync8,
11277
+ renameSync as renameSync4,
11278
+ rmSync as rmSync8
11279
+ } from "fs";
11280
+ import { join as join15 } from "path";
11281
+ function routes(t) {
11282
+ const conv = `/api/workspaces/${t.workspaceId}/conversations/${t.conversationId}`;
11283
+ const agent = `${conv}/participants/agents/${t.agentId}`;
11284
+ return {
11285
+ message: { method: "POST", path: `${conv}/messages` },
11286
+ activity: { method: "POST", path: `${agent}/activity` },
11287
+ thinking: { method: "POST", path: `${agent}/thinking` },
11288
+ text: { method: "POST", path: `${agent}/text` },
11289
+ "active-run": { method: "PATCH", path: `${agent}/active-run` }
11290
+ };
11291
+ }
11292
+ function admitCollectedEntry(raw, turn) {
11293
+ if (!raw || typeof raw !== "object") return null;
11294
+ const entry = raw;
11295
+ const kind = entry.kind;
11296
+ if (!kind || !(kind in routes(turn))) return null;
11297
+ const route = routes(turn)[kind];
11298
+ if (entry.method !== route.method || entry.path !== route.path) return null;
11299
+ if (typeof entry.seq !== "number" || typeof entry.enqueuedAt !== "number") return null;
11300
+ const body = entry.body;
11301
+ if (!body || typeof body !== "object" || typeof body.turnId !== "string") return null;
11302
+ if (!turn.turnIds.includes(body.turnId)) return null;
11303
+ if (kind === "active-run") {
11304
+ if (body.activeRunStartedAt !== null) return null;
11305
+ if (entry.turnId !== `activerun:${turn.conversationId}:${turn.agentId}`) return null;
11306
+ } else if (entry.turnId !== body.turnId) {
11307
+ return null;
11308
+ }
11309
+ return entry;
11310
+ }
11311
+ var atomicMove = (from, to) => {
11312
+ try {
11313
+ renameSync4(from, to);
11314
+ } catch {
11315
+ const partial = `${to}.partial`;
11316
+ copyFileSync2(from, partial);
11317
+ renameSync4(partial, to);
11318
+ rmSync8(from, { force: true });
11319
+ }
11320
+ };
11321
+ function publishStaged(from, to, move = atomicMove) {
11322
+ if (!existsSync11(from)) return { published: 0, complete: true };
11323
+ const staged = readdirSync3(from).filter((n) => n.endsWith(".json")).map((name) => {
11324
+ let entry = {};
11325
+ try {
11326
+ entry = JSON.parse(readFileSync8(join15(from, name), "utf8"));
11327
+ } catch {
11328
+ }
11329
+ return { name, entry };
11330
+ });
11331
+ const settle = (e) => e.kind === "active-run" ? 1 : 0;
11332
+ staged.sort(
11333
+ (a, b) => settle(a.entry) - settle(b.entry) || (a.entry.enqueuedAt ?? 0) - (b.entry.enqueuedAt ?? 0) || String(a.entry.turnId).localeCompare(String(b.entry.turnId)) || (a.entry.seq ?? 0) - (b.entry.seq ?? 0)
11334
+ );
11335
+ if (staged.length) mkdirSync9(to, { recursive: true });
11336
+ let published = 0;
11337
+ for (const { name } of staged) {
11338
+ try {
11339
+ move(join15(from, name), join15(to, name));
11340
+ } catch {
11341
+ return { published, complete: false };
11342
+ }
11343
+ published++;
11344
+ }
11345
+ rmSync8(from, { recursive: true, force: true });
11346
+ return { published, complete: true };
11347
+ }
11348
+
11349
+ // src/providers/runner-base.ts
11350
+ import { createHash as createHash3 } from "crypto";
11351
+ import { existsSync as existsSync12, realpathSync as realpathSync2 } from "fs";
11352
+ import { dirname as dirname7, join as join16, sep } from "path";
11353
+ import { fileURLToPath as fileURLToPath3 } from "url";
11354
+ var COMPANION_MOUNT = "/opt/cabane/companion";
11355
+ var BASE_DOCKERFILE = `FROM node:22-slim
11356
+ RUN apt-get update \\
11357
+ && apt-get install -y --no-install-recommends git curl ca-certificates procps \\
11358
+ && rm -rf /var/lib/apt/lists/* \\
11359
+ && corepack enable
11360
+ RUN usermod -l runner -d /home/runner -m node \\
11361
+ && groupmod -n runner node \\
11362
+ && mkdir -p /work /var/lib/cabane-runner /run/cabane ${COMPANION_MOUNT} \\
11363
+ && chown runner:runner /work /var/lib/cabane-runner
11364
+ RUN mkdir -p /home/runner/.codex /opt/cabane \\
11365
+ && printf '[features]\\nuse_legacy_landlock = true\\n' > /opt/cabane/codex-config.toml \\
11366
+ && cp /opt/cabane/codex-config.toml /home/runner/.codex/config.toml \\
11367
+ && chown -R runner:runner /home/runner/.codex
11368
+ RUN printf '#!/bin/sh\\nexec node "\${CABANE_COMPANION_ROOT:-${COMPANION_MOUNT}}/dist/cli.js" "$@"\\n' > /usr/local/bin/cabane-companion \\
11369
+ && chmod 755 /usr/local/bin/cabane-companion
11370
+ USER runner
11371
+ WORKDIR /home/runner
11372
+ CMD ["sleep", "infinity"]
11373
+ `;
11374
+ function baseImageTag(dockerfile = BASE_DOCKERFILE) {
11375
+ return `cabane-runner-base:${createHash3("sha256").update(dockerfile).digest("hex").slice(0, 12)}`;
11376
+ }
11377
+ async function ensureBaseImage(docker, image, log) {
11378
+ if ((await docker.run(["image", "inspect", image])).code === 0) return true;
11379
+ if (image !== baseImageTag()) {
11380
+ log.error({ image }, "runners: the configured runner image isn't on this engine");
11381
+ return false;
11382
+ }
11383
+ log.info({ image }, "runners: building the runner base image (once per base)");
11384
+ const built = await docker.run(["build", "-t", image, "-"], {
11385
+ input: BASE_DOCKERFILE,
11386
+ timeoutMs: 20 * 6e4
11387
+ });
11388
+ if (built.code !== 0) {
11389
+ log.error(
11390
+ { image, err: built.stderr.trim().slice(-2e3) },
11391
+ "runners: couldn't build the runner base image; no turn can run until it is"
11392
+ );
11393
+ return false;
11394
+ }
11395
+ return true;
11396
+ }
11397
+ function ownInstall(moduleFile = fileURLToPath3(import.meta.url)) {
11398
+ let dir2 = dirname7(moduleFile);
11399
+ for (let i = 0; i < 4; i++) {
11400
+ if (existsSync12(join16(dir2, "dist", "cli.js")) && existsSync12(join16(dir2, "package.json"))) break;
11401
+ dir2 = dirname7(dir2);
11402
+ }
11403
+ if (!existsSync12(join16(dir2, "dist", "cli.js"))) return null;
11404
+ const sdk = join16(dir2, "node_modules", "@anthropic-ai", "claude-agent-sdk");
11405
+ if (!existsSync12(sdk)) return null;
11406
+ const root = realpathSync2(dir2);
11407
+ return realpathSync2(sdk).startsWith(root + sep) ? root : null;
11408
+ }
11409
+ function companionSource(input) {
11410
+ if (input.configured) {
11411
+ return { kind: "dir", path: input.configured, key: `dir:${input.configured}` };
11412
+ }
11413
+ if (input.platform === "linux") {
11414
+ if (input.own)
11415
+ return { kind: "dir", path: input.own, key: `dir:${input.own}:${input.version}` };
11416
+ return {
11417
+ error: "this companion runs from a checkout, which a container can't mount on its own; build one with `pnpm --filter @cabane/companion deploy --prod <dir>` and set runners.docker.companionDir"
11418
+ };
11419
+ }
11420
+ if (input.version === "0.0.0") {
11421
+ return {
11422
+ error: "an unreleased companion can't be installed into a container from npm; set runners.docker.companionDir to a Linux install of it"
11423
+ };
11424
+ }
11425
+ return {
11426
+ kind: "volume",
11427
+ volume: `cabane-companion-${input.version}`,
11428
+ version: input.version,
11429
+ key: `volume:${input.version}`
11430
+ };
11431
+ }
11432
+ function companionRoot(source) {
11433
+ return source.kind === "dir" ? COMPANION_MOUNT : `${COMPANION_MOUNT}/lib/node_modules/@cabane/companion`;
11434
+ }
11435
+ function companionMountArgs(source) {
11436
+ const mount = source.kind === "dir" ? `type=bind,src=${source.path},dst=${COMPANION_MOUNT},readonly` : `type=volume,src=${source.volume},dst=${COMPANION_MOUNT},readonly`;
11437
+ return ["--mount", mount, "-e", `CABANE_COMPANION_ROOT=${companionRoot(source)}`];
11438
+ }
11439
+ var INSTALL_MARKER = `${COMPANION_MOUNT}/.cabane-install-complete`;
11440
+ function volumeInstallScript(version, install = `npm install -g --prefix ${COMPANION_MOUNT} @cabane/companion@${version}`) {
11441
+ const root = `${COMPANION_MOUNT}/lib/node_modules/@cabane/companion`;
11442
+ return [
11443
+ `find ${COMPANION_MOUNT} -mindepth 1 -delete`,
11444
+ install,
11445
+ // Installed is not the same as runnable: the package must start and say
11446
+ // what it runs before the volume counts as done.
11447
+ `node ${root}/dist/cli.js run-turn --manifest > /dev/null`,
11448
+ `printf %s ${version} > ${INSTALL_MARKER}`
11449
+ ].join(" && ");
11450
+ }
11451
+ var volumeMount = (source) => `type=volume,src=${source.volume},dst=${COMPANION_MOUNT}`;
11452
+ async function ensureCompanionVolume(docker, image, source, log, script = volumeInstallScript) {
11453
+ if (source.kind !== "volume") return true;
11454
+ const done = await docker.run([
11455
+ "run",
11456
+ "--rm",
11457
+ "--network",
11458
+ "none",
11459
+ "--mount",
11460
+ volumeMount(source),
11461
+ image,
11462
+ "sh",
11463
+ "-c",
11464
+ `[ "$(cat ${INSTALL_MARKER} 2>/dev/null)" = "$1" ]`,
11465
+ "sh",
11466
+ source.version
11467
+ ]);
11468
+ if (done.code === 0) return true;
11469
+ log.info(
11470
+ { version: source.version },
11471
+ "runners: installing this companion version for its containers"
11472
+ );
11473
+ const installed = await docker.run(
11474
+ [
11475
+ "run",
11476
+ "--rm",
11477
+ "-u",
11478
+ "root",
11479
+ "--mount",
11480
+ volumeMount(source),
11481
+ image,
11482
+ "sh",
11483
+ "-c",
11484
+ script(source.version)
11485
+ ],
11486
+ { timeoutMs: 20 * 6e4 }
11487
+ );
11488
+ if (installed.code !== 0) {
11489
+ log.error(
11490
+ { version: source.version, err: installed.stderr.trim().slice(-2e3) },
11491
+ "runners: couldn't install the companion for its containers; the next start tries again"
11492
+ );
11493
+ return false;
11494
+ }
11495
+ return true;
11496
+ }
11497
+ async function invalidateCompanionVolume(docker, image, source) {
11498
+ if (source.kind !== "volume") return;
11499
+ await docker.run([
11500
+ "run",
11501
+ "--rm",
11502
+ "-u",
11503
+ "root",
11504
+ "--network",
11505
+ "none",
11506
+ "--mount",
11507
+ volumeMount(source),
11508
+ image,
11509
+ "rm",
11510
+ "-f",
11511
+ INSTALL_MARKER
11512
+ ]);
11513
+ }
11514
+
11515
+ // src/providers/docker.ts
11516
+ var CONTAINER_HOME = "/home/runner";
11517
+ var CONTAINER_WORK = "/work";
11518
+ var CONTAINER_STATE = "/var/lib/cabane-runner";
11519
+ var CONTAINER_SOCKETS = "/run/cabane";
11520
+ var PROXY_ALIAS = "cabane-egress";
11521
+ var PROXY_PORT = 3128;
11522
+ var DEFAULT_KILL_GRACE_MS2 = 3e4;
11523
+ var REAP_GRACE_S = 3;
11524
+ var DEFAULT_SESSION_CAP_BYTES = 512 * 1024 * 1024;
11525
+ var COLLECT_CAP_BYTES = 128 * 1024 * 1024;
11526
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11527
+ function containerName(attempt) {
11528
+ return `cabane-runner-${attempt.conversationId}-${attempt.agentId}`;
11529
+ }
11530
+ function sessionArchivePath(deviceDir, attemptId) {
11531
+ if (!UUID_RE.test(attemptId)) throw new Error(`docker: not an attempt id: ${attemptId}`);
11532
+ return join17(deviceDir, "attempts", attemptId, "session.tar");
11533
+ }
11534
+ function pruneOtherAttempts(deviceDir, pair, keep) {
11535
+ const root = join17(deviceDir, "attempts");
11536
+ if (!existsSync13(root)) return;
11537
+ for (const id of readdirSync4(root)) {
11538
+ if (id === keep || !UUID_RE.test(id)) continue;
11539
+ let pairOf = "";
11540
+ try {
11541
+ pairOf = readFileSync9(join17(root, id, "pair"), "utf8");
11542
+ } catch {
11543
+ continue;
11544
+ }
11545
+ if (pairOf === `${pair.conversationId}/${pair.agentId}`) {
11546
+ rmSync9(join17(root, id), { recursive: true, force: true });
11547
+ }
11548
+ }
11549
+ }
11550
+ var CappedFile = class extends Writable {
11551
+ constructor(path, cap) {
11552
+ super();
11553
+ this.path = path;
11554
+ this.cap = cap;
11555
+ this.out = createWriteStream2(path);
11556
+ }
11557
+ path;
11558
+ cap;
11559
+ bytes = 0;
11560
+ over = false;
11561
+ out;
11562
+ _write(chunk, _enc, done) {
11563
+ this.bytes += chunk.length;
11564
+ if (this.bytes > this.cap) {
11565
+ this.over = true;
11566
+ done();
11567
+ return;
11568
+ }
11569
+ this.out.write(chunk, done);
11570
+ }
11571
+ _final(done) {
11572
+ this.out.end(done);
11573
+ }
11574
+ };
11575
+ var DUMP_SCRIPT = `
11576
+ const fs = require('fs'), path = require('path');
11577
+ const dir = process.argv[1], cap = Number(process.argv[2]);
11578
+ let names;
11579
+ try { names = fs.readdirSync(dir); }
11580
+ catch (e) {
11581
+ if (e.code === 'ENOENT') { process.stdout.write(JSON.stringify({ complete: true, files: [] })); process.exit(0); }
11582
+ process.stderr.write(String(e.message)); process.exit(2);
11583
+ }
11584
+ let total = 0, complete = true; const files = [];
11585
+ for (const name of names) {
11586
+ const p = path.join(dir, name);
11587
+ let st;
11588
+ try { st = fs.lstatSync(p); } catch (e) { process.stderr.write(String(e.message)); process.exit(2); }
11589
+ if (!st.isFile()) continue;
11590
+ // Always at least one file per read, so one over the cap is still collected.
11591
+ if (files.length && total + st.size > cap) { complete = false; continue; }
11592
+ let data;
11593
+ try { data = fs.readFileSync(p); } catch (e) { process.stderr.write(String(e.message)); process.exit(2); }
11594
+ total += st.size;
11595
+ files.push({ name, data: data.toString('base64') });
11596
+ }
11597
+ process.stdout.write(JSON.stringify({ complete, files }));
11598
+ `;
11599
+ var SAFE_NAME_RE = /^[\w.%@+-]+$/;
11600
+ function createDockerProvider(opts) {
11601
+ const docker = opts.docker ?? dockerCli();
11602
+ const log = opts.log;
11603
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS2;
11604
+ const sessionCap = opts.sessionCapBytes ?? DEFAULT_SESSION_CAP_BYTES;
11605
+ const collectCap = opts.collectCapBytes ?? COLLECT_CAP_BYTES;
11606
+ const network = `cabane-runners-${opts.deviceKey}`;
11607
+ const companionLabel = createHash4("sha256").update(opts.companion.key).digest("hex").slice(0, 16);
11608
+ const proxy = `cabane-egress-${opts.deviceKey}`;
11609
+ const held = /* @__PURE__ */ new Map();
11610
+ const queues = /* @__PURE__ */ new Map();
11611
+ const must = async (args, what) => {
11612
+ const r = await docker.run(args);
11613
+ if (r.code !== 0)
11614
+ throw new Error(`docker: ${what} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
11615
+ return r.stdout;
11616
+ };
11617
+ const execIn = (name, args, extra = {}) => docker.run(
11618
+ ["exec", ...extra.input !== void 0 ? ["-i"] : [], "-u", "runner", name, ...args],
11619
+ extra
11620
+ );
11621
+ const proxyListening = async () => {
11622
+ for (let i = 0; i < 40; i++) {
11623
+ const r = await docker.run([
11624
+ "exec",
11625
+ proxy,
11626
+ "curl",
11627
+ "-s",
11628
+ "-o",
11629
+ "/dev/null",
11630
+ "--max-time",
11631
+ "1",
11632
+ `http://127.0.0.1:${PROXY_PORT}/`
11633
+ ]);
11634
+ if (r.code === 0) return;
11635
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
11636
+ }
11637
+ throw new Error("docker: the egress proxy never started listening");
11638
+ };
11639
+ let networkReady = null;
11640
+ const proxyKey = async () => {
11641
+ const id = (await docker.run(["image", "inspect", "-f", "{{.Id}}", opts.image])).stdout.trim();
11642
+ return createHash4("sha256").update(JSON.stringify([id || opts.image, opts.companion.key, [...opts.egressAllow].sort()])).digest("hex").slice(0, 16);
11643
+ };
11644
+ const ensureNetwork = () => {
11645
+ networkReady ??= (async () => {
11646
+ const allowKey = await proxyKey();
11647
+ if ((await docker.run(["network", "inspect", network])).code !== 0) {
11648
+ await must(
11649
+ [
11650
+ "network",
11651
+ "create",
11652
+ // No route off the host, and no address of the host's on the
11653
+ // bridge: the host's own ports are out of reach too. The proxy is
11654
+ // the only other thing on the network.
11655
+ "--internal",
11656
+ "-o",
11657
+ "com.docker.network.bridge.inhibit_ipv4=true",
11658
+ "--label",
11659
+ `cabane.device=${opts.deviceKey}`,
11660
+ network
11661
+ ],
11662
+ "creating the runners network"
11663
+ );
11664
+ }
11665
+ const seen = await docker.run([
11666
+ "inspect",
11667
+ "-f",
11668
+ '{{index .Config.Labels "cabane.allow"}} {{.State.Running}}',
11669
+ proxy
11670
+ ]);
11671
+ const [key, running] = seen.stdout.trim().split(" ");
11672
+ if (seen.code === 0 && key === allowKey) {
11673
+ if (running !== "true") {
11674
+ await must(["start", proxy], "starting the egress proxy");
11675
+ await proxyListening();
11676
+ }
11677
+ return;
11678
+ }
11679
+ if (seen.code === 0) await docker.run(["rm", "-f", proxy]);
11680
+ await must(
11681
+ [
11682
+ "run",
11683
+ "-d",
11684
+ "--name",
11685
+ proxy,
11686
+ "--label",
11687
+ `cabane.allow=${allowKey}`,
11688
+ "--label",
11689
+ `cabane.device=${opts.deviceKey}`,
11690
+ "--restart",
11691
+ "unless-stopped",
11692
+ "--cap-drop",
11693
+ "ALL",
11694
+ "--security-opt",
11695
+ "no-new-privileges",
11696
+ "-e",
11697
+ `CABANE_EGRESS_ALLOW=${JSON.stringify(opts.egressAllow)}`,
11698
+ ...companionMountArgs(opts.companion),
11699
+ opts.image,
11700
+ "cabane-companion",
11701
+ "egress-proxy",
11702
+ "--port",
11703
+ String(PROXY_PORT)
11704
+ ],
11705
+ "starting the egress proxy"
11706
+ );
11707
+ await must(
11708
+ ["network", "connect", "--alias", PROXY_ALIAS, network, proxy],
11709
+ "connecting the proxy"
11710
+ );
11711
+ await proxyListening();
11712
+ log.info({ network, proxy, allow: opts.egressAllow }, "runners: egress proxy ready");
11713
+ })().catch((err) => {
11714
+ networkReady = null;
11715
+ throw err;
11716
+ });
11717
+ return networkReady;
11718
+ };
11719
+ const socketRoot = opts.socketRoot ?? join17(
11720
+ process.env.XDG_RUNTIME_DIR && existsSync13(process.env.XDG_RUNTIME_DIR) ? process.env.XDG_RUNTIME_DIR : tmpdir2(),
11721
+ `cabane-runners-${opts.deviceKey}`
11722
+ );
11723
+ const socketDir = (name) => join17(socketRoot, createHash4("sha256").update(name).digest("hex").slice(0, 12));
11724
+ const create = async (name, attempt) => {
11725
+ mkdirSync10(socketRoot, { recursive: true, mode: 448 });
11726
+ mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
11727
+ const mounts = (opts.mounts ?? []).filter((m) => m.source.startsWith("/") && existsSync13(m.source)).flatMap((m) => ["--mount", `type=bind,src=${m.source},dst=${m.target},readonly`]);
11728
+ await must(
11729
+ [
11730
+ "create",
11731
+ "--name",
11732
+ name,
11733
+ "--init",
11734
+ "--hostname",
11735
+ "cabane-runner",
11736
+ "--label",
11737
+ "cabane.runner=1",
11738
+ "--label",
11739
+ `cabane.device=${opts.deviceKey}`,
11740
+ "--label",
11741
+ `cabane.workspace=${attempt.workspaceId}`,
11742
+ "--label",
11743
+ `cabane.conversation=${attempt.conversationId}`,
11744
+ "--label",
11745
+ `cabane.agent=${attempt.agentId}`,
11746
+ "--label",
11747
+ `cabane.companion=${companionLabel}`,
11748
+ "--network",
11749
+ network,
11750
+ "--mount",
11751
+ `type=volume,src=${name}-home,dst=${CONTAINER_HOME}`,
11752
+ "--mount",
11753
+ `type=volume,src=${name}-work,dst=${CONTAINER_WORK}`,
11754
+ "--mount",
11755
+ `type=bind,src=${socketDir(name)},dst=${CONTAINER_SOCKETS},readonly`,
11756
+ ...mounts,
11757
+ ...companionMountArgs(opts.companion),
11758
+ "--cap-drop",
11759
+ "ALL",
11760
+ "--security-opt",
11761
+ "no-new-privileges",
11762
+ "--pids-limit",
11763
+ "4096",
11764
+ opts.image,
11765
+ "sleep",
11766
+ "infinity"
11767
+ ],
11768
+ "creating the container"
11769
+ );
11770
+ };
11771
+ const destroy = async (name) => {
11772
+ await docker.run(["rm", "-f", "-v", name]);
11773
+ await docker.run(["volume", "rm", "-f", `${name}-home`, `${name}-work`]);
11774
+ rmSync9(socketDir(name), { recursive: true, force: true });
11775
+ };
11776
+ const containerEnv = () => {
11777
+ const proxyUrl = `http://${PROXY_ALIAS}:${PROXY_PORT}`;
11778
+ const git = [
11779
+ ["credential.helper", ""],
11780
+ // The helper speaks a socket, not the network: no proxy, and none of
11781
+ // Node's warning about it in git's output.
11782
+ ["credential.helper", "!NODE_USE_ENV_PROXY= cabane-companion git-credential"],
11783
+ ...opts.gitHosts.flatMap((h) => [
11784
+ [`url.https://${h}/.insteadOf`, `git@${h}:`],
11785
+ [`url.https://${h}/.insteadOf`, `ssh://git@${h}/`]
11786
+ ])
11787
+ ];
11788
+ return {
11789
+ HOME: CONTAINER_HOME,
11790
+ CABANE_COMPANION_HOME: CONTAINER_STATE,
11791
+ CABANE_RUNNER_STATE_DIR: CONTAINER_STATE,
11792
+ HTTPS_PROXY: proxyUrl,
11793
+ HTTP_PROXY: proxyUrl,
11794
+ https_proxy: proxyUrl,
11795
+ http_proxy: proxyUrl,
11796
+ NO_PROXY: "localhost,127.0.0.1,::1",
11797
+ no_proxy: "localhost,127.0.0.1,::1",
11798
+ // Node's own fetch — the runner's wire — honours the proxy only when asked.
11799
+ NODE_USE_ENV_PROXY: "1",
11800
+ CABANE_GIT_CREDENTIAL_SOCKET: `${CONTAINER_SOCKETS}/git.sock`,
11801
+ GIT_TERMINAL_PROMPT: "0",
11802
+ GIT_CONFIG_COUNT: String(git.length),
11803
+ ...Object.fromEntries(
11804
+ git.flatMap(([k, v], i) => [
11805
+ [`GIT_CONFIG_KEY_${i}`, k],
11806
+ [`GIT_CONFIG_VALUE_${i}`, v]
11807
+ ])
11808
+ )
11809
+ };
11810
+ };
11811
+ const heldFor = (handle) => {
11812
+ const h = held.get(handle.id);
11813
+ if (!h) throw new Error(`docker: ${handle.id} is not held`);
11814
+ return h;
11815
+ };
11816
+ const writeMarker = (name, attemptId) => execIn(name, [
11817
+ "sh",
11818
+ "-c",
11819
+ `mkdir -p ${CONTAINER_STATE} && printf %s "$1" > ${CONTAINER_STATE}/attempt`,
11820
+ "sh",
11821
+ attemptId
11822
+ ]);
11823
+ const readDir = async (name, from) => {
11824
+ const r = await execIn(name, [
11825
+ "sh",
11826
+ "-c",
11827
+ '[ -e "$1" ] && chmod -R u+rwX "$1"; exec node -e "$2" "$1" "$3"',
11828
+ "sh",
11829
+ from,
11830
+ DUMP_SCRIPT,
11831
+ String(collectCap)
11832
+ ]);
11833
+ if (r.code !== 0) {
11834
+ log.warn(
11835
+ { container: name, dir: from, err: r.stderr.trim() },
11836
+ "runners: couldn't read what the runner left"
11837
+ );
11838
+ return null;
11839
+ }
11840
+ try {
11841
+ const dump = JSON.parse(r.stdout);
11842
+ if (typeof dump.complete !== "boolean" || !Array.isArray(dump.files)) return null;
11843
+ return {
11844
+ complete: dump.complete,
11845
+ files: dump.files.filter(
11846
+ (f) => typeof f.name === "string" && typeof f.data === "string"
11847
+ ).map((f) => ({ name: f.name, data: Buffer.from(f.data, "base64") }))
11848
+ };
11849
+ } catch {
11850
+ return null;
11851
+ }
11852
+ };
11853
+ const removeIn = (name, from, names) => names.length ? execIn(name, ["sh", "-c", 'cd "$1" && shift && rm -f -- "$@"', "sh", from, ...names]) : Promise.resolve(null);
11854
+ const pendingPath = (name) => join17(opts.deviceDir, "runners-pending", `${name}.json`);
11855
+ const readPending = (name) => {
11856
+ try {
11857
+ const ids = JSON.parse(readFileSync9(pendingPath(name), "utf8"));
11858
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
11859
+ } catch {
11860
+ return [];
11861
+ }
11862
+ };
11863
+ const writePending = (name, ids) => {
11864
+ if (!ids.length) {
11865
+ rmSync9(pendingPath(name), { force: true });
11866
+ return;
11867
+ }
11868
+ mkdirSync10(join17(opts.deviceDir, "runners-pending"), { recursive: true });
11869
+ writeFileSync6(pendingPath(name), JSON.stringify([...new Set(ids)]));
11870
+ };
11871
+ const keepSession = async (name, attempt, attemptId) => {
11872
+ const archive = sessionArchivePath(opts.deviceDir, attemptId);
11873
+ mkdirSync10(join17(archive, ".."), { recursive: true });
11874
+ const partial = `${archive}.${process.pid}.partial`;
11875
+ const file = new CappedFile(partial, sessionCap);
11876
+ const r = await execIn(
11877
+ name,
11878
+ [
11879
+ "sh",
11880
+ "-c",
11881
+ 'cd "$HOME" && set -- && for p in .claude/projects .codex; do [ -e "$p" ] && set -- "$@" "$p"; done; [ $# -gt 0 ] || exit 3; exec tar -cf - --exclude=.codex/auth.json "$@"'
11882
+ ],
11883
+ { stdout: file }
11884
+ );
11885
+ await new Promise(
11886
+ (resolve2) => file.writableFinished ? resolve2() : file.once("finish", resolve2)
11887
+ );
11888
+ if (r.code === 0 && !file.over) {
11889
+ renameSync5(partial, archive);
11890
+ writeFileSync6(join17(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
11891
+ pruneOtherAttempts(opts.deviceDir, attempt, attemptId);
11892
+ await writeMarker(name, attemptId);
11893
+ log.debug({ container: name, attemptId, bytes: file.bytes }, "runners: session files kept");
11894
+ return;
11895
+ }
11896
+ rmSync9(partial, { force: true });
11897
+ if (r.code === 3 && !file.over) return;
11898
+ dropArchive(attemptId);
11899
+ log.warn(
11900
+ file.over ? { container: name, attemptId, capBytes: sessionCap } : { container: name, attemptId, err: r.stderr.trim() },
11901
+ file.over ? "runners: the session files are over the size cap and weren't kept; the next new container starts the session over" : "runners: couldn't keep the session files; the next new container starts the session over"
11902
+ );
11903
+ };
11904
+ const stagingDir = (name) => join17(opts.deviceDir, "runners-pending", `${name}.outbox`);
11905
+ const ensureRunning = async (name) => {
11906
+ const seen = await docker.run(["inspect", "-f", "{{.State.Running}}", name]);
11907
+ if (seen.code !== 0) return false;
11908
+ if (seen.stdout.trim() === "true") return true;
11909
+ return (await docker.run(["start", name])).code === 0;
11910
+ };
11911
+ const dropArchive = (attemptId) => {
11912
+ rmSync9(join17(sessionArchivePath(opts.deviceDir, attemptId), ".."), {
11913
+ recursive: true,
11914
+ force: true
11915
+ });
11916
+ };
11917
+ const RETIRE_SCRIPT = [
11918
+ `chmod -R u+rwX /work ${CONTAINER_HOME}`,
11919
+ "find /work -mindepth 1 -delete",
11920
+ `find ${CONTAINER_HOME} -mindepth 1 -delete`,
11921
+ `cp -a /etc/skel/. ${CONTAINER_HOME}/`,
11922
+ `mkdir -p ${CONTAINER_HOME}/.codex`,
11923
+ `cp /opt/cabane/codex-config.toml ${CONTAINER_HOME}/.codex/config.toml`,
11924
+ `rm -rf ${CONTAINER_STATE}/prepared`
11925
+ ].join(" && ");
11926
+ const provider = {
11927
+ name: "docker",
11928
+ inheritsEnvironment: false,
11929
+ async acquire(attempt) {
11930
+ const name = containerName(attempt);
11931
+ const before = queues.get(name) ?? Promise.resolve();
11932
+ let release;
11933
+ const mine = new Promise((resolve2) => release = resolve2);
11934
+ const tail = before.then(() => mine);
11935
+ queues.set(name, tail);
11936
+ void tail.then(() => {
11937
+ if (queues.get(name) === tail) queues.delete(name);
11938
+ });
11939
+ await before;
11940
+ try {
11941
+ await ensureNetwork();
11942
+ mkdirSync10(socketRoot, { recursive: true, mode: 448 });
11943
+ mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
11944
+ let seen = await docker.run([
11945
+ "inspect",
11946
+ "-f",
11947
+ '{{.State.Running}} {{index .Config.Labels "cabane.companion"}}',
11948
+ name
11949
+ ]);
11950
+ if (seen.code === 0 && seen.stdout.trim().split(" ")[1] !== companionLabel) {
11951
+ held.set(name, { attempt, release: () => {
11952
+ }, collected: false });
11953
+ let collected = false;
11954
+ try {
11955
+ await provider.collect({ provider: "docker", id: name }, { attemptId: null });
11956
+ collected = held.get(name)?.collected === true;
11957
+ } finally {
11958
+ held.delete(name);
11959
+ }
11960
+ if (!collected)
11961
+ throw new Error(
11962
+ "docker: this conversation's container runs another companion and what it holds couldn't be collected"
11963
+ );
11964
+ await destroy(name);
11965
+ log.info(
11966
+ { container: name },
11967
+ "runners: replaced a container made with another companion"
11968
+ );
11969
+ seen = { code: 1, stdout: "", stderr: "" };
11970
+ }
11971
+ if (seen.code !== 0) {
11972
+ await create(name, attempt);
11973
+ log.debug({ container: name }, "runners: container created");
11974
+ }
11975
+ if (seen.stdout.trim().split(" ")[0] !== "true")
11976
+ await must(["start", name], "starting the container");
11977
+ const git = await serveGitCredentials({
11978
+ socketPath: join17(socketDir(name), "git.sock"),
11979
+ hosts: opts.gitHosts,
11980
+ fill: opts.gitFill,
11981
+ log
11982
+ });
11983
+ held.set(name, { attempt, release, git, collected: false });
11984
+ return { provider: "docker", id: name };
11985
+ } catch (err) {
11986
+ const keep = opts.keepContainers || readPending(name).length > 0;
11987
+ await (keep ? docker.run(["stop", "-t", "1", name]) : destroy(name)).catch(() => {
11988
+ });
11989
+ release();
11990
+ throw err;
11991
+ }
11992
+ },
11993
+ async bindAttempt(handle, attemptId) {
11994
+ const name = handle.id;
11995
+ const marker = await execIn(name, ["cat", `${CONTAINER_STATE}/attempt`]);
11996
+ const bound = marker.code === 0 ? marker.stdout.trim() : "";
11997
+ if (bound === (attemptId ?? "")) return;
11998
+ const { attempt } = heldFor(handle);
11999
+ pruneOtherAttempts(opts.deviceDir, attempt, attemptId ?? "");
12000
+ if (bound) {
12001
+ const wiped = await execIn(name, ["sh", "-c", RETIRE_SCRIPT]);
12002
+ if (wiped.code !== 0)
12003
+ throw new Error(`docker: couldn't clear the retired attempt: ${wiped.stderr.trim()}`);
12004
+ log.info(
12005
+ { container: name, retired: bound, attemptId },
12006
+ "runners: retired attempt cleared"
12007
+ );
12008
+ }
12009
+ if (attemptId && UUID_RE.test(attemptId)) {
12010
+ const archive = sessionArchivePath(opts.deviceDir, attemptId);
12011
+ if (existsSync13(archive)) {
12012
+ const r = await execIn(name, ["tar", "-x", "-C", CONTAINER_HOME, "-f", "-"], {
12013
+ input: createReadStream(archive)
12014
+ });
12015
+ if (r.code !== 0) {
12016
+ log.warn(
12017
+ { container: name, attemptId, err: r.stderr.trim() },
12018
+ "runners: couldn't restore the session files; the runtime will start its session over"
12019
+ );
12020
+ } else {
12021
+ log.info(
12022
+ { container: name, attemptId, bytes: statSync3(archive).size },
12023
+ "runners: session files restored"
12024
+ );
12025
+ }
12026
+ } else {
12027
+ log.info(
12028
+ { container: name, attemptId },
12029
+ "runners: no session files kept for this attempt on this device"
12030
+ );
12031
+ }
12032
+ }
12033
+ await writeMarker(name, attemptId ?? "");
12034
+ },
12035
+ exec(handle, argv, env, { signal, stdin }) {
12036
+ const name = handle.id;
12037
+ const pidFile = `/tmp/cabane-runner-${randomUUID5()}.pid`;
12038
+ const passed = { ...env, ...opts.runtimeEnv?.() ?? {} };
12039
+ const literal = containerEnv();
12040
+ const args = [
12041
+ "exec",
12042
+ "-i",
12043
+ "-u",
12044
+ "runner",
12045
+ "-w",
12046
+ CONTAINER_HOME,
12047
+ ...Object.entries(literal).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
12048
+ ...Object.keys(passed).flatMap((k) => ["-e", k]),
12049
+ name,
12050
+ "sh",
12051
+ "-c",
12052
+ 'echo $$ > "$0" && exec "$@"',
12053
+ pidFile,
12054
+ ...argv
12055
+ ];
12056
+ const child = docker.spawn(args, passed);
12057
+ child.stdin?.on("error", () => {
12058
+ });
12059
+ if (stdin) child.stdin?.write(stdin);
12060
+ let forceTimer;
12061
+ const stop = () => {
12062
+ void execIn(name, ["sh", "-c", 'kill -TERM "$(cat "$1")"', "sh", pidFile]);
12063
+ forceTimer = setTimeout(() => void docker.run(["kill", name]), killGraceMs);
12064
+ forceTimer.unref?.();
12065
+ };
12066
+ const exit = new Promise((resolve2) => {
12067
+ let settled = false;
12068
+ const done = (code) => {
12069
+ if (settled) return;
12070
+ settled = true;
12071
+ signal.removeEventListener("abort", stop);
12072
+ if (forceTimer) clearTimeout(forceTimer);
12073
+ resolve2(code);
12074
+ };
12075
+ child.once("error", () => done(1));
12076
+ child.once("close", (code, sig) => {
12077
+ const signum = sig ? constants2.signals[sig] : void 0;
12078
+ done(code ?? (signum !== void 0 ? 128 + signum : 1));
12079
+ });
12080
+ if (signal.aborted) stop();
12081
+ else signal.addEventListener("abort", stop, { once: true });
12082
+ });
12083
+ if (!child.stdout || !child.stderr || !child.stdin)
12084
+ throw new Error("docker: runner stdio missing");
12085
+ return {
12086
+ exit,
12087
+ ...child.pid !== void 0 ? { pid: child.pid } : {},
12088
+ stdout: child.stdout,
12089
+ stderr: child.stderr,
12090
+ stdin: child.stdin,
12091
+ // The runner is the only thing this user runs in the container, so
12092
+ // everything it left is everything the user has left.
12093
+ async reap() {
12094
+ await execIn(name, [
12095
+ "sh",
12096
+ "-c",
12097
+ `kill -TERM -1 2>/dev/null; sleep ${REAP_GRACE_S}; kill -KILL -1 2>/dev/null; true`
12098
+ ]);
12099
+ }
12100
+ };
12101
+ },
12102
+ async collect(handle, { attemptId }) {
12103
+ const name = handle.id;
12104
+ const held2 = heldFor(handle);
12105
+ const { attempt } = held2;
12106
+ const turnIds = [.../* @__PURE__ */ new Set([attempt.turnId, ...readPending(name)])];
12107
+ const running = await ensureRunning(name);
12108
+ if (attemptId && UUID_RE.test(attemptId)) {
12109
+ if (!running) {
12110
+ dropArchive(attemptId);
12111
+ log.warn(
12112
+ { container: name, attemptId },
12113
+ "runners: couldn't reach the container to keep the session files; the next turn starts the session over"
12114
+ );
12115
+ } else {
12116
+ await keepSession(name, attempt, attemptId);
12117
+ }
12118
+ }
12119
+ if (!running) {
12120
+ writePending(name, turnIds);
12121
+ return;
12122
+ }
12123
+ const transcripts = await readDir(name, `${CONTAINER_STATE}/transcripts`);
12124
+ if (transcripts) {
12125
+ const kept = transcripts.files.filter((f) => SAFE_NAME_RE.test(f.name));
12126
+ if (kept.length) mkdirSync10(join17(opts.hostStateDir, "transcripts"), { recursive: true });
12127
+ for (const f of kept) writeFileSync6(join17(opts.hostStateDir, "transcripts", f.name), f.data);
12128
+ await removeIn(
12129
+ name,
12130
+ `${CONTAINER_STATE}/transcripts`,
12131
+ kept.map((f) => f.name)
12132
+ );
12133
+ }
12134
+ const outboxRel = join17("outbox", encodeURIComponent(attempt.agentId));
12135
+ const outbox = await readDir(name, `${CONTAINER_STATE}/${outboxRel}`);
12136
+ let unpublished = false;
12137
+ if (outbox) {
12138
+ const scope = {
12139
+ workspaceId: attempt.workspaceId,
12140
+ conversationId: attempt.conversationId,
12141
+ agentId: attempt.agentId,
12142
+ turnIds
12143
+ };
12144
+ let queued = 0;
12145
+ let refused = 0;
12146
+ for (const f of outbox.files) {
12147
+ let entry = null;
12148
+ try {
12149
+ entry = admitCollectedEntry(JSON.parse(f.data.toString("utf8")), scope);
12150
+ } catch {
12151
+ entry = null;
12152
+ }
12153
+ if (!entry) {
12154
+ refused++;
12155
+ continue;
12156
+ }
12157
+ const staged = stagingDir(name);
12158
+ mkdirSync10(staged, { recursive: true });
12159
+ writeFileSync6(
12160
+ join17(staged, `${encodeURIComponent(entry.turnId)}__${entry.seq}.json`),
12161
+ `${JSON.stringify(entry)}
12162
+ `
12163
+ );
12164
+ }
12165
+ await removeIn(
12166
+ name,
12167
+ `${CONTAINER_STATE}/${outboxRel}`,
12168
+ outbox.files.map((f) => f.name)
12169
+ );
12170
+ if (outbox.complete) {
12171
+ const published = publishStaged(
12172
+ stagingDir(name),
12173
+ join17(opts.hostStateDir, outboxRel),
12174
+ opts.moveFile
12175
+ );
12176
+ queued = published.published;
12177
+ if (!published.complete) {
12178
+ unpublished = true;
12179
+ log.warn(
12180
+ { container: name },
12181
+ "runners: couldn't hand all of the runner's outbox to the companion; the rest waits for the next collection"
12182
+ );
12183
+ }
12184
+ }
12185
+ if (refused) {
12186
+ log.warn(
12187
+ { container: name, refused },
12188
+ "runners: refused outbox entries a runner's own credential couldn't have sent"
12189
+ );
12190
+ }
12191
+ if (queued) {
12192
+ log.info(
12193
+ { container: name, queued },
12194
+ "runners: delivering what the runner left in its outbox"
12195
+ );
12196
+ opts.onOutboxCollected?.(attempt.agentId);
12197
+ }
12198
+ }
12199
+ held2.collected = !!transcripts?.complete && !!outbox?.complete && !unpublished;
12200
+ writePending(name, held2.collected ? [] : turnIds);
12201
+ },
12202
+ async release(handle, { destroy: over }) {
12203
+ const h = held.get(handle.id);
12204
+ held.delete(handle.id);
12205
+ try {
12206
+ await h?.git?.close();
12207
+ if (h && !h.collected) {
12208
+ log.warn(
12209
+ { container: handle.id },
12210
+ "runners: kept the container: what its runner left couldn't be collected yet"
12211
+ );
12212
+ await docker.run(["stop", "-t", "10", handle.id]);
12213
+ } else if (over || !opts.keepContainers) await destroy(handle.id);
12214
+ else await docker.run(["stop", "-t", "10", handle.id]);
12215
+ } finally {
12216
+ h?.release();
12217
+ }
12218
+ }
12219
+ };
12220
+ return provider;
12221
+ }
12222
+
12223
+ // src/providers/docker-setup.ts
12224
+ import { createHash as createHash5 } from "crypto";
12225
+ var MODEL_HOSTS = [
12226
+ "api.anthropic.com",
12227
+ "api.openai.com",
12228
+ "chatgpt.com",
12229
+ "auth.openai.com"
12230
+ ];
12231
+ var GIT_HOSTS = ["github.com"];
12232
+ function dockerRunnerSettings(config) {
12233
+ const docker = config.runners?.docker ?? {};
12234
+ const apiUrl = docker.apiUrl ?? config.baseUrl;
12235
+ const apiHost = new URL(apiUrl).host;
12236
+ const egressAllow = [
12237
+ .../* @__PURE__ */ new Set([apiHost, ...MODEL_HOSTS, ...GIT_HOSTS, ...docker.egressAllow ?? []])
12238
+ ];
12239
+ return {
12240
+ image: docker.image ?? baseImageTag(),
12241
+ keepContainers: docker.keepContainers === true,
12242
+ apiUrl,
12243
+ egressAllow,
12244
+ gitHosts: [...GIT_HOSTS]
12245
+ };
12246
+ }
12247
+ function loopbackApi(apiUrl) {
12248
+ const host = new URL(apiUrl).hostname;
12249
+ return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
12250
+ }
12251
+ function deviceKey(config) {
12252
+ const seed = config.deviceId ?? config.baseUrl;
12253
+ return createHash5("sha256").update(seed).digest("hex").slice(0, 12);
12254
+ }
12255
+ function resolveRuntimeEnv(env, store, log) {
12256
+ const out = {};
12257
+ for (const [name, template] of Object.entries(env ?? {})) {
12258
+ const missing = [];
12259
+ const value = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, key) => {
12260
+ const v = store.get(key);
12261
+ if (v === void 0) missing.push(key);
12262
+ return v ?? "";
12263
+ });
12264
+ if (missing.length) {
12265
+ log?.warn({ name, missing }, "runners: a runner env value names secrets this device lacks");
12266
+ continue;
12267
+ }
12268
+ out[name] = value;
12269
+ }
12270
+ return out;
12271
+ }
12272
+ async function probeRunner(docker, image, source, log) {
12273
+ const r = await docker.run(
12274
+ [
12275
+ "run",
12276
+ "--rm",
12277
+ "--network",
12278
+ "none",
12279
+ ...companionMountArgs(source),
12280
+ image,
12281
+ "cabane-companion",
12282
+ "run-turn",
12283
+ "--manifest"
12284
+ ],
12285
+ { timeoutMs: 6e4 }
12286
+ );
12287
+ try {
12288
+ const manifest = JSON.parse(r.stdout.trim());
12289
+ log.info(
12290
+ { image, version: manifest.version, bundled: manifest.bundled },
12291
+ "runners: runner ready"
12292
+ );
12293
+ return manifest;
12294
+ } catch {
12295
+ log.error(
12296
+ { image, err: r.stderr.trim().slice(-2e3) },
12297
+ "runners: the companion didn't start in a runner container"
12298
+ );
12299
+ return null;
12300
+ }
12301
+ }
12302
+ function imageResolution(manifest, image, runtime) {
12303
+ const status = manifest?.bundled[runtime] ?? "platform_package_missing";
12304
+ return {
12305
+ runtime,
12306
+ status,
12307
+ path: status === "present" ? `${image} (bundled)` : null,
12308
+ packageName: null,
12309
+ platform: "linux",
12310
+ arch: "image"
12311
+ };
12312
+ }
12313
+ function containerHookPath(hostPath) {
12314
+ const name = hostPath.split("/").pop() || "hook";
12315
+ const tag = createHash5("sha256").update(hostPath).digest("hex").slice(0, 8);
12316
+ return `/opt/cabane/hooks/${tag}-${name}`;
12317
+ }
12318
+
10744
12319
  // src/runner/device-locks.ts
10745
12320
  var DeviceLocks = class {
10746
12321
  holders = /* @__PURE__ */ new Map();
@@ -10798,10 +12373,20 @@ var DeviceLocks = class {
10798
12373
 
10799
12374
  // src/runner/harness-sandbox.ts
10800
12375
  import { lookup } from "dns/promises";
10801
- import { chmodSync as chmodSync5, mkdirSync as mkdirSync9, mkdtempSync, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
12376
+ import {
12377
+ chmodSync as chmodSync6,
12378
+ mkdirSync as mkdirSync11,
12379
+ mkdtempSync,
12380
+ readFileSync as readFileSync10,
12381
+ realpathSync as realpathSync3,
12382
+ rmSync as rmSync10,
12383
+ writeFileSync as writeFileSync7
12384
+ } from "fs";
12385
+ import { createRequire as createRequire2 } from "module";
10802
12386
  import { isIP } from "net";
10803
- import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir2, userInfo as userInfo2 } from "os";
10804
- import { join as join15 } from "path";
12387
+ import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
12388
+ import { dirname as dirname8, join as join18 } from "path";
12389
+ import { fileURLToPath as fileURLToPath4 } from "url";
10805
12390
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
10806
12391
  function osSandboxUsable(log) {
10807
12392
  if (!SandboxManager.isSupportedPlatform()) {
@@ -10900,15 +12485,15 @@ var DispatchClaims = class {
10900
12485
 
10901
12486
  // src/outbox.ts
10902
12487
  import {
10903
- existsSync as existsSync11,
10904
- mkdirSync as mkdirSync10,
10905
- readdirSync as readdirSync3,
10906
- readFileSync as readFileSync8,
10907
- renameSync as renameSync4,
10908
- rmSync as rmSync8,
10909
- writeFileSync as writeFileSync7
12488
+ existsSync as existsSync14,
12489
+ mkdirSync as mkdirSync12,
12490
+ readdirSync as readdirSync5,
12491
+ readFileSync as readFileSync11,
12492
+ renameSync as renameSync6,
12493
+ rmSync as rmSync11,
12494
+ writeFileSync as writeFileSync8
10910
12495
  } from "fs";
10911
- import { join as join16 } from "path";
12496
+ import { join as join19 } from "path";
10912
12497
  var MAX_ENTRIES = 2e3;
10913
12498
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
10914
12499
  var Outbox = class {
@@ -10921,25 +12506,25 @@ var Outbox = class {
10921
12506
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
10922
12507
  // cases route writes at the right tmpdir.
10923
12508
  dir() {
10924
- return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
12509
+ return join19(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
10925
12510
  }
10926
12511
  fileFor(turnId, seq) {
10927
- return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
12512
+ return join19(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10928
12513
  }
10929
12514
  // Persist a commit for later draining. Atomic (temp file + rename) so a
10930
12515
  // concurrent `list()` never reads a half-written entry, then enforces the
10931
12516
  // per-workspace bounds.
10932
12517
  persist(entry) {
10933
12518
  const dir2 = this.dir();
10934
- mkdirSync10(dir2, { recursive: true });
12519
+ mkdirSync12(dir2, { recursive: true });
10935
12520
  const target = this.fileFor(entry.turnId, entry.seq);
10936
12521
  const tmp = `${target}.${process.pid}.tmp`;
10937
12522
  try {
10938
- writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
10939
- renameSync4(tmp, target);
12523
+ writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
12524
+ renameSync6(tmp, target);
10940
12525
  } catch (err) {
10941
12526
  try {
10942
- rmSync8(tmp, { force: true });
12527
+ rmSync11(tmp, { force: true });
10943
12528
  } catch {
10944
12529
  }
10945
12530
  this.log?.warn(
@@ -10956,19 +12541,19 @@ var Outbox = class {
10956
12541
  // wedging the drain.
10957
12542
  list() {
10958
12543
  const dir2 = this.dir();
10959
- if (!existsSync11(dir2)) return [];
12544
+ if (!existsSync14(dir2)) return [];
10960
12545
  let names;
10961
12546
  try {
10962
- names = readdirSync3(dir2);
12547
+ names = readdirSync5(dir2);
10963
12548
  } catch {
10964
12549
  return [];
10965
12550
  }
10966
12551
  const entries = [];
10967
12552
  for (const name of names) {
10968
12553
  if (!name.endsWith(".json")) continue;
10969
- const full = join16(dir2, name);
12554
+ const full = join19(dir2, name);
10970
12555
  try {
10971
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
12556
+ const parsed = JSON.parse(readFileSync11(full, "utf8"));
10972
12557
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
10973
12558
  entries.push(parsed);
10974
12559
  } else {
@@ -11002,8 +12587,8 @@ var Outbox = class {
11002
12587
  // different: it is positive evidence that nothing was ever queued here.
11003
12588
  maxSeqForTurn(turnId) {
11004
12589
  const dir2 = this.dir();
11005
- if (!existsSync11(dir2)) return 0;
11006
- const names = readdirSync3(dir2);
12590
+ if (!existsSync14(dir2)) return 0;
12591
+ const names = readdirSync5(dir2);
11007
12592
  const prefix = `${encodeURIComponent(turnId)}__`;
11008
12593
  let max = 0;
11009
12594
  for (const name of names) {
@@ -11016,15 +12601,15 @@ var Outbox = class {
11016
12601
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
11017
12602
  remove(turnId, seq) {
11018
12603
  try {
11019
- rmSync8(this.fileFor(turnId, seq), { force: true });
12604
+ rmSync11(this.fileFor(turnId, seq), { force: true });
11020
12605
  } catch {
11021
12606
  }
11022
12607
  }
11023
12608
  size() {
11024
12609
  const dir2 = this.dir();
11025
- if (!existsSync11(dir2)) return 0;
12610
+ if (!existsSync14(dir2)) return 0;
11026
12611
  try {
11027
- return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
12612
+ return readdirSync5(dir2).filter((n) => n.endsWith(".json")).length;
11028
12613
  } catch {
11029
12614
  return 0;
11030
12615
  }
@@ -11035,7 +12620,7 @@ var Outbox = class {
11035
12620
  "A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
11036
12621
  );
11037
12622
  try {
11038
- rmSync8(full, { force: true });
12623
+ rmSync11(full, { force: true });
11039
12624
  } catch {
11040
12625
  }
11041
12626
  }
@@ -11178,8 +12763,8 @@ function sleep3(ms) {
11178
12763
  }
11179
12764
 
11180
12765
  // src/version.ts
11181
- import { createRequire as createRequire2 } from "module";
11182
- var pkg = createRequire2(import.meta.url)("../package.json");
12766
+ import { createRequire as createRequire3 } from "module";
12767
+ var pkg = createRequire3(import.meta.url)("../package.json");
11183
12768
  var COMPANION_VERSION = pkg.version;
11184
12769
 
11185
12770
  // src/supervisor.ts
@@ -11282,8 +12867,27 @@ var CompanionSupervisor = class {
11282
12867
  if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
11283
12868
  if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
11284
12869
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
12870
+ if (this.dockerRunners()) {
12871
+ const { image } = dockerRunnerSettings(this.config);
12872
+ this.resolveBundled = (runtime) => imageResolution(this.runnerImage, image, runtime);
12873
+ }
11285
12874
  this.latchBootBundledState();
11286
12875
  }
12876
+ // CT1681: what a runner container said it can run, probed once at start;
12877
+ // null until then, and when no container could be started.
12878
+ runnerImage = null;
12879
+ // CT1681: the companion every container mounts — this one's own install.
12880
+ runnerCompanion() {
12881
+ return companionSource({
12882
+ configured: this.config.runners?.docker?.companionDir,
12883
+ platform: process.platform,
12884
+ version: COMPANION_VERSION,
12885
+ own: ownInstall()
12886
+ });
12887
+ }
12888
+ dockerRunners() {
12889
+ return runnersEnabled(this.config) && this.config.runners?.provider === "docker";
12890
+ }
11287
12891
  // Stand up the data plane: pair check, initial assignments pull, then the
11288
12892
  // heartbeat + poll loops. A companion with no device token (logged out) does
11289
12893
  // nothing but say so.
@@ -11292,6 +12896,31 @@ var CompanionSupervisor = class {
11292
12896
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
11293
12897
  `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
11294
12898
  );
12899
+ if (this.dockerRunners() && !this.providerForRunners) {
12900
+ const { image, apiUrl } = dockerRunnerSettings(this.config);
12901
+ if (loopbackApi(apiUrl)) {
12902
+ this.log.error(
12903
+ { apiUrl },
12904
+ "runners: a container can't reach this machine's loopback; set runners.docker.apiUrl to an address it can"
12905
+ );
12906
+ }
12907
+ const source = this.runnerCompanion();
12908
+ const docker = dockerCli();
12909
+ if ("error" in source) {
12910
+ this.log.error({ reason: source.error }, "runners: no companion to put in a container");
12911
+ } else if (await ensureBaseImage(docker, image, this.log) && await ensureCompanionVolume(docker, image, source, this.log)) {
12912
+ this.runnerImage = await probeRunner(docker, image, source, this.log);
12913
+ if (!this.runnerImage) await invalidateCompanionVolume(docker, image, source);
12914
+ }
12915
+ this.latchBootBundledState();
12916
+ }
12917
+ if (this.runnerSandbox() === "os") {
12918
+ const policy = this.sandboxPolicy();
12919
+ const posture = postureLines(policy);
12920
+ const summary = posture.join("; ");
12921
+ if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
12922
+ else this.log.error({ posture }, `runners: ${summary}`);
12923
+ }
11295
12924
  const startupHarnessRefresh = this.refreshHarnessStatuses();
11296
12925
  this.trackHeartbeat(startupHarnessRefresh);
11297
12926
  if (!this.config.deviceToken) {
@@ -11399,7 +13028,9 @@ var CompanionSupervisor = class {
11399
13028
  runtimeIssues: this.runtimeIssues(),
11400
13029
  // CT571/CT586: each runtime's `version` from the latest harness probe
11401
13030
  // (fail-soft to null). Informational only — the server matches on name.
11402
- versions: this.harnessVersions
13031
+ versions: this.harnessVersions,
13032
+ // CT1681: shown on the device page.
13033
+ ...runnersEnabled(this.config) ? { runnerProvider: this.config.runners?.provider ?? "local-process" } : {}
11403
13034
  }),
11404
13035
  // CT566: echo the last classified credential state per runtime, when the
11405
13036
  // companion has seen any. Omitted (undefined) until the first observed
@@ -11731,22 +13362,79 @@ var CompanionSupervisor = class {
11731
13362
  // CT1679: `runners.sandbox`, checked against this host once. A host that
11732
13363
  // can't make the sandbox says so once and runs its turns unwrapped.
11733
13364
  sandboxUsable;
13365
+ // CT1684: `runners.policy`, parsed once. An invalid one is kept as the
13366
+ // error, which every sandboxed turn is refused with.
13367
+ parsedSandboxPolicy;
13368
+ sandboxPolicy() {
13369
+ this.parsedSandboxPolicy ??= parseSandboxPolicy(this.config.runners?.policy);
13370
+ return this.parsedSandboxPolicy;
13371
+ }
11734
13372
  runnerSandbox() {
11735
- if (this.config.runners?.sandbox !== "os") return "none";
13373
+ if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
11736
13374
  this.sandboxUsable ??= osSandboxUsable(this.log);
11737
13375
  return this.sandboxUsable ? "os" : "none";
11738
13376
  }
11739
13377
  // CT1679: one provider for the device, built on first use.
11740
13378
  runnerProvider() {
11741
- this.providerForRunners ??= createLocalProcessProvider();
13379
+ this.providerForRunners ??= this.dockerRunners() ? this.createDockerRunners() : createLocalProcessProvider();
11742
13380
  return this.providerForRunners;
11743
13381
  }
13382
+ createDockerRunners() {
13383
+ const settings = dockerRunnerSettings(this.config);
13384
+ const docker = this.config.runners?.docker ?? {};
13385
+ const hooks = [
13386
+ this.config.prepareHook?.command,
13387
+ ...Object.values(this.config.agents ?? {}).map((a) => a.prepareHook?.command)
13388
+ ].filter((c) => typeof c === "string");
13389
+ const source = this.runnerCompanion();
13390
+ return createDockerProvider({
13391
+ image: settings.image,
13392
+ // Without a companion nothing is advertised, so nothing is sent; a turn
13393
+ // that arrives anyway fails at the mount and says so.
13394
+ companion: "error" in source ? { kind: "dir", path: "/nonexistent", key: "none" } : source,
13395
+ deviceKey: deviceKey(this.config),
13396
+ keepContainers: settings.keepContainers,
13397
+ egressAllow: settings.egressAllow,
13398
+ gitHosts: settings.gitHosts,
13399
+ gitFill: deviceGitFill(docker.gitCredentialHelper),
13400
+ runtimeEnv: () => resolveRuntimeEnv(
13401
+ docker.env,
13402
+ loadSecretStoreTolerant(
13403
+ (m) => this.log.warn(
13404
+ { err: m },
13405
+ "Could not read the secrets file. Check ~/.cabane/secrets.json."
13406
+ )
13407
+ ),
13408
+ this.log
13409
+ ),
13410
+ mounts: [...new Set(hooks)].map((source2) => ({ source: source2, target: containerHookPath(source2) })),
13411
+ deviceDir: cabaneDir(),
13412
+ hostStateDir: runnerStateDir(),
13413
+ onOutboxCollected: (agentId) => {
13414
+ for (const wr of this.workspaces.values()) wr.agents.get(agentId)?.kickDrain();
13415
+ },
13416
+ log: this.log
13417
+ });
13418
+ }
13419
+ // CT1681: the runner command for this device's provider: the image's own
13420
+ // companion in a container, this process's otherwise.
13421
+ runnerCommandFor() {
13422
+ if (this.runnerCommand) return this.runnerCommand;
13423
+ return this.dockerRunners() ? ["cabane-companion", "run-turn"] : selfRunnerCommand();
13424
+ }
11744
13425
  buildDispatcher(ctx) {
11745
- const local = localAgentConfig(this.config, {
13426
+ const hostLocal = localAgentConfig(this.config, {
11746
13427
  agentId: ctx.agentId,
11747
13428
  agentUsername: ctx.agentUsername,
11748
13429
  workspaceSlug: ctx.workspaceSlug
11749
13430
  });
13431
+ const local = this.dockerRunners() && hostLocal.prepareHook ? {
13432
+ ...hostLocal,
13433
+ prepareHook: {
13434
+ ...hostLocal.prepareHook,
13435
+ command: containerHookPath(hostLocal.prepareHook.command)
13436
+ }
13437
+ } : hostLocal;
11750
13438
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
11751
13439
  return new Dispatcher({
11752
13440
  deviceId: this.deviceId ?? void 0,
@@ -11795,11 +13483,13 @@ var CompanionSupervisor = class {
11795
13483
  ...runnersEnabled(this.config) ? {
11796
13484
  runner: {
11797
13485
  provider: this.runnerProvider(),
11798
- command: this.runnerCommand ?? selfRunnerCommand(),
13486
+ command: this.runnerCommandFor(),
13487
+ ...this.dockerRunners() ? { runnerBaseUrl: dockerRunnerSettings(this.config).apiUrl } : {},
11799
13488
  reportRunnerGone: (report) => this.reportRunnerGone(report),
11800
13489
  connectorHealth: this.connectorHealth,
11801
13490
  locks: this.runnerLocks,
11802
- sandbox: this.runnerSandbox()
13491
+ sandbox: this.runnerSandbox(),
13492
+ policy: this.sandboxPolicy()
11803
13493
  }
11804
13494
  } : {}
11805
13495
  });
@@ -12119,7 +13809,7 @@ var CompanionSupervisor = class {
12119
13809
  );
12120
13810
  }
12121
13811
  const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
12122
- const turnId = resumedTurnId ?? randomUUID5();
13812
+ const turnId = resumedTurnId ?? randomUUID6();
12123
13813
  if (dispatchId) {
12124
13814
  const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
12125
13815
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -12424,9 +14114,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
12424
14114
  }
12425
14115
  function defaultReexec() {
12426
14116
  clearRuntimeState();
12427
- void import("child_process").then(({ spawn: spawn7 }) => {
14117
+ void import("child_process").then(({ spawn: spawn9 }) => {
12428
14118
  try {
12429
- const child = spawn7(process.execPath, process.argv.slice(1), {
14119
+ const child = spawn9(process.execPath, process.argv.slice(1), {
12430
14120
  stdio: "inherit",
12431
14121
  detached: false
12432
14122
  });
@@ -12496,22 +14186,22 @@ function handleUncaught(log, err, origin) {
12496
14186
  }
12497
14187
 
12498
14188
  // src/crash-marker.ts
12499
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync9, writeFileSync as writeFileSync8 } from "fs";
12500
- import { join as join17 } from "path";
14189
+ import { existsSync as existsSync15, mkdirSync as mkdirSync13, readFileSync as readFileSync12, rmSync as rmSync12, writeFileSync as writeFileSync9 } from "fs";
14190
+ import { join as join20 } from "path";
12501
14191
  function crashMarkerPath() {
12502
- return join17(cabaneDir(), "last-error.json");
14192
+ return join20(cabaneDir(), "last-error.json");
12503
14193
  }
12504
14194
  function recordCrash(rec) {
12505
14195
  try {
12506
- mkdirSync11(cabaneDir(), { recursive: true });
12507
- writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
14196
+ mkdirSync13(cabaneDir(), { recursive: true });
14197
+ writeFileSync9(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
12508
14198
  } catch {
12509
14199
  }
12510
14200
  }
12511
14201
  function clearCrash() {
12512
14202
  try {
12513
14203
  const path = crashMarkerPath();
12514
- if (existsSync12(path)) rmSync9(path, { force: true });
14204
+ if (existsSync15(path)) rmSync12(path, { force: true });
12515
14205
  } catch {
12516
14206
  }
12517
14207
  }
@@ -12554,7 +14244,7 @@ async function createCompanionRuntime(opts = {}) {
12554
14244
  opencodeServerUrl: cfg.opencode?.serverUrl,
12555
14245
  codex: isCodexEnabled(cfg)
12556
14246
  });
12557
- const instanceId = randomUUID6();
14247
+ const instanceId = randomUUID7();
12558
14248
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
12559
14249
  const pre = readLiveRuntimeState();
12560
14250
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();