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.
- package/container/Dockerfile +25 -0
- package/container/build.sh +23 -3
- package/docs/behavior-layers.md +25 -14
- package/docs/configuration.md +139 -11
- package/docs/container-lifecycle.md +151 -1
- package/docs/context-architecture.md +6 -2
- package/docs/extensions.md +9 -1
- package/docs/goals/football-reporter-profile/decisions.md +79 -0
- package/docs/goals/rehearsal-bench/decisions.md +41 -3
- package/docs/goals/rehearsal-bench/roadmap.md +3 -1
- package/docs/goals/release-gate/decisions.md +86 -0
- package/docs/goals/release-gate/roadmap.md +36 -3
- package/docs/live-testing.md +22 -0
- package/docs/pending-verification.md +178 -0
- package/docs/permissions.md +1 -1
- package/docs/profile-guide.md +28 -8
- package/examples/extensions/archive/backends/local.ts +6 -3
- package/examples/extensions/archive/queue.ts +51 -10
- package/examples/extensions/feed-watch/config.ts +42 -2
- package/examples/extensions/feed-watch/digest.ts +4 -7
- package/examples/extensions/feed-watch/items.ts +18 -7
- package/examples/extensions/feed-watch/match.ts +125 -9
- package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
- package/examples/extensions/feed-watch/watch.ts +39 -5
- package/examples/extensions/gws/index.ts +126 -8
- package/examples/extensions/longview/hook.ts +72 -7
- package/examples/extensions/longview/index.ts +2 -0
- package/examples/extensions/morning/README.md +26 -15
- package/examples/extensions/morning/index.ts +29 -6
- package/examples/extensions/morning/lib/hosts.ts +16 -0
- package/examples/extensions/morning/lib/morning.ts +78 -0
- package/examples/extensions/morning/lib/upload.ts +584 -0
- package/examples/extensions/morning/skill/SKILL.md +34 -4
- package/examples/extensions/napkin/index.ts +12 -3
- package/examples/extensions/napkin/pi-spawn.ts +5 -1
- package/examples/extensions/overview/index.ts +20 -0
- package/examples/extensions/overview/skill/SKILL.md +9 -1
- package/examples/extensions/pinchtab/index.ts +36 -7
- package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
- package/examples/profiles/_template/AGENTS.md +6 -1
- package/examples/profiles/football-reporter/AGENTS.md +16 -4
- package/examples/profiles/football-reporter/README.md +1 -1
- package/examples/profiles/football-reporter/config.yaml +42 -6
- package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
- package/examples/profiles/football-reporter/standard.json +45 -8
- package/package.json +11 -6
- package/resources/skills/tasks/SKILL.md +17 -1
- package/resources/templates/AGENTS.md +4 -3
- package/resources/templates/mercury.example.yaml +13 -4
- package/src/adapters/whatsapp-ingress.ts +15 -3
- package/src/agent/container-entry.ts +299 -29
- package/src/agent/container-env.ts +26 -8
- package/src/agent/container-runner.ts +497 -76
- package/src/agent/image-contract.ts +77 -0
- package/src/agent/image-manifest.ts +180 -0
- package/src/agent/image-refresh.ts +318 -0
- package/src/cli/mercury.ts +225 -28
- package/src/cli/mrctl-http.ts +5 -0
- package/src/cli/mrctl.ts +57 -5
- package/src/cli/service-unit.ts +109 -0
- package/src/config-file.ts +22 -1
- package/src/config.ts +105 -14
- package/src/core/api.ts +11 -2
- package/src/core/commands.ts +11 -4
- package/src/core/connection-health.ts +377 -0
- package/src/core/direct-send.ts +235 -14
- package/src/core/exec.ts +10 -0
- package/src/core/history-window.ts +142 -0
- package/src/core/model-command.ts +130 -0
- package/src/core/operator-alerts.ts +326 -23
- package/src/core/permissions.ts +28 -0
- package/src/core/profiles.ts +32 -16
- package/src/core/reply-context.ts +31 -0
- package/src/core/routes/config-builtin.ts +11 -0
- package/src/core/routes/console.ts +98 -16
- package/src/core/routes/dashboard.ts +93 -10
- package/src/core/routes/model.ts +26 -3
- package/src/core/routes/send.ts +1 -1
- package/src/core/routes/tasks.ts +74 -0
- package/src/core/runtime.ts +302 -21
- package/src/core/system-messages.ts +33 -0
- package/src/core/task-scheduler.ts +155 -7
- package/src/extensions/image-builder.ts +1 -1
- package/src/extensions/installer.ts +72 -17
- package/src/extensions/load-project.ts +74 -0
- package/src/extensions/loader.ts +50 -9
- package/src/host-version.ts +32 -0
- package/src/main.ts +42 -21
- package/src/preflight/checks/credential.ts +300 -0
- package/src/preflight/checks/docker.ts +149 -0
- package/src/preflight/checks/extensions.ts +90 -0
- package/src/preflight/checks/host-deps.ts +247 -0
- package/src/preflight/checks/image-contract.ts +228 -0
- package/src/preflight/checks/roundtrip.ts +424 -0
- package/src/preflight/checks/sandbox.ts +159 -0
- package/src/preflight/deps.ts +107 -0
- package/src/preflight/probe-container.ts +156 -0
- package/src/preflight/report.ts +177 -0
- package/src/preflight/run.ts +223 -0
- package/src/server.ts +55 -14
- package/src/storage/db.ts +41 -2
- package/src/storage/models-json.ts +110 -0
- package/src/text/reporter-lint.ts +167 -23
- 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 {
|
|
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
|
|
56
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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 ? "
|
|
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(
|
|
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
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
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 {
|