@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.
- package/README.md +22 -2
- package/SKILL.md +136 -14
- package/bin/ldm.js +422 -75
- package/docs/universal-installer/SPEC.md +16 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +104 -20
- package/lib/detect.mjs +35 -4
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +17 -2
- package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +129 -0
- package/scripts/test-crc-pair-login-flow.mjs +40 -0
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-install-prompt-policy.mjs +84 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
- package/scripts/test-installer-target-self-update.mjs +131 -0
- package/scripts/test-ldm-status-concurrency.mjs +118 -0
- package/scripts/test-ldm-status-timeout.mjs +96 -0
- package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
- package/scripts/test-readme-install-prompt.mjs +66 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
- package/src/hosted-mcp/app/pair.html +165 -57
- package/src/hosted-mcp/app/sprites.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
- package/src/hosted-mcp/demo/index.html +3 -7
- package/src/hosted-mcp/demo/login.html +318 -20
- package/src/hosted-mcp/deploy.sh +308 -56
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
- package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
- package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
- package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
- 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
|
|
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:
|
|
1243
|
-
let skillSrc = join(
|
|
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
|
-
|
|
1255
|
-
|
|
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
|
|
1261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
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
|
+
"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");
|