@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.
- package/package.json +2 -2
- package/src/binary.ts +17 -10
- package/src/cli.ts +38 -8
- package/src/commands/hook.ts +7 -8
- package/src/commands/init.ts +136 -66
- package/src/commands/install-binary.ts +3 -2
- package/src/commands/memoryd.ts +3 -2
- package/src/commands/provision-peer.ts +172 -0
- package/src/commands/status.ts +15 -8
- package/src/commands/uninstall.ts +78 -48
- package/src/commands/update.ts +125 -53
- package/src/commands/verify.ts +125 -13
- package/src/egress.ts +181 -0
- package/src/fleet.ts +94 -31
- package/src/paths.ts +5 -0
- package/src/provision.ts +3 -2
- package/src/roles.ts +2 -1
- package/src/signing.ts +19 -23
- package/src/slot.ts +97 -52
- package/src/surfaces/claude.ts +497 -0
- package/src/surfaces/codex.ts +156 -0
- package/src/surfaces/lock.ts +72 -0
- package/src/surfaces/sweep.ts +170 -0
- package/src/sync-versions.ts +3 -2
- package/src/templates/index.ts +2 -1
- package/src/templates/skills.ts +196 -0
- package/src/watcher.ts +72 -68
package/src/commands/update.ts
CHANGED
|
@@ -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.
|
|
16
|
-
* 5.
|
|
17
|
-
*
|
|
15
|
+
* 4. fleet — re-write the fleet map from `iapeer list --json`;
|
|
16
|
+
* 5. surfaces — direct 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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
194
|
-
|
|
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
|
package/src/commands/verify.ts
CHANGED
|
@@ -25,10 +25,14 @@ import {
|
|
|
25
25
|
renderDoctrine,
|
|
26
26
|
renderedVersion,
|
|
27
27
|
} from "@agfpd/iapeer-memory-core";
|
|
28
|
-
import {
|
|
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
|
|
113
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
+
}
|