@wipcomputer/wip-ldm-os 0.4.85-alpha.8 → 0.4.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +137 -15
  3. package/bin/ldm.js +608 -75
  4. package/lib/deploy.mjs +90 -12
  5. package/lib/registry-migrations.mjs +296 -0
  6. package/package.json +21 -3
  7. package/scripts/test-boot-dir-truth.mjs +158 -0
  8. package/scripts/test-boot-hook-registration.mjs +136 -0
  9. package/scripts/test-boot-payload-trim.mjs +200 -0
  10. package/scripts/test-crc-agentid-tenant-boundary.mjs +2 -2
  11. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  12. package/scripts/test-crc-e2ee-session-route.mjs +9 -2
  13. package/scripts/test-crc-pair-login-flow.mjs +76 -1
  14. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  15. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  16. package/scripts/test-deploy-hook-ownership.mjs +160 -0
  17. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  18. package/scripts/test-install-prompt-policy.mjs +84 -0
  19. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  20. package/scripts/test-installer-target-self-update.mjs +131 -0
  21. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  22. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  23. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  24. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  25. package/scripts/test-ldm-status-timeout.mjs +96 -0
  26. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  27. package/scripts/test-readme-install-prompt.mjs +66 -0
  28. package/shared/boot/boot-config.json +18 -8
  29. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  30. package/shared/rules/security.md +4 -0
  31. package/shared/templates/install-prompt.md +20 -2
  32. package/src/boot/README.md +24 -1
  33. package/src/boot/boot-config.json +18 -8
  34. package/src/boot/boot-hook.mjs +118 -28
  35. package/src/boot/installer.mjs +33 -10
  36. package/src/hosted-mcp/.env.example +4 -0
  37. package/src/hosted-mcp/README.md +37 -0
  38. package/src/hosted-mcp/app/footer.js +2 -2
  39. package/src/hosted-mcp/app/kaleidoscope-login.html +489 -42
  40. package/src/hosted-mcp/app/pair.html +21 -3
  41. package/src/hosted-mcp/app/wip-logo.png +0 -0
  42. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  43. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  44. package/src/hosted-mcp/demo/footer.js +2 -2
  45. package/src/hosted-mcp/demo/index.html +166 -44
  46. package/src/hosted-mcp/demo/login.html +87 -23
  47. package/src/hosted-mcp/demo/privacy.html +4 -214
  48. package/src/hosted-mcp/demo/tos.html +4 -189
  49. package/src/hosted-mcp/deploy.sh +2 -0
  50. package/src/hosted-mcp/docs/self-host.md +268 -0
  51. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  52. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  53. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  54. package/src/hosted-mcp/legal/legal.css +166 -0
  55. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  56. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  57. package/src/hosted-mcp/server.mjs +920 -74
package/lib/deploy.mjs CHANGED
@@ -19,6 +19,7 @@ import { join, basename, resolve, dirname } from 'node:path';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
21
21
  import { moveToTrash, appendToManifest } from './safe.mjs';
22
+ import { configureSessionStartHook } from '../src/boot/installer.mjs';
22
23
 
23
24
  const HOME = process.env.HOME || '';
24
25
  const LDM_ROOT = join(HOME, '.ldm');
@@ -1117,6 +1118,40 @@ function installClaudeCodeHook(repoPath, doorOrDoors, toolName = basename(repoPa
1117
1118
  }
1118
1119
 
1119
1120
  function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath)) {
1121
+ // Boot hook special case: single-owner delegation.
1122
+ //
1123
+ // The SessionStart boot hook's deployed command lives in ~/.ldm/shared/boot,
1124
+ // NOT under ~/.ldm/extensions/<toolName>/. The extension-dir ownership match
1125
+ // below keys on `/<toolName>/` in the command string, so it can never
1126
+ // recognize an existing boot entry and appends a fresh duplicate on every
1127
+ // manifest-driven install (10 copies accumulated 2026-07-04, ~450KB of boot
1128
+ // context per session). configureSessionStartHook() in src/boot/installer.mjs
1129
+ // is the single canonical registrar: it owns the whole boot-hook set,
1130
+ // collapses duplicates, canonicalizes the command to BOOT_DIR, persists, and
1131
+ // no-ops when already correct. Route boot-hook doors there so there is one
1132
+ // writer, not two. Discriminating on the command (not the event alone) keeps
1133
+ // other SessionStart hooks, e.g. wip-branch-guard's, on the normal path.
1134
+ //
1135
+ // Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--deploy-hook-ownership-misses-boot-hook.md
1136
+ const declaredCommand = door.command || '';
1137
+ const isBootHookDoor =
1138
+ (door.event || 'PreToolUse') === 'SessionStart' &&
1139
+ (declaredCommand.includes('boot-hook') || /shared[/\\]boot/.test(declaredCommand));
1140
+ if (isBootHookDoor) {
1141
+ if (DRY_RUN) {
1142
+ ok('Claude Code: would configure SessionStart boot hook (single-owner)');
1143
+ return true;
1144
+ }
1145
+ try {
1146
+ const result = configureSessionStartHook();
1147
+ ok(`Claude Code: ${result}`);
1148
+ return true;
1149
+ } catch (e) {
1150
+ fail(`Claude Code: boot hook registration failed. ${e.message}`);
1151
+ return false;
1152
+ }
1153
+ }
1154
+
1120
1155
  const settingsPath = join(HOME, '.claude', 'settings.json');
1121
1156
  let settings = readJSON(settingsPath);
1122
1157
 
@@ -1258,6 +1293,58 @@ function copySkillTree(skillDir, dest, copyFullFolder = false) {
1258
1293
  }
1259
1294
  }
1260
1295
 
1296
+ const SKILL_COMPANION_DIRS = ['references', 'agents', 'scripts', 'assets'];
1297
+
1298
+ function listSkillCopyEntries(skillDir, copyFullFolder = false) {
1299
+ if (copyFullFolder) {
1300
+ try {
1301
+ return readdirSync(skillDir)
1302
+ .filter((entry) => entry !== '.DS_Store')
1303
+ .sort();
1304
+ } catch {
1305
+ return ['SKILL.md'];
1306
+ }
1307
+ }
1308
+
1309
+ const entries = ['SKILL.md'];
1310
+ for (const child of SKILL_COMPANION_DIRS) {
1311
+ if (existsSync(join(skillDir, child))) entries.push(`${child}/`);
1312
+ }
1313
+ return entries;
1314
+ }
1315
+
1316
+ function printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries }) {
1317
+ const formatTargetEntries = (base) => entries.map((entry) => join(base, entry));
1318
+ const harnessTargets = Object.entries(harnesses)
1319
+ .filter(([, h]) => h.detected && h.skills)
1320
+ .map(([name, h]) => ({ name, base: join(h.skills, toolName) }));
1321
+
1322
+ ok(`Skill: ${toolName}`);
1323
+ log(`Source: ${sourceSkillDir}`);
1324
+ log('Would copy:');
1325
+ for (const entry of entries) log(`- ${entry}`);
1326
+
1327
+ const permanentBase = join(LDM_EXTENSIONS, toolName);
1328
+ log('Permanent copy:');
1329
+ for (const target of formatTargetEntries(permanentBase)) log(`- ${target}`);
1330
+
1331
+ if (harnessTargets.length > 0) {
1332
+ log('Agent skill targets:');
1333
+ for (const target of harnessTargets) {
1334
+ log(`- ${target.name}: ${target.base}`);
1335
+ for (const entryTarget of formatTargetEntries(target.base)) log(` - ${entryTarget}`);
1336
+ }
1337
+ } else {
1338
+ log('Agent skill targets: no detected skill harnesses');
1339
+ }
1340
+
1341
+ if (existsSync(refsSrc) && workspace && existsSync(workspace)) {
1342
+ const homeRefsDest = join(workspace, 'settings', 'docs', 'skills', toolName);
1343
+ log('Workspace docs target:');
1344
+ log(`- ${homeRefsDest} (references/ only)`);
1345
+ }
1346
+ }
1347
+
1261
1348
  function installSkillFolder(skillDir, toolName, opts = {}) {
1262
1349
  const { harnesses, workspace } = getHarnesses();
1263
1350
 
@@ -1281,17 +1368,8 @@ function installSkillFolder(skillDir, toolName, opts = {}) {
1281
1368
  if (!existsSync(refsSrc) && existsSync(permanentRefs)) refsSrc = permanentRefs;
1282
1369
 
1283
1370
  if (DRY_RUN) {
1284
- const targets = Object.entries(harnesses)
1285
- .filter(([,h]) => h.detected && h.skills)
1286
- .map(([,h]) => join(h.skills, toolName));
1287
- ok(`Skill: ${toolName}`);
1288
- log(`Source: ${sourceSkillDir}`);
1289
- if (targets.length > 0) {
1290
- log(`Targets:`);
1291
- for (const target of targets) log(`- ${target}`);
1292
- } else {
1293
- log(`Targets: no detected skill harnesses`);
1294
- }
1371
+ const entries = listSkillCopyEntries(sourceSkillDir, opts.copyFullFolder);
1372
+ printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries });
1295
1373
  return true;
1296
1374
  }
1297
1375
 
@@ -1659,4 +1737,4 @@ export function disableExtension(name) {
1659
1737
 
1660
1738
  // ── Exports for ldm CLI ──
1661
1739
 
1662
- export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS };
1740
+ export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS, installClaudeCodeHook };
@@ -0,0 +1,296 @@
1
+ // Registry migrations for ~/.ldm/extensions/registry.json.
2
+ //
3
+ // Phase 1 of the source-types refactor. Pure planner + npm-probe helper.
4
+ // Called by bin/ldm.js during `ldm install`. Idempotent: entries that already
5
+ // carry `updateSource` are skipped.
6
+ //
7
+ // `planLegacyNpmSourcesMigration` is pure (no filesystem I/O). The companion
8
+ // `executeDirectoryMoves` IS side-effecting; the planner emits a list of
9
+ // directory-move plans and the executor performs them. The split lets tests
10
+ // drive the planner with in-memory fixtures and exercise the executor against
11
+ // a real temp filesystem.
12
+ //
13
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
14
+ // and the parent design ai/product/bugs/installer/2026-05-13--cc-mini--installer-registry-source-types-architecture.md
15
+
16
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
20
+
21
+ // Phase 1 expedient. The known duplicate pairs surfaced on Parker's machine
22
+ // during the 2026-05-13 dogfood. General-case duplicate detection is the
23
+ // hygiene-audit ticket's Check 1
24
+ // (ai/product/bugs/installer/2026-05-13--cc-mini--installer-registry-hygiene-audit.md);
25
+ // do NOT extend this list as a long-term shape. Future entries belong in
26
+ // that audit, not here.
27
+ //
28
+ // Dedupe drops the duplicate's `installed` block entirely. Today both rows
29
+ // in each pair carry the same version, so no data is lost. If a duplicate
30
+ // ever carried a newer version than its canonical, the dedup would silently
31
+ // discard that information. Acceptable for the known pairs; not a general
32
+ // safe pattern.
33
+ const KNOWN_DUPLICATE_PAIRS = [
34
+ { keep: 'cc-session-export', remove: 'session-export' },
35
+ { keep: 'wip-branch-guard', remove: 'package' },
36
+ ];
37
+
38
+ export function emptyLegacyNpmSourcesSummary() {
39
+ return {
40
+ migrated: [],
41
+ phantomsRemoved: [],
42
+ duplicatesRemoved: [],
43
+ // Directory moves to perform AFTER the registry write. The planner
44
+ // emits these as a parallel list to duplicatesRemoved (1:1); the
45
+ // wrapper in bin/ldm.js executes them. See
46
+ // ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
47
+ // for the bug fix: without moving the on-disk directory out of
48
+ // ~/.ldm/extensions/, autoDetectExtensions re-registers the duplicate
49
+ // on the same install run and the dedup never persists.
50
+ directoryMoves: [],
51
+ directoryMovesPerformed: [],
52
+ directoryMovesSkipped: [],
53
+ probedCount: 0,
54
+ probeFailures: [],
55
+ timestamp: new Date().toISOString(),
56
+ };
57
+ }
58
+
59
+ export function summaryHasChanges(summary) {
60
+ return summary.migrated.length > 0
61
+ || summary.phantomsRemoved.length > 0
62
+ || summary.duplicatesRemoved.length > 0;
63
+ }
64
+
65
+ // Pure planner. Returns { newRegistry, summary } without touching the
66
+ // filesystem. Tests pass an in-memory registry, a fake `probeNpm`, and a
67
+ // fake `extensionExists`. Real callers inject the file-backed versions.
68
+ //
69
+ // probeNpm contract:
70
+ // returns true if the package exists on npm
71
+ // returns false if the package returns 404 (definitely doesn't exist)
72
+ // returns null if the probe failed (timeout / network) ... entry is left
73
+ // alone and retried on the next install
74
+ //
75
+ // extensionExists contract:
76
+ // called as (name, entry) -> boolean
77
+ // The entry is provided so the resolver can honor `entry.paths.ldm` and
78
+ // the legacy `entry.ldmPath` field before falling back to the default
79
+ // ~/.ldm/extensions/<name> path. A naive resolver that only checks the
80
+ // default location would falsely classify custom-path entries as
81
+ // phantoms and remove them. Real callers must check both.
82
+ export async function planLegacyNpmSourcesMigration({
83
+ registry,
84
+ probeNpm,
85
+ extensionExists,
86
+ now,
87
+ }) {
88
+ const summary = emptyLegacyNpmSourcesSummary();
89
+ if (now) summary.timestamp = now().toISOString();
90
+ if (!registry?.extensions) return { newRegistry: registry, summary };
91
+
92
+ // Shallow-clone the top level and each entry so the input is not mutated.
93
+ const newRegistry = { ...registry, extensions: {} };
94
+ for (const [name, entry] of Object.entries(registry.extensions)) {
95
+ newRegistry.extensions[name] = { ...entry };
96
+ }
97
+
98
+ // Step 1: phantoms. Registry rows with no on-disk extension directory.
99
+ // The acceptance criterion says these are removed entirely; they're
100
+ // surfaced as an explicit summary delta, not silent.
101
+ //
102
+ // extensionExists is called with both name and entry so the resolver can
103
+ // honor entry.paths.ldm / entry.ldmPath. A custom-path entry must not be
104
+ // misclassified as phantom.
105
+ for (const [name, entry] of Object.entries(newRegistry.extensions)) {
106
+ if (entry.updateSource) continue;
107
+ if (extensionExists(name, entry)) continue;
108
+ summary.phantomsRemoved.push({
109
+ name,
110
+ reason: 'directory missing',
111
+ legacyNpmName: entry.source?.npm || null,
112
+ });
113
+ delete newRegistry.extensions[name];
114
+ }
115
+
116
+ // Step 2: dedupe known pairs. Pure structural fix; the canonical row stays
117
+ // untouched. Future drift is the hygiene-audit ticket's job, not this one.
118
+ //
119
+ // Each removed duplicate also emits a directoryMoves entry: the wrapper
120
+ // moves ~/.ldm/extensions/<remove> to ~/.ldm/_trash/<remove>-deduplicated-<ts>
121
+ // after the registry write so autoDetectExtensions cannot re-register
122
+ // the duplicate on the same install run.
123
+ const trashStamp = summary.timestamp.replace(/[:.]/g, '-');
124
+ for (const { keep, remove } of KNOWN_DUPLICATE_PAIRS) {
125
+ if (newRegistry.extensions[keep] && newRegistry.extensions[remove]) {
126
+ summary.duplicatesRemoved.push({ keep, removed: remove });
127
+ summary.directoryMoves.push({
128
+ name: remove,
129
+ reason: 'deduplicated',
130
+ trashName: `${remove}-deduplicated-${trashStamp}`,
131
+ });
132
+ delete newRegistry.extensions[remove];
133
+ }
134
+ }
135
+
136
+ // Step 3: probe entries that still carry a legacy `source.npm` value.
137
+ // 404 -> migrate to untracked + provenance. 200 -> leave alone. Unknown ->
138
+ // leave alone; the next install will retry.
139
+ const probeTargets = [];
140
+ for (const [name, entry] of Object.entries(newRegistry.extensions)) {
141
+ if (entry.updateSource) continue;
142
+ const npmName = entry.source?.npm;
143
+ if (!npmName) continue;
144
+ probeTargets.push({ name, npmName });
145
+ }
146
+
147
+ const probeResults = await Promise.all(
148
+ probeTargets.map(async ({ name, npmName }) => {
149
+ const exists = await probeNpm(npmName);
150
+ summary.probedCount++;
151
+ return { name, npmName, exists };
152
+ })
153
+ );
154
+
155
+ for (const { name, npmName, exists } of probeResults) {
156
+ if (exists === true) continue;
157
+ if (exists === null) {
158
+ summary.probeFailures.push({ name, npmName });
159
+ continue;
160
+ }
161
+ const entry = newRegistry.extensions[name];
162
+ const legacyRepo = entry.source?.repo || null;
163
+ summary.migrated.push({ name, legacyNpmName: npmName, legacyRepo });
164
+ entry.updateSource = { type: 'untracked' };
165
+ entry.provenance = { ...(entry.provenance || {}) };
166
+ entry.provenance['legacy-npm-name'] = npmName;
167
+ if (legacyRepo) entry.provenance.repo = legacyRepo;
168
+ entry.provenance.untrackedSince = summary.timestamp;
169
+ delete entry.source;
170
+ }
171
+
172
+ // Step 4: entries with no source info at all (the mystery `run`-style row).
173
+ // Per the ticket, migrate to untracked so they stay visible in `ldm status`.
174
+ for (const [name, entry] of Object.entries(newRegistry.extensions)) {
175
+ if (entry.updateSource) continue;
176
+ if (entry.source?.npm || entry.source?.repo) continue;
177
+ summary.migrated.push({
178
+ name,
179
+ legacyNpmName: null,
180
+ legacyRepo: null,
181
+ reason: 'no-source-info',
182
+ });
183
+ entry.updateSource = { type: 'untracked' };
184
+ entry.provenance = { ...(entry.provenance || {}) };
185
+ entry.provenance.untrackedSince = summary.timestamp;
186
+ if ('source' in entry) delete entry.source;
187
+ }
188
+
189
+ return { newRegistry, summary };
190
+ }
191
+
192
+ // Execute the directory-move plans emitted by planLegacyNpmSourcesMigration.
193
+ // Side-effecting: moves directories from `extensionsRoot/<name>` to
194
+ // `trashRoot/<trashName>`. Creates `trashRoot` if needed. Skips moves whose
195
+ // source is missing or whose rename fails. Idempotent: a second call with the
196
+ // same plan returns all-skipped (source-missing) once the moves are done.
197
+ //
198
+ // Returns `{ performed: [...], skipped: [...] }` for the caller to append to
199
+ // the migration summary. Each performed entry gains a `destPath`; each skipped
200
+ // entry gains a `reason`.
201
+ //
202
+ // The contract is split from the planner so:
203
+ // - the planner stays pure and trivially testable with in-memory fixtures,
204
+ // - the executor can be exercised against a real temp filesystem in a test
205
+ // fixture without spawning a full `ldm install` run,
206
+ // - tests can inject `fs` primitives via the optional `fs` option for
207
+ // paranoid scenarios.
208
+ export function executeDirectoryMoves({
209
+ directoryMoves,
210
+ extensionsRoot,
211
+ trashRoot,
212
+ fs,
213
+ }) {
214
+ const _existsSync = fs?.existsSync || existsSync;
215
+ const _mkdirSync = fs?.mkdirSync || mkdirSync;
216
+ const _renameSync = fs?.renameSync || renameSync;
217
+
218
+ const performed = [];
219
+ const skipped = [];
220
+ if (!directoryMoves || directoryMoves.length === 0) {
221
+ return { performed, skipped };
222
+ }
223
+
224
+ _mkdirSync(trashRoot, { recursive: true });
225
+
226
+ for (const move of directoryMoves) {
227
+ const src = join(extensionsRoot, move.name);
228
+ const dest = join(trashRoot, move.trashName);
229
+ if (!_existsSync(src)) {
230
+ skipped.push({ ...move, reason: 'source-missing' });
231
+ continue;
232
+ }
233
+ try {
234
+ _renameSync(src, dest);
235
+ performed.push({ ...move, destPath: dest });
236
+ } catch (err) {
237
+ skipped.push({ ...move, reason: `rename-failed: ${err.message}` });
238
+ }
239
+ }
240
+
241
+ return { performed, skipped };
242
+ }
243
+
244
+ // Real npm-registry probe. Returns true/false/null per the planner contract.
245
+ // Mirrors the fetch pattern used by npmViewVersionForStatus in bin/ldm.js.
246
+ export async function npmPackageExists(pkgName, opts = {}) {
247
+ const registryUrl = (opts.registryUrl || DEFAULT_NPM_REGISTRY).replace(/\/+$/, '');
248
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 2000;
249
+ const controller = new AbortController();
250
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
251
+ try {
252
+ const url = `${registryUrl}/${encodeURIComponent(pkgName)}`;
253
+ const response = await fetch(url, {
254
+ signal: controller.signal,
255
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
256
+ });
257
+ if (response.status === 404) return false;
258
+ if (response.ok) return true;
259
+ return null;
260
+ } catch {
261
+ return null;
262
+ } finally {
263
+ clearTimeout(timeout);
264
+ }
265
+ }
266
+
267
+ // Doctor check: returns a list of registry entries that still carry a
268
+ // `source.npm` value pointing at a package that returns 404. These are
269
+ // candidates for migration on the next `ldm install`, but if the doctor
270
+ // runs first (Parker checks status / doctor before running install) we
271
+ // warn so it's not invisible.
272
+ //
273
+ // Returns: [{ name, npmName }] in lexical order.
274
+ export async function findLegacyNpm404Entries({
275
+ registry,
276
+ probeNpm,
277
+ }) {
278
+ if (!registry?.extensions) return [];
279
+ const targets = [];
280
+ for (const [name, entry] of Object.entries(registry.extensions)) {
281
+ if (entry.updateSource) continue;
282
+ const npmName = entry.source?.npm;
283
+ if (!npmName) continue;
284
+ targets.push({ name, npmName });
285
+ }
286
+ const results = await Promise.all(
287
+ targets.map(async ({ name, npmName }) => {
288
+ const exists = await probeNpm(npmName);
289
+ return { name, npmName, exists };
290
+ })
291
+ );
292
+ return results
293
+ .filter(r => r.exists === false)
294
+ .map(({ name, npmName }) => ({ name, npmName }))
295
+ .sort((a, b) => a.name.localeCompare(b.name));
296
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.85-alpha.8",
3
+ "version": "0.4.86",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -18,21 +18,39 @@
18
18
  "scripts": {
19
19
  "build:bridge": "cd src/bridge && npm install && npx tsup core.ts mcp-server.ts cli.ts openclaw.ts --format esm --dts --clean --outDir ../../dist/bridge",
20
20
  "build": "npm run build:bridge",
21
- "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest",
21
+ "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest && npm run test:install-prompt-policy && npm run test:readme-install-prompt && npm run test:ldm-status-timeout && npm run test:ldm-status-concurrency && npm run test:legacy-npm-sources-migration",
22
22
  "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
23
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
24
+ "test:install-prompt-policy": "node scripts/test-install-prompt-policy.mjs",
25
+ "test:readme-install-prompt": "node scripts/test-readme-install-prompt.mjs",
26
+ "test:ldm-status-timeout": "node scripts/test-ldm-status-timeout.mjs",
27
+ "test:ldm-status-concurrency": "node scripts/test-ldm-status-concurrency.mjs",
28
+ "test:legacy-npm-sources-migration": "node scripts/test-legacy-npm-sources-migration.mjs",
24
29
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
25
30
  "test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
31
+ "test:installer-target-self-update": "node scripts/test-installer-target-self-update.mjs",
26
32
  "test:installer-skill-directory": "node scripts/test-installer-skill-directory.mjs",
33
+ "test:installer-skill-dry-run-destinations": "node scripts/test-installer-skill-dry-run-destinations.mjs",
27
34
  "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
28
35
  "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
36
+ "test:boot-payload-trim": "node scripts/test-boot-payload-trim.mjs",
29
37
  "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
30
38
  "test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
31
39
  "test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
32
40
  "test:crc-pair-status-poll-token": "node scripts/test-crc-pair-status-poll-token.mjs",
41
+ "test:crc-pair-relink-audit-and-rotation": "node scripts/test-crc-pair-relink-audit-and-rotation.mjs",
42
+ "test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
33
43
  "test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
44
+ "test:crc-websocket-abuse-limits": "node scripts/test-crc-websocket-abuse-limits.mjs",
45
+ "test:kaleidoscope-qr-authenticator-confirmation": "node scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs",
46
+ "test:kaleidoscope-onboarding-copy": "node scripts/test-kaleidoscope-onboarding-copy.mjs",
47
+ "test:kaleidoscope-public-stats-baseline": "node scripts/test-kaleidoscope-public-stats-baseline.mjs",
34
48
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
35
- "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
49
+ "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
50
+ "test:boot-hook-registration": "node scripts/test-boot-hook-registration.mjs",
51
+ "test:doctor-hook-dedupe": "node scripts/test-doctor-hook-dedupe.mjs",
52
+ "test:boot-dir-truth": "node scripts/test-boot-dir-truth.mjs",
53
+ "test:deploy-hook-ownership": "node scripts/test-deploy-hook-ownership.mjs"
36
54
  },
37
55
  "claudeCode": {
38
56
  "hook": {
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: `ldm doctor` boot-hook stale-at-execution-path check.
3
+ //
4
+ // Covers:
5
+ // ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
6
+ //
7
+ // The split-brain bug: syncBootHook() deployed boot-hook.mjs to
8
+ // ~/.ldm/library/boot while the registered SessionStart hook executed
9
+ // ~/.ldm/shared/boot. New code landed in library/, sessions ran the stale
10
+ // copy in shared/, and the install reported success. This test drives the
11
+ // doctor check that makes the drift visible (and the --fix that repairs the
12
+ // execution path), plus the no-false-positive cases.
13
+ //
14
+ // Follows the test-doctor-hook-dedupe.mjs pattern: real bin/ldm.js doctor
15
+ // against a temp HOME, crontab/npm shimmed on PATH so operator state and the
16
+ // network are never touched. LDM_SELF_UPDATED=1 skips CLI self-update.
17
+
18
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+ import { tmpdir } from 'node:os';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
+ const cli = join(repo, 'bin', 'ldm.js');
26
+ const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
27
+ // The current boot hook this CLI would deploy: doctor compares against it.
28
+ const srcBootContent = readFileSync(join(repo, 'src', 'boot', 'boot-hook.mjs'), 'utf8');
29
+
30
+ let failed = 0;
31
+ function assert(cond, label, output = '') {
32
+ if (cond) {
33
+ console.log(` [PASS] ${label}`);
34
+ } else {
35
+ console.log(` [FAIL] ${label}`);
36
+ if (output) console.log(` --- output (last lines) ---\n ${output.trim().split('\n').slice(-25).join('\n ')}`);
37
+ failed++;
38
+ }
39
+ }
40
+
41
+ // Build a fixture HOME. The registered SessionStart hook points at
42
+ // ~/.ldm/shared/boot/boot-hook.mjs (the execution path). `execContent`
43
+ // seeds that file; `libraryContent` seeds the parallel ~/.ldm/library/boot
44
+ // copy (null to skip).
45
+ function setupHome({ execContent, libraryContent }) {
46
+ const home = mkdtempSync(join(tmpdir(), 'ldm-bootdir-'));
47
+ const ldmRoot = join(home, '.ldm');
48
+ const fakeBin = join(home, 'fakebin');
49
+ mkdirSync(join(ldmRoot, 'extensions'), { recursive: true });
50
+ mkdirSync(join(home, '.claude'), { recursive: true });
51
+ mkdirSync(fakeBin, { recursive: true });
52
+
53
+ writeFileSync(join(ldmRoot, 'version.json'), JSON.stringify({ version: pkg.version }, null, 2) + '\n');
54
+ writeFileSync(join(ldmRoot, 'extensions', 'registry.json'), JSON.stringify({ _format: 'v2', extensions: {} }, null, 2) + '\n');
55
+
56
+ const execTarget = join(ldmRoot, 'shared', 'boot', 'boot-hook.mjs');
57
+ mkdirSync(dirname(execTarget), { recursive: true });
58
+ writeFileSync(execTarget, execContent);
59
+ if (libraryContent !== null && libraryContent !== undefined) {
60
+ const lib = join(ldmRoot, 'library', 'boot', 'boot-hook.mjs');
61
+ mkdirSync(dirname(lib), { recursive: true });
62
+ writeFileSync(lib, libraryContent);
63
+ }
64
+
65
+ // Registered SessionStart hook runs the shared/boot (execution) copy.
66
+ const settings = {
67
+ hooks: {
68
+ SessionStart: [
69
+ { matcher: '*', hooks: [{ type: 'command', command: `node ${execTarget}`, timeout: 15 }] },
70
+ ],
71
+ },
72
+ };
73
+ const settingsPath = join(home, '.claude', 'settings.json');
74
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
75
+
76
+ writeFileSync(join(fakeBin, 'crontab'), '#!/bin/sh\nexit 0\n');
77
+ chmodSync(join(fakeBin, 'crontab'), 0o755);
78
+ writeFileSync(join(fakeBin, 'npm'), '#!/bin/sh\nexit 0\n');
79
+ chmodSync(join(fakeBin, 'npm'), 0o755);
80
+
81
+ return { home, fakeBin, execTarget };
82
+ }
83
+
84
+ function runDoctor({ home, fakeBin }, fix = false) {
85
+ const args = ['doctor'];
86
+ if (fix) args.push('--fix');
87
+ try {
88
+ return execFileSync('node', [cli, ...args], {
89
+ env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
90
+ encoding: 'utf-8',
91
+ timeout: 30000,
92
+ });
93
+ } catch (err) {
94
+ return (err.stdout || '') + (err.stderr || '');
95
+ }
96
+ }
97
+
98
+ const STALE = '// stale boot hook code (pre-trim)\nprocess.exit(0);\n';
99
+
100
+ console.log('Test 1: stale execution path is reported, not written without --fix');
101
+ {
102
+ const w = setupHome({ execContent: STALE, libraryContent: srcBootContent });
103
+ const before = readFileSync(w.execTarget, 'utf-8');
104
+ const out = runDoctor(w);
105
+ const after = readFileSync(w.execTarget, 'utf-8');
106
+ assert(/boot hook stale at execution path/.test(out), 'reports stale at execution path', out);
107
+ assert(/a current copy exists at:.*library\/boot/.test(out), 'points at the fresh library/boot copy', out);
108
+ assert(before === after, 'execution-path file untouched without --fix');
109
+ rmSync(w.home, { recursive: true, force: true });
110
+ }
111
+
112
+ console.log('Test 2: --fix redeploys the current boot hook to the execution path');
113
+ {
114
+ const w = setupHome({ execContent: STALE, libraryContent: srcBootContent });
115
+ const out = runDoctor(w, true);
116
+ const after = readFileSync(w.execTarget, 'utf-8');
117
+ const backups = readdirSync(dirname(w.execTarget)).filter((f) => f.startsWith('boot-hook.mjs.bak-'));
118
+ assert(/Redeployed the current boot hook to the execution path/.test(out), 'reports redeploy under --fix', out);
119
+ assert(after === srcBootContent, 'execution-path file now matches current src/boot/boot-hook.mjs');
120
+ assert(backups.length === 1, `stale copy backed up before overwrite (found ${backups.length})`);
121
+ const again = runDoctor(w, true);
122
+ assert(!/boot hook stale at execution path/.test(again), 'second --fix run finds nothing stale', again);
123
+ rmSync(w.home, { recursive: true, force: true });
124
+ }
125
+
126
+ console.log('Test 3: in-sync execution path is not flagged');
127
+ {
128
+ const w = setupHome({ execContent: srcBootContent, libraryContent: null });
129
+ const out = runDoctor(w);
130
+ assert(!/boot hook stale at execution path/.test(out), 'no stale report when exec path matches src', out);
131
+ rmSync(w.home, { recursive: true, force: true });
132
+ }
133
+
134
+ console.log('Test 4: no registered boot hook is a no-op (no crash, no false positive)');
135
+ {
136
+ const home = mkdtempSync(join(tmpdir(), 'ldm-bootdir-none-'));
137
+ const ldmRoot = join(home, '.ldm');
138
+ const fakeBin = join(home, 'fakebin');
139
+ mkdirSync(join(ldmRoot, 'extensions'), { recursive: true });
140
+ mkdirSync(join(home, '.claude'), { recursive: true });
141
+ mkdirSync(fakeBin, { recursive: true });
142
+ writeFileSync(join(ldmRoot, 'version.json'), JSON.stringify({ version: pkg.version }, null, 2) + '\n');
143
+ writeFileSync(join(ldmRoot, 'extensions', 'registry.json'), JSON.stringify({ _format: 'v2', extensions: {} }, null, 2) + '\n');
144
+ writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ hooks: {} }, null, 2) + '\n');
145
+ writeFileSync(join(fakeBin, 'crontab'), '#!/bin/sh\nexit 0\n');
146
+ chmodSync(join(fakeBin, 'crontab'), 0o755);
147
+ writeFileSync(join(fakeBin, 'npm'), '#!/bin/sh\nexit 0\n');
148
+ chmodSync(join(fakeBin, 'npm'), 0o755);
149
+ const out = runDoctor({ home, fakeBin });
150
+ assert(!/boot hook stale at execution path/.test(out), 'no stale report when no boot hook is registered', out);
151
+ rmSync(home, { recursive: true, force: true });
152
+ }
153
+
154
+ if (failed > 0) {
155
+ console.log(`\n${failed} assertion(s) failed`);
156
+ process.exit(1);
157
+ }
158
+ console.log('\nAll boot-dir-truth tests passed');