@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.30

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 (43) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +136 -14
  3. package/bin/ldm.js +422 -75
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/lib/registry-migrations.mjs +296 -0
  9. package/package.json +17 -2
  10. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  11. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  12. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  13. package/scripts/test-crc-pair-login-flow.mjs +40 -0
  14. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  15. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  16. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  17. package/scripts/test-install-prompt-policy.mjs +84 -0
  18. package/scripts/test-installer-skill-directory.mjs +55 -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-ldm-status-concurrency.mjs +118 -0
  22. package/scripts/test-ldm-status-timeout.mjs +96 -0
  23. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  24. package/scripts/test-readme-install-prompt.mjs +66 -0
  25. package/shared/templates/install-prompt.md +20 -2
  26. package/src/hosted-mcp/README.md +37 -0
  27. package/src/hosted-mcp/app/footer.js +74 -0
  28. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  29. package/src/hosted-mcp/app/pair.html +165 -57
  30. package/src/hosted-mcp/app/sprites.png +0 -0
  31. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  32. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  33. package/src/hosted-mcp/demo/index.html +3 -7
  34. package/src/hosted-mcp/demo/login.html +318 -20
  35. package/src/hosted-mcp/deploy.sh +308 -56
  36. package/src/hosted-mcp/docs/self-host.md +268 -0
  37. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  38. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  39. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  40. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  41. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  42. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  43. package/src/hosted-mcp/server.mjs +1034 -146
package/lib/deploy.mjs CHANGED
@@ -177,6 +177,14 @@ export function detectHarnesses() {
177
177
  skills: join(codexHome, 'skills'),
178
178
  };
179
179
 
180
+ // WIP agent skill compatibility directory
181
+ const agentsHome = join(HOME, '.agents');
182
+ harnesses['wip-agents'] = {
183
+ detected: existsSync(agentsHome),
184
+ home: agentsHome,
185
+ skills: join(agentsHome, 'skills'),
186
+ };
187
+
180
188
  // Cursor
181
189
  const cursorHome = join(HOME, '.cursor');
182
190
  harnesses['cursor'] = {
@@ -207,7 +215,7 @@ export function detectHarnesses() {
207
215
  function getHarnesses() {
208
216
  try {
209
217
  const config = readJSON(LDM_CONFIG_PATH) || {};
210
- if (config.harnesses) {
218
+ if (config.harnesses && config.harnesses['wip-agents']) {
211
219
  const workspace = (config.workspace || '').replace('~', HOME);
212
220
  return { harnesses: config.harnesses, workspace };
213
221
  }
@@ -1236,11 +1244,77 @@ function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath
1236
1244
  }
1237
1245
  }
1238
1246
 
1239
- function installSkill(repoPath, toolName) {
1247
+ function copySkillTree(skillDir, dest, copyFullFolder = false) {
1248
+ mkdirSync(dest, { recursive: true });
1249
+ if (copyFullFolder) {
1250
+ cpSync(skillDir, dest, { recursive: true });
1251
+ return;
1252
+ }
1253
+
1254
+ cpSync(join(skillDir, 'SKILL.md'), join(dest, 'SKILL.md'));
1255
+ for (const child of ['references', 'agents', 'scripts', 'assets']) {
1256
+ const src = join(skillDir, child);
1257
+ if (existsSync(src)) cpSync(src, join(dest, child), { recursive: true });
1258
+ }
1259
+ }
1260
+
1261
+ const SKILL_COMPANION_DIRS = ['references', 'agents', 'scripts', 'assets'];
1262
+
1263
+ function listSkillCopyEntries(skillDir, copyFullFolder = false) {
1264
+ if (copyFullFolder) {
1265
+ try {
1266
+ return readdirSync(skillDir)
1267
+ .filter((entry) => entry !== '.DS_Store')
1268
+ .sort();
1269
+ } catch {
1270
+ return ['SKILL.md'];
1271
+ }
1272
+ }
1273
+
1274
+ const entries = ['SKILL.md'];
1275
+ for (const child of SKILL_COMPANION_DIRS) {
1276
+ if (existsSync(join(skillDir, child))) entries.push(`${child}/`);
1277
+ }
1278
+ return entries;
1279
+ }
1280
+
1281
+ function printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries }) {
1282
+ const formatTargetEntries = (base) => entries.map((entry) => join(base, entry));
1283
+ const harnessTargets = Object.entries(harnesses)
1284
+ .filter(([, h]) => h.detected && h.skills)
1285
+ .map(([name, h]) => ({ name, base: join(h.skills, toolName) }));
1286
+
1287
+ ok(`Skill: ${toolName}`);
1288
+ log(`Source: ${sourceSkillDir}`);
1289
+ log('Would copy:');
1290
+ for (const entry of entries) log(`- ${entry}`);
1291
+
1292
+ const permanentBase = join(LDM_EXTENSIONS, toolName);
1293
+ log('Permanent copy:');
1294
+ for (const target of formatTargetEntries(permanentBase)) log(`- ${target}`);
1295
+
1296
+ if (harnessTargets.length > 0) {
1297
+ log('Agent skill targets:');
1298
+ for (const target of harnessTargets) {
1299
+ log(`- ${target.name}: ${target.base}`);
1300
+ for (const entryTarget of formatTargetEntries(target.base)) log(` - ${entryTarget}`);
1301
+ }
1302
+ } else {
1303
+ log('Agent skill targets: no detected skill harnesses');
1304
+ }
1305
+
1306
+ if (existsSync(refsSrc) && workspace && existsSync(workspace)) {
1307
+ const homeRefsDest = join(workspace, 'settings', 'docs', 'skills', toolName);
1308
+ log('Workspace docs target:');
1309
+ log(`- ${homeRefsDest} (references/ only)`);
1310
+ }
1311
+ }
1312
+
1313
+ function installSkillFolder(skillDir, toolName, opts = {}) {
1240
1314
  const { harnesses, workspace } = getHarnesses();
1241
1315
 
1242
- // Find SKILL.md source: repo path first, then permanent copy at ~/.ldm/extensions/
1243
- let skillSrc = join(repoPath, 'SKILL.md');
1316
+ // Find SKILL.md source: skill dir first, then permanent copy at ~/.ldm/extensions/
1317
+ let skillSrc = join(skillDir, 'SKILL.md');
1244
1318
  const permanentSkill = join(LDM_EXTENSIONS, toolName, 'SKILL.md');
1245
1319
  if (!existsSync(skillSrc) && existsSync(permanentSkill)) skillSrc = permanentSkill;
1246
1320
  if (!existsSync(skillSrc)) return false;
@@ -1251,14 +1325,16 @@ function installSkill(repoPath, toolName) {
1251
1325
  return false;
1252
1326
  }
1253
1327
 
1254
- // Find references/ source: repo path first, then permanent copy
1255
- let refsSrc = join(repoPath, 'references');
1328
+ const sourceSkillDir = dirname(skillSrc);
1329
+
1330
+ // Find references/ source: skill dir first, then permanent copy
1331
+ let refsSrc = join(skillDir, 'references');
1256
1332
  const permanentRefs = join(LDM_EXTENSIONS, toolName, 'references');
1257
1333
  if (!existsSync(refsSrc) && existsSync(permanentRefs)) refsSrc = permanentRefs;
1258
1334
 
1259
1335
  if (DRY_RUN) {
1260
- const targets = Object.entries(harnesses).filter(([,h]) => h.detected && h.skills).map(([name]) => name);
1261
- ok(`Skill: would deploy ${toolName} to ${targets.join(', ')} (dry run)`);
1336
+ const entries = listSkillCopyEntries(sourceSkillDir, opts.copyFullFolder);
1337
+ printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries });
1262
1338
  return true;
1263
1339
  }
1264
1340
 
@@ -1267,21 +1343,13 @@ function installSkill(repoPath, toolName) {
1267
1343
 
1268
1344
  // 1. Save permanent copy to ~/.ldm/extensions/<name>/ (survives tmp cleanup)
1269
1345
  const ldmSkillDir = join(LDM_EXTENSIONS, toolName);
1270
- mkdirSync(ldmSkillDir, { recursive: true });
1271
- cpSync(skillSrc, join(ldmSkillDir, 'SKILL.md'));
1272
- if (existsSync(refsSrc) && refsSrc !== permanentRefs) {
1273
- cpSync(refsSrc, join(ldmSkillDir, 'references'), { recursive: true });
1274
- }
1346
+ copySkillTree(sourceSkillDir, ldmSkillDir, opts.copyFullFolder);
1275
1347
 
1276
1348
  // 2. Deploy to every detected harness that has a skills path
1277
1349
  for (const [name, harness] of Object.entries(harnesses)) {
1278
1350
  if (!harness.detected || !harness.skills) continue;
1279
1351
  const dest = join(harness.skills, toolName);
1280
- mkdirSync(dest, { recursive: true });
1281
- cpSync(skillSrc, join(dest, 'SKILL.md'));
1282
- if (existsSync(refsSrc)) {
1283
- cpSync(refsSrc, join(dest, 'references'), { recursive: true });
1284
- }
1352
+ copySkillTree(sourceSkillDir, dest, opts.copyFullFolder);
1285
1353
  deployed.push(name);
1286
1354
  }
1287
1355
 
@@ -1301,6 +1369,18 @@ function installSkill(repoPath, toolName) {
1301
1369
  }
1302
1370
  }
1303
1371
 
1372
+ function installSkill(repoPath, toolName, skillInfo = null) {
1373
+ if (Array.isArray(skillInfo?.skills) && skillInfo.skills.length > 0) {
1374
+ let installed = 0;
1375
+ for (const skill of skillInfo.skills) {
1376
+ if (installSkillFolder(skill.path, skill.name, { copyFullFolder: true })) installed++;
1377
+ }
1378
+ return installed > 0;
1379
+ }
1380
+
1381
+ return installSkillFolder(repoPath, toolName);
1382
+ }
1383
+
1304
1384
  // ── Single tool install ──
1305
1385
 
1306
1386
  export function installSingleTool(toolPath) {
@@ -1361,6 +1441,10 @@ export function installSingleTool(toolPath) {
1361
1441
  }
1362
1442
  }
1363
1443
 
1444
+ if (interfaces.skill) {
1445
+ installSkill(toolPath, toolName, interfaces.skill);
1446
+ }
1447
+
1364
1448
  return ifaceNames.length;
1365
1449
  }
1366
1450
 
@@ -1429,7 +1513,7 @@ export function installSingleTool(toolPath) {
1429
1513
 
1430
1514
  if (interfaces.skill) {
1431
1515
  // Skills always deploy. They're instruction files, not running code.
1432
- if (installSkill(toolPath, toolName)) installed++;
1516
+ if (installSkill(toolPath, toolName, interfaces.skill)) installed++;
1433
1517
  }
1434
1518
 
1435
1519
  if (interfaces.module) {
@@ -1590,7 +1674,7 @@ export async function enableExtension(name) {
1590
1674
 
1591
1675
  if (interfaces.mcp) registerMCP(extPath, interfaces.mcp, name);
1592
1676
  if (interfaces.claudeCodeHook) installClaudeCodeHook(extPath, interfaces.claudeCodeHook);
1593
- if (interfaces.skill) installSkill(extPath, name);
1677
+ if (interfaces.skill) installSkill(extPath, name, interfaces.skill);
1594
1678
 
1595
1679
  entry.enabled = true;
1596
1680
  entry.updatedAt = new Date().toISOString();
package/lib/detect.mjs CHANGED
@@ -15,6 +15,23 @@ function readJSON(path) {
15
15
  }
16
16
  }
17
17
 
18
+ function detectSkillDirectories(repoPath) {
19
+ const skillsDir = join(repoPath, 'skills');
20
+ if (!existsSync(skillsDir)) return [];
21
+
22
+ try {
23
+ return readdirSync(skillsDir, { withFileTypes: true })
24
+ .filter(e => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
25
+ .map(e => ({
26
+ name: e.name,
27
+ path: join(skillsDir, e.name),
28
+ skillPath: join(skillsDir, e.name, 'SKILL.md'),
29
+ }));
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
18
35
  /**
19
36
  * Detect all interfaces in a repo.
20
37
  * Returns { interfaces, pkg } where interfaces is an object keyed by interface type.
@@ -48,9 +65,18 @@ export function detectInterfaces(repoPath) {
48
65
  interfaces.openclaw = { config: readJSON(ocPlugin), path: ocPlugin };
49
66
  }
50
67
 
51
- // 5. Skill: SKILL.md exists
52
- if (existsSync(join(repoPath, 'SKILL.md'))) {
53
- interfaces.skill = { path: join(repoPath, 'SKILL.md') };
68
+ // 5. Skill: root SKILL.md, or a skills/<name>/SKILL.md collection.
69
+ const rootSkillPath = join(repoPath, 'SKILL.md');
70
+ if (existsSync(rootSkillPath)) {
71
+ interfaces.skill = { path: rootSkillPath };
72
+ }
73
+
74
+ const skillDirs = detectSkillDirectories(repoPath);
75
+ if (skillDirs.length > 0) {
76
+ interfaces.skill = {
77
+ path: join(repoPath, 'skills'),
78
+ skills: skillDirs,
79
+ };
54
80
  }
55
81
 
56
82
  // 6. Claude Code Hook: guard.mjs or claudeCode.hook(s) in package.json
@@ -103,7 +129,12 @@ export function describeInterfaces(interfaces) {
103
129
  if (interfaces.module) lines.push(`Module: ${JSON.stringify(interfaces.module.main)}`);
104
130
  if (interfaces.mcp) lines.push(`MCP Server: ${interfaces.mcp.file}`);
105
131
  if (interfaces.openclaw) lines.push(`OpenClaw Plugin: ${interfaces.openclaw.config?.name || 'detected'}`);
106
- if (interfaces.skill) lines.push(`Skill: SKILL.md`);
132
+ if (interfaces.skill?.skills?.length) {
133
+ const skills = interfaces.skill.skills.map(s => `${s.name} (${s.skillPath})`);
134
+ lines.push(`Skill: ${skills.join(', ')}`);
135
+ } else if (interfaces.skill) {
136
+ lines.push(`Skill: SKILL.md`);
137
+ }
107
138
  if (interfaces.claudeCodeHook) {
108
139
  const events = interfaces.claudeCodeHook.map(h => h.event || 'PreToolUse');
109
140
  lines.push(`Claude Code Hook: ${events.join(', ')}`);
@@ -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.3",
3
+ "version": "0.4.85-alpha.30",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -18,14 +18,29 @@
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",
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",
26
34
  "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
27
35
  "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
28
36
  "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
37
+ "test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
38
+ "test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
39
+ "test:crc-pair-status-poll-token": "node scripts/test-crc-pair-status-poll-token.mjs",
40
+ "test:crc-pair-relink-audit-and-rotation": "node scripts/test-crc-pair-relink-audit-and-rotation.mjs",
41
+ "test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
42
+ "test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
43
+ "test:crc-websocket-abuse-limits": "node scripts/test-crc-websocket-abuse-limits.mjs",
29
44
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
30
45
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
31
46
  },
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
5
+
6
+ function assertContains(needle, label) {
7
+ if (!server.includes(needle)) {
8
+ throw new Error(`${label} missing expected text: ${needle}`);
9
+ }
10
+ }
11
+
12
+ function assertNotContains(needle, label) {
13
+ if (server.includes(needle)) {
14
+ throw new Error(`${label} still contains forbidden text: ${needle}`);
15
+ }
16
+ }
17
+
18
+ assertContains('const ACCOUNT_TENANT_PREFIX = "acct:";', "account tenant prefix");
19
+ assertContains('const LEGACY_API_KEY_TENANT_PREFIX = "key:";', "legacy key tenant prefix");
20
+ assertContains('function accountTenantIdForUserId(userId)', "account tenant helper");
21
+ assertContains('function identityForApiKey(key)', "api key identity helper");
22
+ assertContains('return identityForApiKey(key);', "http auth uses identity helper");
23
+ assertContains("const agentId = accountTenantIdForUserId(stored.userId);", "registration uses immutable account tenant");
24
+ assertContains("function sanitizeDisplayLabel(raw)", "display label sanitizer");
25
+ assertContains('replace(/[\\u0000-\\u001f\\u007f]/g, "").replace(/\\s+/g, " ").trim().slice(0, 64)', "display label sanitizer preserves label semantics");
26
+ assertContains("const displayLabel = sanitizeDisplayLabel(body?.displayName || body?.username);", "registration treats entered name as display label");
27
+ assertContains("displayLabel,", "registration challenge stores display label");
28
+ assertContains("await saveApiKey(apiKey, agentId, { handle: credentialLabel });", "registration stores handle separately");
29
+ assertContains("p.handle = identity.handle;", "pair stores display handle separately");
30
+ assertContains("handle: identity.handle,", "relay metadata returns display handle");
31
+ assertContains("codexDaemons.has(identity.agentId)", "daemon presence uses tenant id");
32
+ assertContains("codexDaemonPubkeyRegistry.get(identity.agentId)", "daemon pubkey uses tenant id");
33
+ assertContains("agentId: identity.agentId,", "relay tickets bind tenant id");
34
+ assertContains("handle: identity.handle,", "relay tickets preserve display handle");
35
+ assertContains("codexDaemons.set(identity.agentId, ws);", "daemon ws keyed by tenant id");
36
+ assertContains("const webKey = codexRelayKey(identity.agentId, threadId);", "web ws keyed by tenant id");
37
+ assertContains("const daemonWs = codexDaemons.get(identity.agentId);", "web sends to tenant daemon");
38
+ assertNotContains("const agentId = stored.username || (\"passkey-\"", "registration must not use chosen handle as tenant");
39
+ assertNotContains("const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);", "oauth must not reuse chosen handle as tenant");
40
+ assertNotContains("function isUsernameTaken", "display labels must not be globally unique usernames");
41
+ assertNotContains("function sanitizeUsername", "display labels must not be modeled as usernames");
42
+ assertNotContains('json(res, 409, { error: "reserved_handle"', "display labels must not be blocked as reserved security handles");
43
+ assertNotContains('json(res, 409, { error: "handle_taken"', "duplicate display labels must be allowed");
44
+
45
+ function legacyTenantIdForApiKey(key) {
46
+ return "key:" + createHash("sha256").update(key).digest("base64url").slice(0, 32);
47
+ }
48
+
49
+ function accountTenantIdForUserId(userId) {
50
+ return "acct:" + userId;
51
+ }
52
+
53
+ const chosenHandle = "parker-smoke-test";
54
+ const sharedDisplayLabel = "Parker";
55
+ const accountA = accountTenantIdForUserId("user-a");
56
+ const accountB = accountTenantIdForUserId("user-b");
57
+ const threadId = "thread-019dfa";
58
+ if (accountA === accountB) {
59
+ throw new Error("different user ids collapsed to one account tenant");
60
+ }
61
+ if (`${sharedDisplayLabel}:${threadId}` === `${accountA}:${threadId}` || `${sharedDisplayLabel}:${threadId}` === `${accountB}:${threadId}`) {
62
+ throw new Error("display label was used as a relay route key");
63
+ }
64
+
65
+ const legacyA = legacyTenantIdForApiKey("ck-a");
66
+ const legacyB = legacyTenantIdForApiKey("ck-b");
67
+ if (legacyA === legacyB) {
68
+ throw new Error("legacy API-key tenants collided");
69
+ }
70
+
71
+ const webKeyA = `${accountA}:${threadId}`;
72
+ const webKeyB = `${accountB}:${threadId}`;
73
+ if (webKeyA === webKeyB) {
74
+ throw new Error("same display handle can still collide across account tenants");
75
+ }
76
+ if (`${chosenHandle}:${threadId}` === webKeyA || `${chosenHandle}:${threadId}` === webKeyB) {
77
+ throw new Error("model still keys relay routes by display handle");
78
+ }
79
+
80
+ console.log("crc agentId tenant boundary checks passed");