@agfpd/iapeer-memory 0.1.13 → 0.2.1

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.
@@ -12,14 +12,20 @@
12
12
  * 3. doctrines — re-render every role from the roles manifest with the
13
13
  * fresh version marker; roles pick it up on their next
14
14
  * cold wake (ADR-007), no restarts;
15
- * 4. slot — re-declare with the new version (contract obligation);
16
- * 5. launcherregenerate (in case the binary path moved);
17
- * 6. memoryd — MANAGED restart: verified SIGTERM via the pid file
15
+ * 4. fleet — re-write the fleet map from `iapeer list --json`;
16
+ * 5. surfacesdirect per-peer session surfaces sweep over the map
17
+ * (ADR-009 v1.2: the «всё на местах у подключённых пиров»
18
+ * duty — both runtimes, idempotent, repairs drift);
19
+ * 6. plugin-off — v1.1→v1.2 migration: when the on-disk slot still
20
+ * carries a plugin block, sweep the legacy plugin off the
21
+ * fleet (while the old declaration is STILL readable by
22
+ * the core verb) — only after surfaces landed cleanly;
23
+ * 7. slot — re-declare in the v1.2 form (provision command blocks,
24
+ * new version — contract obligation);
25
+ * 8. launcher + triggers + guide — regenerate;
26
+ * 9. memoryd — MANAGED restart: verified SIGTERM via the pid file →
18
27
  * the notifier watcher relaunches through the launcher
19
- * with the NEW binary (exit-detect is unconditional
20
- * notifier fact). Not running → nothing to do;
21
- * 7. plugin — the harness's native plugin update flow (printed hint;
22
- * the package never reaches into harness internals).
28
+ * with the NEW binary. Not running nothing to do.
23
29
  *
24
30
  * Idempotent: same version re-run → identical/no-op on every surface.
25
31
  */
@@ -34,10 +40,14 @@ import {
34
40
  type LocaleId,
35
41
  } from "@agfpd/iapeer-memory-core";
36
42
  import { installBinary } from "../binary.js";
37
- import { writeFleetMap } from "../fleet.js";
43
+ import type { Egress } from "../egress.js";
44
+ import { readFleetMap, writeFleetMap } from "../fleet.js";
38
45
  import { memoryPaths } from "../paths.js";
39
46
  import { readRolesManifest } from "../roles.js";
40
- import { writeSlot } from "../slot.js";
47
+ import { applyMemoryPlugin, readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
48
+ import { withProvisionLock } from "../surfaces/lock.js";
49
+ import { sweepProvision } from "../surfaces/sweep.js";
50
+ import { mcpPort } from "./provision-peer.js";
41
51
  import { guideText, materialiseTemplates } from "../templates/index.js";
42
52
  import { packageVersion } from "../version.js";
43
53
  import {
@@ -50,10 +60,13 @@ import {
50
60
  } from "../watcher.js";
51
61
  import { stopMemorydByPidFile } from "./uninstall.js";
52
62
 
53
- export function cmdUpdate(argv: string[]): number {
63
+ export function cmdUpdate(argv: string[], egress: Egress): number {
54
64
  let skipBinary = false;
55
- for (const a of argv) {
65
+ let iapeerBin: string | undefined;
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
56
68
  if (a === "--skip-binary") skipBinary = true;
69
+ else if (a === "--iapeer-bin") iapeerBin = argv[++i];
57
70
  else {
58
71
  console.error(`iapeer-memory update: unknown flag: ${a}`);
59
72
  return 2;
@@ -80,7 +93,7 @@ export function cmdUpdate(argv: string[]): number {
80
93
  if (skipBinary) {
81
94
  step("binary", "skipped (--skip-binary)");
82
95
  } else {
83
- const bin = installBinary({ outPath: paths.binaryPath });
96
+ const bin = installBinary(egress, { outPath: paths.binaryPath });
84
97
  step(
85
98
  "binary",
86
99
  bin.action === "compiled"
@@ -123,24 +136,103 @@ export function cmdUpdate(argv: string[]): number {
123
136
  );
124
137
  }
125
138
 
126
- // 4. slot version (contract obligation)
127
- const slot = writeSlot({
128
- slotPath: paths.slotPath,
129
- version,
130
- heartbeat: paths.heartbeatPath,
131
- });
132
- step(
133
- "slot",
134
- slot.action === "refused-foreign"
135
- ? `slot held by foreign provider "${slot.existing?.provider}"not ours to update`
136
- : `${slot.action} (v${version})`,
137
- slot.action !== "refused-foreign",
138
- );
139
+ // 4. fleet map — personality → cwd × runtimes (the joint of the surfaces
140
+ // sweep below AND memoryd's fragment renderer, docs/05). BEFORE surfaces
141
+ // and BEFORE the memoryd restart: both consume the fresh map.
142
+ {
143
+ const fleet = writeFleetMap(egress, { fleetMapPath: paths.fleetMapPath, iapeerBin });
144
+ step(
145
+ "fleet",
146
+ fleet.action === "written"
147
+ ? fleet.detail
148
+ : `fleet map not written (${fleet.detail})surfaces sweep runs on the LAST map; fragments stay stale until verify --repair`,
149
+ fleet.action === "written",
150
+ );
151
+ }
139
152
 
140
- // 5. launcher
153
+ // 5. direct session surfaces sweep (ADR-009 v1.2) — the update duty:
154
+ // «всё на местах у подключённых пиров, что codex, что claude».
155
+ const existingSlot = readSlot(paths.slotPath);
156
+ const slotForeign = existingSlot !== null && existingSlot.provider !== SLOT_PROVIDER;
157
+ let surfacesOk = false;
158
+ if (slotForeign) {
159
+ step("surfaces", "skipped (foreign slot — not our host)");
160
+ } else {
161
+ const fleet = readFleetMap(paths.fleetMapPath) ?? [];
162
+ const locked = withProvisionLock({
163
+ stateDir: paths.stateDir,
164
+ fn: () => sweepProvision({ fleet, hooksDir: paths.hooksDir, port: mcpPort() }),
165
+ });
166
+ if (!locked.acquired) {
167
+ step("surfaces", locked.detail, false);
168
+ } else {
169
+ const { results, skipped } = locked.result;
170
+ const failed = results.filter((r) => !r.ok);
171
+ surfacesOk = failed.length === 0;
172
+ step(
173
+ "surfaces",
174
+ `${results.length - failed.length}/${results.length} peer-runtime(s) in place` +
175
+ (skipped.length ? `, ${skipped.length} skipped` : "") +
176
+ " — live sessions pick changes up on next restart",
177
+ surfacesOk,
178
+ );
179
+ for (const f of failed) {
180
+ console.log(
181
+ ` surfaces FAIL ${f.personality}:${f.runtime} — ${f.outcomes
182
+ .filter((o) => o.action === "failed")
183
+ .map((o) => `${o.surface}: ${o.detail ?? "failed"}`)
184
+ .join("; ")}`,
185
+ );
186
+ }
187
+ }
188
+ }
189
+
190
+ // 6. v1.1 → v1.2 migration (one-shot per host): the on-disk slot still
191
+ // carries a plugin block — sweep the legacy plugin off the fleet WHILE the
192
+ // old declaration is still readable (the core verb derives the identity
193
+ // from it), and ONLY after the direct surfaces landed cleanly.
194
+ let migrationBlocked = false;
195
+ if (!slotForeign && existingSlot?.plugin) {
196
+ if (!surfacesOk) {
197
+ migrationBlocked = true;
198
+ step(
199
+ "plugin-off",
200
+ "POSTPONED: direct surfaces did not land cleanly — legacy plugin and v1.1 slot kept (fix and re-run update)",
201
+ false,
202
+ );
203
+ } else {
204
+ const off = applyMemoryPlugin(egress, { mode: "off" });
205
+ step(
206
+ "plugin-off",
207
+ off.suppressed
208
+ ? "skipped (test sandbox — core calls suppressed)"
209
+ : off.ok
210
+ ? "legacy v1.1 session plugin swept off the fleet (memory-plugin off --all)"
211
+ : `legacy plugin off failed (${off.detail.slice(0, 120)}) — manual: iapeer memory-plugin off --all`,
212
+ );
213
+ }
214
+ }
215
+
216
+ // 7. slot version + v1.2 form (contract obligation). Kept v1.1 while the
217
+ // migration is blocked — the legacy channel stays derivable.
218
+ if (slotForeign) {
219
+ step("slot", `slot held by foreign provider "${existingSlot?.provider}" — not ours to update`, false);
220
+ } else if (migrationBlocked) {
221
+ step("slot", "kept v1.1 declaration (migration postponed — see plugin-off)", false);
222
+ } else {
223
+ const slot = writeSlot({
224
+ slotPath: paths.slotPath,
225
+ version,
226
+ binaryPath: paths.binaryPath,
227
+ heartbeat: paths.heartbeatPath,
228
+ });
229
+ step("slot", `${slot.action} (v${version}, provision-command declared)`, slot.action !== "refused-foreign");
230
+ }
231
+
232
+ // 8. launcher
141
233
  step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
142
234
 
143
- // 5b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
235
+ // 8b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
144
236
  // the idempotent re-target path (old hosts with target=index migrate to
145
237
  // target=scriber by this very step).
146
238
  {
@@ -156,11 +248,11 @@ export function cmdUpdate(argv: string[]): number {
156
248
  } catch {
157
249
  // unprovisioned env — registrations below still re-target
158
250
  }
159
- const w = registerWatcher({ launcherPath: paths.launcherPath });
160
- const s = registerTimer({
251
+ const w = registerWatcher(egress, { launcherPath: paths.launcherPath });
252
+ const s = registerTimer(egress, {
161
253
  message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
162
254
  });
163
- const d = registerTimer({ message: dreamTimerMessage() });
255
+ const d = registerTimer(egress, { message: dreamTimerMessage() });
164
256
  const sandboxed = w.suppressed && s.suppressed && d.suppressed;
165
257
  step(
166
258
  "triggers",
@@ -173,7 +265,7 @@ export function cmdUpdate(argv: string[]): number {
173
265
  );
174
266
  }
175
267
 
176
- // 5c. host-wide guide — update an ALREADY-ROLLED-OUT guide only
268
+ // 8c. host-wide guide — update an ALREADY-ROLLED-OUT guide only
177
269
  // (presence = the rollout sanction; init --skip-guide hosts stay
178
270
  // untouched). Vault substituted into {{VAULT_PATH}} (дыра 10.06: the
179
271
  // literal placeholder left peers without the write path).
@@ -190,28 +282,8 @@ export function cmdUpdate(argv: string[]): number {
190
282
  }
191
283
  }
192
284
 
193
- // 5d. fleet map personality cwd for memoryd's fragment renderer
194
- // (docs/05; дыра 10.06). BEFORE the memoryd restart below: the fresh
195
- // daemon renders the whole fleet at startup from this very map.
196
- {
197
- const fleet = writeFleetMap({ fleetMapPath: paths.fleetMapPath });
198
- step(
199
- "fleet",
200
- fleet.action === "written"
201
- ? fleet.detail
202
- : `fleet map not written (${fleet.detail}) — fragments stay stale until verify --repair`,
203
- fleet.action === "written",
204
- );
205
- }
206
-
207
- // 6. memoryd managed restart (the watcher relaunches with the new binary)
208
- step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
209
-
210
- // 7. plugin — harness-owned surface
211
- console.log(
212
- " plugin update via the harness's native plugin flow " +
213
- "(claude/codex plugin manager); the package never reaches into harness internals",
214
- );
285
+ // 9. memoryd managed restart (the watcher relaunches with the new binary)
286
+ step("memoryd", `${stopMemorydByPidFile(egress, paths.pidPath)} the notifier watcher relaunches it with the new binary`);
215
287
 
216
288
  console.log(
217
289
  failures
@@ -25,10 +25,14 @@ import {
25
25
  renderDoctrine,
26
26
  renderedVersion,
27
27
  } from "@agfpd/iapeer-memory-core";
28
- import { writeFleetMap } from "../fleet.js";
28
+ import type { Egress } from "../egress.js";
29
+ import { readFleetMap, writeFleetMap } from "../fleet.js";
29
30
  import { memoryPaths, type MemoryPaths } from "../paths.js";
30
31
  import { readRolesManifest } from "../roles.js";
31
- import { readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
32
+ import { readSlot, slotProvisionBlocks, writeSlot, SLOT_PROVIDER } from "../slot.js";
33
+ import { withProvisionLock } from "../surfaces/lock.js";
34
+ import { checkFleetSurfaces, sweepProvision } from "../surfaces/sweep.js";
35
+ import { mcpPort } from "./provision-peer.js";
32
36
  import { packageVersion } from "../version.js";
33
37
  import {
34
38
  dreamTimerMessage,
@@ -65,7 +69,7 @@ type RolesManifest = {
65
69
  roles: Array<{ role: string; peerCwd: string; template: string }>;
66
70
  };
67
71
 
68
- export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
72
+ export function runVerify(egress: Egress, opts: VerifyOptions = {}): CheckResult[] {
69
73
  const repair = opts.repair ?? false;
70
74
  const paths = opts.paths ?? memoryPaths();
71
75
  const version = opts.version ?? packageVersion();
@@ -95,6 +99,11 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
95
99
 
96
100
  // 1b. memory-provider slot (iapeer memory-slot contract): a provisioned
97
101
  // host must declare the slot; a FOREIGN slot is never repaired over.
102
+ // A v1.1 slot (plugin block) is NEVER migrated here — the migration is
103
+ // update's job (plugin off --all must run while the old declaration is
104
+ // readable; a SessionStart-kicked repair racing ahead of update would
105
+ // strand the legacy plugin on the whole fleet).
106
+ let slotIsLegacyV11 = false;
98
107
  if (!configOk) {
99
108
  results.push({
100
109
  name: "memory-slot",
@@ -103,17 +112,37 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
103
112
  });
104
113
  } else {
105
114
  const slot = readSlot(paths.slotPath);
115
+ const expectedBlocks = slotProvisionBlocks(paths.binaryPath);
116
+ const formOk =
117
+ slot !== null &&
118
+ slot.plugin === undefined &&
119
+ JSON.stringify(slot.provision) === JSON.stringify(expectedBlocks.provision) &&
120
+ JSON.stringify(slot.unprovision) === JSON.stringify(expectedBlocks.unprovision);
106
121
  if (slot && slot.provider !== SLOT_PROVIDER) {
107
122
  results.push({
108
123
  name: "memory-slot",
109
124
  status: "fail",
110
125
  detail: `slot held by foreign provider "${slot.provider}" — refusing to touch (uninstall it first)`,
111
126
  });
112
- } else if (slot && slot.version === version) {
113
- results.push({ name: "memory-slot", status: "ok", detail: `declared v${slot.version}` });
127
+ } else if (slot?.plugin) {
128
+ slotIsLegacyV11 = true;
129
+ results.push({
130
+ name: "memory-slot",
131
+ status: "fail",
132
+ detail:
133
+ "slot is the legacy v1.1 form (plugin block) — migrate via `iapeer-memory update` (verify never migrates: the plugin-off order is update's duty)",
134
+ });
135
+ } else if (slot && slot.version === version && formOk) {
136
+ results.push({
137
+ name: "memory-slot",
138
+ status: "ok",
139
+ detail: `declared v${slot.version}, provision-command in place`,
140
+ });
114
141
  } else {
115
142
  const problem = slot
116
- ? `slot declares v${slot.version}, package is v${version}`
143
+ ? slot.version !== version
144
+ ? `slot declares v${slot.version}, package is v${version}`
145
+ : "provision-command block missing/drifted"
117
146
  : `slot declaration missing at ${paths.slotPath}`;
118
147
  if (!repair) {
119
148
  results.push({ name: "memory-slot", status: "fail", detail: problem });
@@ -121,6 +150,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
121
150
  const w = writeSlot({
122
151
  slotPath: paths.slotPath,
123
152
  version,
153
+ binaryPath: paths.binaryPath,
124
154
  heartbeat: paths.heartbeatPath,
125
155
  });
126
156
  results.push(
@@ -159,7 +189,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
159
189
  if (!repair) {
160
190
  results.push({ name: "fleet-map", status: "fail", detail: problem });
161
191
  } else {
162
- const w = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin: opts.iapeerBin });
192
+ const w = writeFleetMap(egress, { fleetMapPath: paths.fleetMapPath, iapeerBin: opts.iapeerBin });
163
193
  results.push(
164
194
  w.action === "written"
165
195
  ? { name: "fleet-map", status: "repaired", detail: `${problem} — ${w.detail}` }
@@ -169,6 +199,82 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
169
199
  }
170
200
  }
171
201
 
202
+ // 1d. direct per-peer session surfaces (ADR-009 v1.2) across the fleet
203
+ // map — the self-healing loop for newborns on hosts where the core's
204
+ // birth-hook lagged AND the drift-repair duty (требование №2). Skipped on
205
+ // a legacy v1.1 host: the plugin is still the live channel there, direct
206
+ // surfaces land via update's migration.
207
+ if (!configOk) {
208
+ results.push({ name: "peer-surfaces", status: "skip", detail: "not provisioned (config check failed)" });
209
+ } else if (slotIsLegacyV11) {
210
+ results.push({
211
+ name: "peer-surfaces",
212
+ status: "skip",
213
+ detail: "legacy v1.1 host (plugin channel) — direct surfaces land via `iapeer-memory update`",
214
+ });
215
+ } else {
216
+ const fleet = readFleetMap(paths.fleetMapPath);
217
+ if (!fleet) {
218
+ results.push({
219
+ name: "peer-surfaces",
220
+ status: "skip",
221
+ detail: "fleet map unreadable — see fleet-map check",
222
+ });
223
+ } else {
224
+ const { checks, skipped } = checkFleetSurfaces({
225
+ fleet,
226
+ hooksDir: paths.hooksDir,
227
+ port: mcpPort(),
228
+ });
229
+ const bad = checks.filter((c) => !c.ok);
230
+ if (bad.length === 0) {
231
+ results.push({
232
+ name: "peer-surfaces",
233
+ status: "ok",
234
+ detail:
235
+ `${checks.length} peer-runtime(s) in place` +
236
+ (skipped.length ? ` (${skipped.length} skipped: no session runtime / missing cwd)` : ""),
237
+ });
238
+ } else if (!repair) {
239
+ for (const b of bad) {
240
+ results.push({
241
+ name: `peer-surfaces[${b.personality}:${b.runtime}]`,
242
+ status: "fail",
243
+ detail: b.problems.join("; "),
244
+ });
245
+ }
246
+ } else {
247
+ const badPeers = fleet.filter((p) => bad.some((b) => b.cwd === p.cwd));
248
+ const locked = withProvisionLock({
249
+ stateDir: paths.stateDir,
250
+ fn: () => sweepProvision({ fleet: badPeers, hooksDir: paths.hooksDir, port: mcpPort() }),
251
+ });
252
+ if (!locked.acquired) {
253
+ results.push({ name: "peer-surfaces", status: "fail", detail: locked.detail });
254
+ } else {
255
+ const stillBad = locked.result.results.filter((r) => !r.ok);
256
+ results.push(
257
+ stillBad.length === 0
258
+ ? {
259
+ name: "peer-surfaces",
260
+ status: "repaired",
261
+ detail: `${bad.length} drifted peer-runtime(s) re-provisioned (${bad
262
+ .map((b) => `${b.personality}:${b.runtime}`)
263
+ .join(", ")})`,
264
+ }
265
+ : {
266
+ name: "peer-surfaces",
267
+ status: "fail",
268
+ detail: `repair failed for ${stillBad
269
+ .map((r) => `${r.personality}:${r.runtime}`)
270
+ .join(", ")}`,
271
+ },
272
+ );
273
+ }
274
+ }
275
+ }
276
+ }
277
+
172
278
  // 2. memoryd heartbeat
173
279
  try {
174
280
  const stat = fs.statSync(paths.heartbeatPath);
@@ -238,7 +344,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
238
344
  launcherPath: paths.launcherPath,
239
345
  binaryPath: paths.binaryPath,
240
346
  });
241
- return registerWatcher({
347
+ return registerWatcher(egress, {
242
348
  launcherPath: paths.launcherPath,
243
349
  iapeerBin: opts.iapeerBin,
244
350
  });
@@ -268,7 +374,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
268
374
  } catch {
269
375
  // unprovisioned env — the registration alone still heals the trigger
270
376
  }
271
- return registerTimer({
377
+ return registerTimer(egress, {
272
378
  message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
273
379
  iapeerBin: opts.iapeerBin,
274
380
  });
@@ -281,7 +387,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
281
387
  expect: (t) =>
282
388
  t.target !== "index" ? `target is ${t.target ?? "?"}, expected index` : null,
283
389
  repairSend: () =>
284
- registerTimer({ message: dreamTimerMessage(), iapeerBin: opts.iapeerBin }),
390
+ registerTimer(egress, { message: dreamTimerMessage(), iapeerBin: opts.iapeerBin }),
285
391
  },
286
392
  ];
287
393
  for (const c of checks) {
@@ -394,17 +500,23 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
394
500
  return results;
395
501
  }
396
502
 
397
- export function cmdVerify(argv: string[]): number {
503
+ export function cmdVerify(argv: string[], egress: Egress): number {
398
504
  let repair = false;
399
- for (const a of argv) {
505
+ let iapeerBin: string | undefined;
506
+ for (let i = 0; i < argv.length; i++) {
507
+ const a = argv[i];
400
508
  if (a === "--repair") repair = true;
509
+ // Mirror of `update --iapeer-bin` (fb662ed): the hermetic CLI test class
510
+ // needs an explicitly named core binary — the egress explicit-bin
511
+ // allowance keys on it.
512
+ else if (a === "--iapeer-bin") iapeerBin = argv[++i];
401
513
  else {
402
514
  console.error(`iapeer-memory verify: unknown flag: ${a}`);
403
515
  return 2;
404
516
  }
405
517
  }
406
518
 
407
- const results = runVerify({ repair });
519
+ const results = runVerify(egress, { repair, iapeerBin });
408
520
  const width = Math.max(...results.map((r) => r.name.length));
409
521
  for (const r of results) {
410
522
  const mark =
package/src/egress.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Egress hub — the ONE doorway from this package to the live host
3
+ * (docs/_planning/DENY_BY_DEFAULT_DESIGN.md §4, accepted by boris 11.06).
4
+ *
5
+ * Topology, not another fuse: four incidents («тест дотянулся до прода»)
6
+ * shared one root — outbound channels were allowed by default and refused
7
+ * only under a test flag each call site had to remember. This module
8
+ * inverts the default. Modules never spawn/kill/probe the host themselves
9
+ * and never PATH-resolve an external binary — they take an explicit
10
+ * {@link Egress} handle. The single live constructor, {@link liveEgress},
11
+ * is called from `cli.ts main()`; while a test-sandbox env is armed it
12
+ * hands back a REFUSING handle instead, and every channel reports refusal
13
+ * (callers map it to their SKIP semantics — the iapeer `skipped-sandbox`
14
+ * precedent). A module imported directly by a test has no doorway to the
15
+ * host by construction: there is nothing to forget.
16
+ *
17
+ * The grep invariant (И3) pins the topology: no `Bun.spawn*`/`process.kill`
18
+ * outside this file in src/.
19
+ *
20
+ * EXPLICIT ALLOWANCES of the refusing handle — each narrow, each here, all
21
+ * in one place (deny by DEFAULT, authorized consciously):
22
+ *
23
+ * 1. `explicitBin` spawns — argv[0] was NAMED by the operator/test via a
24
+ * flag (`--iapeer-bin <path>`). Same safety class as the sanctioned
25
+ * fake-bin test pattern: a consciously named binary is an authorization,
26
+ * a PATH-resolved default is not. (Closes the old env-juggling dance:
27
+ * fake-bin tests no longer clear the sandbox vars.)
28
+ * 2. Self-runtime spawns — argv[0] === process.execPath (bun): the binary
29
+ * compile (`bun build --compile` to a path-conventioned target) and the
30
+ * hook kick (self `verify --repair`). A child process re-enters through
31
+ * its OWN main() and inherits the sandbox env → its egress refuses too;
32
+ * nothing transitively reaches the host.
33
+ * 3. `ps` probes — read-only process-table lookup feeding the verified-kill
34
+ * guard (`pidLooksLikeOurs`). Refusing it would break the guard whose
35
+ * whole job is to make kill() safe.
36
+ * 4. Loopback fetch — status' own-daemon probes in sandboxed e2e. The
37
+ * host-daemon collision is closed by test port isolation (И3), not by
38
+ * refusing the probe. Non-loopback fetch refuses.
39
+ *
40
+ * kill() stays guarded by the verified-kill contract (owner verification
41
+ * before signalling — accepted at the P3c review), not by refusal: the pid
42
+ * PROVENANCE (sandbox pid file vs prod pid file) is the FS-belt's question
43
+ * (И2), and refusing kill would orphan sandbox daemons in e2e.
44
+ */
45
+
46
+ export type EgressSpawnResult = {
47
+ exitCode: number;
48
+ stdout: string;
49
+ stderr: string;
50
+ /** Set when the spawn itself failed (binary missing) or the egress
51
+ * refused the channel — exitCode is 127 by convention then. */
52
+ spawnError?: string;
53
+ /** True when the refusing egress (test sandbox) blocked the channel. */
54
+ refused?: boolean;
55
+ };
56
+
57
+ export type EgressSpawnOpts = {
58
+ timeoutMs?: number;
59
+ /** argv[0] was explicitly named by the operator/test (a `--*-bin` flag) —
60
+ * allowance 1 of the refusing handle. */
61
+ explicitBin?: boolean;
62
+ };
63
+
64
+ export interface Egress {
65
+ /** True for the refusing handle — callers may report a SKIP up front. */
66
+ readonly refused: boolean;
67
+ /** External binary, synchronous (iapeer, ps, openssl, security, codesign,
68
+ * bun). Never throws — a missing binary is a result, not a crash. */
69
+ spawnSync(argv: string[], opts?: EgressSpawnOpts): EgressSpawnResult;
70
+ /** Fire-and-forget detached spawn (hook kick → self `verify --repair`). */
71
+ spawnDetached(argv: string[]): { started: boolean; detail?: string };
72
+ /** Signal a live process. Never throws; `delivered: false` = process gone. */
73
+ kill(pid: number, signal: NodeJS.Signals): { delivered: boolean };
74
+ /** HTTP probe (status' loopback checks) — read-as-egress (П5). */
75
+ fetch(url: string, init?: RequestInit): Promise<Response>;
76
+ }
77
+
78
+ /** Default name of the ecosystem CLI — the ONE place it lives (П2: no
79
+ * scattered `?? "iapeer"` defaults; the danger moved into the handle). */
80
+ export const IAPEER_BIN = "iapeer";
81
+
82
+ /** The ONE definition of the sandbox-env check lives in core's fs-guard
83
+ * (the FS belt uses it too) — re-exported here for the constructor and
84
+ * its tests. */
85
+ import { sandboxEnvArmed } from "@agfpd/iapeer-memory-core";
86
+ export { sandboxEnvArmed };
87
+
88
+ function rawSpawnSync(argv: string[], opts?: EgressSpawnOpts): EgressSpawnResult {
89
+ try {
90
+ const proc = Bun.spawnSync(argv, {
91
+ stdout: "pipe",
92
+ stderr: "pipe",
93
+ ...(opts?.timeoutMs !== undefined ? { timeout: opts.timeoutMs } : {}),
94
+ });
95
+ return {
96
+ exitCode: proc.exitCode,
97
+ stdout: proc.stdout.toString(),
98
+ stderr: proc.stderr.toString(),
99
+ };
100
+ } catch (err) {
101
+ return { exitCode: 127, stdout: "", stderr: "", spawnError: String(err) };
102
+ }
103
+ }
104
+
105
+ function rawSpawnDetached(argv: string[]): { started: boolean; detail?: string } {
106
+ try {
107
+ const proc = Bun.spawn(argv, {
108
+ stdout: "ignore",
109
+ stderr: "ignore",
110
+ stdin: "ignore",
111
+ });
112
+ proc.unref();
113
+ return { started: true };
114
+ } catch (err) {
115
+ return { started: false, detail: String(err) };
116
+ }
117
+ }
118
+
119
+ function rawKill(pid: number, signal: NodeJS.Signals): { delivered: boolean } {
120
+ try {
121
+ process.kill(pid, signal);
122
+ return { delivered: true };
123
+ } catch {
124
+ return { delivered: false };
125
+ }
126
+ }
127
+
128
+ function isLoopback(url: string): boolean {
129
+ try {
130
+ const host = new URL(url).hostname;
131
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ const REFUSAL = "egress refused (test sandbox) — pass a fake egress";
138
+
139
+ function refusedResult(): EgressSpawnResult {
140
+ return { exitCode: 127, stdout: "", stderr: "", spawnError: REFUSAL, refused: true };
141
+ }
142
+
143
+ function refusingEgress(): Egress {
144
+ return {
145
+ refused: true,
146
+ spawnSync(argv, opts) {
147
+ if (opts?.explicitBin) return rawSpawnSync(argv, opts); // allowance 1
148
+ if (argv[0] === process.execPath) return rawSpawnSync(argv, opts); // allowance 2
149
+ if (argv[0] === "ps") return rawSpawnSync(argv, opts); // allowance 3
150
+ return refusedResult();
151
+ },
152
+ spawnDetached(argv) {
153
+ if (argv[0] === process.execPath) return rawSpawnDetached(argv); // allowance 2
154
+ return { started: false, detail: REFUSAL };
155
+ },
156
+ kill: rawKill, // verified-kill contract guards this, not refusal (header)
157
+ fetch(url, init) {
158
+ if (isLoopback(url)) return fetch(url, init); // allowance 4
159
+ return Promise.reject(new Error(REFUSAL));
160
+ },
161
+ };
162
+ }
163
+
164
+ function realEgress(): Egress {
165
+ return {
166
+ refused: false,
167
+ spawnSync: rawSpawnSync,
168
+ spawnDetached: rawSpawnDetached,
169
+ kill: rawKill,
170
+ fetch: (url, init) => fetch(url, init),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The ONE live constructor — called from `cli.ts main()` only. Refuses
176
+ * (hands back the refusing handle) while a test-sandbox env is armed; the
177
+ * decision is taken ONCE here, never re-checked at call sites.
178
+ */
179
+ export function liveEgress(): Egress {
180
+ return sandboxEnvArmed() ? refusingEgress() : realEgress();
181
+ }