@wipcomputer/wip-ldm-os 0.4.85-alpha.30 → 0.4.85-alpha.33
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/bin/ldm.js +187 -1
- package/lib/deploy.mjs +36 -1
- package/package.json +10 -2
- 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-pair-login-flow.mjs +76 -1
- package/scripts/test-deploy-hook-ownership.mjs +160 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -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/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/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/app/footer.js +2 -2
- package/src/hosted-mcp/app/kaleidoscope-login.html +486 -42
- package/src/hosted-mcp/app/wip-logo.png +0 -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/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 +662 -35
package/bin/ldm.js
CHANGED
|
@@ -24,6 +24,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync
|
|
|
24
24
|
import { join, basename, resolve, dirname } from 'node:path';
|
|
25
25
|
import { execSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
27
28
|
|
|
28
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
30
|
const __dirname = dirname(__filename);
|
|
@@ -409,9 +410,39 @@ function cleanStaleHooks() {
|
|
|
409
410
|
|
|
410
411
|
// ── Boot hook sync (#49) ──
|
|
411
412
|
|
|
413
|
+
// The registered SessionStart hook command is the single source of truth for
|
|
414
|
+
// WHERE the boot hook actually executes from. Historically syncBootHook()
|
|
415
|
+
// deployed to ~/.ldm/library/boot while the registered command pointed at
|
|
416
|
+
// ~/.ldm/shared/boot (installer.mjs BOOT_DIR): new code landed in library/,
|
|
417
|
+
// sessions ran the stale copy in shared/, and the install reported success.
|
|
418
|
+
// Resolve the deploy target from the registered entry so the two can never
|
|
419
|
+
// diverge again. Fallback is shared/boot, matching installer.mjs BOOT_DIR and
|
|
420
|
+
// configureSessionStartHook() so a fresh machine stays consistent.
|
|
421
|
+
// Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
|
|
422
|
+
function getRegisteredBootHookTarget() {
|
|
423
|
+
const settings = readJSON(join(HOME, '.claude', 'settings.json'));
|
|
424
|
+
const entries = settings?.hooks?.SessionStart;
|
|
425
|
+
if (!Array.isArray(entries)) return null;
|
|
426
|
+
for (const entry of entries) {
|
|
427
|
+
for (const h of (entry?.hooks || [])) {
|
|
428
|
+
const cmd = h?.command || '';
|
|
429
|
+
const m = cmd.match(/(\S*boot-hook\.mjs)/);
|
|
430
|
+
if (m) return m[1].replace(/^["']|["']$/g, '');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveBootExecDir() {
|
|
437
|
+
const target = getRegisteredBootHookTarget();
|
|
438
|
+
if (target) return dirname(target);
|
|
439
|
+
// Canonical default: same as src/boot/installer.mjs BOOT_DIR.
|
|
440
|
+
return join(LDM_ROOT, 'shared', 'boot');
|
|
441
|
+
}
|
|
442
|
+
|
|
412
443
|
function syncBootHook() {
|
|
413
444
|
const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
|
|
414
|
-
const destBoot = join(
|
|
445
|
+
const destBoot = join(resolveBootExecDir(), 'boot-hook.mjs');
|
|
415
446
|
|
|
416
447
|
if (!existsSync(srcBoot)) return false;
|
|
417
448
|
|
|
@@ -3099,6 +3130,161 @@ async function cmdDoctor() {
|
|
|
3099
3130
|
}
|
|
3100
3131
|
}
|
|
3101
3132
|
|
|
3133
|
+
// Duplicate hook entries + invalid model value in ~/.claude/settings.json.
|
|
3134
|
+
// Duplicates: the same hook (event + matcher + commands) registered more
|
|
3135
|
+
// than once runs once per copy every session. 10 duplicate SessionStart
|
|
3136
|
+
// boot-hook entries observed 2026-07-04 (~450KB of boot context per
|
|
3137
|
+
// session start). Invalid model: a paste carrying ANSI escape codes can
|
|
3138
|
+
// land in /model and get persisted (e.g. "opus" + ESC + "[1m"); every new
|
|
3139
|
+
// session then fails its first API call. The corruption signal is a
|
|
3140
|
+
// CONTROL character (ESC 0x1B etc.), never the visible charset: bracketed
|
|
3141
|
+
// IDs like "claude-fable-5[1m]" are legitimate 1M-context variants and
|
|
3142
|
+
// must be left untouched.
|
|
3143
|
+
// Reported always; collapsed/removed under --fix with a timestamped
|
|
3144
|
+
// backup written before the first mutation.
|
|
3145
|
+
// See ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
|
|
3146
|
+
// and ai/product/bugs/guard/2026-07-04--cc-mini--no-blessed-recipe-for-live-settings-remediation.md
|
|
3147
|
+
{
|
|
3148
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
3149
|
+
const settings = readJSON(settingsPath);
|
|
3150
|
+
if (!settings && existsSync(settingsPath)) {
|
|
3151
|
+
console.log(' ! ~/.claude/settings.json exists but is not valid JSON; skipping hook/model checks');
|
|
3152
|
+
issues++;
|
|
3153
|
+
}
|
|
3154
|
+
let settingsDirty = false;
|
|
3155
|
+
let backupDone = false;
|
|
3156
|
+
const backupSettingsOnce = () => {
|
|
3157
|
+
if (backupDone) return;
|
|
3158
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3159
|
+
const bak = `${settingsPath}.bak-${stamp}`;
|
|
3160
|
+
copyFileSync(settingsPath, bak);
|
|
3161
|
+
console.log(` + Backup written: ${bak}`);
|
|
3162
|
+
backupDone = true;
|
|
3163
|
+
};
|
|
3164
|
+
|
|
3165
|
+
// Duplicate hook entries (same event + matcher + hook command list).
|
|
3166
|
+
// Deliberately ignores type/timeout: entries differing only in timeout
|
|
3167
|
+
// are treated as duplicates and collapse to the first.
|
|
3168
|
+
if (settings?.hooks) {
|
|
3169
|
+
const dupes = [];
|
|
3170
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
3171
|
+
if (!Array.isArray(groups)) continue;
|
|
3172
|
+
const seen = new Map();
|
|
3173
|
+
groups.forEach((g, i) => {
|
|
3174
|
+
const key = JSON.stringify({
|
|
3175
|
+
matcher: g?.matcher,
|
|
3176
|
+
hooks: (g?.hooks || []).map((h) => h?.command),
|
|
3177
|
+
});
|
|
3178
|
+
if (!seen.has(key)) seen.set(key, []);
|
|
3179
|
+
seen.get(key).push(i);
|
|
3180
|
+
});
|
|
3181
|
+
for (const idxs of seen.values()) {
|
|
3182
|
+
if (idxs.length > 1) dupes.push({ event, idxs });
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
const extra = dupes.reduce((n, d) => n + d.idxs.length - 1, 0);
|
|
3186
|
+
if (extra > 0) {
|
|
3187
|
+
for (const d of dupes) {
|
|
3188
|
+
const cmd = settings.hooks[d.event][d.idxs[0]]?.hooks?.[0]?.command || '(no command)';
|
|
3189
|
+
console.log(` ! ${d.event}: ${d.idxs.length} identical hook entries for: ${cmd}`);
|
|
3190
|
+
}
|
|
3191
|
+
if (FIX_FLAG) {
|
|
3192
|
+
backupSettingsOnce();
|
|
3193
|
+
for (const d of dupes) {
|
|
3194
|
+
// Keep the first, drop the rest. Right-to-left so indices stay valid.
|
|
3195
|
+
for (let j = d.idxs.length - 1; j >= 1; j--) {
|
|
3196
|
+
settings.hooks[d.event].splice(d.idxs[j], 1);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
settingsDirty = true;
|
|
3200
|
+
console.log(` + Collapsed ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
|
|
3201
|
+
} else {
|
|
3202
|
+
console.log(` Run: ldm doctor --fix to collapse ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
|
|
3203
|
+
issues += extra;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// Invalid model value. Corruption means CONTROL characters (ESC 0x1B
|
|
3209
|
+
// from ANSI fragments, NUL, DEL...) or an impossible length, never a
|
|
3210
|
+
// visible-charset judgment: "claude-fable-5[1m]" is a legitimate
|
|
3211
|
+
// 1M-context model ID and must pass.
|
|
3212
|
+
if (settings && typeof settings.model === 'string') {
|
|
3213
|
+
const valid =
|
|
3214
|
+
settings.model.length > 0 &&
|
|
3215
|
+
settings.model.length <= 256 &&
|
|
3216
|
+
!/[\x00-\x1f\x7f]/.test(settings.model);
|
|
3217
|
+
if (!valid) {
|
|
3218
|
+
console.log(` ! settings.json model value is invalid: ${JSON.stringify(settings.model)}`);
|
|
3219
|
+
if (FIX_FLAG) {
|
|
3220
|
+
backupSettingsOnce();
|
|
3221
|
+
delete settings.model;
|
|
3222
|
+
settingsDirty = true;
|
|
3223
|
+
console.log(' + Removed invalid model value (re-pick with /model in Claude Code)');
|
|
3224
|
+
} else {
|
|
3225
|
+
console.log(' Run: ldm doctor --fix to remove it, then re-pick with /model in Claude Code');
|
|
3226
|
+
issues++;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
if (settingsDirty) {
|
|
3232
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// Boot hook stale at execution path.
|
|
3237
|
+
//
|
|
3238
|
+
// Split-brain deploys are silent: version tracking can say the boot hook
|
|
3239
|
+
// updated while the SessionStart hook still executes an older copy on disk
|
|
3240
|
+
// (2026-07-05: install reported alpha.32's boot trim active, but the live
|
|
3241
|
+
// payload was the untrimmed 49KB because syncBootHook wrote library/boot
|
|
3242
|
+
// while the hook ran shared/boot). Hash-compare the file the registered
|
|
3243
|
+
// hook actually runs against this CLI's src/boot/boot-hook.mjs (the code the
|
|
3244
|
+
// installer would deploy). Divergence means the hook is running stale code.
|
|
3245
|
+
// Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
|
|
3246
|
+
{
|
|
3247
|
+
const execTarget = getRegisteredBootHookTarget();
|
|
3248
|
+
const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
|
|
3249
|
+
const hashFile = (p) => {
|
|
3250
|
+
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
3251
|
+
};
|
|
3252
|
+
if (execTarget && existsSync(srcBoot)) {
|
|
3253
|
+
const srcHash = hashFile(srcBoot);
|
|
3254
|
+
const execExists = existsSync(execTarget);
|
|
3255
|
+
const execHash = execExists ? hashFile(execTarget) : null;
|
|
3256
|
+
// Informational: which of the known deploy locations already matches src.
|
|
3257
|
+
const freshCandidates = [
|
|
3258
|
+
join(LDM_ROOT, 'shared', 'boot', 'boot-hook.mjs'),
|
|
3259
|
+
join(LDM_ROOT, 'library', 'boot', 'boot-hook.mjs'),
|
|
3260
|
+
].filter((p) => existsSync(p) && hashFile(p) === srcHash);
|
|
3261
|
+
if (!execExists || execHash !== srcHash) {
|
|
3262
|
+
const short = (h) => (h ? h.slice(0, 12) : 'missing');
|
|
3263
|
+
console.log(` ! boot hook stale at execution path: ${execTarget.replace(HOME, '~')}`);
|
|
3264
|
+
console.log(` the registered SessionStart hook runs ${short(execHash)}, current is ${short(srcHash)}`);
|
|
3265
|
+
if (freshCandidates.length > 0) {
|
|
3266
|
+
console.log(` a current copy exists at: ${freshCandidates[0].replace(HOME, '~')}`);
|
|
3267
|
+
}
|
|
3268
|
+
if (FIX_FLAG) {
|
|
3269
|
+
try {
|
|
3270
|
+
if (execExists) {
|
|
3271
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3272
|
+
copyFileSync(execTarget, `${execTarget}.bak-${stamp}`);
|
|
3273
|
+
}
|
|
3274
|
+
mkdirSync(dirname(execTarget), { recursive: true });
|
|
3275
|
+
copyFileSync(srcBoot, execTarget);
|
|
3276
|
+
console.log(' + Redeployed the current boot hook to the execution path');
|
|
3277
|
+
} catch (e) {
|
|
3278
|
+
console.log(` x Could not redeploy boot hook to execution path: ${e.message}`);
|
|
3279
|
+
}
|
|
3280
|
+
} else {
|
|
3281
|
+
console.log(' Run: ldm doctor --fix to redeploy the current boot hook to the execution path');
|
|
3282
|
+
issues++;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3102
3288
|
// --fix: clean registry entries with /tmp/ sources or ldm-install- names (#54)
|
|
3103
3289
|
if (FIX_FLAG) {
|
|
3104
3290
|
const registry = readJSON(REGISTRY_PATH);
|
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
|
|
|
@@ -1702,4 +1737,4 @@ export function disableExtension(name) {
|
|
|
1702
1737
|
|
|
1703
1738
|
// ── Exports for ldm CLI ──
|
|
1704
1739
|
|
|
1705
|
-
export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS };
|
|
1740
|
+
export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS, installClaudeCodeHook };
|
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.33",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"test:installer-skill-dry-run-destinations": "node scripts/test-installer-skill-dry-run-destinations.mjs",
|
|
34
34
|
"test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
|
|
35
35
|
"test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
|
|
36
|
+
"test:boot-payload-trim": "node scripts/test-boot-payload-trim.mjs",
|
|
36
37
|
"test:bin-manifest": "node scripts/test-bin-manifest.mjs",
|
|
37
38
|
"test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
|
|
38
39
|
"test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
|
|
@@ -41,8 +42,15 @@
|
|
|
41
42
|
"test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
|
|
42
43
|
"test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
|
|
43
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",
|
|
44
48
|
"fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
|
|
45
|
-
"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"
|
|
46
54
|
},
|
|
47
55
|
"claudeCode": {
|
|
48
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');
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test: configureSessionStartHook() registration semantics.
|
|
3
|
+
//
|
|
4
|
+
// Covers the 2026-07-04 tickets:
|
|
5
|
+
// ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--boot-hook-update-in-place-never-persists.md
|
|
6
|
+
// ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
|
|
7
|
+
//
|
|
8
|
+
// Cases:
|
|
9
|
+
// 1. Fresh install: entry added and persisted.
|
|
10
|
+
// 2. Repeat run: no duplicate, file byte-identical (no write).
|
|
11
|
+
// 3. Changed entry (old command path, old timeout): updated ON DISK.
|
|
12
|
+
// This is the update-in-place-never-persists bug.
|
|
13
|
+
// 4. Duplicate-laden settings (10 boot-hook copies + 1 unrelated hook):
|
|
14
|
+
// collapses to 1 boot entry, unrelated hook preserved, persisted.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
// Note: the temp prefix must not contain "boot-hook" or "shared/boot".
|
|
21
|
+
// Those substrings are the installer's ownership matchers, and the temp
|
|
22
|
+
// HOME path ends up inside every fixture hook command.
|
|
23
|
+
const tempHome = mkdtempSync(join(tmpdir(), 'ldm-bootreg-'));
|
|
24
|
+
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function assert(cond, label, detail = '') {
|
|
27
|
+
if (cond) {
|
|
28
|
+
console.log(` [PASS] ${label}`);
|
|
29
|
+
} else {
|
|
30
|
+
console.log(` [FAIL] ${label}`);
|
|
31
|
+
if (detail) console.log(` ${detail}`);
|
|
32
|
+
failed++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const settingsPath = join(tempHome, '.claude', 'settings.json');
|
|
37
|
+
function seedSettings(obj) {
|
|
38
|
+
mkdirSync(join(tempHome, '.claude'), { recursive: true });
|
|
39
|
+
writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + '\n');
|
|
40
|
+
}
|
|
41
|
+
function readSettings() {
|
|
42
|
+
return JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
function bootEntries(settings) {
|
|
45
|
+
return (settings.hooks?.SessionStart || []).filter((entry) =>
|
|
46
|
+
(entry?.hooks || []).some((h) => h?.command?.includes('boot-hook'))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// HOME must be set before the module import: installer.mjs resolves
|
|
52
|
+
// homedir() into module-level constants at import time.
|
|
53
|
+
process.env.HOME = tempHome;
|
|
54
|
+
const { configureSessionStartHook } = await import('../src/boot/installer.mjs');
|
|
55
|
+
const expectedCommand = `node ${join(tempHome, '.ldm', 'shared', 'boot', 'boot-hook.mjs')}`;
|
|
56
|
+
|
|
57
|
+
console.log('Test 1: fresh install adds and persists the entry');
|
|
58
|
+
{
|
|
59
|
+
seedSettings({ hooks: {} });
|
|
60
|
+
const msg = configureSessionStartHook();
|
|
61
|
+
const s = readSettings();
|
|
62
|
+
assert(/added/.test(msg), `reports added (got: ${msg})`);
|
|
63
|
+
assert(bootEntries(s).length === 1, 'exactly one boot-hook entry on disk');
|
|
64
|
+
assert(s.hooks.SessionStart[0].hooks[0].command === expectedCommand, 'command path is BOOT_DIR boot-hook.mjs');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('Test 2: repeat run is a no-op, byte-identical file');
|
|
68
|
+
{
|
|
69
|
+
const before = readFileSync(settingsPath, 'utf-8');
|
|
70
|
+
const msg = configureSessionStartHook();
|
|
71
|
+
const after = readFileSync(settingsPath, 'utf-8');
|
|
72
|
+
assert(/already configured/.test(msg), `reports already configured (got: ${msg})`);
|
|
73
|
+
assert(before === after, 'settings.json byte-identical after second run');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('Test 3: changed entry is updated ON DISK (the persist bug)');
|
|
77
|
+
{
|
|
78
|
+
seedSettings({
|
|
79
|
+
hooks: {
|
|
80
|
+
SessionStart: [
|
|
81
|
+
{
|
|
82
|
+
matcher: '*',
|
|
83
|
+
hooks: [{ type: 'command', command: 'node /old/path/shared/boot/boot-hook.mjs', timeout: 10 }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const msg = configureSessionStartHook();
|
|
89
|
+
const s = readSettings();
|
|
90
|
+
assert(/updated/.test(msg), `reports updated (got: ${msg})`);
|
|
91
|
+
assert(bootEntries(s).length === 1, 'still exactly one boot-hook entry');
|
|
92
|
+
assert(
|
|
93
|
+
s.hooks.SessionStart[0].hooks[0].command === expectedCommand,
|
|
94
|
+
'new command path persisted to disk',
|
|
95
|
+
`on disk: ${s.hooks.SessionStart[0].hooks[0].command}`
|
|
96
|
+
);
|
|
97
|
+
assert(s.hooks.SessionStart[0].hooks[0].timeout === 15, 'new timeout persisted to disk');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('Test 4: duplicate-laden settings collapse to one, unrelated hooks preserved');
|
|
101
|
+
{
|
|
102
|
+
const bootEntry = {
|
|
103
|
+
matcher: '*',
|
|
104
|
+
hooks: [{ type: 'command', command: expectedCommand, timeout: 15 }],
|
|
105
|
+
};
|
|
106
|
+
const guardEntry = {
|
|
107
|
+
hooks: [{ type: 'command', command: `node ${join(tempHome, '.ldm', 'extensions', 'wip-branch-guard', 'guard.mjs')}`, timeout: 10 }],
|
|
108
|
+
};
|
|
109
|
+
seedSettings({
|
|
110
|
+
hooks: {
|
|
111
|
+
SessionStart: [
|
|
112
|
+
...Array.from({ length: 5 }, () => structuredClone(bootEntry)),
|
|
113
|
+
guardEntry,
|
|
114
|
+
...Array.from({ length: 5 }, () => structuredClone(bootEntry)),
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const msg = configureSessionStartHook();
|
|
119
|
+
const s = readSettings();
|
|
120
|
+
assert(/removed 9 duplicate entries/.test(msg), `reports 9 duplicates removed (got: ${msg})`);
|
|
121
|
+
assert(bootEntries(s).length === 1, 'exactly one boot-hook entry after collapse');
|
|
122
|
+
assert(s.hooks.SessionStart.length === 2, 'boot hook + branch guard remain');
|
|
123
|
+
assert(
|
|
124
|
+
s.hooks.SessionStart.some((e) => e.hooks?.[0]?.command?.includes('wip-branch-guard')),
|
|
125
|
+
'unrelated branch-guard entry preserved'
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (failed > 0) {
|
|
133
|
+
console.log(`\n${failed} assertion(s) failed`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
console.log('\nAll boot-hook registration tests passed');
|