@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/cli.js CHANGED
@@ -543,8 +543,38 @@ var init_config = __esm({
543
543
  // configuration in its own right rather than a degraded one.
544
544
  runners: z2.object({
545
545
  enabled: z2.boolean().optional(),
546
- provider: z2.literal("local-process").optional(),
547
- sandbox: z2.enum(["os", "none"]).optional()
546
+ provider: z2.enum(["local-process", "docker"]).optional(),
547
+ sandbox: z2.enum(["os", "none"]).optional(),
548
+ // CT1681: the `docker` provider — a container per conversation and agent,
549
+ // from one image, on a network whose only way out is an allow-listed proxy.
550
+ docker: z2.object({
551
+ // The base image a container runs. Default: the one this companion
552
+ // builds from its own Dockerfile on first use. One named here must
553
+ // have that one's layout (the `runner` user, `cabane-companion` on
554
+ // PATH reading `CABANE_COMPANION_ROOT`).
555
+ image: z2.string().min(1).optional(),
556
+ // The companion install mounted into every container. Default, on
557
+ // Linux: this companion's own. Needed for one run from a checkout
558
+ // (a `pnpm --filter @cabane/companion deploy --prod <dir>`).
559
+ companionDir: z2.string().min(1).optional(),
560
+ // Hosts a turn may reach besides the Cabane API, the model providers
561
+ // and the git host. `*.example.com` matches any subdomain.
562
+ egressAllow: z2.array(z2.string().min(1)).optional(),
563
+ // Keep a turn's container between turns and start it again for the
564
+ // next one. Off by default: the container goes after every turn and
565
+ // the runtime's session files are restored into the next.
566
+ keepContainers: z2.boolean().optional(),
567
+ // The Cabane URL as a container reaches it, for a device whose own
568
+ // `baseUrl` is a loopback address the container can't see.
569
+ apiUrl: z2.string().url().refine(isAllowedBaseUrl).optional(),
570
+ // Env set on each turn's runner, `${NAME}` resolved from secrets.json
571
+ // at exec: the runtime's own auth, which the image never carries.
572
+ env: z2.record(z2.string(), z2.string()).optional(),
573
+ // A git credential helper run on this device to answer the
574
+ // container's git for an allowed host, e.g. `!gh auth git-credential`.
575
+ // Absent: git's own configured helpers on this device.
576
+ gitCredentialHelper: z2.string().min(1).optional()
577
+ }).strict().optional()
548
578
  }).strict().optional()
549
579
  });
550
580
  }
@@ -2543,8 +2573,8 @@ var init_policy = __esm({
2543
2573
  // packages/agent-runtime/src/claude-code/model.ts
2544
2574
  function parseClaudeCodeModel(model) {
2545
2575
  if (!model) return null;
2546
- const sep = model.indexOf("/");
2547
- const bare = sep === -1 || model.slice(0, sep) !== "anthropic" ? model : model.slice(sep + 1);
2576
+ const sep2 = model.indexOf("/");
2577
+ const bare = sep2 === -1 || model.slice(0, sep2) !== "anthropic" ? model : model.slice(sep2 + 1);
2548
2578
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
2549
2579
  }
2550
2580
  var CLAUDE_CODE_DEFAULT_MODEL_BARE;
@@ -3991,9 +4021,9 @@ var init_policy2 = __esm({
3991
4021
 
3992
4022
  // packages/agent-runtime/src/opencode/model.ts
3993
4023
  function parseOpencodeModel(model) {
3994
- const sep = model.indexOf("/");
3995
- if (sep === -1) return { providerID: "", modelID: model };
3996
- return { providerID: model.slice(0, sep), modelID: model.slice(sep + 1) };
4024
+ const sep2 = model.indexOf("/");
4025
+ if (sep2 === -1) return { providerID: "", modelID: model };
4026
+ return { providerID: model.slice(0, sep2), modelID: model.slice(sep2 + 1) };
3997
4027
  }
3998
4028
  var init_model2 = __esm({
3999
4029
  "packages/agent-runtime/src/opencode/model.ts"() {
@@ -4262,10 +4292,10 @@ async function* parseSseStream(body, sessionId, signal) {
4262
4292
  const { value, done } = await reader.read();
4263
4293
  if (done) return;
4264
4294
  buf += decoder.decode(value, { stream: true });
4265
- let sep;
4266
- while ((sep = buf.indexOf("\n\n")) !== -1) {
4267
- const frame = buf.slice(0, sep);
4268
- buf = buf.slice(sep + 2);
4295
+ let sep2;
4296
+ while ((sep2 = buf.indexOf("\n\n")) !== -1) {
4297
+ const frame = buf.slice(0, sep2);
4298
+ buf = buf.slice(sep2 + 2);
4269
4299
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
4270
4300
  if (!data) continue;
4271
4301
  let ev;
@@ -5291,8 +5321,8 @@ var init_policy3 = __esm({
5291
5321
 
5292
5322
  // packages/agent-runtime/src/codex/model.ts
5293
5323
  function parseCodexModel(model) {
5294
- const sep = model.indexOf("/");
5295
- const bare = sep === -1 || model.slice(0, sep) !== "openai" ? model : model.slice(sep + 1);
5324
+ const sep2 = model.indexOf("/");
5325
+ const bare = sep2 === -1 || model.slice(0, sep2) !== "openai" ? model : model.slice(sep2 + 1);
5296
5326
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
5297
5327
  }
5298
5328
  var CODEX_DEFAULT_MODEL_BARES;
@@ -6990,8 +7020,107 @@ var init_turn_containment = __esm({
6990
7020
  }
6991
7021
  });
6992
7022
 
6993
- // src/run-config.ts
7023
+ // src/secrets.ts
7024
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
7025
+ import { join as join10 } from "path";
6994
7026
  import { z as z13 } from "zod";
7027
+ function secretsPath() {
7028
+ return join10(cabaneDir(), "secrets.json");
7029
+ }
7030
+ function loadSecretStore() {
7031
+ const path = secretsPath();
7032
+ if (!existsSync7(path)) return makeStore({});
7033
+ let raw;
7034
+ try {
7035
+ raw = readFileSync6(path, "utf8");
7036
+ } catch (err) {
7037
+ throw new ConfigError(
7038
+ `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
7039
+ );
7040
+ }
7041
+ if (raw.trim().length === 0) return makeStore({});
7042
+ let parsed;
7043
+ try {
7044
+ parsed = JSON.parse(raw);
7045
+ } catch (err) {
7046
+ throw new ConfigError(
7047
+ `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
7048
+ );
7049
+ }
7050
+ const result = secretStoreSchema.safeParse(parsed);
7051
+ if (!result.success) {
7052
+ throw new ConfigError(
7053
+ `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
7054
+ );
7055
+ }
7056
+ return makeStore(result.data);
7057
+ }
7058
+ function loadSecretStoreTolerant(onWarn) {
7059
+ try {
7060
+ return loadSecretStore();
7061
+ } catch (err) {
7062
+ onWarn?.(err instanceof Error ? err.message : String(err));
7063
+ return makeStore({});
7064
+ }
7065
+ }
7066
+ function makeStore(map) {
7067
+ return {
7068
+ get: (name) => map[name],
7069
+ names: () => Object.keys(map).sort()
7070
+ };
7071
+ }
7072
+ function requiredSecretNames(mcpServers) {
7073
+ const names = /* @__PURE__ */ new Set();
7074
+ for (const server of Object.values(mcpServers ?? {})) {
7075
+ const bags = [
7076
+ "env" in server ? server.env : void 0,
7077
+ "headers" in server ? server.headers : void 0
7078
+ ];
7079
+ for (const bag of bags) {
7080
+ for (const value of Object.values(bag ?? {})) {
7081
+ for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
7082
+ }
7083
+ }
7084
+ }
7085
+ return [...names].sort();
7086
+ }
7087
+ function resolveMcpSecrets(mcpServers, store) {
7088
+ const missing = /* @__PURE__ */ new Set();
7089
+ const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
7090
+ const resolved = store.get(name);
7091
+ if (resolved === void 0) {
7092
+ missing.add(name);
7093
+ return _match;
7094
+ }
7095
+ return resolved;
7096
+ });
7097
+ const mapBag = (bag) => {
7098
+ const out = {};
7099
+ for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
7100
+ return out;
7101
+ };
7102
+ const servers = {};
7103
+ for (const [name, server] of Object.entries(mcpServers)) {
7104
+ const next = { ...server };
7105
+ if ("env" in next && next.env) next.env = mapBag(next.env);
7106
+ if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
7107
+ servers[name] = next;
7108
+ }
7109
+ return { servers, missing: [...missing].sort() };
7110
+ }
7111
+ var PLACEHOLDER_RE, secretStoreSchema;
7112
+ var init_secrets = __esm({
7113
+ "src/secrets.ts"() {
7114
+ "use strict";
7115
+ init_config();
7116
+ init_errors();
7117
+ PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
7118
+ secretStoreSchema = z13.record(z13.string(), z13.string());
7119
+ }
7120
+ });
7121
+
7122
+ // src/run-config.ts
7123
+ import { z as z14 } from "zod";
6995
7124
  function parseRunConfig(raw, onWarn) {
6996
7125
  const result = runConfigSchema.safeParse(raw ?? {});
6997
7126
  if (result.success) return result.data;
@@ -7004,38 +7133,38 @@ var mcpStdioServerSchema, mcpHttpServerSchema, mcpSseServerSchema, mcpServerDefS
7004
7133
  var init_run_config = __esm({
7005
7134
  "src/run-config.ts"() {
7006
7135
  "use strict";
7007
- mcpStdioServerSchema = z13.object({
7008
- type: z13.literal("stdio").optional(),
7009
- command: z13.string().min(1),
7010
- args: z13.array(z13.string()).optional(),
7011
- env: z13.record(z13.string(), z13.string()).optional()
7136
+ mcpStdioServerSchema = z14.object({
7137
+ type: z14.literal("stdio").optional(),
7138
+ command: z14.string().min(1),
7139
+ args: z14.array(z14.string()).optional(),
7140
+ env: z14.record(z14.string(), z14.string()).optional()
7012
7141
  });
7013
- mcpHttpServerSchema = z13.object({
7014
- type: z13.literal("http"),
7015
- url: z13.string().url(),
7016
- headers: z13.record(z13.string(), z13.string()).optional()
7142
+ mcpHttpServerSchema = z14.object({
7143
+ type: z14.literal("http"),
7144
+ url: z14.string().url(),
7145
+ headers: z14.record(z14.string(), z14.string()).optional()
7017
7146
  });
7018
- mcpSseServerSchema = z13.object({
7019
- type: z13.literal("sse"),
7020
- url: z13.string().url(),
7021
- headers: z13.record(z13.string(), z13.string()).optional()
7147
+ mcpSseServerSchema = z14.object({
7148
+ type: z14.literal("sse"),
7149
+ url: z14.string().url(),
7150
+ headers: z14.record(z14.string(), z14.string()).optional()
7022
7151
  });
7023
- mcpServerDefSchema = z13.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
7024
- thinkingConfigSchema = z13.discriminatedUnion("type", [
7025
- z13.object({ type: z13.literal("adaptive") }),
7026
- z13.object({ type: z13.literal("enabled"), budgetTokens: z13.number().int().positive().optional() }),
7027
- z13.object({ type: z13.literal("disabled") })
7152
+ mcpServerDefSchema = z14.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
7153
+ thinkingConfigSchema = z14.discriminatedUnion("type", [
7154
+ z14.object({ type: z14.literal("adaptive") }),
7155
+ z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
7156
+ z14.object({ type: z14.literal("disabled") })
7028
7157
  ]);
7029
- effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
7030
- runConfigSchema = z13.object({
7158
+ effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
7159
+ runConfigSchema = z14.object({
7031
7160
  // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
7032
7161
  // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
7033
7162
  // is the locked surface. Kept in lockstep with `@cabane/shared`'s
7034
7163
  // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
7035
7164
  // stripped, so an older companion riding a newer server never rejects the config).
7036
- hostAccess: z13.boolean().optional(),
7037
- mcpServers: z13.record(z13.string(), mcpServerDefSchema).optional(),
7038
- model: z13.string().min(1).optional(),
7165
+ hostAccess: z14.boolean().optional(),
7166
+ mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
7167
+ model: z14.string().min(1).optional(),
7039
7168
  thinking: thinkingConfigSchema.optional(),
7040
7169
  effort: effortSchema.optional()
7041
7170
  });
@@ -7044,7 +7173,7 @@ var init_run_config = __esm({
7044
7173
  });
7045
7174
 
7046
7175
  // src/runner/protocol.ts
7047
- import { z as z14 } from "zod";
7176
+ import { z as z15 } from "zod";
7048
7177
  function required(env, name) {
7049
7178
  const value = env[name];
7050
7179
  if (!value) throw new Error(`runner: ${name} is not set`);
@@ -7122,56 +7251,64 @@ var init_protocol = __esm({
7122
7251
  // Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
7123
7252
  spec: "CABANE_RUNNER_SPEC"
7124
7253
  };
7125
- runtimeHealthSchema = z14.object({
7126
- quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
7127
- limitedUntil: z14.string().nullable(),
7128
- lastError: z14.string().nullable()
7254
+ runtimeHealthSchema = z15.object({
7255
+ quotaState: z15.enum(["ok", "limited", "auth_expired", "unknown"]),
7256
+ limitedUntil: z15.string().nullable(),
7257
+ lastError: z15.string().nullable()
7129
7258
  });
7130
- runnerSpecSchema = z14.object({
7131
- deviceId: z14.string().optional(),
7259
+ runnerSpecSchema = z15.object({
7260
+ deviceId: z15.string().optional(),
7132
7261
  // The launcher's boot id: the one its heartbeat carries, so a runner's lease
7133
7262
  // matches the device that holds it.
7134
- bootId: z14.string(),
7135
- workspaceId: z14.string(),
7136
- workspaceSlug: z14.string(),
7137
- agentId: z14.string(),
7138
- agentUsername: z14.string(),
7139
- agentName: z14.string(),
7263
+ bootId: z15.string(),
7264
+ workspaceId: z15.string(),
7265
+ workspaceSlug: z15.string(),
7266
+ agentId: z15.string(),
7267
+ agentUsername: z15.string(),
7268
+ agentName: z15.string(),
7140
7269
  local: localAgentConfigSchema,
7141
7270
  runConfig: runConfigSchema,
7142
- opencodeServerUrl: z14.string().optional(),
7143
- claudeCodeAvailable: z14.boolean(),
7144
- codexAvailable: z14.boolean(),
7145
- idleTimeoutMs: z14.number().int().positive().optional(),
7146
- totalTimeoutMs: z14.number().int().positive().optional(),
7147
- leaseRenewalMs: z14.number().int().positive().optional(),
7148
- preparingRowDelayMs: z14.number().int().nonnegative().optional(),
7271
+ opencodeServerUrl: z15.string().optional(),
7272
+ claudeCodeAvailable: z15.boolean(),
7273
+ codexAvailable: z15.boolean(),
7274
+ idleTimeoutMs: z15.number().int().positive().optional(),
7275
+ totalTimeoutMs: z15.number().int().positive().optional(),
7276
+ leaseRenewalMs: z15.number().int().positive().optional(),
7277
+ preparingRowDelayMs: z15.number().int().nonnegative().optional(),
7149
7278
  // Whether the turn writes a transcript. The directory is the runner's own
7150
7279
  // (`transcriptsDir()`), so `CABANE_RUNNER_STATE_DIR` moves it with the outbox.
7151
- transcripts: z14.boolean(),
7280
+ transcripts: z15.boolean(),
7152
7281
  // Whether the harness starts under the OS sandbox. The launcher sends 'os'
7153
7282
  // only when this host can make one.
7154
- sandbox: z14.enum(["os", "none"]),
7155
- logLevel: z14.string(),
7283
+ sandbox: z15.enum(["os", "none"]),
7284
+ logLevel: z15.string(),
7156
7285
  // The launcher's connector-health observations, by runtime, as of the start.
7157
- connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
7286
+ connectorHealth: z15.record(z15.string(), runtimeHealthSchema),
7158
7287
  // True when this (conversation, agent) pair was already told its session
7159
7288
  // could not be saved: the notice is once per pair, not once per process.
7160
- sessionWriteNoticeClaimed: z14.boolean(),
7289
+ sessionWriteNoticeClaimed: z15.boolean(),
7161
7290
  // CT1680: the runner's identity, minted with its credential. The start
7162
7291
  // PATCH carries it so the lease knows which runner holds the span.
7163
- runnerId: z14.string()
7292
+ runnerId: z15.string(),
7293
+ // CT1681: the launcher keeps the runtime's session files beside the attempt
7294
+ // and restores them into this runner's home, so the runner names the attempt
7295
+ // each context was composed for and waits for the launcher to say it's in
7296
+ // place. Absent: nothing to wait for (a runner on the launcher's own disk).
7297
+ attemptState: z15.boolean().optional(),
7298
+ // CT1681: MCP `${NAME}` secrets come from the launcher, by name, instead of
7299
+ // this machine's secrets.json. Absent: the runner reads the device's file.
7300
+ secretsFromLauncher: z15.boolean().optional()
7164
7301
  });
7165
- dispatchSchema = z14.object({
7166
- type: z14.literal("device:dispatch_requested"),
7167
- agentId: z14.string(),
7168
- deviceId: z14.string(),
7169
- conversationId: z14.string(),
7170
- messageId: z14.string(),
7171
- episode: z14.number().int().optional()
7302
+ dispatchSchema = z15.object({
7303
+ type: z15.literal("device:dispatch_requested"),
7304
+ agentId: z15.string(),
7305
+ deviceId: z15.string(),
7306
+ conversationId: z15.string(),
7307
+ messageId: z15.string(),
7308
+ episode: z15.number().int().optional()
7172
7309
  });
7173
- credentialsSchema = z14.object({
7174
- credential: z14.string().startsWith("cabrn_")
7310
+ credentialsSchema = z15.object({
7311
+ credential: z15.string().startsWith("cabrn_")
7175
7312
  });
7176
7313
  }
7177
7314
  });
@@ -7183,6 +7320,11 @@ function selfRunnerCommand() {
7183
7320
  if (!script) throw new Error("runners: cannot tell which script started this companion");
7184
7321
  return [process.execPath, ...process.execArgv, script, "run-turn"];
7185
7322
  }
7323
+ function defaultSecretStore(log) {
7324
+ return loadSecretStoreTolerant(
7325
+ (m) => log.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
7326
+ );
7327
+ }
7186
7328
  function healthSnapshot(store) {
7187
7329
  const out = {};
7188
7330
  for (const r of store?.reports() ?? []) {
@@ -7216,6 +7358,8 @@ function runnerSpec(opts, launch, duties, runnerId) {
7216
7358
  ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
7217
7359
  transcripts: opts.transcriptDir !== void 0,
7218
7360
  sandbox: launch.sandbox ?? "none",
7361
+ ...launch.provider.bindAttempt ? { attemptState: true } : {},
7362
+ ...launch.provider.inheritsEnvironment === false ? { secretsFromLauncher: true } : {},
7219
7363
  logLevel: opts.log.level,
7220
7364
  connectorHealth: healthSnapshot(launch.connectorHealth),
7221
7365
  sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
@@ -7355,14 +7499,40 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7355
7499
  const remote = [];
7356
7500
  const lockOwner = (id) => `${turnId}#${id}`;
7357
7501
  const locks = launch.locks;
7502
+ let leasedAttemptId = null;
7358
7503
  try {
7359
7504
  handle = await launch.provider.acquire(attempt);
7505
+ } catch (err) {
7506
+ const reason2 = `runner_unavailable: ${describeError(err)}`;
7507
+ log.warn(
7508
+ { turnId, provider: launch.provider.name, err: describeError(err) },
7509
+ "runner: no place to run the turn"
7510
+ );
7511
+ controller.signal.removeEventListener("abort", forwardStop);
7512
+ duties.releaseAbort(turnId, controller);
7513
+ await settleUnstarted(opts, payload, turnId, reason2, log);
7514
+ await launch.reportRunnerGone?.({
7515
+ workspaceId: opts.workspaceId,
7516
+ conversationId: payload.conversationId,
7517
+ agentId: payload.agentId,
7518
+ messageId: payload.messageId,
7519
+ runnerId: minted.runnerId,
7520
+ reason: "provider_failed"
7521
+ });
7522
+ const durationMs2 = Date.now() - startedAt;
7523
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: reason2 });
7524
+ return { ok: false, durationMs: durationMs2, reason: reason2 };
7525
+ }
7526
+ try {
7527
+ const place = handle;
7360
7528
  const env = {};
7361
- for (const [k, v] of Object.entries(launch.env ?? process.env)) {
7362
- if (v !== void 0) env[k] = v;
7529
+ if (launch.provider.inheritsEnvironment !== false) {
7530
+ for (const [k, v] of Object.entries(launch.env ?? process.env)) {
7531
+ if (v !== void 0) env[k] = v;
7532
+ }
7363
7533
  }
7364
7534
  const input = {
7365
- baseUrl: opts.baseUrl,
7535
+ baseUrl: launch.runnerBaseUrl ?? opts.baseUrl,
7366
7536
  credential: minted.credential,
7367
7537
  dispatch: payload,
7368
7538
  turnId,
@@ -7440,6 +7610,38 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7440
7610
  case "remote_session":
7441
7611
  remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
7442
7612
  return;
7613
+ case "attempt": {
7614
+ const bind = launch.provider.bindAttempt?.(place, event.attemptId) ?? Promise.resolve();
7615
+ void bind.then(
7616
+ () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "bound", id: event.id })),
7617
+ (err) => {
7618
+ const refused = describeError(err);
7619
+ log.warn(
7620
+ { attemptId: event.attemptId, err: refused },
7621
+ "runner: couldn't make the place ready for the turn's attempt; not running it there"
7622
+ );
7623
+ running.stdin.write(
7624
+ encodeLauncherMessage({ cabaneLauncher: "bound", id: event.id, refused })
7625
+ );
7626
+ }
7627
+ );
7628
+ return;
7629
+ }
7630
+ case "leased":
7631
+ leasedAttemptId = event.attemptId;
7632
+ return;
7633
+ case "secrets": {
7634
+ const store = (launch.secretStore ?? defaultSecretStore)(log);
7635
+ const values = {};
7636
+ for (const name of event.names) {
7637
+ const value = store.get(name);
7638
+ if (value !== void 0) values[name] = value;
7639
+ }
7640
+ running.stdin.write(
7641
+ encodeLauncherMessage({ cabaneLauncher: "secrets", id: event.id, values })
7642
+ );
7643
+ return;
7644
+ }
7443
7645
  }
7444
7646
  });
7445
7647
  const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
@@ -7450,7 +7652,14 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7450
7652
  child?.stdin.end();
7451
7653
  controller.signal.removeEventListener("abort", forwardStop);
7452
7654
  duties.releaseAbort(turnId, controller);
7453
- if (handle) await launch.provider.release(handle, { destroy: false });
7655
+ if (handle) {
7656
+ await launch.provider.collect?.(handle, { attemptId: leasedAttemptId }).catch(
7657
+ (err) => log.warn({ err: describeError(err) }, "runner: couldn't collect what the runner left")
7658
+ );
7659
+ await launch.provider.release(handle, { destroy: false }).catch(
7660
+ (err) => log.warn({ err: describeError(err) }, "runner: couldn't give its place back")
7661
+ );
7662
+ }
7454
7663
  }
7455
7664
  if (result) return result;
7456
7665
  const durationMs = Date.now() - startedAt;
@@ -7501,6 +7710,7 @@ var init_launch = __esm({
7501
7710
  init_errors();
7502
7711
  init_boot_id();
7503
7712
  init_turn_containment();
7713
+ init_secrets();
7504
7714
  init_protocol();
7505
7715
  PINO_LEVELS = {
7506
7716
  10: "trace",
@@ -7515,7 +7725,7 @@ var init_launch = __esm({
7515
7725
 
7516
7726
  // src/turn-control-tools.ts
7517
7727
  import { randomUUID as randomUUID2 } from "crypto";
7518
- import { z as z15 } from "zod";
7728
+ import { z as z16 } from "zod";
7519
7729
  function createSkipState() {
7520
7730
  return { skipped: false, reason: null };
7521
7731
  }
@@ -7566,7 +7776,7 @@ function createTurnControlMcpServer(acts, skipState) {
7566
7776
  SKIP_TURN_TOOL,
7567
7777
  TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
7568
7778
  {
7569
- reason: z15.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
7779
+ reason: z16.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
7570
7780
  },
7571
7781
  async (args) => {
7572
7782
  skipState.skipped = true;
@@ -7582,42 +7792,42 @@ function createTurnControlMcpServer(acts, skipState) {
7582
7792
  ASK_TOOL,
7583
7793
  TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
7584
7794
  {
7585
- targetUserId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
7586
- question: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
7587
- headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
7588
- options: z15.array(
7589
- z15.union([
7590
- z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
7591
- z15.object({
7592
- label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
7593
- move: z15.object({
7594
- task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
7595
- status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
7795
+ targetUserId: z16.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
7796
+ question: z16.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
7797
+ headline: z16.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
7798
+ options: z16.array(
7799
+ z16.union([
7800
+ z16.string().min(1).max(MAX_ASK_OPTION_BYTES),
7801
+ z16.object({
7802
+ label: z16.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
7803
+ move: z16.object({
7804
+ task: z16.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
7805
+ status: z16.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
7596
7806
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
7597
7807
  })
7598
7808
  ])
7599
7809
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7600
- questions: z15.array(
7601
- z15.object({
7602
- headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
7603
- body: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
7604
- options: z15.array(
7605
- z15.union([
7606
- z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
7607
- z15.object({
7608
- label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
7609
- move: z15.object({
7610
- task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
7611
- status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
7810
+ questions: z16.array(
7811
+ z16.object({
7812
+ headline: z16.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
7813
+ body: z16.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
7814
+ options: z16.array(
7815
+ z16.union([
7816
+ z16.string().min(1).max(MAX_ASK_OPTION_BYTES),
7817
+ z16.object({
7818
+ label: z16.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
7819
+ move: z16.object({
7820
+ task: z16.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
7821
+ status: z16.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
7612
7822
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
7613
7823
  })
7614
7824
  ])
7615
7825
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7616
7826
  })
7617
7827
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
7618
- replaces: z15.object({
7619
- askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
7620
- reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
7828
+ replaces: z16.object({
7829
+ askId: z16.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
7830
+ reason: z16.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
7621
7831
  }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
7622
7832
  },
7623
7833
  async (args) => {
@@ -7658,9 +7868,9 @@ function createTurnControlMcpServer(acts, skipState) {
7658
7868
  WAKE_ME_TOOL,
7659
7869
  TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
7660
7870
  {
7661
- afterSeconds: z15.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
7662
- at: z15.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
7663
- note: z15.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
7871
+ afterSeconds: z16.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
7872
+ at: z16.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
7873
+ note: z16.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
7664
7874
  },
7665
7875
  async (args) => {
7666
7876
  const hasAfter = args.afterSeconds !== void 0;
@@ -7694,8 +7904,8 @@ function createTurnControlMcpServer(acts, skipState) {
7694
7904
  WITHDRAW_ASK_TOOL,
7695
7905
  TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
7696
7906
  {
7697
- askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
7698
- reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
7907
+ askId: z16.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
7908
+ reason: z16.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
7699
7909
  },
7700
7910
  async (args) => performAct(acts, {
7701
7911
  kind: "withdraw_ask",
@@ -7790,10 +8000,10 @@ var init_build_options = __esm({
7790
8000
  // src/codex-instructions.ts
7791
8001
  import { mkdtemp, rm, writeFile } from "fs/promises";
7792
8002
  import { tmpdir } from "os";
7793
- import { join as join10 } from "path";
8003
+ import { join as join11 } from "path";
7794
8004
  async function writeCodexInstructionsFile(contents) {
7795
- const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
7796
- const path = join10(dir2, "instructions.md");
8005
+ const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
8006
+ const path = join11(dir2, "instructions.md");
7797
8007
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
7798
8008
  return {
7799
8009
  path,
@@ -7917,22 +8127,22 @@ var init_prepare_failure = __esm({
7917
8127
  });
7918
8128
 
7919
8129
  // src/prepared.ts
7920
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
7921
- import { join as join11 } from "path";
8130
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync7, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "fs";
8131
+ import { join as join12 } from "path";
7922
8132
  function dirFor(workspaceId) {
7923
- return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
8133
+ return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
7924
8134
  }
7925
8135
  function conversationDir(workspaceId, conversationId) {
7926
- return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
8136
+ return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
7927
8137
  }
7928
8138
  function pathFor2(workspaceId, conversationId, agentId) {
7929
- return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
8139
+ return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
7930
8140
  }
7931
8141
  function readPrepared(workspaceId, conversationId, agentId) {
7932
8142
  const path = pathFor2(workspaceId, conversationId, agentId);
7933
- if (!existsSync7(path)) return null;
8143
+ if (!existsSync8(path)) return null;
7934
8144
  try {
7935
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
8145
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
7936
8146
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
7937
8147
  return {
7938
8148
  cwd: parsed.cwd,
@@ -7962,105 +8172,6 @@ var init_prepared = __esm({
7962
8172
  }
7963
8173
  });
7964
8174
 
7965
- // src/secrets.ts
7966
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
7967
- import { join as join12 } from "path";
7968
- import { z as z16 } from "zod";
7969
- function secretsPath() {
7970
- return join12(cabaneDir(), "secrets.json");
7971
- }
7972
- function loadSecretStore() {
7973
- const path = secretsPath();
7974
- if (!existsSync8(path)) return makeStore({});
7975
- let raw;
7976
- try {
7977
- raw = readFileSync7(path, "utf8");
7978
- } catch (err) {
7979
- throw new ConfigError(
7980
- `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
7981
- );
7982
- }
7983
- if (raw.trim().length === 0) return makeStore({});
7984
- let parsed;
7985
- try {
7986
- parsed = JSON.parse(raw);
7987
- } catch (err) {
7988
- throw new ConfigError(
7989
- `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
7990
- );
7991
- }
7992
- const result = secretStoreSchema.safeParse(parsed);
7993
- if (!result.success) {
7994
- throw new ConfigError(
7995
- `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
7996
- );
7997
- }
7998
- return makeStore(result.data);
7999
- }
8000
- function loadSecretStoreTolerant(onWarn) {
8001
- try {
8002
- return loadSecretStore();
8003
- } catch (err) {
8004
- onWarn?.(err instanceof Error ? err.message : String(err));
8005
- return makeStore({});
8006
- }
8007
- }
8008
- function makeStore(map) {
8009
- return {
8010
- get: (name) => map[name],
8011
- names: () => Object.keys(map).sort()
8012
- };
8013
- }
8014
- function requiredSecretNames(mcpServers) {
8015
- const names = /* @__PURE__ */ new Set();
8016
- for (const server of Object.values(mcpServers ?? {})) {
8017
- const bags = [
8018
- "env" in server ? server.env : void 0,
8019
- "headers" in server ? server.headers : void 0
8020
- ];
8021
- for (const bag of bags) {
8022
- for (const value of Object.values(bag ?? {})) {
8023
- for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
8024
- }
8025
- }
8026
- }
8027
- return [...names].sort();
8028
- }
8029
- function resolveMcpSecrets(mcpServers, store) {
8030
- const missing = /* @__PURE__ */ new Set();
8031
- const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
8032
- const resolved = store.get(name);
8033
- if (resolved === void 0) {
8034
- missing.add(name);
8035
- return _match;
8036
- }
8037
- return resolved;
8038
- });
8039
- const mapBag = (bag) => {
8040
- const out = {};
8041
- for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
8042
- return out;
8043
- };
8044
- const servers = {};
8045
- for (const [name, server] of Object.entries(mcpServers)) {
8046
- const next = { ...server };
8047
- if ("env" in next && next.env) next.env = mapBag(next.env);
8048
- if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
8049
- servers[name] = next;
8050
- }
8051
- return { servers, missing: [...missing].sort() };
8052
- }
8053
- var PLACEHOLDER_RE, secretStoreSchema;
8054
- var init_secrets = __esm({
8055
- "src/secrets.ts"() {
8056
- "use strict";
8057
- init_config();
8058
- init_errors();
8059
- PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8060
- secretStoreSchema = z16.record(z16.string(), z16.string());
8061
- }
8062
- });
8063
-
8064
8175
  // src/transcript-writer.ts
8065
8176
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
8066
8177
  import { basename, dirname as dirname6, join as join13 } from "path";
@@ -8727,6 +8838,12 @@ var init_turn_execution = __esm({
8727
8838
  this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
8728
8839
  if (this.resumedSpoke) turnLog.debug({ turnId }, "dispatcher: resumed span already spoke");
8729
8840
  }
8841
+ try {
8842
+ await this.opts.inRunner?.onContext?.(turnContext.attemptId ?? null);
8843
+ } catch (err) {
8844
+ const reason = `runner_unavailable: ${err instanceof Error ? err.message : String(err)}`;
8845
+ throw this.concluded(reason, reason);
8846
+ }
8730
8847
  }
8731
8848
  gateTrigger() {
8732
8849
  const { payload, turnLog } = this;
@@ -8744,7 +8861,8 @@ var init_turn_execution = __esm({
8744
8861
  }
8745
8862
  async resolveSecrets() {
8746
8863
  const { payload, workspaceId, turnLog } = this;
8747
- const secretStore = this.opts.secretStore ?? loadSecretStoreTolerant(
8864
+ const fromLauncher = this.opts.inRunner?.secrets;
8865
+ const secretStore = this.opts.secretStore ?? (fromLauncher ? await fromLauncher(requiredSecretNames(this.turnContext.mcpServers)) : null) ?? loadSecretStoreTolerant(
8748
8866
  (m) => turnLog.warn({ err: m }, "Could not read the secrets file. Check ~/.cabane/secrets.json.")
8749
8867
  );
8750
8868
  const { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
@@ -8904,42 +9022,48 @@ var init_turn_execution = __esm({
8904
9022
  const { payload, workspaceId, turnId, turnLog } = this;
8905
9023
  const abortController = this.abortController;
8906
9024
  this.supervisor.registerAbort(turnId, abortController);
9025
+ let started;
8907
9026
  try {
8908
- await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8909
- activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
8910
- // CT1679: a runner leases under its launcher's boot id — the one the
8911
- // device heartbeats — or the lost-run sweep reads it as a restart.
8912
- bootId: this.opts.inRunner?.bootId ?? bootId,
8913
- // CT1680: the runner holding the span, beside the device's boot id.
8914
- ...this.opts.inRunner ? { runnerId: this.opts.inRunner.runnerId } : {},
8915
- // CT1380: the durable record that this turn resumed, and from where.
8916
- ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
8917
- // CT1454: the issuance the context THIS attempt runs on came from. Read
8918
- // off `this.turnContext`, which is the fetch that composed this request —
8919
- // so a retransmitted start echoes the same id and reuses the same record,
8920
- // and a resumed attempt echoes its own, newer one. The post-admission
8921
- // fresh-session recompose (`freshRequest`) deliberately does not touch it:
8922
- // it adopts only the prompt, never the run-config, so its issuance must
8923
- // not become this attempt's evidence.
8924
- ...this.turnContext.issuanceId ? { issuanceId: this.turnContext.issuanceId } : {},
8925
- // CT33: hand the server this turn's id so the new-run chokepoint's
8926
- // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
8927
- // already emitted a "preparing" activity row for this turn above (it
8928
- // runs before this flip), which would otherwise make the sweep mistake
8929
- // this live turn for an abandoned one and close it with a `stopped`.
8930
- turnId,
8931
- // The newest message this turn's read covered: the lease records it,
8932
- // and settle moves the cursor to it. Omitted when the server sent
8933
- // none (an older API), so the cursor stays where it was.
8934
- ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
8935
- ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
8936
- // Rooms: the attempt this request's session belongs to (null = fresh).
8937
- ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {},
8938
- // CT1543: the frame this start acts on. The turn-context fetch already
8939
- // checked it, but that was before an assembly this turn awaited — the
8940
- // lease checks it again where the stamp is actually taken.
8941
- ...payload.episode !== void 0 ? { dispatchMessageId: payload.messageId, dispatchEpisode: payload.episode } : {}
8942
- });
9027
+ started = await this.opts.api.setActiveRun(
9028
+ workspaceId,
9029
+ payload.conversationId,
9030
+ payload.agentId,
9031
+ {
9032
+ activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
9033
+ // CT1679: a runner leases under its launcher's boot id — the one the
9034
+ // device heartbeats — or the lost-run sweep reads it as a restart.
9035
+ bootId: this.opts.inRunner?.bootId ?? bootId,
9036
+ // CT1680: the runner holding the span, beside the device's boot id.
9037
+ ...this.opts.inRunner ? { runnerId: this.opts.inRunner.runnerId } : {},
9038
+ // CT1380: the durable record that this turn resumed, and from where.
9039
+ ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
9040
+ // CT1454: the issuance the context THIS attempt runs on came from. Read
9041
+ // off `this.turnContext`, which is the fetch that composed this request —
9042
+ // so a retransmitted start echoes the same id and reuses the same record,
9043
+ // and a resumed attempt echoes its own, newer one. The post-admission
9044
+ // fresh-session recompose (`freshRequest`) deliberately does not touch it:
9045
+ // it adopts only the prompt, never the run-config, so its issuance must
9046
+ // not become this attempt's evidence.
9047
+ ...this.turnContext.issuanceId ? { issuanceId: this.turnContext.issuanceId } : {},
9048
+ // CT33: hand the server this turn's id so the new-run chokepoint's
9049
+ // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
9050
+ // already emitted a "preparing" activity row for this turn above (it
9051
+ // runs before this flip), which would otherwise make the sweep mistake
9052
+ // this live turn for an abandoned one and close it with a `stopped`.
9053
+ turnId,
9054
+ // The newest message this turn's read covered: the lease records it,
9055
+ // and settle moves the cursor to it. Omitted when the server sent
9056
+ // none (an older API), so the cursor stays where it was.
9057
+ ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
9058
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
9059
+ // Rooms: the attempt this request's session belongs to (null = fresh).
9060
+ ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {},
9061
+ // CT1543: the frame this start acts on. The turn-context fetch already
9062
+ // checked it, but that was before an assembly this turn awaited — the
9063
+ // lease checks it again where the stamp is actually taken.
9064
+ ...payload.episode !== void 0 ? { dispatchMessageId: payload.messageId, dispatchEpisode: payload.episode } : {}
9065
+ }
9066
+ );
8943
9067
  } catch (err) {
8944
9068
  if (leaseRefusal(err) === "dispatch_context_stale" && !this.recomposedForLease) {
8945
9069
  this.recomposedForLease = true;
@@ -8964,6 +9088,7 @@ var init_turn_execution = __esm({
8964
9088
  throw this.concluded(reason, reason);
8965
9089
  }
8966
9090
  this.admitted = true;
9091
+ if (started?.attemptId) this.opts.inRunner?.onLeased?.(started.attemptId);
8967
9092
  }
8968
9093
  buildRequest() {
8969
9094
  const { payload, workspaceId } = this;
@@ -9619,32 +9744,275 @@ var init_dispatcher = __esm({
9619
9744
  { turnId: opts.turnId ?? randomUUID4(), resumed: opts.resumed === true }
9620
9745
  );
9621
9746
  }
9622
- return new TurnExecution(this.opts, supervisor, payload, opts).run();
9623
- }
9624
- // SJ383: cancel a specific (conversation, agent) run if one is in flight in
9625
- // THIS companion process. Returns true if an in-flight run was aborted.
9626
- cancel(conversationId, agentId) {
9627
- const key = runKey(conversationId, agentId);
9628
- const pair2 = this.aborts.get(key);
9629
- if (!pair2 || pair2.size === 0) return false;
9630
- for (const ac of [...pair2.values()]) {
9631
- try {
9632
- ac.abort();
9633
- } catch {
9634
- }
9747
+ return new TurnExecution(this.opts, supervisor, payload, opts).run();
9748
+ }
9749
+ // SJ383: cancel a specific (conversation, agent) run if one is in flight in
9750
+ // THIS companion process. Returns true if an in-flight run was aborted.
9751
+ cancel(conversationId, agentId) {
9752
+ const key = runKey(conversationId, agentId);
9753
+ const pair2 = this.aborts.get(key);
9754
+ if (!pair2 || pair2.size === 0) return false;
9755
+ for (const ac of [...pair2.values()]) {
9756
+ try {
9757
+ ac.abort();
9758
+ } catch {
9759
+ }
9760
+ }
9761
+ return true;
9762
+ }
9763
+ };
9764
+ }
9765
+ });
9766
+
9767
+ // src/runner/egress-proxy.ts
9768
+ var egress_proxy_exports = {};
9769
+ __export(egress_proxy_exports, {
9770
+ connectTarget: () => connectTarget,
9771
+ createEgressProxy: () => createEgressProxy,
9772
+ egressAllowed: () => egressAllowed
9773
+ });
9774
+ import { createServer as createServer2, request as httpRequest } from "http";
9775
+ import { connect as connect3 } from "net";
9776
+ function egressAllowed(host, allow, port, defaultPort = 443) {
9777
+ const h = host.toLowerCase().replace(/\.$/, "");
9778
+ if (!h) return false;
9779
+ const p = port ?? defaultPort;
9780
+ return allow.some((entry) => {
9781
+ const raw = entry.toLowerCase().trim();
9782
+ const m = /^(.*):(\d{1,5})$/.exec(raw);
9783
+ const name = m ? m[1] : raw;
9784
+ const wanted = m ? Number(m[2]) : defaultPort;
9785
+ if (p !== wanted) return false;
9786
+ if (name.startsWith("*.")) return h.endsWith(name.slice(1));
9787
+ return h === name;
9788
+ });
9789
+ }
9790
+ function connectTarget(target) {
9791
+ const m = /^\[?([^\]]+?)\]?:(\d{1,5})$/.exec(target);
9792
+ if (!m) return null;
9793
+ const port = Number(m[2]);
9794
+ if (!m[1] || port < 1 || port > 65535) return null;
9795
+ return { host: m[1], port };
9796
+ }
9797
+ function createEgressProxy(opts) {
9798
+ const { allow, log } = opts;
9799
+ const refuse = (host) => `cabane: ${host} is not on this device's egress allow-list (runners.docker.egressAllow)
9800
+ `;
9801
+ const server = createServer2((req, res) => {
9802
+ req.on("error", () => res.destroy());
9803
+ res.on("error", () => req.destroy());
9804
+ let url;
9805
+ try {
9806
+ url = new URL(req.url ?? "");
9807
+ } catch {
9808
+ res.writeHead(400).end("cabane: this proxy forwards absolute http:// URLs only\n");
9809
+ return;
9810
+ }
9811
+ if (url.protocol !== "http:" || !egressAllowed(url.hostname, allow, url.port ? Number(url.port) : 80, 80)) {
9812
+ log?.warn({ host: url.hostname }, "egress: refused");
9813
+ res.writeHead(403, { "content-type": "text/plain" }).end(refuse(url.hostname));
9814
+ return;
9815
+ }
9816
+ const upstream = httpRequest(
9817
+ {
9818
+ host: url.hostname,
9819
+ port: url.port || 80,
9820
+ method: req.method,
9821
+ path: `${url.pathname}${url.search}`,
9822
+ headers: req.headers
9823
+ },
9824
+ (up) => {
9825
+ res.writeHead(up.statusCode ?? 502, up.headers);
9826
+ up.pipe(res);
9827
+ }
9828
+ );
9829
+ upstream.on("error", () => {
9830
+ if (!res.headersSent) res.writeHead(502);
9831
+ res.end();
9832
+ });
9833
+ req.pipe(upstream);
9834
+ });
9835
+ server.on("clientError", (_err, socket) => socket.destroy());
9836
+ server.on("connect", (req, socket, head) => {
9837
+ socket.on("error", () => socket.destroy());
9838
+ const target = connectTarget(req.url ?? "");
9839
+ if (!target || !egressAllowed(target.host, allow, target.port)) {
9840
+ const host = target?.host ?? String(req.url);
9841
+ log?.warn({ host }, "egress: refused");
9842
+ const body = refuse(host);
9843
+ socket.end(
9844
+ `HTTP/1.1 403 Forbidden\r
9845
+ content-type: text/plain\r
9846
+ content-length: ${Buffer.byteLength(body)}\r
9847
+ \r
9848
+ ${body}`
9849
+ );
9850
+ return;
9851
+ }
9852
+ const upstream = connect3(target.port, target.host, () => {
9853
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
9854
+ if (head.length) upstream.write(head);
9855
+ upstream.pipe(socket);
9856
+ socket.pipe(upstream);
9857
+ });
9858
+ const close = () => {
9859
+ upstream.destroy();
9860
+ socket.destroy();
9861
+ };
9862
+ upstream.on("error", () => {
9863
+ if (!socket.destroyed) socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
9864
+ close();
9865
+ });
9866
+ socket.on("error", close);
9867
+ socket.on("close", () => upstream.destroy());
9868
+ });
9869
+ return server;
9870
+ }
9871
+ var init_egress_proxy = __esm({
9872
+ "src/runner/egress-proxy.ts"() {
9873
+ "use strict";
9874
+ }
9875
+ });
9876
+
9877
+ // src/runner/git-credential.ts
9878
+ var git_credential_exports = {};
9879
+ __export(git_credential_exports, {
9880
+ askGitCredential: () => askGitCredential,
9881
+ deviceGitFill: () => deviceGitFill,
9882
+ parseCredential: () => parseCredential,
9883
+ serveGitCredentials: () => serveGitCredentials
9884
+ });
9885
+ import { spawn as spawn8 } from "child_process";
9886
+ import { chmodSync as chmodSync5, rmSync as rmSync7 } from "fs";
9887
+ import { connect as connect4, createServer as createServer3 } from "net";
9888
+ function parseCredential(text) {
9889
+ const out = {};
9890
+ for (const line of text.split("\n")) {
9891
+ const eq = line.indexOf("=");
9892
+ if (eq <= 0) continue;
9893
+ out[line.slice(0, eq)] = line.slice(eq + 1);
9894
+ }
9895
+ return out;
9896
+ }
9897
+ function formatCredential(fields) {
9898
+ return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join("\n") + "\n\n";
9899
+ }
9900
+ function deviceGitFill(helper, timeoutMs = 15e3) {
9901
+ return (request) => new Promise((resolve2) => {
9902
+ const args = [
9903
+ ...helper ? ["-c", "credential.helper=", "-c", `credential.helper=${helper}`] : [],
9904
+ "credential",
9905
+ "fill"
9906
+ ];
9907
+ const child = spawn8("git", args, {
9908
+ stdio: ["pipe", "pipe", "ignore"],
9909
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" }
9910
+ });
9911
+ let out = "";
9912
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
9913
+ child.stdout.on("data", (chunk) => out += String(chunk));
9914
+ child.once("error", () => resolve2(null));
9915
+ child.once("close", (code) => {
9916
+ clearTimeout(timer);
9917
+ resolve2(code === 0 ? out : null);
9918
+ });
9919
+ child.stdin.end(formatCredential(request));
9920
+ });
9921
+ }
9922
+ async function serveGitCredentials(opts) {
9923
+ const { socketPath, hosts, fill, log } = opts;
9924
+ rmSync7(socketPath, { force: true });
9925
+ const server = createServer3({ allowHalfOpen: true }, (socket) => {
9926
+ let input = "";
9927
+ socket.setEncoding("utf8");
9928
+ socket.on("data", (chunk) => input += chunk);
9929
+ socket.on("error", () => socket.destroy());
9930
+ socket.on("end", () => {
9931
+ void (async () => {
9932
+ const [op = "", ...rest] = input.split("\n");
9933
+ const request = parseCredential(rest.join("\n"));
9934
+ if (op !== "get" || request.protocol !== "https" || !request.host) {
9935
+ socket.end();
9936
+ return;
9937
+ }
9938
+ if (!egressAllowed(request.host.replace(/:\d+$/, ""), hosts)) {
9939
+ log?.warn(
9940
+ { host: request.host },
9941
+ "runner: git asked for a credential for a host it may not use"
9942
+ );
9943
+ socket.end();
9944
+ return;
9945
+ }
9946
+ const answer = await fill({
9947
+ protocol: "https",
9948
+ host: request.host,
9949
+ ...request.path ? { path: request.path } : {},
9950
+ ...request.username ? { username: request.username } : {}
9951
+ });
9952
+ if (!answer) {
9953
+ log?.warn(
9954
+ { host: request.host },
9955
+ "runner: this device's git has no credential for the host"
9956
+ );
9957
+ socket.end();
9958
+ return;
9635
9959
  }
9636
- return true;
9637
- }
9638
- };
9960
+ const filled = parseCredential(answer);
9961
+ socket.end(
9962
+ formatCredential({
9963
+ protocol: "https",
9964
+ host: request.host,
9965
+ ...filled.username ? { username: filled.username } : {},
9966
+ ...filled.password ? { password: filled.password } : {}
9967
+ })
9968
+ );
9969
+ })();
9970
+ });
9971
+ });
9972
+ await new Promise((resolve2, reject) => {
9973
+ server.once("error", reject);
9974
+ server.listen(socketPath, () => {
9975
+ server.off("error", reject);
9976
+ resolve2();
9977
+ });
9978
+ });
9979
+ chmodSync5(socketPath, 438);
9980
+ return {
9981
+ close: () => new Promise((resolve2) => {
9982
+ server.close(() => resolve2());
9983
+ rmSync7(socketPath, { force: true });
9984
+ })
9985
+ };
9986
+ }
9987
+ function askGitCredential(socketPath, op, request, timeoutMs = 2e4) {
9988
+ return new Promise((resolve2) => {
9989
+ const socket = connect4(socketPath);
9990
+ let out = "";
9991
+ const timer = setTimeout(() => socket.destroy(), timeoutMs);
9992
+ socket.setEncoding("utf8");
9993
+ socket.on("data", (chunk) => out += chunk);
9994
+ socket.on("error", () => resolve2(""));
9995
+ socket.on("close", () => {
9996
+ clearTimeout(timer);
9997
+ resolve2(out);
9998
+ });
9999
+ socket.end(`${op}
10000
+ ${request}`);
10001
+ });
10002
+ }
10003
+ var init_git_credential = __esm({
10004
+ "src/runner/git-credential.ts"() {
10005
+ "use strict";
10006
+ init_egress_proxy();
9639
10007
  }
9640
10008
  });
9641
10009
 
9642
10010
  // src/runner/harness-sandbox.ts
9643
10011
  import { lookup } from "dns/promises";
9644
- import { chmodSync as chmodSync5, mkdirSync as mkdirSync9, mkdtempSync, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
10012
+ import { chmodSync as chmodSync6, mkdirSync as mkdirSync11, mkdtempSync, rmSync as rmSync10, writeFileSync as writeFileSync7 } from "fs";
9645
10013
  import { isIP } from "net";
9646
- import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir2, userInfo as userInfo2 } from "os";
9647
- import { join as join15 } from "path";
10014
+ import { homedir as homedir3, networkInterfaces, tmpdir as tmpdir3, userInfo as userInfo2 } from "os";
10015
+ import { join as join18 } from "path";
9648
10016
  import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
9649
10017
  function accountHome() {
9650
10018
  try {
@@ -9678,8 +10046,8 @@ function ownAddresses() {
9678
10046
  return Object.values(networkInterfaces()).flat().flatMap((i) => i ? [i.address] : []);
9679
10047
  }
9680
10048
  function harnessSandboxConfig(input) {
9681
- const claudeDir = input.claudeConfigDir ?? join15(input.home, ".claude");
9682
- const codexDir = input.codexHome ?? join15(input.home, ".codex");
10049
+ const claudeDir = input.claudeConfigDir ?? join18(input.home, ".claude");
10050
+ const codexDir = input.codexHome ?? join18(input.home, ".codex");
9683
10051
  return {
9684
10052
  network: {
9685
10053
  allowedDomains: [
@@ -9695,9 +10063,9 @@ function harnessSandboxConfig(input) {
9695
10063
  denyRead: [
9696
10064
  input.cabaneDir,
9697
10065
  ...[.../* @__PURE__ */ new Set([input.home, input.accountHome ?? input.home])].flatMap((home) => [
9698
- join15(home, ".ssh"),
9699
- join15(home, ".aws"),
9700
- join15(home, ".gnupg")
10066
+ join18(home, ".ssh"),
10067
+ join18(home, ".aws"),
10068
+ join18(home, ".gnupg")
9701
10069
  ])
9702
10070
  ],
9703
10071
  // The checkout, scratch, and the harnesses' own session state — a turn
@@ -9707,7 +10075,7 @@ function harnessSandboxConfig(input) {
9707
10075
  ...input.cwd ? [input.cwd] : [],
9708
10076
  input.tmp,
9709
10077
  claudeDir,
9710
- join15(input.home, ".claude.json"),
10078
+ join18(input.home, ".claude.json"),
9711
10079
  codexDir
9712
10080
  ],
9713
10081
  denyWrite: []
@@ -9742,7 +10110,7 @@ async function prepareHarnessSandbox(input) {
9742
10110
  ownAddresses: ownAddresses(),
9743
10111
  home: homedir3(),
9744
10112
  accountHome: accountHome(),
9745
- tmp: tmpdir2(),
10113
+ tmp: tmpdir3(),
9746
10114
  cabaneDir: cabaneDir(),
9747
10115
  claudeConfigDir: process.env.CLAUDE_CONFIG_DIR,
9748
10116
  codexHome: process.env.CODEX_HOME
@@ -9768,10 +10136,10 @@ async function prepareHarnessSandbox(input) {
9768
10136
  });
9769
10137
  return null;
9770
10138
  }
9771
- mkdirSync9(join15(cabaneDir(), "sandbox"), { recursive: true });
9772
- const dir2 = mkdtempSync(join15(cabaneDir(), "sandbox", "turn-"));
9773
- const script = join15(dir2, "exec-sandboxed");
9774
- writeFileSync6(
10139
+ mkdirSync11(join18(cabaneDir(), "sandbox"), { recursive: true });
10140
+ const dir2 = mkdtempSync(join18(cabaneDir(), "sandbox", "turn-"));
10141
+ const script = join18(dir2, "exec-sandboxed");
10142
+ writeFileSync7(
9775
10143
  script,
9776
10144
  [
9777
10145
  "#!/bin/bash",
@@ -9781,20 +10149,20 @@ async function prepareHarnessSandbox(input) {
9781
10149
  ""
9782
10150
  ].join("\n")
9783
10151
  );
9784
- chmodSync5(script, 448);
10152
+ chmodSync6(script, 448);
9785
10153
  let shims = 0;
9786
10154
  return {
9787
10155
  wrap: (command, args) => ({ command: script, args: [command, ...args] }),
9788
10156
  executableFor: (real) => {
9789
- const shim = join15(dir2, `exec-${++shims}`);
9790
- writeFileSync6(shim, `#!/bin/bash
10157
+ const shim = join18(dir2, `exec-${++shims}`);
10158
+ writeFileSync7(shim, `#!/bin/bash
9791
10159
  exec ${shellQuote(script)} ${shellQuote(real)} "$@"
9792
10160
  `);
9793
- chmodSync5(shim, 448);
10161
+ chmodSync6(shim, 448);
9794
10162
  return shim;
9795
10163
  },
9796
10164
  dispose: async () => {
9797
- rmSync7(dir2, { recursive: true, force: true });
10165
+ rmSync10(dir2, { recursive: true, force: true });
9798
10166
  await SandboxManager.reset();
9799
10167
  }
9800
10168
  };
@@ -9822,15 +10190,15 @@ var init_harness_sandbox = __esm({
9822
10190
 
9823
10191
  // src/outbox.ts
9824
10192
  import {
9825
- existsSync as existsSync11,
9826
- mkdirSync as mkdirSync10,
9827
- readdirSync as readdirSync3,
9828
- readFileSync as readFileSync8,
9829
- renameSync as renameSync4,
9830
- rmSync as rmSync8,
9831
- writeFileSync as writeFileSync7
10193
+ existsSync as existsSync14,
10194
+ mkdirSync as mkdirSync12,
10195
+ readdirSync as readdirSync5,
10196
+ readFileSync as readFileSync10,
10197
+ renameSync as renameSync6,
10198
+ rmSync as rmSync11,
10199
+ writeFileSync as writeFileSync8
9832
10200
  } from "fs";
9833
- import { join as join16 } from "path";
10201
+ import { join as join19 } from "path";
9834
10202
  var MAX_ENTRIES, MAX_AGE_MS, Outbox;
9835
10203
  var init_outbox = __esm({
9836
10204
  "src/outbox.ts"() {
@@ -9848,25 +10216,25 @@ var init_outbox = __esm({
9848
10216
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
9849
10217
  // cases route writes at the right tmpdir.
9850
10218
  dir() {
9851
- return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
10219
+ return join19(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
9852
10220
  }
9853
10221
  fileFor(turnId, seq) {
9854
- return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10222
+ return join19(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9855
10223
  }
9856
10224
  // Persist a commit for later draining. Atomic (temp file + rename) so a
9857
10225
  // concurrent `list()` never reads a half-written entry, then enforces the
9858
10226
  // per-workspace bounds.
9859
10227
  persist(entry) {
9860
10228
  const dir2 = this.dir();
9861
- mkdirSync10(dir2, { recursive: true });
10229
+ mkdirSync12(dir2, { recursive: true });
9862
10230
  const target = this.fileFor(entry.turnId, entry.seq);
9863
10231
  const tmp = `${target}.${process.pid}.tmp`;
9864
10232
  try {
9865
- writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
9866
- renameSync4(tmp, target);
10233
+ writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
10234
+ renameSync6(tmp, target);
9867
10235
  } catch (err) {
9868
10236
  try {
9869
- rmSync8(tmp, { force: true });
10237
+ rmSync11(tmp, { force: true });
9870
10238
  } catch {
9871
10239
  }
9872
10240
  this.log?.warn(
@@ -9883,19 +10251,19 @@ var init_outbox = __esm({
9883
10251
  // wedging the drain.
9884
10252
  list() {
9885
10253
  const dir2 = this.dir();
9886
- if (!existsSync11(dir2)) return [];
10254
+ if (!existsSync14(dir2)) return [];
9887
10255
  let names;
9888
10256
  try {
9889
- names = readdirSync3(dir2);
10257
+ names = readdirSync5(dir2);
9890
10258
  } catch {
9891
10259
  return [];
9892
10260
  }
9893
10261
  const entries = [];
9894
10262
  for (const name of names) {
9895
10263
  if (!name.endsWith(".json")) continue;
9896
- const full = join16(dir2, name);
10264
+ const full = join19(dir2, name);
9897
10265
  try {
9898
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
10266
+ const parsed = JSON.parse(readFileSync10(full, "utf8"));
9899
10267
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
9900
10268
  entries.push(parsed);
9901
10269
  } else {
@@ -9929,8 +10297,8 @@ var init_outbox = __esm({
9929
10297
  // different: it is positive evidence that nothing was ever queued here.
9930
10298
  maxSeqForTurn(turnId) {
9931
10299
  const dir2 = this.dir();
9932
- if (!existsSync11(dir2)) return 0;
9933
- const names = readdirSync3(dir2);
10300
+ if (!existsSync14(dir2)) return 0;
10301
+ const names = readdirSync5(dir2);
9934
10302
  const prefix = `${encodeURIComponent(turnId)}__`;
9935
10303
  let max = 0;
9936
10304
  for (const name of names) {
@@ -9943,15 +10311,15 @@ var init_outbox = __esm({
9943
10311
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
9944
10312
  remove(turnId, seq) {
9945
10313
  try {
9946
- rmSync8(this.fileFor(turnId, seq), { force: true });
10314
+ rmSync11(this.fileFor(turnId, seq), { force: true });
9947
10315
  } catch {
9948
10316
  }
9949
10317
  }
9950
10318
  size() {
9951
10319
  const dir2 = this.dir();
9952
- if (!existsSync11(dir2)) return 0;
10320
+ if (!existsSync14(dir2)) return 0;
9953
10321
  try {
9954
- return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
10322
+ return readdirSync5(dir2).filter((n) => n.endsWith(".json")).length;
9955
10323
  } catch {
9956
10324
  return 0;
9957
10325
  }
@@ -9962,7 +10330,7 @@ var init_outbox = __esm({
9962
10330
  "A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
9963
10331
  );
9964
10332
  try {
9965
- rmSync8(full, { force: true });
10333
+ rmSync11(full, { force: true });
9966
10334
  } catch {
9967
10335
  }
9968
10336
  }
@@ -9998,9 +10366,42 @@ var init_outbox = __esm({
9998
10366
  }
9999
10367
  });
10000
10368
 
10369
+ // src/version.ts
10370
+ import { createRequire as createRequire2 } from "module";
10371
+ var pkg, COMPANION_VERSION;
10372
+ var init_version2 = __esm({
10373
+ "src/version.ts"() {
10374
+ "use strict";
10375
+ pkg = createRequire2(import.meta.url)("../package.json");
10376
+ COMPANION_VERSION = pkg.version;
10377
+ }
10378
+ });
10379
+
10380
+ // src/runner/image-manifest.ts
10381
+ var image_manifest_exports = {};
10382
+ __export(image_manifest_exports, {
10383
+ runnerManifest: () => runnerManifest
10384
+ });
10385
+ function runnerManifest() {
10386
+ return {
10387
+ version: COMPANION_VERSION,
10388
+ bundled: {
10389
+ "claude-code": resolveBundledBinary("claude-code").status,
10390
+ codex: resolveBundledBinary("codex").status
10391
+ }
10392
+ };
10393
+ }
10394
+ var init_image_manifest = __esm({
10395
+ "src/runner/image-manifest.ts"() {
10396
+ "use strict";
10397
+ init_bundled_binaries();
10398
+ init_version2();
10399
+ }
10400
+ });
10401
+
10001
10402
  // src/runner/exec-shim.ts
10002
- import { chmodSync as chmodSync6, mkdirSync as mkdirSync13, mkdtempSync as mkdtempSync2, rmSync as rmSync10, writeFileSync as writeFileSync9 } from "fs";
10003
- import { join as join19 } from "path";
10403
+ import { chmodSync as chmodSync7, mkdirSync as mkdirSync15, mkdtempSync as mkdtempSync2, rmSync as rmSync13, writeFileSync as writeFileSync10 } from "fs";
10404
+ import { join as join22 } from "path";
10004
10405
  function shellQuote2(value) {
10005
10406
  return `'${value.replace(/'/g, `'\\''`)}'`;
10006
10407
  }
@@ -10010,18 +10411,18 @@ function createShimDir() {
10010
10411
  return {
10011
10412
  write(argv) {
10012
10413
  if (!dir2) {
10013
- mkdirSync13(join19(cabaneDir(), "runner"), { recursive: true });
10014
- dir2 = mkdtempSync2(join19(cabaneDir(), "runner", "turn-"));
10414
+ mkdirSync15(join22(cabaneDir(), "runner"), { recursive: true });
10415
+ dir2 = mkdtempSync2(join22(cabaneDir(), "runner", "turn-"));
10015
10416
  }
10016
- const path = join19(dir2, `exec-${++count}`);
10017
- writeFileSync9(path, `#!/bin/sh
10417
+ const path = join22(dir2, `exec-${++count}`);
10418
+ writeFileSync10(path, `#!/bin/sh
10018
10419
  exec ${argv.map(shellQuote2).join(" ")} "$@"
10019
10420
  `);
10020
- chmodSync6(path, 448);
10421
+ chmodSync7(path, 448);
10021
10422
  return path;
10022
10423
  },
10023
10424
  dispose() {
10024
- if (dir2) rmSync10(dir2, { recursive: true, force: true });
10425
+ if (dir2) rmSync13(dir2, { recursive: true, force: true });
10025
10426
  dir2 = null;
10026
10427
  }
10027
10428
  };
@@ -10037,6 +10438,7 @@ var init_exec_shim = __esm({
10037
10438
  import { createInterface as createInterface3 } from "readline";
10038
10439
  function openLauncherChannel(stdin, emit, onLauncherGone) {
10039
10440
  const waiting = /* @__PURE__ */ new Map();
10441
+ const replies = /* @__PURE__ */ new Map();
10040
10442
  let next = 0;
10041
10443
  let resolveFirst;
10042
10444
  let gotFirst = false;
@@ -10050,11 +10452,26 @@ function openLauncherChannel(stdin, emit, onLauncherGone) {
10050
10452
  }
10051
10453
  const message = decodeLauncherMessage(line);
10052
10454
  if (message?.cabaneLauncher === "granted") waiting.get(message.id)?.();
10455
+ else if (message) replies.get(message.id)?.(message);
10053
10456
  });
10457
+ let gone = false;
10054
10458
  lines2.once("close", () => {
10459
+ gone = true;
10055
10460
  if (!gotFirst) resolveFirst("");
10461
+ for (const reply2 of [...replies.values()]) reply2(null);
10056
10462
  onLauncherGone();
10057
10463
  });
10464
+ const ask = (event) => {
10465
+ if (gone) return Promise.resolve(null);
10466
+ const id = String(++next);
10467
+ return new Promise((resolve2) => {
10468
+ replies.set(id, (message) => {
10469
+ replies.delete(id);
10470
+ resolve2(message);
10471
+ });
10472
+ emit(event(id));
10473
+ });
10474
+ };
10058
10475
  return {
10059
10476
  first,
10060
10477
  acquire(key, signal) {
@@ -10080,6 +10497,14 @@ function openLauncherChannel(stdin, emit, onLauncherGone) {
10080
10497
  signal.addEventListener("abort", onAbort, { once: true });
10081
10498
  emit({ cabaneRunner: "lock", id, key });
10082
10499
  });
10500
+ },
10501
+ async bindAttempt(attemptId) {
10502
+ const reply2 = await ask((id) => ({ cabaneRunner: "attempt", id, attemptId }));
10503
+ if (reply2?.cabaneLauncher === "bound" && reply2.refused) throw new Error(reply2.refused);
10504
+ },
10505
+ async secrets(names) {
10506
+ const reply2 = await ask((id) => ({ cabaneRunner: "secrets", id, names: [...names] }));
10507
+ return reply2?.cabaneLauncher === "secrets" ? reply2.values : {};
10083
10508
  }
10084
10509
  };
10085
10510
  }
@@ -10093,6 +10518,7 @@ var init_launcher_channel = __esm({
10093
10518
  // src/runner/run-turn.ts
10094
10519
  var run_turn_exports = {};
10095
10520
  __export(run_turn_exports, {
10521
+ drainBeforeExit: () => drainBeforeExit,
10096
10522
  runTurn: () => runTurn,
10097
10523
  runnerDispatcherOptions: () => runnerDispatcherOptions,
10098
10524
  runnerLogger: () => runnerLogger,
@@ -10100,6 +10526,19 @@ __export(run_turn_exports, {
10100
10526
  });
10101
10527
  import { spawnSync } from "child_process";
10102
10528
  import pino2 from "pino";
10529
+ async function drainBeforeExit(outbox, api, graceMs = OUTBOX_GRACE_MS, log) {
10530
+ if (outbox.size() === 0) return;
10531
+ const deadline = Date.now() + graceMs;
10532
+ while (Date.now() < deadline) {
10533
+ const { remaining } = await api.drainOutbox().catch(() => ({ remaining: outbox.size() }));
10534
+ if (remaining === 0) return;
10535
+ await new Promise((resolve2) => setTimeout(resolve2, Math.min(OUTBOX_RETRY_MS, graceMs)));
10536
+ }
10537
+ log?.warn(
10538
+ { remaining: outbox.size() },
10539
+ "runner: exiting with updates Cabane hasn't taken yet; the companion delivers them"
10540
+ );
10541
+ }
10103
10542
  function runnerLogger(input, stderr) {
10104
10543
  return pino2({ level: input.spec.logLevel }, stderr).child({ agentName: input.spec.agentName });
10105
10544
  }
@@ -10116,6 +10555,8 @@ function runnerDispatcherOptions(input, log, emit, extras = {}) {
10116
10555
  outbox,
10117
10556
  log
10118
10557
  });
10558
+ extras.onOutbox?.(outbox, api);
10559
+ const { bindAttempt, secrets } = extras;
10119
10560
  const health = new Map(Object.entries(spec.connectorHealth));
10120
10561
  const connectorHealth = {
10121
10562
  lookup: (runtime) => health.get(runtime),
@@ -10157,6 +10598,11 @@ function runnerDispatcherOptions(input, log, emit, extras = {}) {
10157
10598
  (sessionId) => emit({ cabaneRunner: "remote_session", serverUrl, sessionId })
10158
10599
  ),
10159
10600
  ...extras.onProcessGroup ? { onProcessGroup: extras.onProcessGroup } : {},
10601
+ ...spec.attemptState && bindAttempt ? {
10602
+ onContext: (attemptId) => bindAttempt(attemptId),
10603
+ onLeased: (attemptId) => emit({ cabaneRunner: "leased", attemptId })
10604
+ } : {},
10605
+ ...spec.secretsFromLauncher && secrets ? { secrets: async (names) => makeStore(await secrets(names)) } : {},
10160
10606
  codexExecutable: (path, containment) => {
10161
10607
  const prefix = containment.scopePrefix(process.env);
10162
10608
  return prefix ? shims.write([...prefix, path]) : path;
@@ -10259,6 +10705,7 @@ async function runTurn(env, deps) {
10259
10705
  notifyStart: (info) => emit({ cabaneRunner: "start", ...info }),
10260
10706
  notifyEnd: (info) => emit({ cabaneRunner: "end", ...info })
10261
10707
  };
10708
+ let outbox;
10262
10709
  const opts = runnerDispatcherOptions(input, log, emit, {
10263
10710
  ...deps.queryFn ? { queryFn: deps.queryFn } : {},
10264
10711
  onSandbox: (s) => sandbox = s,
@@ -10267,7 +10714,10 @@ async function runTurn(env, deps) {
10267
10714
  onProcessGroup: (pgid) => {
10268
10715
  groups.push(pgid);
10269
10716
  emit({ cabaneRunner: "process_group", pgid });
10270
- }
10717
+ },
10718
+ bindAttempt: (attemptId) => channel.bindAttempt(attemptId),
10719
+ secrets: (names) => channel.secrets(names),
10720
+ onOutbox: (o, a) => outbox = { outbox: o, api: a }
10271
10721
  });
10272
10722
  const result = await new TurnExecution(opts, supervisor, input.dispatch, {
10273
10723
  turnId: input.turnId,
@@ -10276,6 +10726,7 @@ async function runTurn(env, deps) {
10276
10726
  shims.dispose();
10277
10727
  await sandbox?.dispose();
10278
10728
  });
10729
+ if (outbox) await drainBeforeExit(outbox.outbox, outbox.api, deps.outboxGraceMs, log);
10279
10730
  emit({ cabaneRunner: "result", result });
10280
10731
  return { code: result.ok ? 0 : 1, result };
10281
10732
  } catch (err) {
@@ -10285,12 +10736,13 @@ async function runTurn(env, deps) {
10285
10736
  deps.signals.off("SIGTERM", onTerm);
10286
10737
  }
10287
10738
  }
10288
- var LAUNCHER_GONE_GRACE_MS;
10739
+ var OUTBOX_GRACE_MS, OUTBOX_RETRY_MS, LAUNCHER_GONE_GRACE_MS;
10289
10740
  var init_run_turn = __esm({
10290
10741
  "src/runner/run-turn.ts"() {
10291
10742
  "use strict";
10292
10743
  init_api();
10293
10744
  init_outbox();
10745
+ init_secrets();
10294
10746
  init_exec_shim();
10295
10747
  init_harness_sandbox();
10296
10748
  init_launcher_channel();
@@ -10298,6 +10750,8 @@ var init_run_turn = __esm({
10298
10750
  init_transcript_writer();
10299
10751
  init_turn_execution();
10300
10752
  init_protocol();
10753
+ OUTBOX_GRACE_MS = 3e4;
10754
+ OUTBOX_RETRY_MS = 1e3;
10301
10755
  LAUNCHER_GONE_GRACE_MS = 3e3;
10302
10756
  }
10303
10757
  });
@@ -10601,7 +11055,8 @@ function buildCompanionManifest(opts) {
10601
11055
  return {
10602
11056
  runtimes,
10603
11057
  capabilities: { ...DEVICE_MANIFEST.capabilities },
10604
- runtimeIssues: opts.runtimeIssues ?? []
11058
+ runtimeIssues: opts.runtimeIssues ?? [],
11059
+ ...opts.runnerProvider ? { runner: { provider: opts.runnerProvider } } : {}
10605
11060
  };
10606
11061
  }
10607
11062
  function runtimeIssuesFor(gates) {
@@ -11431,7 +11886,7 @@ async function pair(opts = {}) {
11431
11886
  }
11432
11887
 
11433
11888
  // src/cli.ts
11434
- import { readFileSync as readFileSync11 } from "fs";
11889
+ import { readFileSync as readFileSync13 } from "fs";
11435
11890
 
11436
11891
  // src/commands/logs.ts
11437
11892
  import { once } from "events";
@@ -11637,7 +12092,7 @@ import { hostname as hostname4 } from "os";
11637
12092
 
11638
12093
  // src/runtime.ts
11639
12094
  init_config();
11640
- import { randomUUID as randomUUID6 } from "crypto";
12095
+ import { randomUUID as randomUUID7 } from "crypto";
11641
12096
 
11642
12097
  // src/dashboard/server.ts
11643
12098
  init_errors();
@@ -12277,7 +12732,7 @@ function turnFailureCopy(reason, runtime) {
12277
12732
  init_api_error_shape();
12278
12733
  init_boot_id();
12279
12734
  init_api();
12280
- import { randomUUID as randomUUID5 } from "crypto";
12735
+ import { randomUUID as randomUUID6 } from "crypto";
12281
12736
 
12282
12737
  // src/device-api.ts
12283
12738
  init_errors();
@@ -12549,56 +13004,976 @@ var ConnectorHealthStore = class {
12549
13004
  lastError: h.lastError
12550
13005
  }));
12551
13006
  }
12552
- };
12553
-
12554
- // src/supervisor.ts
12555
- init_dispatcher();
13007
+ };
13008
+
13009
+ // src/supervisor.ts
13010
+ init_dispatcher();
13011
+
13012
+ // src/providers/local-process.ts
13013
+ import { spawn as spawn6 } from "child_process";
13014
+ import { constants } from "os";
13015
+ var DEFAULT_KILL_GRACE_MS = 3e4;
13016
+ var DEFAULT_REAP_GRACE_MS = 3e3;
13017
+ function exitCode(code, signal) {
13018
+ if (code !== null) return code;
13019
+ const signum = signal ? constants.signals[signal] : void 0;
13020
+ return signum !== void 0 ? 128 + signum : 1;
13021
+ }
13022
+ function createLocalProcessProvider(opts = {}) {
13023
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
13024
+ const spawnFn = opts.spawnFn ?? spawn6;
13025
+ const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
13026
+ const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
13027
+ const groupAlive = (pgid) => {
13028
+ try {
13029
+ killFn(-pgid, 0);
13030
+ return true;
13031
+ } catch {
13032
+ return false;
13033
+ }
13034
+ };
13035
+ const signalGroup = (pgid, signal) => {
13036
+ try {
13037
+ killFn(-pgid, signal);
13038
+ } catch {
13039
+ }
13040
+ };
13041
+ return {
13042
+ name: "local-process",
13043
+ async acquire(attempt) {
13044
+ return { provider: "local-process", id: attempt.turnId };
13045
+ },
13046
+ exec(_handle, argv, env, { signal, stdin }) {
13047
+ const [command, ...args] = argv;
13048
+ if (!command) throw new Error("local-process: exec needs a command");
13049
+ const child = spawnFn(command, args, {
13050
+ env: { ...env },
13051
+ stdio: ["pipe", "pipe", "pipe"],
13052
+ detached: process.platform !== "win32"
13053
+ });
13054
+ child.stdin?.on("error", () => {
13055
+ });
13056
+ if (stdin) child.stdin?.write(stdin);
13057
+ const exit = new Promise((resolve2) => {
13058
+ let settled = false;
13059
+ const done = (code) => {
13060
+ if (settled) return;
13061
+ settled = true;
13062
+ signal.removeEventListener("abort", stop2);
13063
+ if (forceTimer) clearTimeout(forceTimer);
13064
+ resolve2(code);
13065
+ };
13066
+ let forceTimer;
13067
+ const stop2 = () => {
13068
+ child.kill("SIGTERM");
13069
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
13070
+ forceTimer.unref?.();
13071
+ };
13072
+ child.once("error", () => done(1));
13073
+ child.once("close", (code, sig) => done(exitCode(code, sig)));
13074
+ if (signal.aborted) stop2();
13075
+ else signal.addEventListener("abort", stop2, { once: true });
13076
+ });
13077
+ if (!child.stdout || !child.stderr || !child.stdin)
13078
+ throw new Error("local-process: runner stdio missing");
13079
+ const pid = child.pid;
13080
+ return {
13081
+ exit,
13082
+ ...pid !== void 0 ? { pid } : {},
13083
+ stdout: child.stdout,
13084
+ stderr: child.stderr,
13085
+ stdin: child.stdin,
13086
+ async reap(groups) {
13087
+ if (process.platform === "win32") return;
13088
+ const targets = [.../* @__PURE__ */ new Set([...pid !== void 0 ? [pid] : [], ...groups])].filter(
13089
+ groupAlive
13090
+ );
13091
+ if (!targets.length) return;
13092
+ for (const pgid of targets) signalGroup(pgid, "SIGTERM");
13093
+ await new Promise((resolve2) => setTimeout(resolve2, reapGraceMs));
13094
+ for (const pgid of targets.filter(groupAlive)) signalGroup(pgid, "SIGKILL");
13095
+ }
13096
+ };
13097
+ },
13098
+ async release() {
13099
+ }
13100
+ };
13101
+ }
13102
+
13103
+ // src/providers/docker.ts
13104
+ import { createHash as createHash4, randomUUID as randomUUID5 } from "crypto";
13105
+ import { constants as constants2, tmpdir as tmpdir2 } from "os";
13106
+ import {
13107
+ createReadStream,
13108
+ createWriteStream as createWriteStream2,
13109
+ existsSync as existsSync13,
13110
+ mkdirSync as mkdirSync10,
13111
+ readdirSync as readdirSync4,
13112
+ readFileSync as readFileSync9,
13113
+ renameSync as renameSync5,
13114
+ rmSync as rmSync9,
13115
+ statSync as statSync3,
13116
+ writeFileSync as writeFileSync6
13117
+ } from "fs";
13118
+ import { join as join17 } from "path";
13119
+ import { Writable } from "stream";
13120
+
13121
+ // src/providers/docker-cli.ts
13122
+ import { spawn as spawn7 } from "child_process";
13123
+ function cliEnv(extra) {
13124
+ const keep = [
13125
+ "PATH",
13126
+ "HOME",
13127
+ "DOCKER_HOST",
13128
+ "DOCKER_CONFIG",
13129
+ "DOCKER_CONTEXT",
13130
+ "DOCKER_CERT_PATH"
13131
+ ];
13132
+ const env = {};
13133
+ for (const k of keep) if (process.env[k] !== void 0) env[k] = process.env[k];
13134
+ return { ...env, ...extra };
13135
+ }
13136
+ function dockerCli(bin = "docker") {
13137
+ return {
13138
+ run(args, opts = {}) {
13139
+ return new Promise((resolve2) => {
13140
+ let child;
13141
+ try {
13142
+ child = spawn7(bin, [...args], {
13143
+ env: cliEnv(opts.env),
13144
+ stdio: ["pipe", "pipe", "pipe"]
13145
+ });
13146
+ } catch (err) {
13147
+ resolve2({ code: 127, stdout: "", stderr: String(err) });
13148
+ return;
13149
+ }
13150
+ let stdout = "";
13151
+ let stderr = "";
13152
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : void 0;
13153
+ if (opts.stdout) child.stdout?.pipe(opts.stdout);
13154
+ else child.stdout?.on("data", (chunk) => stdout += String(chunk));
13155
+ child.stderr?.on("data", (chunk) => stderr += String(chunk));
13156
+ child.stdin?.on("error", () => {
13157
+ });
13158
+ child.once("error", (err) => {
13159
+ if (timer) clearTimeout(timer);
13160
+ resolve2({ code: 127, stdout, stderr: stderr || err.message });
13161
+ });
13162
+ child.once("close", (code, signal) => {
13163
+ if (timer) clearTimeout(timer);
13164
+ resolve2({ code: code ?? (signal ? 137 : 1), stdout, stderr });
13165
+ });
13166
+ const input = opts.input;
13167
+ if (input === void 0) child.stdin?.end();
13168
+ else if (typeof input === "string" || Buffer.isBuffer(input)) child.stdin?.end(input);
13169
+ else input.pipe(child.stdin);
13170
+ });
13171
+ },
13172
+ spawn(args, env) {
13173
+ return spawn7(bin, [...args], { env: cliEnv(env), stdio: ["pipe", "pipe", "pipe"] });
13174
+ }
13175
+ };
13176
+ }
13177
+
13178
+ // src/providers/docker.ts
13179
+ init_git_credential();
13180
+
13181
+ // src/runner/collected-outbox.ts
13182
+ import {
13183
+ copyFileSync as copyFileSync2,
13184
+ existsSync as existsSync11,
13185
+ mkdirSync as mkdirSync9,
13186
+ readdirSync as readdirSync3,
13187
+ readFileSync as readFileSync8,
13188
+ renameSync as renameSync4,
13189
+ rmSync as rmSync8
13190
+ } from "fs";
13191
+ import { join as join15 } from "path";
13192
+ function routes(t) {
13193
+ const conv = `/api/workspaces/${t.workspaceId}/conversations/${t.conversationId}`;
13194
+ const agent = `${conv}/participants/agents/${t.agentId}`;
13195
+ return {
13196
+ message: { method: "POST", path: `${conv}/messages` },
13197
+ activity: { method: "POST", path: `${agent}/activity` },
13198
+ thinking: { method: "POST", path: `${agent}/thinking` },
13199
+ text: { method: "POST", path: `${agent}/text` },
13200
+ "active-run": { method: "PATCH", path: `${agent}/active-run` }
13201
+ };
13202
+ }
13203
+ function admitCollectedEntry(raw, turn) {
13204
+ if (!raw || typeof raw !== "object") return null;
13205
+ const entry = raw;
13206
+ const kind = entry.kind;
13207
+ if (!kind || !(kind in routes(turn))) return null;
13208
+ const route = routes(turn)[kind];
13209
+ if (entry.method !== route.method || entry.path !== route.path) return null;
13210
+ if (typeof entry.seq !== "number" || typeof entry.enqueuedAt !== "number") return null;
13211
+ const body = entry.body;
13212
+ if (!body || typeof body !== "object" || typeof body.turnId !== "string") return null;
13213
+ if (!turn.turnIds.includes(body.turnId)) return null;
13214
+ if (kind === "active-run") {
13215
+ if (body.activeRunStartedAt !== null) return null;
13216
+ if (entry.turnId !== `activerun:${turn.conversationId}:${turn.agentId}`) return null;
13217
+ } else if (entry.turnId !== body.turnId) {
13218
+ return null;
13219
+ }
13220
+ return entry;
13221
+ }
13222
+ var atomicMove = (from, to) => {
13223
+ try {
13224
+ renameSync4(from, to);
13225
+ } catch {
13226
+ const partial = `${to}.partial`;
13227
+ copyFileSync2(from, partial);
13228
+ renameSync4(partial, to);
13229
+ rmSync8(from, { force: true });
13230
+ }
13231
+ };
13232
+ function publishStaged(from, to, move = atomicMove) {
13233
+ if (!existsSync11(from)) return { published: 0, complete: true };
13234
+ const staged = readdirSync3(from).filter((n) => n.endsWith(".json")).map((name) => {
13235
+ let entry = {};
13236
+ try {
13237
+ entry = JSON.parse(readFileSync8(join15(from, name), "utf8"));
13238
+ } catch {
13239
+ }
13240
+ return { name, entry };
13241
+ });
13242
+ const settle = (e) => e.kind === "active-run" ? 1 : 0;
13243
+ staged.sort(
13244
+ (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)
13245
+ );
13246
+ if (staged.length) mkdirSync9(to, { recursive: true });
13247
+ let published = 0;
13248
+ for (const { name } of staged) {
13249
+ try {
13250
+ move(join15(from, name), join15(to, name));
13251
+ } catch {
13252
+ return { published, complete: false };
13253
+ }
13254
+ published++;
13255
+ }
13256
+ rmSync8(from, { recursive: true, force: true });
13257
+ return { published, complete: true };
13258
+ }
13259
+
13260
+ // src/providers/runner-base.ts
13261
+ import { createHash as createHash3 } from "crypto";
13262
+ import { existsSync as existsSync12, realpathSync as realpathSync2 } from "fs";
13263
+ import { dirname as dirname7, join as join16, sep } from "path";
13264
+ import { fileURLToPath as fileURLToPath3 } from "url";
13265
+ var COMPANION_MOUNT = "/opt/cabane/companion";
13266
+ var BASE_DOCKERFILE = `FROM node:22-slim
13267
+ RUN apt-get update \\
13268
+ && apt-get install -y --no-install-recommends git curl ca-certificates procps \\
13269
+ && rm -rf /var/lib/apt/lists/* \\
13270
+ && corepack enable
13271
+ RUN usermod -l runner -d /home/runner -m node \\
13272
+ && groupmod -n runner node \\
13273
+ && mkdir -p /work /var/lib/cabane-runner /run/cabane ${COMPANION_MOUNT} \\
13274
+ && chown runner:runner /work /var/lib/cabane-runner
13275
+ RUN mkdir -p /home/runner/.codex /opt/cabane \\
13276
+ && printf '[features]\\nuse_legacy_landlock = true\\n' > /opt/cabane/codex-config.toml \\
13277
+ && cp /opt/cabane/codex-config.toml /home/runner/.codex/config.toml \\
13278
+ && chown -R runner:runner /home/runner/.codex
13279
+ RUN printf '#!/bin/sh\\nexec node "\${CABANE_COMPANION_ROOT:-${COMPANION_MOUNT}}/dist/cli.js" "$@"\\n' > /usr/local/bin/cabane-companion \\
13280
+ && chmod 755 /usr/local/bin/cabane-companion
13281
+ USER runner
13282
+ WORKDIR /home/runner
13283
+ CMD ["sleep", "infinity"]
13284
+ `;
13285
+ function baseImageTag(dockerfile = BASE_DOCKERFILE) {
13286
+ return `cabane-runner-base:${createHash3("sha256").update(dockerfile).digest("hex").slice(0, 12)}`;
13287
+ }
13288
+ async function ensureBaseImage(docker, image, log) {
13289
+ if ((await docker.run(["image", "inspect", image])).code === 0) return true;
13290
+ if (image !== baseImageTag()) {
13291
+ log.error({ image }, "runners: the configured runner image isn't on this engine");
13292
+ return false;
13293
+ }
13294
+ log.info({ image }, "runners: building the runner base image (once per base)");
13295
+ const built = await docker.run(["build", "-t", image, "-"], {
13296
+ input: BASE_DOCKERFILE,
13297
+ timeoutMs: 20 * 6e4
13298
+ });
13299
+ if (built.code !== 0) {
13300
+ log.error(
13301
+ { image, err: built.stderr.trim().slice(-2e3) },
13302
+ "runners: couldn't build the runner base image; no turn can run until it is"
13303
+ );
13304
+ return false;
13305
+ }
13306
+ return true;
13307
+ }
13308
+ function ownInstall(moduleFile = fileURLToPath3(import.meta.url)) {
13309
+ let dir2 = dirname7(moduleFile);
13310
+ for (let i = 0; i < 4; i++) {
13311
+ if (existsSync12(join16(dir2, "dist", "cli.js")) && existsSync12(join16(dir2, "package.json"))) break;
13312
+ dir2 = dirname7(dir2);
13313
+ }
13314
+ if (!existsSync12(join16(dir2, "dist", "cli.js"))) return null;
13315
+ const sdk = join16(dir2, "node_modules", "@anthropic-ai", "claude-agent-sdk");
13316
+ if (!existsSync12(sdk)) return null;
13317
+ const root = realpathSync2(dir2);
13318
+ return realpathSync2(sdk).startsWith(root + sep) ? root : null;
13319
+ }
13320
+ function companionSource(input) {
13321
+ if (input.configured) {
13322
+ return { kind: "dir", path: input.configured, key: `dir:${input.configured}` };
13323
+ }
13324
+ if (input.platform === "linux") {
13325
+ if (input.own)
13326
+ return { kind: "dir", path: input.own, key: `dir:${input.own}:${input.version}` };
13327
+ return {
13328
+ 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"
13329
+ };
13330
+ }
13331
+ if (input.version === "0.0.0") {
13332
+ return {
13333
+ error: "an unreleased companion can't be installed into a container from npm; set runners.docker.companionDir to a Linux install of it"
13334
+ };
13335
+ }
13336
+ return {
13337
+ kind: "volume",
13338
+ volume: `cabane-companion-${input.version}`,
13339
+ version: input.version,
13340
+ key: `volume:${input.version}`
13341
+ };
13342
+ }
13343
+ function companionRoot(source) {
13344
+ return source.kind === "dir" ? COMPANION_MOUNT : `${COMPANION_MOUNT}/lib/node_modules/@cabane/companion`;
13345
+ }
13346
+ function companionMountArgs(source) {
13347
+ const mount = source.kind === "dir" ? `type=bind,src=${source.path},dst=${COMPANION_MOUNT},readonly` : `type=volume,src=${source.volume},dst=${COMPANION_MOUNT},readonly`;
13348
+ return ["--mount", mount, "-e", `CABANE_COMPANION_ROOT=${companionRoot(source)}`];
13349
+ }
13350
+ var INSTALL_MARKER = `${COMPANION_MOUNT}/.cabane-install-complete`;
13351
+ function volumeInstallScript(version, install = `npm install -g --prefix ${COMPANION_MOUNT} @cabane/companion@${version}`) {
13352
+ const root = `${COMPANION_MOUNT}/lib/node_modules/@cabane/companion`;
13353
+ return [
13354
+ `find ${COMPANION_MOUNT} -mindepth 1 -delete`,
13355
+ install,
13356
+ // Installed is not the same as runnable: the package must start and say
13357
+ // what it runs before the volume counts as done.
13358
+ `node ${root}/dist/cli.js run-turn --manifest > /dev/null`,
13359
+ `printf %s ${version} > ${INSTALL_MARKER}`
13360
+ ].join(" && ");
13361
+ }
13362
+ var volumeMount = (source) => `type=volume,src=${source.volume},dst=${COMPANION_MOUNT}`;
13363
+ async function ensureCompanionVolume(docker, image, source, log, script = volumeInstallScript) {
13364
+ if (source.kind !== "volume") return true;
13365
+ const done = await docker.run([
13366
+ "run",
13367
+ "--rm",
13368
+ "--network",
13369
+ "none",
13370
+ "--mount",
13371
+ volumeMount(source),
13372
+ image,
13373
+ "sh",
13374
+ "-c",
13375
+ `[ "$(cat ${INSTALL_MARKER} 2>/dev/null)" = "$1" ]`,
13376
+ "sh",
13377
+ source.version
13378
+ ]);
13379
+ if (done.code === 0) return true;
13380
+ log.info(
13381
+ { version: source.version },
13382
+ "runners: installing this companion version for its containers"
13383
+ );
13384
+ const installed = await docker.run(
13385
+ [
13386
+ "run",
13387
+ "--rm",
13388
+ "-u",
13389
+ "root",
13390
+ "--mount",
13391
+ volumeMount(source),
13392
+ image,
13393
+ "sh",
13394
+ "-c",
13395
+ script(source.version)
13396
+ ],
13397
+ { timeoutMs: 20 * 6e4 }
13398
+ );
13399
+ if (installed.code !== 0) {
13400
+ log.error(
13401
+ { version: source.version, err: installed.stderr.trim().slice(-2e3) },
13402
+ "runners: couldn't install the companion for its containers; the next start tries again"
13403
+ );
13404
+ return false;
13405
+ }
13406
+ return true;
13407
+ }
13408
+ async function invalidateCompanionVolume(docker, image, source) {
13409
+ if (source.kind !== "volume") return;
13410
+ await docker.run([
13411
+ "run",
13412
+ "--rm",
13413
+ "-u",
13414
+ "root",
13415
+ "--network",
13416
+ "none",
13417
+ "--mount",
13418
+ volumeMount(source),
13419
+ image,
13420
+ "rm",
13421
+ "-f",
13422
+ INSTALL_MARKER
13423
+ ]);
13424
+ }
12556
13425
 
12557
- // src/providers/local-process.ts
12558
- import { spawn as spawn6 } from "child_process";
12559
- import { constants } from "os";
12560
- var DEFAULT_KILL_GRACE_MS = 3e4;
12561
- var DEFAULT_REAP_GRACE_MS = 3e3;
12562
- function exitCode(code, signal) {
12563
- if (code !== null) return code;
12564
- const signum = signal ? constants.signals[signal] : void 0;
12565
- return signum !== void 0 ? 128 + signum : 1;
13426
+ // src/providers/docker.ts
13427
+ var CONTAINER_HOME = "/home/runner";
13428
+ var CONTAINER_WORK = "/work";
13429
+ var CONTAINER_STATE = "/var/lib/cabane-runner";
13430
+ var CONTAINER_SOCKETS = "/run/cabane";
13431
+ var PROXY_ALIAS = "cabane-egress";
13432
+ var PROXY_PORT = 3128;
13433
+ var DEFAULT_KILL_GRACE_MS2 = 3e4;
13434
+ var REAP_GRACE_S = 3;
13435
+ var DEFAULT_SESSION_CAP_BYTES = 512 * 1024 * 1024;
13436
+ var COLLECT_CAP_BYTES = 128 * 1024 * 1024;
13437
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13438
+ function containerName(attempt) {
13439
+ return `cabane-runner-${attempt.conversationId}-${attempt.agentId}`;
13440
+ }
13441
+ function sessionArchivePath(deviceDir, attemptId) {
13442
+ if (!UUID_RE.test(attemptId)) throw new Error(`docker: not an attempt id: ${attemptId}`);
13443
+ return join17(deviceDir, "attempts", attemptId, "session.tar");
13444
+ }
13445
+ function pruneOtherAttempts(deviceDir, pair2, keep) {
13446
+ const root = join17(deviceDir, "attempts");
13447
+ if (!existsSync13(root)) return;
13448
+ for (const id of readdirSync4(root)) {
13449
+ if (id === keep || !UUID_RE.test(id)) continue;
13450
+ let pairOf = "";
13451
+ try {
13452
+ pairOf = readFileSync9(join17(root, id, "pair"), "utf8");
13453
+ } catch {
13454
+ continue;
13455
+ }
13456
+ if (pairOf === `${pair2.conversationId}/${pair2.agentId}`) {
13457
+ rmSync9(join17(root, id), { recursive: true, force: true });
13458
+ }
13459
+ }
12566
13460
  }
12567
- function createLocalProcessProvider(opts = {}) {
12568
- const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
12569
- const spawnFn = opts.spawnFn ?? spawn6;
12570
- const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
12571
- const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
12572
- const groupAlive = (pgid) => {
13461
+ var CappedFile = class extends Writable {
13462
+ constructor(path, cap) {
13463
+ super();
13464
+ this.path = path;
13465
+ this.cap = cap;
13466
+ this.out = createWriteStream2(path);
13467
+ }
13468
+ path;
13469
+ cap;
13470
+ bytes = 0;
13471
+ over = false;
13472
+ out;
13473
+ _write(chunk, _enc, done) {
13474
+ this.bytes += chunk.length;
13475
+ if (this.bytes > this.cap) {
13476
+ this.over = true;
13477
+ done();
13478
+ return;
13479
+ }
13480
+ this.out.write(chunk, done);
13481
+ }
13482
+ _final(done) {
13483
+ this.out.end(done);
13484
+ }
13485
+ };
13486
+ var DUMP_SCRIPT = `
13487
+ const fs = require('fs'), path = require('path');
13488
+ const dir = process.argv[1], cap = Number(process.argv[2]);
13489
+ let names;
13490
+ try { names = fs.readdirSync(dir); }
13491
+ catch (e) {
13492
+ if (e.code === 'ENOENT') { process.stdout.write(JSON.stringify({ complete: true, files: [] })); process.exit(0); }
13493
+ process.stderr.write(String(e.message)); process.exit(2);
13494
+ }
13495
+ let total = 0, complete = true; const files = [];
13496
+ for (const name of names) {
13497
+ const p = path.join(dir, name);
13498
+ let st;
13499
+ try { st = fs.lstatSync(p); } catch (e) { process.stderr.write(String(e.message)); process.exit(2); }
13500
+ if (!st.isFile()) continue;
13501
+ // Always at least one file per read, so one over the cap is still collected.
13502
+ if (files.length && total + st.size > cap) { complete = false; continue; }
13503
+ let data;
13504
+ try { data = fs.readFileSync(p); } catch (e) { process.stderr.write(String(e.message)); process.exit(2); }
13505
+ total += st.size;
13506
+ files.push({ name, data: data.toString('base64') });
13507
+ }
13508
+ process.stdout.write(JSON.stringify({ complete, files }));
13509
+ `;
13510
+ var SAFE_NAME_RE = /^[\w.%@+-]+$/;
13511
+ function createDockerProvider(opts) {
13512
+ const docker = opts.docker ?? dockerCli();
13513
+ const log = opts.log;
13514
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS2;
13515
+ const sessionCap = opts.sessionCapBytes ?? DEFAULT_SESSION_CAP_BYTES;
13516
+ const collectCap = opts.collectCapBytes ?? COLLECT_CAP_BYTES;
13517
+ const network = `cabane-runners-${opts.deviceKey}`;
13518
+ const companionLabel = createHash4("sha256").update(opts.companion.key).digest("hex").slice(0, 16);
13519
+ const proxy = `cabane-egress-${opts.deviceKey}`;
13520
+ const held = /* @__PURE__ */ new Map();
13521
+ const queues = /* @__PURE__ */ new Map();
13522
+ const must = async (args, what) => {
13523
+ const r = await docker.run(args);
13524
+ if (r.code !== 0)
13525
+ throw new Error(`docker: ${what} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
13526
+ return r.stdout;
13527
+ };
13528
+ const execIn = (name, args, extra = {}) => docker.run(
13529
+ ["exec", ...extra.input !== void 0 ? ["-i"] : [], "-u", "runner", name, ...args],
13530
+ extra
13531
+ );
13532
+ const proxyListening = async () => {
13533
+ for (let i = 0; i < 40; i++) {
13534
+ const r = await docker.run([
13535
+ "exec",
13536
+ proxy,
13537
+ "curl",
13538
+ "-s",
13539
+ "-o",
13540
+ "/dev/null",
13541
+ "--max-time",
13542
+ "1",
13543
+ `http://127.0.0.1:${PROXY_PORT}/`
13544
+ ]);
13545
+ if (r.code === 0) return;
13546
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
13547
+ }
13548
+ throw new Error("docker: the egress proxy never started listening");
13549
+ };
13550
+ let networkReady = null;
13551
+ const proxyKey = async () => {
13552
+ const id = (await docker.run(["image", "inspect", "-f", "{{.Id}}", opts.image])).stdout.trim();
13553
+ return createHash4("sha256").update(JSON.stringify([id || opts.image, opts.companion.key, [...opts.egressAllow].sort()])).digest("hex").slice(0, 16);
13554
+ };
13555
+ const ensureNetwork = () => {
13556
+ networkReady ??= (async () => {
13557
+ const allowKey = await proxyKey();
13558
+ if ((await docker.run(["network", "inspect", network])).code !== 0) {
13559
+ await must(
13560
+ [
13561
+ "network",
13562
+ "create",
13563
+ // No route off the host, and no address of the host's on the
13564
+ // bridge: the host's own ports are out of reach too. The proxy is
13565
+ // the only other thing on the network.
13566
+ "--internal",
13567
+ "-o",
13568
+ "com.docker.network.bridge.inhibit_ipv4=true",
13569
+ "--label",
13570
+ `cabane.device=${opts.deviceKey}`,
13571
+ network
13572
+ ],
13573
+ "creating the runners network"
13574
+ );
13575
+ }
13576
+ const seen = await docker.run([
13577
+ "inspect",
13578
+ "-f",
13579
+ '{{index .Config.Labels "cabane.allow"}} {{.State.Running}}',
13580
+ proxy
13581
+ ]);
13582
+ const [key, running] = seen.stdout.trim().split(" ");
13583
+ if (seen.code === 0 && key === allowKey) {
13584
+ if (running !== "true") {
13585
+ await must(["start", proxy], "starting the egress proxy");
13586
+ await proxyListening();
13587
+ }
13588
+ return;
13589
+ }
13590
+ if (seen.code === 0) await docker.run(["rm", "-f", proxy]);
13591
+ await must(
13592
+ [
13593
+ "run",
13594
+ "-d",
13595
+ "--name",
13596
+ proxy,
13597
+ "--label",
13598
+ `cabane.allow=${allowKey}`,
13599
+ "--label",
13600
+ `cabane.device=${opts.deviceKey}`,
13601
+ "--restart",
13602
+ "unless-stopped",
13603
+ "--cap-drop",
13604
+ "ALL",
13605
+ "--security-opt",
13606
+ "no-new-privileges",
13607
+ "-e",
13608
+ `CABANE_EGRESS_ALLOW=${JSON.stringify(opts.egressAllow)}`,
13609
+ ...companionMountArgs(opts.companion),
13610
+ opts.image,
13611
+ "cabane-companion",
13612
+ "egress-proxy",
13613
+ "--port",
13614
+ String(PROXY_PORT)
13615
+ ],
13616
+ "starting the egress proxy"
13617
+ );
13618
+ await must(
13619
+ ["network", "connect", "--alias", PROXY_ALIAS, network, proxy],
13620
+ "connecting the proxy"
13621
+ );
13622
+ await proxyListening();
13623
+ log.info({ network, proxy, allow: opts.egressAllow }, "runners: egress proxy ready");
13624
+ })().catch((err) => {
13625
+ networkReady = null;
13626
+ throw err;
13627
+ });
13628
+ return networkReady;
13629
+ };
13630
+ const socketRoot = opts.socketRoot ?? join17(
13631
+ process.env.XDG_RUNTIME_DIR && existsSync13(process.env.XDG_RUNTIME_DIR) ? process.env.XDG_RUNTIME_DIR : tmpdir2(),
13632
+ `cabane-runners-${opts.deviceKey}`
13633
+ );
13634
+ const socketDir = (name) => join17(socketRoot, createHash4("sha256").update(name).digest("hex").slice(0, 12));
13635
+ const create = async (name, attempt) => {
13636
+ mkdirSync10(socketRoot, { recursive: true, mode: 448 });
13637
+ mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
13638
+ const mounts = (opts.mounts ?? []).filter((m) => m.source.startsWith("/") && existsSync13(m.source)).flatMap((m) => ["--mount", `type=bind,src=${m.source},dst=${m.target},readonly`]);
13639
+ await must(
13640
+ [
13641
+ "create",
13642
+ "--name",
13643
+ name,
13644
+ "--init",
13645
+ "--hostname",
13646
+ "cabane-runner",
13647
+ "--label",
13648
+ "cabane.runner=1",
13649
+ "--label",
13650
+ `cabane.device=${opts.deviceKey}`,
13651
+ "--label",
13652
+ `cabane.workspace=${attempt.workspaceId}`,
13653
+ "--label",
13654
+ `cabane.conversation=${attempt.conversationId}`,
13655
+ "--label",
13656
+ `cabane.agent=${attempt.agentId}`,
13657
+ "--label",
13658
+ `cabane.companion=${companionLabel}`,
13659
+ "--network",
13660
+ network,
13661
+ "--mount",
13662
+ `type=volume,src=${name}-home,dst=${CONTAINER_HOME}`,
13663
+ "--mount",
13664
+ `type=volume,src=${name}-work,dst=${CONTAINER_WORK}`,
13665
+ "--mount",
13666
+ `type=bind,src=${socketDir(name)},dst=${CONTAINER_SOCKETS},readonly`,
13667
+ ...mounts,
13668
+ ...companionMountArgs(opts.companion),
13669
+ "--cap-drop",
13670
+ "ALL",
13671
+ "--security-opt",
13672
+ "no-new-privileges",
13673
+ "--pids-limit",
13674
+ "4096",
13675
+ opts.image,
13676
+ "sleep",
13677
+ "infinity"
13678
+ ],
13679
+ "creating the container"
13680
+ );
13681
+ };
13682
+ const destroy = async (name) => {
13683
+ await docker.run(["rm", "-f", "-v", name]);
13684
+ await docker.run(["volume", "rm", "-f", `${name}-home`, `${name}-work`]);
13685
+ rmSync9(socketDir(name), { recursive: true, force: true });
13686
+ };
13687
+ const containerEnv = () => {
13688
+ const proxyUrl = `http://${PROXY_ALIAS}:${PROXY_PORT}`;
13689
+ const git = [
13690
+ ["credential.helper", ""],
13691
+ // The helper speaks a socket, not the network: no proxy, and none of
13692
+ // Node's warning about it in git's output.
13693
+ ["credential.helper", "!NODE_USE_ENV_PROXY= cabane-companion git-credential"],
13694
+ ...opts.gitHosts.flatMap((h) => [
13695
+ [`url.https://${h}/.insteadOf`, `git@${h}:`],
13696
+ [`url.https://${h}/.insteadOf`, `ssh://git@${h}/`]
13697
+ ])
13698
+ ];
13699
+ return {
13700
+ HOME: CONTAINER_HOME,
13701
+ CABANE_COMPANION_HOME: CONTAINER_STATE,
13702
+ CABANE_RUNNER_STATE_DIR: CONTAINER_STATE,
13703
+ HTTPS_PROXY: proxyUrl,
13704
+ HTTP_PROXY: proxyUrl,
13705
+ https_proxy: proxyUrl,
13706
+ http_proxy: proxyUrl,
13707
+ NO_PROXY: "localhost,127.0.0.1,::1",
13708
+ no_proxy: "localhost,127.0.0.1,::1",
13709
+ // Node's own fetch — the runner's wire — honours the proxy only when asked.
13710
+ NODE_USE_ENV_PROXY: "1",
13711
+ CABANE_GIT_CREDENTIAL_SOCKET: `${CONTAINER_SOCKETS}/git.sock`,
13712
+ GIT_TERMINAL_PROMPT: "0",
13713
+ GIT_CONFIG_COUNT: String(git.length),
13714
+ ...Object.fromEntries(
13715
+ git.flatMap(([k, v], i) => [
13716
+ [`GIT_CONFIG_KEY_${i}`, k],
13717
+ [`GIT_CONFIG_VALUE_${i}`, v]
13718
+ ])
13719
+ )
13720
+ };
13721
+ };
13722
+ const heldFor = (handle) => {
13723
+ const h = held.get(handle.id);
13724
+ if (!h) throw new Error(`docker: ${handle.id} is not held`);
13725
+ return h;
13726
+ };
13727
+ const writeMarker = (name, attemptId) => execIn(name, [
13728
+ "sh",
13729
+ "-c",
13730
+ `mkdir -p ${CONTAINER_STATE} && printf %s "$1" > ${CONTAINER_STATE}/attempt`,
13731
+ "sh",
13732
+ attemptId
13733
+ ]);
13734
+ const readDir = async (name, from) => {
13735
+ const r = await execIn(name, [
13736
+ "sh",
13737
+ "-c",
13738
+ '[ -e "$1" ] && chmod -R u+rwX "$1"; exec node -e "$2" "$1" "$3"',
13739
+ "sh",
13740
+ from,
13741
+ DUMP_SCRIPT,
13742
+ String(collectCap)
13743
+ ]);
13744
+ if (r.code !== 0) {
13745
+ log.warn(
13746
+ { container: name, dir: from, err: r.stderr.trim() },
13747
+ "runners: couldn't read what the runner left"
13748
+ );
13749
+ return null;
13750
+ }
12573
13751
  try {
12574
- killFn(-pgid, 0);
12575
- return true;
13752
+ const dump = JSON.parse(r.stdout);
13753
+ if (typeof dump.complete !== "boolean" || !Array.isArray(dump.files)) return null;
13754
+ return {
13755
+ complete: dump.complete,
13756
+ files: dump.files.filter(
13757
+ (f) => typeof f.name === "string" && typeof f.data === "string"
13758
+ ).map((f) => ({ name: f.name, data: Buffer.from(f.data, "base64") }))
13759
+ };
12576
13760
  } catch {
12577
- return false;
13761
+ return null;
12578
13762
  }
12579
13763
  };
12580
- const signalGroup = (pgid, signal) => {
13764
+ const removeIn = (name, from, names) => names.length ? execIn(name, ["sh", "-c", 'cd "$1" && shift && rm -f -- "$@"', "sh", from, ...names]) : Promise.resolve(null);
13765
+ const pendingPath = (name) => join17(opts.deviceDir, "runners-pending", `${name}.json`);
13766
+ const readPending = (name) => {
12581
13767
  try {
12582
- killFn(-pgid, signal);
13768
+ const ids = JSON.parse(readFileSync9(pendingPath(name), "utf8"));
13769
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
12583
13770
  } catch {
13771
+ return [];
12584
13772
  }
12585
13773
  };
12586
- return {
12587
- name: "local-process",
13774
+ const writePending = (name, ids) => {
13775
+ if (!ids.length) {
13776
+ rmSync9(pendingPath(name), { force: true });
13777
+ return;
13778
+ }
13779
+ mkdirSync10(join17(opts.deviceDir, "runners-pending"), { recursive: true });
13780
+ writeFileSync6(pendingPath(name), JSON.stringify([...new Set(ids)]));
13781
+ };
13782
+ const keepSession = async (name, attempt, attemptId) => {
13783
+ const archive = sessionArchivePath(opts.deviceDir, attemptId);
13784
+ mkdirSync10(join17(archive, ".."), { recursive: true });
13785
+ const partial = `${archive}.${process.pid}.partial`;
13786
+ const file = new CappedFile(partial, sessionCap);
13787
+ const r = await execIn(
13788
+ name,
13789
+ [
13790
+ "sh",
13791
+ "-c",
13792
+ '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 "$@"'
13793
+ ],
13794
+ { stdout: file }
13795
+ );
13796
+ await new Promise(
13797
+ (resolve2) => file.writableFinished ? resolve2() : file.once("finish", resolve2)
13798
+ );
13799
+ if (r.code === 0 && !file.over) {
13800
+ renameSync5(partial, archive);
13801
+ writeFileSync6(join17(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
13802
+ pruneOtherAttempts(opts.deviceDir, attempt, attemptId);
13803
+ await writeMarker(name, attemptId);
13804
+ log.debug({ container: name, attemptId, bytes: file.bytes }, "runners: session files kept");
13805
+ return;
13806
+ }
13807
+ rmSync9(partial, { force: true });
13808
+ if (r.code === 3 && !file.over) return;
13809
+ dropArchive(attemptId);
13810
+ log.warn(
13811
+ file.over ? { container: name, attemptId, capBytes: sessionCap } : { container: name, attemptId, err: r.stderr.trim() },
13812
+ 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"
13813
+ );
13814
+ };
13815
+ const stagingDir = (name) => join17(opts.deviceDir, "runners-pending", `${name}.outbox`);
13816
+ const ensureRunning = async (name) => {
13817
+ const seen = await docker.run(["inspect", "-f", "{{.State.Running}}", name]);
13818
+ if (seen.code !== 0) return false;
13819
+ if (seen.stdout.trim() === "true") return true;
13820
+ return (await docker.run(["start", name])).code === 0;
13821
+ };
13822
+ const dropArchive = (attemptId) => {
13823
+ rmSync9(join17(sessionArchivePath(opts.deviceDir, attemptId), ".."), {
13824
+ recursive: true,
13825
+ force: true
13826
+ });
13827
+ };
13828
+ const RETIRE_SCRIPT = [
13829
+ `chmod -R u+rwX /work ${CONTAINER_HOME}`,
13830
+ "find /work -mindepth 1 -delete",
13831
+ `find ${CONTAINER_HOME} -mindepth 1 -delete`,
13832
+ `cp -a /etc/skel/. ${CONTAINER_HOME}/`,
13833
+ `mkdir -p ${CONTAINER_HOME}/.codex`,
13834
+ `cp /opt/cabane/codex-config.toml ${CONTAINER_HOME}/.codex/config.toml`,
13835
+ `rm -rf ${CONTAINER_STATE}/prepared`
13836
+ ].join(" && ");
13837
+ const provider = {
13838
+ name: "docker",
13839
+ inheritsEnvironment: false,
12588
13840
  async acquire(attempt) {
12589
- return { provider: "local-process", id: attempt.turnId };
12590
- },
12591
- exec(_handle, argv, env, { signal, stdin }) {
12592
- const [command, ...args] = argv;
12593
- if (!command) throw new Error("local-process: exec needs a command");
12594
- const child = spawnFn(command, args, {
12595
- env: { ...env },
12596
- stdio: ["pipe", "pipe", "pipe"],
12597
- detached: process.platform !== "win32"
13841
+ const name = containerName(attempt);
13842
+ const before = queues.get(name) ?? Promise.resolve();
13843
+ let release;
13844
+ const mine = new Promise((resolve2) => release = resolve2);
13845
+ const tail = before.then(() => mine);
13846
+ queues.set(name, tail);
13847
+ void tail.then(() => {
13848
+ if (queues.get(name) === tail) queues.delete(name);
12598
13849
  });
13850
+ await before;
13851
+ try {
13852
+ await ensureNetwork();
13853
+ mkdirSync10(socketRoot, { recursive: true, mode: 448 });
13854
+ mkdirSync10(socketDir(name), { recursive: true, mode: 457 });
13855
+ let seen = await docker.run([
13856
+ "inspect",
13857
+ "-f",
13858
+ '{{.State.Running}} {{index .Config.Labels "cabane.companion"}}',
13859
+ name
13860
+ ]);
13861
+ if (seen.code === 0 && seen.stdout.trim().split(" ")[1] !== companionLabel) {
13862
+ held.set(name, { attempt, release: () => {
13863
+ }, collected: false });
13864
+ let collected = false;
13865
+ try {
13866
+ await provider.collect({ provider: "docker", id: name }, { attemptId: null });
13867
+ collected = held.get(name)?.collected === true;
13868
+ } finally {
13869
+ held.delete(name);
13870
+ }
13871
+ if (!collected)
13872
+ throw new Error(
13873
+ "docker: this conversation's container runs another companion and what it holds couldn't be collected"
13874
+ );
13875
+ await destroy(name);
13876
+ log.info(
13877
+ { container: name },
13878
+ "runners: replaced a container made with another companion"
13879
+ );
13880
+ seen = { code: 1, stdout: "", stderr: "" };
13881
+ }
13882
+ if (seen.code !== 0) {
13883
+ await create(name, attempt);
13884
+ log.debug({ container: name }, "runners: container created");
13885
+ }
13886
+ if (seen.stdout.trim().split(" ")[0] !== "true")
13887
+ await must(["start", name], "starting the container");
13888
+ const git = await serveGitCredentials({
13889
+ socketPath: join17(socketDir(name), "git.sock"),
13890
+ hosts: opts.gitHosts,
13891
+ fill: opts.gitFill,
13892
+ log
13893
+ });
13894
+ held.set(name, { attempt, release, git, collected: false });
13895
+ return { provider: "docker", id: name };
13896
+ } catch (err) {
13897
+ const keep = opts.keepContainers || readPending(name).length > 0;
13898
+ await (keep ? docker.run(["stop", "-t", "1", name]) : destroy(name)).catch(() => {
13899
+ });
13900
+ release();
13901
+ throw err;
13902
+ }
13903
+ },
13904
+ async bindAttempt(handle, attemptId) {
13905
+ const name = handle.id;
13906
+ const marker = await execIn(name, ["cat", `${CONTAINER_STATE}/attempt`]);
13907
+ const bound = marker.code === 0 ? marker.stdout.trim() : "";
13908
+ if (bound === (attemptId ?? "")) return;
13909
+ const { attempt } = heldFor(handle);
13910
+ pruneOtherAttempts(opts.deviceDir, attempt, attemptId ?? "");
13911
+ if (bound) {
13912
+ const wiped = await execIn(name, ["sh", "-c", RETIRE_SCRIPT]);
13913
+ if (wiped.code !== 0)
13914
+ throw new Error(`docker: couldn't clear the retired attempt: ${wiped.stderr.trim()}`);
13915
+ log.info(
13916
+ { container: name, retired: bound, attemptId },
13917
+ "runners: retired attempt cleared"
13918
+ );
13919
+ }
13920
+ if (attemptId && UUID_RE.test(attemptId)) {
13921
+ const archive = sessionArchivePath(opts.deviceDir, attemptId);
13922
+ if (existsSync13(archive)) {
13923
+ const r = await execIn(name, ["tar", "-x", "-C", CONTAINER_HOME, "-f", "-"], {
13924
+ input: createReadStream(archive)
13925
+ });
13926
+ if (r.code !== 0) {
13927
+ log.warn(
13928
+ { container: name, attemptId, err: r.stderr.trim() },
13929
+ "runners: couldn't restore the session files; the runtime will start its session over"
13930
+ );
13931
+ } else {
13932
+ log.info(
13933
+ { container: name, attemptId, bytes: statSync3(archive).size },
13934
+ "runners: session files restored"
13935
+ );
13936
+ }
13937
+ } else {
13938
+ log.info(
13939
+ { container: name, attemptId },
13940
+ "runners: no session files kept for this attempt on this device"
13941
+ );
13942
+ }
13943
+ }
13944
+ await writeMarker(name, attemptId ?? "");
13945
+ },
13946
+ exec(handle, argv, env, { signal, stdin }) {
13947
+ const name = handle.id;
13948
+ const pidFile = `/tmp/cabane-runner-${randomUUID5()}.pid`;
13949
+ const passed = { ...env, ...opts.runtimeEnv?.() ?? {} };
13950
+ const literal = containerEnv();
13951
+ const args = [
13952
+ "exec",
13953
+ "-i",
13954
+ "-u",
13955
+ "runner",
13956
+ "-w",
13957
+ CONTAINER_HOME,
13958
+ ...Object.entries(literal).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
13959
+ ...Object.keys(passed).flatMap((k) => ["-e", k]),
13960
+ name,
13961
+ "sh",
13962
+ "-c",
13963
+ 'echo $$ > "$0" && exec "$@"',
13964
+ pidFile,
13965
+ ...argv
13966
+ ];
13967
+ const child = docker.spawn(args, passed);
12599
13968
  child.stdin?.on("error", () => {
12600
13969
  });
12601
13970
  if (stdin) child.stdin?.write(stdin);
13971
+ let forceTimer;
13972
+ const stop2 = () => {
13973
+ void execIn(name, ["sh", "-c", 'kill -TERM "$(cat "$1")"', "sh", pidFile]);
13974
+ forceTimer = setTimeout(() => void docker.run(["kill", name]), killGraceMs);
13975
+ forceTimer.unref?.();
13976
+ };
12602
13977
  const exit = new Promise((resolve2) => {
12603
13978
  let settled = false;
12604
13979
  const done = (code) => {
@@ -12608,43 +13983,253 @@ function createLocalProcessProvider(opts = {}) {
12608
13983
  if (forceTimer) clearTimeout(forceTimer);
12609
13984
  resolve2(code);
12610
13985
  };
12611
- let forceTimer;
12612
- const stop2 = () => {
12613
- child.kill("SIGTERM");
12614
- forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
12615
- forceTimer.unref?.();
12616
- };
12617
13986
  child.once("error", () => done(1));
12618
- child.once("close", (code, sig) => done(exitCode(code, sig)));
13987
+ child.once("close", (code, sig) => {
13988
+ const signum = sig ? constants2.signals[sig] : void 0;
13989
+ done(code ?? (signum !== void 0 ? 128 + signum : 1));
13990
+ });
12619
13991
  if (signal.aborted) stop2();
12620
13992
  else signal.addEventListener("abort", stop2, { once: true });
12621
13993
  });
12622
13994
  if (!child.stdout || !child.stderr || !child.stdin)
12623
- throw new Error("local-process: runner stdio missing");
12624
- const pid = child.pid;
13995
+ throw new Error("docker: runner stdio missing");
12625
13996
  return {
12626
13997
  exit,
12627
- ...pid !== void 0 ? { pid } : {},
13998
+ ...child.pid !== void 0 ? { pid: child.pid } : {},
12628
13999
  stdout: child.stdout,
12629
14000
  stderr: child.stderr,
12630
14001
  stdin: child.stdin,
12631
- async reap(groups) {
12632
- if (process.platform === "win32") return;
12633
- const targets = [.../* @__PURE__ */ new Set([...pid !== void 0 ? [pid] : [], ...groups])].filter(
12634
- groupAlive
12635
- );
12636
- if (!targets.length) return;
12637
- for (const pgid of targets) signalGroup(pgid, "SIGTERM");
12638
- await new Promise((resolve2) => setTimeout(resolve2, reapGraceMs));
12639
- for (const pgid of targets.filter(groupAlive)) signalGroup(pgid, "SIGKILL");
14002
+ // The runner is the only thing this user runs in the container, so
14003
+ // everything it left is everything the user has left.
14004
+ async reap() {
14005
+ await execIn(name, [
14006
+ "sh",
14007
+ "-c",
14008
+ `kill -TERM -1 2>/dev/null; sleep ${REAP_GRACE_S}; kill -KILL -1 2>/dev/null; true`
14009
+ ]);
12640
14010
  }
12641
14011
  };
12642
14012
  },
12643
- async release() {
14013
+ async collect(handle, { attemptId }) {
14014
+ const name = handle.id;
14015
+ const held2 = heldFor(handle);
14016
+ const { attempt } = held2;
14017
+ const turnIds = [.../* @__PURE__ */ new Set([attempt.turnId, ...readPending(name)])];
14018
+ const running = await ensureRunning(name);
14019
+ if (attemptId && UUID_RE.test(attemptId)) {
14020
+ if (!running) {
14021
+ dropArchive(attemptId);
14022
+ log.warn(
14023
+ { container: name, attemptId },
14024
+ "runners: couldn't reach the container to keep the session files; the next turn starts the session over"
14025
+ );
14026
+ } else {
14027
+ await keepSession(name, attempt, attemptId);
14028
+ }
14029
+ }
14030
+ if (!running) {
14031
+ writePending(name, turnIds);
14032
+ return;
14033
+ }
14034
+ const transcripts = await readDir(name, `${CONTAINER_STATE}/transcripts`);
14035
+ if (transcripts) {
14036
+ const kept = transcripts.files.filter((f) => SAFE_NAME_RE.test(f.name));
14037
+ if (kept.length) mkdirSync10(join17(opts.hostStateDir, "transcripts"), { recursive: true });
14038
+ for (const f of kept) writeFileSync6(join17(opts.hostStateDir, "transcripts", f.name), f.data);
14039
+ await removeIn(
14040
+ name,
14041
+ `${CONTAINER_STATE}/transcripts`,
14042
+ kept.map((f) => f.name)
14043
+ );
14044
+ }
14045
+ const outboxRel = join17("outbox", encodeURIComponent(attempt.agentId));
14046
+ const outbox = await readDir(name, `${CONTAINER_STATE}/${outboxRel}`);
14047
+ let unpublished = false;
14048
+ if (outbox) {
14049
+ const scope = {
14050
+ workspaceId: attempt.workspaceId,
14051
+ conversationId: attempt.conversationId,
14052
+ agentId: attempt.agentId,
14053
+ turnIds
14054
+ };
14055
+ let queued = 0;
14056
+ let refused = 0;
14057
+ for (const f of outbox.files) {
14058
+ let entry = null;
14059
+ try {
14060
+ entry = admitCollectedEntry(JSON.parse(f.data.toString("utf8")), scope);
14061
+ } catch {
14062
+ entry = null;
14063
+ }
14064
+ if (!entry) {
14065
+ refused++;
14066
+ continue;
14067
+ }
14068
+ const staged = stagingDir(name);
14069
+ mkdirSync10(staged, { recursive: true });
14070
+ writeFileSync6(
14071
+ join17(staged, `${encodeURIComponent(entry.turnId)}__${entry.seq}.json`),
14072
+ `${JSON.stringify(entry)}
14073
+ `
14074
+ );
14075
+ }
14076
+ await removeIn(
14077
+ name,
14078
+ `${CONTAINER_STATE}/${outboxRel}`,
14079
+ outbox.files.map((f) => f.name)
14080
+ );
14081
+ if (outbox.complete) {
14082
+ const published = publishStaged(
14083
+ stagingDir(name),
14084
+ join17(opts.hostStateDir, outboxRel),
14085
+ opts.moveFile
14086
+ );
14087
+ queued = published.published;
14088
+ if (!published.complete) {
14089
+ unpublished = true;
14090
+ log.warn(
14091
+ { container: name },
14092
+ "runners: couldn't hand all of the runner's outbox to the companion; the rest waits for the next collection"
14093
+ );
14094
+ }
14095
+ }
14096
+ if (refused) {
14097
+ log.warn(
14098
+ { container: name, refused },
14099
+ "runners: refused outbox entries a runner's own credential couldn't have sent"
14100
+ );
14101
+ }
14102
+ if (queued) {
14103
+ log.info(
14104
+ { container: name, queued },
14105
+ "runners: delivering what the runner left in its outbox"
14106
+ );
14107
+ opts.onOutboxCollected?.(attempt.agentId);
14108
+ }
14109
+ }
14110
+ held2.collected = !!transcripts?.complete && !!outbox?.complete && !unpublished;
14111
+ writePending(name, held2.collected ? [] : turnIds);
14112
+ },
14113
+ async release(handle, { destroy: over }) {
14114
+ const h = held.get(handle.id);
14115
+ held.delete(handle.id);
14116
+ try {
14117
+ await h?.git?.close();
14118
+ if (h && !h.collected) {
14119
+ log.warn(
14120
+ { container: handle.id },
14121
+ "runners: kept the container: what its runner left couldn't be collected yet"
14122
+ );
14123
+ await docker.run(["stop", "-t", "10", handle.id]);
14124
+ } else if (over || !opts.keepContainers) await destroy(handle.id);
14125
+ else await docker.run(["stop", "-t", "10", handle.id]);
14126
+ } finally {
14127
+ h?.release();
14128
+ }
12644
14129
  }
12645
14130
  };
14131
+ return provider;
12646
14132
  }
12647
14133
 
14134
+ // src/providers/docker-setup.ts
14135
+ import { createHash as createHash5 } from "crypto";
14136
+ var MODEL_HOSTS = [
14137
+ "api.anthropic.com",
14138
+ "api.openai.com",
14139
+ "chatgpt.com",
14140
+ "auth.openai.com"
14141
+ ];
14142
+ var GIT_HOSTS = ["github.com"];
14143
+ function dockerRunnerSettings(config) {
14144
+ const docker = config.runners?.docker ?? {};
14145
+ const apiUrl = docker.apiUrl ?? config.baseUrl;
14146
+ const apiHost = new URL(apiUrl).host;
14147
+ const egressAllow = [
14148
+ .../* @__PURE__ */ new Set([apiHost, ...MODEL_HOSTS, ...GIT_HOSTS, ...docker.egressAllow ?? []])
14149
+ ];
14150
+ return {
14151
+ image: docker.image ?? baseImageTag(),
14152
+ keepContainers: docker.keepContainers === true,
14153
+ apiUrl,
14154
+ egressAllow,
14155
+ gitHosts: [...GIT_HOSTS]
14156
+ };
14157
+ }
14158
+ function loopbackApi(apiUrl) {
14159
+ const host = new URL(apiUrl).hostname;
14160
+ return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
14161
+ }
14162
+ function deviceKey(config) {
14163
+ const seed = config.deviceId ?? config.baseUrl;
14164
+ return createHash5("sha256").update(seed).digest("hex").slice(0, 12);
14165
+ }
14166
+ function resolveRuntimeEnv(env, store, log) {
14167
+ const out = {};
14168
+ for (const [name, template] of Object.entries(env ?? {})) {
14169
+ const missing = [];
14170
+ const value = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, key) => {
14171
+ const v = store.get(key);
14172
+ if (v === void 0) missing.push(key);
14173
+ return v ?? "";
14174
+ });
14175
+ if (missing.length) {
14176
+ log?.warn({ name, missing }, "runners: a runner env value names secrets this device lacks");
14177
+ continue;
14178
+ }
14179
+ out[name] = value;
14180
+ }
14181
+ return out;
14182
+ }
14183
+ async function probeRunner(docker, image, source, log) {
14184
+ const r = await docker.run(
14185
+ [
14186
+ "run",
14187
+ "--rm",
14188
+ "--network",
14189
+ "none",
14190
+ ...companionMountArgs(source),
14191
+ image,
14192
+ "cabane-companion",
14193
+ "run-turn",
14194
+ "--manifest"
14195
+ ],
14196
+ { timeoutMs: 6e4 }
14197
+ );
14198
+ try {
14199
+ const manifest = JSON.parse(r.stdout.trim());
14200
+ log.info(
14201
+ { image, version: manifest.version, bundled: manifest.bundled },
14202
+ "runners: runner ready"
14203
+ );
14204
+ return manifest;
14205
+ } catch {
14206
+ log.error(
14207
+ { image, err: r.stderr.trim().slice(-2e3) },
14208
+ "runners: the companion didn't start in a runner container"
14209
+ );
14210
+ return null;
14211
+ }
14212
+ }
14213
+ function imageResolution(manifest, image, runtime) {
14214
+ const status2 = manifest?.bundled[runtime] ?? "platform_package_missing";
14215
+ return {
14216
+ runtime,
14217
+ status: status2,
14218
+ path: status2 === "present" ? `${image} (bundled)` : null,
14219
+ packageName: null,
14220
+ platform: "linux",
14221
+ arch: "image"
14222
+ };
14223
+ }
14224
+ function containerHookPath(hostPath) {
14225
+ const name = hostPath.split("/").pop() || "hook";
14226
+ const tag = createHash5("sha256").update(hostPath).digest("hex").slice(0, 8);
14227
+ return `/opt/cabane/hooks/${tag}-${name}`;
14228
+ }
14229
+
14230
+ // src/supervisor.ts
14231
+ init_git_credential();
14232
+
12648
14233
  // src/runner/device-locks.ts
12649
14234
  var DeviceLocks = class {
12650
14235
  holders = /* @__PURE__ */ new Map();
@@ -12896,13 +14481,7 @@ function sleep4(ms) {
12896
14481
 
12897
14482
  // src/supervisor.ts
12898
14483
  init_transcript_writer();
12899
-
12900
- // src/version.ts
12901
- import { createRequire as createRequire2 } from "module";
12902
- var pkg = createRequire2(import.meta.url)("../package.json");
12903
- var COMPANION_VERSION = pkg.version;
12904
-
12905
- // src/supervisor.ts
14484
+ init_version2();
12906
14485
  init_src();
12907
14486
  var HEARTBEAT_INTERVAL_MS = 3e4;
12908
14487
  var SHUTDOWN_HEARTBEAT_WAIT_MS = 1e3;
@@ -13003,8 +14582,27 @@ var CompanionSupervisor = class {
13003
14582
  if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
13004
14583
  if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
13005
14584
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
14585
+ if (this.dockerRunners()) {
14586
+ const { image } = dockerRunnerSettings(this.config);
14587
+ this.resolveBundled = (runtime) => imageResolution(this.runnerImage, image, runtime);
14588
+ }
13006
14589
  this.latchBootBundledState();
13007
14590
  }
14591
+ // CT1681: what a runner container said it can run, probed once at start;
14592
+ // null until then, and when no container could be started.
14593
+ runnerImage = null;
14594
+ // CT1681: the companion every container mounts — this one's own install.
14595
+ runnerCompanion() {
14596
+ return companionSource({
14597
+ configured: this.config.runners?.docker?.companionDir,
14598
+ platform: process.platform,
14599
+ version: COMPANION_VERSION,
14600
+ own: ownInstall()
14601
+ });
14602
+ }
14603
+ dockerRunners() {
14604
+ return runnersEnabled(this.config) && this.config.runners?.provider === "docker";
14605
+ }
13008
14606
  // Stand up the data plane: pair check, initial assignments pull, then the
13009
14607
  // heartbeat + poll loops. A companion with no device token (logged out) does
13010
14608
  // nothing but say so.
@@ -13013,6 +14611,24 @@ var CompanionSupervisor = class {
13013
14611
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
13014
14612
  `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname3()}`
13015
14613
  );
14614
+ if (this.dockerRunners() && !this.providerForRunners) {
14615
+ const { image, apiUrl } = dockerRunnerSettings(this.config);
14616
+ if (loopbackApi(apiUrl)) {
14617
+ this.log.error(
14618
+ { apiUrl },
14619
+ "runners: a container can't reach this machine's loopback; set runners.docker.apiUrl to an address it can"
14620
+ );
14621
+ }
14622
+ const source = this.runnerCompanion();
14623
+ const docker = dockerCli();
14624
+ if ("error" in source) {
14625
+ this.log.error({ reason: source.error }, "runners: no companion to put in a container");
14626
+ } else if (await ensureBaseImage(docker, image, this.log) && await ensureCompanionVolume(docker, image, source, this.log)) {
14627
+ this.runnerImage = await probeRunner(docker, image, source, this.log);
14628
+ if (!this.runnerImage) await invalidateCompanionVolume(docker, image, source);
14629
+ }
14630
+ this.latchBootBundledState();
14631
+ }
13016
14632
  const startupHarnessRefresh = this.refreshHarnessStatuses();
13017
14633
  this.trackHeartbeat(startupHarnessRefresh);
13018
14634
  if (!this.config.deviceToken) {
@@ -13120,7 +14736,9 @@ var CompanionSupervisor = class {
13120
14736
  runtimeIssues: this.runtimeIssues(),
13121
14737
  // CT571/CT586: each runtime's `version` from the latest harness probe
13122
14738
  // (fail-soft to null). Informational only — the server matches on name.
13123
- versions: this.harnessVersions
14739
+ versions: this.harnessVersions,
14740
+ // CT1681: shown on the device page.
14741
+ ...runnersEnabled(this.config) ? { runnerProvider: this.config.runners?.provider ?? "local-process" } : {}
13124
14742
  }),
13125
14743
  // CT566: echo the last classified credential state per runtime, when the
13126
14744
  // companion has seen any. Omitted (undefined) until the first observed
@@ -13453,21 +15071,71 @@ var CompanionSupervisor = class {
13453
15071
  // can't make the sandbox says so once and runs its turns unwrapped.
13454
15072
  sandboxUsable;
13455
15073
  runnerSandbox() {
13456
- if (this.config.runners?.sandbox !== "os") return "none";
15074
+ if (this.config.runners?.sandbox !== "os" || this.dockerRunners()) return "none";
13457
15075
  this.sandboxUsable ??= osSandboxUsable(this.log);
13458
15076
  return this.sandboxUsable ? "os" : "none";
13459
15077
  }
13460
15078
  // CT1679: one provider for the device, built on first use.
13461
15079
  runnerProvider() {
13462
- this.providerForRunners ??= createLocalProcessProvider();
15080
+ this.providerForRunners ??= this.dockerRunners() ? this.createDockerRunners() : createLocalProcessProvider();
13463
15081
  return this.providerForRunners;
13464
15082
  }
15083
+ createDockerRunners() {
15084
+ const settings = dockerRunnerSettings(this.config);
15085
+ const docker = this.config.runners?.docker ?? {};
15086
+ const hooks = [
15087
+ this.config.prepareHook?.command,
15088
+ ...Object.values(this.config.agents ?? {}).map((a) => a.prepareHook?.command)
15089
+ ].filter((c) => typeof c === "string");
15090
+ const source = this.runnerCompanion();
15091
+ return createDockerProvider({
15092
+ image: settings.image,
15093
+ // Without a companion nothing is advertised, so nothing is sent; a turn
15094
+ // that arrives anyway fails at the mount and says so.
15095
+ companion: "error" in source ? { kind: "dir", path: "/nonexistent", key: "none" } : source,
15096
+ deviceKey: deviceKey(this.config),
15097
+ keepContainers: settings.keepContainers,
15098
+ egressAllow: settings.egressAllow,
15099
+ gitHosts: settings.gitHosts,
15100
+ gitFill: deviceGitFill(docker.gitCredentialHelper),
15101
+ runtimeEnv: () => resolveRuntimeEnv(
15102
+ docker.env,
15103
+ loadSecretStoreTolerant(
15104
+ (m) => this.log.warn(
15105
+ { err: m },
15106
+ "Could not read the secrets file. Check ~/.cabane/secrets.json."
15107
+ )
15108
+ ),
15109
+ this.log
15110
+ ),
15111
+ mounts: [...new Set(hooks)].map((source2) => ({ source: source2, target: containerHookPath(source2) })),
15112
+ deviceDir: cabaneDir(),
15113
+ hostStateDir: runnerStateDir(),
15114
+ onOutboxCollected: (agentId) => {
15115
+ for (const wr of this.workspaces.values()) wr.agents.get(agentId)?.kickDrain();
15116
+ },
15117
+ log: this.log
15118
+ });
15119
+ }
15120
+ // CT1681: the runner command for this device's provider: the image's own
15121
+ // companion in a container, this process's otherwise.
15122
+ runnerCommandFor() {
15123
+ if (this.runnerCommand) return this.runnerCommand;
15124
+ return this.dockerRunners() ? ["cabane-companion", "run-turn"] : selfRunnerCommand();
15125
+ }
13465
15126
  buildDispatcher(ctx) {
13466
- const local = localAgentConfig(this.config, {
15127
+ const hostLocal = localAgentConfig(this.config, {
13467
15128
  agentId: ctx.agentId,
13468
15129
  agentUsername: ctx.agentUsername,
13469
15130
  workspaceSlug: ctx.workspaceSlug
13470
15131
  });
15132
+ const local = this.dockerRunners() && hostLocal.prepareHook ? {
15133
+ ...hostLocal,
15134
+ prepareHook: {
15135
+ ...hostLocal.prepareHook,
15136
+ command: containerHookPath(hostLocal.prepareHook.command)
15137
+ }
15138
+ } : hostLocal;
13471
15139
  if (this.dispatcherFactory) return this.dispatcherFactory({ ...ctx, local });
13472
15140
  return new Dispatcher({
13473
15141
  deviceId: this.deviceId ?? void 0,
@@ -13516,7 +15184,8 @@ var CompanionSupervisor = class {
13516
15184
  ...runnersEnabled(this.config) ? {
13517
15185
  runner: {
13518
15186
  provider: this.runnerProvider(),
13519
- command: this.runnerCommand ?? selfRunnerCommand(),
15187
+ command: this.runnerCommandFor(),
15188
+ ...this.dockerRunners() ? { runnerBaseUrl: dockerRunnerSettings(this.config).apiUrl } : {},
13520
15189
  reportRunnerGone: (report) => this.reportRunnerGone(report),
13521
15190
  connectorHealth: this.connectorHealth,
13522
15191
  locks: this.runnerLocks,
@@ -13840,7 +15509,7 @@ var CompanionSupervisor = class {
13840
15509
  );
13841
15510
  }
13842
15511
  const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
13843
- const turnId = resumedTurnId ?? randomUUID5();
15512
+ const turnId = resumedTurnId ?? randomUUID6();
13844
15513
  if (dispatchId) {
13845
15514
  const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
13846
15515
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -14145,9 +15814,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
14145
15814
  }
14146
15815
  function defaultReexec() {
14147
15816
  clearRuntimeState();
14148
- void import("child_process").then(({ spawn: spawn8 }) => {
15817
+ void import("child_process").then(({ spawn: spawn10 }) => {
14149
15818
  try {
14150
- const child = spawn8(process.execPath, process.argv.slice(1), {
15819
+ const child = spawn10(process.execPath, process.argv.slice(1), {
14151
15820
  stdio: "inherit",
14152
15821
  detached: false
14153
15822
  });
@@ -14159,6 +15828,9 @@ function defaultReexec() {
14159
15828
  });
14160
15829
  }
14161
15830
 
15831
+ // src/runtime.ts
15832
+ init_version2();
15833
+
14162
15834
  // src/safety.ts
14163
15835
  var RECOVERABLE_CODES = /* @__PURE__ */ new Set([
14164
15836
  "EPIPE",
@@ -14218,22 +15890,22 @@ function handleUncaught(log, err, origin) {
14218
15890
 
14219
15891
  // src/crash-marker.ts
14220
15892
  init_config();
14221
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync9, writeFileSync as writeFileSync8 } from "fs";
14222
- import { join as join17 } from "path";
15893
+ import { existsSync as existsSync15, mkdirSync as mkdirSync13, readFileSync as readFileSync11, rmSync as rmSync12, writeFileSync as writeFileSync9 } from "fs";
15894
+ import { join as join20 } from "path";
14223
15895
  function crashMarkerPath() {
14224
- return join17(cabaneDir(), "last-error.json");
15896
+ return join20(cabaneDir(), "last-error.json");
14225
15897
  }
14226
15898
  function recordCrash(rec2) {
14227
15899
  try {
14228
- mkdirSync11(cabaneDir(), { recursive: true });
14229
- writeFileSync8(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
15900
+ mkdirSync13(cabaneDir(), { recursive: true });
15901
+ writeFileSync9(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
14230
15902
  } catch {
14231
15903
  }
14232
15904
  }
14233
15905
  function clearCrash() {
14234
15906
  try {
14235
15907
  const path = crashMarkerPath();
14236
- if (existsSync12(path)) rmSync9(path, { force: true });
15908
+ if (existsSync15(path)) rmSync12(path, { force: true });
14237
15909
  } catch {
14238
15910
  }
14239
15911
  }
@@ -14276,7 +15948,7 @@ async function createCompanionRuntime(opts = {}) {
14276
15948
  opencodeServerUrl: cfg.opencode?.serverUrl,
14277
15949
  codex: isCodexEnabled(cfg)
14278
15950
  });
14279
- const instanceId = randomUUID6();
15951
+ const instanceId = randomUUID7();
14280
15952
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
14281
15953
  const pre = readLiveRuntimeState();
14282
15954
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
@@ -14406,16 +16078,16 @@ async function closeSurfaces(control, dashboard) {
14406
16078
  init_term();
14407
16079
 
14408
16080
  // src/commands/daemon.ts
14409
- import { spawn as spawn7 } from "child_process";
14410
- import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync3 } from "fs";
16081
+ import { spawn as spawn9 } from "child_process";
16082
+ import { closeSync as closeSync3, mkdirSync as mkdirSync14, openSync as openSync3 } from "fs";
14411
16083
 
14412
16084
  // src/cli-entry.ts
14413
- import { existsSync as existsSync13 } from "fs";
14414
- import { fileURLToPath as fileURLToPath3 } from "url";
16085
+ import { existsSync as existsSync16 } from "fs";
16086
+ import { fileURLToPath as fileURLToPath4 } from "url";
14415
16087
  var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
14416
16088
  function companionCliEntry(deps = {}) {
14417
- const exists = deps.exists ?? existsSync13;
14418
- const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath3(new URL(rel, import.meta.url)));
16089
+ const exists = deps.exists ?? existsSync16;
16090
+ const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath4(new URL(rel, import.meta.url)));
14419
16091
  for (const candidate of candidates) {
14420
16092
  if (exists(candidate)) return candidate;
14421
16093
  }
@@ -14516,10 +16188,10 @@ Stop: cabane-companion stop
14516
16188
  }
14517
16189
  function defaultSpawnDetached(args) {
14518
16190
  const cliPath = companionCliEntry();
14519
- mkdirSync12(cabaneDir(), { recursive: true });
16191
+ mkdirSync14(cabaneDir(), { recursive: true });
14520
16192
  const logFd = openSync3(companionLogPath(), "a");
14521
16193
  try {
14522
- return spawn7(process.execPath, [cliPath, ...args], {
16194
+ return spawn9(process.execPath, [cliPath, ...args], {
14523
16195
  detached: true,
14524
16196
  stdio: ["ignore", logFd, logFd],
14525
16197
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -14808,7 +16480,7 @@ async function runAttached(runtime) {
14808
16480
 
14809
16481
  // src/commands/status.ts
14810
16482
  init_config();
14811
- import { readdirSync as readdirSync4 } from "fs";
16483
+ import { readdirSync as readdirSync6 } from "fs";
14812
16484
  init_secrets();
14813
16485
  init_transcript_writer();
14814
16486
  async function status() {
@@ -14870,7 +16542,7 @@ function transcriptsLine() {
14870
16542
  let count = 0;
14871
16543
  let newest;
14872
16544
  try {
14873
- const files = readdirSync4(dir2).filter((f) => f.endsWith(".jsonl")).sort();
16545
+ const files = readdirSync6(dir2).filter((f) => f.endsWith(".jsonl")).sort();
14874
16546
  count = files.length;
14875
16547
  newest = files[files.length - 1];
14876
16548
  } catch {
@@ -14951,8 +16623,8 @@ function isAlive(kill, pid) {
14951
16623
  init_errors();
14952
16624
  init_src();
14953
16625
  init_transcript_writer();
14954
- import { existsSync as existsSync14, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
14955
- import { isAbsolute, join as join18 } from "path";
16626
+ import { existsSync as existsSync17, readFileSync as readFileSync12, readdirSync as readdirSync7 } from "fs";
16627
+ import { isAbsolute, join as join21 } from "path";
14956
16628
  async function transcript(opts = {}) {
14957
16629
  const dir2 = transcriptsDir();
14958
16630
  if (opts.follow) {
@@ -14969,7 +16641,7 @@ async function transcript(opts = {}) {
14969
16641
  process.stdout.write(emptyMessage(dir2));
14970
16642
  return;
14971
16643
  }
14972
- process.stdout.write(renderFile(join18(dir2, newest)) + "\n");
16644
+ process.stdout.write(renderFile(join21(dir2, newest)) + "\n");
14973
16645
  return;
14974
16646
  }
14975
16647
  printList(dir2);
@@ -15052,7 +16724,7 @@ function isComplete(content) {
15052
16724
  async function followTranscripts(dir2) {
15053
16725
  const follower = new TranscriptFollower({
15054
16726
  listFiles: () => listFiles(dir2),
15055
- read: (f) => readFileSync10(join18(dir2, f), "utf8"),
16727
+ read: (f) => readFileSync12(join21(dir2, f), "utf8"),
15056
16728
  write: (s) => process.stdout.write(s),
15057
16729
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
15058
16730
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -15077,7 +16749,7 @@ async function followTranscripts(dir2) {
15077
16749
  }
15078
16750
  function listFiles(dir2) {
15079
16751
  try {
15080
- return readdirSync5(dir2).filter((f) => f.endsWith(".jsonl")).sort().reverse();
16752
+ return readdirSync7(dir2).filter((f) => f.endsWith(".jsonl")).sort().reverse();
15081
16753
  } catch {
15082
16754
  return [];
15083
16755
  }
@@ -15092,7 +16764,7 @@ function printList(dir2) {
15092
16764
 
15093
16765
  `);
15094
16766
  for (const f of files.slice(0, 20)) {
15095
- const { meta, outcome } = peek(join18(dir2, f));
16767
+ const { meta, outcome } = peek(join21(dir2, f));
15096
16768
  const when = fmtTime(rec(meta)?.ts);
15097
16769
  const ws = str2(rec(meta)?.workspaceSlug);
15098
16770
  const o = rec(outcome);
@@ -15113,7 +16785,7 @@ function peek(path) {
15113
16785
  let meta;
15114
16786
  let outcome;
15115
16787
  try {
15116
- for (const line of readFileSync10(path, "utf8").split("\n")) {
16788
+ for (const line of readFileSync12(path, "utf8").split("\n")) {
15117
16789
  if (!line.trim()) continue;
15118
16790
  const o = safeParse(line);
15119
16791
  const t = str2(rec(o)?.type);
@@ -15126,13 +16798,13 @@ function peek(path) {
15126
16798
  }
15127
16799
  function resolveTarget(dir2, target) {
15128
16800
  if (isAbsolute(target) || target.includes("/")) {
15129
- if (existsSync14(target)) return target;
16801
+ if (existsSync17(target)) return target;
15130
16802
  throw new CompanionError(`no transcript at ${target}.`);
15131
16803
  }
15132
- const exact = join18(dir2, target);
15133
- if (existsSync14(exact)) return exact;
16804
+ const exact = join21(dir2, target);
16805
+ if (existsSync17(exact)) return exact;
15134
16806
  const matches = listFiles(dir2).filter((f) => f.includes(target));
15135
- if (matches.length === 1) return join18(dir2, matches[0]);
16807
+ if (matches.length === 1) return join21(dir2, matches[0]);
15136
16808
  if (matches.length === 0) {
15137
16809
  throw new CompanionError(
15138
16810
  `no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
@@ -15146,7 +16818,7 @@ function resolveTarget(dir2, target) {
15146
16818
  function renderFile(path) {
15147
16819
  let content;
15148
16820
  try {
15149
- content = readFileSync10(path, "utf8");
16821
+ content = readFileSync12(path, "utf8");
15150
16822
  } catch (err) {
15151
16823
  throw new CompanionError(
15152
16824
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -15295,6 +16967,7 @@ They are written per dispatch while \`cabane-companion start\` is running.
15295
16967
 
15296
16968
  // src/cli.ts
15297
16969
  init_errors();
16970
+ init_version2();
15298
16971
  var program = new Command();
15299
16972
  program.name("cabane-companion").description(
15300
16973
  "Connect a coding agent on your machine to your Cabane workspace as a responder \u2014 reply to Cabane messages while staying a full local AI client (Claude Code or OpenCode)."
@@ -15307,7 +16980,7 @@ program.command("pair").description(
15307
16980
  });
15308
16981
  });
15309
16982
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
15310
- writeCompletedPairing(readFileSync11(0, "utf8"));
16983
+ writeCompletedPairing(readFileSync13(0, "utf8"));
15311
16984
  });
15312
16985
  program.command("start").description(
15313
16986
  "pair this device if needed, connect a coding agent on it, and run in the background."
@@ -15364,7 +17037,13 @@ program.command("logout").description(
15364
17037
  ...opts.purge ? { purge: true } : {}
15365
17038
  });
15366
17039
  });
15367
- program.command("run-turn", { hidden: true }).action(async () => {
17040
+ program.command("run-turn", { hidden: true }).option("--manifest", "print what this install can run, as JSON, and exit").action(async (opts) => {
17041
+ if (opts.manifest) {
17042
+ const { runnerManifest: runnerManifest2 } = await Promise.resolve().then(() => (init_image_manifest(), image_manifest_exports));
17043
+ process.stdout.write(`${JSON.stringify(runnerManifest2())}
17044
+ `);
17045
+ return;
17046
+ }
15368
17047
  const { runTurn: runTurn2 } = await Promise.resolve().then(() => (init_run_turn(), run_turn_exports));
15369
17048
  const { code } = await runTurn2(process.env, {
15370
17049
  stdin: process.stdin,
@@ -15376,6 +17055,28 @@ program.command("run-turn", { hidden: true }).action(async () => {
15376
17055
  await new Promise((resolve2) => process.stderr.write("", () => resolve2()));
15377
17056
  process.exit(code);
15378
17057
  });
17058
+ program.command("egress-proxy", { hidden: true }).requiredOption("--port <port>", "port to listen on").action(async (opts) => {
17059
+ const { createEgressProxy: createEgressProxy2 } = await Promise.resolve().then(() => (init_egress_proxy(), egress_proxy_exports));
17060
+ const allow = JSON.parse(process.env.CABANE_EGRESS_ALLOW ?? "[]");
17061
+ const log = (await import("pino")).default({ level: "info" }, process.stderr);
17062
+ createEgressProxy2({ allow, log }).listen(
17063
+ Number(opts.port),
17064
+ "0.0.0.0",
17065
+ () => log.info({ allow }, "egress proxy listening")
17066
+ );
17067
+ });
17068
+ program.command("git-credential <operation>", { hidden: true }).action(async (operation) => {
17069
+ const socket = process.env.CABANE_GIT_CREDENTIAL_SOCKET;
17070
+ if (!socket) return;
17071
+ const { askGitCredential: askGitCredential2 } = await Promise.resolve().then(() => (init_git_credential(), git_credential_exports));
17072
+ const request = await new Promise((resolve2) => {
17073
+ let input = "";
17074
+ process.stdin.setEncoding("utf8");
17075
+ process.stdin.on("data", (chunk) => input += chunk);
17076
+ process.stdin.on("end", () => resolve2(input));
17077
+ });
17078
+ process.stdout.write(await askGitCredential2(socket, operation, request));
17079
+ });
15379
17080
  program.parseAsync(process.argv).catch((err) => {
15380
17081
  if (err instanceof CompanionError) {
15381
17082
  process.stderr.write(`error: ${err.message}