mercury-agent 0.4.28 → 0.5.0

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.
@@ -1,1323 +1,1346 @@
1
- import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import fs from "node:fs";
4
- import path, { dirname } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { type AppConfig, resolveProjectPath } from "../config.js";
7
- import { scanOutbox } from "../core/outbox.js";
8
- import type { ExtImageBuildState } from "../extensions/image-builder.js";
9
- import { type Logger, logger } from "../logger.js";
10
- import { getApiKeyFromPiAuthFile } from "../storage/pi-auth.js";
11
- import type {
12
- ContainerResult,
13
- MessageAttachment,
14
- StoredMessage,
15
- TokenUsage,
16
- } from "../types.js";
17
- import {
18
- apiSocketDir,
19
- INNER_RUN_DIR,
20
- innerApiSocketPath,
21
- } from "./api-socket.js";
22
- import { ContainerError } from "./container-error.js";
23
-
24
- /**
25
- * In-container mountpoint for the per-message IO dir. The host passes the request
26
- * payload as `input.json` and the inner container writes its reply as `result.json`
27
- * here. This is the reply channel that replaces the inner-container attach stream:
28
- * launching the inner container detached (`docker create` + `docker start`, no
29
- * attach) is the only pattern that works through the Bun `fetch()`-based body-proxy
30
- * the cloud agent lane goes through the proxy cannot carry Docker's hijacked
31
- * attach connection, so an attached run hangs to its idleTimeout (see
32
- * docs/debug/major/2026-05-25-agent-lane-docker-run-wait-hang-no-chat-response.md).
33
- */
34
- const INNER_IO_DIR = "/run/mercury-io";
35
-
36
- /** Poll interval (ms) while waiting for the inner container's result file. */
37
- const RESULT_POLL_MS = 150;
38
- /** Run a `docker inspect` liveness probe every Nth poll (~2s) to fail fast on crash. */
39
- const LIVENESS_EVERY = 14;
40
- /** Default timeout for short Docker CLI commands (create, start, inspect, kill). */
41
- const EXEC_DOCKER_TIMEOUT_MS = 20_000;
42
-
43
- function sleep(ms: number): Promise<void> {
44
- return new Promise((resolve) => setTimeout(resolve, ms));
45
- }
46
-
47
- /**
48
- * Run a short, non-streaming `docker` command and capture its result. Used for
49
- * `create` / `start` / `inspect` / `kill` — all plain request/response Docker API
50
- * calls (no `/wait`, no attach), which the body-proxy forwards cleanly. Never
51
- * rejects: a spawn error is surfaced as a non-zero `code` so callers branch on one
52
- * shape. The timeout guards against a wedged daemon/proxy connection.
53
- */
54
- function execDocker(
55
- args: string[],
56
- timeoutMs = EXEC_DOCKER_TIMEOUT_MS,
57
- ): Promise<{
58
- code: number;
59
- stdout: string;
60
- stderr: string;
61
- timedOut: boolean;
62
- }> {
63
- return new Promise((resolve) => {
64
- const proc = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
65
- let stdout = "";
66
- let stderr = "";
67
- let killed = false;
68
- let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
69
- killed = true;
70
- try {
71
- proc.kill("SIGKILL");
72
- } catch {
73
- // already exited
74
- }
75
- }, timeoutMs);
76
- const done = (code: number, errOverride?: string) => {
77
- if (timer) {
78
- clearTimeout(timer);
79
- timer = null;
80
- }
81
- resolve({
82
- code,
83
- stdout,
84
- stderr: errOverride ?? stderr,
85
- timedOut: killed,
86
- });
87
- };
88
- proc.stdout.on("data", (chunk: Buffer) => {
89
- stdout += chunk.toString("utf8");
90
- });
91
- proc.stderr.on("data", (chunk: Buffer) => {
92
- stderr += chunk.toString("utf8");
93
- });
94
- proc.on("error", (error) => done(1, stderr || String(error)));
95
- proc.on("close", (code) => done(code ?? 1));
96
- });
97
- }
98
-
99
- // Anthropic OAuth constants — duplicated from console/src/lib/oauth.ts to avoid cross-package imports.
100
- const ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
101
- const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
102
-
103
- type AnthropicOAuthCreds = { access: string; refresh: string; expires: number };
104
-
105
- // Prevents hammering the OAuth token refresh endpoint on 429 responses — at most
106
- // one refresh attempt per minute across all spawns in this process lifetime.
107
- let lastOAuthRefreshAttemptAt = 0;
108
- const OAUTH_REFRESH_COOLDOWN_MS = 60_000;
109
-
110
- /**
111
- * Persist a freshly refreshed Anthropic OAuth token back to the console DB so
112
- * the next rolling deploy starts with a valid credential.
113
- * Failures are logged but never throw — caller awaits this so the failure is
114
- * known before continuing, but spawn is never blocked indefinitely.
115
- */
116
- async function pushOAuthTokenToConsole(
117
- consoleUrl: string,
118
- internalSecret: string,
119
- agentId: string,
120
- creds: AnthropicOAuthCreds,
121
- ): Promise<void> {
122
- try {
123
- const res = await fetch(`${consoleUrl}/api/agent/oauth-token`, {
124
- method: "POST",
125
- headers: {
126
- "Content-Type": "application/json",
127
- Authorization: `Bearer ${internalSecret}`,
128
- },
129
- body: JSON.stringify({
130
- agentId,
131
- provider: "anthropic",
132
- access: creds.access,
133
- refresh: creds.refresh,
134
- expires: creds.expires,
135
- }),
136
- signal: AbortSignal.timeout(3_000),
137
- });
138
- if (!res.ok) {
139
- const body = await res.text().catch(() => "");
140
- logger.warn("OAuth token write-back to console failed", {
141
- status: res.status,
142
- body: body.slice(0, 200),
143
- });
144
- } else {
145
- logger.debug("OAuth token written back to console DB");
146
- }
147
- } catch (err) {
148
- logger.warn(
149
- "OAuth token write-back to console failed (network error)",
150
- err instanceof Error ? err : undefined,
151
- );
152
- }
153
- }
154
-
155
- /**
156
- * Fetch the current Anthropic OAuth credential blob from the console DB.
157
- * Called after an invalid_grant failure — the user may have already reconnected
158
- * in the console, so the DB may hold a fresh token the container doesn't know about.
159
- * Returns null on any error (network, auth, not configured).
160
- */
161
- async function fetchOAuthTokenFromConsole(
162
- consoleUrl: string,
163
- internalSecret: string,
164
- agentId: string,
165
- ): Promise<AnthropicOAuthCreds | null> {
166
- try {
167
- const url = new URL(`${consoleUrl}/api/agent/oauth-token`);
168
- url.searchParams.set("agentId", agentId);
169
- url.searchParams.set("provider", "anthropic");
170
- const res = await fetch(url.toString(), {
171
- headers: { Authorization: `Bearer ${internalSecret}` },
172
- signal: AbortSignal.timeout(3_000),
173
- });
174
- if (!res.ok) return null;
175
- const data = (await res.json()) as {
176
- access?: unknown;
177
- refresh?: unknown;
178
- expires?: unknown;
179
- };
180
- if (
181
- typeof data.access === "string" &&
182
- data.access &&
183
- typeof data.refresh === "string" &&
184
- data.refresh &&
185
- typeof data.expires === "number"
186
- ) {
187
- return {
188
- access: data.access,
189
- refresh: data.refresh,
190
- expires: data.expires,
191
- };
192
- }
193
- return null;
194
- } catch {
195
- return null;
196
- }
197
- }
198
-
199
- async function refreshAnthropicOAuth(
200
- creds: AnthropicOAuthCreds,
201
- ): Promise<AnthropicOAuthCreds> {
202
- const res = await fetch(ANTHROPIC_TOKEN_URL, {
203
- method: "POST",
204
- headers: { "Content-Type": "application/json" },
205
- body: JSON.stringify({
206
- grant_type: "refresh_token",
207
- refresh_token: creds.refresh,
208
- client_id: ANTHROPIC_CLIENT_ID,
209
- }),
210
- });
211
- if (!res.ok) {
212
- const body = await res.text().catch(() => "");
213
- throw new Error(`Anthropic OAuth refresh failed (${res.status}): ${body}`);
214
- }
215
- const data = (await res.json()) as {
216
- access_token?: string;
217
- refresh_token?: string;
218
- expires_in?: number;
219
- };
220
- if (!data.access_token)
221
- throw new Error("Anthropic refresh response missing access_token");
222
- return {
223
- access: data.access_token,
224
- refresh: data.refresh_token ?? creds.refresh,
225
- expires: Date.now() + (data.expires_in ?? 3600) * 1000,
226
- };
227
- }
228
-
229
- /** External calls used by {@link resolveOAuthCredentialForSpawn}; injectable for tests. */
230
- export type OAuthSpawnDeps = {
231
- refresh: (creds: AnthropicOAuthCreds) => Promise<AnthropicOAuthCreds>;
232
- pushToConsole: (
233
- consoleUrl: string,
234
- internalSecret: string,
235
- agentId: string,
236
- creds: AnthropicOAuthCreds,
237
- ) => Promise<void>;
238
- fetchFromConsole: (
239
- consoleUrl: string,
240
- internalSecret: string,
241
- agentId: string,
242
- ) => Promise<AnthropicOAuthCreds | null>;
243
- now: () => number;
244
- };
245
-
246
- const defaultOAuthSpawnDeps: OAuthSpawnDeps = {
247
- refresh: refreshAnthropicOAuth,
248
- pushToConsole: pushOAuthTokenToConsole,
249
- fetchFromConsole: fetchOAuthTokenFromConsole,
250
- now: Date.now,
251
- };
252
-
253
- export type OAuthSpawnResolution = {
254
- /** Bare access token to inject as ANTHROPIC_OAUTH_TOKEN into the inner container. */
255
- access: string;
256
- /** New full credential blob for the in-process env, or null if unchanged. */
257
- updatedBlob: string | null;
258
- };
259
-
260
- /**
261
- * Resolve an Anthropic OAuth credential blob into a fresh bare access token at
262
- * container-spawn time. This is the single chokepoint for the OAuth credential
263
- * lifecycle in mercury-fork — it embeds all three container-side steps:
264
- * 1. refresh the access token when it is within the 60s expiry lookahead,
265
- * 2. write the refreshed blob back to the console DB,
266
- * 4. on `invalid_grant`, pull the current blob from the console DB.
267
- *
268
- * Throws only when the credential is genuinely unrecoverable and the user must
269
- * reconnect. Transient failures (network, 429, cooldown) fall back to the
270
- * current access token so the spawn is never blocked.
271
- */
272
- export async function resolveOAuthCredentialForSpawn(
273
- parsedCreds: AnthropicOAuthCreds,
274
- opts: {
275
- consoleUrl?: string;
276
- consoleInternalSecret?: string;
277
- agentId?: string;
278
- },
279
- deps: OAuthSpawnDeps = defaultOAuthSpawnDeps,
280
- ): Promise<OAuthSpawnResolution> {
281
- let freshAccess = parsedCreds.access;
282
- let updatedBlob: string | null = null;
283
-
284
- const needsRefresh = deps.now() + 60_000 > parsedCreds.expires;
285
- const canRetry =
286
- deps.now() - lastOAuthRefreshAttemptAt > OAUTH_REFRESH_COOLDOWN_MS;
287
-
288
- if (needsRefresh && canRetry) {
289
- try {
290
- lastOAuthRefreshAttemptAt = deps.now();
291
- const refreshed = await deps.refresh(parsedCreds);
292
- updatedBlob = JSON.stringify(refreshed);
293
- freshAccess = refreshed.access;
294
- // Persist to console DB so the next rolling deploy reads a valid token.
295
- if (opts.consoleUrl && opts.consoleInternalSecret && opts.agentId) {
296
- await deps.pushToConsole(
297
- opts.consoleUrl,
298
- opts.consoleInternalSecret,
299
- opts.agentId,
300
- refreshed,
301
- );
302
- } else if (opts.consoleUrl && !opts.consoleInternalSecret) {
303
- logger.warn(
304
- "Anthropic OAuth token refreshed but write-back to console is disabled — MERCURY_CONSOLE_INTERNAL_SECRET is not set; refreshed token will be lost on next container restart",
305
- );
306
- }
307
- } catch (err) {
308
- const msg = err instanceof Error ? err.message : String(err);
309
- const isInvalidGrant = msg.includes("invalid_grant");
310
- if (isInvalidGrant && opts.consoleUrl && opts.consoleInternalSecret) {
311
- // The refresh token has been invalidated (user likely reconnected in the
312
- // console). Try to pull the current credential blob from the DB — the
313
- // console may already have fresh tokens that this container doesn't know about.
314
- if (opts.agentId) {
315
- const consoleCreds = await deps.fetchFromConsole(
316
- opts.consoleUrl,
317
- opts.consoleInternalSecret,
318
- opts.agentId,
319
- );
320
- if (consoleCreds && consoleCreds.refresh !== parsedCreds.refresh) {
321
- // Console has a different (newer) refresh token — user already reconnected.
322
- updatedBlob = JSON.stringify(consoleCreds);
323
- freshAccess = consoleCreds.access;
324
- logger.info(
325
- "Anthropic OAuth invalid_grant recovered from console DB — using fresh token",
326
- );
327
- } else if (consoleCreds === null) {
328
- // Console was unreachable cannot determine if the user has reconnected.
329
- logger.error(
330
- "Anthropic OAuth refresh failed with invalid_grant and console credential fetch failed — please reconnect or check connectivity",
331
- { agentId: opts.agentId },
332
- );
333
- throw new Error(
334
- "Anthropic OAuth token is invalid (invalid_grant) and fresh credentials could not be fetched from the console. Please reconnect your Anthropic account or check the console is reachable.",
335
- );
336
- } else {
337
- // Same token in console user has not reconnected yet.
338
- logger.error(
339
- "Anthropic OAuth refresh failed with invalid_grant and no fresh token is available — user must reconnect in the console",
340
- { agentId: opts.agentId },
341
- );
342
- throw new Error(
343
- "Anthropic OAuth token is invalid (invalid_grant). Please reconnect your Anthropic account in the console.",
344
- );
345
- }
346
- } else {
347
- logger.error(
348
- "Anthropic OAuth refresh failed with invalid_grant; no MERCURY_AGENT_ID set for console fetch",
349
- );
350
- throw new Error(
351
- "Anthropic OAuth token is invalid (invalid_grant). Please reconnect your Anthropic account in the console.",
352
- );
353
- }
354
- } else if (isInvalidGrant) {
355
- // No console configured — cannot recover; surface the failure.
356
- logger.error(
357
- "Anthropic OAuth refresh failed with invalid_grant; re-authentication required",
358
- );
359
- throw new Error(
360
- "Anthropic OAuth token is invalid (invalid_grant). Please reconnect your Anthropic account.",
361
- );
362
- } else {
363
- // Transient error (network, 429, etc.) current access token may still be valid.
364
- logger.warn(
365
- "Anthropic OAuth refresh failed at spawn time; using current access token",
366
- err instanceof Error ? err : undefined,
367
- );
368
- }
369
- }
370
- } else if (needsRefresh) {
371
- logger.warn(
372
- "Anthropic OAuth token expired; skipping refresh (rate-limit cooldown active)",
373
- );
374
- }
375
-
376
- return { access: freshAccess, updatedBlob };
377
- }
378
-
379
- const CONTAINER_LABEL = "mercury.managed=true";
380
- const AGENT_ID_LABEL_KEY = "mercury.agent-id";
381
-
382
- const __dirname = dirname(fileURLToPath(import.meta.url));
383
- const PACKAGE_ROOT = path.join(__dirname, "../..");
384
-
385
- /** Exit code 137 = SIGKILL (128 + 9), typically from OOM killer */
386
- const OOM_EXIT_CODE = 137;
387
-
388
- let _isDockerDesktop: boolean | undefined;
389
- function isDockerDesktop(): boolean {
390
- if (_isDockerDesktop !== undefined) return _isDockerDesktop;
391
- try {
392
- const result = spawnSync("docker", ["info", "--format", "{{.Name}}"], {
393
- encoding: "utf8",
394
- timeout: 10_000,
395
- stdio: ["pipe", "pipe", "pipe"],
396
- });
397
- const name = (result.stdout ?? "").trim();
398
- const stderr = (result.stderr ?? "").trim();
399
- if (name === "docker-desktop") {
400
- _isDockerDesktop = true;
401
- } else {
402
- // Fallback: check Docker context or server OS for Docker Desktop indicators
403
- const ctxResult = spawnSync(
404
- "docker",
405
- ["info", "--format", "{{.OperatingSystem}}"],
406
- { encoding: "utf8", timeout: 10_000, stdio: ["pipe", "pipe", "pipe"] },
407
- );
408
- const os = (ctxResult.stdout ?? "").trim();
409
- _isDockerDesktop =
410
- os.includes("Docker Desktop") || os.includes("Docker for Windows");
411
- if (_isDockerDesktop) {
412
- logger.info("Docker Desktop detected via OperatingSystem field", {
413
- name,
414
- os,
415
- });
416
- } else {
417
- logger.debug("Docker Desktop not detected", {
418
- name,
419
- os,
420
- exitCode: result.status,
421
- stderr: stderr.slice(0, 200),
422
- });
423
- }
424
- }
425
- } catch {
426
- _isDockerDesktop = false;
427
- }
428
- return _isDockerDesktop;
429
- }
430
-
431
- export class AgentContainerRunner {
432
- // Inner containers now run detached (no long-lived `docker` child process to
433
- // hold a handle to), so we track only the container name — termination is by
434
- // `docker kill <name>`, and the per-message poll loop owns cleanup.
435
- private readonly runningBySpace = new Map<
436
- string,
437
- { containerName: string }
438
- >();
439
- private readonly abortedSpaces = new Set<string>();
440
- private readonly timedOutSpaces = new Set<string>();
441
- private containerCounter = 0;
442
- private buildState: ExtImageBuildState | undefined = undefined;
443
- private readonly resolvedApiHost: string;
444
-
445
- constructor(private readonly config: AppConfig) {
446
- this.validateImage();
447
- this.resolvedApiHost = this.resolveApiHost();
448
- }
449
-
450
- /**
451
- * Resolve the API host that inner containers will use to reach us.
452
- *
453
- * gVisor (runsc) cannot use Docker's embedded DNS (127.0.0.11 is unreachable
454
- * from the gVisor network sandbox), so container-name hostnames don't resolve.
455
- * Previously the outer container joined the shared default bridge (docker0) and
456
- * handed inner containers its bridge IP but that left the outer reachable by
457
- * any neighbor on docker0, undercutting per-agent network isolation.
458
- *
459
- * Now inner containers reach the API over a per-agent unix socket (see
460
- * api-socket.ts), so the outer no longer joins docker0. API_URL becomes a dummy
461
- * (`http://localhost:<port>`) that mrctl ignores once API_SOCKET is set; we keep
462
- * it non-empty only so mrctl's presence assertion passes. runc/local are
463
- * unchanged and keep using the real hostname.
464
- */
465
- private resolveApiHost(): string {
466
- const configured = this.config.containerApiHost;
467
- if (!configured) return "host.docker.internal";
468
- if (this.config.containerRuntime !== "runsc") return configured;
469
- // gVisor: dummy host — the real transport is the unix socket (API_SOCKET).
470
- return "localhost";
471
- }
472
-
473
- /** Set a background build state — currentImage() is resolved at each spawn. */
474
- setBuildState(state: ExtImageBuildState): void {
475
- this.buildState = state;
476
- }
477
-
478
- /** The image to use for container spawns. */
479
- get image(): string {
480
- if (this.buildState) return this.buildState.currentImage();
481
- return this.config.agentContainerImage;
482
- }
483
-
484
- /**
485
- * Warn if using a custom image that might be missing required tools.
486
- * Known presets (mercury-agent:*) are assumed to be valid.
487
- */
488
- private validateImage(): void {
489
- const image = this.config.agentContainerImage;
490
-
491
- // Skip validation for known presets
492
- if (
493
- image.startsWith("mercury-agent:") ||
494
- image.includes("/mercury-agent:")
495
- ) {
496
- return;
497
- }
498
-
499
- // For custom images, log a warning about requirements
500
- logger.warn("Using custom agent image", {
501
- image,
502
- note: `Ensure image has: bun, pi, mrctl${this.config.containerRuntime === "runsc" ? "" : ", bubblewrap (runc mode)"}`,
503
- docs: "See docs/container-lifecycle.md for custom image requirements",
504
- });
505
- }
506
-
507
- /**
508
- * Ensure the agent image is available locally, pulling it if needed.
509
- * Should be called on startup before accepting work.
510
- */
511
- async ensureImage(): Promise<void> {
512
- const image = this.image;
513
- try {
514
- execSync(`docker image inspect ${image}`, {
515
- stdio: "ignore",
516
- timeout: 10_000,
517
- });
518
- logger.debug("Agent image found locally", { image });
519
- } catch {
520
- logger.info("Agent image not found locally, pulling...", { image });
521
- try {
522
- execSync(`docker pull ${image}`, {
523
- stdio: "inherit",
524
- timeout: 300_000,
525
- });
526
- logger.info("Agent image pulled successfully", { image });
527
- } catch {
528
- throw new Error(
529
- `Failed to pull agent image: ${image}\n` +
530
- `Build it locally with: mercury build\n` +
531
- `Or pull manually: docker pull ${image}`,
532
- );
533
- }
534
- }
535
- }
536
-
537
- isRunning(spaceId: string): boolean {
538
- return this.runningBySpace.has(spaceId);
539
- }
540
-
541
- /**
542
- * Clean up any orphaned containers from previous runs.
543
- * Should be called on startup before accepting new work.
544
- */
545
- async cleanupOrphans(): Promise<number> {
546
- try {
547
- const agentId = process.env.MERCURY_AGENT_ID;
548
- const filter = agentId
549
- ? `--filter "label=${CONTAINER_LABEL}" --filter "label=${AGENT_ID_LABEL_KEY}=${agentId}"`
550
- : `--filter "label=${CONTAINER_LABEL}"`;
551
- // Find containers with our labels (running or stopped)
552
- const result = execSync(`docker ps -a ${filter} --format "{{.ID}}"`, {
553
- encoding: "utf8",
554
- timeout: 10_000,
555
- }).trim();
556
-
557
- if (!result) return 0;
558
-
559
- const containerIds = result.split("\n").filter(Boolean);
560
- if (containerIds.length === 0) return 0;
561
-
562
- logger.info("Found orphaned containers, cleaning up", {
563
- count: containerIds.length,
564
- });
565
-
566
- // Force remove all orphaned containers
567
- execSync(`docker rm -f ${containerIds.join(" ")}`, {
568
- encoding: "utf8",
569
- timeout: 30_000,
570
- });
571
-
572
- logger.info("Cleaned up orphaned containers", {
573
- count: containerIds.length,
574
- });
575
- return containerIds.length;
576
- } catch (error) {
577
- // If docker command fails (e.g., docker not installed), log and continue
578
- if (error instanceof Error && error.message.includes("ENOENT")) {
579
- logger.warn("Docker not found, skipping orphan cleanup");
580
- } else {
581
- logger.warn(
582
- "Failed to cleanup orphaned containers",
583
- error instanceof Error ? error : undefined,
584
- );
585
- }
586
- return 0;
587
- }
588
- }
589
-
590
- /**
591
- * Kill all running containers using docker kill for reliable termination.
592
- * Note: runningBySpace entries are cleaned up by each reply()'s poll loop.
593
- * During shutdown the loop may not run before exit, but that's fine —
594
- * Docker cleans up --rm containers regardless once killed.
595
- */
596
- killAll(): void {
597
- for (const [spaceId, { containerName }] of this.runningBySpace) {
598
- this.abortedSpaces.add(spaceId);
599
- try {
600
- execSync(`docker kill ${containerName}`, { timeout: 5000 });
601
- } catch {
602
- // docker kill can fail (container already exited/reaped) — the poll loop
603
- // observes abortedSpaces and unwinds either way.
604
- }
605
- }
606
- }
607
-
608
- get activeCount(): number {
609
- return this.runningBySpace.size;
610
- }
611
-
612
- getActiveSpaces(): string[] {
613
- return [...this.runningBySpace.keys()];
614
- }
615
-
616
- abort(spaceId: string): boolean {
617
- const entry = this.runningBySpace.get(spaceId);
618
- if (!entry) return false;
619
-
620
- this.abortedSpaces.add(spaceId);
621
-
622
- // Use docker kill for reliable container termination; the poll loop observes
623
- // abortedSpaces and rejects the in-flight reply().
624
- try {
625
- execSync(`docker kill ${entry.containerName}`, { timeout: 5000 });
626
- } catch {
627
- // docker kill can fail (container already exited/reaped) — abortedSpaces
628
- // still unwinds the poll loop.
629
- }
630
- return true;
631
- }
632
-
633
- private generateContainerName(): string {
634
- const id = ++this.containerCounter;
635
- const timestamp = Date.now();
636
- const agentId = process.env.MERCURY_AGENT_ID;
637
- return agentId
638
- ? `mercury-${agentId}-${timestamp}-${id}`
639
- : `mercury-${timestamp}-${id}`;
640
- }
641
-
642
- async reply(input: {
643
- spaceId: string;
644
- spaceWorkspace: string;
645
- messages: StoredMessage[];
646
- anchorMessages?: StoredMessage[];
647
- prompt: string;
648
- callerId: string;
649
- callerRole?: string;
650
- authorName?: string;
651
- attachments?: MessageAttachment[];
652
- preferences?: Array<{ key: string; value: string }>;
653
- extraEnv?: Record<string, string>;
654
- claimedEnvSources?: Set<string>;
655
- }): Promise<ContainerResult> {
656
- const globalDir = path.resolve(this.config.globalDir);
657
- const spacesRoot = path.resolve(this.config.spacesDir);
658
-
659
- fs.mkdirSync(globalDir, { recursive: true });
660
- fs.mkdirSync(spacesRoot, { recursive: true });
661
- try {
662
- execFileSync("chown", ["-R", "1000:1000", globalDir], { stdio: "pipe" });
663
- } catch {
664
- // CAP_CHOWN may be unavailable (--cap-drop=ALL without --cap-add=CHOWN).
665
- // Skills are installed world-readable, so the inner container (uid 1000)
666
- // can still read them. New containers should have --cap-add=CHOWN.
667
- logger.warn(
668
- "chown globalDir failed (CAP_CHOWN unavailable), continuing",
669
- { globalDir },
670
- );
671
- }
672
-
673
- const authFromPi = await getApiKeyFromPiAuthFile({
674
- provider: this.config.modelProvider,
675
- authPath: this.config.authPath ?? path.join(globalDir, "auth.json"),
676
- });
677
-
678
- // Env vars that should never be passed to containers
679
- const BLOCKED_ENV_VARS = new Set([
680
- "MERCURY_API_SECRET",
681
- // Host-only: the inner→outer API socket path is set by code per spawn;
682
- // never let an agent override which socket mrctl targets.
683
- "MERCURY_API_SOCKET",
684
- "MERCURY_CHAT_API_KEY",
685
- "MERCURY_ADMINS",
686
- // Host-only: affects `docker run` flags, not the agent process inside the container
687
- "MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
688
- // Host-only: selects the OCI runtime for `docker run --runtime`; not meaningful inside the container
689
- "MERCURY_CONTAINER_RUNTIME",
690
- // Host-only: resolved volume mountpoint on the host; inner containers don't need it
691
- "MERCURY_HOST_DATA_DIR",
692
- "MERCURY_SLACK_BOT_TOKEN",
693
- "MERCURY_SLACK_SIGNING_SECRET",
694
- "MERCURY_DISCORD_BOT_TOKEN",
695
- "MERCURY_DISCORD_GATEWAY_SECRET",
696
- "MERCURY_TELEGRAM_BOT_TOKEN",
697
- "MERCURY_TELEGRAM_WEBHOOK_SECRET_TOKEN",
698
- "MERCURY_TEAMS_APP_ID",
699
- "MERCURY_TEAMS_APP_PASSWORD",
700
- "MERCURY_WHATSAPP_AUTH_DIR",
701
- ]);
702
-
703
- // Pass MERCURY_* vars to container with prefix stripped, excluding blocked vars
704
- const claimed = input.claimedEnvSources;
705
- const passthroughEnvPairs = Object.entries(process.env)
706
- .filter(
707
- (entry): entry is [string, string] =>
708
- entry[0].startsWith("MERCURY_") &&
709
- entry[1] !== undefined &&
710
- !BLOCKED_ENV_VARS.has(entry[0]) &&
711
- !claimed?.has(entry[0]),
712
- )
713
- .map(([key, value]) => ({
714
- key: key.replace("MERCURY_", ""),
715
- value: value,
716
- }));
717
-
718
- // Legacy path: older console versions stored the OAuth credential blob in
719
- // MERCURY_ANTHROPIC_API_KEY instead of MERCURY_ANTHROPIC_OAUTH_TOKEN.
720
- // Current console uses MERCURY_ANTHROPIC_OAUTH_TOKEN (handled below), but
721
- // keep this guard so agents provisioned before the migration don't break.
722
- const anthApiKeyIdx = passthroughEnvPairs.findIndex(
723
- (p) =>
724
- p.key === "ANTHROPIC_API_KEY" && p.value.trimStart().startsWith("{"),
725
- );
726
- if (anthApiKeyIdx !== -1) {
727
- try {
728
- const raw = passthroughEnvPairs[anthApiKeyIdx]?.value ?? "";
729
- const parsed = JSON.parse(raw) as Record<string, unknown>;
730
- const access =
731
- typeof parsed.access === "string" ? parsed.access : undefined;
732
- if (access) {
733
- passthroughEnvPairs.splice(anthApiKeyIdx, 1);
734
- passthroughEnvPairs.push({
735
- key: "ANTHROPIC_OAUTH_TOKEN",
736
- value: access,
737
- });
738
- }
739
- } catch {
740
- // Not valid JSON — leave as-is and let pi handle/reject it
741
- }
742
- }
743
-
744
- // MERCURY_ANTHROPIC_OAUTH_TOKEN now carries a full credential blob
745
- // ({"access":"...","refresh":"...","expires":...}) so the fork can refresh
746
- // the access token at each spawn instead of relying on the frozen value
747
- // injected when the outer container started. Remove the blob unconditionally
748
- // to ensure raw JSON never leaks into the inner container, then push a fresh
749
- // bare token (or the current token if refresh fails / is rate-limited).
750
- const anthOauthIdx = passthroughEnvPairs.findIndex(
751
- (p) =>
752
- p.key === "ANTHROPIC_OAUTH_TOKEN" &&
753
- p.value.trimStart().startsWith("{"),
754
- );
755
- if (anthOauthIdx !== -1) {
756
- const raw = passthroughEnvPairs[anthOauthIdx]?.value ?? "";
757
- passthroughEnvPairs.splice(anthOauthIdx, 1);
758
- let parsedCreds: AnthropicOAuthCreds | undefined;
759
- try {
760
- parsedCreds = JSON.parse(raw) as AnthropicOAuthCreds;
761
- } catch {
762
- logger.warn("Anthropic OAuth blob corrupt; skipping token injection");
763
- }
764
- if (parsedCreds) {
765
- const { access, updatedBlob } = await resolveOAuthCredentialForSpawn(
766
- parsedCreds,
767
- {
768
- consoleUrl: this.config.consoleUrl,
769
- consoleInternalSecret: this.config.consoleInternalSecret,
770
- agentId: process.env.MERCURY_AGENT_ID,
771
- },
772
- );
773
- // Update outer container's in-process env so subsequent spawns within
774
- // this process lifetime start with the fresh blob (avoids re-refreshing
775
- // a token that was just fetched). Not persisted across process restarts.
776
- if (updatedBlob) {
777
- process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN = updatedBlob;
778
- }
779
- passthroughEnvPairs.push({
780
- key: "ANTHROPIC_OAUTH_TOKEN",
781
- value: access,
782
- });
783
- }
784
- }
785
-
786
- // Check for pi auth file fallback for Anthropic
787
- const hasAnthropicKey = passthroughEnvPairs.some(
788
- (p) => p.key === "ANTHROPIC_API_KEY" || p.key === "ANTHROPIC_OAUTH_TOKEN",
789
- );
790
- if (
791
- !hasAnthropicKey &&
792
- this.config.modelProvider === "anthropic" &&
793
- authFromPi
794
- ) {
795
- passthroughEnvPairs.push({
796
- key: "ANTHROPIC_OAUTH_TOKEN",
797
- value: authFromPi,
798
- });
799
- }
800
-
801
- const envPairs = [
802
- // Internal vars (set by code, not from env)
803
- { key: "HOME", value: "/home/mercury" },
804
- {
805
- key: "PATH",
806
- value:
807
- "/home/mercury/.local/bin:/home/mercury/.bun/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin",
808
- },
809
- { key: "PI_CODING_AGENT_DIR", value: "/home/mercury/.pi/agent" },
810
- { key: "CALLER_ID", value: input.callerId },
811
- { key: "SPACE_ID", value: input.spaceId },
812
- {
813
- key: "API_URL",
814
- value: `http://${this.resolvedApiHost}:${this.config.port}`,
815
- },
816
- // API secret for mrctl auth from inside containers
817
- { key: "API_SECRET", value: this.config.apiSecret ?? "" },
818
- // gVisor: inner containers reach the API over a per-agent unix socket
819
- // (the outer is off docker0). mrctl uses this transport when set; API_URL
820
- // host/port above are then ignored. Absent for runc/local.
821
- ...(this.config.containerRuntime === "runsc"
822
- ? [{ key: "API_SOCKET", value: innerApiSocketPath() }]
823
- : []),
824
- // Passthrough vars (MERCURY_* with prefix stripped)
825
- ...passthroughEnvPairs,
826
- // Host-resolved model chain (overrides any stale MODEL_CHAIN from passthrough)
827
- {
828
- key: "MODEL_CHAIN",
829
- value: JSON.stringify(this.config.resolvedModelChain),
830
- },
831
- {
832
- key: "MODEL_RETRY_MAX_PER_LEG",
833
- value: String(this.config.modelMaxRetriesPerLeg),
834
- },
835
- {
836
- key: "MODEL_CHAIN_BUDGET_MS",
837
- value: String(this.config.effectiveModelChainBudgetMs),
838
- },
839
- {
840
- key: "MODEL_CHAIN_CAPABILITIES",
841
- value: JSON.stringify(this.config.resolvedModelChainCapabilities),
842
- },
843
- {
844
- key: "OVERRIDE_PI_SYSTEM_PROMPT",
845
- value: this.config.overridePiSystemPrompt ? "true" : "false",
846
- },
847
- ].filter((x): x is { key: string; value: string } => Boolean(x.value));
848
-
849
- const containerName = this.generateContainerName();
850
-
851
- // Resolve docs paths for self-documenting agent
852
- const docsDir = path.resolve(PACKAGE_ROOT, "docs");
853
- const readmePath = path.resolve(PACKAGE_ROOT, "README.md");
854
-
855
- // In cloud deployments the outer container runs with a Docker named volume at
856
- // config.globalDir / config.spacesDir. When those paths are passed as bind-mount
857
- // sources to the host Docker daemon (via the Docker socket), the daemon treats them
858
- // as HOST filesystem paths — a different directory from the volume. Setting
859
- // MERCURY_HOST_DATA_DIR to the volume's actual host-side mountpoint
860
- // (/var/lib/docker/volumes/<name>/_data) lets inner containers mount the same data
861
- // the outer container reads and writes. Falls back to config paths for local dev
862
- // where no named volume is in use.
863
- const hostDataDir = process.env.MERCURY_HOST_DATA_DIR;
864
- const innerGlobalDir = hostDataDir
865
- ? path.join(hostDataDir, "global")
866
- : globalDir;
867
- const innerSpacesRoot = hostDataDir
868
- ? path.join(hostDataDir, "spaces")
869
- : spacesRoot;
870
-
871
- // Mount only the specific space directory for isolation
872
- const spaceDir = path.resolve(spacesRoot, input.spaceId);
873
- const innerSpaceDir = path.join(innerSpacesRoot, input.spaceId);
874
- fs.mkdirSync(spaceDir, { recursive: true });
875
- try {
876
- execFileSync("chown", ["-R", "1000:1000", spaceDir], { stdio: "pipe" });
877
- } catch {
878
- logger.warn(
879
- "chown spaceDir failed (CAP_CHOWN unavailable), falling back to chmod 777",
880
- { spaceDir },
881
- );
882
- try {
883
- fs.chmodSync(spaceDir, 0o777);
884
- } catch {
885
- logger.warn(
886
- "chmod spaceDir also failed, inner container may lack write access",
887
- { spaceDir },
888
- );
889
- }
890
- }
891
-
892
- const agentId = process.env.MERCURY_AGENT_ID;
893
- // `docker create` (not `run`) and no `-i`: the container is started detached
894
- // and communicates over the mounted IO dir, never the attach stream. This is
895
- // the only launch shape that survives the Bun body-proxy on the cloud agent
896
- // lane (it cannot proxy Docker's hijacked attach connection).
897
- const args = [
898
- "create",
899
- "--rm",
900
- "--name",
901
- containerName,
902
- "--label",
903
- CONTAINER_LABEL,
904
- ...(agentId ? ["--label", `${AGENT_ID_LABEL_KEY}=${agentId}`] : []),
905
- ];
906
-
907
- if (
908
- this.config.containerNetwork &&
909
- this.config.containerRuntime !== "runsc"
910
- ) {
911
- // runc: join the shared network so inner containers can resolve the
912
- // outer container by DNS name and reach external APIs.
913
- args.push("--network", this.config.containerNetwork);
914
- } else {
915
- // Default bridge (no --network flag). gVisor always lands here because
916
- // user-defined Docker networks break gVisor's outbound DNS (Docker's
917
- // embedded resolver at 127.0.0.11 is unreachable from gVisor). Inner
918
- // containers keep docker0 for outbound DNS/HTTPS; the inner→outer API
919
- // callback rides the per-agent unix socket (API_SOCKET), not docker0.
920
- args.push("--add-host", "host.docker.internal:host-gateway");
921
- }
922
-
923
- // Per-message IO dir — the detached reply channel. Mirrors the global/spaces
924
- // host-path translation: the host bind source must live under the agent's own
925
- // data volume (`hostDataDir`) so it satisfies the body-proxy's RW-bind
926
- // allowlist (`/var/lib/docker/volumes/mercury-<agentId>-data/...`). The outer
927
- // writes input.json here; the inner writes result.json back.
928
- const ioLocalDir = path.join(
929
- resolveProjectPath(this.config.dataDir),
930
- "io",
931
- containerName,
932
- );
933
- const ioHostDir = hostDataDir
934
- ? path.join(hostDataDir, "io", containerName)
935
- : ioLocalDir;
936
-
937
- args.push(
938
- "-v",
939
- `${innerSpaceDir}:/spaces/${input.spaceId}`,
940
- "-v",
941
- `${innerGlobalDir}:/home/mercury/.pi/agent`,
942
- "-v",
943
- `${readmePath}:/docs/mercury/README.md:ro`,
944
- "-v",
945
- `${docsDir}:/docs/mercury/docs:ro`,
946
- "-v",
947
- `${ioHostDir}:${INNER_IO_DIR}`,
948
- "-e",
949
- `IO_DIR=${INNER_IO_DIR}`,
950
- );
951
-
952
- if (this.config.containerRuntime === "runsc") {
953
- // Mount the per-agent run dir so the inner container can reach the outer's
954
- // API unix socket (api-<hostname>.sock, created in main.ts). Mirrors the
955
- // global/spaces host-path translation: the host-side source is the data
956
- // volume's run dir, exposed at /run/mercury inside the inner container.
957
- // Resolve dataDir with the same helper main.ts uses to create the socket,
958
- // so the bind source and the listener never disagree on the run-dir path.
959
- const localRunDir = apiSocketDir(resolveProjectPath(this.config.dataDir));
960
- const innerRunDir = hostDataDir ? apiSocketDir(hostDataDir) : localRunDir;
961
- // Ensure the host bind source exists (main.ts created it at startup; this
962
- // guards against config drift). Created in the in-container data dir, which
963
- // is the same volume the host path resolves to.
964
- fs.mkdirSync(localRunDir, { recursive: true });
965
- args.push("-v", `${innerRunDir}:${INNER_RUN_DIR}`);
966
- // gVisor: intercepts all syscalls at a user-space kernel boundary — no bwrap needed.
967
- // Restores full Docker hardening (SYS_ADMIN relaxation not required).
968
- // CONTAINER_RUNTIME=runsc is passed explicitly (stripped prefix) so container-entry
969
- // skips the bwrap spawn path.
970
- args.push(
971
- "--runtime=runsc",
972
- "--cap-drop=ALL",
973
- "--security-opt=no-new-privileges",
974
- "--memory=2g",
975
- "--cpus=2",
976
- "--pids-limit=512",
977
- "-e",
978
- "CONTAINER_RUNTIME=runsc",
979
- );
980
- } else if (this.config.containerBwrapDockerCompat || isDockerDesktop()) {
981
- if (this.config.containerBwrapDockerCompat) {
982
- logger.info(
983
- "Enabling bwrap Docker compat with --privileged (containerBwrapDockerCompat=true)",
984
- );
985
- args.push("--privileged");
986
- } else {
987
- logger.info(
988
- "Enabling bwrap Docker compat (seccomp/apparmor/SYS_ADMIN) for Docker Desktop",
989
- );
990
- args.push(
991
- "--security-opt",
992
- "seccomp=unconfined",
993
- "--security-opt",
994
- "apparmor=unconfined",
995
- "--cap-add",
996
- "SYS_ADMIN",
997
- );
998
- }
999
- }
1000
-
1001
- for (const { key, value } of envPairs) {
1002
- args.push("-e", `${key}=${value}`);
1003
- }
1004
-
1005
- // Extension env vars from before_container hooks
1006
- if (input.extraEnv) {
1007
- for (const [key, value] of Object.entries(input.extraEnv)) {
1008
- args.push("-e", `${key}=${value}`);
1009
- }
1010
- }
1011
-
1012
- const buildingNow = this.buildState?.building ?? false;
1013
- const spawnImage = this.image;
1014
- if (buildingNow) {
1015
- logger.info("Ext image still building, spawning with base image", {
1016
- image: spawnImage,
1017
- });
1018
- }
1019
- args.push(spawnImage);
1020
-
1021
- // Per-run nonce — retained in the payload for the inner container's legacy
1022
- // stdout-marker fallback (used only for direct/manual attach against a real
1023
- // daemon; the detached cloud path reads result.json instead).
1024
- const nonce = randomBytes(8).toString("hex");
1025
-
1026
- const payload = {
1027
- ...input,
1028
- messages: input.messages,
1029
- anchorMessages: input.anchorMessages,
1030
- spaceWorkspace: input.spaceWorkspace
1031
- .replace(spacesRoot, "/spaces")
1032
- .replaceAll("\\", "/"),
1033
- callerRole: input.callerRole ?? "member",
1034
- authorName: input.authorName,
1035
- nonce,
1036
- };
1037
-
1038
- // Create child logger with context for this container run
1039
- const log: Logger = logger.child({
1040
- spaceId: input.spaceId,
1041
- container: containerName,
1042
- });
1043
-
1044
- const startTime = Date.now();
1045
-
1046
- // Stage the request payload where the inner container will read it, and make
1047
- // the dir writable by the inner uid (1000) so it can drop result.json back.
1048
- fs.mkdirSync(ioLocalDir, { recursive: true });
1049
- fs.writeFileSync(
1050
- path.join(ioLocalDir, "input.json"),
1051
- JSON.stringify(payload),
1052
- );
1053
- try {
1054
- execFileSync("chown", ["-R", "1000:1000", ioLocalDir], { stdio: "pipe" });
1055
- } catch {
1056
- try {
1057
- fs.chmodSync(ioLocalDir, 0o777);
1058
- } catch {
1059
- logger.warn("chown/chmod ioDir failed; inner may not write result", {
1060
- ioLocalDir,
1061
- });
1062
- }
1063
- }
1064
-
1065
- const resultPath = path.join(ioLocalDir, "result.json");
1066
- const cleanupIo = () => {
1067
- try {
1068
- fs.rmSync(ioLocalDir, { recursive: true, force: true });
1069
- } catch {
1070
- // best effort orphaned IO dirs are harmless and small
1071
- }
1072
- };
1073
-
1074
- // Create the container (detached). `docker create` is where a pruned/missing
1075
- // image surfaces (exit 125, "No such image"/"Unable to find image"), so this
1076
- // error shape feeds replyWithRetry's rebuild-and-retry path unchanged.
1077
- const created = await execDocker(args);
1078
- if (created.code !== 0) {
1079
- cleanupIo();
1080
- const output = created.timedOut
1081
- ? `docker create timed out after ${Math.round(EXEC_DOCKER_TIMEOUT_MS / 1000)}s — Docker daemon may be unresponsive`
1082
- : created.stderr ||
1083
- created.stdout ||
1084
- `docker create exited with code ${created.code} (no output)`;
1085
- log.error("docker create failed", {
1086
- exitCode: created.code,
1087
- timedOut: created.timedOut,
1088
- output,
1089
- });
1090
- throw ContainerError.error(created.code, output);
1091
- }
1092
-
1093
- this.runningBySpace.set(input.spaceId, { containerName });
1094
- const deadline = startTime + this.config.containerTimeoutMs;
1095
-
1096
- try {
1097
- // Start detached no `-a`/attach, so the body-proxy only sees
1098
- // POST /containers/<id>/start (plain request/response). Returns immediately.
1099
- const started = await execDocker(["start", containerName]);
1100
- if (started.code !== 0) {
1101
- const output = started.timedOut
1102
- ? `docker start timed out after ${Math.round(EXEC_DOCKER_TIMEOUT_MS / 1000)}s — Docker daemon may be unresponsive`
1103
- : started.stderr ||
1104
- started.stdout ||
1105
- `docker start exited with code ${started.code} (no output)`;
1106
- log.error("docker start failed", {
1107
- exitCode: started.code,
1108
- timedOut: started.timedOut,
1109
- containerName,
1110
- output,
1111
- });
1112
- throw ContainerError.error(started.code, output);
1113
- }
1114
- log.info("Container started", { event: "container.start" });
1115
-
1116
- // Poll the mounted result file. The inner container writes result.json
1117
- // atomically (tmp + rename) on every outcome, so its presence means a
1118
- // complete payload. A periodic `docker inspect` fails fast if the container
1119
- // died without writing one (hard crash / OOM) instead of waiting out the
1120
- // full timeout.
1121
- let iter = 0;
1122
- while (true) {
1123
- if (fs.existsSync(resultPath)) {
1124
- return this.consumeResult(resultPath, input, startTime, log);
1125
- }
1126
-
1127
- if (this.timedOutSpaces.has(input.spaceId) || Date.now() >= deadline) {
1128
- this.timedOutSpaces.delete(input.spaceId);
1129
- await execDocker(["kill", containerName]);
1130
- // The kill loses the race against a just-written result occasionally.
1131
- if (fs.existsSync(resultPath)) {
1132
- return this.consumeResult(resultPath, input, startTime, log);
1133
- }
1134
- log.warn("Container exited", {
1135
- event: "container.end",
1136
- durationMs: Date.now() - startTime,
1137
- reason: "timeout",
1138
- });
1139
- throw ContainerError.timeout(input.spaceId);
1140
- }
1141
-
1142
- if (this.abortedSpaces.has(input.spaceId)) {
1143
- this.abortedSpaces.delete(input.spaceId);
1144
- await execDocker(["kill", containerName]);
1145
- log.info("Container exited", {
1146
- event: "container.end",
1147
- durationMs: Date.now() - startTime,
1148
- reason: "aborted",
1149
- });
1150
- throw ContainerError.aborted(input.spaceId);
1151
- }
1152
-
1153
- if (++iter % LIVENESS_EVERY === 0) {
1154
- const crash = await this.detectCrash(
1155
- containerName,
1156
- resultPath,
1157
- input.spaceId,
1158
- );
1159
- if (crash) {
1160
- log.error("Container exited", {
1161
- event: "container.end",
1162
- exitCode: crash.exitCode,
1163
- durationMs: Date.now() - startTime,
1164
- reason: crash.reason,
1165
- });
1166
- throw crash;
1167
- }
1168
- // detectCrash may have observed result.json appear during its grace wait
1169
- if (fs.existsSync(resultPath)) {
1170
- return this.consumeResult(resultPath, input, startTime, log);
1171
- }
1172
- }
1173
-
1174
- await sleep(RESULT_POLL_MS);
1175
- }
1176
- } finally {
1177
- this.runningBySpace.delete(input.spaceId);
1178
- cleanupIo();
1179
- }
1180
- }
1181
-
1182
- /**
1183
- * Parse the inner container's result file and build the ContainerResult.
1184
- * `{ ok: false }` means the container caught its own failure and reported it;
1185
- * surface it as an error so callers see the real message rather than a generic
1186
- * crash.
1187
- */
1188
- private consumeResult(
1189
- resultPath: string,
1190
- input: { spaceId: string; spaceWorkspace: string },
1191
- startTime: number,
1192
- log: Logger,
1193
- ): ContainerResult {
1194
- let parsed: {
1195
- ok?: boolean;
1196
- reply?: string;
1197
- usage?: TokenUsage;
1198
- error?: string;
1199
- };
1200
- try {
1201
- parsed = JSON.parse(fs.readFileSync(resultPath, "utf8"));
1202
- } catch (e) {
1203
- throw new Error(
1204
- `Malformed container result: ${e instanceof Error ? e.message : String(e)}`,
1205
- );
1206
- }
1207
-
1208
- if (parsed.ok === false) {
1209
- throw ContainerError.error(
1210
- 1,
1211
- parsed.error ?? "container reported failure",
1212
- );
1213
- }
1214
-
1215
- log.info("Container exited", {
1216
- event: "container.end",
1217
- exitCode: 0,
1218
- durationMs: Date.now() - startTime,
1219
- });
1220
-
1221
- const replyText = parsed.reply ?? "Done.";
1222
- const files = scanOutbox(input.spaceWorkspace, startTime);
1223
- return { reply: replyText, files, usage: parsed.usage };
1224
- }
1225
-
1226
- /**
1227
- * Liveness probe used while polling for the result file. Returns a
1228
- * ContainerError when the inner container is gone/exited without producing a
1229
- * result (a hard crash the container couldn't catch — e.g. OOM kill, gVisor
1230
- * panic), or `null` if it's still running. Grants a short grace so the
1231
- * exit→result-write→--rm-reap race resolves in favour of a real result.
1232
- */
1233
- private async detectCrash(
1234
- containerName: string,
1235
- resultPath: string,
1236
- spaceId: string,
1237
- ): Promise<ContainerError | null> {
1238
- const insp = await execDocker([
1239
- "inspect",
1240
- "-f",
1241
- "{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}",
1242
- containerName,
1243
- ]);
1244
-
1245
- // Container still present and running — no crash.
1246
- if (insp.code === 0 && insp.stdout.trim().startsWith("running")) {
1247
- return null;
1248
- }
1249
-
1250
- // Either inspect 404'd (--rm already reaped an exited container) or the
1251
- // container is in a terminal state. Give the result file a moment to land.
1252
- await sleep(RESULT_POLL_MS);
1253
- if (fs.existsSync(resultPath)) return null;
1254
-
1255
- if (insp.code !== 0) {
1256
- // Reaped without a result — exit code is unrecoverable post-reap.
1257
- return ContainerError.error(
1258
- 1,
1259
- "inner container exited without producing a result (possible crash)",
1260
- );
1261
- }
1262
-
1263
- const [, exitStr, oom] = insp.stdout.trim().split("|");
1264
- const exitCode = Number.parseInt(exitStr ?? "1", 10) || 1;
1265
- if (oom === "true" || exitCode === OOM_EXIT_CODE) {
1266
- return ContainerError.oom(spaceId, exitCode);
1267
- }
1268
- return ContainerError.error(
1269
- exitCode,
1270
- "inner container exited without producing a result",
1271
- );
1272
- }
1273
-
1274
- /**
1275
- * Spawn a container for a reply, with automatic recovery if the derived ext
1276
- * image was pruned by a rolling deploy.
1277
- *
1278
- * Docker returns exit code 125 with "No such image" or "Unable to find image"
1279
- * in stderr when an image that existed at build time has since been pruned.
1280
- * On that specific error we trigger a background rebuild and immediately retry
1281
- * with the base image so the current message is not dropped.
1282
- *
1283
- * rebuild() synchronously resets resolvedImage → baseImage before its first
1284
- * await, so by the time we call reply() again this.image already returns the
1285
- * base image. The rebuild completes in the background; subsequent spawns use
1286
- * the fresh derived image once it is ready.
1287
- */
1288
- async replyWithRetry(
1289
- input: Parameters<AgentContainerRunner["reply"]>[0],
1290
- ): Promise<ContainerResult> {
1291
- try {
1292
- return await this.reply(input);
1293
- } catch (err) {
1294
- if (
1295
- err instanceof ContainerError &&
1296
- err.reason === "error" &&
1297
- err.exitCode === 125 &&
1298
- this.buildState &&
1299
- (err.message.includes("No such image") ||
1300
- err.message.includes("Unable to find image"))
1301
- ) {
1302
- // Capture before rebuild() resets resolvedImage to baseImage
1303
- const missingImage = this.buildState.currentImage();
1304
- // Fire rebuild without awaiting rebuild() synchronously resets
1305
- // resolvedImage to baseImage before its first internal await, so the
1306
- // retry below immediately uses the base image rather than blocking for
1307
- // the full ~4-minute Playwright build and timing out the connection.
1308
- void this.buildState.rebuild().catch((rebuildErr) => {
1309
- logger.error(
1310
- "Unexpected error in background ext image rebuild",
1311
- rebuildErr instanceof Error ? rebuildErr : undefined,
1312
- );
1313
- });
1314
- logger.warn(
1315
- "Ext image missing (pruned by rolling deploy?), triggering background rebuild and retrying with base image",
1316
- { image: missingImage },
1317
- );
1318
- return await this.reply(input);
1319
- }
1320
- throw err;
1321
- }
1322
- }
1323
- }
1
+ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path, { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { type AppConfig, resolveProjectPath } from "../config.js";
7
+ import { mintCallerToken } from "../core/caller-token.js";
8
+ import { scanOutbox } from "../core/outbox.js";
9
+ import type { ExtImageBuildState } from "../extensions/image-builder.js";
10
+ import { type Logger, logger } from "../logger.js";
11
+ import { getApiKeyFromPiAuthFile } from "../storage/pi-auth.js";
12
+ import type {
13
+ ContainerResult,
14
+ MessageAttachment,
15
+ StoredMessage,
16
+ TokenUsage,
17
+ } from "../types.js";
18
+ import {
19
+ apiSocketDir,
20
+ INNER_RUN_DIR,
21
+ innerApiSocketPath,
22
+ } from "./api-socket.js";
23
+ import { ContainerError } from "./container-error.js";
24
+
25
+ /**
26
+ * In-container mountpoint for the per-message IO dir. The host passes the request
27
+ * payload as `input.json` and the inner container writes its reply as `result.json`
28
+ * here. This is the reply channel that replaces the inner-container attach stream:
29
+ * launching the inner container detached (`docker create` + `docker start`, no
30
+ * attach) is the only pattern that works through the Bun `fetch()`-based body-proxy
31
+ * the cloud agent lane goes through the proxy cannot carry Docker's hijacked
32
+ * attach connection, so an attached run hangs to its idleTimeout (see
33
+ * docs/debug/major/2026-05-25-agent-lane-docker-run-wait-hang-no-chat-response.md).
34
+ */
35
+ const INNER_IO_DIR = "/run/mercury-io";
36
+
37
+ /** Poll interval (ms) while waiting for the inner container's result file. */
38
+ const RESULT_POLL_MS = 150;
39
+ /** Run a `docker inspect` liveness probe every Nth poll (~2s) to fail fast on crash. */
40
+ const LIVENESS_EVERY = 14;
41
+ /** Default timeout for short Docker CLI commands (create, start, inspect, kill). */
42
+ const EXEC_DOCKER_TIMEOUT_MS = 20_000;
43
+ /** Extra seconds added to a caller token's TTL beyond the container timeout, so the token never expires mid-turn. */
44
+ const CALLER_TOKEN_TTL_BUFFER_SECONDS = 60;
45
+
46
+ function sleep(ms: number): Promise<void> {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ /**
51
+ * Run a short, non-streaming `docker` command and capture its result. Used for
52
+ * `create` / `start` / `inspect` / `kill` — all plain request/response Docker API
53
+ * calls (no `/wait`, no attach), which the body-proxy forwards cleanly. Never
54
+ * rejects: a spawn error is surfaced as a non-zero `code` so callers branch on one
55
+ * shape. The timeout guards against a wedged daemon/proxy connection.
56
+ */
57
+ function execDocker(
58
+ args: string[],
59
+ timeoutMs = EXEC_DOCKER_TIMEOUT_MS,
60
+ ): Promise<{
61
+ code: number;
62
+ stdout: string;
63
+ stderr: string;
64
+ timedOut: boolean;
65
+ }> {
66
+ return new Promise((resolve) => {
67
+ const proc = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
68
+ let stdout = "";
69
+ let stderr = "";
70
+ let killed = false;
71
+ let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
72
+ killed = true;
73
+ try {
74
+ proc.kill("SIGKILL");
75
+ } catch {
76
+ // already exited
77
+ }
78
+ }, timeoutMs);
79
+ const done = (code: number, errOverride?: string) => {
80
+ if (timer) {
81
+ clearTimeout(timer);
82
+ timer = null;
83
+ }
84
+ resolve({
85
+ code,
86
+ stdout,
87
+ stderr: errOverride ?? stderr,
88
+ timedOut: killed,
89
+ });
90
+ };
91
+ proc.stdout.on("data", (chunk: Buffer) => {
92
+ stdout += chunk.toString("utf8");
93
+ });
94
+ proc.stderr.on("data", (chunk: Buffer) => {
95
+ stderr += chunk.toString("utf8");
96
+ });
97
+ proc.on("error", (error) => done(1, stderr || String(error)));
98
+ proc.on("close", (code) => done(code ?? 1));
99
+ });
100
+ }
101
+
102
+ // Anthropic OAuth constants — duplicated from console/src/lib/oauth.ts to avoid cross-package imports.
103
+ const ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
104
+ const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
105
+
106
+ type AnthropicOAuthCreds = { access: string; refresh: string; expires: number };
107
+
108
+ // Prevents hammering the OAuth token refresh endpoint on 429 responses — at most
109
+ // one refresh attempt per minute across all spawns in this process lifetime.
110
+ let lastOAuthRefreshAttemptAt = 0;
111
+ const OAUTH_REFRESH_COOLDOWN_MS = 60_000;
112
+
113
+ /**
114
+ * Persist a freshly refreshed Anthropic OAuth token back to the console DB so
115
+ * the next rolling deploy starts with a valid credential.
116
+ * Failures are logged but never throw — caller awaits this so the failure is
117
+ * known before continuing, but spawn is never blocked indefinitely.
118
+ */
119
+ async function pushOAuthTokenToConsole(
120
+ consoleUrl: string,
121
+ internalSecret: string,
122
+ agentId: string,
123
+ creds: AnthropicOAuthCreds,
124
+ ): Promise<void> {
125
+ try {
126
+ const res = await fetch(`${consoleUrl}/api/agent/oauth-token`, {
127
+ method: "POST",
128
+ headers: {
129
+ "Content-Type": "application/json",
130
+ Authorization: `Bearer ${internalSecret}`,
131
+ },
132
+ body: JSON.stringify({
133
+ agentId,
134
+ provider: "anthropic",
135
+ access: creds.access,
136
+ refresh: creds.refresh,
137
+ expires: creds.expires,
138
+ }),
139
+ signal: AbortSignal.timeout(3_000),
140
+ });
141
+ if (!res.ok) {
142
+ const body = await res.text().catch(() => "");
143
+ logger.warn("OAuth token write-back to console failed", {
144
+ status: res.status,
145
+ body: body.slice(0, 200),
146
+ });
147
+ } else {
148
+ logger.debug("OAuth token written back to console DB");
149
+ }
150
+ } catch (err) {
151
+ logger.warn(
152
+ "OAuth token write-back to console failed (network error)",
153
+ err instanceof Error ? err : undefined,
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Fetch the current Anthropic OAuth credential blob from the console DB.
160
+ * Called after an invalid_grant failure — the user may have already reconnected
161
+ * in the console, so the DB may hold a fresh token the container doesn't know about.
162
+ * Returns null on any error (network, auth, not configured).
163
+ */
164
+ async function fetchOAuthTokenFromConsole(
165
+ consoleUrl: string,
166
+ internalSecret: string,
167
+ agentId: string,
168
+ ): Promise<AnthropicOAuthCreds | null> {
169
+ try {
170
+ const url = new URL(`${consoleUrl}/api/agent/oauth-token`);
171
+ url.searchParams.set("agentId", agentId);
172
+ url.searchParams.set("provider", "anthropic");
173
+ const res = await fetch(url.toString(), {
174
+ headers: { Authorization: `Bearer ${internalSecret}` },
175
+ signal: AbortSignal.timeout(3_000),
176
+ });
177
+ if (!res.ok) return null;
178
+ const data = (await res.json()) as {
179
+ access?: unknown;
180
+ refresh?: unknown;
181
+ expires?: unknown;
182
+ };
183
+ if (
184
+ typeof data.access === "string" &&
185
+ data.access &&
186
+ typeof data.refresh === "string" &&
187
+ data.refresh &&
188
+ typeof data.expires === "number"
189
+ ) {
190
+ return {
191
+ access: data.access,
192
+ refresh: data.refresh,
193
+ expires: data.expires,
194
+ };
195
+ }
196
+ return null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ async function refreshAnthropicOAuth(
203
+ creds: AnthropicOAuthCreds,
204
+ ): Promise<AnthropicOAuthCreds> {
205
+ const res = await fetch(ANTHROPIC_TOKEN_URL, {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify({
209
+ grant_type: "refresh_token",
210
+ refresh_token: creds.refresh,
211
+ client_id: ANTHROPIC_CLIENT_ID,
212
+ }),
213
+ });
214
+ if (!res.ok) {
215
+ const body = await res.text().catch(() => "");
216
+ throw new Error(`Anthropic OAuth refresh failed (${res.status}): ${body}`);
217
+ }
218
+ const data = (await res.json()) as {
219
+ access_token?: string;
220
+ refresh_token?: string;
221
+ expires_in?: number;
222
+ };
223
+ if (!data.access_token)
224
+ throw new Error("Anthropic refresh response missing access_token");
225
+ return {
226
+ access: data.access_token,
227
+ refresh: data.refresh_token ?? creds.refresh,
228
+ expires: Date.now() + (data.expires_in ?? 3600) * 1000,
229
+ };
230
+ }
231
+
232
+ /** External calls used by {@link resolveOAuthCredentialForSpawn}; injectable for tests. */
233
+ export type OAuthSpawnDeps = {
234
+ refresh: (creds: AnthropicOAuthCreds) => Promise<AnthropicOAuthCreds>;
235
+ pushToConsole: (
236
+ consoleUrl: string,
237
+ internalSecret: string,
238
+ agentId: string,
239
+ creds: AnthropicOAuthCreds,
240
+ ) => Promise<void>;
241
+ fetchFromConsole: (
242
+ consoleUrl: string,
243
+ internalSecret: string,
244
+ agentId: string,
245
+ ) => Promise<AnthropicOAuthCreds | null>;
246
+ now: () => number;
247
+ };
248
+
249
+ const defaultOAuthSpawnDeps: OAuthSpawnDeps = {
250
+ refresh: refreshAnthropicOAuth,
251
+ pushToConsole: pushOAuthTokenToConsole,
252
+ fetchFromConsole: fetchOAuthTokenFromConsole,
253
+ now: Date.now,
254
+ };
255
+
256
+ export type OAuthSpawnResolution = {
257
+ /** Bare access token to inject as ANTHROPIC_OAUTH_TOKEN into the inner container. */
258
+ access: string;
259
+ /** New full credential blob for the in-process env, or null if unchanged. */
260
+ updatedBlob: string | null;
261
+ };
262
+
263
+ /**
264
+ * Resolve an Anthropic OAuth credential blob into a fresh bare access token at
265
+ * container-spawn time. This is the single chokepoint for the OAuth credential
266
+ * lifecycle in mercury-fork it embeds all three container-side steps:
267
+ * 1. refresh the access token when it is within the 60s expiry lookahead,
268
+ * 2. write the refreshed blob back to the console DB,
269
+ * 4. on `invalid_grant`, pull the current blob from the console DB.
270
+ *
271
+ * Throws only when the credential is genuinely unrecoverable and the user must
272
+ * reconnect. Transient failures (network, 429, cooldown) fall back to the
273
+ * current access token so the spawn is never blocked.
274
+ */
275
+ export async function resolveOAuthCredentialForSpawn(
276
+ parsedCreds: AnthropicOAuthCreds,
277
+ opts: {
278
+ consoleUrl?: string;
279
+ consoleInternalSecret?: string;
280
+ agentId?: string;
281
+ },
282
+ deps: OAuthSpawnDeps = defaultOAuthSpawnDeps,
283
+ ): Promise<OAuthSpawnResolution> {
284
+ let freshAccess = parsedCreds.access;
285
+ let updatedBlob: string | null = null;
286
+
287
+ const needsRefresh = deps.now() + 60_000 > parsedCreds.expires;
288
+ const canRetry =
289
+ deps.now() - lastOAuthRefreshAttemptAt > OAUTH_REFRESH_COOLDOWN_MS;
290
+
291
+ if (needsRefresh && canRetry) {
292
+ try {
293
+ lastOAuthRefreshAttemptAt = deps.now();
294
+ const refreshed = await deps.refresh(parsedCreds);
295
+ updatedBlob = JSON.stringify(refreshed);
296
+ freshAccess = refreshed.access;
297
+ // Persist to console DB so the next rolling deploy reads a valid token.
298
+ if (opts.consoleUrl && opts.consoleInternalSecret && opts.agentId) {
299
+ await deps.pushToConsole(
300
+ opts.consoleUrl,
301
+ opts.consoleInternalSecret,
302
+ opts.agentId,
303
+ refreshed,
304
+ );
305
+ } else if (opts.consoleUrl && !opts.consoleInternalSecret) {
306
+ logger.warn(
307
+ "Anthropic OAuth token refreshed but write-back to console is disabled — MERCURY_CONSOLE_INTERNAL_SECRET is not set; refreshed token will be lost on next container restart",
308
+ );
309
+ }
310
+ } catch (err) {
311
+ const msg = err instanceof Error ? err.message : String(err);
312
+ const isInvalidGrant = msg.includes("invalid_grant");
313
+ if (isInvalidGrant && opts.consoleUrl && opts.consoleInternalSecret) {
314
+ // The refresh token has been invalidated (user likely reconnected in the
315
+ // console). Try to pull the current credential blob from the DB — the
316
+ // console may already have fresh tokens that this container doesn't know about.
317
+ if (opts.agentId) {
318
+ const consoleCreds = await deps.fetchFromConsole(
319
+ opts.consoleUrl,
320
+ opts.consoleInternalSecret,
321
+ opts.agentId,
322
+ );
323
+ if (consoleCreds && consoleCreds.refresh !== parsedCreds.refresh) {
324
+ // Console has a different (newer) refresh token — user already reconnected.
325
+ updatedBlob = JSON.stringify(consoleCreds);
326
+ freshAccess = consoleCreds.access;
327
+ logger.info(
328
+ "Anthropic OAuth invalid_grant recovered from console DB using fresh token",
329
+ );
330
+ } else if (consoleCreds === null) {
331
+ // Console was unreachable — cannot determine if the user has reconnected.
332
+ logger.error(
333
+ "Anthropic OAuth refresh failed with invalid_grant and console credential fetch failed — please reconnect or check connectivity",
334
+ { agentId: opts.agentId },
335
+ );
336
+ throw new Error(
337
+ "Anthropic OAuth token is invalid (invalid_grant) and fresh credentials could not be fetched from the console. Please reconnect your Anthropic account or check the console is reachable.",
338
+ );
339
+ } else {
340
+ // Same token in console — user has not reconnected yet.
341
+ logger.error(
342
+ "Anthropic OAuth refresh failed with invalid_grant and no fresh token is available — user must reconnect in the console",
343
+ { agentId: opts.agentId },
344
+ );
345
+ throw new Error(
346
+ "Anthropic OAuth token is invalid (invalid_grant). Please reconnect your Anthropic account in the console.",
347
+ );
348
+ }
349
+ } else {
350
+ logger.error(
351
+ "Anthropic OAuth refresh failed with invalid_grant; no MERCURY_AGENT_ID set for console fetch",
352
+ );
353
+ throw new Error(
354
+ "Anthropic OAuth token is invalid (invalid_grant). Please reconnect your Anthropic account in the console.",
355
+ );
356
+ }
357
+ } else if (isInvalidGrant) {
358
+ // No console configured — cannot recover; surface the failure.
359
+ logger.error(
360
+ "Anthropic OAuth refresh failed with invalid_grant; re-authentication required",
361
+ );
362
+ throw new Error(
363
+ "Anthropic OAuth token is invalid (invalid_grant). Please reconnect your Anthropic account.",
364
+ );
365
+ } else {
366
+ // Transient error (network, 429, etc.) — current access token may still be valid.
367
+ logger.warn(
368
+ "Anthropic OAuth refresh failed at spawn time; using current access token",
369
+ err instanceof Error ? err : undefined,
370
+ );
371
+ }
372
+ }
373
+ } else if (needsRefresh) {
374
+ logger.warn(
375
+ "Anthropic OAuth token expired; skipping refresh (rate-limit cooldown active)",
376
+ );
377
+ }
378
+
379
+ return { access: freshAccess, updatedBlob };
380
+ }
381
+
382
+ const CONTAINER_LABEL = "mercury.managed=true";
383
+ const AGENT_ID_LABEL_KEY = "mercury.agent-id";
384
+
385
+ const __dirname = dirname(fileURLToPath(import.meta.url));
386
+ const PACKAGE_ROOT = path.join(__dirname, "../..");
387
+
388
+ /** Exit code 137 = SIGKILL (128 + 9), typically from OOM killer */
389
+ const OOM_EXIT_CODE = 137;
390
+
391
+ let _isDockerDesktop: boolean | undefined;
392
+ function isDockerDesktop(): boolean {
393
+ if (_isDockerDesktop !== undefined) return _isDockerDesktop;
394
+ try {
395
+ const result = spawnSync("docker", ["info", "--format", "{{.Name}}"], {
396
+ encoding: "utf8",
397
+ timeout: 10_000,
398
+ stdio: ["pipe", "pipe", "pipe"],
399
+ });
400
+ const name = (result.stdout ?? "").trim();
401
+ const stderr = (result.stderr ?? "").trim();
402
+ if (name === "docker-desktop") {
403
+ _isDockerDesktop = true;
404
+ } else {
405
+ // Fallback: check Docker context or server OS for Docker Desktop indicators
406
+ const ctxResult = spawnSync(
407
+ "docker",
408
+ ["info", "--format", "{{.OperatingSystem}}"],
409
+ { encoding: "utf8", timeout: 10_000, stdio: ["pipe", "pipe", "pipe"] },
410
+ );
411
+ const os = (ctxResult.stdout ?? "").trim();
412
+ _isDockerDesktop =
413
+ os.includes("Docker Desktop") || os.includes("Docker for Windows");
414
+ if (_isDockerDesktop) {
415
+ logger.info("Docker Desktop detected via OperatingSystem field", {
416
+ name,
417
+ os,
418
+ });
419
+ } else {
420
+ logger.debug("Docker Desktop not detected", {
421
+ name,
422
+ os,
423
+ exitCode: result.status,
424
+ stderr: stderr.slice(0, 200),
425
+ });
426
+ }
427
+ }
428
+ } catch {
429
+ _isDockerDesktop = false;
430
+ }
431
+ return _isDockerDesktop;
432
+ }
433
+
434
+ export class AgentContainerRunner {
435
+ // Inner containers now run detached (no long-lived `docker` child process to
436
+ // hold a handle to), so we track only the container name — termination is by
437
+ // `docker kill <name>`, and the per-message poll loop owns cleanup.
438
+ private readonly runningBySpace = new Map<
439
+ string,
440
+ { containerName: string }
441
+ >();
442
+ private readonly abortedSpaces = new Set<string>();
443
+ private readonly timedOutSpaces = new Set<string>();
444
+ private containerCounter = 0;
445
+ private buildState: ExtImageBuildState | undefined = undefined;
446
+ private readonly resolvedApiHost: string;
447
+
448
+ constructor(private readonly config: AppConfig) {
449
+ this.validateImage();
450
+ this.resolvedApiHost = this.resolveApiHost();
451
+ }
452
+
453
+ /**
454
+ * Resolve the API host that inner containers will use to reach us.
455
+ *
456
+ * gVisor (runsc) cannot use Docker's embedded DNS (127.0.0.11 is unreachable
457
+ * from the gVisor network sandbox), so container-name hostnames don't resolve.
458
+ * Previously the outer container joined the shared default bridge (docker0) and
459
+ * handed inner containers its bridge IP but that left the outer reachable by
460
+ * any neighbor on docker0, undercutting per-agent network isolation.
461
+ *
462
+ * Now inner containers reach the API over a per-agent unix socket (see
463
+ * api-socket.ts), so the outer no longer joins docker0. API_URL becomes a dummy
464
+ * (`http://localhost:<port>`) that mrctl ignores once API_SOCKET is set; we keep
465
+ * it non-empty only so mrctl's presence assertion passes. runc/local are
466
+ * unchanged and keep using the real hostname.
467
+ */
468
+ private resolveApiHost(): string {
469
+ const configured = this.config.containerApiHost;
470
+ if (!configured) return "host.docker.internal";
471
+ if (this.config.containerRuntime !== "runsc") return configured;
472
+ // gVisor: dummy host — the real transport is the unix socket (API_SOCKET).
473
+ return "localhost";
474
+ }
475
+
476
+ /** Set a background build state — currentImage() is resolved at each spawn. */
477
+ setBuildState(state: ExtImageBuildState): void {
478
+ this.buildState = state;
479
+ }
480
+
481
+ /** The image to use for container spawns. */
482
+ get image(): string {
483
+ if (this.buildState) return this.buildState.currentImage();
484
+ return this.config.agentContainerImage;
485
+ }
486
+
487
+ /**
488
+ * Warn if using a custom image that might be missing required tools.
489
+ * Known presets (mercury-agent:*) are assumed to be valid.
490
+ */
491
+ private validateImage(): void {
492
+ const image = this.config.agentContainerImage;
493
+
494
+ // Skip validation for known presets
495
+ if (
496
+ image.startsWith("mercury-agent:") ||
497
+ image.includes("/mercury-agent:")
498
+ ) {
499
+ return;
500
+ }
501
+
502
+ // For custom images, log a warning about requirements
503
+ logger.warn("Using custom agent image", {
504
+ image,
505
+ note: `Ensure image has: bun, pi, mrctl${this.config.containerRuntime === "runsc" ? "" : ", bubblewrap (runc mode)"}`,
506
+ docs: "See docs/container-lifecycle.md for custom image requirements",
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Ensure the agent image is available locally, pulling it if needed.
512
+ * Should be called on startup before accepting work.
513
+ */
514
+ async ensureImage(): Promise<void> {
515
+ const image = this.image;
516
+ try {
517
+ execSync(`docker image inspect ${image}`, {
518
+ stdio: "ignore",
519
+ timeout: 10_000,
520
+ });
521
+ logger.debug("Agent image found locally", { image });
522
+ } catch {
523
+ logger.info("Agent image not found locally, pulling...", { image });
524
+ try {
525
+ execSync(`docker pull ${image}`, {
526
+ stdio: "inherit",
527
+ timeout: 300_000,
528
+ });
529
+ logger.info("Agent image pulled successfully", { image });
530
+ } catch {
531
+ throw new Error(
532
+ `Failed to pull agent image: ${image}\n` +
533
+ `Build it locally with: mercury build\n` +
534
+ `Or pull manually: docker pull ${image}`,
535
+ );
536
+ }
537
+ }
538
+ }
539
+
540
+ isRunning(spaceId: string): boolean {
541
+ return this.runningBySpace.has(spaceId);
542
+ }
543
+
544
+ /**
545
+ * Clean up any orphaned containers from previous runs.
546
+ * Should be called on startup before accepting new work.
547
+ */
548
+ async cleanupOrphans(): Promise<number> {
549
+ try {
550
+ const agentId = process.env.MERCURY_AGENT_ID;
551
+ const filter = agentId
552
+ ? `--filter "label=${CONTAINER_LABEL}" --filter "label=${AGENT_ID_LABEL_KEY}=${agentId}"`
553
+ : `--filter "label=${CONTAINER_LABEL}"`;
554
+ // Find containers with our labels (running or stopped)
555
+ const result = execSync(`docker ps -a ${filter} --format "{{.ID}}"`, {
556
+ encoding: "utf8",
557
+ timeout: 10_000,
558
+ }).trim();
559
+
560
+ if (!result) return 0;
561
+
562
+ const containerIds = result.split("\n").filter(Boolean);
563
+ if (containerIds.length === 0) return 0;
564
+
565
+ logger.info("Found orphaned containers, cleaning up", {
566
+ count: containerIds.length,
567
+ });
568
+
569
+ // Force remove all orphaned containers
570
+ execSync(`docker rm -f ${containerIds.join(" ")}`, {
571
+ encoding: "utf8",
572
+ timeout: 30_000,
573
+ });
574
+
575
+ logger.info("Cleaned up orphaned containers", {
576
+ count: containerIds.length,
577
+ });
578
+ return containerIds.length;
579
+ } catch (error) {
580
+ // If docker command fails (e.g., docker not installed), log and continue
581
+ if (error instanceof Error && error.message.includes("ENOENT")) {
582
+ logger.warn("Docker not found, skipping orphan cleanup");
583
+ } else {
584
+ logger.warn(
585
+ "Failed to cleanup orphaned containers",
586
+ error instanceof Error ? error : undefined,
587
+ );
588
+ }
589
+ return 0;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Kill all running containers using docker kill for reliable termination.
595
+ * Note: runningBySpace entries are cleaned up by each reply()'s poll loop.
596
+ * During shutdown the loop may not run before exit, but that's fine —
597
+ * Docker cleans up --rm containers regardless once killed.
598
+ */
599
+ killAll(): void {
600
+ for (const [spaceId, { containerName }] of this.runningBySpace) {
601
+ this.abortedSpaces.add(spaceId);
602
+ try {
603
+ execSync(`docker kill ${containerName}`, { timeout: 5000 });
604
+ } catch {
605
+ // docker kill can fail (container already exited/reaped) — the poll loop
606
+ // observes abortedSpaces and unwinds either way.
607
+ }
608
+ }
609
+ }
610
+
611
+ get activeCount(): number {
612
+ return this.runningBySpace.size;
613
+ }
614
+
615
+ getActiveSpaces(): string[] {
616
+ return [...this.runningBySpace.keys()];
617
+ }
618
+
619
+ abort(spaceId: string): boolean {
620
+ const entry = this.runningBySpace.get(spaceId);
621
+ if (!entry) return false;
622
+
623
+ this.abortedSpaces.add(spaceId);
624
+
625
+ // Use docker kill for reliable container termination; the poll loop observes
626
+ // abortedSpaces and rejects the in-flight reply().
627
+ try {
628
+ execSync(`docker kill ${entry.containerName}`, { timeout: 5000 });
629
+ } catch {
630
+ // docker kill can fail (container already exited/reaped) — abortedSpaces
631
+ // still unwinds the poll loop.
632
+ }
633
+ return true;
634
+ }
635
+
636
+ private generateContainerName(): string {
637
+ const id = ++this.containerCounter;
638
+ const timestamp = Date.now();
639
+ const agentId = process.env.MERCURY_AGENT_ID;
640
+ return agentId
641
+ ? `mercury-${agentId}-${timestamp}-${id}`
642
+ : `mercury-${timestamp}-${id}`;
643
+ }
644
+
645
+ async reply(input: {
646
+ spaceId: string;
647
+ spaceWorkspace: string;
648
+ messages: StoredMessage[];
649
+ anchorMessages?: StoredMessage[];
650
+ prompt: string;
651
+ callerId: string;
652
+ callerRole?: string;
653
+ authorName?: string;
654
+ attachments?: MessageAttachment[];
655
+ preferences?: Array<{ key: string; value: string }>;
656
+ extraEnv?: Record<string, string>;
657
+ claimedEnvSources?: Set<string>;
658
+ }): Promise<ContainerResult> {
659
+ const globalDir = path.resolve(this.config.globalDir);
660
+ const spacesRoot = path.resolve(this.config.spacesDir);
661
+
662
+ fs.mkdirSync(globalDir, { recursive: true });
663
+ fs.mkdirSync(spacesRoot, { recursive: true });
664
+ try {
665
+ execFileSync("chown", ["-R", "1000:1000", globalDir], { stdio: "pipe" });
666
+ } catch {
667
+ // CAP_CHOWN may be unavailable (--cap-drop=ALL without --cap-add=CHOWN).
668
+ // Skills are installed world-readable, so the inner container (uid 1000)
669
+ // can still read them. New containers should have --cap-add=CHOWN.
670
+ logger.warn(
671
+ "chown globalDir failed (CAP_CHOWN unavailable), continuing",
672
+ { globalDir },
673
+ );
674
+ }
675
+
676
+ const authFromPi = await getApiKeyFromPiAuthFile({
677
+ provider: this.config.modelProvider,
678
+ authPath: this.config.authPath ?? path.join(globalDir, "auth.json"),
679
+ });
680
+
681
+ // Env vars that should never be passed to containers
682
+ const BLOCKED_ENV_VARS = new Set([
683
+ "MERCURY_API_SECRET",
684
+ // Host-only: signs per-turn caller tokens. Injecting it would let the
685
+ // agent forge a token for any caller, defeating the whole mechanism.
686
+ "MERCURY_CALLER_TOKEN_KEY",
687
+ // Host-only: the inner→outer API socket path is set by code per spawn;
688
+ // never let an agent override which socket mrctl targets.
689
+ "MERCURY_API_SOCKET",
690
+ "MERCURY_CHAT_API_KEY",
691
+ "MERCURY_ADMINS",
692
+ // Host-only: affects `docker run` flags, not the agent process inside the container
693
+ "MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
694
+ // Host-only: selects the OCI runtime for `docker run --runtime`; not meaningful inside the container
695
+ "MERCURY_CONTAINER_RUNTIME",
696
+ // Host-only: resolved volume mountpoint on the host; inner containers don't need it
697
+ "MERCURY_HOST_DATA_DIR",
698
+ "MERCURY_SLACK_BOT_TOKEN",
699
+ "MERCURY_SLACK_SIGNING_SECRET",
700
+ "MERCURY_DISCORD_BOT_TOKEN",
701
+ "MERCURY_DISCORD_GATEWAY_SECRET",
702
+ "MERCURY_TELEGRAM_BOT_TOKEN",
703
+ "MERCURY_TELEGRAM_WEBHOOK_SECRET_TOKEN",
704
+ "MERCURY_TEAMS_APP_ID",
705
+ "MERCURY_TEAMS_APP_PASSWORD",
706
+ "MERCURY_WHATSAPP_AUTH_DIR",
707
+ ]);
708
+
709
+ // Pass MERCURY_* vars to container with prefix stripped, excluding blocked vars
710
+ const claimed = input.claimedEnvSources;
711
+ const passthroughEnvPairs = Object.entries(process.env)
712
+ .filter(
713
+ (entry): entry is [string, string] =>
714
+ entry[0].startsWith("MERCURY_") &&
715
+ entry[1] !== undefined &&
716
+ !BLOCKED_ENV_VARS.has(entry[0]) &&
717
+ !claimed?.has(entry[0]),
718
+ )
719
+ .map(([key, value]) => ({
720
+ key: key.replace("MERCURY_", ""),
721
+ value: value,
722
+ }));
723
+
724
+ // Legacy path: older console versions stored the OAuth credential blob in
725
+ // MERCURY_ANTHROPIC_API_KEY instead of MERCURY_ANTHROPIC_OAUTH_TOKEN.
726
+ // Current console uses MERCURY_ANTHROPIC_OAUTH_TOKEN (handled below), but
727
+ // keep this guard so agents provisioned before the migration don't break.
728
+ const anthApiKeyIdx = passthroughEnvPairs.findIndex(
729
+ (p) =>
730
+ p.key === "ANTHROPIC_API_KEY" && p.value.trimStart().startsWith("{"),
731
+ );
732
+ if (anthApiKeyIdx !== -1) {
733
+ try {
734
+ const raw = passthroughEnvPairs[anthApiKeyIdx]?.value ?? "";
735
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
736
+ const access =
737
+ typeof parsed.access === "string" ? parsed.access : undefined;
738
+ if (access) {
739
+ passthroughEnvPairs.splice(anthApiKeyIdx, 1);
740
+ passthroughEnvPairs.push({
741
+ key: "ANTHROPIC_OAUTH_TOKEN",
742
+ value: access,
743
+ });
744
+ }
745
+ } catch {
746
+ // Not valid JSON leave as-is and let pi handle/reject it
747
+ }
748
+ }
749
+
750
+ // MERCURY_ANTHROPIC_OAUTH_TOKEN now carries a full credential blob
751
+ // ({"access":"...","refresh":"...","expires":...}) so the fork can refresh
752
+ // the access token at each spawn instead of relying on the frozen value
753
+ // injected when the outer container started. Remove the blob unconditionally
754
+ // to ensure raw JSON never leaks into the inner container, then push a fresh
755
+ // bare token (or the current token if refresh fails / is rate-limited).
756
+ const anthOauthIdx = passthroughEnvPairs.findIndex(
757
+ (p) =>
758
+ p.key === "ANTHROPIC_OAUTH_TOKEN" &&
759
+ p.value.trimStart().startsWith("{"),
760
+ );
761
+ if (anthOauthIdx !== -1) {
762
+ const raw = passthroughEnvPairs[anthOauthIdx]?.value ?? "";
763
+ passthroughEnvPairs.splice(anthOauthIdx, 1);
764
+ let parsedCreds: AnthropicOAuthCreds | undefined;
765
+ try {
766
+ parsedCreds = JSON.parse(raw) as AnthropicOAuthCreds;
767
+ } catch {
768
+ logger.warn("Anthropic OAuth blob corrupt; skipping token injection");
769
+ }
770
+ if (parsedCreds) {
771
+ const { access, updatedBlob } = await resolveOAuthCredentialForSpawn(
772
+ parsedCreds,
773
+ {
774
+ consoleUrl: this.config.consoleUrl,
775
+ consoleInternalSecret: this.config.consoleInternalSecret,
776
+ agentId: process.env.MERCURY_AGENT_ID,
777
+ },
778
+ );
779
+ // Update outer container's in-process env so subsequent spawns within
780
+ // this process lifetime start with the fresh blob (avoids re-refreshing
781
+ // a token that was just fetched). Not persisted across process restarts.
782
+ if (updatedBlob) {
783
+ process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN = updatedBlob;
784
+ }
785
+ passthroughEnvPairs.push({
786
+ key: "ANTHROPIC_OAUTH_TOKEN",
787
+ value: access,
788
+ });
789
+ }
790
+ }
791
+
792
+ // Check for pi auth file fallback for Anthropic
793
+ const hasAnthropicKey = passthroughEnvPairs.some(
794
+ (p) => p.key === "ANTHROPIC_API_KEY" || p.key === "ANTHROPIC_OAUTH_TOKEN",
795
+ );
796
+ if (
797
+ !hasAnthropicKey &&
798
+ this.config.modelProvider === "anthropic" &&
799
+ authFromPi
800
+ ) {
801
+ passthroughEnvPairs.push({
802
+ key: "ANTHROPIC_OAUTH_TOKEN",
803
+ value: authFromPi,
804
+ });
805
+ }
806
+
807
+ const envPairs = [
808
+ // Internal vars (set by code, not from env)
809
+ { key: "HOME", value: "/home/mercury" },
810
+ {
811
+ key: "PATH",
812
+ value:
813
+ "/home/mercury/.local/bin:/home/mercury/.bun/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin",
814
+ },
815
+ { key: "PI_CODING_AGENT_DIR", value: "/home/mercury/.pi/agent" },
816
+ { key: "CALLER_ID", value: input.callerId },
817
+ { key: "SPACE_ID", value: input.spaceId },
818
+ {
819
+ key: "API_URL",
820
+ value: `http://${this.resolvedApiHost}:${this.config.port}`,
821
+ },
822
+ // API secret for mrctl auth from inside containers
823
+ { key: "API_SECRET", value: this.config.apiSecret ?? "" },
824
+ // Per-turn caller token: authoritative, unspoofable identity for the host
825
+ // API. Bound to this caller+space, short-lived. The signing key stays on
826
+ // the host, so the agent can read this token but cannot forge another.
827
+ {
828
+ key: "CALLER_TOKEN",
829
+ value: mintCallerToken(
830
+ {
831
+ callerId: input.callerId,
832
+ spaceId: input.spaceId,
833
+ exp:
834
+ Math.floor(Date.now() / 1000) +
835
+ Math.ceil((this.config.containerTimeoutMs ?? 600_000) / 1000) +
836
+ CALLER_TOKEN_TTL_BUFFER_SECONDS,
837
+ },
838
+ this.config.callerTokenKey,
839
+ ),
840
+ },
841
+ // gVisor: inner containers reach the API over a per-agent unix socket
842
+ // (the outer is off docker0). mrctl uses this transport when set; API_URL
843
+ // host/port above are then ignored. Absent for runc/local.
844
+ ...(this.config.containerRuntime === "runsc"
845
+ ? [{ key: "API_SOCKET", value: innerApiSocketPath() }]
846
+ : []),
847
+ // Passthrough vars (MERCURY_* with prefix stripped)
848
+ ...passthroughEnvPairs,
849
+ // Host-resolved model chain (overrides any stale MODEL_CHAIN from passthrough)
850
+ {
851
+ key: "MODEL_CHAIN",
852
+ value: JSON.stringify(this.config.resolvedModelChain),
853
+ },
854
+ {
855
+ key: "MODEL_RETRY_MAX_PER_LEG",
856
+ value: String(this.config.modelMaxRetriesPerLeg),
857
+ },
858
+ {
859
+ key: "MODEL_CHAIN_BUDGET_MS",
860
+ value: String(this.config.effectiveModelChainBudgetMs),
861
+ },
862
+ {
863
+ key: "MODEL_CHAIN_CAPABILITIES",
864
+ value: JSON.stringify(this.config.resolvedModelChainCapabilities),
865
+ },
866
+ {
867
+ key: "OVERRIDE_PI_SYSTEM_PROMPT",
868
+ value: this.config.overridePiSystemPrompt ? "true" : "false",
869
+ },
870
+ ].filter((x): x is { key: string; value: string } => Boolean(x.value));
871
+
872
+ const containerName = this.generateContainerName();
873
+
874
+ // Resolve docs paths for self-documenting agent
875
+ const docsDir = path.resolve(PACKAGE_ROOT, "docs");
876
+ const readmePath = path.resolve(PACKAGE_ROOT, "README.md");
877
+
878
+ // In cloud deployments the outer container runs with a Docker named volume at
879
+ // config.globalDir / config.spacesDir. When those paths are passed as bind-mount
880
+ // sources to the host Docker daemon (via the Docker socket), the daemon treats them
881
+ // as HOST filesystem paths — a different directory from the volume. Setting
882
+ // MERCURY_HOST_DATA_DIR to the volume's actual host-side mountpoint
883
+ // (/var/lib/docker/volumes/<name>/_data) lets inner containers mount the same data
884
+ // the outer container reads and writes. Falls back to config paths for local dev
885
+ // where no named volume is in use.
886
+ const hostDataDir = process.env.MERCURY_HOST_DATA_DIR;
887
+ const innerGlobalDir = hostDataDir
888
+ ? path.join(hostDataDir, "global")
889
+ : globalDir;
890
+ const innerSpacesRoot = hostDataDir
891
+ ? path.join(hostDataDir, "spaces")
892
+ : spacesRoot;
893
+
894
+ // Mount only the specific space directory for isolation
895
+ const spaceDir = path.resolve(spacesRoot, input.spaceId);
896
+ const innerSpaceDir = path.join(innerSpacesRoot, input.spaceId);
897
+ fs.mkdirSync(spaceDir, { recursive: true });
898
+ try {
899
+ execFileSync("chown", ["-R", "1000:1000", spaceDir], { stdio: "pipe" });
900
+ } catch {
901
+ logger.warn(
902
+ "chown spaceDir failed (CAP_CHOWN unavailable), falling back to chmod 777",
903
+ { spaceDir },
904
+ );
905
+ try {
906
+ fs.chmodSync(spaceDir, 0o777);
907
+ } catch {
908
+ logger.warn(
909
+ "chmod spaceDir also failed, inner container may lack write access",
910
+ { spaceDir },
911
+ );
912
+ }
913
+ }
914
+
915
+ const agentId = process.env.MERCURY_AGENT_ID;
916
+ // `docker create` (not `run`) and no `-i`: the container is started detached
917
+ // and communicates over the mounted IO dir, never the attach stream. This is
918
+ // the only launch shape that survives the Bun body-proxy on the cloud agent
919
+ // lane (it cannot proxy Docker's hijacked attach connection).
920
+ const args = [
921
+ "create",
922
+ "--rm",
923
+ "--name",
924
+ containerName,
925
+ "--label",
926
+ CONTAINER_LABEL,
927
+ ...(agentId ? ["--label", `${AGENT_ID_LABEL_KEY}=${agentId}`] : []),
928
+ ];
929
+
930
+ if (
931
+ this.config.containerNetwork &&
932
+ this.config.containerRuntime !== "runsc"
933
+ ) {
934
+ // runc: join the shared network so inner containers can resolve the
935
+ // outer container by DNS name and reach external APIs.
936
+ args.push("--network", this.config.containerNetwork);
937
+ } else {
938
+ // Default bridge (no --network flag). gVisor always lands here because
939
+ // user-defined Docker networks break gVisor's outbound DNS (Docker's
940
+ // embedded resolver at 127.0.0.11 is unreachable from gVisor). Inner
941
+ // containers keep docker0 for outbound DNS/HTTPS; the inner→outer API
942
+ // callback rides the per-agent unix socket (API_SOCKET), not docker0.
943
+ args.push("--add-host", "host.docker.internal:host-gateway");
944
+ }
945
+
946
+ // Per-message IO dir — the detached reply channel. Mirrors the global/spaces
947
+ // host-path translation: the host bind source must live under the agent's own
948
+ // data volume (`hostDataDir`) so it satisfies the body-proxy's RW-bind
949
+ // allowlist (`/var/lib/docker/volumes/mercury-<agentId>-data/...`). The outer
950
+ // writes input.json here; the inner writes result.json back.
951
+ const ioLocalDir = path.join(
952
+ resolveProjectPath(this.config.dataDir),
953
+ "io",
954
+ containerName,
955
+ );
956
+ const ioHostDir = hostDataDir
957
+ ? path.join(hostDataDir, "io", containerName)
958
+ : ioLocalDir;
959
+
960
+ args.push(
961
+ "-v",
962
+ `${innerSpaceDir}:/spaces/${input.spaceId}`,
963
+ "-v",
964
+ `${innerGlobalDir}:/home/mercury/.pi/agent`,
965
+ "-v",
966
+ `${readmePath}:/docs/mercury/README.md:ro`,
967
+ "-v",
968
+ `${docsDir}:/docs/mercury/docs:ro`,
969
+ "-v",
970
+ `${ioHostDir}:${INNER_IO_DIR}`,
971
+ "-e",
972
+ `IO_DIR=${INNER_IO_DIR}`,
973
+ );
974
+
975
+ if (this.config.containerRuntime === "runsc") {
976
+ // Mount the per-agent run dir so the inner container can reach the outer's
977
+ // API unix socket (api-<hostname>.sock, created in main.ts). Mirrors the
978
+ // global/spaces host-path translation: the host-side source is the data
979
+ // volume's run dir, exposed at /run/mercury inside the inner container.
980
+ // Resolve dataDir with the same helper main.ts uses to create the socket,
981
+ // so the bind source and the listener never disagree on the run-dir path.
982
+ const localRunDir = apiSocketDir(resolveProjectPath(this.config.dataDir));
983
+ const innerRunDir = hostDataDir ? apiSocketDir(hostDataDir) : localRunDir;
984
+ // Ensure the host bind source exists (main.ts created it at startup; this
985
+ // guards against config drift). Created in the in-container data dir, which
986
+ // is the same volume the host path resolves to.
987
+ fs.mkdirSync(localRunDir, { recursive: true });
988
+ args.push("-v", `${innerRunDir}:${INNER_RUN_DIR}`);
989
+ // gVisor: intercepts all syscalls at a user-space kernel boundary — no bwrap needed.
990
+ // Restores full Docker hardening (SYS_ADMIN relaxation not required).
991
+ // CONTAINER_RUNTIME=runsc is passed explicitly (stripped prefix) so container-entry
992
+ // skips the bwrap spawn path.
993
+ args.push(
994
+ "--runtime=runsc",
995
+ "--cap-drop=ALL",
996
+ "--security-opt=no-new-privileges",
997
+ "--memory=2g",
998
+ "--cpus=2",
999
+ "--pids-limit=512",
1000
+ "-e",
1001
+ "CONTAINER_RUNTIME=runsc",
1002
+ );
1003
+ } else if (this.config.containerBwrapDockerCompat || isDockerDesktop()) {
1004
+ if (this.config.containerBwrapDockerCompat) {
1005
+ logger.info(
1006
+ "Enabling bwrap Docker compat with --privileged (containerBwrapDockerCompat=true)",
1007
+ );
1008
+ args.push("--privileged");
1009
+ } else {
1010
+ logger.info(
1011
+ "Enabling bwrap Docker compat (seccomp/apparmor/SYS_ADMIN) for Docker Desktop",
1012
+ );
1013
+ args.push(
1014
+ "--security-opt",
1015
+ "seccomp=unconfined",
1016
+ "--security-opt",
1017
+ "apparmor=unconfined",
1018
+ "--cap-add",
1019
+ "SYS_ADMIN",
1020
+ );
1021
+ }
1022
+ }
1023
+
1024
+ for (const { key, value } of envPairs) {
1025
+ args.push("-e", `${key}=${value}`);
1026
+ }
1027
+
1028
+ // Extension env vars from before_container hooks
1029
+ if (input.extraEnv) {
1030
+ for (const [key, value] of Object.entries(input.extraEnv)) {
1031
+ args.push("-e", `${key}=${value}`);
1032
+ }
1033
+ }
1034
+
1035
+ const buildingNow = this.buildState?.building ?? false;
1036
+ const spawnImage = this.image;
1037
+ if (buildingNow) {
1038
+ logger.info("Ext image still building, spawning with base image", {
1039
+ image: spawnImage,
1040
+ });
1041
+ }
1042
+ args.push(spawnImage);
1043
+
1044
+ // Per-run nonce — retained in the payload for the inner container's legacy
1045
+ // stdout-marker fallback (used only for direct/manual attach against a real
1046
+ // daemon; the detached cloud path reads result.json instead).
1047
+ const nonce = randomBytes(8).toString("hex");
1048
+
1049
+ const payload = {
1050
+ ...input,
1051
+ messages: input.messages,
1052
+ anchorMessages: input.anchorMessages,
1053
+ spaceWorkspace: input.spaceWorkspace
1054
+ .replace(spacesRoot, "/spaces")
1055
+ .replaceAll("\\", "/"),
1056
+ callerRole: input.callerRole ?? "member",
1057
+ authorName: input.authorName,
1058
+ nonce,
1059
+ };
1060
+
1061
+ // Create child logger with context for this container run
1062
+ const log: Logger = logger.child({
1063
+ spaceId: input.spaceId,
1064
+ container: containerName,
1065
+ });
1066
+
1067
+ const startTime = Date.now();
1068
+
1069
+ // Stage the request payload where the inner container will read it, and make
1070
+ // the dir writable by the inner uid (1000) so it can drop result.json back.
1071
+ fs.mkdirSync(ioLocalDir, { recursive: true });
1072
+ fs.writeFileSync(
1073
+ path.join(ioLocalDir, "input.json"),
1074
+ JSON.stringify(payload),
1075
+ );
1076
+ try {
1077
+ execFileSync("chown", ["-R", "1000:1000", ioLocalDir], { stdio: "pipe" });
1078
+ } catch {
1079
+ try {
1080
+ fs.chmodSync(ioLocalDir, 0o777);
1081
+ } catch {
1082
+ logger.warn("chown/chmod ioDir failed; inner may not write result", {
1083
+ ioLocalDir,
1084
+ });
1085
+ }
1086
+ }
1087
+
1088
+ const resultPath = path.join(ioLocalDir, "result.json");
1089
+ const cleanupIo = () => {
1090
+ try {
1091
+ fs.rmSync(ioLocalDir, { recursive: true, force: true });
1092
+ } catch {
1093
+ // best effort — orphaned IO dirs are harmless and small
1094
+ }
1095
+ };
1096
+
1097
+ // Create the container (detached). `docker create` is where a pruned/missing
1098
+ // image surfaces (exit 125, "No such image"/"Unable to find image"), so this
1099
+ // error shape feeds replyWithRetry's rebuild-and-retry path unchanged.
1100
+ const created = await execDocker(args);
1101
+ if (created.code !== 0) {
1102
+ cleanupIo();
1103
+ const output = created.timedOut
1104
+ ? `docker create timed out after ${Math.round(EXEC_DOCKER_TIMEOUT_MS / 1000)}s — Docker daemon may be unresponsive`
1105
+ : created.stderr ||
1106
+ created.stdout ||
1107
+ `docker create exited with code ${created.code} (no output)`;
1108
+ log.error("docker create failed", {
1109
+ exitCode: created.code,
1110
+ timedOut: created.timedOut,
1111
+ output,
1112
+ });
1113
+ throw ContainerError.error(created.code, output);
1114
+ }
1115
+
1116
+ this.runningBySpace.set(input.spaceId, { containerName });
1117
+ const deadline = startTime + this.config.containerTimeoutMs;
1118
+
1119
+ try {
1120
+ // Start detached — no `-a`/attach, so the body-proxy only sees
1121
+ // POST /containers/<id>/start (plain request/response). Returns immediately.
1122
+ const started = await execDocker(["start", containerName]);
1123
+ if (started.code !== 0) {
1124
+ const output = started.timedOut
1125
+ ? `docker start timed out after ${Math.round(EXEC_DOCKER_TIMEOUT_MS / 1000)}s — Docker daemon may be unresponsive`
1126
+ : started.stderr ||
1127
+ started.stdout ||
1128
+ `docker start exited with code ${started.code} (no output)`;
1129
+ log.error("docker start failed", {
1130
+ exitCode: started.code,
1131
+ timedOut: started.timedOut,
1132
+ containerName,
1133
+ output,
1134
+ });
1135
+ throw ContainerError.error(started.code, output);
1136
+ }
1137
+ log.info("Container started", { event: "container.start" });
1138
+
1139
+ // Poll the mounted result file. The inner container writes result.json
1140
+ // atomically (tmp + rename) on every outcome, so its presence means a
1141
+ // complete payload. A periodic `docker inspect` fails fast if the container
1142
+ // died without writing one (hard crash / OOM) instead of waiting out the
1143
+ // full timeout.
1144
+ let iter = 0;
1145
+ while (true) {
1146
+ if (fs.existsSync(resultPath)) {
1147
+ return this.consumeResult(resultPath, input, startTime, log);
1148
+ }
1149
+
1150
+ if (this.timedOutSpaces.has(input.spaceId) || Date.now() >= deadline) {
1151
+ this.timedOutSpaces.delete(input.spaceId);
1152
+ await execDocker(["kill", containerName]);
1153
+ // The kill loses the race against a just-written result occasionally.
1154
+ if (fs.existsSync(resultPath)) {
1155
+ return this.consumeResult(resultPath, input, startTime, log);
1156
+ }
1157
+ log.warn("Container exited", {
1158
+ event: "container.end",
1159
+ durationMs: Date.now() - startTime,
1160
+ reason: "timeout",
1161
+ });
1162
+ throw ContainerError.timeout(input.spaceId);
1163
+ }
1164
+
1165
+ if (this.abortedSpaces.has(input.spaceId)) {
1166
+ this.abortedSpaces.delete(input.spaceId);
1167
+ await execDocker(["kill", containerName]);
1168
+ log.info("Container exited", {
1169
+ event: "container.end",
1170
+ durationMs: Date.now() - startTime,
1171
+ reason: "aborted",
1172
+ });
1173
+ throw ContainerError.aborted(input.spaceId);
1174
+ }
1175
+
1176
+ if (++iter % LIVENESS_EVERY === 0) {
1177
+ const crash = await this.detectCrash(
1178
+ containerName,
1179
+ resultPath,
1180
+ input.spaceId,
1181
+ );
1182
+ if (crash) {
1183
+ log.error("Container exited", {
1184
+ event: "container.end",
1185
+ exitCode: crash.exitCode,
1186
+ durationMs: Date.now() - startTime,
1187
+ reason: crash.reason,
1188
+ });
1189
+ throw crash;
1190
+ }
1191
+ // detectCrash may have observed result.json appear during its grace wait
1192
+ if (fs.existsSync(resultPath)) {
1193
+ return this.consumeResult(resultPath, input, startTime, log);
1194
+ }
1195
+ }
1196
+
1197
+ await sleep(RESULT_POLL_MS);
1198
+ }
1199
+ } finally {
1200
+ this.runningBySpace.delete(input.spaceId);
1201
+ cleanupIo();
1202
+ }
1203
+ }
1204
+
1205
+ /**
1206
+ * Parse the inner container's result file and build the ContainerResult.
1207
+ * `{ ok: false }` means the container caught its own failure and reported it;
1208
+ * surface it as an error so callers see the real message rather than a generic
1209
+ * crash.
1210
+ */
1211
+ private consumeResult(
1212
+ resultPath: string,
1213
+ input: { spaceId: string; spaceWorkspace: string },
1214
+ startTime: number,
1215
+ log: Logger,
1216
+ ): ContainerResult {
1217
+ let parsed: {
1218
+ ok?: boolean;
1219
+ reply?: string;
1220
+ usage?: TokenUsage;
1221
+ error?: string;
1222
+ };
1223
+ try {
1224
+ parsed = JSON.parse(fs.readFileSync(resultPath, "utf8"));
1225
+ } catch (e) {
1226
+ throw new Error(
1227
+ `Malformed container result: ${e instanceof Error ? e.message : String(e)}`,
1228
+ );
1229
+ }
1230
+
1231
+ if (parsed.ok === false) {
1232
+ throw ContainerError.error(
1233
+ 1,
1234
+ parsed.error ?? "container reported failure",
1235
+ );
1236
+ }
1237
+
1238
+ log.info("Container exited", {
1239
+ event: "container.end",
1240
+ exitCode: 0,
1241
+ durationMs: Date.now() - startTime,
1242
+ });
1243
+
1244
+ const replyText = parsed.reply ?? "Done.";
1245
+ const files = scanOutbox(input.spaceWorkspace, startTime);
1246
+ return { reply: replyText, files, usage: parsed.usage };
1247
+ }
1248
+
1249
+ /**
1250
+ * Liveness probe used while polling for the result file. Returns a
1251
+ * ContainerError when the inner container is gone/exited without producing a
1252
+ * result (a hard crash the container couldn't catch — e.g. OOM kill, gVisor
1253
+ * panic), or `null` if it's still running. Grants a short grace so the
1254
+ * exit→result-write→--rm-reap race resolves in favour of a real result.
1255
+ */
1256
+ private async detectCrash(
1257
+ containerName: string,
1258
+ resultPath: string,
1259
+ spaceId: string,
1260
+ ): Promise<ContainerError | null> {
1261
+ const insp = await execDocker([
1262
+ "inspect",
1263
+ "-f",
1264
+ "{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}",
1265
+ containerName,
1266
+ ]);
1267
+
1268
+ // Container still present and running — no crash.
1269
+ if (insp.code === 0 && insp.stdout.trim().startsWith("running")) {
1270
+ return null;
1271
+ }
1272
+
1273
+ // Either inspect 404'd (--rm already reaped an exited container) or the
1274
+ // container is in a terminal state. Give the result file a moment to land.
1275
+ await sleep(RESULT_POLL_MS);
1276
+ if (fs.existsSync(resultPath)) return null;
1277
+
1278
+ if (insp.code !== 0) {
1279
+ // Reaped without a result exit code is unrecoverable post-reap.
1280
+ return ContainerError.error(
1281
+ 1,
1282
+ "inner container exited without producing a result (possible crash)",
1283
+ );
1284
+ }
1285
+
1286
+ const [, exitStr, oom] = insp.stdout.trim().split("|");
1287
+ const exitCode = Number.parseInt(exitStr ?? "1", 10) || 1;
1288
+ if (oom === "true" || exitCode === OOM_EXIT_CODE) {
1289
+ return ContainerError.oom(spaceId, exitCode);
1290
+ }
1291
+ return ContainerError.error(
1292
+ exitCode,
1293
+ "inner container exited without producing a result",
1294
+ );
1295
+ }
1296
+
1297
+ /**
1298
+ * Spawn a container for a reply, with automatic recovery if the derived ext
1299
+ * image was pruned by a rolling deploy.
1300
+ *
1301
+ * Docker returns exit code 125 with "No such image" or "Unable to find image"
1302
+ * in stderr when an image that existed at build time has since been pruned.
1303
+ * On that specific error we trigger a background rebuild and immediately retry
1304
+ * with the base image so the current message is not dropped.
1305
+ *
1306
+ * rebuild() synchronously resets resolvedImage baseImage before its first
1307
+ * await, so by the time we call reply() again this.image already returns the
1308
+ * base image. The rebuild completes in the background; subsequent spawns use
1309
+ * the fresh derived image once it is ready.
1310
+ */
1311
+ async replyWithRetry(
1312
+ input: Parameters<AgentContainerRunner["reply"]>[0],
1313
+ ): Promise<ContainerResult> {
1314
+ try {
1315
+ return await this.reply(input);
1316
+ } catch (err) {
1317
+ if (
1318
+ err instanceof ContainerError &&
1319
+ err.reason === "error" &&
1320
+ err.exitCode === 125 &&
1321
+ this.buildState &&
1322
+ (err.message.includes("No such image") ||
1323
+ err.message.includes("Unable to find image"))
1324
+ ) {
1325
+ // Capture before rebuild() resets resolvedImage to baseImage
1326
+ const missingImage = this.buildState.currentImage();
1327
+ // Fire rebuild without awaiting — rebuild() synchronously resets
1328
+ // resolvedImage to baseImage before its first internal await, so the
1329
+ // retry below immediately uses the base image rather than blocking for
1330
+ // the full ~4-minute Playwright build and timing out the connection.
1331
+ void this.buildState.rebuild().catch((rebuildErr) => {
1332
+ logger.error(
1333
+ "Unexpected error in background ext image rebuild",
1334
+ rebuildErr instanceof Error ? rebuildErr : undefined,
1335
+ );
1336
+ });
1337
+ logger.warn(
1338
+ "Ext image missing (pruned by rolling deploy?), triggering background rebuild and retrying with base image",
1339
+ { image: missingImage },
1340
+ );
1341
+ return await this.reply(input);
1342
+ }
1343
+ throw err;
1344
+ }
1345
+ }
1346
+ }