@agfpd/iapeer-memory 0.1.2 → 0.1.3
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 +6 -2
- package/src/commands/init.ts +55 -10
- package/src/commands/install-binary.ts +2 -1
- package/src/commands/status.ts +3 -1
- package/src/commands/uninstall.ts +19 -3
- package/src/commands/update.ts +44 -2
- package/src/commands/verify.ts +117 -41
- package/src/paths.ts +3 -0
- package/src/signing.ts +168 -0
- package/src/templates/roles-en.ts +123 -108
- package/src/templates/roles-ru.ts +112 -101
- package/src/watcher.ts +203 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "iapeer-memory — peer memory for the iapeer ecosystem: vault, memoryd (index/search/MCP-http), layer-5 context fragments, role doctrines. The package IS the system; the claude/codex plugins are thin session sockets (docs/10-distribution.md, ADR-009).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@agfpd/iapeer-memory-core": "0.1.
|
|
30
|
+
"@agfpd/iapeer-memory-core": "0.1.3"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/bun": "^1.2.0",
|
package/src/binary.ts
CHANGED
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import path from "node:path";
|
|
26
|
+
import { signInstalledBinary, type SigningOutcome } from "./signing.js";
|
|
26
27
|
|
|
27
28
|
export function isCompiledRuntime(): boolean {
|
|
28
29
|
return import.meta.url.includes("/$bunfs/");
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export type InstallBinaryOutcome =
|
|
32
|
-
| { action: "compiled"; outPath: string; bytes: number }
|
|
33
|
+
| { action: "compiled"; outPath: string; bytes: number; signing: SigningOutcome }
|
|
33
34
|
| { action: "skipped-compiled"; outPath: string }
|
|
34
35
|
| { action: "failed"; outPath: string; detail: string };
|
|
35
36
|
|
|
@@ -65,7 +66,10 @@ export function installBinary(opts: { outPath: string }): InstallBinaryOutcome {
|
|
|
65
66
|
|
|
66
67
|
fs.chmodSync(tmp, 0o755);
|
|
67
68
|
fs.renameSync(tmp, outPath); // atomic swap — safe over a running binary on macOS
|
|
68
|
-
|
|
69
|
+
// Stable-identity re-sign on EVERY compile path (TCC grants survive
|
|
70
|
+
// updates — contract with iapeer, see signing.ts). Soft-fail by design.
|
|
71
|
+
const signing = signInstalledBinary(outPath);
|
|
72
|
+
return { action: "compiled", outPath, bytes: fs.statSync(outPath).size, signing };
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export function removeBinary(outPath: string): "removed" | "absent" {
|
package/src/commands/init.ts
CHANGED
|
@@ -41,7 +41,15 @@ import {
|
|
|
41
41
|
ROLE_NAMES,
|
|
42
42
|
} from "../templates/index.js";
|
|
43
43
|
import { packageVersion } from "../version.js";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
dreamTimerMessage,
|
|
46
|
+
patchWakePolicyEphemeral,
|
|
47
|
+
registerTimer,
|
|
48
|
+
registerWatcher,
|
|
49
|
+
sweepTimerMessage,
|
|
50
|
+
writeLauncherScript,
|
|
51
|
+
writeStaleCheckScript,
|
|
52
|
+
} from "../watcher.js";
|
|
45
53
|
|
|
46
54
|
type InitFlags = {
|
|
47
55
|
vault?: string;
|
|
@@ -241,7 +249,9 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
241
249
|
step(
|
|
242
250
|
"binary",
|
|
243
251
|
bin.action === "compiled"
|
|
244
|
-
? `${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB
|
|
252
|
+
? `${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB; signing: ${bin.signing.state}` +
|
|
253
|
+
`${bin.signing.state === "signed-new-identity" ? " — the one install-time keychain event" : ""}` +
|
|
254
|
+
`${bin.signing.state === "failed-soft" ? ` — ${bin.signing.detail}` : ""})`
|
|
245
255
|
: bin.action === "skipped-compiled"
|
|
246
256
|
? `kept existing ${bin.outPath} (running from the installed binary)`
|
|
247
257
|
: `compile failed — ${bin.detail}`,
|
|
@@ -291,17 +301,26 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
291
301
|
roleEntries.push({ role, peerCwd, template });
|
|
292
302
|
}
|
|
293
303
|
writeRolesManifest({ rolesManifestPath: paths.rolesManifestPath, roles: roleEntries });
|
|
304
|
+
// The copywriter is the inverted pipeline's first receiver — an
|
|
305
|
+
// EPHEMERAL worker (clean window per delivery, ADR-015): patch one key
|
|
306
|
+
// into its core-owned profile, no-clobber.
|
|
307
|
+
const cwEntry = roleEntries.find((r) => r.role === "copywriter");
|
|
308
|
+
const wakePolicy = cwEntry ? patchWakePolicyEphemeral(cwEntry.peerCwd) : "missing-profile";
|
|
294
309
|
step(
|
|
295
310
|
"roles",
|
|
296
|
-
`${roleEntries.map((r) => r.role).join(", ")} (doctrines v${version},
|
|
297
|
-
|
|
311
|
+
`${roleEntries.map((r) => r.role).join(", ")} (doctrines v${version}, ` +
|
|
312
|
+
`copywriter wake_policy: ${wakePolicy}, manifest ${paths.rolesManifestPath})`,
|
|
313
|
+
rolesOk && roleEntries.length === ROLE_NAMES.length && wakePolicy !== "missing-profile",
|
|
298
314
|
);
|
|
299
315
|
}
|
|
300
316
|
|
|
301
|
-
// 7.
|
|
302
|
-
//
|
|
317
|
+
// 7. notifier wiring (ADR-015, инверсия): the EVENT trigger targets the
|
|
318
|
+
// COPYWRITER (first receiver); two TIMERS target the index — weekly
|
|
319
|
+
// DREAM_TICK and the check-gated fail-open sweep. Registrant = index for
|
|
320
|
+
// all three (one durable profile to verify); same-id re-send = replace.
|
|
303
321
|
if (flags.skipEcosystem) {
|
|
304
322
|
step("watcher", "skipped (--skip-ecosystem)");
|
|
323
|
+
step("timers", "skipped (--skip-ecosystem)");
|
|
305
324
|
} else {
|
|
306
325
|
writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath });
|
|
307
326
|
const sent = registerWatcher({
|
|
@@ -310,10 +329,36 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
310
329
|
});
|
|
311
330
|
step(
|
|
312
331
|
"watcher",
|
|
313
|
-
sent.
|
|
314
|
-
?
|
|
315
|
-
:
|
|
316
|
-
|
|
332
|
+
sent.suppressed
|
|
333
|
+
? "skipped (test sandbox — sends suppressed)"
|
|
334
|
+
: sent.ok
|
|
335
|
+
? `registration sent (${paths.launcherPath} → target copywriter); confirm: iapeer-memory verify`
|
|
336
|
+
: `registration failed — ${sent.detail}`,
|
|
337
|
+
sent.ok || Boolean(sent.suppressed),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
writeStaleCheckScript({
|
|
341
|
+
checkScriptPath: paths.checkScriptPath,
|
|
342
|
+
vaultPath: vault,
|
|
343
|
+
inboxFolders: [getTaxonomy(locale).folders.inbox, getTaxonomy(locale).folders.inboxHuman],
|
|
344
|
+
});
|
|
345
|
+
const sweep = registerTimer({
|
|
346
|
+
message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
|
|
347
|
+
iapeerBin: flags.iapeerBin,
|
|
348
|
+
});
|
|
349
|
+
const dream = registerTimer({
|
|
350
|
+
message: dreamTimerMessage(),
|
|
351
|
+
iapeerBin: flags.iapeerBin,
|
|
352
|
+
});
|
|
353
|
+
const timersSandboxed = sweep.suppressed && dream.suppressed;
|
|
354
|
+
step(
|
|
355
|
+
"timers",
|
|
356
|
+
timersSandboxed
|
|
357
|
+
? "skipped (test sandbox — sends suppressed)"
|
|
358
|
+
: sweep.ok && dream.ok
|
|
359
|
+
? `sweep (@every 1h, check ${paths.checkScriptPath}) + dream-tick (weekly) → target index`
|
|
360
|
+
: `sweep: ${sweep.ok ? "sent" : sweep.detail}; dream: ${dream.ok ? "sent" : dream.detail}`,
|
|
361
|
+
Boolean(timersSandboxed) || (sweep.ok && dream.ok),
|
|
317
362
|
);
|
|
318
363
|
}
|
|
319
364
|
|
|
@@ -28,7 +28,8 @@ export function cmdInstallBinary(argv: string[]): number {
|
|
|
28
28
|
switch (outcome.action) {
|
|
29
29
|
case "compiled":
|
|
30
30
|
console.log(
|
|
31
|
-
`install-binary: compiled ${outcome.outPath} (${Math.round(outcome.bytes / 1024 / 1024)}MB
|
|
31
|
+
`install-binary: compiled ${outcome.outPath} (${Math.round(outcome.bytes / 1024 / 1024)}MB; ` +
|
|
32
|
+
`signing: ${outcome.signing.state}${outcome.signing.state === "failed-soft" ? ` — ${outcome.signing.detail}` : ""})`,
|
|
32
33
|
);
|
|
33
34
|
return 0;
|
|
34
35
|
case "skipped-compiled":
|
package/src/commands/status.ts
CHANGED
|
@@ -147,7 +147,9 @@ export async function cmdStatus(argv: string[]): Promise<number> {
|
|
|
147
147
|
}
|
|
148
148
|
console.log(
|
|
149
149
|
` ${"inbox".padEnd(width)} ` +
|
|
150
|
-
(count < 0
|
|
150
|
+
(count < 0
|
|
151
|
+
? `folder missing (${inboxDir})`
|
|
152
|
+
: `${count} draft(s) awaiting the pipeline (a growing pile = the copywriter thread is stuck; the sweep places stale drafts unvetted)`),
|
|
151
153
|
);
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -19,7 +19,13 @@ import fs from "node:fs";
|
|
|
19
19
|
import { memoryPaths } from "../paths.js";
|
|
20
20
|
import { removeBinary } from "../binary.js";
|
|
21
21
|
import { removeSlot } from "../slot.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
DREAM_TRIGGER_ID,
|
|
24
|
+
SWEEP_TRIGGER_ID,
|
|
25
|
+
unregisterTimer,
|
|
26
|
+
unregisterWatcher,
|
|
27
|
+
WATCHER_TRIGGER_ID,
|
|
28
|
+
} from "../watcher.js";
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Owner verification before signalling: the process command line must look
|
|
@@ -97,8 +103,8 @@ export function cmdUninstall(argv: string[]): number {
|
|
|
97
103
|
console.log(`slot : ${slot === "removed" ? "declaration removed" : "already absent"}`);
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
//
|
|
101
|
-
// the teaching
|
|
106
|
+
// notifier wiring: best-effort unregister of all three triggers (not-found
|
|
107
|
+
// is soft on the notifier side; teaching replies go to the index session).
|
|
102
108
|
const unreg = unregisterWatcher({ iapeerBin });
|
|
103
109
|
console.log(
|
|
104
110
|
`watcher : ${
|
|
@@ -107,6 +113,16 @@ export function cmdUninstall(argv: string[]): number {
|
|
|
107
113
|
: `unregister not sent (${unreg.detail}) — remove the trigger manually via the watcher peer`
|
|
108
114
|
}`,
|
|
109
115
|
);
|
|
116
|
+
for (const id of [SWEEP_TRIGGER_ID, DREAM_TRIGGER_ID]) {
|
|
117
|
+
const t = unregisterTimer({ id, iapeerBin });
|
|
118
|
+
console.log(
|
|
119
|
+
`timer : ${
|
|
120
|
+
t.ok
|
|
121
|
+
? `unregister sent for ${id}`
|
|
122
|
+
: `unregister not sent for ${id} (${t.detail}) — remove manually via the timer peer`
|
|
123
|
+
}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
110
126
|
|
|
111
127
|
console.log(`memoryd : ${stopMemorydByPidFile(paths.pidPath)}`);
|
|
112
128
|
|
package/src/commands/update.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
|
+
configFromEnv,
|
|
28
29
|
isLocaleId,
|
|
29
30
|
renderDoctrine,
|
|
30
31
|
type LocaleId,
|
|
@@ -35,7 +36,14 @@ import { readRolesManifest } from "../roles.js";
|
|
|
35
36
|
import { writeSlot } from "../slot.js";
|
|
36
37
|
import { materialiseTemplates } from "../templates/index.js";
|
|
37
38
|
import { packageVersion } from "../version.js";
|
|
38
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
dreamTimerMessage,
|
|
41
|
+
registerTimer,
|
|
42
|
+
registerWatcher,
|
|
43
|
+
sweepTimerMessage,
|
|
44
|
+
writeLauncherScript,
|
|
45
|
+
writeStaleCheckScript,
|
|
46
|
+
} from "../watcher.js";
|
|
39
47
|
import { stopMemorydByPidFile } from "./uninstall.js";
|
|
40
48
|
|
|
41
49
|
export function cmdUpdate(argv: string[]): number {
|
|
@@ -72,7 +80,8 @@ export function cmdUpdate(argv: string[]): number {
|
|
|
72
80
|
step(
|
|
73
81
|
"binary",
|
|
74
82
|
bin.action === "compiled"
|
|
75
|
-
? `recompiled ${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB
|
|
83
|
+
? `recompiled ${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB; signing: ${bin.signing.state}` +
|
|
84
|
+
`${bin.signing.state === "failed-soft" ? ` — ${bin.signing.detail}` : ""})`
|
|
76
85
|
: bin.action === "skipped-compiled"
|
|
77
86
|
? "running FROM the installed binary — recompile via: npx @agfpd/iapeer-memory@latest update"
|
|
78
87
|
: `compile failed — ${bin.detail}`,
|
|
@@ -120,6 +129,39 @@ export function cmdUpdate(argv: string[]): number {
|
|
|
120
129
|
// 5. launcher
|
|
121
130
|
step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
|
|
122
131
|
|
|
132
|
+
// 5b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
|
|
133
|
+
// the idempotent re-target path (old hosts with target=index migrate to
|
|
134
|
+
// target=copywriter by this very step).
|
|
135
|
+
{
|
|
136
|
+
let inboxFolders: string[] | null = null;
|
|
137
|
+
try {
|
|
138
|
+
const config = configFromEnv();
|
|
139
|
+
inboxFolders = [config.taxonomy.folders.inbox, config.taxonomy.folders.inboxHuman];
|
|
140
|
+
writeStaleCheckScript({
|
|
141
|
+
checkScriptPath: paths.checkScriptPath,
|
|
142
|
+
vaultPath: config.vaultPath,
|
|
143
|
+
inboxFolders,
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
// unprovisioned env — registrations below still re-target
|
|
147
|
+
}
|
|
148
|
+
const w = registerWatcher({ launcherPath: paths.launcherPath });
|
|
149
|
+
const s = registerTimer({
|
|
150
|
+
message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
|
|
151
|
+
});
|
|
152
|
+
const d = registerTimer({ message: dreamTimerMessage() });
|
|
153
|
+
const sandboxed = w.suppressed && s.suppressed && d.suppressed;
|
|
154
|
+
step(
|
|
155
|
+
"triggers",
|
|
156
|
+
sandboxed
|
|
157
|
+
? "skipped (test sandbox — sends suppressed)"
|
|
158
|
+
: w.ok && s.ok && d.ok
|
|
159
|
+
? `re-sent: event→copywriter, sweep+dream→index (same id = replace); confirm: verify`
|
|
160
|
+
: `event: ${w.ok ? "sent" : w.detail}; sweep: ${s.ok ? "sent" : s.detail}; dream: ${d.ok ? "sent" : d.detail}`,
|
|
161
|
+
Boolean(sandboxed) || (w.ok && s.ok && d.ok),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
// 6. memoryd managed restart (the watcher relaunches with the new binary)
|
|
124
166
|
step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
|
|
125
167
|
|
package/src/commands/verify.ts
CHANGED
|
@@ -30,9 +30,16 @@ import { readRolesManifest } from "../roles.js";
|
|
|
30
30
|
import { readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
|
|
31
31
|
import { packageVersion } from "../version.js";
|
|
32
32
|
import {
|
|
33
|
+
dreamTimerMessage,
|
|
34
|
+
DEFAULT_EVENT_TARGET,
|
|
35
|
+
DREAM_TRIGGER_ID,
|
|
33
36
|
readWatcherTrigger,
|
|
37
|
+
registerTimer,
|
|
34
38
|
registerWatcher,
|
|
39
|
+
sweepTimerMessage,
|
|
40
|
+
SWEEP_TRIGGER_ID,
|
|
35
41
|
writeLauncherScript,
|
|
42
|
+
writeStaleCheckScript,
|
|
36
43
|
WATCHER_TRIGGER_ID,
|
|
37
44
|
} from "../watcher.js";
|
|
38
45
|
|
|
@@ -49,6 +56,8 @@ export type VerifyOptions = {
|
|
|
49
56
|
staleMs?: number;
|
|
50
57
|
/** Injectable for tests. */
|
|
51
58
|
nowMs?: number;
|
|
59
|
+
/** Injectable for tests — repair MUST NOT reach the live notifier. */
|
|
60
|
+
iapeerBin?: string;
|
|
52
61
|
};
|
|
53
62
|
|
|
54
63
|
type RolesManifest = {
|
|
@@ -149,56 +158,123 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
|
|
|
149
158
|
});
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
// 3. notifier
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// re-run confirms.
|
|
161
|
+
// 3. notifier wiring (ADR-015). Durable triggers live in the REGISTRANT's
|
|
162
|
+
// peer profile (canonical storage contract): registrant = index for all
|
|
163
|
+
// three — the EVENT trigger (target=copywriter, inverted pipeline), the
|
|
164
|
+
// sweep timer and the dream timer (both target=index). Re-registration is
|
|
165
|
+
// ASYNC (same id = replace) — repair reports "sent", a re-run confirms.
|
|
158
166
|
{
|
|
159
167
|
const manifest = readRolesManifest(paths.rolesManifestPath);
|
|
160
168
|
const indexEntry = manifest?.roles.find((r) => r.role === "index") ?? null;
|
|
161
169
|
if (!indexEntry) {
|
|
162
|
-
|
|
163
|
-
name: "notifier-watcher",
|
|
164
|
-
status: "skip",
|
|
165
|
-
detail: "roles manifest has no index peer — init has not run",
|
|
166
|
-
});
|
|
167
|
-
} else {
|
|
168
|
-
const trigger = readWatcherTrigger({ registrantCwd: indexEntry.peerCwd });
|
|
169
|
-
const scriptOk = trigger?.script === paths.launcherPath;
|
|
170
|
-
if (trigger && scriptOk) {
|
|
170
|
+
for (const name of ["notifier-watcher", "sweep-timer", "dream-timer"]) {
|
|
171
171
|
results.push({
|
|
172
|
-
name
|
|
173
|
-
status: "
|
|
174
|
-
detail:
|
|
172
|
+
name,
|
|
173
|
+
status: "skip",
|
|
174
|
+
detail: "roles manifest has no index peer — init has not run",
|
|
175
175
|
});
|
|
176
|
-
}
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
const registrantCwd = indexEntry.peerCwd;
|
|
179
|
+
const checks: Array<{
|
|
180
|
+
name: string;
|
|
181
|
+
id: string;
|
|
182
|
+
role: "event" | "time";
|
|
183
|
+
expect: (t: NonNullable<ReturnType<typeof readWatcherTrigger>>) => string | null;
|
|
184
|
+
repairSend: () => { ok: boolean; detail: string };
|
|
185
|
+
}> = [
|
|
186
|
+
{
|
|
187
|
+
name: "notifier-watcher",
|
|
188
|
+
id: WATCHER_TRIGGER_ID,
|
|
189
|
+
role: "event",
|
|
190
|
+
expect: (t) =>
|
|
191
|
+
t.script !== paths.launcherPath
|
|
192
|
+
? `script is ${t.script}, expected ${paths.launcherPath}`
|
|
193
|
+
: t.target !== DEFAULT_EVENT_TARGET
|
|
194
|
+
? `target is ${t.target ?? "?"}, expected ${DEFAULT_EVENT_TARGET} (inverted pipeline)`
|
|
195
|
+
: null,
|
|
196
|
+
repairSend: () => {
|
|
197
|
+
writeLauncherScript({
|
|
198
|
+
launcherPath: paths.launcherPath,
|
|
199
|
+
binaryPath: paths.binaryPath,
|
|
200
|
+
});
|
|
201
|
+
return registerWatcher({
|
|
202
|
+
launcherPath: paths.launcherPath,
|
|
203
|
+
iapeerBin: opts.iapeerBin,
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "sweep-timer",
|
|
209
|
+
id: SWEEP_TRIGGER_ID,
|
|
210
|
+
role: "time",
|
|
211
|
+
expect: (t) =>
|
|
212
|
+
(t as { check?: string }).check !== paths.checkScriptPath
|
|
213
|
+
? `check is ${(t as { check?: string }).check ?? "?"}, expected ${paths.checkScriptPath}`
|
|
214
|
+
: t.target !== "index"
|
|
215
|
+
? `target is ${t.target ?? "?"}, expected index`
|
|
216
|
+
: null,
|
|
217
|
+
repairSend: () => {
|
|
218
|
+
try {
|
|
219
|
+
const config = configFromEnv();
|
|
220
|
+
writeStaleCheckScript({
|
|
221
|
+
checkScriptPath: paths.checkScriptPath,
|
|
222
|
+
vaultPath: config.vaultPath,
|
|
223
|
+
inboxFolders: [
|
|
224
|
+
config.taxonomy.folders.inbox,
|
|
225
|
+
config.taxonomy.folders.inboxHuman,
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
// unprovisioned env — the registration alone still heals the trigger
|
|
230
|
+
}
|
|
231
|
+
return registerTimer({
|
|
232
|
+
message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
|
|
233
|
+
iapeerBin: opts.iapeerBin,
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "dream-timer",
|
|
239
|
+
id: DREAM_TRIGGER_ID,
|
|
240
|
+
role: "time",
|
|
241
|
+
expect: (t) =>
|
|
242
|
+
t.target !== "index" ? `target is ${t.target ?? "?"}, expected index` : null,
|
|
243
|
+
repairSend: () =>
|
|
244
|
+
registerTimer({ message: dreamTimerMessage(), iapeerBin: opts.iapeerBin }),
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
for (const c of checks) {
|
|
248
|
+
const trigger = readWatcherTrigger({ registrantCwd, id: c.id, role: c.role });
|
|
177
249
|
const problem = trigger
|
|
178
|
-
?
|
|
179
|
-
: `no ${
|
|
180
|
-
if (
|
|
181
|
-
results.push({
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
binaryPath: paths.binaryPath,
|
|
250
|
+
? c.expect(trigger)
|
|
251
|
+
: `no ${c.id} trigger in ${registrantCwd}/.iapeer/peer-profile.json`;
|
|
252
|
+
if (problem === null) {
|
|
253
|
+
results.push({
|
|
254
|
+
name: c.name,
|
|
255
|
+
status: "ok",
|
|
256
|
+
detail: `trigger ${c.id} in index profile → target ${trigger!.target ?? "?"}`,
|
|
186
257
|
});
|
|
187
|
-
|
|
188
|
-
results.push(
|
|
189
|
-
sent.ok
|
|
190
|
-
? {
|
|
191
|
-
name: "notifier-watcher",
|
|
192
|
-
status: "repaired",
|
|
193
|
-
detail: `${problem} — re-registration sent (async; re-run verify to confirm)`,
|
|
194
|
-
}
|
|
195
|
-
: {
|
|
196
|
-
name: "notifier-watcher",
|
|
197
|
-
status: "fail",
|
|
198
|
-
detail: `${problem}; re-registration failed — ${sent.detail}`,
|
|
199
|
-
},
|
|
200
|
-
);
|
|
258
|
+
continue;
|
|
201
259
|
}
|
|
260
|
+
if (!repair) {
|
|
261
|
+
results.push({ name: c.name, status: "fail", detail: problem });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const sent = c.repairSend();
|
|
265
|
+
results.push(
|
|
266
|
+
sent.ok
|
|
267
|
+
? {
|
|
268
|
+
name: c.name,
|
|
269
|
+
status: "repaired",
|
|
270
|
+
detail: `${problem} — re-registration sent (async; re-run verify to confirm)`,
|
|
271
|
+
}
|
|
272
|
+
: {
|
|
273
|
+
name: c.name,
|
|
274
|
+
status: "fail",
|
|
275
|
+
detail: `${problem}; re-registration failed — ${sent.detail}`,
|
|
276
|
+
},
|
|
277
|
+
);
|
|
202
278
|
}
|
|
203
279
|
}
|
|
204
280
|
}
|
package/src/paths.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type MemoryPaths = {
|
|
|
50
50
|
templatesDir: string;
|
|
51
51
|
/** memoryd launcher — the notifier watcher's script (wraps the stable binary). */
|
|
52
52
|
launcherPath: string;
|
|
53
|
+
/** Sweep check-script — gates the fail-open inbox sweep (ADR-015). */
|
|
54
|
+
checkScriptPath: string;
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
export function memoryPaths(
|
|
@@ -87,6 +89,7 @@ export function memoryPaths(
|
|
|
87
89
|
env.IAPEER_MEMORY_BINARY_PATH || path.join(home, ".local", "bin", "iapeer-memory"),
|
|
88
90
|
templatesDir: path.join(path.dirname(configFile), "templates"),
|
|
89
91
|
launcherPath: path.join(path.dirname(configFile), "memoryd-launcher.sh"),
|
|
92
|
+
checkScriptPath: path.join(path.dirname(configFile), "inbox-stale-check.sh"),
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
|
package/src/signing.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable code-signing for the compiled binary — TCC grants must SURVIVE
|
|
3
|
+
* updates. Port of the iapeer reference (their signing.ts, 0079106): the
|
|
4
|
+
* bun-compiled binary is ad-hoc linker-signed (CDHash-only requirement) —
|
|
5
|
+
* every update is a NEW TCC subject → re-prompts. On THIS product the
|
|
6
|
+
* stake is higher: the vault lives in iCloud Drive (~/Library/Mobile
|
|
7
|
+
* Documents — TCC-protected), memoryd touches it on every index pass.
|
|
8
|
+
*
|
|
9
|
+
* CONTRACT with iapeer (topic memory-slot, 10.06, fixed on both sides —
|
|
10
|
+
* their comment block over SIGNING_IDENTITY_CN, b3f5dd4):
|
|
11
|
+
* - SHARED CN «iapeer Local Codesign» — one keychain identity per host for
|
|
12
|
+
* the whole stack; PER-PRODUCT identifier (ours: com.agfpd.iapeer-memory)
|
|
13
|
+
* → designated requirements differ per product, TCC subjects stay
|
|
14
|
+
* separate, ONE keychain prompt per host.
|
|
15
|
+
* - first-needs-creates with the IDENTICAL profile (EKU codeSigning,
|
|
16
|
+
* system LibreSSL p12, `security import -T /usr/bin/codesign`).
|
|
17
|
+
* Changing the CN or the profile — only by mutual agreement.
|
|
18
|
+
* - Footnote 1 (race): two installers first-creating the identity
|
|
19
|
+
* simultaneously → duplicate CN → codesign "ambiguous identity".
|
|
20
|
+
* Residual accepted: installs are operator-sequential; we never sign
|
|
21
|
+
* from parallel sweeps.
|
|
22
|
+
* - Footnote 2 (blast radius): deleting/re-creating the identity changes
|
|
23
|
+
* the cert leaf → every product of the stack re-prompts once. The
|
|
24
|
+
* accepted price of one shared key.
|
|
25
|
+
* - Footnote 3 (expiry): the cert lives 3650 days (~2036); expiry =
|
|
26
|
+
* re-creation = footnote 2.
|
|
27
|
+
*
|
|
28
|
+
* ONE binary = ONE identifier: memoryd is the same Mach-O
|
|
29
|
+
* (`launcher → exec binary memoryd`), so a single TCC subject covers the
|
|
30
|
+
* CLI and the daemon.
|
|
31
|
+
*
|
|
32
|
+
* Failure policy: SOFT — the binary works ad-hoc-signed exactly as before;
|
|
33
|
+
* a signing hiccup must never break install/update (reported loud: the
|
|
34
|
+
* operator learns TCC prompts will re-appear). 90 s ceiling so an
|
|
35
|
+
* unanswered keychain prompt can't wedge an unattended update.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import os from "node:os";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
|
|
42
|
+
export const SIGNING_IDENTITY_CN = "iapeer Local Codesign";
|
|
43
|
+
export const SIGNING_IDENTIFIER = "com.agfpd.iapeer-memory";
|
|
44
|
+
|
|
45
|
+
/** System LibreSSL — always present on macOS; its pkcs12 output imports
|
|
46
|
+
* into the keychain directly (homebrew OpenSSL 3.x p12 needs -legacy —
|
|
47
|
+
* live-caught by the iapeer experiment; pinning the system binary removes
|
|
48
|
+
* the PATH-dependent branch). */
|
|
49
|
+
const SYSTEM_OPENSSL = "/usr/bin/openssl";
|
|
50
|
+
|
|
51
|
+
export type SigningRunner = (
|
|
52
|
+
cmd: string,
|
|
53
|
+
args: string[],
|
|
54
|
+
) => { status: number | null; stdout: string; stderr: string };
|
|
55
|
+
|
|
56
|
+
const defaultRunner: SigningRunner = (cmd, args) => {
|
|
57
|
+
try {
|
|
58
|
+
const r = Bun.spawnSync([cmd, ...args], {
|
|
59
|
+
stdout: "pipe",
|
|
60
|
+
stderr: "pipe",
|
|
61
|
+
timeout: 90_000,
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
status: r.exitCode,
|
|
65
|
+
stdout: r.stdout.toString(),
|
|
66
|
+
stderr: r.stderr.toString(),
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { status: null, stdout: "", stderr: String(err) };
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type SigningOutcome = {
|
|
74
|
+
state:
|
|
75
|
+
| "signed" // re-signed with the existing identity
|
|
76
|
+
| "signed-new-identity" // identity created this run (the ONE install-time event)
|
|
77
|
+
| "skipped-sandbox" // tests never touch the real keychain
|
|
78
|
+
| "failed-soft"; // binary stays ad-hoc (works; TCC prompts return)
|
|
79
|
+
detail?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Deliberately NOT `-v` (valid-only): the self-signed cert reads
|
|
83
|
+
* CSSMERR_TP_NOT_TRUSTED, which is fine for signing — `-v` would hide it
|
|
84
|
+
* and re-create endlessly (iapeer reference fact). */
|
|
85
|
+
function identityPresent(run: SigningRunner): boolean {
|
|
86
|
+
const r = run("security", ["find-identity", "-p", "codesigning"]);
|
|
87
|
+
return r.status === 0 && r.stdout.includes(`"${SIGNING_IDENTITY_CN}"`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
|
|
91
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "iapeer-memory-signing-"));
|
|
92
|
+
const key = path.join(dir, "key.pem");
|
|
93
|
+
const cert = path.join(dir, "cert.pem");
|
|
94
|
+
const p12 = path.join(dir, "id.p12");
|
|
95
|
+
// Throwaway transport password — lives seconds inside a 0700 tmp dir.
|
|
96
|
+
const pass = `iapeer-memory-${process.pid}-${Math.floor(Math.random() * 1e9)}`;
|
|
97
|
+
try {
|
|
98
|
+
const req = run(SYSTEM_OPENSSL, [
|
|
99
|
+
"req", "-x509", "-newkey", "rsa:2048", "-keyout", key, "-out", cert,
|
|
100
|
+
"-days", "3650", "-nodes", "-subj", `/CN=${SIGNING_IDENTITY_CN}`,
|
|
101
|
+
"-addext", "keyUsage=digitalSignature",
|
|
102
|
+
"-addext", "extendedKeyUsage=codeSigning",
|
|
103
|
+
]);
|
|
104
|
+
if (req.status !== 0) {
|
|
105
|
+
return { ok: false, detail: `openssl req failed: ${req.stderr.trim().split("\n")[0] ?? ""}` };
|
|
106
|
+
}
|
|
107
|
+
const exp = run(SYSTEM_OPENSSL, [
|
|
108
|
+
"pkcs12", "-export", "-inkey", key, "-in", cert, "-out", p12,
|
|
109
|
+
"-passout", `pass:${pass}`, "-name", SIGNING_IDENTITY_CN,
|
|
110
|
+
]);
|
|
111
|
+
if (exp.status !== 0) {
|
|
112
|
+
return { ok: false, detail: `openssl pkcs12 failed: ${exp.stderr.trim().split("\n")[0] ?? ""}` };
|
|
113
|
+
}
|
|
114
|
+
// -T pre-authorizes codesign in the key's ACL — at most ONE keychain
|
|
115
|
+
// confirmation at the very first signing (the install-time event).
|
|
116
|
+
const imp = run("security", ["import", p12, "-P", pass, "-T", "/usr/bin/codesign"]);
|
|
117
|
+
if (imp.status !== 0) {
|
|
118
|
+
return { ok: false, detail: `security import failed: ${imp.stderr.trim().split("\n")[0] ?? ""}` };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true };
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
} catch {
|
|
125
|
+
// best-effort cleanup of the throwaway key material
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Re-sign the installed binary with the stable local identity (creating it
|
|
132
|
+
* on first use). Called by installBinary after the atomic rename — every
|
|
133
|
+
* install/update path — so the designated requirement (and every TCC
|
|
134
|
+
* grant) stays constant while the bytes change.
|
|
135
|
+
*/
|
|
136
|
+
export function signInstalledBinary(
|
|
137
|
+
binPath: string,
|
|
138
|
+
run: SigningRunner = defaultRunner,
|
|
139
|
+
): SigningOutcome {
|
|
140
|
+
// Both test belts (keychain is HOST-GLOBAL, same class as live sends).
|
|
141
|
+
if (
|
|
142
|
+
process.env.IAPEER_TEST_SANDBOX === "1" ||
|
|
143
|
+
process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1"
|
|
144
|
+
) {
|
|
145
|
+
return { state: "skipped-sandbox", detail: "test sandbox — not touching the real keychain" };
|
|
146
|
+
}
|
|
147
|
+
let created = false;
|
|
148
|
+
if (!identityPresent(run)) {
|
|
149
|
+
const c = createIdentity(run);
|
|
150
|
+
if (!c.ok) {
|
|
151
|
+
return {
|
|
152
|
+
state: "failed-soft",
|
|
153
|
+
detail: `${c.detail} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
created = true;
|
|
157
|
+
}
|
|
158
|
+
const sign = run("codesign", [
|
|
159
|
+
"-f", "-s", SIGNING_IDENTITY_CN, "--identifier", SIGNING_IDENTIFIER, binPath,
|
|
160
|
+
]);
|
|
161
|
+
if (sign.status !== 0) {
|
|
162
|
+
return {
|
|
163
|
+
state: "failed-soft",
|
|
164
|
+
detail: `codesign failed: ${sign.stderr.trim().split("\n")[0] ?? `exit ${sign.status}`} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return created ? { state: "signed-new-identity" } : { state: "signed" };
|
|
168
|
+
}
|