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