@agfpd/iapeer 0.4.78 → 0.4.79

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.
@@ -4,7 +4,7 @@ A peer can run in one of two **approval modes**. In `yolo` (the default — ever
4
4
 
5
5
  This is a **normative contract**. The approval broker is the daemon's, the fleet endpoints and `ev` kinds below are the surface that operator faces (CLI, tray, the Telegram runtime) are written against — not the daemon source. The interception facts (which runtime event fires, in which shape) are pinned to **Claude Code 2.1.201** and **codex-cli 0.142.5**, taken from the live binaries + current runtime docs.
6
6
 
7
- `gated` means **«ask before acting»**, not «sandboxed». It removes the bypass so blocking questions surface; it does **not** add an OS sandbox (a separate axis, not in v1). On codex, gated is explicitly `approvals ON + sandbox OFF` (see the toggle table).
7
+ `gated` means **«ask before acting»** — blocking questions surface instead of being bypassed. On **claude**, gated stays sandbox-free (the `PermissionRequest` hook is the interceptor). On **codex**, gated is the EXEMPLAR (reframe 07.07): it runs `approvals ON + a workspace-write sandbox` so codex shows its **OWN native approval modal** in the TUI on a risky action — an attached human answers it right there — while the supervisor proxies that same modal to the bar. iapeer installs **no broker hook** on gated codex (a `PreToolUse` hook would silence the native modal). The shared memory vault (outside every peer cwd) is granted writable so routine memory writes never prompt. See the codex toggle table.
8
8
 
9
9
  ## The approval-mode toggle
10
10
 
@@ -37,17 +37,17 @@ The bypass is a **launch argument**, different per runtime, and the mode is a co
37
37
 
38
38
  **Why matcher-free `PermissionRequest`, not `PreToolUse`.** `PermissionRequest` fires **only when the runtime's permission config decided to prompt** — a call not covered by an allow/deny rule. So the policy of *what to ask* stays 100% at the runtime: a user tunes it with ordinary permission rules (a tool in `permissions.allow` is never asked → the hook never fires → auto-allowed). No class matcher to drift, no new tool slipping through, no hang. iapeer only seeds the hook + the one allow-rule for the peer's own MCP tool.
39
39
 
40
- **codex** (`PreToolUse` hook — verified live, codex-cli 0.142.5):
40
+ **codex** (native modal + supervisor proxy — codex-cli 0.142.5):
41
41
 
42
42
  | # | Surface | yolo | gated |
43
43
  |---|---|---|---|
44
44
  | X1 | argv `--dangerously-bypass-approvals-and-sandbox` | present | **removed** |
45
- | X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=danger-full-access` — approvals **ON**, sandbox **OFF** (so loopback-MCP `send_to_peer` + writes to the memory vault outside cwd still work; `workspace-write` would cut the network and external paths). Session-scoped via `-c`, not host config. |
46
- | X3 | `PreToolUse` hook `<cwd>/.codex/hooks.json` + trust pre-seed in `~/.codex/config.toml [hooks.state]` | **absent** | **installed + trusted** (`preSeedCodexHooksTrust`; an untrusted codex hook is silently skipped) |
45
+ | X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=workspace-write -c sandbox_workspace_write.network_access=false` (+ `-c sandbox_workspace_write.writable_roots=[…]` when the host has a memory vault). approvals **ON**, an OS sandbox that makes cwd/`/tmp`/`$TMPDIR` + the vault silent and a risky write (outside those) or outbound network pop codex's **native modal**. The old `danger-full-access` was disproven by the live sandbox matrix (07.07): `send_to_peer` (MCP) is unaffected by the sandbox because Seatbelt wraps the shell CHILDREN codex spawns, not the codex process, so the loopback MCP call is never sandboxed; and the vault is restored via `writable_roots`. Session-scoped via `-c`, not host config. |
46
+ | X3 | `PreToolUse` broker hook | **absent** | **absent** — gated codex relies on its NATIVE modal + the supervisor proxy, so iapeer installs no broker hook (it would silence the native modal). A prior (pre-reframe) hook is removed on the next gated toggle. |
47
47
  | X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (both modes) | pre-trusted (matters more with approvals active) |
48
48
  | X5 | ready-gate `isInputReady` (`›` composer) | mode-independent | unchanged |
49
49
 
50
- The codex hook is matched by tool-name regex `^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$` — the coarse class matcher lives on the runtime's own `hooks.json` (the user tunes it as ordinary runtime config).
50
+ The gated↔yolo difference for codex is thus ONLY the launch config + the profile field (no hooks.json either way), so the round-trip is byte-identical by construction. (The `iapeer approval-hook` binary + its codex `allow`→abstain shaping stay in the tree — still correct for any deny-by-rule hook an operator adds, and for claude — they are simply not installed by the codex gated toggle.)
51
51
 
52
52
  ### Idempotency and the application moment
53
53
 
@@ -62,7 +62,7 @@ All surfaces are read/applied **at session start**. A **live session keeps its l
62
62
  Both runtimes carry a Claude-compatible **hook** system that intercepts an approval programmatically and returns `allow`/`deny` + a reason + the structured action content. That is the primary mechanism. **pty screen-scrape is a backstop for one class only** — claude's `dangerous-rm` circuit-breaker, which sits *above* the permission layer and no hook can see.
63
63
 
64
64
  - **claude `PermissionRequest`** — stdin carries `{tool_name, tool_input, …}`; the hook prints `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. A `deny` carries `message` to the model. Fires only on prompt-worthy calls.
65
- - **codex `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies. Verified live (0.142.5): codex fires `PreToolUse` (not `PermissionRequest`) for tool calls with `tool_name:"Bash"` for shell; a `deny` hard-blocks the tool and the reason reaches the model even under `sandbox_mode=danger-full-access` — so under the gated config (danger-full-access + on-request → `permission_mode=bypassPermissions`) the hook is the SOLE gate.
65
+ - **codex** — gated codex is NOT hook-gated (reframe): it runs `approval_policy=on-request` + a `workspace-write` sandbox and shows its **own native modal**, which the supervisor pty-scrape catches and routes (see below). The `iapeer approval-hook` binary still exists and its codex path is correct (verified live 0.142.5: codex fires `PreToolUse` with `tool_name:"Bash"`; a `deny` hard-blocks the tool and the reason reaches the model; a codex `allow` is unsupported by codex's `PreToolUse` so the hook **abstains** on allow rather than emitting a rejected decision) — but the codex gated toggle does not install it. It remains available for a deny-by-rule hook an operator wires by hand.
66
66
  - **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and routes by class (see *Supervisor pty-scrape classification* below): the known auto-Yes classes are pressed under yolo (with an audit line) / brokered under gated, while `org-policy` and any unrecognized `unknown-modal` always go to the human.
67
67
 
68
68
  The hook binary is the `iapeer approval-hook` subcommand: it reads the runtime's hook JSON on stdin, resolves `PEER_PERSONALITY`/`PEER_RUNTIME` from its session env and the daemon URL from `router.json`, POSTs a blocking request to the broker, and prints the runtime-appropriate decision JSON.
@@ -81,6 +81,8 @@ The supervisor nag-watcher classifies every mid-session blocking TUI modal it ob
81
81
  - **`org-policy`** is a barrier ABOVE the peer (an MCP org-restriction). It is recognized but **always routed to the human** on both modes (the owner's org rule is human-only), with the precise 3-option keys. Previously it was excluded from the matcher and a yolo peer HUNG on it — now closed.
82
82
  - **`unknown-modal`** is the generic residue: a numbered-select modal (a new one Anthropic/OpenAI shipped) matching none of the known signatures. It is **always routed to the human** on both modes. Because the option layout is unknown, the injection is **binary** (v1): **Allow presses option 1** (the proceed/primary position in every known select; the position-robust affirmative) and **Deny presses `Escape`** (the universal modal cancel — it commits no numbered choice, so it can never accidentally affirm). The broker `content` carries **explicit button semantics** — the verbatim modal block plus a header stating exactly what Allow presses (`option 1: "<verbatim label>"`) and that Deny cancels via Esc — so the human decides informed, not on an abstract Allow/Deny. A **>2-way** choice on an unknown modal is a v2 extension (most approvals are binary). If a modal ignores the injected keys (Esc not honored), the daemon **bounds re-enqueue** (a few rounds) then **logs once and safe-parks** — the peer stays blocked (safe), never an infinite ask loop nor a blind Yes. Both claude (`❯`) and codex (`›`) are covered; codex's known boot dialogs are excluded (owned by the boot-driver).
83
83
 
84
+ > **This is exactly the path that surfaces gated codex's native approval modal** (the reframe). Verified live (codex-cli 0.142.5): a write outside the workspace/writable roots yields `Would you like to run the following command? … › 1. Yes, proceed (y) / 2. … / 3. No, and tell Codex what to do differently (esc) — Press enter to confirm or esc to cancel`. The footer matches the select-footer signature, so `detectNumberedModal('›')` catches it; **Allow = option 1** ("Yes, proceed") and **Deny = Esc** (which is codex's own "No" option here), so the generic binary injection is exactly right for it. So gated codex needs no bespoke breaker — it rides this same generic path, and an attached human can equally answer the modal in the TUI.
85
+
84
86
  The supervisor scrape is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm above the permission layer; a non-hookable modal such as codex MCP-elicitation). A blocking prompt that is NOT a numbered select (a free-form text-input prompt) is out of v1 scope — the numbered-select family is the approval/confirm class in practice.
85
87
 
86
88
  ## Nomenclature v1 and content per action (criterion «what is being approved»)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.78",
3
+ "version": "0.4.79",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -188,12 +188,21 @@ export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.Pro
188
188
  if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
189
189
  else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
190
190
  } else if (rt === 'codex') {
191
- if (mode === 'gated') {
192
- const r = installCodexApproval(cwd, env)
193
- surfaces.push(`codex: ${r ? `hooks.json installed + trust ${r.trust?.state ?? '?'}` : 'hooks.json write SKIPPED'}`)
194
- } else {
195
- surfaces.push(`codex: ${removeCodexApproval(cwd, env) ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`)
196
- }
191
+ // Gated codex is the EXEMPLAR (docs/17 reframe, 07.07): it shows its OWN native approval modal
192
+ // (from the on-request / workspace-write launch config in codex buildArgv) and the SUPERVISOR
193
+ // proxies that modal to the bar (unknownBlockingModal → routeCircuitBreaker). So iapeer installs
194
+ // NO broker PreToolUse hook on gated codex — the hook would SILENCE the native modal (its whole
195
+ // pre-reframe purpose). We still REMOVE any hook a prior (pre-reframe) gated toggle left behind, so
196
+ // an existing peer migrates cleanly to the native-modal model. yolo is identical (no hook). The
197
+ // gated↔yolo difference for codex is now ONLY the launch config + the profile field, not hooks.json
198
+ // — so the round-trip stays byte-identical. (claude keeps its PermissionRequest broker hook — its
199
+ // native prompt is what that hook cleanly intercepts; only codex switched to the native-modal model.)
200
+ const removed = removeCodexApproval(cwd, env)
201
+ surfaces.push(
202
+ mode === 'gated'
203
+ ? `codex: native modal + supervisor proxy, no broker hook${removed ? ' (cleared prior hook)' : ''}`
204
+ : `codex: ${removed ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`,
205
+ )
197
206
  }
198
207
  }
199
208
  return { mode, surfaces }
@@ -209,13 +209,32 @@ export const codexAdapter: RuntimeAdapter = {
209
209
  * NO currency — no marketplace/install/update on this path.
210
210
  */
211
211
  buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
212
- // Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current
213
- // fleet behavior). gated = NO bypass + `-c approval_policy=on-request` (the model
214
- // asks before acting → the PreToolUse/PermissionRequest hook intercepts) with
215
- // `-c sandbox_mode=danger-full-access` — gated gates APPROVALS, it does NOT add a
216
- // sandbox (workspace-write would sever the loopback-MCP send_to_peer + vault writes;
217
- // sandboxing is a separate axis, not v1). Session-scoped `-c`, never a host-config edit.
212
+ // Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current fleet behavior).
213
+ //
214
+ // gated = codex as the EXEMPLAR (docs/17 reframe, 07.07): the peer shows its OWN NATIVE approval
215
+ // modal in the TUI on a risky action — an attached human approves right there — while the supervisor
216
+ // proxies that same modal to the bar (unknownBlockingModal → routeCircuitBreaker → broker card). So
217
+ // gated launches WITHOUT the bypass and WITHOUT a broker PreToolUse hook (the hook would SILENCE the
218
+ // native modal — that was the pre-reframe design; setApprovalMode no longer installs it on gated
219
+ // codex). The config, session-scoped `-c` (never a host-config edit):
220
+ // - `approval_policy=on-request` — the model asks before a non-auto-approved action → native modal.
221
+ // - `sandbox_mode=workspace-write` — writes to the peer cwd (+ /tmp, $TMPDIR) are SILENT; a write
222
+ // OUTSIDE those is blocked → native modal. This SUPERSEDES the old `danger-full-access` (whose
223
+ // comment claimed ws-write would sever MCP + vault writes — DISPROVEN by the live sandbox matrix
224
+ // 07.07): send_to_peer (MCP) goes THROUGH regardless of network_access because Seatbelt wraps the
225
+ // shell CHILDREN codex spawns, not the codex process itself, so the loopback MCP call is never
226
+ // sandboxed; and the vault is restored to writable via writable_roots just below.
227
+ // - `sandbox_workspace_write.writable_roots=[…]` — the extra roots (the shared memory vault, which
228
+ // lives OUTSIDE every peer cwd) so ROUTINE memory writes stay silent instead of popping the modal.
229
+ // Omitted when spec.writableRoots is empty (no memory provider). The value is a TOML inline array
230
+ // of strings — JSON.stringify yields exactly that (paths quoted, `"`/`\` escaped); launch shell-
231
+ // quotes the whole `-c` element once, so spaces/`~` in the path arrive intact. Array `-c` form
232
+ // verified live (codex-cli 0.142.5).
233
+ // - `sandbox_workspace_write.network_access=false` — pin outbound network OFF (the ws-write default,
234
+ // set explicitly so a codex default change can't silently open it). Real outbound stays a risk →
235
+ // native modal; MCP send_to_peer is unaffected (see above).
218
236
  const gated = spec.approvalMode === 'gated'
237
+ const roots = spec.writableRoots ?? []
219
238
  return [
220
239
  cfg.codexBin,
221
240
  ...(spec.resume ? ['resume', '--last'] : []),
@@ -226,7 +245,15 @@ export const codexAdapter: RuntimeAdapter = {
226
245
  ? ['-c', `model_instructions_file=${spec.systemPromptFile}`]
227
246
  : []),
228
247
  ...(gated
229
- ? ['-c', 'approval_policy=on-request', '-c', 'sandbox_mode=danger-full-access']
248
+ ? [
249
+ '-c',
250
+ 'approval_policy=on-request',
251
+ '-c',
252
+ 'sandbox_mode=workspace-write',
253
+ ...(roots.length > 0 ? ['-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`] : []),
254
+ '-c',
255
+ 'sandbox_workspace_write.network_access=false',
256
+ ]
230
257
  : ['--dangerously-bypass-approvals-and-sandbox']),
231
258
  ...(spec.extraArgs ?? []),
232
259
  ]
@@ -259,13 +286,16 @@ export const codexAdapter: RuntimeAdapter = {
259
286
  },
260
287
 
261
288
  /**
262
- * GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness). codex carries no known
263
- * mid-session circuit-breaker (its tool approvals ride the PreToolUse hook under gated, and yolo
264
- * bypasses them), so it declares no blockingConfirm/nagDismissKeys — but a NON-hookable modal
265
- * (MCP-elicitation, a future codex prompt) can still surface on screen and hang the pty. This catches
266
- * that residue structurally (bottom-most `›` is a numbered option + ≥2 options; see modalDetect.ts),
267
- * excluding the known boot dialogs (owned by the boot-driver). The daemon routes it to the human with
268
- * explicit button semantics + the timing stuck-gate on top.
289
+ * GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness) — AND, since the gated reframe
290
+ * (07.07), the catcher for codex's OWN NATIVE approval modal. codex declares no blockingConfirm: under
291
+ * yolo the bypass flag means no modal ever shows; under gated codex shows its native `approval_policy=
292
+ * on-request` / sandbox modal, which is a numbered `›` select — so THIS structural detector (bottom-most
293
+ * `›` is a numbered option + ≥2 options; see modalDetect.ts) is what surfaces it to the human, exactly
294
+ * like a truly-unforeseen modal. The daemon routes it to the human (bar card) with explicit button
295
+ * semantics + the timing stuck-gate on top; an ATTACHED human can equally answer it in the TUI directly.
296
+ * Known boot dialogs (owned by the boot-driver) are excluded first. NB modalDetect keys on a SELECT
297
+ * FOOTER (isSelectFooter) — that the codex approval modal's footer matches it is a LIVE-verified
298
+ * invariant (docs/17 §gated-codex); a codex footer that ever diverges would need its own signature here.
269
299
  */
270
300
  unknownBlockingModal(pane: string): { content: string; option1: string } | null {
271
301
  if (isKnownCodexBlockingSignature(pane)) return null
@@ -175,6 +175,13 @@ export interface LaunchSpec {
175
175
  * requests route to a human via the daemon broker (a PreToolUse hook, installed
176
176
  * separately at toggle time, is the interceptor). buildArgv reads it. */
177
177
  approvalMode?: ApprovalMode
178
+ /** Extra sandbox-writable roots BEYOND the peer cwd, for a gated codex peer launched under
179
+ * `sandbox_mode=workspace-write` (docs/17). Consumed ONLY by codex's gated buildArgv (emitted as
180
+ * `-c sandbox_workspace_write.writable_roots=[…]`) — claude/router/yolo ignore it, the same
181
+ * codex-only shape as `disallowedTools` is claude-only. Resolved at spec-build from the host's
182
+ * memory provider (the shared vault lives OUTSIDE every peer cwd, so a memory write would otherwise
183
+ * hit codex's native modal); empty/omitted → no extra roots. */
184
+ writableRoots?: string[]
178
185
  }
179
186
 
180
187
  // ─────────────────────────────────────────────────────────────────────────────
@@ -28,6 +28,7 @@ import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/
28
28
  import { err, ok, type Result } from '../core/errors.ts'
29
29
  import { capPaneLogs } from '../launch/cmdlog.ts'
30
30
  import { resolveGlobalRoot } from '../storage/index.ts'
31
+ import { resolveMemoryWritableRoots } from '../status/index.ts'
31
32
  import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
32
33
  import {
33
34
  ephemeralQueueDepth,
@@ -1109,6 +1110,17 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1109
1110
  ? composePeerPrompt(peer, cwd, identity, cfg, env, peersIndex.peers.map(publicPeerSummary))
1110
1111
  : undefined
1111
1112
 
1113
+ // Human-approval mode from the LOCAL profile (docs/17): drives the runtime bypass flag +
1114
+ // gated permission/approval config in buildArgv. Default yolo. Resolved ONCE — also gates the
1115
+ // writable-roots resolution below.
1116
+ const approvalMode = peerApprovalMode(cwd)
1117
+ // Extra sandbox-writable roots for a GATED CODEX peer (docs/17): under sandbox_mode=workspace-write
1118
+ // a write outside the peer cwd (a memory note into the shared vault) is BLOCKED and pops codex's
1119
+ // native modal, so the vault is granted at launch. Resolved from the host's memory provider ONLY when
1120
+ // it can matter (codex + gated) — a yolo/claude wake never reads it. codex's gated buildArgv consumes
1121
+ // it; every other path ignores the field. Fail-open (empty when no memory provider).
1122
+ const writableRoots = runtime === 'codex' && approvalMode === 'gated' ? resolveMemoryWritableRoots(env) : undefined
1123
+
1112
1124
  // Hand the fully-resolved spec to the launch primitive (HOW). lifecycle has
1113
1125
  // made every WHEN/HOW-MANY decision (lock, registry, H4, runtime, resume).
1114
1126
  const spec: LaunchSpec = {
@@ -1124,9 +1136,8 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1124
1136
  // Carry the peer's nature so the launch primitive can enforce an adapter's
1125
1137
  // intelligence gate (telegram requires natural). From the registry record.
1126
1138
  intelligence: peer.intelligence,
1127
- // Human-approval mode from the LOCAL profile (docs/17): drives the runtime
1128
- // bypass flag + gated permission/approval config in buildArgv. Default yolo.
1129
- approvalMode: peerApprovalMode(cwd),
1139
+ approvalMode,
1140
+ ...(writableRoots && writableRoots.length > 0 ? { writableRoots } : {}),
1130
1141
  }
1131
1142
  const launchCfg: LaunchConfig = {
1132
1143
  claudeBin: cfg.claudeBin,
@@ -13,7 +13,7 @@
13
13
  import { closeSync, openSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { IAPEER_VERSION } from '../core/version.ts'
16
- import { resolveGlobalRoot } from '../storage/index.ts'
16
+ import { resolveGlobalRoot, pluginInstallDir } from '../storage/index.ts'
17
17
  import { daemonDiscoveryPath } from '../daemon/index.ts'
18
18
  import { waitForDaemonHealthy } from '../update/index.ts'
19
19
  import { iapeerBinPath } from '../install/index.ts'
@@ -112,6 +112,67 @@ export function heartbeatAgeSecs(provider: { heartbeat?: string }, nowMs: number
112
112
  }
113
113
  }
114
114
 
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ // Sandbox writable roots for a gated codex peer (docs/17). A gated codex session
117
+ // launches under `sandbox_mode=workspace-write`: writes to its own cwd (+ /tmp,
118
+ // $TMPDIR) are silent, but a write OUTSIDE those — a memory note into the shared
119
+ // vault, which lives in iCloud OUTSIDE every peer cwd — is BLOCKED and pops codex's
120
+ // native approval modal. Routine memory writes must not prompt, so the vault is added
121
+ // to `sandbox_workspace_write.writable_roots` at launch. This resolves those extra
122
+ // writable root(s) for the host (empty when the host has no memory provider).
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ /** config.env key the memory provider records its vault path under. */
126
+ const MEMORY_VAULT_ENV_KEY = 'IAPEER_MEMORY_VAULT_PATH'
127
+
128
+ /**
129
+ * Extra sandbox-writable roots a gated codex peer needs beyond its cwd — the memory vault
130
+ * on a host that has a memory provider, else empty.
131
+ *
132
+ * INTERIM TECHDEBT (v1 — a durable, tracked debt, NOT a permanent design): the vault path is
133
+ * read from the provider's OPERATOR-OWNED `config.env` (`IAPEER_MEMORY_VAULT_PATH`), located via
134
+ * the provider name the memory SLOT declares. This deliberately BYPASSES the slot contract — the
135
+ * slot (`memory-provider.json`) is the ONLY declarative foundation↔provider boundary; `config.env`
136
+ * is the provider's PRIVATE surface. It is read-only and fail-open, but it IS a side-coupling that a
137
+ * future change to the slot contract must be aware of. Why the bypass: the slot is single-writer
138
+ * (the provider), so foundation cannot populate a vault field itself, and adding one is a cross-repo
139
+ * change to `@agfpd/iapeer-memory` + its release — a disproportionate cost that would block delivery
140
+ * on a foreign deploy. FOLLOW-UP (the way out of the debt): promote the data root INTO the slot (a
141
+ * `MemoryProvider.dataRoots` field the provider writes) so foundation reads it from the declared
142
+ * contract and this `config.env` read is retired.
143
+ *
144
+ * Fail-open at every step: no memory slot / no config.env / no key / a non-absolute value → `[]`. A
145
+ * gated codex peer still works with an empty result — only a cross-vault write would then prompt (a
146
+ * safe degraded state, never a break). Uses the injected `env` for path resolution (IAPEER_ROOT-aware
147
+ * → sandbox-isolated in tests, exactly like the rest of the storage layer).
148
+ */
149
+ export function resolveMemoryWritableRoots(env: NodeJS.ProcessEnv = process.env): string[] {
150
+ const slot = readMemoryProvider(env)
151
+ if (!slot) return [] // no memory provider on this host → no vault to grant
152
+ try {
153
+ const cfgPath = join(pluginInstallDir(slot.provider, { env }), 'config.env')
154
+ const text = readFileSync(cfgPath, 'utf8')
155
+ for (const rawLine of text.split(/\r?\n/)) {
156
+ const line = rawLine.trim()
157
+ if (!line || line.startsWith('#')) continue
158
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
159
+ if (!m || m[1] !== MEMORY_VAULT_ENV_KEY) continue
160
+ let value = m[2].trim()
161
+ if (
162
+ value.length >= 2 &&
163
+ ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
164
+ ) {
165
+ value = value.slice(1, -1)
166
+ }
167
+ // Absolute path only — a relative writable_root is meaningless to codex's sandbox.
168
+ return value.startsWith('/') ? [value] : []
169
+ }
170
+ return [] // key absent from config.env
171
+ } catch {
172
+ return [] // unreadable config.env — fail-open to bare
173
+ }
174
+ }
175
+
115
176
  // ─────────────────────────────────────────────────────────────────────────────
116
177
  // VOICE slot — the voice-connect provider declaration (the same declarative
117
178
  // contract as memory, two differences by design: NO provision/unprovision (voice