mercury-agent 0.18.2 → 0.20.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.
Files changed (107) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +25 -14
  4. package/docs/configuration.md +139 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  9. package/docs/goals/rehearsal-bench/decisions.md +41 -3
  10. package/docs/goals/rehearsal-bench/roadmap.md +3 -1
  11. package/docs/goals/release-gate/decisions.md +86 -0
  12. package/docs/goals/release-gate/roadmap.md +36 -3
  13. package/docs/live-testing.md +22 -0
  14. package/docs/pending-verification.md +178 -0
  15. package/docs/permissions.md +1 -1
  16. package/docs/profile-guide.md +28 -8
  17. package/examples/extensions/archive/backends/local.ts +6 -3
  18. package/examples/extensions/archive/queue.ts +51 -10
  19. package/examples/extensions/feed-watch/config.ts +42 -2
  20. package/examples/extensions/feed-watch/digest.ts +4 -7
  21. package/examples/extensions/feed-watch/items.ts +18 -7
  22. package/examples/extensions/feed-watch/match.ts +125 -9
  23. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  24. package/examples/extensions/feed-watch/watch.ts +39 -5
  25. package/examples/extensions/gws/index.ts +126 -8
  26. package/examples/extensions/longview/hook.ts +72 -7
  27. package/examples/extensions/longview/index.ts +2 -0
  28. package/examples/extensions/morning/README.md +26 -15
  29. package/examples/extensions/morning/index.ts +29 -6
  30. package/examples/extensions/morning/lib/hosts.ts +16 -0
  31. package/examples/extensions/morning/lib/morning.ts +78 -0
  32. package/examples/extensions/morning/lib/upload.ts +584 -0
  33. package/examples/extensions/morning/skill/SKILL.md +34 -4
  34. package/examples/extensions/napkin/index.ts +12 -3
  35. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  36. package/examples/extensions/overview/index.ts +20 -0
  37. package/examples/extensions/overview/skill/SKILL.md +9 -1
  38. package/examples/extensions/pinchtab/index.ts +36 -7
  39. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  40. package/examples/profiles/_template/AGENTS.md +6 -1
  41. package/examples/profiles/football-reporter/AGENTS.md +16 -4
  42. package/examples/profiles/football-reporter/README.md +1 -1
  43. package/examples/profiles/football-reporter/config.yaml +42 -6
  44. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  45. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  46. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  47. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  48. package/examples/profiles/football-reporter/standard.json +45 -8
  49. package/package.json +11 -6
  50. package/resources/skills/tasks/SKILL.md +17 -1
  51. package/resources/templates/AGENTS.md +4 -3
  52. package/resources/templates/mercury.example.yaml +13 -4
  53. package/src/adapters/whatsapp-ingress.ts +15 -3
  54. package/src/agent/container-entry.ts +299 -29
  55. package/src/agent/container-env.ts +26 -8
  56. package/src/agent/container-runner.ts +497 -76
  57. package/src/agent/image-contract.ts +77 -0
  58. package/src/agent/image-manifest.ts +180 -0
  59. package/src/agent/image-refresh.ts +318 -0
  60. package/src/cli/mercury.ts +225 -28
  61. package/src/cli/mrctl-http.ts +5 -0
  62. package/src/cli/mrctl.ts +57 -5
  63. package/src/cli/service-unit.ts +109 -0
  64. package/src/config-file.ts +22 -1
  65. package/src/config.ts +105 -14
  66. package/src/core/api.ts +11 -2
  67. package/src/core/commands.ts +11 -4
  68. package/src/core/connection-health.ts +377 -0
  69. package/src/core/direct-send.ts +235 -14
  70. package/src/core/exec.ts +10 -0
  71. package/src/core/history-window.ts +142 -0
  72. package/src/core/model-command.ts +130 -0
  73. package/src/core/operator-alerts.ts +326 -23
  74. package/src/core/permissions.ts +28 -0
  75. package/src/core/profiles.ts +32 -16
  76. package/src/core/reply-context.ts +31 -0
  77. package/src/core/routes/config-builtin.ts +11 -0
  78. package/src/core/routes/console.ts +98 -16
  79. package/src/core/routes/dashboard.ts +93 -10
  80. package/src/core/routes/model.ts +26 -3
  81. package/src/core/routes/send.ts +1 -1
  82. package/src/core/routes/tasks.ts +74 -0
  83. package/src/core/runtime.ts +302 -21
  84. package/src/core/system-messages.ts +33 -0
  85. package/src/core/task-scheduler.ts +155 -7
  86. package/src/extensions/image-builder.ts +1 -1
  87. package/src/extensions/installer.ts +72 -17
  88. package/src/extensions/load-project.ts +74 -0
  89. package/src/extensions/loader.ts +50 -9
  90. package/src/host-version.ts +32 -0
  91. package/src/main.ts +42 -21
  92. package/src/preflight/checks/credential.ts +300 -0
  93. package/src/preflight/checks/docker.ts +149 -0
  94. package/src/preflight/checks/extensions.ts +90 -0
  95. package/src/preflight/checks/host-deps.ts +247 -0
  96. package/src/preflight/checks/image-contract.ts +228 -0
  97. package/src/preflight/checks/roundtrip.ts +424 -0
  98. package/src/preflight/checks/sandbox.ts +159 -0
  99. package/src/preflight/deps.ts +107 -0
  100. package/src/preflight/probe-container.ts +156 -0
  101. package/src/preflight/report.ts +177 -0
  102. package/src/preflight/run.ts +223 -0
  103. package/src/server.ts +55 -14
  104. package/src/storage/db.ts +41 -2
  105. package/src/storage/models-json.ts +110 -0
  106. package/src/text/reporter-lint.ts +167 -23
  107. package/src/types.ts +22 -0
@@ -3,12 +3,23 @@ import { randomBytes } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path, { dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { type AppConfig, resolveProjectPath } from "../config.js";
6
+ import {
7
+ type AppConfig,
8
+ type ModelLeg,
9
+ resolveProjectPath,
10
+ } from "../config.js";
7
11
  import { mintCallerToken } from "../core/caller-token.js";
8
12
  import { scanOutbox } from "../core/outbox.js";
9
13
  import type { ExtImageBuildState } from "../extensions/image-builder.js";
14
+ import { readHostVersion } from "../host-version.js";
10
15
  import { type Logger, logger } from "../logger.js";
11
16
  import { ensurePiResourceDir } from "../storage/memory.js";
17
+ import {
18
+ hostEnvVarForModelsJsonRef,
19
+ modelsJsonDefinesProvider,
20
+ modelsJsonEnvRefs,
21
+ parseModelsJson,
22
+ } from "../storage/models-json.js";
12
23
  import {
13
24
  describeRefreshFailure,
14
25
  getPiAuthCredential,
@@ -32,6 +43,11 @@ import {
32
43
  import { RUN_TRACE_FILE } from "./container-entry.js";
33
44
  import { selectPassthroughEnv } from "./container-env.js";
34
45
  import { ContainerError } from "./container-error.js";
46
+ import {
47
+ ensureAgentImage,
48
+ IMAGE_PULL_RECORD_FILE,
49
+ type ImagePullRecord,
50
+ } from "./image-refresh.js";
35
51
 
36
52
  /**
37
53
  * In-container mountpoint for the per-message IO dir. The host passes the request
@@ -52,8 +68,9 @@ export const RUN_TRACE_DIR = "traces";
52
68
  * is deleted, so it outlives the `--rm` container that produced it.
53
69
  *
54
70
  * Returns the destination path, or null when there is nothing to harvest — the
55
- * container was killed before writing anything, or tracing was off for the run.
56
- * Never throws: losing a diagnostic must not change how a turn ends.
71
+ * container was killed before writing anything, or the run was neither traced
72
+ * (`TRACE_RUNS=1`) nor emitted a standalone `traceEvent`, so no file was ever
73
+ * created. Never throws: losing a diagnostic must not change how a turn ends.
57
74
  */
58
75
  export function harvestRunTrace(
59
76
  ioLocalDir: string,
@@ -447,7 +464,7 @@ function sleep(ms: number): Promise<void> {
447
464
  return new Promise((resolve) => setTimeout(resolve, ms));
448
465
  }
449
466
 
450
- type ExecResult = {
467
+ export type ExecResult = {
451
468
  code: number;
452
469
  stdout: string;
453
470
  stderr: string;
@@ -518,7 +535,7 @@ function execCommand(
518
535
  * / `kill` / `pull` / `ps` / `rm` — all plain request/response Docker API
519
536
  * calls (no `/wait`, no attach), which the body-proxy forwards cleanly.
520
537
  */
521
- function execDocker(
538
+ export function execDocker(
522
539
  args: string[],
523
540
  timeoutMs = EXEC_DOCKER_TIMEOUT_MS,
524
541
  ): Promise<ExecResult> {
@@ -808,6 +825,78 @@ export async function resolveOAuthCredentialForSpawn(
808
825
  const CONTAINER_LABEL = "mercury.managed=true";
809
826
  const AGENT_ID_LABEL_KEY = "mercury.agent-id";
810
827
 
828
+ /**
829
+ * Every container Mercury creates carries these, so a process that dies
830
+ * mid-spawn leaves something the next boot's `cleanupOrphans` reaps. Exported
831
+ * because preflight's probe containers must be reapable by the same sweep.
832
+ */
833
+ export const MERCURY_CONTAINER_LABEL = CONTAINER_LABEL;
834
+ export const MERCURY_AGENT_ID_LABEL_KEY = AGENT_ID_LABEL_KEY;
835
+
836
+ /**
837
+ * The gVisor arm of the production spawn, verbatim. Named so `reply()` and
838
+ * `mercury preflight`'s round trip build one shape from one list: a probe that
839
+ * passes a shape the real spawn does not run is the July 2026 post-mortem
840
+ * again (`--dev-bind / /` passed where `--proc /proc` failed).
841
+ *
842
+ * `CONTAINER_RUNTIME=runsc` rides along because it belongs to the same arm —
843
+ * container-entry reads it to skip the bwrap spawn path.
844
+ */
845
+ export const RUNSC_HARDENING_ARGS: readonly string[] = [
846
+ "--runtime=runsc",
847
+ "--cap-drop=ALL",
848
+ "--security-opt=no-new-privileges",
849
+ "--memory=2g",
850
+ "--cpus=2",
851
+ "--pids-limit=512",
852
+ "-e",
853
+ "CONTAINER_RUNTIME=runsc",
854
+ ];
855
+
856
+ /**
857
+ * The default-bridge arm: no `--network` flag, and the host reachable by name.
858
+ * Used whenever `containerNetwork` is unset or the runtime is runsc (user-defined
859
+ * Docker networks break gVisor's outbound DNS).
860
+ */
861
+ export const DEFAULT_BRIDGE_ARGS: readonly string[] = [
862
+ "--add-host",
863
+ "host.docker.internal:host-gateway",
864
+ ];
865
+
866
+ /**
867
+ * The Docker Desktop relaxation, gated behind `containerAllowUnconfined`.
868
+ * Named for the same reason as the two above — the round-trip probe must run
869
+ * the arm the real spawn would run on this host, weakening included.
870
+ */
871
+ export const DOCKER_DESKTOP_UNCONFINED_ARGS: readonly string[] = [
872
+ "--security-opt",
873
+ "seccomp=unconfined",
874
+ "--security-opt",
875
+ "apparmor=unconfined",
876
+ "--cap-add",
877
+ "SYS_ADMIN",
878
+ ];
879
+
880
+ /**
881
+ * The API host an inner container dials, as `API_URL`.
882
+ *
883
+ * A free function so `mercury preflight`'s round trip resolves it the way a
884
+ * real spawn does instead of restating the rule — a second implementation here
885
+ * would put the probe on a different endpoint than the turn it is vouching for.
886
+ *
887
+ * gVisor (runsc) cannot use Docker's embedded DNS (127.0.0.11 is unreachable
888
+ * from the gVisor network sandbox), so container-name hostnames do not resolve
889
+ * and the outer no longer joins docker0. `API_URL` becomes a dummy that mrctl
890
+ * ignores once `API_SOCKET` is set; it stays non-empty only so mrctl's presence
891
+ * assertion passes. runc/local keep using the real hostname.
892
+ */
893
+ export function resolveContainerApiHost(config: AppConfig): string {
894
+ const configured = config.containerApiHost;
895
+ if (!configured) return "host.docker.internal";
896
+ if (config.containerRuntime !== "runsc") return configured;
897
+ return "localhost";
898
+ }
899
+
811
900
  const __dirname = dirname(fileURLToPath(import.meta.url));
812
901
  const PACKAGE_ROOT = path.join(__dirname, "../..");
813
902
 
@@ -1061,6 +1150,12 @@ export class AgentContainerRunner {
1061
1150
  * spawn, because the real run will produce its own, more specific error.
1062
1151
  */
1063
1152
  private sandboxBlockedDetail: string | undefined = undefined;
1153
+ /**
1154
+ * Set once the append-mode-under-OAuth warning has been logged. The condition
1155
+ * is per-process configuration, not per-spawn, so repeating it on every turn
1156
+ * would bury the one line that matters under its own noise.
1157
+ */
1158
+ private warnedAppendUnderOAuth = false;
1064
1159
 
1065
1160
  constructor(private readonly config: AppConfig) {
1066
1161
  this.validateImage();
@@ -1083,11 +1178,7 @@ export class AgentContainerRunner {
1083
1178
  * unchanged and keep using the real hostname.
1084
1179
  */
1085
1180
  private resolveApiHost(): string {
1086
- const configured = this.config.containerApiHost;
1087
- if (!configured) return "host.docker.internal";
1088
- if (this.config.containerRuntime !== "runsc") return configured;
1089
- // gVisor: dummy host — the real transport is the unix socket (API_SOCKET).
1090
- return "localhost";
1181
+ return resolveContainerApiHost(this.config);
1091
1182
  }
1092
1183
 
1093
1184
  /** Set a background build state — currentImage() is resolved at each spawn. */
@@ -1124,35 +1215,75 @@ export class AgentContainerRunner {
1124
1215
  });
1125
1216
  }
1126
1217
 
1218
+ /** Where the last successful pull is recorded, so a refresh is asked once. */
1219
+ private imagePullRecordPath(): string {
1220
+ return path.join(path.resolve(this.config.dataDir), IMAGE_PULL_RECORD_FILE);
1221
+ }
1222
+
1223
+ /**
1224
+ * Read the last pull record. Never throws — an unreadable or corrupt record
1225
+ * is treated as "no boot ever recorded a pull", which refreshes. Refreshing
1226
+ * on absence costs one pull; a wrong "current" costs a month of the wrong
1227
+ * container.
1228
+ */
1229
+ private readImagePullRecord(): ImagePullRecord | null {
1230
+ try {
1231
+ const raw = fs.readFileSync(this.imagePullRecordPath(), "utf8");
1232
+ const parsed = JSON.parse(raw) as Partial<ImagePullRecord>;
1233
+ if (typeof parsed.version !== "string" || !parsed.version) return null;
1234
+ if (typeof parsed.image !== "string" || !parsed.image) return null;
1235
+ return { version: parsed.version, image: parsed.image };
1236
+ } catch {
1237
+ return null;
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * Record the version/reference pair that just pulled successfully.
1243
+ *
1244
+ * Only ever called after a pull that returned 0. A failed refresh
1245
+ * deliberately leaves the record alone so the next boot asks again —
1246
+ * recording a pull that did not happen is how a tag stays frozen while the
1247
+ * bookkeeping says it is fresh.
1248
+ */
1249
+ private writeImagePullRecord(record: ImagePullRecord): void {
1250
+ const file = this.imagePullRecordPath();
1251
+ try {
1252
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1253
+ fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
1254
+ } catch (error) {
1255
+ // Non-fatal: the cost of not recording is one extra pull next boot.
1256
+ logger.warn("Could not record the agent image pull", {
1257
+ file,
1258
+ detail: error instanceof Error ? error.message : String(error),
1259
+ });
1260
+ }
1261
+ }
1262
+
1127
1263
  /**
1128
- * Ensure the agent image is available locally, pulling it if needed.
1264
+ * Ensure the agent image is available locally, pulling it if needed — and
1265
+ * re-pulling a floating tag whose host version has moved.
1266
+ *
1129
1267
  * Should be called on startup before accepting work.
1268
+ *
1269
+ * The re-pull is the narrow half of release-gate R0.4. Without it a floating
1270
+ * default (`ghcr.io/.../mercury-agent:latest`) meant "whatever this box
1271
+ * happened to pull the first time": a host upgraded through npm kept
1272
+ * yesterday's container, `/health` stayed 200, and nothing said so. See
1273
+ * `image-refresh.ts` for the whole flow, why the decision looks the way it
1274
+ * does, and why pinning the default to the host version is not this change.
1130
1275
  */
1131
1276
  async ensureImage(): Promise<void> {
1132
- const image = this.image;
1133
- const inspect = await execDocker(["image", "inspect", image], 10_000);
1134
- if (inspect.code === 0) {
1135
- logger.debug("Agent image found locally", { image });
1136
- return;
1137
- }
1138
- // Pull output is captured, not streamed: the HTTP server is already
1139
- // answering (warming) while this runs, and a synchronous inherit-stdio
1140
- // pull used to freeze it for up to five minutes.
1141
- logger.info("Agent image not found locally, pulling (can take minutes)", {
1142
- image,
1277
+ await ensureAgentImage(this.image, {
1278
+ exec: (args, timeoutMs) => execDocker(args, timeoutMs),
1279
+ hostVersion: readHostVersion(PACKAGE_ROOT),
1280
+ readRecord: () => this.readImagePullRecord(),
1281
+ writeRecord: (record) => this.writeImagePullRecord(record),
1282
+ log: {
1283
+ info: (message, meta) => logger.info(message, meta),
1284
+ warn: (message, meta) => logger.warn(message, meta),
1285
+ },
1143
1286
  });
1144
- const pull = await execDocker(["pull", image], 300_000);
1145
- if (pull.code !== 0) {
1146
- const reason = pull.timedOut
1147
- ? "timed out after 300000ms"
1148
- : pull.stderr.trim().slice(-2000);
1149
- throw new Error(
1150
- `Failed to pull agent image: ${image}${reason ? ` (${reason})` : ""}\n` +
1151
- `Build it locally with: mercury build\n` +
1152
- `Or pull manually: docker pull ${image}`,
1153
- );
1154
- }
1155
- logger.info("Agent image pulled successfully", { image });
1156
1287
  }
1157
1288
 
1158
1289
  /**
@@ -1633,6 +1764,45 @@ export class AgentContainerRunner {
1633
1764
  : `mercury-${timestamp}-${id}`;
1634
1765
  }
1635
1766
 
1767
+ /**
1768
+ * Warn once when the container is about to run in append mode while carrying
1769
+ * an Anthropic **OAuth** credential.
1770
+ *
1771
+ * `--append-system-prompt` keeps pi's own coding-agent preamble in the
1772
+ * request, and Anthropic's OAuth gate fingerprints that preamble as a
1773
+ * third-party app: every call is answered with 400 `Third-party apps now draw
1774
+ * from your extra usage, not your plan limits`. The message names billing, so
1775
+ * without this line an operator spends the outage reading their plan balance.
1776
+ * An API key is not gated, which is why the credential — not just the flag —
1777
+ * has to be in the condition.
1778
+ *
1779
+ * Warn, do not force. `override_pi_system_prompt: false` is something an
1780
+ * operator wrote on purpose, and silently overriding it here would put a
1781
+ * second copy of the rule on the host, where the container's own reading of
1782
+ * `OVERRIDE_PI_SYSTEM_PROMPT` could then disagree with it.
1783
+ *
1784
+ * Called after `injectChainOAuthCredentials` so a chain-only Anthropic leg —
1785
+ * whose token is pushed there, not by the primary-credential block — is
1786
+ * covered too.
1787
+ */
1788
+ private warnIfAppendModeUnderOAuth(
1789
+ envPairs: Array<{ key: string; value: string }>,
1790
+ extraEnv: Record<string, string> | undefined,
1791
+ spaceId: string,
1792
+ ): void {
1793
+ if (this.config.overridePiSystemPrompt) return;
1794
+ if (this.warnedAppendUnderOAuth) return;
1795
+ const usesOAuth =
1796
+ envPairs.some((p) => p.key === "ANTHROPIC_OAUTH_TOKEN" && p.value) ||
1797
+ Boolean(extraEnv?.ANTHROPIC_OAUTH_TOKEN);
1798
+ if (!usesOAuth) return;
1799
+ this.warnedAppendUnderOAuth = true;
1800
+ logger.warn(
1801
+ "override_pi_system_prompt=false with an Anthropic OAuth credential: pi's default preamble reaches the API and Anthropic's gate answers 400 \"Third-party apps now draw from your extra usage\" on every request. That is a classification error, not a quota one. Set agent.override_pi_system_prompt: true (or unset it to take the default).",
1802
+ { spaceId },
1803
+ );
1804
+ }
1805
+
1636
1806
  /**
1637
1807
  * Resolve each OAuth provider in the model chain from the host's auth.json
1638
1808
  * and append its credential to `envPairs` as the env var pi reads it from.
@@ -1656,11 +1826,19 @@ export class AgentContainerRunner {
1656
1826
  // Injected like `OAuthSpawnDeps` above, so a test can exercise the
1657
1827
  // refresh-failure branch without a token endpoint.
1658
1828
  resolveCredential: typeof getPiAuthCredential = getPiAuthCredential,
1829
+ /**
1830
+ * Providers the caller has already resolved. Asking twice would spend a
1831
+ * second single-use refresh token and log the same finding at two levels —
1832
+ * `getPiAuthCredential` coalesces *concurrent* callers, not sequential
1833
+ * ones, so the caller has to say what it already did.
1834
+ */
1835
+ skip?: ReadonlySet<string>,
1659
1836
  ): Promise<void> {
1660
1837
  const providers = new Set(
1661
1838
  (this.config.resolvedModelChain ?? []).map((leg) => leg.provider),
1662
1839
  );
1663
1840
  for (const provider of providers) {
1841
+ if (skip?.has(provider)) continue;
1664
1842
  const envVar = providerCredentialEnvVar(provider);
1665
1843
  if (!envVar) {
1666
1844
  if (hasOAuthEntry(authPath, provider)) {
@@ -1693,6 +1871,214 @@ export class AgentContainerRunner {
1693
1871
  }
1694
1872
  }
1695
1873
 
1874
+ /**
1875
+ * The chain this spawn will actually run, container-side.
1876
+ *
1877
+ * `runtime.ts` narrows `MODEL_CHAIN` to a single leg when the space pins a
1878
+ * model, so the pin — not `resolvedModelChain[0]` — is what pi will use.
1879
+ * Reading the assembled env rather than re-deriving the pin keeps this one
1880
+ * answer with the spawn instead of a second guess at it.
1881
+ */
1882
+ private effectiveModelChain(
1883
+ extraEnv: Record<string, string> | undefined,
1884
+ ): ModelLeg[] {
1885
+ const raw = extraEnv?.MODEL_CHAIN;
1886
+ if (raw) {
1887
+ try {
1888
+ const parsed = JSON.parse(raw) as unknown;
1889
+ if (Array.isArray(parsed)) {
1890
+ const legs = parsed.filter(
1891
+ (l): l is ModelLeg =>
1892
+ Boolean(l) &&
1893
+ typeof (l as ModelLeg).provider === "string" &&
1894
+ typeof (l as ModelLeg).model === "string",
1895
+ );
1896
+ if (legs.length > 0) return legs;
1897
+ }
1898
+ } catch {
1899
+ // Unparseable MODEL_CHAIN is runtime.ts's own JSON.stringify output, so
1900
+ // this cannot normally happen; fall back to the configured chain rather
1901
+ // than refuse on a shape nobody set by hand.
1902
+ }
1903
+ }
1904
+ return this.config.resolvedModelChain ?? [];
1905
+ }
1906
+
1907
+ /**
1908
+ * Whether one leg has a credential in the env this spawn is about to carry.
1909
+ *
1910
+ * Names are container-side: the passthrough strips the `MERCURY_` prefix, and
1911
+ * a `models.json` `"apiKey": "$GATEWAY_KEY"` is expanded by pi from
1912
+ * `GATEWAY_KEY` inside the container. `missing` therefore reports the *host*
1913
+ * names, which is what an operator sets.
1914
+ */
1915
+ private legCredentialStatus(
1916
+ leg: ModelLeg,
1917
+ present: (containerVar: string) => boolean,
1918
+ readModelsJson: () => unknown,
1919
+ ): { served: boolean; missing: string[] } {
1920
+ // Anthropic accepts either form; every other provider has at most one var.
1921
+ const vars =
1922
+ leg.provider === "anthropic"
1923
+ ? ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]
1924
+ : [providerCredentialEnvVar(leg.provider)].filter((v): v is string =>
1925
+ Boolean(v),
1926
+ );
1927
+ if (vars.some(present)) return { served: true, missing: [] };
1928
+
1929
+ // Only a leg whose env lookup came up empty is worth a disk read, so the
1930
+ // usual spawn never opens models.json at all.
1931
+ const modelsJson = readModelsJson();
1932
+ const refs = modelsJsonEnvRefs(modelsJson, leg.provider);
1933
+ if (refs.length > 0) {
1934
+ const missing = refs.filter((ref) => !present(ref));
1935
+ return {
1936
+ served: missing.length === 0,
1937
+ missing: missing.map(hostEnvVarForModelsJsonRef),
1938
+ };
1939
+ }
1940
+ // A provider defined in models.json with no `$VAR` at all is a keyless
1941
+ // endpoint the operator configured on purpose (a local model, an open
1942
+ // gateway). There is no credential to be missing, so nothing may refuse
1943
+ // over it — absence of a *requirement* is not absence of a guard input.
1944
+ if (modelsJsonDefinesProvider(modelsJson, leg.provider)) {
1945
+ return { served: true, missing: [] };
1946
+ }
1947
+ return { served: false, missing: vars.map((v) => `MERCURY_${v}`) };
1948
+ }
1949
+
1950
+ /**
1951
+ * Fail fast when the leg this run will use has no credential and no other leg
1952
+ * can carry it either.
1953
+ *
1954
+ * The refusal used to be Anthropic-only —
1955
+ * `if (!hasAnthropicKey && this.config.modelProvider === "anthropic")` — so a
1956
+ * chain whose first leg was a `models.json` gateway or `github-copilot` got
1957
+ * no host-side check at all: the container started, pi exited 1 with "No API
1958
+ * key found", and the caller was told to try again while the real diagnosis
1959
+ * sat in a container log.
1960
+ *
1961
+ * Two things this deliberately does **not** do:
1962
+ *
1963
+ * - It does not refuse when a *later* leg is served. `MODEL_CHAIN` exists so
1964
+ * another leg can carry the turn, which is why
1965
+ * `injectChainOAuthCredentials` only warns per leg. Only "the first leg has
1966
+ * nothing and no leg does" is a dead run. A space that pins a model gets a
1967
+ * one-leg chain, so a pinned unserved leg does refuse — correctly, there is
1968
+ * no fallback to wait for.
1969
+ * - It does not re-implement credential resolution. The env it reads is the
1970
+ * env the container gets, after every injection above; the provider→var
1971
+ * mapping is `providerCredentialEnvVar`; the `models.json` `$VAR` lookup is
1972
+ * the one `mercury preflight`'s `credential.first-leg` check uses.
1973
+ */
1974
+ private assertChainCredential(
1975
+ envPairs: Array<{ key: string; value: string }>,
1976
+ extraEnv: Record<string, string> | undefined,
1977
+ globalDir: string,
1978
+ spaceId: string,
1979
+ /**
1980
+ * Why the Anthropic leg is unserved, when the block above found out —
1981
+ * a corrupt blob, a refresh failure, or nothing on file. Used verbatim so
1982
+ * an Anthropic box keeps the diagnosis it has always had instead of the
1983
+ * generic "set MERCURY_… on the host".
1984
+ */
1985
+ anthropicDetail?: string,
1986
+ ): void {
1987
+ const legs = this.effectiveModelChain(extraEnv);
1988
+ if (legs.length === 0) return;
1989
+
1990
+ const set = new Set<string>();
1991
+ for (const pair of envPairs) if (pair.value) set.add(pair.key);
1992
+ for (const [key, value] of Object.entries(extraEnv ?? {})) {
1993
+ if (value) set.add(key);
1994
+ }
1995
+ const present = (name: string): boolean => set.has(name);
1996
+
1997
+ // Read only when some leg's env lookup came up empty — the happy path
1998
+ // never touches the disk.
1999
+ let modelsJson: unknown;
2000
+ let modelsJsonRead = false;
2001
+ const readModelsJson = (): unknown => {
2002
+ if (modelsJsonRead) return modelsJson;
2003
+ modelsJsonRead = true;
2004
+ const file = path.join(globalDir, "models.json");
2005
+ try {
2006
+ const { parsed, error } = parseModelsJson(
2007
+ fs.readFileSync(file, "utf8"),
2008
+ );
2009
+ if (error) {
2010
+ logger.warn("models.json is not readable as JSON, ignoring", {
2011
+ file,
2012
+ detail: error,
2013
+ });
2014
+ }
2015
+ modelsJson = parsed;
2016
+ } catch (error) {
2017
+ const code = (error as { code?: string } | null)?.code;
2018
+ if (code !== "ENOENT") {
2019
+ logger.warn("Could not read models.json", {
2020
+ file,
2021
+ detail: error instanceof Error ? error.message : String(error),
2022
+ });
2023
+ }
2024
+ modelsJson = null;
2025
+ }
2026
+ return modelsJson;
2027
+ };
2028
+
2029
+ const first = legs[0];
2030
+ if (!first) return;
2031
+ const firstStatus = this.legCredentialStatus(
2032
+ first,
2033
+ present,
2034
+ readModelsJson,
2035
+ );
2036
+ if (firstStatus.served) return;
2037
+
2038
+ const servedLater = legs
2039
+ .slice(1)
2040
+ .some(
2041
+ (leg) => this.legCredentialStatus(leg, present, readModelsJson).served,
2042
+ );
2043
+ if (servedLater) {
2044
+ // injectChainOAuthCredentials already logged why this leg is unserved.
2045
+ logger.warn(
2046
+ `Model chain leg "${first.provider}:${first.model}" starts without a credential; a later leg will have to carry this run.`,
2047
+ { spaceId, provider: first.provider },
2048
+ );
2049
+ return;
2050
+ }
2051
+
2052
+ // "Set it on the host" is the wrong instruction when the operator already
2053
+ // did and the boundary dropped it — that is a different failure with a
2054
+ // different fix, and telling someone to do again what they already did is
2055
+ // how an outage gets long. Which boundary depends on the mode, so the two
2056
+ // are worded apart rather than blamed on `claimed` either way.
2057
+ const dropped = firstStatus.missing.filter((name) => process.env[name]);
2058
+ const plural = dropped.length > 1;
2059
+ const droppedNames = dropped.join(" and ");
2060
+ const named =
2061
+ dropped.length > 0
2062
+ ? this.config.containerEnvPassthrough === "claimed"
2063
+ ? `${droppedNames} ${plural ? "are" : "is"} set on the host but env_passthrough is "claimed", which forwards only the fixed model-provider credentials — the container never receives ${plural ? "them" : "it"}. Set agent.env_passthrough: all, or have an extension declare ${droppedNames}`
2064
+ : `${droppedNames} ${plural ? "are" : "is"} set on the host but did not reach this container — the name is either blocked outright (BLOCKED_ENV_VARS) or claimed by an extension this caller may not use, so it is injected only behind that permission gate`
2065
+ : firstStatus.missing.length > 0
2066
+ ? `set ${firstStatus.missing.join(" or ")} on the host`
2067
+ : `pi reads no credential env var for "${first.provider}" and neither auth.json nor models.json keys it — define it in models.json with a $VAR apiKey, or use a provider Mercury can pass a credential for`;
2068
+ const detail =
2069
+ first.provider === "anthropic" && anthropicDetail
2070
+ ? anthropicDetail
2071
+ : `no credential for model leg ${first.provider}:${first.model} and no other chain leg has one — ${named}`;
2072
+ logger.error(`Refusing to start container: ${detail}`, {
2073
+ spaceId,
2074
+ provider: first.provider,
2075
+ model: first.model,
2076
+ chainLength: legs.length,
2077
+ missing: firstStatus.missing,
2078
+ });
2079
+ throw ContainerError.noCredentials(spaceId, detail);
2080
+ }
2081
+
1696
2082
  async reply(input: {
1697
2083
  spaceId: string;
1698
2084
  spaceWorkspace: string;
@@ -1837,11 +2223,27 @@ export class AgentContainerRunner {
1837
2223
  input.extraEnv?.ANTHROPIC_API_KEY ||
1838
2224
  input.extraEnv?.ANTHROPIC_OAUTH_TOKEN,
1839
2225
  );
1840
- if (!hasAnthropicKey && this.config.modelProvider === "anthropic") {
2226
+ // Why the chain and not `config.modelProvider`: `modelProvider` defaults to
2227
+ // "anthropic" and is *independent* of `model.chain`, so a box configured
2228
+ // entirely with a gateway chain still reported "anthropic" here — and this
2229
+ // block refused every container on it over a credential nothing was going
2230
+ // to use. That is the 2026-08-31 "gateway box refuses every container"
2231
+ // shape. The question is which providers this run can actually reach.
2232
+ // Only the *first* leg: a later anthropic leg is `injectChainOAuthCredentials`'s
2233
+ // job, and asking twice would spend a second single-use refresh token and
2234
+ // log the same finding at two levels.
2235
+ const anthropicIsFirstLeg =
2236
+ this.effectiveModelChain(input.extraEnv)[0]?.provider === "anthropic";
2237
+ // Diagnosis kept for `assertChainCredential` below rather than thrown here:
2238
+ // an unserved anthropic leg is only fatal when no other leg can carry the
2239
+ // run, and this is the one place that knows *why* anthropic is unserved.
2240
+ let anthropicCredentialDetail: string | undefined;
2241
+ const askedAnthropic = !hasAnthropicKey && anthropicIsFirstLeg;
2242
+ if (askedAnthropic) {
1841
2243
  const resolvedAuthPath =
1842
2244
  this.config.authPath ?? path.join(globalDir, "auth.json");
1843
2245
  const piAuth = await getPiAuthCredential({
1844
- provider: this.config.modelProvider,
2246
+ provider: "anthropic",
1845
2247
  authPath: resolvedAuthPath,
1846
2248
  });
1847
2249
  if (piAuth.status === "ok") {
@@ -1850,10 +2252,7 @@ export class AgentContainerRunner {
1850
2252
  value: piAuth.apiKey,
1851
2253
  });
1852
2254
  } else {
1853
- // Fail fast: without a credential, pi inside the container exits 1
1854
- // ("No API key found for anthropic") after a full container start,
1855
- // and the user gets a misleading "try again" message.
1856
- const detail = oauthBlobCorrupt
2255
+ anthropicCredentialDetail = oauthBlobCorrupt
1857
2256
  ? "MERCURY_ANTHROPIC_OAUTH_TOKEN holds a corrupt credential blob — re-provision it or set MERCURY_ANTHROPIC_API_KEY"
1858
2257
  : piAuth.status === "refresh-failed"
1859
2258
  ? `Anthropic OAuth refresh failed for ${resolvedAuthPath} — ${describeRefreshFailure(piAuth)}${
@@ -1862,14 +2261,16 @@ export class AgentContainerRunner {
1862
2261
  : ". Run mercury auth login from the project directory that owns that file, or set MERCURY_ANTHROPIC_API_KEY"
1863
2262
  }`
1864
2263
  : `no Anthropic credential configured (checked ${resolvedAuthPath}) — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN`;
1865
- logger.error(`Refusing to start container: ${detail}`, {
1866
- spaceId: input.spaceId,
1867
- authPath: resolvedAuthPath,
1868
- ...(piAuth.status === "refresh-failed"
1869
- ? { reason: piAuth.reason ?? "unknown", upstream: piAuth.detail }
1870
- : {}),
1871
- });
1872
- throw ContainerError.noCredentials(input.spaceId, detail);
2264
+ logger.warn(
2265
+ `Anthropic leg has no usable credential: ${anthropicCredentialDetail}`,
2266
+ {
2267
+ spaceId: input.spaceId,
2268
+ authPath: resolvedAuthPath,
2269
+ ...(piAuth.status === "refresh-failed"
2270
+ ? { reason: piAuth.reason ?? "unknown", upstream: piAuth.detail }
2271
+ : {}),
2272
+ },
2273
+ );
1873
2274
  }
1874
2275
  }
1875
2276
 
@@ -1882,6 +2283,27 @@ export class AgentContainerRunner {
1882
2283
  input.extraEnv,
1883
2284
  this.config.authPath ?? path.join(globalDir, "auth.json"),
1884
2285
  input.spaceId,
2286
+ undefined,
2287
+ // The block above already asked auth.json for anthropic and either
2288
+ // pushed the token or recorded why it could not. Asking again would
2289
+ // spend a second single-use refresh token and warn twice for one event.
2290
+ askedAnthropic ? new Set(["anthropic"]) : undefined,
2291
+ );
2292
+
2293
+ // Fail fast for every provider, not just anthropic. Runs after every
2294
+ // injection above so it reads the env the container will actually carry.
2295
+ this.assertChainCredential(
2296
+ passthroughEnvPairs,
2297
+ input.extraEnv,
2298
+ globalDir,
2299
+ input.spaceId,
2300
+ anthropicCredentialDetail,
2301
+ );
2302
+
2303
+ this.warnIfAppendModeUnderOAuth(
2304
+ passthroughEnvPairs,
2305
+ input.extraEnv,
2306
+ input.spaceId,
1885
2307
  );
1886
2308
 
1887
2309
  const envPairs = [
@@ -1944,8 +2366,15 @@ export class AgentContainerRunner {
1944
2366
  value: JSON.stringify(this.config.resolvedModelChainCapabilities),
1945
2367
  },
1946
2368
  {
2369
+ // The container honours "false"/"0" as append mode — the mode the
2370
+ // OAuth gate rejects — so the only path to the literal "false" must be
2371
+ // a value the host positively read as an opt-out. The field is typed
2372
+ // `boolean` (`overridePiSystemPromptFromEnv` in config.ts rejects
2373
+ // everything that is not an opt-out before it gets here), so this is
2374
+ // equivalent to a plain ternary today; the explicit `=== false` keeps
2375
+ // it that way if the field ever widens.
1947
2376
  key: "OVERRIDE_PI_SYSTEM_PROMPT",
1948
- value: this.config.overridePiSystemPrompt ? "true" : "false",
2377
+ value: this.config.overridePiSystemPrompt === false ? "false" : "true",
1949
2378
  },
1950
2379
  { key: "BOT_USERNAME", value: this.config.botUsername },
1951
2380
  ].filter((x): x is { key: string; value: string } => Boolean(x.value));
@@ -2033,7 +2462,7 @@ export class AgentContainerRunner {
2033
2462
  // embedded resolver at 127.0.0.11 is unreachable from gVisor). Inner
2034
2463
  // containers keep docker0 for outbound DNS/HTTPS; the inner→outer API
2035
2464
  // callback rides the per-agent unix socket (API_SOCKET), not docker0.
2036
- args.push("--add-host", "host.docker.internal:host-gateway");
2465
+ args.push(...DEFAULT_BRIDGE_ARGS);
2037
2466
  }
2038
2467
 
2039
2468
  // Per-message IO dir — the detached reply channel. Mirrors the global/spaces
@@ -2094,16 +2523,7 @@ export class AgentContainerRunner {
2094
2523
  // Restores full Docker hardening (SYS_ADMIN relaxation not required).
2095
2524
  // CONTAINER_RUNTIME=runsc is passed explicitly (stripped prefix) so container-entry
2096
2525
  // skips the bwrap spawn path.
2097
- args.push(
2098
- "--runtime=runsc",
2099
- "--cap-drop=ALL",
2100
- "--security-opt=no-new-privileges",
2101
- "--memory=2g",
2102
- "--cpus=2",
2103
- "--pids-limit=512",
2104
- "-e",
2105
- "CONTAINER_RUNTIME=runsc",
2106
- );
2526
+ args.push(...RUNSC_HARDENING_ARGS);
2107
2527
  } else if (
2108
2528
  this.config.containerBwrapDockerCompat ||
2109
2529
  (await isDockerDesktop())
@@ -2141,14 +2561,7 @@ export class AgentContainerRunner {
2141
2561
  "MERCURY_CONTAINER_RUNTIME=runsc avoids this on Linux hosts.",
2142
2562
  { spaceId: input.spaceId },
2143
2563
  );
2144
- args.push(
2145
- "--security-opt",
2146
- "seccomp=unconfined",
2147
- "--security-opt",
2148
- "apparmor=unconfined",
2149
- "--cap-add",
2150
- "SYS_ADMIN",
2151
- );
2564
+ args.push(...DOCKER_DESKTOP_UNCONFINED_ARGS);
2152
2565
  }
2153
2566
  } else if (this.sandboxBlockedDetail) {
2154
2567
  // Default Linux shape, and the boot probe already proved bwrap cannot
@@ -2246,14 +2659,22 @@ export class AgentContainerRunner {
2246
2659
  // Harvest before delete, and from inside cleanupIo rather than beside each
2247
2660
  // call of it — every exit path routes through here, including the timeout
2248
2661
  // and crash paths, which are the ones a trace exists to explain.
2249
- if (this.config.containerTraceRuns) {
2250
- const dest = harvestRunTrace(
2251
- ioLocalDir,
2252
- this.config.dataDir,
2253
- containerName,
2254
- );
2255
- if (dest) logger.info("Harvested agent run trace", { trace: dest });
2256
- }
2662
+ //
2663
+ // Unconditional, not gated on `containerTraceRuns`. That flag gates what
2664
+ // the *container* writes (`TRACE_RUNS=1` → the full pi transcript, which
2665
+ // is why it is off by default), but the agent also writes counts-only
2666
+ // diagnostics into the same file on runs nobody thought to trace —
2667
+ // `history_budget_dropped_turns` is the first (`container-entry.ts`
2668
+ // `traceEvent`). Gating the harvest here would delete them with the IO
2669
+ // dir and put the event back where the bug found it: a log nobody reads.
2670
+ // Nothing is created when the file is absent — `harvestRunTrace` returns
2671
+ // null before it touches the destination.
2672
+ const dest = harvestRunTrace(
2673
+ ioLocalDir,
2674
+ this.config.dataDir,
2675
+ containerName,
2676
+ );
2677
+ if (dest) logger.info("Harvested agent run trace", { trace: dest });
2257
2678
  try {
2258
2679
  fs.rmSync(ioLocalDir, { recursive: true, force: true });
2259
2680
  } catch {