@cabane/companion 0.6.124 → 0.6.125

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 {
@@ -360,8 +360,38 @@ var companionConfigSchema = z2.object({
360
360
  // configuration in its own right rather than a degraded one.
361
361
  runners: z2.object({
362
362
  enabled: z2.boolean().optional(),
363
- provider: z2.literal("local-process").optional(),
364
- sandbox: z2.enum(["os", "none"]).optional()
363
+ provider: z2.enum(["local-process", "docker"]).optional(),
364
+ sandbox: z2.enum(["os", "none"]).optional(),
365
+ // CT1681: the `docker` provider — a container per conversation and agent,
366
+ // from one image, on a network whose only way out is an allow-listed proxy.
367
+ docker: z2.object({
368
+ // The base image a container runs. Default: the one this companion
369
+ // builds from its own Dockerfile on first use. One named here must
370
+ // have that one's layout (the `runner` user, `cabane-companion` on
371
+ // PATH reading `CABANE_COMPANION_ROOT`).
372
+ image: z2.string().min(1).optional(),
373
+ // The companion install mounted into every container. Default, on
374
+ // Linux: this companion's own. Needed for one run from a checkout
375
+ // (a `pnpm --filter @cabane/companion deploy --prod <dir>`).
376
+ companionDir: z2.string().min(1).optional(),
377
+ // Hosts a turn may reach besides the Cabane API, the model providers
378
+ // and the git host. `*.example.com` matches any subdomain.
379
+ egressAllow: z2.array(z2.string().min(1)).optional(),
380
+ // Keep a turn's container between turns and start it again for the
381
+ // next one. Off by default: the container goes after every turn and
382
+ // the runtime's session files are restored into the next.
383
+ keepContainers: z2.boolean().optional(),
384
+ // The Cabane URL as a container reaches it, for a device whose own
385
+ // `baseUrl` is a loopback address the container can't see.
386
+ apiUrl: z2.string().url().refine(isAllowedBaseUrl).optional(),
387
+ // Env set on each turn's runner, `${NAME}` resolved from secrets.json
388
+ // at exec: the runtime's own auth, which the image never carries.
389
+ env: z2.record(z2.string(), z2.string()).optional(),
390
+ // A git credential helper run on this device to answer the
391
+ // container's git for an allowed host, e.g. `!gh auth git-credential`.
392
+ // Absent: git's own configured helpers on this device.
393
+ gitCredentialHelper: z2.string().min(1).optional()
394
+ }).strict().optional()
365
395
  }).strict().optional()
366
396
  });
367
397
  function runnersEnabled(cfg) {
@@ -778,7 +808,8 @@ function buildCompanionManifest(opts) {
778
808
  return {
779
809
  runtimes,
780
810
  capabilities: { ...DEVICE_MANIFEST.capabilities },
781
- runtimeIssues: opts.runtimeIssues ?? []
811
+ runtimeIssues: opts.runtimeIssues ?? [],
812
+ ...opts.runnerProvider ? { runner: { provider: opts.runnerProvider } } : {}
782
813
  };
783
814
  }
784
815
  function runtimeIssuesFor(gates) {
@@ -2294,7 +2325,7 @@ import { randomUUID } from "crypto";
2294
2325
  var bootId = randomUUID();
2295
2326
 
2296
2327
  // src/supervisor.ts
2297
- import { randomUUID as randomUUID5 } from "crypto";
2328
+ import { randomUUID as randomUUID6 } from "crypto";
2298
2329
 
2299
2330
  // src/api.ts
2300
2331
  var DEVICE_HEADER = "x-cabane-device";
@@ -3961,8 +3992,8 @@ function readThinking(runtimeOptions) {
3961
3992
  var CLAUDE_CODE_DEFAULT_MODEL_BARE = "default";
3962
3993
  function parseClaudeCodeModel(model) {
3963
3994
  if (!model) return null;
3964
- const sep = model.indexOf("/");
3965
- const bare = sep === -1 || model.slice(0, sep) !== "anthropic" ? model : model.slice(sep + 1);
3995
+ const sep2 = model.indexOf("/");
3996
+ const bare = sep2 === -1 || model.slice(0, sep2) !== "anthropic" ? model : model.slice(sep2 + 1);
3966
3997
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
3967
3998
  }
3968
3999
 
@@ -5313,9 +5344,9 @@ function readOpencodeDialect(runtimeOptions) {
5313
5344
 
5314
5345
  // packages/agent-runtime/src/opencode/model.ts
5315
5346
  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) };
5347
+ const sep2 = model.indexOf("/");
5348
+ if (sep2 === -1) return { providerID: "", modelID: model };
5349
+ return { providerID: model.slice(0, sep2), modelID: model.slice(sep2 + 1) };
5319
5350
  }
5320
5351
 
5321
5352
  // packages/agent-runtime/src/opencode/run-spec.ts
@@ -5567,10 +5598,10 @@ async function* parseSseStream(body, sessionId, signal) {
5567
5598
  const { value, done } = await reader.read();
5568
5599
  if (done) return;
5569
5600
  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);
5601
+ let sep2;
5602
+ while ((sep2 = buf.indexOf("\n\n")) !== -1) {
5603
+ const frame = buf.slice(0, sep2);
5604
+ buf = buf.slice(sep2 + 2);
5574
5605
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
5575
5606
  if (!data) continue;
5576
5607
  let ev;
@@ -6530,8 +6561,8 @@ function readCodexDialect(runtimeOptions) {
6530
6561
  // packages/agent-runtime/src/codex/model.ts
6531
6562
  var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
6532
6563
  function parseCodexModel(model) {
6533
- const sep = model.indexOf("/");
6534
- const bare = sep === -1 || model.slice(0, sep) !== "openai" ? model : model.slice(sep + 1);
6564
+ const sep2 = model.indexOf("/");
6565
+ const bare = sep2 === -1 || model.slice(0, sep2) !== "openai" ? model : model.slice(sep2 + 1);
6535
6566
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
6536
6567
  }
6537
6568
 
@@ -8151,43 +8182,134 @@ function createTurnContainment(turnId, deps = {}) {
8151
8182
  };
8152
8183
  }
8153
8184
 
8185
+ // src/secrets.ts
8186
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
8187
+ import { join as join10 } from "path";
8188
+ import { z as z13 } from "zod";
8189
+ var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8190
+ function secretsPath() {
8191
+ return join10(cabaneDir(), "secrets.json");
8192
+ }
8193
+ var secretStoreSchema = z13.record(z13.string(), z13.string());
8194
+ function loadSecretStore() {
8195
+ const path = secretsPath();
8196
+ if (!existsSync7(path)) return makeStore({});
8197
+ let raw;
8198
+ try {
8199
+ raw = readFileSync6(path, "utf8");
8200
+ } catch (err) {
8201
+ throw new ConfigError(
8202
+ `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
8203
+ );
8204
+ }
8205
+ if (raw.trim().length === 0) return makeStore({});
8206
+ let parsed;
8207
+ try {
8208
+ parsed = JSON.parse(raw);
8209
+ } catch (err) {
8210
+ throw new ConfigError(
8211
+ `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
8212
+ );
8213
+ }
8214
+ const result = secretStoreSchema.safeParse(parsed);
8215
+ if (!result.success) {
8216
+ throw new ConfigError(
8217
+ `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
8218
+ );
8219
+ }
8220
+ return makeStore(result.data);
8221
+ }
8222
+ function loadSecretStoreTolerant(onWarn) {
8223
+ try {
8224
+ return loadSecretStore();
8225
+ } catch (err) {
8226
+ onWarn?.(err instanceof Error ? err.message : String(err));
8227
+ return makeStore({});
8228
+ }
8229
+ }
8230
+ function makeStore(map) {
8231
+ return {
8232
+ get: (name) => map[name],
8233
+ names: () => Object.keys(map).sort()
8234
+ };
8235
+ }
8236
+ function requiredSecretNames(mcpServers) {
8237
+ const names = /* @__PURE__ */ new Set();
8238
+ for (const server of Object.values(mcpServers ?? {})) {
8239
+ const bags = [
8240
+ "env" in server ? server.env : void 0,
8241
+ "headers" in server ? server.headers : void 0
8242
+ ];
8243
+ for (const bag of bags) {
8244
+ for (const value of Object.values(bag ?? {})) {
8245
+ for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
8246
+ }
8247
+ }
8248
+ }
8249
+ return [...names].sort();
8250
+ }
8251
+ function resolveMcpSecrets(mcpServers, store) {
8252
+ const missing = /* @__PURE__ */ new Set();
8253
+ const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
8254
+ const resolved = store.get(name);
8255
+ if (resolved === void 0) {
8256
+ missing.add(name);
8257
+ return _match;
8258
+ }
8259
+ return resolved;
8260
+ });
8261
+ const mapBag = (bag) => {
8262
+ const out = {};
8263
+ for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
8264
+ return out;
8265
+ };
8266
+ const servers = {};
8267
+ for (const [name, server] of Object.entries(mcpServers)) {
8268
+ const next = { ...server };
8269
+ if ("env" in next && next.env) next.env = mapBag(next.env);
8270
+ if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
8271
+ servers[name] = next;
8272
+ }
8273
+ return { servers, missing: [...missing].sort() };
8274
+ }
8275
+
8154
8276
  // src/runner/protocol.ts
8155
- import { z as z14 } from "zod";
8277
+ import { z as z15 } from "zod";
8156
8278
 
8157
8279
  // 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()
8280
+ import { z as z14 } from "zod";
8281
+ var mcpStdioServerSchema = z14.object({
8282
+ type: z14.literal("stdio").optional(),
8283
+ command: z14.string().min(1),
8284
+ args: z14.array(z14.string()).optional(),
8285
+ env: z14.record(z14.string(), z14.string()).optional()
8164
8286
  });
8165
- var mcpHttpServerSchema = z13.object({
8166
- type: z13.literal("http"),
8167
- url: z13.string().url(),
8168
- headers: z13.record(z13.string(), z13.string()).optional()
8287
+ var mcpHttpServerSchema = z14.object({
8288
+ type: z14.literal("http"),
8289
+ url: z14.string().url(),
8290
+ headers: z14.record(z14.string(), z14.string()).optional()
8169
8291
  });
8170
- var mcpSseServerSchema = z13.object({
8171
- type: z13.literal("sse"),
8172
- url: z13.string().url(),
8173
- headers: z13.record(z13.string(), z13.string()).optional()
8292
+ var mcpSseServerSchema = z14.object({
8293
+ type: z14.literal("sse"),
8294
+ url: z14.string().url(),
8295
+ headers: z14.record(z14.string(), z14.string()).optional()
8174
8296
  });
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") })
8297
+ var mcpServerDefSchema = z14.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
8298
+ var thinkingConfigSchema = z14.discriminatedUnion("type", [
8299
+ z14.object({ type: z14.literal("adaptive") }),
8300
+ z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
8301
+ z14.object({ type: z14.literal("disabled") })
8180
8302
  ]);
8181
- var effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
8182
- var runConfigSchema = z13.object({
8303
+ var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
8304
+ var runConfigSchema = z14.object({
8183
8305
  // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8184
8306
  // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8185
8307
  // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8186
8308
  // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8187
8309
  // 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(),
8310
+ hostAccess: z14.boolean().optional(),
8311
+ mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
8312
+ model: z14.string().min(1).optional(),
8191
8313
  thinking: thinkingConfigSchema.optional(),
8192
8314
  effort: effortSchema.optional()
8193
8315
  });
@@ -8215,56 +8337,64 @@ var RUNNER_ENV = {
8215
8337
  // Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
8216
8338
  spec: "CABANE_RUNNER_SPEC"
8217
8339
  };
8218
- var runtimeHealthSchema = z14.object({
8219
- quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
8220
- limitedUntil: z14.string().nullable(),
8221
- lastError: z14.string().nullable()
8340
+ var runtimeHealthSchema = z15.object({
8341
+ quotaState: z15.enum(["ok", "limited", "auth_expired", "unknown"]),
8342
+ limitedUntil: z15.string().nullable(),
8343
+ lastError: z15.string().nullable()
8222
8344
  });
8223
- var runnerSpecSchema = z14.object({
8224
- deviceId: z14.string().optional(),
8345
+ var runnerSpecSchema = z15.object({
8346
+ deviceId: z15.string().optional(),
8225
8347
  // The launcher's boot id: the one its heartbeat carries, so a runner's lease
8226
8348
  // 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(),
8349
+ bootId: z15.string(),
8350
+ workspaceId: z15.string(),
8351
+ workspaceSlug: z15.string(),
8352
+ agentId: z15.string(),
8353
+ agentUsername: z15.string(),
8354
+ agentName: z15.string(),
8233
8355
  local: localAgentConfigSchema,
8234
8356
  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(),
8357
+ opencodeServerUrl: z15.string().optional(),
8358
+ claudeCodeAvailable: z15.boolean(),
8359
+ codexAvailable: z15.boolean(),
8360
+ idleTimeoutMs: z15.number().int().positive().optional(),
8361
+ totalTimeoutMs: z15.number().int().positive().optional(),
8362
+ leaseRenewalMs: z15.number().int().positive().optional(),
8363
+ preparingRowDelayMs: z15.number().int().nonnegative().optional(),
8242
8364
  // Whether the turn writes a transcript. The directory is the runner's own
8243
8365
  // (`transcriptsDir()`), so `CABANE_RUNNER_STATE_DIR` moves it with the outbox.
8244
- transcripts: z14.boolean(),
8366
+ transcripts: z15.boolean(),
8245
8367
  // Whether the harness starts under the OS sandbox. The launcher sends 'os'
8246
8368
  // only when this host can make one.
8247
- sandbox: z14.enum(["os", "none"]),
8248
- logLevel: z14.string(),
8369
+ sandbox: z15.enum(["os", "none"]),
8370
+ logLevel: z15.string(),
8249
8371
  // The launcher's connector-health observations, by runtime, as of the start.
8250
- connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
8372
+ connectorHealth: z15.record(z15.string(), runtimeHealthSchema),
8251
8373
  // True when this (conversation, agent) pair was already told its session
8252
8374
  // could not be saved: the notice is once per pair, not once per process.
8253
- sessionWriteNoticeClaimed: z14.boolean(),
8375
+ sessionWriteNoticeClaimed: z15.boolean(),
8254
8376
  // CT1680: the runner's identity, minted with its credential. The start
8255
8377
  // PATCH carries it so the lease knows which runner holds the span.
8256
- runnerId: z14.string()
8378
+ runnerId: z15.string(),
8379
+ // CT1681: the launcher keeps the runtime's session files beside the attempt
8380
+ // and restores them into this runner's home, so the runner names the attempt
8381
+ // each context was composed for and waits for the launcher to say it's in
8382
+ // place. Absent: nothing to wait for (a runner on the launcher's own disk).
8383
+ attemptState: z15.boolean().optional(),
8384
+ // CT1681: MCP `${NAME}` secrets come from the launcher, by name, instead of
8385
+ // this machine's secrets.json. Absent: the runner reads the device's file.
8386
+ secretsFromLauncher: z15.boolean().optional()
8257
8387
  });
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()
8388
+ var dispatchSchema = z15.object({
8389
+ type: z15.literal("device:dispatch_requested"),
8390
+ agentId: z15.string(),
8391
+ deviceId: z15.string(),
8392
+ conversationId: z15.string(),
8393
+ messageId: z15.string(),
8394
+ episode: z15.number().int().optional()
8265
8395
  });
8266
- var credentialsSchema = z14.object({
8267
- credential: z14.string().startsWith("cabrn_")
8396
+ var credentialsSchema = z15.object({
8397
+ credential: z15.string().startsWith("cabrn_")
8268
8398
  });
8269
8399
  function runnerEnv(input) {
8270
8400
  return {
@@ -8301,6 +8431,11 @@ function selfRunnerCommand() {
8301
8431
  if (!script) throw new Error("runners: cannot tell which script started this companion");
8302
8432
  return [process.execPath, ...process.execArgv, script, "run-turn"];
8303
8433
  }
8434
+ function defaultSecretStore(log) {
8435
+ return loadSecretStoreTolerant(
8436
+ (m) => log.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
8437
+ );
8438
+ }
8304
8439
  function healthSnapshot(store) {
8305
8440
  const out = {};
8306
8441
  for (const r of store?.reports() ?? []) {
@@ -8334,6 +8469,8 @@ function runnerSpec(opts, launch, duties, runnerId) {
8334
8469
  ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
8335
8470
  transcripts: opts.transcriptDir !== void 0,
8336
8471
  sandbox: launch.sandbox ?? "none",
8472
+ ...launch.provider.bindAttempt ? { attemptState: true } : {},
8473
+ ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
8337
8474
  logLevel: opts.log.level,
8338
8475
  connectorHealth: healthSnapshot(launch.connectorHealth),
8339
8476
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -8481,14 +8618,40 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8481
8618
  const remote = [];
8482
8619
  const lockOwner = (id) => `${turnId}#${id}`;
8483
8620
  const locks = launch.locks;
8621
+ let leasedAttemptId = null;
8484
8622
  try {
8485
8623
  handle = await launch.provider.acquire(attempt);
8624
+ } catch (err) {
8625
+ const reason2 = `runner_unavailable: ${describeError(err)}`;
8626
+ log.warn(
8627
+ { turnId, provider: launch.provider.name, err: describeError(err) },
8628
+ "runner: no place to run the turn"
8629
+ );
8630
+ controller.signal.removeEventListener("abort", forwardStop);
8631
+ duties.releaseAbort(turnId, controller);
8632
+ await settleUnstarted(opts, payload, turnId, reason2, log);
8633
+ await launch.reportRunnerGone?.({
8634
+ workspaceId: opts.workspaceId,
8635
+ conversationId: payload.conversationId,
8636
+ agentId: payload.agentId,
8637
+ messageId: payload.messageId,
8638
+ runnerId: minted.runnerId,
8639
+ reason: "provider_failed"
8640
+ });
8641
+ const durationMs2 = Date.now() - startedAt;
8642
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: reason2 });
8643
+ return { ok: false, durationMs: durationMs2, reason: reason2 };
8644
+ }
8645
+ try {
8646
+ const place = handle;
8486
8647
  const env = {};
8487
- for (const [k, v] of Object.entries(launch.env ?? process.env)) {
8488
- if (v !== void 0) env[k] = v;
8648
+ if (launch.provider.inheritsEnvironment !== false) {
8649
+ for (const [k, v] of Object.entries(launch.env ?? process.env)) {
8650
+ if (v !== void 0) env[k] = v;
8651
+ }
8489
8652
  }
8490
8653
  const input = {
8491
- baseUrl: opts.baseUrl,
8654
+ baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
8492
8655
  credential: minted.credential,
8493
8656
  dispatch: payload,
8494
8657
  turnId,
@@ -8566,6 +8729,38 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8566
8729
  case "remote_session":
8567
8730
  remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
8568
8731
  return;
8732
+ case "attempt": {
8733
+ const bind = launch.provider.bindAttempt?.(place, event.attemptId) ?? Promise.resolve();
8734
+ void bind.then(
8735
+ () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "bound", id: event.id })),
8736
+ (err) => {
8737
+ const refused = describeError(err);
8738
+ log.warn(
8739
+ { attemptId: event.attemptId, err: refused },
8740
+ "runner: couldn't make the place ready for the turn's attempt; not running it there"
8741
+ );
8742
+ running.stdin.write(
8743
+ encodeLauncherMessage({ cabaneLauncher: "bound", id: event.id, refused })
8744
+ );
8745
+ }
8746
+ );
8747
+ return;
8748
+ }
8749
+ case "leased":
8750
+ leasedAttemptId = event.attemptId;
8751
+ return;
8752
+ case "secrets": {
8753
+ const store = (launch.secretStore ?? defaultSecretStore)(log);
8754
+ const values = {};
8755
+ for (const name of event.names) {
8756
+ const value = store.get(name);
8757
+ if (value !== void 0) values[name] = value;
8758
+ }
8759
+ running.stdin.write(
8760
+ encodeLauncherMessage({ cabaneLauncher: "secrets", id: event.id, values })
8761
+ );
8762
+ return;
8763
+ }
8569
8764
  }
8570
8765
  });
8571
8766
  const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
@@ -8576,7 +8771,14 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8576
8771
  child?.stdin.end();
8577
8772
  controller.signal.removeEventListener("abort", forwardStop);
8578
8773
  duties.releaseAbort(turnId, controller);
8579
- if (handle) await launch.provider.release(handle, { destroy: false });
8774
+ if (handle) {
8775
+ await launch.provider.collect?.(handle, { attemptId: leasedAttemptId }).catch(
8776
+ (err) => log.warn({ err: describeError(err) }, "runner: couldn't collect what the runner left")
8777
+ );
8778
+ await launch.provider.release(handle, { destroy: false }).catch(
8779
+ (err) => log.warn({ err: describeError(err) }, "runner: couldn't give its place back")
8780
+ );
8781
+ }
8580
8782
  }
8581
8783
  if (result) return result;
8582
8784
  const durationMs = Date.now() - startedAt;
@@ -8635,7 +8837,7 @@ import { existsSync as existsSync9 } from "fs";
8635
8837
 
8636
8838
  // src/turn-control-tools.ts
8637
8839
  import { randomUUID as randomUUID2 } from "crypto";
8638
- import { z as z15 } from "zod";
8840
+ import { z as z16 } from "zod";
8639
8841
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
8640
8842
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8641
8843
  var SKIP_TURN_TOOL = "skip_turn";
@@ -8698,7 +8900,7 @@ function createTurnControlMcpServer(acts, skipState) {
8698
8900
  SKIP_TURN_TOOL,
8699
8901
  TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
8700
8902
  {
8701
- reason: z15.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
8903
+ reason: z16.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
8702
8904
  },
8703
8905
  async (args) => {
8704
8906
  skipState.skipped = true;
@@ -8714,42 +8916,42 @@ function createTurnControlMcpServer(acts, skipState) {
8714
8916
  ASK_TOOL,
8715
8917
  TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
8716
8918
  {
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"])
8919
+ targetUserId: z16.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
8920
+ question: z16.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
8921
+ headline: z16.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
8922
+ options: z16.array(
8923
+ z16.union([
8924
+ z16.string().min(1).max(MAX_ASK_OPTION_BYTES),
8925
+ z16.object({
8926
+ label: z16.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
8927
+ move: z16.object({
8928
+ task: z16.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
8929
+ status: z16.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
8728
8930
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
8729
8931
  })
8730
8932
  ])
8731
8933
  ).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"])
8934
+ questions: z16.array(
8935
+ z16.object({
8936
+ headline: z16.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
8937
+ body: z16.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
8938
+ options: z16.array(
8939
+ z16.union([
8940
+ z16.string().min(1).max(MAX_ASK_OPTION_BYTES),
8941
+ z16.object({
8942
+ label: z16.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
8943
+ move: z16.object({
8944
+ task: z16.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
8945
+ status: z16.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
8744
8946
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
8745
8947
  })
8746
8948
  ])
8747
8949
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
8748
8950
  })
8749
8951
  ).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"])
8952
+ replaces: z16.object({
8953
+ askId: z16.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
8954
+ reason: z16.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
8753
8955
  }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
8754
8956
  },
8755
8957
  async (args) => {
@@ -8790,9 +8992,9 @@ function createTurnControlMcpServer(acts, skipState) {
8790
8992
  WAKE_ME_TOOL,
8791
8993
  TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
8792
8994
  {
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)
8995
+ afterSeconds: z16.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
8996
+ at: z16.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
8997
+ note: z16.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
8796
8998
  },
8797
8999
  async (args) => {
8798
9000
  const hasAfter = args.afterSeconds !== void 0;
@@ -8826,8 +9028,8 @@ function createTurnControlMcpServer(acts, skipState) {
8826
9028
  WITHDRAW_ASK_TOOL,
8827
9029
  TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
8828
9030
  {
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)
9031
+ askId: z16.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
9032
+ reason: z16.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
8831
9033
  },
8832
9034
  async (args) => performAct(acts, {
8833
9035
  kind: "withdraw_ask",
@@ -8894,11 +9096,11 @@ function trimSlash2(s) {
8894
9096
  // src/codex-instructions.ts
8895
9097
  import { mkdtemp, rm, writeFile } from "fs/promises";
8896
9098
  import { tmpdir } from "os";
8897
- import { join as join10 } from "path";
9099
+ import { join as join11 } from "path";
8898
9100
  var PREFIX = "cabane-codex-instructions-";
8899
9101
  async function writeCodexInstructionsFile(contents) {
8900
- const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
8901
- const path = join10(dir2, "instructions.md");
9102
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
9103
+ const path = join11(dir2, "instructions.md");
8902
9104
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
8903
9105
  return {
8904
9106
  path,
@@ -9004,22 +9206,22 @@ function reportFailure(config, body) {
9004
9206
  }
9005
9207
 
9006
9208
  // 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";
9209
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "fs";
9210
+ import { join as join12 } from "path";
9009
9211
  function dirFor(workspaceId) {
9010
- return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
9212
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
9011
9213
  }
9012
9214
  function conversationDir(workspaceId, conversationId) {
9013
- return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
9215
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
9014
9216
  }
9015
9217
  function pathFor2(workspaceId, conversationId, agentId) {
9016
- return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
9218
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
9017
9219
  }
9018
9220
  function readPrepared(workspaceId, conversationId, agentId) {
9019
9221
  const path = pathFor2(workspaceId, conversationId, agentId);
9020
- if (!existsSync7(path)) return null;
9222
+ if (!existsSync8(path)) return null;
9021
9223
  try {
9022
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
9224
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
9023
9225
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
9024
9226
  return {
9025
9227
  cwd: parsed.cwd,
@@ -9043,97 +9245,6 @@ function clearPrepared(workspaceId, conversationId, agentId) {
9043
9245
  rmSync5(pathFor2(workspaceId, conversationId, agentId), { force: true });
9044
9246
  }
9045
9247
 
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
- };
9096
- }
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);
9107
- }
9108
- }
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
9248
  // src/transcript-writer.ts
9138
9249
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
9139
9250
  import { basename, dirname as dirname6, join as join13 } from "path";
@@ -9741,6 +9852,12 @@ var TurnExecution = class {
9741
9852
  this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
9742
9853
  if (this.resumedSpoke) turnLog.debug({ turnId }, "dispatcher: resumed span already spoke");
9743
9854
  }
9855
+ try {
9856
+ await this.opts.inRunner?.onContext?.(turnContext.attemptId ?? null);
9857
+ } catch (err) {
9858
+ const reason = `runner_unavailable: ${err instanceof Error ? err.message : String(err)}`;
9859
+ throw this.concluded(reason, reason);
9860
+ }
9744
9861
  }
9745
9862
  gateTrigger() {
9746
9863
  const { payload, turnLog } = this;
@@ -9758,7 +9875,8 @@ var TurnExecution = class {
9758
9875
  }
9759
9876
  async resolveSecrets() {
9760
9877
  const { payload, workspaceId, turnLog } = this;
9761
- const secretStore = this.opts.secretStore ?? loadSecretStoreTolerant(
9878
+ const fromLauncher = this.opts.inRunner?.secrets;
9879
+ const secretStore = this.opts.secretStore ?? (fromLauncher ? await fromLauncher(requiredSecretNames(this.turnContext.mcpServers)) : null) ?? loadSecretStoreTolerant(
9762
9880
  (m) => turnLog.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
9763
9881
  );
9764
9882
  const { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
@@ -9918,42 +10036,48 @@ var TurnExecution = class {
9918
10036
  const { payload, workspaceId, turnId, turnLog } = this;
9919
10037
  const abortController = this.abortController;
9920
10038
  this.supervisor.registerAbort(turnId, abortController);
10039
+ let started;
9921
10040
  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
- });
10041
+ started = await this.opts.api.setActiveRun(
10042
+ workspaceId,
10043
+ payload.conversationId,
10044
+ payload.agentId,
10045
+ {
10046
+ activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
10047
+ // CT1679: a runner leases under its launcher's boot id — the one the
10048
+ // device heartbeats — or the lost-run sweep reads it as a restart.
10049
+ bootId: this.opts.inRunner?.bootId ?? bootId,
10050
+ // CT1680: the runner holding the span, beside the device's boot id.
10051
+ ...this.opts.inRunner ? { runnerId: this.opts.inRunner.runnerId } : {},
10052
+ // CT1380: the durable record that this turn resumed, and from where.
10053
+ ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
10054
+ // CT1454: the issuance the context THIS attempt runs on came from. Read
10055
+ // off `this.turnContext`, which is the fetch that composed this request —
10056
+ // so a retransmitted start echoes the same id and reuses the same record,
10057
+ // and a resumed attempt echoes its own, newer one. The post-admission
10058
+ // fresh-session recompose (`freshRequest`) deliberately does not touch it:
10059
+ // it adopts only the prompt, never the run-config, so its issuance must
10060
+ // not become this attempt's evidence.
10061
+ ...this.turnContext.issuanceId ? { issuanceId: this.turnContext.issuanceId } : {},
10062
+ // CT33: hand the server this turn's id so the new-run chokepoint's
10063
+ // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
10064
+ // already emitted a "preparing" activity row for this turn above (it
10065
+ // runs before this flip), which would otherwise make the sweep mistake
10066
+ // this live turn for an abandoned one and close it with a `stopped`.
10067
+ turnId,
10068
+ // The newest message this turn's read covered: the lease records it,
10069
+ // and settle moves the cursor to it. Omitted when the server sent
10070
+ // none (an older API), so the cursor stays where it was.
10071
+ ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
10072
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
10073
+ // Rooms: the attempt this request's session belongs to (null = fresh).
10074
+ ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {},
10075
+ // CT1543: the frame this start acts on. The turn-context fetch already
10076
+ // checked it, but that was before an assembly this turn awaited — the
10077
+ // lease checks it again where the stamp is actually taken.
10078
+ ...payload.episode !== void 0 ? { dispatchMessageId: payload.messageId, dispatchEpisode: payload.episode } : {}
10079
+ }
10080
+ );
9957
10081
  } catch (err) {
9958
10082
  if (leaseRefusal(err) === "dispatch_context_stale" && !this.recomposedForLease) {
9959
10083
  this.recomposedForLease = true;
@@ -9978,6 +10102,7 @@ var TurnExecution = class {
9978
10102
  throw this.concluded(reason, reason);
9979
10103
  }
9980
10104
  this.admitted = true;
10105
+ if (started?.attemptId) this.opts.inRunner?.onLeased?.(started.attemptId);
9981
10106
  }
9982
10107
  buildRequest() {
9983
10108
  const { payload, workspaceId } = this;
@@ -10741,18 +10866,1266 @@ function createLocalProcessProvider(opts = {}) {
10741
10866
  };
10742
10867
  }
10743
10868
 
10744
- // src/runner/device-locks.ts
10745
- var DeviceLocks = class {
10746
- holders = /* @__PURE__ */ new Map();
10747
- queues = /* @__PURE__ */ new Map();
10748
- // `owner` is unique across the device; `grant` is called once, when it holds.
10749
- request(key, owner, grant) {
10750
- if (!this.holders.has(key)) {
10751
- this.holders.set(key, owner);
10752
- grant();
10753
- return;
10869
+ // src/providers/docker.ts
10870
+ import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
10871
+ import { constants as constants2, tmpdir as tmpdir2 } from "os";
10872
+ import {
10873
+ createReadStream,
10874
+ createWriteStream as createWriteStream2,
10875
+ existsSync as existsSync13,
10876
+ mkdirSync as mkdirSync10,
10877
+ readdirSync as readdirSync4,
10878
+ readFileSync as readFileSync9,
10879
+ renameSync as renameSync5,
10880
+ rmSync as rmSync9,
10881
+ statSync as statSync3,
10882
+ writeFileSync as writeFileSync6
10883
+ } from "fs";
10884
+ import { join as join17 } from "path";
10885
+ import { Writable } from "stream";
10886
+
10887
+ // src/providers/docker-cli.ts
10888
+ import { spawn as spawn7 } from "child_process";
10889
+ function cliEnv(extra) {
10890
+ const keep = [
10891
+ "PATH",
10892
+ "HOME",
10893
+ "DOCKER_HOST",
10894
+ "DOCKER_CONFIG",
10895
+ "DOCKER_CONTEXT",
10896
+ "DOCKER_CERT_PATH"
10897
+ ];
10898
+ const env = {};
10899
+ for (const k of keep) if (process.env[k] !== void 0) env[k] = process.env[k];
10900
+ return { ...env, ...extra };
10901
+ }
10902
+ function dockerCli(bin = "docker") {
10903
+ return {
10904
+ run(args, opts = {}) {
10905
+ return new Promise((resolve2) => {
10906
+ let child;
10907
+ try {
10908
+ child = spawn7(bin, [...args], {
10909
+ env: cliEnv(opts.env),
10910
+ stdio: ["pipe", "pipe", "pipe"]
10911
+ });
10912
+ } catch (err) {
10913
+ resolve2({ code: 127, stdout: "", stderr: String(err) });
10914
+ return;
10915
+ }
10916
+ let stdout = "";
10917
+ let stderr = "";
10918
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : void 0;
10919
+ if (opts.stdout) child.stdout?.pipe(opts.stdout);
10920
+ else child.stdout?.on("data", (chunk) => stdout += String(chunk));
10921
+ child.stderr?.on("data", (chunk) => stderr += String(chunk));
10922
+ child.stdin?.on("error", () => {
10923
+ });
10924
+ child.once("error", (err) => {
10925
+ if (timer) clearTimeout(timer);
10926
+ resolve2({ code: 127, stdout, stderr: stderr || err.message });
10927
+ });
10928
+ child.once("close", (code, signal) => {
10929
+ if (timer) clearTimeout(timer);
10930
+ resolve2({ code: code ?? (signal ? 137 : 1), stdout, stderr });
10931
+ });
10932
+ const input = opts.input;
10933
+ if (input === void 0) child.stdin?.end();
10934
+ else if (typeof input === "string" || Buffer.isBuffer(input)) child.stdin?.end(input);
10935
+ else input.pipe(child.stdin);
10936
+ });
10937
+ },
10938
+ spawn(args, env) {
10939
+ return spawn7(bin, [...args], { env: cliEnv(env), stdio: ["pipe", "pipe", "pipe"] });
10754
10940
  }
10755
- const queue = this.queues.get(key) ?? [];
10941
+ };
10942
+ }
10943
+
10944
+ // src/runner/git-credential.ts
10945
+ import { spawn as spawn8 } from "child_process";
10946
+ import { chmodSync as chmodSync5, rmSync as rmSync7 } from "fs";
10947
+ import { connect as connect3, createServer as createServer3 } from "net";
10948
+
10949
+ // src/runner/egress-proxy.ts
10950
+ import { createServer as createServer2, request as httpRequest } from "http";
10951
+ import { connect as connect2 } from "net";
10952
+ function egressAllowed(host, allow, port, defaultPort = 443) {
10953
+ const h = host.toLowerCase().replace(/\.$/, "");
10954
+ if (!h) return false;
10955
+ const p = port ?? defaultPort;
10956
+ return allow.some((entry) => {
10957
+ const raw = entry.toLowerCase().trim();
10958
+ const m = /^(.*):(\d{1,5})$/.exec(raw);
10959
+ const name = m ? m[1] : raw;
10960
+ const wanted = m ? Number(m[2]) : defaultPort;
10961
+ if (p !== wanted) return false;
10962
+ if (name.startsWith("*.")) return h.endsWith(name.slice(1));
10963
+ return h === name;
10964
+ });
10965
+ }
10966
+
10967
+ // src/runner/git-credential.ts
10968
+ function parseCredential(text) {
10969
+ const out = {};
10970
+ for (const line of text.split("\n")) {
10971
+ const eq = line.indexOf("=");
10972
+ if (eq <= 0) continue;
10973
+ out[line.slice(0, eq)] = line.slice(eq + 1);
10974
+ }
10975
+ return out;
10976
+ }
10977
+ function formatCredential(fields) {
10978
+ return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join("\n") + "\n\n";
10979
+ }
10980
+ function deviceGitFill(helper, timeoutMs = 15e3) {
10981
+ return (request) => new Promise((resolve2) => {
10982
+ const args = [
10983
+ ...helper ? ["-c", "credential.helper=", "-c", `credential.helper=${helper}`] : [],
10984
+ "credential",
10985
+ "fill"
10986
+ ];
10987
+ const child = spawn8("git", args, {
10988
+ stdio: ["pipe", "pipe", "ignore"],
10989
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" }
10990
+ });
10991
+ let out = "";
10992
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
10993
+ child.stdout.on("data", (chunk) => out += String(chunk));
10994
+ child.once("error", () => resolve2(null));
10995
+ child.once("close", (code) => {
10996
+ clearTimeout(timer);
10997
+ resolve2(code === 0 ? out : null);
10998
+ });
10999
+ child.stdin.end(formatCredential(request));
11000
+ });
11001
+ }
11002
+ async function serveGitCredentials(opts) {
11003
+ const { socketPath, hosts, fill, log } = opts;
11004
+ rmSync7(socketPath, { force: true });
11005
+ const server = createServer3({ allowHalfOpen: true }, (socket) => {
11006
+ let input = "";
11007
+ socket.setEncoding("utf8");
11008
+ socket.on("data", (chunk) => input += chunk);
11009
+ socket.on("error", () => socket.destroy());
11010
+ socket.on("end", () => {
11011
+ void (async () => {
11012
+ const [op = "", ...rest] = input.split("\n");
11013
+ const request = parseCredential(rest.join("\n"));
11014
+ if (op !== "get" || request.protocol !== "https" || !request.host) {
11015
+ socket.end();
11016
+ return;
11017
+ }
11018
+ if (!egressAllowed(request.host.replace(/:\d+$/, ""), hosts)) {
11019
+ log?.warn(
11020
+ { host: request.host },
11021
+ "runner: git asked for a credential for a host it may not use"
11022
+ );
11023
+ socket.end();
11024
+ return;
11025
+ }
11026
+ const answer = await fill({
11027
+ protocol: "https",
11028
+ host: request.host,
11029
+ ...request.path ? { path: request.path } : {},
11030
+ ...request.username ? { username: request.username } : {}
11031
+ });
11032
+ if (!answer) {
11033
+ log?.warn(
11034
+ { host: request.host },
11035
+ "runner: this device's git has no credential for the host"
11036
+ );
11037
+ socket.end();
11038
+ return;
11039
+ }
11040
+ const filled = parseCredential(answer);
11041
+ socket.end(
11042
+ formatCredential({
11043
+ protocol: "https",
11044
+ host: request.host,
11045
+ ...filled.username ? { username: filled.username } : {},
11046
+ ...filled.password ? { password: filled.password } : {}
11047
+ })
11048
+ );
11049
+ })();
11050
+ });
11051
+ });
11052
+ await new Promise((resolve2, reject) => {
11053
+ server.once("error", reject);
11054
+ server.listen(socketPath, () => {
11055
+ server.off("error", reject);
11056
+ resolve2();
11057
+ });
11058
+ });
11059
+ chmodSync5(socketPath, 438);
11060
+ return {
11061
+ close: () => new Promise((resolve2) => {
11062
+ server.close(() => resolve2());
11063
+ rmSync7(socketPath, { force: true });
11064
+ })
11065
+ };
11066
+ }
11067
+
11068
+ // src/runner/collected-outbox.ts
11069
+ import {
11070
+ copyFileSync as copyFileSync2,
11071
+ existsSync as existsSync11,
11072
+ mkdirSync as mkdirSync9,
11073
+ readdirSync as readdirSync3,
11074
+ readFileSync as readFileSync8,
11075
+ renameSync as renameSync4,
11076
+ rmSync as rmSync8
11077
+ } from "fs";
11078
+ import { join as join15 } from "path";
11079
+ function routes(t) {
11080
+ const conv = `/api/workspaces/${t.workspaceId}/conversations/${t.conversationId}`;
11081
+ const agent = `${conv}/participants/agents/${t.agentId}`;
11082
+ return {
11083
+ message: { method: "POST", path: `${conv}/messages` },
11084
+ activity: { method: "POST", path: `${agent}/activity` },
11085
+ thinking: { method: "POST", path: `${agent}/thinking` },
11086
+ text: { method: "POST", path: `${agent}/text` },
11087
+ "active-run": { method: "PATCH", path: `${agent}/active-run` }
11088
+ };
11089
+ }
11090
+ function admitCollectedEntry(raw, turn) {
11091
+ if (!raw || typeof raw !== "object") return null;
11092
+ const entry = raw;
11093
+ const kind = entry.kind;
11094
+ if (!kind || !(kind in routes(turn))) return null;
11095
+ const route = routes(turn)[kind];
11096
+ if (entry.method !== route.method || entry.path !== route.path) return null;
11097
+ if (typeof entry.seq !== "number" || typeof entry.enqueuedAt !== "number") return null;
11098
+ const body = entry.body;
11099
+ if (!body || typeof body !== "object" || typeof body.turnId !== "string") return null;
11100
+ if (!turn.turnIds.includes(body.turnId)) return null;
11101
+ if (kind === "active-run") {
11102
+ if (body.activeRunStartedAt !== null) return null;
11103
+ if (entry.turnId !== `activerun:${turn.conversationId}:${turn.agentId}`) return null;
11104
+ } else if (entry.turnId !== body.turnId) {
11105
+ return null;
11106
+ }
11107
+ return entry;
11108
+ }
11109
+ var atomicMove = (from, to) => {
11110
+ try {
11111
+ renameSync4(from, to);
11112
+ } catch {
11113
+ const partial = `${to}.partial`;
11114
+ copyFileSync2(from, partial);
11115
+ renameSync4(partial, to);
11116
+ rmSync8(from, { force: true });
11117
+ }
11118
+ };
11119
+ function publishStaged(from, to, move = atomicMove) {
11120
+ if (!existsSync11(from)) return { published: 0, complete: true };
11121
+ const staged = readdirSync3(from).filter((n) => n.endsWith(".json")).map((name) => {
11122
+ let entry = {};
11123
+ try {
11124
+ entry = JSON.parse(readFileSync8(join15(from, name), "utf8"));
11125
+ } catch {
11126
+ }
11127
+ return { name, entry };
11128
+ });
11129
+ const settle = (e) => e.kind === "active-run" ? 1 : 0;
11130
+ staged.sort(
11131
+ (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)
11132
+ );
11133
+ if (staged.length) mkdirSync9(to, { recursive: true });
11134
+ let published = 0;
11135
+ for (const { name } of staged) {
11136
+ try {
11137
+ move(join15(from, name), join15(to, name));
11138
+ } catch {
11139
+ return { published, complete: false };
11140
+ }
11141
+ published++;
11142
+ }
11143
+ rmSync8(from, { recursive: true, force: true });
11144
+ return { published, complete: true };
11145
+ }
11146
+
11147
+ // src/providers/runner-base.ts
11148
+ import { createHash as createHash3 } from "crypto";
11149
+ import { existsSync as existsSync12, realpathSync as realpathSync2 } from "fs";
11150
+ import { dirname as dirname7, join as join16, sep } from "path";
11151
+ import { fileURLToPath as fileURLToPath3 } from "url";
11152
+ var COMPANION_MOUNT = "/opt/cabane/companion";
11153
+ var BASE_DOCKERFILE = `FROM node:22-slim
11154
+ RUN apt-get update \\
11155
+ && apt-get install -y --no-install-recommends git curl ca-certificates procps \\
11156
+ && rm -rf /var/lib/apt/lists/* \\
11157
+ && corepack enable
11158
+ RUN usermod -l runner -d /home/runner -m node \\
11159
+ && groupmod -n runner node \\
11160
+ && mkdir -p /work /var/lib/cabane-runner /run/cabane ${COMPANION_MOUNT} \\
11161
+ && chown runner:runner /work /var/lib/cabane-runner
11162
+ RUN mkdir -p /home/runner/.codex /opt/cabane \\
11163
+ && printf '[features]\\nuse_legacy_landlock = true\\n' > /opt/cabane/codex-config.toml \\
11164
+ && cp /opt/cabane/codex-config.toml /home/runner/.codex/config.toml \\
11165
+ && chown -R runner:runner /home/runner/.codex
11166
+ RUN printf '#!/bin/sh\\nexec node "\${CABANE_COMPANION_ROOT:-${COMPANION_MOUNT}}/dist/cli.js" "$@"\\n' > /usr/local/bin/cabane-companion \\
11167
+ && chmod 755 /usr/local/bin/cabane-companion
11168
+ USER runner
11169
+ WORKDIR /home/runner
11170
+ CMD ["sleep", "infinity"]
11171
+ `;
11172
+ function baseImageTag(dockerfile = BASE_DOCKERFILE) {
11173
+ return `cabane-runner-base:${createHash3("sha256").update(dockerfile).digest("hex").slice(0, 12)}`;
11174
+ }
11175
+ async function ensureBaseImage(docker, image, log) {
11176
+ if ((await docker.run(["image", "inspect", image])).code === 0) return true;
11177
+ if (image !== baseImageTag()) {
11178
+ log.error({ image }, "runners: the configured runner image isn't on this engine");
11179
+ return false;
11180
+ }
11181
+ log.info({ image }, "runners: building the runner base image (once per base)");
11182
+ const built = await docker.run(["build", "-t", image, "-"], {
11183
+ input: BASE_DOCKERFILE,
11184
+ timeoutMs: 20 * 6e4
11185
+ });
11186
+ if (built.code !== 0) {
11187
+ log.error(
11188
+ { image, err: built.stderr.trim().slice(-2e3) },
11189
+ "runners: couldn't build the runner base image; no turn can run until it is"
11190
+ );
11191
+ return false;
11192
+ }
11193
+ return true;
11194
+ }
11195
+ function ownInstall(moduleFile = fileURLToPath3(import.meta.url)) {
11196
+ let dir2 = dirname7(moduleFile);
11197
+ for (let i = 0; i < 4; i++) {
11198
+ if (existsSync12(join16(dir2, "dist", "cli.js")) && existsSync12(join16(dir2, "package.json"))) break;
11199
+ dir2 = dirname7(dir2);
11200
+ }
11201
+ if (!existsSync12(join16(dir2, "dist", "cli.js"))) return null;
11202
+ const sdk = join16(dir2, "node_modules", "@anthropic-ai", "claude-agent-sdk");
11203
+ if (!existsSync12(sdk)) return null;
11204
+ const root = realpathSync2(dir2);
11205
+ return realpathSync2(sdk).startsWith(root + sep) ? root : null;
11206
+ }
11207
+ function companionSource(input) {
11208
+ if (input.configured) {
11209
+ return { kind: "dir", path: input.configured, key: `dir:${input.configured}` };
11210
+ }
11211
+ if (input.platform === "linux") {
11212
+ if (input.own)
11213
+ return { kind: "dir", path: input.own, key: `dir:${input.own}:${input.version}` };
11214
+ return {
11215
+ 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"
11216
+ };
11217
+ }
11218
+ if (input.version === "0.0.0") {
11219
+ return {
11220
+ error: "an unreleased companion can't be installed into a container from npm; set runners.docker.companionDir to a Linux install of it"
11221
+ };
11222
+ }
11223
+ return {
11224
+ kind: "volume",
11225
+ volume: `cabane-companion-${input.version}`,
11226
+ version: input.version,
11227
+ key: `volume:${input.version}`
11228
+ };
11229
+ }
11230
+ function companionRoot(source) {
11231
+ return source.kind === "dir" ? COMPANION_MOUNT : `${COMPANION_MOUNT}/lib/node_modules/@cabane/companion`;
11232
+ }
11233
+ function companionMountArgs(source) {
11234
+ const mount = source.kind === "dir" ? `type=bind,src=${source.path},dst=${COMPANION_MOUNT},readonly` : `type=volume,src=${source.volume},dst=${COMPANION_MOUNT},readonly`;
11235
+ return ["--mount", mount, "-e", `CABANE_COMPANION_ROOT=${companionRoot(source)}`];
11236
+ }
11237
+ var INSTALL_MARKER = `${COMPANION_MOUNT}/.cabane-install-complete`;
11238
+ function volumeInstallScript(version, install = `npm install -g --prefix ${COMPANION_MOUNT} @cabane/companion@${version}`) {
11239
+ const root = `${COMPANION_MOUNT}/lib/node_modules/@cabane/companion`;
11240
+ return [
11241
+ `find ${COMPANION_MOUNT} -mindepth 1 -delete`,
11242
+ install,
11243
+ // Installed is not the same as runnable: the package must start and say
11244
+ // what it runs before the volume counts as done.
11245
+ `node ${root}/dist/cli.js run-turn --manifest > /dev/null`,
11246
+ `printf %s ${version} > ${INSTALL_MARKER}`
11247
+ ].join(" && ");
11248
+ }
11249
+ var volumeMount = (source) => `type=volume,src=${source.volume},dst=${COMPANION_MOUNT}`;
11250
+ async function ensureCompanionVolume(docker, image, source, log, script = volumeInstallScript) {
11251
+ if (source.kind !== "volume") return true;
11252
+ const done = await docker.run([
11253
+ "run",
11254
+ "--rm",
11255
+ "--network",
11256
+ "none",
11257
+ "--mount",
11258
+ volumeMount(source),
11259
+ image,
11260
+ "sh",
11261
+ "-c",
11262
+ `[ "$(cat ${INSTALL_MARKER} 2>/dev/null)" = "$1" ]`,
11263
+ "sh",
11264
+ source.version
11265
+ ]);
11266
+ if (done.code === 0) return true;
11267
+ log.info(
11268
+ { version: source.version },
11269
+ "runners: installing this companion version for its containers"
11270
+ );
11271
+ const installed = await docker.run(
11272
+ [
11273
+ "run",
11274
+ "--rm",
11275
+ "-u",
11276
+ "root",
11277
+ "--mount",
11278
+ volumeMount(source),
11279
+ image,
11280
+ "sh",
11281
+ "-c",
11282
+ script(source.version)
11283
+ ],
11284
+ { timeoutMs: 20 * 6e4 }
11285
+ );
11286
+ if (installed.code !== 0) {
11287
+ log.error(
11288
+ { version: source.version, err: installed.stderr.trim().slice(-2e3) },
11289
+ "runners: couldn't install the companion for its containers; the next start tries again"
11290
+ );
11291
+ return false;
11292
+ }
11293
+ return true;
11294
+ }
11295
+ async function invalidateCompanionVolume(docker, image, source) {
11296
+ if (source.kind !== "volume") return;
11297
+ await docker.run([
11298
+ "run",
11299
+ "--rm",
11300
+ "-u",
11301
+ "root",
11302
+ "--network",
11303
+ "none",
11304
+ "--mount",
11305
+ volumeMount(source),
11306
+ image,
11307
+ "rm",
11308
+ "-f",
11309
+ INSTALL_MARKER
11310
+ ]);
11311
+ }
11312
+
11313
+ // src/providers/docker.ts
11314
+ var CONTAINER_HOME = "/home/runner";
11315
+ var CONTAINER_WORK = "/work";
11316
+ var CONTAINER_STATE = "/var/lib/cabane-runner";
11317
+ var CONTAINER_SOCKETS = "/run/cabane";
11318
+ var PROXY_ALIAS = "cabane-egress";
11319
+ var PROXY_PORT = 3128;
11320
+ var DEFAULT_KILL_GRACE_MS2 = 3e4;
11321
+ var REAP_GRACE_S = 3;
11322
+ var DEFAULT_SESSION_CAP_BYTES = 512 * 1024 * 1024;
11323
+ var COLLECT_CAP_BYTES = 128 * 1024 * 1024;
11324
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11325
+ function containerName(attempt) {
11326
+ return `cabane-runner-${attempt.conversationId}-${attempt.agentId}`;
11327
+ }
11328
+ function sessionArchivePath(deviceDir, attemptId) {
11329
+ if (!UUID_RE.test(attemptId)) throw new Error(`docker: not an attempt id: ${attemptId}`);
11330
+ return join17(deviceDir, "attempts", attemptId, "session.tar");
11331
+ }
11332
+ function pruneOtherAttempts(deviceDir, pair, keep) {
11333
+ const root = join17(deviceDir, "attempts");
11334
+ if (!existsSync13(root)) return;
11335
+ for (const id of readdirSync4(root)) {
11336
+ if (id === keep || !UUID_RE.test(id)) continue;
11337
+ let pairOf = "";
11338
+ try {
11339
+ pairOf = readFileSync9(join17(root, id, "pair"), "utf8");
11340
+ } catch {
11341
+ continue;
11342
+ }
11343
+ if (pairOf === `${pair.conversationId}/${pair.agentId}`) {
11344
+ rmSync9(join17(root, id), { recursive: true, force: true });
11345
+ }
11346
+ }
11347
+ }
11348
+ var CappedFile = class extends Writable {
11349
+ constructor(path, cap) {
11350
+ super();
11351
+ this.path = path;
11352
+ this.cap = cap;
11353
+ this.out = createWriteStream2(path);
11354
+ }
11355
+ path;
11356
+ cap;
11357
+ bytes = 0;
11358
+ over = false;
11359
+ out;
11360
+ _write(chunk, _enc, done) {
11361
+ this.bytes += chunk.length;
11362
+ if (this.bytes > this.cap) {
11363
+ this.over = true;
11364
+ done();
11365
+ return;
11366
+ }
11367
+ this.out.write(chunk, done);
11368
+ }
11369
+ _final(done) {
11370
+ this.out.end(done);
11371
+ }
11372
+ };
11373
+ var DUMP_SCRIPT = `
11374
+ const fs = require('fs'), path = require('path');
11375
+ const dir = process.argv[1], cap = Number(process.argv[2]);
11376
+ let names;
11377
+ try { names = fs.readdirSync(dir); }
11378
+ catch (e) {
11379
+ if (e.code === 'ENOENT') { process.stdout.write(JSON.stringify({ complete: true, files: [] })); process.exit(0); }
11380
+ process.stderr.write(String(e.message)); process.exit(2);
11381
+ }
11382
+ let total = 0, complete = true; const files = [];
11383
+ for (const name of names) {
11384
+ const p = path.join(dir, name);
11385
+ let st;
11386
+ try { st = fs.lstatSync(p); } catch (e) { process.stderr.write(String(e.message)); process.exit(2); }
11387
+ if (!st.isFile()) continue;
11388
+ // Always at least one file per read, so one over the cap is still collected.
11389
+ if (files.length && total + st.size > cap) { complete = false; continue; }
11390
+ let data;
11391
+ try { data = fs.readFileSync(p); } catch (e) { process.stderr.write(String(e.message)); process.exit(2); }
11392
+ total += st.size;
11393
+ files.push({ name, data: data.toString('base64') });
11394
+ }
11395
+ process.stdout.write(JSON.stringify({ complete, files }));
11396
+ `;
11397
+ var SAFE_NAME_RE = /^[\w.%@+-]+$/;
11398
+ function createDockerProvider(opts) {
11399
+ const docker = opts.docker ?? dockerCli();
11400
+ const log = opts.log;
11401
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS2;
11402
+ const sessionCap = opts.sessionCapBytes ?? DEFAULT_SESSION_CAP_BYTES;
11403
+ const collectCap = opts.collectCapBytes ?? COLLECT_CAP_BYTES;
11404
+ const network = `cabane-runners-${opts.deviceKey}`;
11405
+ const companionLabel = createHash4("sha256").update(opts.companion.key).digest("hex").slice(0, 16);
11406
+ const proxy = `cabane-egress-${opts.deviceKey}`;
11407
+ const held = /* @__PURE__ */ new Map();
11408
+ const queues = /* @__PURE__ */ new Map();
11409
+ const must = async (args, what) => {
11410
+ const r = await docker.run(args);
11411
+ if (r.code !== 0)
11412
+ throw new Error(`docker: ${what} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
11413
+ return r.stdout;
11414
+ };
11415
+ const execIn = (name, args, extra = {}) => docker.run(
11416
+ ["exec", ...extra.input !== void 0 ? ["-i"] : [], "-u", "runner", name, ...args],
11417
+ extra
11418
+ );
11419
+ const proxyListening = async () => {
11420
+ for (let i = 0; i < 40; i++) {
11421
+ const r = await docker.run([
11422
+ "exec",
11423
+ proxy,
11424
+ "curl",
11425
+ "-s",
11426
+ "-o",
11427
+ "/dev/null",
11428
+ "--max-time",
11429
+ "1",
11430
+ `http://127.0.0.1:${PROXY_PORT}/`
11431
+ ]);
11432
+ if (r.code === 0) return;
11433
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
11434
+ }
11435
+ throw new Error("docker: the egress proxy never started listening");
11436
+ };
11437
+ let networkReady = null;
11438
+ const proxyKey = async () => {
11439
+ const id = (await docker.run(["image", "inspect", "-f", "{{.Id}}", opts.image])).stdout.trim();
11440
+ return createHash4("sha256").update(JSON.stringify([id || opts.image, opts.companion.key, [...opts.egressAllow].sort()])).digest("hex").slice(0, 16);
11441
+ };
11442
+ const ensureNetwork = () => {
11443
+ networkReady ??= (async () => {
11444
+ const allowKey = await proxyKey();
11445
+ if ((await docker.run(["network", "inspect", network])).code !== 0) {
11446
+ await must(
11447
+ [
11448
+ "network",
11449
+ "create",
11450
+ // No route off the host, and no address of the host's on the
11451
+ // bridge: the host's own ports are out of reach too. The proxy is
11452
+ // the only other thing on the network.
11453
+ "--internal",
11454
+ "-o",
11455
+ "com.docker.network.bridge.inhibit_ipv4=true",
11456
+ "--label",
11457
+ `cabane.device=${opts.deviceKey}`,
11458
+ network
11459
+ ],
11460
+ "creating the runners network"
11461
+ );
11462
+ }
11463
+ const seen = await docker.run([
11464
+ "inspect",
11465
+ "-f",
11466
+ '{{index .Config.Labels "cabane.allow"}} {{.State.Running}}',
11467
+ proxy
11468
+ ]);
11469
+ const [key, running] = seen.stdout.trim().split(" ");
11470
+ if (seen.code === 0 && key === allowKey) {
11471
+ if (running !== "true") {
11472
+ await must(["start", proxy], "starting the egress proxy");
11473
+ await proxyListening();
11474
+ }
11475
+ return;
11476
+ }
11477
+ if (seen.code === 0) await docker.run(["rm", "-f", proxy]);
11478
+ await must(
11479
+ [
11480
+ "run",
11481
+ "-d",
11482
+ "--name",
11483
+ proxy,
11484
+ "--label",
11485
+ `cabane.allow=${allowKey}`,
11486
+ "--label",
11487
+ `cabane.device=${opts.deviceKey}`,
11488
+ "--restart",
11489
+ "unless-stopped",
11490
+ "--cap-drop",
11491
+ "ALL",
11492
+ "--security-opt",
11493
+ "no-new-privileges",
11494
+ "-e",
11495
+ `CABANE_EGRESS_ALLOW=${JSON.stringify(opts.egressAllow)}`,
11496
+ ...companionMountArgs(opts.companion),
11497
+ opts.image,
11498
+ "cabane-companion",
11499
+ "egress-proxy",
11500
+ "--port",
11501
+ String(PROXY_PORT)
11502
+ ],
11503
+ "starting the egress proxy"
11504
+ );
11505
+ await must(
11506
+ ["network", "connect", "--alias", PROXY_ALIAS, network, proxy],
11507
+ "connecting the proxy"
11508
+ );
11509
+ await proxyListening();
11510
+ log.info({ network, proxy, allow: opts.egressAllow }, "runners: egress proxy ready");
11511
+ })().catch((err) => {
11512
+ networkReady = null;
11513
+ throw err;
11514
+ });
11515
+ return networkReady;
11516
+ };
11517
+ const socketRoot = opts.socketRoot ?? join17(
11518
+ process.env.XDG_RUNTIME_DIR && existsSync13(process.env.XDG_RUNTIME_DIR) ? process.env.XDG_RUNTIME_DIR : tmpdir2(),
11519
+ `cabane-runners-${opts.deviceKey}`
11520
+ );
11521
+ const socketDir = (name) => join17(socketRoot, createHash4("sha256").update(name).digest("hex").slice(0, 12));
11522
+ const create = async (name, attempt) => {
11523
+ mkdirSync10(socketRoot, { recursive: true, mode: 448 });
11524
+ mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
11525
+ const mounts = (opts.mounts ?? []).filter((m) => m.source.startsWith("/") && existsSync13(m.source)).flatMap((m) => ["--mount", `type=bind,src=${m.source},dst=${m.target},readonly`]);
11526
+ await must(
11527
+ [
11528
+ "create",
11529
+ "--name",
11530
+ name,
11531
+ "--init",
11532
+ "--hostname",
11533
+ "cabane-runner",
11534
+ "--label",
11535
+ "cabane.runner=1",
11536
+ "--label",
11537
+ `cabane.device=${opts.deviceKey}`,
11538
+ "--label",
11539
+ `cabane.workspace=${attempt.workspaceId}`,
11540
+ "--label",
11541
+ `cabane.conversation=${attempt.conversationId}`,
11542
+ "--label",
11543
+ `cabane.agent=${attempt.agentId}`,
11544
+ "--label",
11545
+ `cabane.companion=${companionLabel}`,
11546
+ "--network",
11547
+ network,
11548
+ "--mount",
11549
+ `type=volume,src=${name}-home,dst=${CONTAINER_HOME}`,
11550
+ "--mount",
11551
+ `type=volume,src=${name}-work,dst=${CONTAINER_WORK}`,
11552
+ "--mount",
11553
+ `type=bind,src=${socketDir(name)},dst=${CONTAINER_SOCKETS},readonly`,
11554
+ ...mounts,
11555
+ ...companionMountArgs(opts.companion),
11556
+ "--cap-drop",
11557
+ "ALL",
11558
+ "--security-opt",
11559
+ "no-new-privileges",
11560
+ "--pids-limit",
11561
+ "4096",
11562
+ opts.image,
11563
+ "sleep",
11564
+ "infinity"
11565
+ ],
11566
+ "creating the container"
11567
+ );
11568
+ };
11569
+ const destroy = async (name) => {
11570
+ await docker.run(["rm", "-f", "-v", name]);
11571
+ await docker.run(["volume", "rm", "-f", `${name}-home`, `${name}-work`]);
11572
+ rmSync9(socketDir(name), { recursive: true, force: true });
11573
+ };
11574
+ const containerEnv = () => {
11575
+ const proxyUrl = `http://${PROXY_ALIAS}:${PROXY_PORT}`;
11576
+ const git = [
11577
+ ["credential.helper", ""],
11578
+ // The helper speaks a socket, not the network: no proxy, and none of
11579
+ // Node's warning about it in git's output.
11580
+ ["credential.helper", "!NODE_USE_ENV_PROXY= cabane-companion git-credential"],
11581
+ ...opts.gitHosts.flatMap((h) => [
11582
+ [`url.https://${h}/.insteadOf`, `git@${h}:`],
11583
+ [`url.https://${h}/.insteadOf`, `ssh://git@${h}/`]
11584
+ ])
11585
+ ];
11586
+ return {
11587
+ HOME: CONTAINER_HOME,
11588
+ CABANE_COMPANION_HOME: CONTAINER_STATE,
11589
+ CABANE_RUNNER_STATE_DIR: CONTAINER_STATE,
11590
+ HTTPS_PROXY: proxyUrl,
11591
+ HTTP_PROXY: proxyUrl,
11592
+ https_proxy: proxyUrl,
11593
+ http_proxy: proxyUrl,
11594
+ NO_PROXY: "localhost,127.0.0.1,::1",
11595
+ no_proxy: "localhost,127.0.0.1,::1",
11596
+ // Node's own fetch — the runner's wire — honours the proxy only when asked.
11597
+ NODE_USE_ENV_PROXY: "1",
11598
+ CABANE_GIT_CREDENTIAL_SOCKET: `${CONTAINER_SOCKETS}/git.sock`,
11599
+ GIT_TERMINAL_PROMPT: "0",
11600
+ GIT_CONFIG_COUNT: String(git.length),
11601
+ ...Object.fromEntries(
11602
+ git.flatMap(([k, v], i) => [
11603
+ [`GIT_CONFIG_KEY_${i}`, k],
11604
+ [`GIT_CONFIG_VALUE_${i}`, v]
11605
+ ])
11606
+ )
11607
+ };
11608
+ };
11609
+ const heldFor = (handle) => {
11610
+ const h = held.get(handle.id);
11611
+ if (!h) throw new Error(`docker: ${handle.id} is not held`);
11612
+ return h;
11613
+ };
11614
+ const writeMarker = (name, attemptId) => execIn(name, [
11615
+ "sh",
11616
+ "-c",
11617
+ `mkdir -p ${CONTAINER_STATE} && printf %s "$1" > ${CONTAINER_STATE}/attempt`,
11618
+ "sh",
11619
+ attemptId
11620
+ ]);
11621
+ const readDir = async (name, from) => {
11622
+ const r = await execIn(name, [
11623
+ "sh",
11624
+ "-c",
11625
+ '[ -e "$1" ] && chmod -R u+rwX "$1"; exec node -e "$2" "$1" "$3"',
11626
+ "sh",
11627
+ from,
11628
+ DUMP_SCRIPT,
11629
+ String(collectCap)
11630
+ ]);
11631
+ if (r.code !== 0) {
11632
+ log.warn(
11633
+ { container: name, dir: from, err: r.stderr.trim() },
11634
+ "runners: couldn't read what the runner left"
11635
+ );
11636
+ return null;
11637
+ }
11638
+ try {
11639
+ const dump = JSON.parse(r.stdout);
11640
+ if (typeof dump.complete !== "boolean" || !Array.isArray(dump.files)) return null;
11641
+ return {
11642
+ complete: dump.complete,
11643
+ files: dump.files.filter(
11644
+ (f) => typeof f.name === "string" && typeof f.data === "string"
11645
+ ).map((f) => ({ name: f.name, data: Buffer.from(f.data, "base64") }))
11646
+ };
11647
+ } catch {
11648
+ return null;
11649
+ }
11650
+ };
11651
+ const removeIn = (name, from, names) => names.length ? execIn(name, ["sh", "-c", 'cd "$1" && shift && rm -f -- "$@"', "sh", from, ...names]) : Promise.resolve(null);
11652
+ const pendingPath = (name) => join17(opts.deviceDir, "runners-pending", `${name}.json`);
11653
+ const readPending = (name) => {
11654
+ try {
11655
+ const ids = JSON.parse(readFileSync9(pendingPath(name), "utf8"));
11656
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
11657
+ } catch {
11658
+ return [];
11659
+ }
11660
+ };
11661
+ const writePending = (name, ids) => {
11662
+ if (!ids.length) {
11663
+ rmSync9(pendingPath(name), { force: true });
11664
+ return;
11665
+ }
11666
+ mkdirSync10(join17(opts.deviceDir, "runners-pending"), { recursive: true });
11667
+ writeFileSync6(pendingPath(name), JSON.stringify([...new Set(ids)]));
11668
+ };
11669
+ const keepSession = async (name, attempt, attemptId) => {
11670
+ const archive = sessionArchivePath(opts.deviceDir, attemptId);
11671
+ mkdirSync10(join17(archive, ".."), { recursive: true });
11672
+ const partial = `${archive}.${process.pid}.partial`;
11673
+ const file = new CappedFile(partial, sessionCap);
11674
+ const r = await execIn(
11675
+ name,
11676
+ [
11677
+ "sh",
11678
+ "-c",
11679
+ '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 "$@"'
11680
+ ],
11681
+ { stdout: file }
11682
+ );
11683
+ await new Promise(
11684
+ (resolve2) => file.writableFinished ? resolve2() : file.once("finish", resolve2)
11685
+ );
11686
+ if (r.code === 0 && !file.over) {
11687
+ renameSync5(partial, archive);
11688
+ writeFileSync6(join17(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
11689
+ pruneOtherAttempts(opts.deviceDir, attempt, attemptId);
11690
+ await writeMarker(name, attemptId);
11691
+ log.debug({ container: name, attemptId, bytes: file.bytes }, "runners: session files kept");
11692
+ return;
11693
+ }
11694
+ rmSync9(partial, { force: true });
11695
+ if (r.code === 3 && !file.over) return;
11696
+ dropArchive(attemptId);
11697
+ log.warn(
11698
+ file.over ? { container: name, attemptId, capBytes: sessionCap } : { container: name, attemptId, err: r.stderr.trim() },
11699
+ 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"
11700
+ );
11701
+ };
11702
+ const stagingDir = (name) => join17(opts.deviceDir, "runners-pending", `${name}.outbox`);
11703
+ const ensureRunning = async (name) => {
11704
+ const seen = await docker.run(["inspect", "-f", "{{.State.Running}}", name]);
11705
+ if (seen.code !== 0) return false;
11706
+ if (seen.stdout.trim() === "true") return true;
11707
+ return (await docker.run(["start", name])).code === 0;
11708
+ };
11709
+ const dropArchive = (attemptId) => {
11710
+ rmSync9(join17(sessionArchivePath(opts.deviceDir, attemptId), ".."), {
11711
+ recursive: true,
11712
+ force: true
11713
+ });
11714
+ };
11715
+ const RETIRE_SCRIPT = [
11716
+ `chmod -R u+rwX /work ${CONTAINER_HOME}`,
11717
+ "find /work -mindepth 1 -delete",
11718
+ `find ${CONTAINER_HOME} -mindepth 1 -delete`,
11719
+ `cp -a /etc/skel/. ${CONTAINER_HOME}/`,
11720
+ `mkdir -p ${CONTAINER_HOME}/.codex`,
11721
+ `cp /opt/cabane/codex-config.toml ${CONTAINER_HOME}/.codex/config.toml`,
11722
+ `rm -rf ${CONTAINER_STATE}/prepared`
11723
+ ].join(" && ");
11724
+ const provider = {
11725
+ name: "docker",
11726
+ inheritsEnvironment: false,
11727
+ async acquire(attempt) {
11728
+ const name = containerName(attempt);
11729
+ const before = queues.get(name) ?? Promise.resolve();
11730
+ let release;
11731
+ const mine = new Promise((resolve2) => release = resolve2);
11732
+ const tail = before.then(() => mine);
11733
+ queues.set(name, tail);
11734
+ void tail.then(() => {
11735
+ if (queues.get(name) === tail) queues.delete(name);
11736
+ });
11737
+ await before;
11738
+ try {
11739
+ await ensureNetwork();
11740
+ mkdirSync10(socketRoot, { recursive: true, mode: 448 });
11741
+ mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
11742
+ let seen = await docker.run([
11743
+ "inspect",
11744
+ "-f",
11745
+ '{{.State.Running}} {{index .Config.Labels "cabane.companion"}}',
11746
+ name
11747
+ ]);
11748
+ if (seen.code === 0 && seen.stdout.trim().split(" ")[1] !== companionLabel) {
11749
+ held.set(name, { attempt, release: () => {
11750
+ }, collected: false });
11751
+ let collected = false;
11752
+ try {
11753
+ await provider.collect({ provider: "docker", id: name }, { attemptId: null });
11754
+ collected = held.get(name)?.collected === true;
11755
+ } finally {
11756
+ held.delete(name);
11757
+ }
11758
+ if (!collected)
11759
+ throw new Error(
11760
+ "docker: this conversation's container runs another companion and what it holds couldn't be collected"
11761
+ );
11762
+ await destroy(name);
11763
+ log.info(
11764
+ { container: name },
11765
+ "runners: replaced a container made with another companion"
11766
+ );
11767
+ seen = { code: 1, stdout: "", stderr: "" };
11768
+ }
11769
+ if (seen.code !== 0) {
11770
+ await create(name, attempt);
11771
+ log.debug({ container: name }, "runners: container created");
11772
+ }
11773
+ if (seen.stdout.trim().split(" ")[0] !== "true")
11774
+ await must(["start", name], "starting the container");
11775
+ const git = await serveGitCredentials({
11776
+ socketPath: join17(socketDir(name), "git.sock"),
11777
+ hosts: opts.gitHosts,
11778
+ fill: opts.gitFill,
11779
+ log
11780
+ });
11781
+ held.set(name, { attempt, release, git, collected: false });
11782
+ return { provider: "docker", id: name };
11783
+ } catch (err) {
11784
+ const keep = opts.keepContainers || readPending(name).length > 0;
11785
+ await (keep ? docker.run(["stop", "-t", "1", name]) : destroy(name)).catch(() => {
11786
+ });
11787
+ release();
11788
+ throw err;
11789
+ }
11790
+ },
11791
+ async bindAttempt(handle, attemptId) {
11792
+ const name = handle.id;
11793
+ const marker = await execIn(name, ["cat", `${CONTAINER_STATE}/attempt`]);
11794
+ const bound = marker.code === 0 ? marker.stdout.trim() : "";
11795
+ if (bound === (attemptId ?? "")) return;
11796
+ const { attempt } = heldFor(handle);
11797
+ pruneOtherAttempts(opts.deviceDir, attempt, attemptId ?? "");
11798
+ if (bound) {
11799
+ const wiped = await execIn(name, ["sh", "-c", RETIRE_SCRIPT]);
11800
+ if (wiped.code !== 0)
11801
+ throw new Error(`docker: couldn't clear the retired attempt: ${wiped.stderr.trim()}`);
11802
+ log.info(
11803
+ { container: name, retired: bound, attemptId },
11804
+ "runners: retired attempt cleared"
11805
+ );
11806
+ }
11807
+ if (attemptId && UUID_RE.test(attemptId)) {
11808
+ const archive = sessionArchivePath(opts.deviceDir, attemptId);
11809
+ if (existsSync13(archive)) {
11810
+ const r = await execIn(name, ["tar", "-x", "-C", CONTAINER_HOME, "-f", "-"], {
11811
+ input: createReadStream(archive)
11812
+ });
11813
+ if (r.code !== 0) {
11814
+ log.warn(
11815
+ { container: name, attemptId, err: r.stderr.trim() },
11816
+ "runners: couldn't restore the session files; the runtime will start its session over"
11817
+ );
11818
+ } else {
11819
+ log.info(
11820
+ { container: name, attemptId, bytes: statSync3(archive).size },
11821
+ "runners: session files restored"
11822
+ );
11823
+ }
11824
+ } else {
11825
+ log.info(
11826
+ { container: name, attemptId },
11827
+ "runners: no session files kept for this attempt on this device"
11828
+ );
11829
+ }
11830
+ }
11831
+ await writeMarker(name, attemptId ?? "");
11832
+ },
11833
+ exec(handle, argv, env, { signal, stdin }) {
11834
+ const name = handle.id;
11835
+ const pidFile = `/tmp/cabane-runner-${randomUUID5()}.pid`;
11836
+ const passed = { ...env, ...opts.runtimeEnv?.() ?? {} };
11837
+ const literal = containerEnv();
11838
+ const args = [
11839
+ "exec",
11840
+ "-i",
11841
+ "-u",
11842
+ "runner",
11843
+ "-w",
11844
+ CONTAINER_HOME,
11845
+ ...Object.entries(literal).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
11846
+ ...Object.keys(passed).flatMap((k) => ["-e", k]),
11847
+ name,
11848
+ "sh",
11849
+ "-c",
11850
+ 'echo $$ > "$0" && exec "$@"',
11851
+ pidFile,
11852
+ ...argv
11853
+ ];
11854
+ const child = docker.spawn(args, passed);
11855
+ child.stdin?.on("error", () => {
11856
+ });
11857
+ if (stdin) child.stdin?.write(stdin);
11858
+ let forceTimer;
11859
+ const stop = () => {
11860
+ void execIn(name, ["sh", "-c", 'kill -TERM "$(cat "$1")"', "sh", pidFile]);
11861
+ forceTimer = setTimeout(() => void docker.run(["kill", name]), killGraceMs);
11862
+ forceTimer.unref?.();
11863
+ };
11864
+ const exit = new Promise((resolve2) => {
11865
+ let settled = false;
11866
+ const done = (code) => {
11867
+ if (settled) return;
11868
+ settled = true;
11869
+ signal.removeEventListener("abort", stop);
11870
+ if (forceTimer) clearTimeout(forceTimer);
11871
+ resolve2(code);
11872
+ };
11873
+ child.once("error", () => done(1));
11874
+ child.once("close", (code, sig) => {
11875
+ const signum = sig ? constants2.signals[sig] : void 0;
11876
+ done(code ?? (signum !== void 0 ? 128 + signum : 1));
11877
+ });
11878
+ if (signal.aborted) stop();
11879
+ else signal.addEventListener("abort", stop, { once: true });
11880
+ });
11881
+ if (!child.stdout || !child.stderr || !child.stdin)
11882
+ throw new Error("docker: runner stdio missing");
11883
+ return {
11884
+ exit,
11885
+ ...child.pid !== void 0 ? { pid: child.pid } : {},
11886
+ stdout: child.stdout,
11887
+ stderr: child.stderr,
11888
+ stdin: child.stdin,
11889
+ // The runner is the only thing this user runs in the container, so
11890
+ // everything it left is everything the user has left.
11891
+ async reap() {
11892
+ await execIn(name, [
11893
+ "sh",
11894
+ "-c",
11895
+ `kill -TERM -1 2>/dev/null; sleep ${REAP_GRACE_S}; kill -KILL -1 2>/dev/null; true`
11896
+ ]);
11897
+ }
11898
+ };
11899
+ },
11900
+ async collect(handle, { attemptId }) {
11901
+ const name = handle.id;
11902
+ const held2 = heldFor(handle);
11903
+ const { attempt } = held2;
11904
+ const turnIds = [.../* @__PURE__ */ new Set([attempt.turnId, ...readPending(name)])];
11905
+ const running = await ensureRunning(name);
11906
+ if (attemptId && UUID_RE.test(attemptId)) {
11907
+ if (!running) {
11908
+ dropArchive(attemptId);
11909
+ log.warn(
11910
+ { container: name, attemptId },
11911
+ "runners: couldn't reach the container to keep the session files; the next turn starts the session over"
11912
+ );
11913
+ } else {
11914
+ await keepSession(name, attempt, attemptId);
11915
+ }
11916
+ }
11917
+ if (!running) {
11918
+ writePending(name, turnIds);
11919
+ return;
11920
+ }
11921
+ const transcripts = await readDir(name, `${CONTAINER_STATE}/transcripts`);
11922
+ if (transcripts) {
11923
+ const kept = transcripts.files.filter((f) => SAFE_NAME_RE.test(f.name));
11924
+ if (kept.length) mkdirSync10(join17(opts.hostStateDir, "transcripts"), { recursive: true });
11925
+ for (const f of kept) writeFileSync6(join17(opts.hostStateDir, "transcripts", f.name), f.data);
11926
+ await removeIn(
11927
+ name,
11928
+ `${CONTAINER_STATE}/transcripts`,
11929
+ kept.map((f) => f.name)
11930
+ );
11931
+ }
11932
+ const outboxRel = join17("outbox", encodeURIComponent(attempt.agentId));
11933
+ const outbox = await readDir(name, `${CONTAINER_STATE}/${outboxRel}`);
11934
+ let unpublished = false;
11935
+ if (outbox) {
11936
+ const scope = {
11937
+ workspaceId: attempt.workspaceId,
11938
+ conversationId: attempt.conversationId,
11939
+ agentId: attempt.agentId,
11940
+ turnIds
11941
+ };
11942
+ let queued = 0;
11943
+ let refused = 0;
11944
+ for (const f of outbox.files) {
11945
+ let entry = null;
11946
+ try {
11947
+ entry = admitCollectedEntry(JSON.parse(f.data.toString("utf8")), scope);
11948
+ } catch {
11949
+ entry = null;
11950
+ }
11951
+ if (!entry) {
11952
+ refused++;
11953
+ continue;
11954
+ }
11955
+ const staged = stagingDir(name);
11956
+ mkdirSync10(staged, { recursive: true });
11957
+ writeFileSync6(
11958
+ join17(staged, `${encodeURIComponent(entry.turnId)}__${entry.seq}.json`),
11959
+ `${JSON.stringify(entry)}
11960
+ `
11961
+ );
11962
+ }
11963
+ await removeIn(
11964
+ name,
11965
+ `${CONTAINER_STATE}/${outboxRel}`,
11966
+ outbox.files.map((f) => f.name)
11967
+ );
11968
+ if (outbox.complete) {
11969
+ const published = publishStaged(
11970
+ stagingDir(name),
11971
+ join17(opts.hostStateDir, outboxRel),
11972
+ opts.moveFile
11973
+ );
11974
+ queued = published.published;
11975
+ if (!published.complete) {
11976
+ unpublished = true;
11977
+ log.warn(
11978
+ { container: name },
11979
+ "runners: couldn't hand all of the runner's outbox to the companion; the rest waits for the next collection"
11980
+ );
11981
+ }
11982
+ }
11983
+ if (refused) {
11984
+ log.warn(
11985
+ { container: name, refused },
11986
+ "runners: refused outbox entries a runner's own credential couldn't have sent"
11987
+ );
11988
+ }
11989
+ if (queued) {
11990
+ log.info(
11991
+ { container: name, queued },
11992
+ "runners: delivering what the runner left in its outbox"
11993
+ );
11994
+ opts.onOutboxCollected?.(attempt.agentId);
11995
+ }
11996
+ }
11997
+ held2.collected = !!transcripts?.complete && !!outbox?.complete && !unpublished;
11998
+ writePending(name, held2.collected ? [] : turnIds);
11999
+ },
12000
+ async release(handle, { destroy: over }) {
12001
+ const h = held.get(handle.id);
12002
+ held.delete(handle.id);
12003
+ try {
12004
+ await h?.git?.close();
12005
+ if (h && !h.collected) {
12006
+ log.warn(
12007
+ { container: handle.id },
12008
+ "runners: kept the container: what its runner left couldn't be collected yet"
12009
+ );
12010
+ await docker.run(["stop", "-t", "10", handle.id]);
12011
+ } else if (over || !opts.keepContainers) await destroy(handle.id);
12012
+ else await docker.run(["stop", "-t", "10", handle.id]);
12013
+ } finally {
12014
+ h?.release();
12015
+ }
12016
+ }
12017
+ };
12018
+ return provider;
12019
+ }
12020
+
12021
+ // src/providers/docker-setup.ts
12022
+ import { createHash as createHash5 } from "crypto";
12023
+ var MODEL_HOSTS = [
12024
+ "api.anthropic.com",
12025
+ "api.openai.com",
12026
+ "chatgpt.com",
12027
+ "auth.openai.com"
12028
+ ];
12029
+ var GIT_HOSTS = ["github.com"];
12030
+ function dockerRunnerSettings(config) {
12031
+ const docker = config.runners?.docker ?? {};
12032
+ const apiUrl = docker.apiUrl ?? config.baseUrl;
12033
+ const apiHost = new URL(apiUrl).host;
12034
+ const egressAllow = [
12035
+ .../* @__PURE__ */ new Set([apiHost, ...MODEL_HOSTS, ...GIT_HOSTS, ...docker.egressAllow ?? []])
12036
+ ];
12037
+ return {
12038
+ image: docker.image ?? baseImageTag(),
12039
+ keepContainers: docker.keepContainers === true,
12040
+ apiUrl,
12041
+ egressAllow,
12042
+ gitHosts: [...GIT_HOSTS]
12043
+ };
12044
+ }
12045
+ function loopbackApi(apiUrl) {
12046
+ const host = new URL(apiUrl).hostname;
12047
+ return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
12048
+ }
12049
+ function deviceKey(config) {
12050
+ const seed = config.deviceId ?? config.baseUrl;
12051
+ return createHash5("sha256").update(seed).digest("hex").slice(0, 12);
12052
+ }
12053
+ function resolveRuntimeEnv(env, store, log) {
12054
+ const out = {};
12055
+ for (const [name, template] of Object.entries(env ?? {})) {
12056
+ const missing = [];
12057
+ const value = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, key) => {
12058
+ const v = store.get(key);
12059
+ if (v === void 0) missing.push(key);
12060
+ return v ?? "";
12061
+ });
12062
+ if (missing.length) {
12063
+ log?.warn({ name, missing }, "runners: a runner env value names secrets this device lacks");
12064
+ continue;
12065
+ }
12066
+ out[name] = value;
12067
+ }
12068
+ return out;
12069
+ }
12070
+ async function probeRunner(docker, image, source, log) {
12071
+ const r = await docker.run(
12072
+ [
12073
+ "run",
12074
+ "--rm",
12075
+ "--network",
12076
+ "none",
12077
+ ...companionMountArgs(source),
12078
+ image,
12079
+ "cabane-companion",
12080
+ "run-turn",
12081
+ "--manifest"
12082
+ ],
12083
+ { timeoutMs: 6e4 }
12084
+ );
12085
+ try {
12086
+ const manifest = JSON.parse(r.stdout.trim());
12087
+ log.info(
12088
+ { image, version: manifest.version, bundled: manifest.bundled },
12089
+ "runners: runner ready"
12090
+ );
12091
+ return manifest;
12092
+ } catch {
12093
+ log.error(
12094
+ { image, err: r.stderr.trim().slice(-2e3) },
12095
+ "runners: the companion didn't start in a runner container"
12096
+ );
12097
+ return null;
12098
+ }
12099
+ }
12100
+ function imageResolution(manifest, image, runtime) {
12101
+ const status = manifest?.bundled[runtime] ?? "platform_package_missing";
12102
+ return {
12103
+ runtime,
12104
+ status,
12105
+ path: status === "present" ? `${image} (bundled)` : null,
12106
+ packageName: null,
12107
+ platform: "linux",
12108
+ arch: "image"
12109
+ };
12110
+ }
12111
+ function containerHookPath(hostPath) {
12112
+ const name = hostPath.split("/").pop() || "hook";
12113
+ const tag = createHash5("sha256").update(hostPath).digest("hex").slice(0, 8);
12114
+ return `/opt/cabane/hooks/${tag}-${name}`;
12115
+ }
12116
+
12117
+ // src/runner/device-locks.ts
12118
+ var DeviceLocks = class {
12119
+ holders = /* @__PURE__ */ new Map();
12120
+ queues = /* @__PURE__ */ new Map();
12121
+ // `owner` is unique across the device; `grant` is called once, when it holds.
12122
+ request(key, owner, grant) {
12123
+ if (!this.holders.has(key)) {
12124
+ this.holders.set(key, owner);
12125
+ grant();
12126
+ return;
12127
+ }
12128
+ const queue = this.queues.get(key) ?? [];
10756
12129
  queue.push({ owner, grant });
10757
12130
  this.queues.set(key, queue);
10758
12131
  }
@@ -10798,10 +12171,10 @@ var DeviceLocks = class {
10798
12171
 
10799
12172
  // src/runner/harness-sandbox.ts
10800
12173
  import { lookup } from "dns/promises";
10801
- import { chmodSync as chmodSync5, mkdirSync as mkdirSync9, mkdtempSync, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
12174
+ import { chmodSync as chmodSync6, mkdirSync as mkdirSync11, mkdtempSync, rmSync as rmSync10, writeFileSync as writeFileSync7 } from "fs";
10802
12175
  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";
12176
+ import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
12177
+ import { join as join18 } from "path";
10805
12178
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
10806
12179
  function osSandboxUsable(log) {
10807
12180
  if (!SandboxManager.isSupportedPlatform()) {
@@ -10900,15 +12273,15 @@ var DispatchClaims = class {
10900
12273
 
10901
12274
  // src/outbox.ts
10902
12275
  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
12276
+ existsSync as existsSync14,
12277
+ mkdirSync as mkdirSync12,
12278
+ readdirSync as readdirSync5,
12279
+ readFileSync as readFileSync10,
12280
+ renameSync as renameSync6,
12281
+ rmSync as rmSync11,
12282
+ writeFileSync as writeFileSync8
10910
12283
  } from "fs";
10911
- import { join as join16 } from "path";
12284
+ import { join as join19 } from "path";
10912
12285
  var MAX_ENTRIES = 2e3;
10913
12286
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
10914
12287
  var Outbox = class {
@@ -10921,25 +12294,25 @@ var Outbox = class {
10921
12294
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
10922
12295
  // cases route writes at the right tmpdir.
10923
12296
  dir() {
10924
- return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
12297
+ return join19(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
10925
12298
  }
10926
12299
  fileFor(turnId, seq) {
10927
- return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
12300
+ return join19(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10928
12301
  }
10929
12302
  // Persist a commit for later draining. Atomic (temp file + rename) so a
10930
12303
  // concurrent `list()` never reads a half-written entry, then enforces the
10931
12304
  // per-workspace bounds.
10932
12305
  persist(entry) {
10933
12306
  const dir2 = this.dir();
10934
- mkdirSync10(dir2, { recursive: true });
12307
+ mkdirSync12(dir2, { recursive: true });
10935
12308
  const target = this.fileFor(entry.turnId, entry.seq);
10936
12309
  const tmp = `${target}.${process.pid}.tmp`;
10937
12310
  try {
10938
- writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
10939
- renameSync4(tmp, target);
12311
+ writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
12312
+ renameSync6(tmp, target);
10940
12313
  } catch (err) {
10941
12314
  try {
10942
- rmSync8(tmp, { force: true });
12315
+ rmSync11(tmp, { force: true });
10943
12316
  } catch {
10944
12317
  }
10945
12318
  this.log?.warn(
@@ -10956,19 +12329,19 @@ var Outbox = class {
10956
12329
  // wedging the drain.
10957
12330
  list() {
10958
12331
  const dir2 = this.dir();
10959
- if (!existsSync11(dir2)) return [];
12332
+ if (!existsSync14(dir2)) return [];
10960
12333
  let names;
10961
12334
  try {
10962
- names = readdirSync3(dir2);
12335
+ names = readdirSync5(dir2);
10963
12336
  } catch {
10964
12337
  return [];
10965
12338
  }
10966
12339
  const entries = [];
10967
12340
  for (const name of names) {
10968
12341
  if (!name.endsWith(".json")) continue;
10969
- const full = join16(dir2, name);
12342
+ const full = join19(dir2, name);
10970
12343
  try {
10971
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
12344
+ const parsed = JSON.parse(readFileSync10(full, "utf8"));
10972
12345
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
10973
12346
  entries.push(parsed);
10974
12347
  } else {
@@ -11002,8 +12375,8 @@ var Outbox = class {
11002
12375
  // different: it is positive evidence that nothing was ever queued here.
11003
12376
  maxSeqForTurn(turnId) {
11004
12377
  const dir2 = this.dir();
11005
- if (!existsSync11(dir2)) return 0;
11006
- const names = readdirSync3(dir2);
12378
+ if (!existsSync14(dir2)) return 0;
12379
+ const names = readdirSync5(dir2);
11007
12380
  const prefix = `${encodeURIComponent(turnId)}__`;
11008
12381
  let max = 0;
11009
12382
  for (const name of names) {
@@ -11016,15 +12389,15 @@ var Outbox = class {
11016
12389
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
11017
12390
  remove(turnId, seq) {
11018
12391
  try {
11019
- rmSync8(this.fileFor(turnId, seq), { force: true });
12392
+ rmSync11(this.fileFor(turnId, seq), { force: true });
11020
12393
  } catch {
11021
12394
  }
11022
12395
  }
11023
12396
  size() {
11024
12397
  const dir2 = this.dir();
11025
- if (!existsSync11(dir2)) return 0;
12398
+ if (!existsSync14(dir2)) return 0;
11026
12399
  try {
11027
- return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
12400
+ return readdirSync5(dir2).filter((n) => n.endsWith(".json")).length;
11028
12401
  } catch {
11029
12402
  return 0;
11030
12403
  }
@@ -11035,7 +12408,7 @@ var Outbox = class {
11035
12408
  "A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
11036
12409
  );
11037
12410
  try {
11038
- rmSync8(full, { force: true });
12411
+ rmSync11(full, { force: true });
11039
12412
  } catch {
11040
12413
  }
11041
12414
  }
@@ -11282,8 +12655,27 @@ var CompanionSupervisor = class {
11282
12655
  if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
11283
12656
  if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
11284
12657
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
12658
+ if (this.dockerRunners()) {
12659
+ const { image } = dockerRunnerSettings(this.config);
12660
+ this.resolveBundled = (runtime) => imageResolution(this.runnerImage, image, runtime);
12661
+ }
11285
12662
  this.latchBootBundledState();
11286
12663
  }
12664
+ // CT1681: what a runner container said it can run, probed once at start;
12665
+ // null until then, and when no container could be started.
12666
+ runnerImage = null;
12667
+ // CT1681: the companion every container mounts — this one's own install.
12668
+ runnerCompanion() {
12669
+ return companionSource({
12670
+ configured: this.config.runners?.docker?.companionDir,
12671
+ platform: process.platform,
12672
+ version: COMPANION_VERSION,
12673
+ own: ownInstall()
12674
+ });
12675
+ }
12676
+ dockerRunners() {
12677
+ return runnersEnabled(this.config) && this.config.runners?.provider === "docker";
12678
+ }
11287
12679
  // Stand up the data plane: pair check, initial assignments pull, then the
11288
12680
  // heartbeat + poll loops. A companion with no device token (logged out) does
11289
12681
  // nothing but say so.
@@ -11292,6 +12684,24 @@ var CompanionSupervisor = class {
11292
12684
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
11293
12685
  `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
11294
12686
  );
12687
+ if (this.dockerRunners() && !this.providerForRunners) {
12688
+ const { image, apiUrl } = dockerRunnerSettings(this.config);
12689
+ if (loopbackApi(apiUrl)) {
12690
+ this.log.error(
12691
+ { apiUrl },
12692
+ "runners: a container can't reach this machine's loopback; set runners.docker.apiUrl to an address it can"
12693
+ );
12694
+ }
12695
+ const source = this.runnerCompanion();
12696
+ const docker = dockerCli();
12697
+ if ("error" in source) {
12698
+ this.log.error({ reason: source.error }, "runners: no companion to put in a container");
12699
+ } else if (await ensureBaseImage(docker, image, this.log) && await ensureCompanionVolume(docker, image, source, this.log)) {
12700
+ this.runnerImage = await probeRunner(docker, image, source, this.log);
12701
+ if (!this.runnerImage) await invalidateCompanionVolume(docker, image, source);
12702
+ }
12703
+ this.latchBootBundledState();
12704
+ }
11295
12705
  const startupHarnessRefresh = this.refreshHarnessStatuses();
11296
12706
  this.trackHeartbeat(startupHarnessRefresh);
11297
12707
  if (!this.config.deviceToken) {
@@ -11399,7 +12809,9 @@ var CompanionSupervisor = class {
11399
12809
  runtimeIssues: this.runtimeIssues(),
11400
12810
  // CT571/CT586: each runtime's `version` from the latest harness probe
11401
12811
  // (fail-soft to null). Informational only — the server matches on name.
11402
- versions: this.harnessVersions
12812
+ versions: this.harnessVersions,
12813
+ // CT1681: shown on the device page.
12814
+ ...runnersEnabled(this.config) ? { runnerProvider: this.config.runners?.provider ?? "local-process" } : {}
11403
12815
  }),
11404
12816
  // CT566: echo the last classified credential state per runtime, when the
11405
12817
  // companion has seen any. Omitted (undefined) until the first observed
@@ -11732,21 +13144,71 @@ var CompanionSupervisor = class {
11732
13144
  // can't make the sandbox says so once and runs its turns unwrapped.
11733
13145
  sandboxUsable;
11734
13146
  runnerSandbox() {
11735
- if (this.config.runners?.sandbox !== "os") return "none";
13147
+ if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
11736
13148
  this.sandboxUsable ??= osSandboxUsable(this.log);
11737
13149
  return this.sandboxUsable ? "os" : "none";
11738
13150
  }
11739
13151
  // CT1679: one provider for the device, built on first use.
11740
13152
  runnerProvider() {
11741
- this.providerForRunners ??= createLocalProcessProvider();
13153
+ this.providerForRunners ??= this.dockerRunners() ? this.createDockerRunners() : createLocalProcessProvider();
11742
13154
  return this.providerForRunners;
11743
13155
  }
13156
+ createDockerRunners() {
13157
+ const settings = dockerRunnerSettings(this.config);
13158
+ const docker = this.config.runners?.docker ?? {};
13159
+ const hooks = [
13160
+ this.config.prepareHook?.command,
13161
+ ...Object.values(this.config.agents ?? {}).map((a) => a.prepareHook?.command)
13162
+ ].filter((c) => typeof c === "string");
13163
+ const source = this.runnerCompanion();
13164
+ return createDockerProvider({
13165
+ image: settings.image,
13166
+ // Without a companion nothing is advertised, so nothing is sent; a turn
13167
+ // that arrives anyway fails at the mount and says so.
13168
+ companion: "error" in source ? { kind: "dir", path: "/nonexistent", key: "none" } : source,
13169
+ deviceKey: deviceKey(this.config),
13170
+ keepContainers: settings.keepContainers,
13171
+ egressAllow: settings.egressAllow,
13172
+ gitHosts: settings.gitHosts,
13173
+ gitFill: deviceGitFill(docker.gitCredentialHelper),
13174
+ runtimeEnv: () => resolveRuntimeEnv(
13175
+ docker.env,
13176
+ loadSecretStoreTolerant(
13177
+ (m) => this.log.warn(
13178
+ { err: m },
13179
+ "Could not read the secrets file. Check ~/.cabane/secrets.json."
13180
+ )
13181
+ ),
13182
+ this.log
13183
+ ),
13184
+ mounts: [...new Set(hooks)].map((source2) => ({ source: source2, target: containerHookPath(source2) })),
13185
+ deviceDir: cabaneDir(),
13186
+ hostStateDir: runnerStateDir(),
13187
+ onOutboxCollected: (agentId) => {
13188
+ for (const wr of this.workspaces.values()) wr.agents.get(agentId)?.kickDrain();
13189
+ },
13190
+ log: this.log
13191
+ });
13192
+ }
13193
+ // CT1681: the runner command for this device's provider: the image's own
13194
+ // companion in a container, this process's otherwise.
13195
+ runnerCommandFor() {
13196
+ if (this.runnerCommand) return this.runnerCommand;
13197
+ return this.dockerRunners() ? ["cabane-companion", "run-turn"] : selfRunnerCommand();
13198
+ }
11744
13199
  buildDispatcher(ctx) {
11745
- const local = localAgentConfig(this.config, {
13200
+ const hostLocal = localAgentConfig(this.config, {
11746
13201
  agentId: ctx.agentId,
11747
13202
  agentUsername: ctx.agentUsername,
11748
13203
  workspaceSlug: ctx.workspaceSlug
11749
13204
  });
13205
+ const local = this.dockerRunners() && hostLocal.prepareHook ? {
13206
+ ...hostLocal,
13207
+ prepareHook: {
13208
+ ...hostLocal.prepareHook,
13209
+ command: containerHookPath(hostLocal.prepareHook.command)
13210
+ }
13211
+ } : hostLocal;
11750
13212
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
11751
13213
  return new Dispatcher({
11752
13214
  deviceId: this.deviceId ?? void 0,
@@ -11795,7 +13257,8 @@ var CompanionSupervisor = class {
11795
13257
  ...runnersEnabled(this.config) ? {
11796
13258
  runner: {
11797
13259
  provider: this.runnerProvider(),
11798
- command: this.runnerCommand ?? selfRunnerCommand(),
13260
+ command: this.runnerCommandFor(),
13261
+ ...this.dockerRunners() ? { runnerBaseUrl: dockerRunnerSettings(this.config).apiUrl } : {},
11799
13262
  reportRunnerGone: (report) => this.reportRunnerGone(report),
11800
13263
  connectorHealth: this.connectorHealth,
11801
13264
  locks: this.runnerLocks,
@@ -12119,7 +13582,7 @@ var CompanionSupervisor = class {
12119
13582
  );
12120
13583
  }
12121
13584
  const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
12122
- const turnId = resumedTurnId ?? randomUUID5();
13585
+ const turnId = resumedTurnId ?? randomUUID6();
12123
13586
  if (dispatchId) {
12124
13587
  const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
12125
13588
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -12424,9 +13887,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
12424
13887
  }
12425
13888
  function defaultReexec() {
12426
13889
  clearRuntimeState();
12427
- void import("child_process").then(({ spawn: spawn7 }) => {
13890
+ void import("child_process").then(({ spawn: spawn9 }) => {
12428
13891
  try {
12429
- const child = spawn7(process.execPath, process.argv.slice(1), {
13892
+ const child = spawn9(process.execPath, process.argv.slice(1), {
12430
13893
  stdio: "inherit",
12431
13894
  detached: false
12432
13895
  });
@@ -12496,22 +13959,22 @@ function handleUncaught(log, err, origin) {
12496
13959
  }
12497
13960
 
12498
13961
  // 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";
13962
+ import { existsSync as existsSync15, mkdirSync as mkdirSync13, readFileSync as readFileSync11, rmSync as rmSync12, writeFileSync as writeFileSync9 } from "fs";
13963
+ import { join as join20 } from "path";
12501
13964
  function crashMarkerPath() {
12502
- return join17(cabaneDir(), "last-error.json");
13965
+ return join20(cabaneDir(), "last-error.json");
12503
13966
  }
12504
13967
  function recordCrash(rec) {
12505
13968
  try {
12506
- mkdirSync11(cabaneDir(), { recursive: true });
12507
- writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
13969
+ mkdirSync13(cabaneDir(), { recursive: true });
13970
+ writeFileSync9(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
12508
13971
  } catch {
12509
13972
  }
12510
13973
  }
12511
13974
  function clearCrash() {
12512
13975
  try {
12513
13976
  const path = crashMarkerPath();
12514
- if (existsSync12(path)) rmSync9(path, { force: true });
13977
+ if (existsSync15(path)) rmSync12(path, { force: true });
12515
13978
  } catch {
12516
13979
  }
12517
13980
  }
@@ -12554,7 +14017,7 @@ async function createCompanionRuntime(opts = {}) {
12554
14017
  opencodeServerUrl: cfg.opencode?.serverUrl,
12555
14018
  codex: isCodexEnabled(cfg)
12556
14019
  });
12557
- const instanceId = randomUUID6();
14020
+ const instanceId = randomUUID7();
12558
14021
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
12559
14022
  const pre = readLiveRuntimeState();
12560
14023
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();