@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.
Files changed (35) hide show
  1. package/bin/ldm.js +187 -1
  2. package/lib/deploy.mjs +36 -1
  3. package/package.json +10 -2
  4. package/scripts/test-boot-dir-truth.mjs +158 -0
  5. package/scripts/test-boot-hook-registration.mjs +136 -0
  6. package/scripts/test-boot-payload-trim.mjs +200 -0
  7. package/scripts/test-crc-pair-login-flow.mjs +76 -1
  8. package/scripts/test-deploy-hook-ownership.mjs +160 -0
  9. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  10. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  11. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  12. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  13. package/shared/boot/boot-config.json +18 -8
  14. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  15. package/shared/rules/security.md +4 -0
  16. package/src/boot/README.md +24 -1
  17. package/src/boot/boot-config.json +18 -8
  18. package/src/boot/boot-hook.mjs +118 -28
  19. package/src/boot/installer.mjs +33 -10
  20. package/src/hosted-mcp/.env.example +4 -0
  21. package/src/hosted-mcp/app/footer.js +2 -2
  22. package/src/hosted-mcp/app/kaleidoscope-login.html +486 -42
  23. package/src/hosted-mcp/app/wip-logo.png +0 -0
  24. package/src/hosted-mcp/demo/footer.js +2 -2
  25. package/src/hosted-mcp/demo/index.html +166 -44
  26. package/src/hosted-mcp/demo/login.html +87 -23
  27. package/src/hosted-mcp/demo/privacy.html +4 -214
  28. package/src/hosted-mcp/demo/tos.html +4 -189
  29. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  30. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  31. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  32. package/src/hosted-mcp/legal/legal.css +166 -0
  33. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  34. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  35. package/src/hosted-mcp/server.mjs +662 -35
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: boot-hook payload trimming (CC speedup master plan, Task 4b).
3
+ //
4
+ // Covers:
5
+ // 1. A step with no maxLines in config gets the code default cap + a
6
+ // truncation marker that names the full path.
7
+ // 2. A per-step maxLines in config overrides the default.
8
+ // 3. A most-recent step whose newest file is older than stalenessDays is
9
+ // NOT injected: a path-only stale line is emitted instead.
10
+ // 4. A fresh most-recent file IS injected in full.
11
+ // 5. The payload ends with a one-line summary (bytes/lines/sections, capped,
12
+ // stale).
13
+ // 6. maxTotalLines still stops the loop early.
14
+ //
15
+ // The hook resolves homedir() into a module-level constant at import time and
16
+ // loads boot-config.json relative to its own file. So each case copies the
17
+ // hook into a temp workdir next to a generated boot-config.json and runs it as
18
+ // a subprocess with HOME pointed at a temp fixture. The hook's dynamic imports
19
+ // of ../../lib/*.mjs fail harmlessly (they are wrapped in try/catch) from the
20
+ // temp location, which is fine: this test only exercises payload assembly.
21
+
22
+ import { mkdirSync, mkdtempSync, copyFileSync, writeFileSync, rmSync } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { tmpdir } from 'node:os';
25
+ import { execFileSync } from 'node:child_process';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const repo = dirname(dirname(fileURLToPath(import.meta.url)));
29
+ const HOOK_SRC = join(repo, 'src', 'boot', 'boot-hook.mjs');
30
+
31
+ let failed = 0;
32
+ function assert(cond, label, detail = '') {
33
+ if (cond) {
34
+ console.log(` [PASS] ${label}`);
35
+ } else {
36
+ console.log(` [FAIL] ${label}`);
37
+ if (detail) console.log(` ${detail}`);
38
+ failed++;
39
+ }
40
+ }
41
+
42
+ // Compute today/yesterday in the hook's timezone so daily-log and fresh-journal
43
+ // filenames match what the hook looks for.
44
+ function ymd(offsetDays = 0) {
45
+ const now = new Date();
46
+ now.setDate(now.getDate() + offsetDays);
47
+ return new Intl.DateTimeFormat('en-CA', {
48
+ timeZone: 'America/Los_Angeles',
49
+ year: 'numeric',
50
+ month: '2-digit',
51
+ day: '2-digit',
52
+ }).format(now);
53
+ }
54
+
55
+ function lines(n, marker) {
56
+ const out = [];
57
+ for (let i = 1; i <= n; i++) out.push(`${marker} line ${i}`);
58
+ return out.join('\n') + '\n';
59
+ }
60
+
61
+ // Run the hook with a given config object and a HOME-populating callback.
62
+ // Returns the injected additionalContext string.
63
+ function runHook(configObj, populateHome) {
64
+ const work = mkdtempSync(join(tmpdir(), 'ldm-bootpay-work-'));
65
+ const home = mkdtempSync(join(tmpdir(), 'ldm-bootpay-home-'));
66
+ try {
67
+ copyFileSync(HOOK_SRC, join(work, 'boot-hook.mjs'));
68
+ writeFileSync(join(work, 'boot-config.json'), JSON.stringify(configObj, null, 2));
69
+ populateHome(home);
70
+
71
+ const stdout = execFileSync('node', [join(work, 'boot-hook.mjs')], {
72
+ input: JSON.stringify({ session_id: 'test', hook_event_name: 'SessionStart', cwd: home }),
73
+ env: { ...process.env, HOME: home },
74
+ encoding: 'utf-8',
75
+ timeout: 30000,
76
+ });
77
+ const parsed = JSON.parse(stdout);
78
+ return parsed?.hookSpecificOutput?.additionalContext || '';
79
+ } finally {
80
+ rmSync(work, { recursive: true, force: true });
81
+ rmSync(home, { recursive: true, force: true });
82
+ }
83
+ }
84
+
85
+ function writeHomeFile(home, rel, content) {
86
+ const full = join(home, rel);
87
+ mkdirSync(dirname(full), { recursive: true });
88
+ writeFileSync(full, content);
89
+ return full;
90
+ }
91
+
92
+ // ── Case 1 + 2 + 5: default cap, config override, payload summary ──
93
+ console.log('Case 1/2/5: default cap, config override, payload summary');
94
+ {
95
+ const config = {
96
+ agentId: 'test-agent',
97
+ timezone: 'America/Los_Angeles',
98
+ maxTotalLines: 2000,
99
+ steps: {
100
+ // no maxLines -> code default for sharedContext is 80
101
+ sharedContext: { path: '~/shared.md', label: 'SHARED', stepNumber: 2, critical: true },
102
+ // no maxLines -> code default for soul is 80
103
+ soul: { path: '~/soul.md', label: 'SOUL', stepNumber: 7 },
104
+ // explicit maxLines overrides
105
+ overridden: { path: '~/capped.md', label: 'CAPPED', stepNumber: 11, maxLines: 5 },
106
+ },
107
+ };
108
+ let soulPath, cappedPath;
109
+ const ctx = runHook(config, (home) => {
110
+ writeHomeFile(home, 'shared.md', lines(20, 'SHARED_BODY')); // under cap, not truncated
111
+ soulPath = writeHomeFile(home, 'soul.md', lines(200, 'SOUL_BODY')); // over default 80
112
+ cappedPath = writeHomeFile(home, 'capped.md', lines(100, 'CAP_BODY')); // over explicit 5
113
+ });
114
+
115
+ assert(/truncated at 80 of \d+ lines/.test(ctx), 'soul truncated at default cap 80', ctx.match(/truncated at[^\n]*soul[^\n]*/)?.[0] || ctx.slice(0, 300));
116
+ assert(ctx.includes(`Read the rest: ${soulPath}`), 'soul truncation marker names full path');
117
+ assert(/truncated at 5 of \d+ lines/.test(ctx), 'config maxLines=5 override respected');
118
+ assert(ctx.includes(`Read the rest: ${cappedPath}`), 'capped truncation marker names full path');
119
+ assert(/SHARED_BODY line 20\b/.test(ctx), 'under-cap file not truncated (body fully present)');
120
+ const summaryLine = ctx.match(/== Boot payload:[^\n]*/)?.[0] || '';
121
+ assert(/== Boot payload: \d+ bytes, \d+ lines, \d+ sections\./.test(summaryLine), 'payload summary line present', summaryLine);
122
+ const cappedPart = summaryLine.match(/Capped:([^.]*)\./)?.[1] || '';
123
+ assert(/Step 7\b/.test(cappedPart) && /Step 11\b/.test(cappedPart), 'summary lists capped steps 7 and 11', summaryLine);
124
+ assert(/Stale\/path-only: none\./.test(summaryLine), 'summary reports no stale steps', summaryLine);
125
+ }
126
+
127
+ // ── Case 3: stale most-recent journal emits path-only line ──
128
+ console.log('Case 3: stale journal is path-only, body not injected');
129
+ {
130
+ const config = {
131
+ agentId: 'test-agent',
132
+ timezone: 'America/Los_Angeles',
133
+ maxTotalLines: 2000,
134
+ stalenessDays: 14,
135
+ steps: {
136
+ ccJournals: { dir: '~/journals', label: 'JOURNAL', stepNumber: 8, maxLines: 80, strategy: 'most-recent' },
137
+ },
138
+ };
139
+ const staleDate = ymd(-100);
140
+ let journalPath;
141
+ const ctx = runHook(config, (home) => {
142
+ journalPath = writeHomeFile(home, `journals/${staleDate}-journal.md`, lines(50, 'STALE_JOURNAL_BODY'));
143
+ });
144
+
145
+ assert(!/STALE_JOURNAL_BODY/.test(ctx), 'stale journal body is NOT injected');
146
+ assert(new RegExp(`stale: newest file is ${staleDate}`).test(ctx), 'stale line names the file date');
147
+ assert(/cutoff 14d/.test(ctx), 'stale line names the cutoff');
148
+ assert(ctx.includes(`Read if needed: ${journalPath}`), 'stale line names the full path');
149
+ assert(/Stale\/path-only: Step 8\b/.test(ctx), 'summary lists stale step 8');
150
+ }
151
+
152
+ // ── Case 4: fresh most-recent journal is injected in full ──
153
+ console.log('Case 4: fresh journal body IS injected');
154
+ {
155
+ const config = {
156
+ agentId: 'test-agent',
157
+ timezone: 'America/Los_Angeles',
158
+ maxTotalLines: 2000,
159
+ stalenessDays: 14,
160
+ steps: {
161
+ ccJournals: { dir: '~/journals', label: 'JOURNAL', stepNumber: 8, maxLines: 80, strategy: 'most-recent' },
162
+ },
163
+ };
164
+ const freshDate = ymd(0);
165
+ const ctx = runHook(config, (home) => {
166
+ writeHomeFile(home, `journals/${freshDate}-journal.md`, lines(10, 'FRESH_JOURNAL_BODY'));
167
+ });
168
+
169
+ assert(/FRESH_JOURNAL_BODY line 1\b/.test(ctx), 'fresh journal body injected');
170
+ assert(!/stale:/.test(ctx), 'no stale marker for fresh journal');
171
+ assert(/Stale\/path-only: none\./.test(ctx), 'summary reports no stale steps');
172
+ }
173
+
174
+ // ── Case 6: maxTotalLines stops the loop early ──
175
+ console.log('Case 6: maxTotalLines respected');
176
+ {
177
+ const config = {
178
+ agentId: 'test-agent',
179
+ timezone: 'America/Los_Angeles',
180
+ maxTotalLines: 5, // tiny: first big step blows past it
181
+ steps: {
182
+ sharedContext: { path: '~/shared.md', label: 'SHARED', stepNumber: 2, critical: true },
183
+ context: { path: '~/context.md', label: 'CONTEXT', stepNumber: 6, critical: true },
184
+ },
185
+ };
186
+ const ctx = runHook(config, (home) => {
187
+ writeHomeFile(home, 'shared.md', lines(40, 'FIRST_BODY'));
188
+ writeHomeFile(home, 'context.md', lines(40, 'SECOND_BODY'));
189
+ });
190
+
191
+ assert(/FIRST_BODY line 1\b/.test(ctx), 'first step loaded');
192
+ assert(!/SECOND_BODY/.test(ctx), 'second step skipped after maxTotalLines cap');
193
+ }
194
+
195
+ console.log('');
196
+ if (failed > 0) {
197
+ console.log(`${failed} test(s) failed.`);
198
+ process.exit(1);
199
+ }
200
+ console.log('All boot payload trim tests passed.');
@@ -12,6 +12,12 @@ function assertContains(source, needle, label) {
12
12
  }
13
13
  }
14
14
 
15
+ function assertNotContains(source, needle, label) {
16
+ if (source.includes(needle)) {
17
+ throw new Error(`${label} must not contain: ${needle}`);
18
+ }
19
+ }
20
+
15
21
  function assertBefore(source, first, second, label) {
16
22
  const firstIndex = source.indexOf(first);
17
23
  const secondIndex = source.indexOf(second);
@@ -20,15 +26,74 @@ function assertBefore(source, first, second, label) {
20
26
  }
21
27
  }
22
28
 
29
+ function extractServerNextSanitizer(source) {
30
+ const consts = [
31
+ source.match(/const PAIR_NEXT_REGEX = .+;/)?.[0],
32
+ source.match(/const REMOTE_CONTROL_NEXT_REGEX = .+;/)?.[0],
33
+ source.match(/const DEMO_NEXT_REGEX = .+;/)?.[0],
34
+ ];
35
+ if (consts.some((line) => !line)) {
36
+ throw new Error("server sanitizer test could not extract next regex constants");
37
+ }
38
+ const start = source.indexOf("function sanitizeCrcPairNext(raw)");
39
+ const end = source.indexOf("// POST /api/qr-login", start);
40
+ if (start === -1 || end === -1) {
41
+ throw new Error("server sanitizer test could not extract sanitizeCrcPairNext");
42
+ }
43
+ return Function(`${consts.join("\n")}\n${source.slice(start, end)}\nreturn sanitizeCrcPairNext;`)();
44
+ }
45
+
46
+ function assertSanitizedNext(sanitize, value, expected) {
47
+ const actual = sanitize(value);
48
+ if (actual !== expected) {
49
+ throw new Error(`sanitizeCrcPairNext(${JSON.stringify(value)}) expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
50
+ }
51
+ }
52
+
23
53
  assertContains(server, "const PAIR_NEXT_REGEX = /^\\/pair\\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;", "server pair regex");
24
54
  assertContains(server, "const REMOTE_CONTROL_NEXT_REGEX = /^\\/codex-remote-control\\/", "server remote-control regex");
55
+ assertContains(server, "const DEMO_NEXT_REGEX = /^\\/demo$/;", "server demo regex");
25
56
  assertContains(server, "purpose, // \"pair\" | null", "server stores pair purpose");
26
- assertContains(server, "next: next || null, // sanitized `/pair/<CODE>` or null", "server stores sanitized next");
57
+ assertContains(server, "next: next || null, // sanitized `/pair/<CODE>`, `/codex-remote-control/<UUID>`, `/demo`, or null", "server stores sanitized next");
58
+ assertContains(server, "DEMO_NEXT_REGEX.test(decoded)", "server allows demo next");
27
59
  assertContains(server, "json(res, 200, { status: \"approved\", agentId: entry.agentId });", "server strips desktop pair status");
28
60
  assertContains(server, "json(res, 200, { ok: true, next: entry.next });", "server returns next to phone approve");
61
+ assertContains(server, "tenantId: entry.tenantId || null,", "server returns QR login tenant");
62
+ assertContains(server, "const verifiedIdentity = identityForApiKey(apiKey);", "server canonicalizes QR approval identity");
63
+
64
+ const sanitizeCrcPairNext = extractServerNextSanitizer(server);
65
+ assertSanitizedNext(sanitizeCrcPairNext, "/demo", "/demo");
66
+ assertSanitizedNext(sanitizeCrcPairNext, "/demo/", null);
67
+ assertSanitizedNext(sanitizeCrcPairNext, "/demo/../admin", null);
68
+ assertSanitizedNext(sanitizeCrcPairNext, "//evil.com", null);
69
+ assertSanitizedNext(sanitizeCrcPairNext, "https://evil", null);
70
+ assertSanitizedNext(sanitizeCrcPairNext, "/demoX", null);
29
71
 
30
72
  for (const file of loginFiles) {
31
73
  const html = readFileSync(file, "utf8");
74
+ assertContains(html, "var DEMO_NEXT_REGEX = /^\\/demo$/;", `${file} demo regex`);
75
+ assertContains(html, "function defaultLocalPasskeysOn()", `${file} has local passkey default helper`);
76
+ assertContains(html, "return isMobileDevice();", `${file} defaults local passkeys on for mobile`);
77
+ assertContains(html, "function getLocalPasskeysPreference()", `${file} has local passkey preference helper`);
78
+ assertContains(html, "if (stored === 'on' || stored === 'off') return stored;", `${file} respects stored local passkey preference`);
79
+ assertContains(html, "function needsCustomQR() {\n return !isLocalPasskeysOn();\n}", `${file} uses QR whenever local passkeys are off`);
80
+ assertNotContains(html, "return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();", `${file} must not gate QR by browser or mobile status`);
81
+ assertContains(html, "DEMO_NEXT_REGEX.test(raw)", `${file} allowlists demo next`);
82
+ assertContains(html, "function storeDemoHandoffIfNeeded(next, identity, isNewAccount)", `${file} demo handoff helper`);
83
+ assertContains(html, "sessionStorage.setItem('lesa-token', identity.apiKey);", `${file} stores demo token`);
84
+ assertContains(html, "sessionStorage.setItem('lesa-agent', identity.agentId);", `${file} stores demo agent`);
85
+ assertContains(html, "if (identity.tenantId) sessionStorage.setItem('lesa-tenant', identity.tenantId);", `${file} stores demo tenant`);
86
+ assertContains(html, "if (isNewAccount) sessionStorage.setItem('lesa-new-account', 'true');", `${file} stores demo new-account flag conditionally`);
87
+ assertContains(html, "tenantId: result.tenantId,", `${file} sends tenant to QR approval`);
88
+ assertContains(html, "storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');", `${file} QR status stores demo handoff`);
89
+ assertContains(html, "storeDemoHandoffIfNeeded('/demo', data, qrLoginMode === 'register');", `${file} plain QR success keeps demo handoff`);
90
+ assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, true);", `${file} plain create success keeps demo handoff`);
91
+ assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, false);", `${file} plain sign-in success keeps demo handoff`);
92
+ if (html.includes('onclick="sessionStorage.clear();"')) {
93
+ throw new Error(`${file} Try the Demo button must not clear sessionStorage`);
94
+ }
95
+ assertContains(html, "followPairNextIfPresent(approveResponse, result, true)", `${file} create account marks new account`);
96
+ assertContains(html, "followPairNextIfPresent(approveResponse, result, false)", `${file} sign-in does not mark new account`);
32
97
  assertContains(html, "function isPairNextOnDesktop()", `${file} desktop pair helper`);
33
98
  assertContains(html, "} else if (isPairNextOnDesktop()) {", `${file} auto-start desktop pair QR`);
34
99
  assertContains(html, "startQrLogin('', 'signin');", `${file} pair QR uses sign-in mode`);
@@ -37,4 +102,14 @@ for (const file of loginFiles) {
37
102
  assertBefore(html, "if (isPairNextOnDesktop() && !qrSessionMode)", "if (needsCustomQR() && !qrSessionMode)", `${file} create button forces pair QR before normal QR fallback`);
38
103
  }
39
104
 
105
+ const appLogin = readFileSync("src/hosted-mcp/app/kaleidoscope-login.html", "utf8");
106
+ assertContains(appLogin, "Local passkeys are on by default on mobile devices.", "app login mobile tooltip copy");
107
+ assertContains(appLogin, "This device's passkeys are used for login and device sync.", "app login mobile tooltip current-device copy");
108
+ assertContains(appLogin, "Turn this off to use or save passkeys on a different device.", "app login mobile tooltip off copy");
109
+ assertContains(appLogin, "Local passkeys are off by default on desktop.", "app login desktop tooltip copy");
110
+ assertContains(appLogin, "Your mobile device's passkeys are used for login and device sync.", "app login desktop tooltip mobile-device copy");
111
+ assertContains(appLogin, "Turn this on to use or save passkeys on this computer.", "app login desktop tooltip on copy");
112
+ assertNotContains(appLogin, "phone's passkeys", "app login tooltip must not say phone passkeys");
113
+ assertNotContains(appLogin, "mobile devices' passkeys", "app login tooltip must not use plural possessive");
114
+
40
115
  console.log("crc pair login flow checks passed");
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: manifest-driven boot-hook registration is single-owner.
3
+ //
4
+ // Covers:
5
+ // ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--deploy-hook-ownership-misses-boot-hook.md
6
+ //
7
+ // Before this fix, lib/deploy.mjs installClaudeCodeHookEvent() matched owned
8
+ // hook entries by an extension-dir tag (`/<toolName>/`). The boot hook's
9
+ // deployed command is `node ~/.ldm/shared/boot/boot-hook.mjs`, which contains
10
+ // no `/wip-ldm-os/` segment, so the ownership match found nothing and appended
11
+ // a fresh SessionStart entry on every manifest-driven `ldm install` (the real
12
+ // mechanism behind the 10-entry accumulation on 2026-07-04).
13
+ //
14
+ // The fix routes boot-hook doors to configureSessionStartHook() (the single
15
+ // canonical registrar), so the deploy path no longer appends.
16
+ //
17
+ // Cases:
18
+ // 1. Fresh manifest install: exactly one boot entry, canonical command.
19
+ // 2. Repeat manifest install: byte-identical settings.json (true no-op).
20
+ // 3. Pre-accumulated + unrelated SessionStart hook: collapses boot entries
21
+ // to one, preserves the non-boot SessionStart entry (discriminates on the
22
+ // command, not the event).
23
+ // 4. Dry run: reports intent, writes nothing.
24
+ //
25
+ // HOME must be set before importing deploy.mjs: both deploy.mjs (process.env.HOME)
26
+ // and the transitively-imported src/boot/installer.mjs (os.homedir()) resolve
27
+ // their HOME-based constants at module-load time.
28
+
29
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { tmpdir } from 'node:os';
32
+
33
+ // The temp prefix must not contain "boot-hook" or "shared/boot": those are the
34
+ // ownership matchers, and the temp HOME ends up inside every fixture command.
35
+ const tempHome = mkdtempSync(join(tmpdir(), 'ldm-deployown-'));
36
+ process.env.HOME = tempHome;
37
+
38
+ const { installClaudeCodeHook, setFlags } = await import('../lib/deploy.mjs');
39
+
40
+ // Mirror detect.mjs: the wip-ldm-os package.json declares a single SessionStart
41
+ // boot-hook door. deploy.mjs receives it as a one-element array.
42
+ const bootDoor = {
43
+ event: 'SessionStart',
44
+ matcher: '*',
45
+ command: `node ${join(tempHome, '.ldm', 'shared', 'boot', 'boot-hook.mjs')}`,
46
+ timeout: 15,
47
+ };
48
+ const expectedCommand = `node ${join(tempHome, '.ldm', 'shared', 'boot', 'boot-hook.mjs')}`;
49
+
50
+ let failed = 0;
51
+ function assert(cond, label, detail = '') {
52
+ if (cond) {
53
+ console.log(` [PASS] ${label}`);
54
+ } else {
55
+ console.log(` [FAIL] ${label}`);
56
+ if (detail) console.log(` ${detail}`);
57
+ failed++;
58
+ }
59
+ }
60
+
61
+ const settingsPath = join(tempHome, '.claude', 'settings.json');
62
+ function seedSettings(obj) {
63
+ mkdirSync(join(tempHome, '.claude'), { recursive: true });
64
+ writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + '\n');
65
+ }
66
+ function readSettings() {
67
+ return JSON.parse(readFileSync(settingsPath, 'utf-8'));
68
+ }
69
+ function bootEntries(settings) {
70
+ return (settings.hooks?.SessionStart || []).filter((entry) =>
71
+ (entry?.hooks || []).some((h) => h?.command?.includes('boot-hook'))
72
+ );
73
+ }
74
+ // Install through the manifest wrapper exactly as detect.mjs feeds it: an array.
75
+ function manifestInstall() {
76
+ return installClaudeCodeHook(join(tempHome, 'repo'), [bootDoor], 'wip-ldm-os');
77
+ }
78
+
79
+ try {
80
+ console.log('Test 1: fresh manifest install adds exactly one boot entry');
81
+ {
82
+ seedSettings({ hooks: {} });
83
+ manifestInstall();
84
+ const s = readSettings();
85
+ assert(bootEntries(s).length === 1, 'exactly one boot-hook SessionStart entry');
86
+ assert(
87
+ s.hooks.SessionStart[0].hooks[0].command === expectedCommand,
88
+ 'command canonicalized to BOOT_DIR boot-hook.mjs',
89
+ `on disk: ${s.hooks.SessionStart[0].hooks[0].command}`
90
+ );
91
+ }
92
+
93
+ console.log('Test 2: repeat manifest install is a byte-identical no-op');
94
+ {
95
+ const before = readFileSync(settingsPath, 'utf-8');
96
+ manifestInstall();
97
+ const after = readFileSync(settingsPath, 'utf-8');
98
+ assert(before === after, 'settings.json byte-identical after second manifest install');
99
+ assert(bootEntries(readSettings()).length === 1, 'still exactly one boot-hook entry (no append)');
100
+ }
101
+
102
+ console.log('Test 3: pre-accumulated boot entries collapse; unrelated SessionStart hook preserved');
103
+ {
104
+ // Three legacy boot appends (the pre-fix accumulation) using the old
105
+ // hardcoded absolute path, plus an unrelated SessionStart guard entry.
106
+ const legacyBoot = {
107
+ matcher: '*',
108
+ hooks: [{ type: 'command', command: 'node /Users/someone/.ldm/shared/boot/boot-hook.mjs', timeout: 15 }],
109
+ };
110
+ const guardSessionStart = {
111
+ matcher: 'Read|Glob',
112
+ hooks: [{ type: 'command', command: `node ${join(tempHome, '.ldm', 'extensions', 'wip-branch-guard', 'guard.mjs')}`, timeout: 10 }],
113
+ };
114
+ seedSettings({
115
+ hooks: {
116
+ SessionStart: [
117
+ structuredClone(legacyBoot),
118
+ structuredClone(legacyBoot),
119
+ guardSessionStart,
120
+ structuredClone(legacyBoot),
121
+ ],
122
+ },
123
+ });
124
+ manifestInstall();
125
+ const s = readSettings();
126
+ assert(bootEntries(s).length === 1, 'boot entries collapsed to one');
127
+ assert(
128
+ s.hooks.SessionStart[0].hooks[0].command === expectedCommand,
129
+ 'surviving boot entry canonicalized to BOOT_DIR'
130
+ );
131
+ assert(
132
+ s.hooks.SessionStart.some((e) => e.hooks?.[0]?.command?.includes('wip-branch-guard')),
133
+ 'unrelated SessionStart guard entry preserved (discriminates on command, not event)'
134
+ );
135
+ assert(s.hooks.SessionStart.length === 2, 'exactly boot + guard remain');
136
+ }
137
+
138
+ console.log('Test 4: dry run reports intent and writes nothing');
139
+ {
140
+ seedSettings({ hooks: {} });
141
+ const before = readFileSync(settingsPath, 'utf-8');
142
+ setFlags({ dryRun: true });
143
+ try {
144
+ manifestInstall();
145
+ } finally {
146
+ setFlags({ dryRun: false });
147
+ }
148
+ const after = readFileSync(settingsPath, 'utf-8');
149
+ assert(before === after, 'settings.json untouched under dry run');
150
+ assert(bootEntries(readSettings()).length === 0, 'no boot entry written under dry run');
151
+ }
152
+ } finally {
153
+ rmSync(tempHome, { recursive: true, force: true });
154
+ }
155
+
156
+ if (failed > 0) {
157
+ console.log(`\n${failed} assertion(s) failed`);
158
+ process.exit(1);
159
+ }
160
+ console.log('\nAll deploy-hook ownership tests passed');
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: `ldm doctor` duplicate-hook + invalid-model checks.
3
+ //
4
+ // Covers the 2026-07-04 tickets:
5
+ // ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
6
+ // ai/product/bugs/guard/2026-07-04--cc-mini--no-blessed-recipe-for-live-settings-remediation.md
7
+ //
8
+ // Cases:
9
+ // 1. Duplicate-laden settings: doctor reports, does NOT write without --fix.
10
+ // 2. --fix collapses duplicates to 1, writes a timestamped backup first,
11
+ // preserves unrelated hooks and unknown keys.
12
+ // 3. Invalid model value ("opus" + real ESC 0x1B + "[1m"): reported, removed under --fix.
13
+ // 4. Malformed settings.json: warns and skips, file untouched, no crash.
14
+ //
15
+ // Follows the test-doctor-cron-target.mjs pattern: real bin/ldm.js doctor
16
+ // against a temp HOME, crontab/npm shimmed on PATH so the operator's real
17
+ // state is never read.
18
+
19
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { tmpdir } from 'node:os';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
26
+ const cli = join(repo, 'bin', 'ldm.js');
27
+ const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
28
+
29
+ let failed = 0;
30
+ function assert(cond, label, output = '') {
31
+ if (cond) {
32
+ console.log(` [PASS] ${label}`);
33
+ } else {
34
+ console.log(` [FAIL] ${label}`);
35
+ if (output) console.log(` --- output (last lines) ---\n ${output.trim().split('\n').slice(-25).join('\n ')}`);
36
+ failed++;
37
+ }
38
+ }
39
+
40
+ function setupHome(settingsFactory) {
41
+ const home = mkdtempSync(join(tmpdir(), 'ldm-doctor-dedupe-'));
42
+ const ldmRoot = join(home, '.ldm');
43
+ const fakeBin = join(home, 'fakebin');
44
+ mkdirSync(join(ldmRoot, 'extensions'), { recursive: true });
45
+ mkdirSync(join(home, '.claude'), { recursive: true });
46
+ mkdirSync(fakeBin, { recursive: true });
47
+
48
+ writeFileSync(join(ldmRoot, 'version.json'), JSON.stringify({ version: pkg.version }, null, 2) + '\n');
49
+ writeFileSync(join(ldmRoot, 'extensions', 'registry.json'), JSON.stringify({ _format: 'v2', extensions: {} }, null, 2) + '\n');
50
+
51
+ // Hook scripts must exist on disk: doctor --fix runs the stale-path
52
+ // cleanup (#30) before the dedupe pass, and missing targets would be
53
+ // removed as stale instead of collapsed as duplicates.
54
+ const bootScript = join(ldmRoot, 'shared', 'boot', 'boot-hook.mjs');
55
+ const guardScript = join(ldmRoot, 'extensions', 'wip-branch-guard', 'guard.mjs');
56
+ const stopScript = join(ldmRoot, 'extensions', 'stop-hook.mjs');
57
+ for (const f of [bootScript, guardScript, stopScript]) {
58
+ mkdirSync(dirname(f), { recursive: true });
59
+ writeFileSync(f, 'process.exit(0);\n');
60
+ }
61
+
62
+ const paths = { bootScript, guardScript, stopScript };
63
+ const settingsContent = settingsFactory(paths);
64
+ const settingsPath = join(home, '.claude', 'settings.json');
65
+ writeFileSync(settingsPath, typeof settingsContent === 'string'
66
+ ? settingsContent
67
+ : JSON.stringify(settingsContent, null, 2) + '\n');
68
+
69
+ // Empty crontab + no-op npm on PATH (never touch operator state/network).
70
+ writeFileSync(join(fakeBin, 'crontab'), '#!/bin/sh\nexit 0\n');
71
+ chmodSync(join(fakeBin, 'crontab'), 0o755);
72
+ writeFileSync(join(fakeBin, 'npm'), '#!/bin/sh\nexit 0\n');
73
+ chmodSync(join(fakeBin, 'npm'), 0o755);
74
+
75
+ return { home, fakeBin, settingsPath, paths };
76
+ }
77
+
78
+ function runDoctor({ home, fakeBin }, fix = false) {
79
+ const args = ['doctor'];
80
+ if (fix) args.push('--fix');
81
+ try {
82
+ return execFileSync('node', [cli, ...args], {
83
+ env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
84
+ encoding: 'utf-8',
85
+ timeout: 30000,
86
+ });
87
+ } catch (err) {
88
+ return (err.stdout || '') + (err.stderr || '');
89
+ }
90
+ }
91
+
92
+ const bootCmd = (paths) => `node ${paths.bootScript}`;
93
+ const bootEntry = (paths) => ({ matcher: '*', hooks: [{ type: 'command', command: bootCmd(paths), timeout: 15 }] });
94
+ const guardEntry = (paths) => ({ hooks: [{ type: 'command', command: `node ${paths.guardScript}`, timeout: 10 }] });
95
+
96
+ function dupeLadenSettings(paths) {
97
+ return {
98
+ unknownTopLevelKey: { keep: 'me' },
99
+ permissions: { defaultMode: 'default' },
100
+ hooks: {
101
+ SessionStart: [
102
+ ...Array.from({ length: 5 }, () => bootEntry(paths)),
103
+ guardEntry(paths),
104
+ ...Array.from({ length: 5 }, () => bootEntry(paths)),
105
+ ],
106
+ Stop: [{ hooks: [{ type: 'command', command: `node ${paths.stopScript}` }] }],
107
+ },
108
+ };
109
+ }
110
+
111
+ console.log('Test 1: duplicates reported, no write without --fix');
112
+ {
113
+ const w = setupHome(dupeLadenSettings);
114
+ const before = readFileSync(w.settingsPath, 'utf-8');
115
+ const out = runDoctor(w);
116
+ const after = readFileSync(w.settingsPath, 'utf-8');
117
+ assert(/SessionStart: 10 identical hook entries/.test(out), 'reports 10 identical SessionStart entries', out);
118
+ assert(/ldm doctor --fix to collapse 9 duplicate hook entries/.test(out), 'suggests --fix with count', out);
119
+ assert(before === after, 'settings.json untouched without --fix');
120
+ rmSync(w.home, { recursive: true, force: true });
121
+ }
122
+
123
+ console.log('Test 2: --fix collapses, backs up first, preserves everything else');
124
+ {
125
+ const w = setupHome(dupeLadenSettings);
126
+ const out = runDoctor(w, true);
127
+ const s = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
128
+ const bootCount = s.hooks.SessionStart.filter((e) => e.hooks?.[0]?.command === bootCmd(w.paths)).length;
129
+ const backups = readdirSync(join(w.home, '.claude')).filter((f) => f.startsWith('settings.json.bak-'));
130
+ assert(/Collapsed 9 duplicate hook entries/.test(out), 'reports 9 collapsed', out);
131
+ assert(bootCount === 1, `boot-hook entries collapsed to 1 (got ${bootCount})`);
132
+ assert(s.hooks.SessionStart.length === 2, 'boot hook + branch guard remain');
133
+ assert(s.hooks.Stop.length === 1, 'unrelated Stop hook preserved');
134
+ assert(s.unknownTopLevelKey?.keep === 'me', 'unknown top-level key preserved');
135
+ assert(backups.length === 1, `timestamped backup written (found ${backups.length})`);
136
+ if (backups.length === 1) {
137
+ const bak = JSON.parse(readFileSync(join(w.home, '.claude', backups[0]), 'utf-8'));
138
+ assert(bak.hooks.SessionStart.length === 11, 'backup holds the pre-fix state');
139
+ }
140
+ const again = runDoctor(w, true);
141
+ assert(!/Collapsed/.test(again), 'second --fix run finds nothing to collapse', again);
142
+ rmSync(w.home, { recursive: true, force: true });
143
+ }
144
+
145
+ console.log('Test 3: invalid model value reported and removed under --fix');
146
+ {
147
+ // The corruption fixture carries a REAL ESC control char (0x1B): "opus"
148
+ // plus an ANSI bold fragment, which is what a terminal paste persists.
149
+ // Printable brackets alone are NOT corruption (see Test 4).
150
+ const w = setupHome((paths) => ({ model: 'opus\x1b[1m', hooks: { SessionStart: [bootEntry(paths)] } }));
151
+ const out = runDoctor(w);
152
+ assert(/model value is invalid/.test(out), 'reports invalid model', out);
153
+ const before = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
154
+ assert(typeof before.model === 'string', 'model untouched without --fix');
155
+ const fixOut = runDoctor(w, true);
156
+ const s = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
157
+ assert(/Removed invalid model value/.test(fixOut), 'reports removal under --fix', fixOut);
158
+ assert(!('model' in s), 'model key removed from disk');
159
+ assert(s.hooks.SessionStart.length === 1, 'hooks untouched by model fix');
160
+ rmSync(w.home, { recursive: true, force: true });
161
+ }
162
+
163
+ console.log('Test 4: valid model values are left alone (including bracketed 1M-context IDs)');
164
+ {
165
+ const w = setupHome(() => ({ model: 'claude-fable-5[1m]', hooks: {} }));
166
+ const out = runDoctor(w, true);
167
+ const s = JSON.parse(readFileSync(w.settingsPath, 'utf-8'));
168
+ assert(!/model value is invalid/.test(out), 'no invalid-model report for bracketed 1M id', out);
169
+ assert(s.model === 'claude-fable-5[1m]', 'legitimate 1M-context model preserved');
170
+ rmSync(w.home, { recursive: true, force: true });
171
+ }
172
+
173
+ console.log('Test 5: malformed settings.json warns, skips, file untouched');
174
+ {
175
+ const w = setupHome(() => '{ this is not json\n');
176
+ const before = readFileSync(w.settingsPath, 'utf-8');
177
+ const out = runDoctor(w, true);
178
+ const after = readFileSync(w.settingsPath, 'utf-8');
179
+ assert(/not valid JSON; skipping hook\/model checks/.test(out), 'warns about malformed JSON', out);
180
+ assert(before === after, 'malformed file untouched');
181
+ rmSync(w.home, { recursive: true, force: true });
182
+ }
183
+
184
+ if (failed > 0) {
185
+ console.log(`\n${failed} assertion(s) failed`);
186
+ process.exit(1);
187
+ }
188
+ console.log('\nAll doctor hook-dedupe tests passed');