@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.
- package/README.md +22 -2
- package/SKILL.md +137 -15
- package/bin/ldm.js +608 -75
- package/lib/deploy.mjs +90 -12
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +21 -3
- package/scripts/test-boot-dir-truth.mjs +158 -0
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-agentid-tenant-boundary.mjs +2 -2
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +9 -2
- package/scripts/test-crc-pair-login-flow.mjs +76 -1
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-deploy-hook-ownership.mjs +160 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -0
- package/scripts/test-install-prompt-policy.mjs +84 -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-kaleidoscope-onboarding-copy.mjs +170 -0
- package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
- package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -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/boot/boot-config.json +18 -8
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
- package/shared/rules/security.md +4 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/boot/README.md +24 -1
- package/src/boot/boot-config.json +18 -8
- package/src/boot/boot-hook.mjs +118 -28
- package/src/boot/installer.mjs +33 -10
- package/src/hosted-mcp/.env.example +4 -0
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +2 -2
- package/src/hosted-mcp/app/kaleidoscope-login.html +489 -42
- package/src/hosted-mcp/app/pair.html +21 -3
- package/src/hosted-mcp/app/wip-logo.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/footer.js +2 -2
- package/src/hosted-mcp/demo/index.html +166 -44
- package/src/hosted-mcp/demo/login.html +87 -23
- package/src/hosted-mcp/demo/privacy.html +4 -214
- package/src/hosted-mcp/demo/tos.html +4 -189
- package/src/hosted-mcp/deploy.sh +2 -0
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
- package/src/hosted-mcp/legal/legal-footer.js +75 -0
- package/src/hosted-mcp/legal/legal.css +166 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
- package/src/hosted-mcp/legal/privacy/index.html +253 -0
- 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
|
|
1285
|
-
|
|
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.
|
|
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');
|