mercury-agent 0.5.2 → 0.5.7

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 (38) hide show
  1. package/package.json +1 -1
  2. package/docs/ARCHITECTURE.md +0 -34
  3. package/docs/DESIGN.md +0 -42
  4. package/docs/ROADMAP.md +0 -43
  5. package/docs/TODOS.md +0 -147
  6. package/docs/VISION.md +0 -32
  7. package/docs/archive/.gitkeep +0 -0
  8. package/docs/archive/applicative-profiles/2026-07-01-applicative-profiles.md +0 -270
  9. package/docs/archive/applicative-profiles/2026-07-01-caller-bound-capability-token.md +0 -184
  10. package/docs/archive/applicative-profiles/2026-07-01-dm-auto-space.md +0 -273
  11. package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +0 -71
  12. package/docs/backlog/.gitkeep +0 -0
  13. package/docs/bugs/.gitkeep +0 -0
  14. package/docs/debug/major/.gitkeep +0 -0
  15. package/docs/debug/major/2026-07-01-strict-yaml-schema-crash.md +0 -85
  16. package/docs/debug/major/2026-07-02-bwrap-privileged-linux-docker.md +0 -160
  17. package/docs/debug/major/2026-07-02-e2big-prompt-delivery-fix.md +0 -382
  18. package/docs/debug/major/2026-07-02-registry-pull-no-local-fallback.md +0 -194
  19. package/docs/debug/minor/.gitkeep +0 -0
  20. package/docs/debug/moderate/.gitkeep +0 -0
  21. package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +0 -72
  22. package/docs/html-slides/.gitkeep +0 -0
  23. package/docs/ideas/.gitkeep +0 -0
  24. package/docs/ideas/business-extensions.md +0 -48
  25. package/docs/in-progress/.gitkeep +0 -0
  26. package/docs/notes/.gitkeep +0 -0
  27. package/docs/pending-updates/.gitkeep +0 -0
  28. package/docs/runbooks/gws-oauth-setup.md +0 -211
  29. package/docs/runbooks/publish-checklist.md +0 -54
  30. package/docs/templates/TEMPLATE-AUTOPILOT-CONFIG.yaml +0 -35
  31. package/docs/templates/TEMPLATE-BUG.md +0 -71
  32. package/docs/templates/TEMPLATE-CI.yml +0 -25
  33. package/docs/templates/TEMPLATE-DECISIONS.md +0 -11
  34. package/docs/templates/TEMPLATE-FEATURE.md +0 -127
  35. package/docs/templates/TEMPLATE-GOAL.md +0 -31
  36. package/docs/templates/TEMPLATE-IDEA.md +0 -31
  37. package/docs/templates/TEMPLATE-NOTE.md +0 -27
  38. package/docs/templates/TEMPLATE-ROADMAP.md +0 -21
@@ -1,160 +0,0 @@
1
- # Bug: bwrap fails to mount /proc on Linux Docker Engine (non-Desktop)
2
-
3
- **Status:** Fixed
4
- **Severity:** P1 — blocks all agent responses on standard Linux Docker hosts
5
- **Affected:** Linux servers running Docker Engine (not Docker Desktop, not gVisor)
6
- **File:** `src/agent/container-runner.ts`, bwrap compat branch (~line 980)
7
- **Last updated:** 2026-07-02
8
-
9
- ## Symptom
10
-
11
- ```
12
- Container error: Container failed (exit code 1): Error: pi CLI failed (1):
13
- bwrap: Can't mount proc on /newroot/proc: Operation not permitted
14
- ```
15
-
16
- Happens on Linux servers (e.g. Hetzner, AWS EC2, DigitalOcean) running
17
- Docker Engine with the default kernel config. The existing bwrap Docker compat
18
- flags (`seccomp=unconfined`, `apparmor=unconfined`, `CAP_SYS_ADMIN`) are not
19
- sufficient for bwrap to mount `/proc` inside the container.
20
-
21
- ## Root cause
22
-
23
- On Linux Docker Engine, the default seccomp + AppArmor + capability set does
24
- not allow `mount(2)` inside a user namespace, even with:
25
- - `--security-opt seccomp=unconfined`
26
- - `--security-opt apparmor=unconfined`
27
- - `--cap-add SYS_ADMIN`
28
-
29
- This is a known limitation: Docker's default behavior strips many mount-related
30
- permissions that `--privileged` restores (device cgroup rules, all capabilities,
31
- full /sys and /proc access).
32
-
33
- **Verified:** `--privileged` makes bwrap work:
34
- ```bash
35
- # Fails:
36
- docker run --rm --entrypoint bwrap \
37
- --security-opt seccomp=unconfined --security-opt apparmor=unconfined \
38
- --cap-add SYS_ADMIN mercury-agent:latest \
39
- --ro-bind /usr /usr --proc /proc --dev /dev --unshare-pid echo hello
40
- # → bwrap: Can't mount proc on /newroot/proc: Operation not permitted
41
-
42
- # Works:
43
- docker run --rm --entrypoint bwrap --privileged mercury-agent:latest \
44
- --ro-bind /usr /usr --proc /proc --dev /dev --unshare-pid echo hello
45
- # → execvp echo: No such file or directory (bwrap ran fine, echo just not in /usr)
46
- ```
47
-
48
- ## Fix
49
-
50
- ### Option A: Add `--privileged` flag to bwrap compat (recommended short-term)
51
-
52
- In `container-runner.ts` (~line 985), replace the current compat flags with
53
- `--privileged` when `containerBwrapDockerCompat` is explicitly set to `true`:
54
-
55
- ```typescript
56
- } else if (this.config.containerBwrapDockerCompat || isDockerDesktop()) {
57
- logger.info("Enabling bwrap Docker compat (seccomp/apparmor/SYS_ADMIN)", {
58
- configFlag: this.config.containerBwrapDockerCompat,
59
- dockerDesktop: isDockerDesktop(),
60
- });
61
- if (this.config.containerBwrapDockerCompat) {
62
- // Explicit opt-in: Linux Docker Engine needs --privileged for bwrap
63
- // to mount /proc inside a user namespace. The finer-grained flags
64
- // (seccomp=unconfined + apparmor=unconfined + SYS_ADMIN) are not
65
- // sufficient on standard Linux kernels.
66
- args.push("--privileged");
67
- } else {
68
- // Docker Desktop auto-detection: use the lighter-weight flags
69
- // (Docker Desktop's VM has a more permissive kernel config)
70
- args.push(
71
- "--security-opt",
72
- "seccomp=unconfined",
73
- "--security-opt",
74
- "apparmor=unconfined",
75
- "--cap-add",
76
- "SYS_ADMIN",
77
- );
78
- }
79
- }
80
- ```
81
-
82
- This keeps the lighter flags for Docker Desktop (where they work) and uses
83
- `--privileged` only when the user explicitly opted in via
84
- `MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT=true`.
85
-
86
- ### Option B: Add a separate `MERCURY_CONTAINER_PRIVILEGED` config flag
87
-
88
- If you prefer to keep the compat flag behavior unchanged, add a new flag:
89
-
90
- In `config.ts`, add:
91
- ```typescript
92
- containerPrivileged: boolean; // default: false
93
- ```
94
-
95
- In `config-file.ts`, add the env mapping:
96
- ```typescript
97
- containerPrivileged: "MERCURY_CONTAINER_PRIVILEGED",
98
- ```
99
-
100
- In `container-runner.ts`, after the existing compat block:
101
- ```typescript
102
- if (this.config.containerPrivileged) {
103
- args.push("--privileged");
104
- }
105
- ```
106
-
107
- User sets `MERCURY_CONTAINER_PRIVILEGED=true` in `.env`.
108
-
109
- ### Option C: Auto-detect and use --privileged as fallback
110
-
111
- Run a probe container on startup to test if bwrap works with the lighter
112
- flags. If it fails, automatically escalate to `--privileged` and log a
113
- warning. This is the most user-friendly but adds startup latency.
114
-
115
- ## Workaround (immediate)
116
-
117
- Until the fix is in place, users can work around this by manually running
118
- mercury with Docker's `--privileged` flag. However, since `mercury run`
119
- doesn't expose extra Docker args, the only current workaround is to
120
- modify `MERCURY_AGENT_IMAGE` to point to a locally-built image and skip
121
- bwrap entirely by setting `MERCURY_CONTAINER_RUNTIME=runsc` (requires
122
- gVisor installed), or to apply the code change.
123
-
124
- ## Linux server setup checklist
125
-
126
- These sysctl settings are needed regardless of the fix above:
127
-
128
- ```bash
129
- sysctl -w kernel.unprivileged_userns_clone=1
130
- sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
131
- cat > /etc/sysctl.d/99-bubblewrap.conf << 'EOF'
132
- kernel.unprivileged_userns_clone=1
133
- kernel.apparmor_restrict_unprivileged_userns=0
134
- EOF
135
- ```
136
-
137
- ## How to verify
138
-
139
- 1. Set `MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT=true` in `.env`
140
- 2. Start mercury: `pm2 restart all` or `mercury service install`
141
- 3. Run: `mercury chat 'hello'`
142
- 4. Check logs — should see successful container run, no bwrap errors
143
-
144
- ---
145
-
146
- ## Post-Mortem
147
-
148
- ### Investigation
149
- Reconciled this hanging bug doc against the current code during `/f-doc-sync` triage. Read the bwrap compat branch in `src/agent/container-runner.ts` (grep for `privileged`/`containerBwrapDockerCompat`/`isDockerDesktop`).
150
-
151
- ### Root Cause
152
- On standard Linux Docker Engine, the lighter compat flags (`seccomp=unconfined` + `apparmor=unconfined` + `CAP_SYS_ADMIN`) don't permit `mount(2)` inside a user namespace, so bwrap failed with `Can't mount proc on /newroot/proc: Operation not permitted`. Only `--privileged` restores the permissions bwrap needs.
153
-
154
- ### Fix
155
- **Option A was already implemented** — no new code required. In `container-runner.ts` (lines 1003–1008), the compat branch now pushes `--privileged` when `containerBwrapDockerCompat` is explicitly `true` (with a log line "Enabling bwrap Docker compat with --privileged"), while Docker Desktop auto-detection keeps the lighter `seccomp/apparmor/SYS_ADMIN` flags. This matches the recommended fix exactly.
156
-
157
- ### Lessons
158
- - Reconcile hanging bug docs against the code before opening a fix session — this P1 was already resolved but never archived.
159
- - Container-sandbox permissions differ between Docker Desktop (permissive VM kernel) and bare Linux Docker Engine; gate the heavier `--privileged` escalation behind an explicit opt-in rather than applying it everywhere.
160
- - `docs/bugs/` and `docs/debug/` are gitignored working docs — archival here is a local move only.
@@ -1,382 +0,0 @@
1
- # Fix: E2BIG — Argument list too long on bwrap/pi spawn
2
-
3
- **Status:** Fixed
4
- **Severity:** P0 — causes container crash on long conversations
5
- **Affected:** All runtimes (bwrap + gVisor/direct), Docker Desktop especially
6
- **File:** `src/agent/container-entry.ts`
7
- **Last updated:** 2026-07-02
8
-
9
- ## Symptom
10
-
11
- ```
12
- Container failed (exit code 1): Error: E2BIG: argument list too long, posix_spawn 'bwrap'
13
- ```
14
-
15
- Happens when a conversation has enough history that the assembled CLI args + env vars
16
- exceed Linux's `ARG_MAX` (~2MB). The user sees: *"Something went wrong processing your request."*
17
-
18
- ## Root Cause
19
-
20
- `invokePiOnce()` (line ~741) passes both the **system prompt** and **user prompt**
21
- (which includes full conversation history XML) as CLI arguments:
22
-
23
- ```typescript
24
- // CURRENT — both prompts are argv strings
25
- const piArgs = [
26
- "--print", "--mode", "json", ...sessionArgs,
27
- "--provider", provider, "--model", model,
28
- ...toolModeArgs, "--no-extensions",
29
- "-e", "/app/src/extensions/permission-guard.ts",
30
- "-e", "/app/resources/pi-extensions/subagent/index.ts",
31
- systemPromptFlag,
32
- systemPrompt, // <-- can be large (system + ext prompt)
33
- buildPrompt(payload), // <-- HUGE (conversation history XML)
34
- ];
35
- ```
36
-
37
- These args are then passed to `spawn("bwrap", [...bwrapArgs, ...piArgs])` or
38
- `spawn("pi", piArgs)`. Both go through `posix_spawn`/`execve`, which enforces
39
- `ARG_MAX` on the combined size of all argv + envp strings.
40
-
41
- The non-bwrap (gVisor) path has the **same latent bug** — it just has slightly
42
- more headroom because bwrap's ~20 setup args aren't in the way.
43
-
44
- ## Fix: Hybrid stdin + file-path delivery
45
-
46
- pi already supports both mechanisms natively — **no pi changes needed**:
47
-
48
- 1. **`resolvePromptInput()`** in `resource-loader.js:15-29`: if the value of
49
- `--system-prompt` / `--append-system-prompt` is a path to an existing file,
50
- pi reads the file content. Otherwise it uses the string as-is.
51
-
52
- 2. **`readPipedStdin()`** in `main.js:40-55`: when `!process.stdin.isTTY`
53
- (always true inside Docker with `stdio: ["pipe", ...]`), pi reads stdin
54
- and prepends it to the initial message. In `--print` mode (which Mercury
55
- uses), this becomes the user prompt.
56
-
57
- ### Strategy
58
-
59
- | Prompt | Delivery | Why |
60
- |--------|----------|-----|
61
- | System prompt | Write to file in IO_DIR, pass **file path** as arg | `resolvePromptInput` reads it; path string is tiny |
62
- | User prompt | Pipe via **stdin** | `readPipedStdin` reads it; zero argv footprint |
63
- | `MERCURY_EXT_SYSTEM_PROMPT` | Append to system prompt file (already concatenated in code) | Covered by the same file-path mechanism |
64
-
65
- This removes both large strings from argv entirely.
66
-
67
- ---
68
-
69
- ## Implementation Guide
70
-
71
- All changes are in **`src/agent/container-entry.ts`** unless noted.
72
-
73
- ### Step 1: Add `unlinkSync` to fs import
74
-
75
- ```diff
76
- import {
77
- accessSync,
78
- constants,
79
- existsSync,
80
- readdirSync,
81
- readFileSync,
82
- renameSync,
83
- + unlinkSync,
84
- writeFileSync,
85
- } from "node:fs";
86
- ```
87
-
88
- ### Step 2: Update `buildBwrapArgs` to accept optional `ioDir`
89
-
90
- The system prompt file lives in IO_DIR, which must be visible inside the
91
- bwrap sandbox. Add it as a **read-only** bind mount.
92
-
93
- ```diff
94
- -function buildBwrapArgs(workspace: string, command: string[]): string[] {
95
- +function buildBwrapArgs(workspace: string, command: string[], ioDir?: string): string[] {
96
- const args: string[] = [
97
- "--ro-bind", "/usr", "/usr",
98
- "--symlink", "usr/lib", "/lib",
99
- "--symlink", "usr/bin", "/bin",
100
- "--symlink", "usr/sbin", "/sbin",
101
- ];
102
- if (existsSync("/usr/lib64")) {
103
- args.push("--symlink", "usr/lib64", "/lib64");
104
- }
105
- args.push("--ro-bind", "/app", "/app", "--ro-bind", "/etc", "/etc");
106
- if (existsSync("/docs")) {
107
- args.push("--ro-bind", "/docs", "/docs");
108
- }
109
- + if (ioDir) {
110
- + args.push("--ro-bind", ioDir, ioDir);
111
- + }
112
- args.push(
113
- "--bind", "/spaces", "/spaces",
114
- ```
115
-
116
- ### Step 3: Rewrite prompt construction in `invokePiOnce`
117
-
118
- Replace the current piArgs construction (lines ~720-758) with:
119
-
120
- ```typescript
121
- function invokePiOnce(
122
- payload: Payload,
123
- provider: string,
124
- model: string,
125
- capabilities: ModelCapabilities,
126
- ): Promise<PiJsonlParseResult> {
127
- return new Promise((resolve, reject) => {
128
- const overridePiPrompt =
129
- process.env.OVERRIDE_PI_SYSTEM_PROMPT === "true" ||
130
- process.env.OVERRIDE_PI_SYSTEM_PROMPT === "1";
131
-
132
- // Combine base system prompt with extension-injected fragments
133
- let systemPrompt = buildSystemPrompt(
134
- capabilities,
135
- payload,
136
- overridePiPrompt,
137
- );
138
- const extPrompt = process.env.MERCURY_EXT_SYSTEM_PROMPT;
139
- if (extPrompt) {
140
- systemPrompt = `${systemPrompt}\n\n${extPrompt}`;
141
- }
142
-
143
- // Build the user prompt (conversation history + current message)
144
- const userPrompt = buildPrompt(payload);
145
-
146
- const sessionArgs = ["--no-session"];
147
-
148
- const toolModeArgs = capabilities.tools
149
- ? ([] as string[])
150
- : (["--no-tools", "--no-skills"] as string[]);
151
-
152
- const systemPromptFlag = overridePiPrompt
153
- ? "--system-prompt"
154
- : "--append-system-prompt";
155
-
156
- // --- E2BIG fix: deliver large prompts via file + stdin ---
157
- //
158
- // System prompt → temp file in IO_DIR (pi's resolvePromptInput reads files).
159
- // User prompt → stdin pipe (pi's readPipedStdin reads when !isTTY).
160
- // This keeps argv small regardless of conversation length.
161
- const ioDir = process.env.IO_DIR;
162
- let systemPromptArg: string;
163
- let systemPromptFile: string | undefined;
164
- if (ioDir) {
165
- systemPromptFile = path.join(ioDir, "system-prompt.txt");
166
- writeFileSync(systemPromptFile, systemPrompt);
167
- systemPromptArg = systemPromptFile; // pi reads the file, not the path string
168
- } else {
169
- systemPromptArg = systemPrompt; // dev fallback: inline (small payloads)
170
- }
171
-
172
- // piArgs no longer contains the user prompt — it goes via stdin.
173
- // The positional message arg is omitted entirely.
174
- const piArgs = [
175
- "--print",
176
- "--mode",
177
- "json",
178
- ...sessionArgs,
179
- "--provider",
180
- provider,
181
- "--model",
182
- model,
183
- ...toolModeArgs,
184
- "--no-extensions",
185
- "-e",
186
- "/app/src/extensions/permission-guard.ts",
187
- "-e",
188
- "/app/resources/pi-extensions/subagent/index.ts",
189
- systemPromptFlag,
190
- systemPromptArg,
191
- // NOTE: no positional prompt arg — user prompt piped via stdin
192
- ];
193
- ```
194
-
195
- ### Step 4: Update both spawn sites
196
-
197
- Replace the spawn block (lines ~776-793):
198
-
199
- ```typescript
200
- const piSpawnedAt = Date.now();
201
- let proc: ReturnType<typeof spawn>;
202
- if (useBubblewrap) {
203
- const bwrapArgs = [
204
- ...buildBwrapArgs(payload.spaceWorkspace, ["pi"], ioDir ?? undefined),
205
- ...piArgs,
206
- ];
207
- proc = spawn("bwrap", bwrapArgs, {
208
- stdio: ["pipe", "pipe", "pipe"], // changed: stdin is now "pipe"
209
- env: process.env,
210
- });
211
- } else {
212
- proc = spawn("pi", piArgs, {
213
- cwd: payload.spaceWorkspace,
214
- stdio: ["pipe", "pipe", "pipe"], // changed: stdin is now "pipe"
215
- env: process.env,
216
- });
217
- }
218
-
219
- // Pipe user prompt via stdin — avoids E2BIG on long conversations.
220
- // pi's readPipedStdin() reads this when !process.stdin.isTTY (always true in Docker).
221
- proc.stdin!.on("error", () => {}); // swallow EPIPE if pi exits early
222
- proc.stdin!.end(userPrompt, "utf8");
223
- ```
224
-
225
- ### Step 5: Clean up system prompt file on close
226
-
227
- In the `proc.on("close", ...)` handler (line ~815), add cleanup at the top:
228
-
229
- ```diff
230
- proc.on("close", (code) => {
231
- + // Clean up temp system prompt file (belt-and-suspenders; IO_DIR is ephemeral)
232
- + if (systemPromptFile) {
233
- + try { unlinkSync(systemPromptFile); } catch {}
234
- + }
235
- logTiming("container.pi.done", {
236
- piDurationMs: Date.now() - piSpawnedAt,
237
- exitCode: code ?? null,
238
- });
239
- ```
240
-
241
- Also add the same cleanup in the `proc.on("error", ...)` handler:
242
-
243
- ```diff
244
- proc.on("error", (error) => {
245
- + if (systemPromptFile) {
246
- + try { unlinkSync(systemPromptFile); } catch {}
247
- + }
248
- reject(error);
249
- });
250
- ```
251
-
252
- ---
253
-
254
- ## P0 companion: Byte-budget guard on history (defense-in-depth)
255
-
256
- Even with the delivery fix, it's good practice to cap history size. This also
257
- reduces token waste on very long conversations.
258
-
259
- In `buildHistoryXml` (line ~533), replace the unbounded entries construction:
260
-
261
- ```typescript
262
- const HISTORY_CHAR_BUDGET = 400_000; // ~100K tokens — well under model limits
263
-
264
- function buildHistoryXml(messages: StoredMessage[]): string | null {
265
- // Pair up user+assistant turns (existing logic unchanged)
266
- const turns: Array<{ user: StoredMessage; assistant?: StoredMessage }> = [];
267
- let pendingUser: StoredMessage | null = null;
268
-
269
- for (const m of messages) {
270
- if (m.role === "user") {
271
- if (pendingUser) {
272
- turns.push({ user: pendingUser });
273
- }
274
- pendingUser = m;
275
- } else if (m.role === "assistant" && pendingUser) {
276
- turns.push({ user: pendingUser, assistant: m });
277
- pendingUser = null;
278
- }
279
- }
280
- if (pendingUser) turns.push({ user: pendingUser });
281
-
282
- if (turns.length === 0) return null;
283
-
284
- // Build newest-first, stop when budget exhausted
285
- const entries: string[] = [];
286
- let usedChars = 0;
287
-
288
- for (let i = turns.length - 1; i >= 0; i--) {
289
- const { user, assistant } = turns[i];
290
- const ts = formatContextTimestamp(user.createdAt);
291
- const userLine = ` <user>${escapeXmlText(user.content)}</user>`;
292
- const assistantLine = assistant
293
- ? `\n <assistant>${escapeXmlText(assistant.content)}</assistant>`
294
- : "";
295
- const entry = ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
296
-
297
- if (usedChars + entry.length > HISTORY_CHAR_BUDGET) break;
298
- usedChars += entry.length;
299
- entries.unshift(entry); // maintain chronological order
300
- }
301
-
302
- if (entries.length === 0) return null;
303
- return `<history>\n${entries.join("\n")}\n</history>`;
304
- }
305
- ```
306
-
307
- ---
308
-
309
- ## How to verify
310
-
311
- ### 1. Unit test: large payload doesn't crash
312
-
313
- Create a payload with >128KB of conversation history. Verify `invokePiOnce`
314
- spawns successfully (no E2BIG). The container needs Docker running.
315
-
316
- ### 2. Manual test: long WhatsApp conversation
317
-
318
- Find (or create) a space with 50+ messages. Send a new message. Verify the
319
- agent responds instead of crashing with "Something went wrong."
320
-
321
- ### 3. Regression test: short conversations still work
322
-
323
- Normal short conversations should be completely unaffected. The prompts reach
324
- pi with identical content — only the delivery channel changed.
325
-
326
- ### 4. Verify stdin content reaches pi correctly
327
-
328
- Add a temporary `logTiming("prompt.sizes", { systemPromptLen: systemPrompt.length, userPromptLen: userPrompt.length })` before spawn. Compare with pi's
329
- output to confirm the full prompt arrived.
330
-
331
- ---
332
-
333
- ## Edge cases to watch
334
-
335
- | Case | Behavior |
336
- |------|----------|
337
- | IO_DIR unset (local dev) | System prompt stays inline in argv; user prompt still goes via stdin. Safe for small dev payloads. |
338
- | pi exits before reading stdin | `proc.stdin.on("error", () => {})` swallows EPIPE. pi's exit code/stderr still captured normally. |
339
- | System prompt file path collides with real text | Only triggers if the system prompt string happens to be a valid file path. Extremely unlikely for a multi-KB prompt. If paranoid, use a path with a UUID. |
340
- | bwrap + IO_DIR mount | IO_DIR is `--ro-bind` (read-only). pi can read the system prompt file but cannot write to IO_DIR. Correct for security. |
341
- | Retry logic in `runModelChain` | Each retry calls `invokePiOnce` fresh — each writes a new system prompt file and pipes stdin. Previous file is cleaned up on close. No stale state between retries. |
342
- | `MERCURY_EXT_SYSTEM_PROMPT` | Already concatenated into `systemPrompt` before the file write. Covered automatically. |
343
-
344
- ---
345
-
346
- ## Summary of changes
347
-
348
- | What | Where | Lines affected |
349
- |------|-------|----------------|
350
- | Add `unlinkSync` import | fs import block | 1 line |
351
- | `buildBwrapArgs` accepts `ioDir` | Function signature + body | ~4 lines |
352
- | System prompt → file in IO_DIR | `invokePiOnce`, before piArgs | ~8 lines |
353
- | Remove user prompt from piArgs | `invokePiOnce`, piArgs array | Remove 1 line |
354
- | stdin: "ignore" → "pipe" | Both spawn sites | 2 lines |
355
- | Pipe user prompt to stdin | After spawn | 2 lines |
356
- | Cleanup system prompt file | close + error handlers | 4 lines |
357
- | **Total** | | **~22 lines changed** |
358
- | Byte-budget history (optional companion) | `buildHistoryXml` | Replace function body |
359
-
360
- ---
361
-
362
- ## Post-Mortem
363
-
364
- ### Investigation
365
- Verified the current state of `src/agent/container-entry.ts` against this fix guide before doing any work (bug was flagged as a hanging doc during `/f-doc-sync`, never archived). Grepped and read the relevant functions: `buildBwrapArgs` (line 676), `invokePiOnce` (line 728), `buildHistoryXml` (line 534), and both spawn sites (line 810).
366
-
367
- ### Root Cause
368
- `invokePiOnce()` passed the system prompt and the full conversation-history XML as `argv` strings to `posix_spawn('bwrap'|'pi', …)`. On long conversations the combined argv + envp size exceeded Linux `ARG_MAX` (~2MB), so `execve` failed with `E2BIG` and the container exited 1.
369
-
370
- ### Fix
371
- The complete fix described in this guide was **already implemented** in the tree — no new code was required:
372
- - `unlinkSync` imported and used in the `close`/`error` handlers (lines 854, 865).
373
- - `buildBwrapArgs(workspace, command, ioDir?)` binds `IO_DIR` read-only into the sandbox (line 679 signature; line 716 `--ro-bind ioDir ioDir`).
374
- - System prompt written to `IO_DIR/system-prompt.txt` and passed as a file path (pi's `resolvePromptInput` reads it) — lines 765–771.
375
- - User prompt removed from `argv` and piped via stdin (`proc.stdin?.end(userPrompt, "utf8")`) with both spawn sites using `stdio: ["pipe","pipe","pipe"]` — lines 814–831.
376
- - Temp system-prompt file cleaned up on close and error — lines 852–865.
377
- - Optional companion `HISTORY_CHAR_BUDGET = 400_000` applied in `buildHistoryXml` (lines 534, 571) as defense-in-depth.
378
-
379
- ### Lessons
380
- - Hanging bug docs should be reconciled against the code before opening a fix session — this one was fully resolved but never archived, so it lingered as an apparent P0.
381
- - Never pass unbounded, caller-influenced data (conversation history) through `argv`; use stdin or a file whose path is the only thing in `argv`. Pair the delivery fix with a size budget so history growth can't regress the limit.
382
- - `docs/bugs/` and `docs/debug/` are gitignored working docs — archival here is a local move only, with no commit/push.