@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
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fixture test for the Phase 1 source-types migration planner.
|
|
3
|
+
// See lib/registry-migrations.mjs and
|
|
4
|
+
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
5
|
+
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
planLegacyNpmSourcesMigration,
|
|
12
|
+
summaryHasChanges,
|
|
13
|
+
emptyLegacyNpmSourcesSummary,
|
|
14
|
+
executeDirectoryMoves,
|
|
15
|
+
} from '../lib/registry-migrations.mjs';
|
|
16
|
+
|
|
17
|
+
function fail(msg) {
|
|
18
|
+
throw new Error(msg);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertEqual(actual, expected, label) {
|
|
22
|
+
if (actual !== expected) {
|
|
23
|
+
fail(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertDeepEqual(actual, expected, label) {
|
|
28
|
+
const a = JSON.stringify(actual, Object.keys(actual || {}).sort());
|
|
29
|
+
const e = JSON.stringify(expected, Object.keys(expected || {}).sort());
|
|
30
|
+
if (a !== e) {
|
|
31
|
+
fail(`${label}: expected ${e}, got ${a}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Mirrors the shape we see on Parker's machine in the 2026-05-13 dogfood.
|
|
36
|
+
function buildFixtureRegistry() {
|
|
37
|
+
return {
|
|
38
|
+
_format: 'v2',
|
|
39
|
+
extensions: {
|
|
40
|
+
// 404 npm + on-disk: should migrate to untracked.
|
|
41
|
+
'cc-session-export': {
|
|
42
|
+
source: { type: 'github', npm: 'session-export', repo: 'wipcomputer/cc-session-export' },
|
|
43
|
+
installed: { version: '1.0.0' },
|
|
44
|
+
},
|
|
45
|
+
// Duplicate of cc-session-export: should be deduped (removed).
|
|
46
|
+
'session-export': {
|
|
47
|
+
source: { type: 'github', npm: 'session-export' },
|
|
48
|
+
installed: { version: '1.0.0' },
|
|
49
|
+
},
|
|
50
|
+
'compaction-indicator': {
|
|
51
|
+
source: { type: 'github', npm: 'compaction-indicator' },
|
|
52
|
+
installed: { version: '1.0.1' },
|
|
53
|
+
},
|
|
54
|
+
'lesa-bridge': {
|
|
55
|
+
source: { type: 'github', npm: 'lesa-bridge' },
|
|
56
|
+
installed: { version: '0.3.0' },
|
|
57
|
+
},
|
|
58
|
+
// Duplicate of wip-branch-guard: should be deduped.
|
|
59
|
+
'package': {
|
|
60
|
+
source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
|
|
61
|
+
installed: { version: '1.0.0' },
|
|
62
|
+
},
|
|
63
|
+
'wip-branch-guard': {
|
|
64
|
+
source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
|
|
65
|
+
installed: { version: '1.0.0' },
|
|
66
|
+
},
|
|
67
|
+
// Real npm package: should be left alone.
|
|
68
|
+
'memory-crystal': {
|
|
69
|
+
source: { type: 'github', npm: '@wipcomputer/memory-crystal' },
|
|
70
|
+
installed: { version: '2.0.0' },
|
|
71
|
+
},
|
|
72
|
+
// Phantom: no on-disk directory. Should be removed.
|
|
73
|
+
'tavily': {
|
|
74
|
+
source: { type: 'github', npm: '@wipcomputer/openclaw-tavily' },
|
|
75
|
+
installed: { version: '0.1.0' },
|
|
76
|
+
},
|
|
77
|
+
// Mystery row: no source info at all but on-disk. Should be classified
|
|
78
|
+
// untracked (Step 4 path in the planner).
|
|
79
|
+
//
|
|
80
|
+
// Note: on Parker's real machine the `run` entry has no on-disk
|
|
81
|
+
// directory, so it hits Step 1 (phantom removal) instead. We exercise
|
|
82
|
+
// the Step 4 path here via this fixture; Step 1 is covered by the
|
|
83
|
+
// `tavily` fixture below.
|
|
84
|
+
'run': {
|
|
85
|
+
installed: { version: 'unknown' },
|
|
86
|
+
},
|
|
87
|
+
// Already migrated: must be skipped (idempotency).
|
|
88
|
+
'already-untracked': {
|
|
89
|
+
updateSource: { type: 'untracked' },
|
|
90
|
+
provenance: { 'legacy-npm-name': 'already-untracked', untrackedSince: '2026-05-13T00:00:00.000Z' },
|
|
91
|
+
installed: { version: '0.1.0' },
|
|
92
|
+
},
|
|
93
|
+
// Probe will fail (timeout). Should be left alone, listed in probeFailures.
|
|
94
|
+
'flaky-network': {
|
|
95
|
+
source: { type: 'github', npm: 'flaky-network' },
|
|
96
|
+
installed: { version: '0.1.0' },
|
|
97
|
+
},
|
|
98
|
+
// Custom-path entry: declares `paths.ldm` pointing outside the default
|
|
99
|
+
// ~/.ldm/extensions/<name> location. The planner must NOT classify
|
|
100
|
+
// this as a phantom. Its source.npm is 404, so it should be migrated
|
|
101
|
+
// to untracked while the custom path is preserved.
|
|
102
|
+
'custom-path-untracked': {
|
|
103
|
+
source: { type: 'github', npm: 'custom-path-untracked' },
|
|
104
|
+
paths: { ldm: '/custom/location/path' },
|
|
105
|
+
installed: { version: '1.0.0' },
|
|
106
|
+
},
|
|
107
|
+
// Legacy custom-path field (`ldmPath` flat). Same expectation.
|
|
108
|
+
'legacy-custom-path': {
|
|
109
|
+
source: { type: 'github', npm: 'legacy-custom-path' },
|
|
110
|
+
ldmPath: '/legacy/custom/path',
|
|
111
|
+
installed: { version: '0.2.0' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const NPM_404 = new Set([
|
|
118
|
+
'session-export',
|
|
119
|
+
'compaction-indicator',
|
|
120
|
+
'lesa-bridge',
|
|
121
|
+
'custom-path-untracked',
|
|
122
|
+
'legacy-custom-path',
|
|
123
|
+
// @wipcomputer/wip-branch-guard simulated as 200 (exists). But the dedupe
|
|
124
|
+
// happens first, so `package` is gone before the probe runs.
|
|
125
|
+
// The remaining `wip-branch-guard` will see exists=true and be left alone.
|
|
126
|
+
]);
|
|
127
|
+
const NPM_200 = new Set([
|
|
128
|
+
'@wipcomputer/memory-crystal',
|
|
129
|
+
'@wipcomputer/wip-branch-guard',
|
|
130
|
+
]);
|
|
131
|
+
const NPM_FAIL = new Set([
|
|
132
|
+
'flaky-network',
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
function fakeProbeNpm(name) {
|
|
136
|
+
if (NPM_FAIL.has(name)) return Promise.resolve(null);
|
|
137
|
+
if (NPM_200.has(name)) return Promise.resolve(true);
|
|
138
|
+
if (NPM_404.has(name)) return Promise.resolve(false);
|
|
139
|
+
// Fail loudly when the planner probes an npm name the test forgot to
|
|
140
|
+
// enumerate. Future planner changes that call probeNpm with new names
|
|
141
|
+
// must explicitly declare expected behavior here; silent 404 defaults
|
|
142
|
+
// would swallow regressions.
|
|
143
|
+
fail(`fakeProbeNpm called with un-enumerated name "${name}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// On-disk extension simulator. `tavily` is a phantom (no directory at any
|
|
147
|
+
// location). All other names get a default directory unless they declare a
|
|
148
|
+
// custom path, in which case we honor the custom path. The planner must
|
|
149
|
+
// pass the entry to extensionExists for this to work.
|
|
150
|
+
const PHANTOM_NAMES = new Set(['tavily']);
|
|
151
|
+
const CUSTOM_PATH_EXISTS = new Set(['/custom/location/path', '/legacy/custom/path']);
|
|
152
|
+
function fakeExtensionExists(name, entry) {
|
|
153
|
+
if (entry?.paths?.ldm) return CUSTOM_PATH_EXISTS.has(entry.paths.ldm);
|
|
154
|
+
if (entry?.ldmPath) return CUSTOM_PATH_EXISTS.has(entry.ldmPath);
|
|
155
|
+
return !PHANTOM_NAMES.has(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
|
|
159
|
+
|
|
160
|
+
// ── Test 1: full migration on fixture ──────────────────────────────────────
|
|
161
|
+
{
|
|
162
|
+
const input = buildFixtureRegistry();
|
|
163
|
+
const inputSnapshot = JSON.stringify(input);
|
|
164
|
+
|
|
165
|
+
const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
|
|
166
|
+
registry: input,
|
|
167
|
+
probeNpm: fakeProbeNpm,
|
|
168
|
+
extensionExists: fakeExtensionExists,
|
|
169
|
+
now: FIXED_NOW,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Input must not be mutated.
|
|
173
|
+
assertEqual(JSON.stringify(input), inputSnapshot, 'input registry mutated');
|
|
174
|
+
|
|
175
|
+
// Phantoms removed.
|
|
176
|
+
assertEqual(summary.phantomsRemoved.length, 1, 'phantomsRemoved.length');
|
|
177
|
+
assertEqual(summary.phantomsRemoved[0].name, 'tavily', 'phantom name');
|
|
178
|
+
assertEqual(newRegistry.extensions.tavily, undefined, 'phantom still in registry');
|
|
179
|
+
|
|
180
|
+
// Duplicates removed.
|
|
181
|
+
assertEqual(summary.duplicatesRemoved.length, 2, 'duplicatesRemoved.length');
|
|
182
|
+
const removedDupes = summary.duplicatesRemoved.map(d => d.removed).sort();
|
|
183
|
+
assertDeepEqual(removedDupes, ['package', 'session-export'], 'dedupe targets');
|
|
184
|
+
assertEqual(newRegistry.extensions['session-export'], undefined, 'session-export still in registry');
|
|
185
|
+
assertEqual(newRegistry.extensions['package'], undefined, 'package still in registry');
|
|
186
|
+
if (!newRegistry.extensions['cc-session-export']) fail('canonical cc-session-export removed');
|
|
187
|
+
if (!newRegistry.extensions['wip-branch-guard']) fail('canonical wip-branch-guard removed');
|
|
188
|
+
|
|
189
|
+
// Each removed duplicate must also produce a directoryMoves entry. The
|
|
190
|
+
// wrapper in bin/ldm.js executes these moves after the registry write so
|
|
191
|
+
// autoDetectExtensions cannot re-register the duplicate on the same install
|
|
192
|
+
// run. Without this contract, the registry dedup reverts (see
|
|
193
|
+
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
|
|
194
|
+
assertEqual(summary.directoryMoves.length, 2, 'directoryMoves.length');
|
|
195
|
+
const moveNames = summary.directoryMoves.map(m => m.name).sort();
|
|
196
|
+
assertDeepEqual(moveNames, ['package', 'session-export'], 'directoryMoves names');
|
|
197
|
+
for (const m of summary.directoryMoves) {
|
|
198
|
+
assertEqual(m.reason, 'deduplicated', `directoryMoves[${m.name}].reason`);
|
|
199
|
+
if (!m.trashName.startsWith(`${m.name}-deduplicated-`)) {
|
|
200
|
+
fail(`directoryMoves[${m.name}].trashName should start with "${m.name}-deduplicated-" but was "${m.trashName}"`);
|
|
201
|
+
}
|
|
202
|
+
if (!m.trashName.includes('2026-05-13T18-00-00-000Z')) {
|
|
203
|
+
fail(`directoryMoves[${m.name}].trashName should embed the fixed-now timestamp but was "${m.trashName}"`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The planner is pure: it must NOT pre-populate directoryMovesPerformed or
|
|
208
|
+
// directoryMovesSkipped. Those are wrapper outputs from real filesystem I/O.
|
|
209
|
+
assertEqual(summary.directoryMovesPerformed.length, 0, 'directoryMovesPerformed should be empty from pure planner');
|
|
210
|
+
assertEqual(summary.directoryMovesSkipped.length, 0, 'directoryMovesSkipped should be empty from pure planner');
|
|
211
|
+
|
|
212
|
+
// Migrated entries: cc-session-export, compaction-indicator, lesa-bridge,
|
|
213
|
+
// run, plus the two custom-path entries (custom-path-untracked,
|
|
214
|
+
// legacy-custom-path). `session-export` and `package` were deduped before
|
|
215
|
+
// probe. `wip-branch-guard` exists on npm. `flaky-network` probe failed.
|
|
216
|
+
const migratedNames = summary.migrated.map(m => m.name).sort();
|
|
217
|
+
assertDeepEqual(migratedNames, [
|
|
218
|
+
'cc-session-export', 'compaction-indicator', 'custom-path-untracked',
|
|
219
|
+
'legacy-custom-path', 'lesa-bridge', 'run',
|
|
220
|
+
], 'migrated names');
|
|
221
|
+
|
|
222
|
+
// Custom-path entries must NOT be removed as phantoms. The planner must
|
|
223
|
+
// pass the entry to extensionExists so the custom path is honored.
|
|
224
|
+
// Regression guard for the round-3 Codex blocker (data-loss path on
|
|
225
|
+
// entries with entry.paths.ldm or entry.ldmPath).
|
|
226
|
+
const cpu = newRegistry.extensions['custom-path-untracked'];
|
|
227
|
+
if (!cpu) fail('custom-path-untracked was removed as phantom (extensionExists ignored entry.paths.ldm)');
|
|
228
|
+
assertEqual(cpu.updateSource?.type, 'untracked', 'custom-path-untracked.updateSource.type');
|
|
229
|
+
assertEqual(cpu.paths?.ldm, '/custom/location/path', 'custom-path-untracked.paths.ldm preserved');
|
|
230
|
+
const lcp = newRegistry.extensions['legacy-custom-path'];
|
|
231
|
+
if (!lcp) fail('legacy-custom-path was removed as phantom (extensionExists ignored entry.ldmPath)');
|
|
232
|
+
assertEqual(lcp.updateSource?.type, 'untracked', 'legacy-custom-path.updateSource.type');
|
|
233
|
+
assertEqual(lcp.ldmPath, '/legacy/custom/path', 'legacy-custom-path.ldmPath preserved');
|
|
234
|
+
|
|
235
|
+
// Each migrated entry has updateSource.type=untracked.
|
|
236
|
+
for (const name of migratedNames) {
|
|
237
|
+
const e = newRegistry.extensions[name];
|
|
238
|
+
if (!e) fail(`migrated entry ${name} missing from newRegistry`);
|
|
239
|
+
assertEqual(e.updateSource?.type, 'untracked', `${name}.updateSource.type`);
|
|
240
|
+
if ('source' in e) fail(`${name}.source should be deleted`);
|
|
241
|
+
if (!e.provenance) fail(`${name}.provenance missing`);
|
|
242
|
+
assertEqual(e.provenance.untrackedSince, '2026-05-13T18:00:00.000Z', `${name}.provenance.untrackedSince`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// cc-session-export specifically: legacy-npm-name + repo preserved.
|
|
246
|
+
const ccse = newRegistry.extensions['cc-session-export'];
|
|
247
|
+
assertEqual(ccse.provenance['legacy-npm-name'], 'session-export', 'cc-session-export legacy-npm-name');
|
|
248
|
+
assertEqual(ccse.provenance.repo, 'wipcomputer/cc-session-export', 'cc-session-export legacy repo');
|
|
249
|
+
|
|
250
|
+
// `run` had no source info: legacy-npm-name absent, no repo, but still classified.
|
|
251
|
+
const runEntry = newRegistry.extensions.run;
|
|
252
|
+
if ('legacy-npm-name' in runEntry.provenance) fail('run.provenance should not have legacy-npm-name');
|
|
253
|
+
if ('repo' in runEntry.provenance) fail('run.provenance should not have repo');
|
|
254
|
+
|
|
255
|
+
// Real npm package left alone.
|
|
256
|
+
const mc = newRegistry.extensions['memory-crystal'];
|
|
257
|
+
if (mc.updateSource) fail('memory-crystal should not be migrated (real npm pkg)');
|
|
258
|
+
assertEqual(mc.source?.npm, '@wipcomputer/memory-crystal', 'memory-crystal source preserved');
|
|
259
|
+
|
|
260
|
+
// wip-branch-guard left alone (real npm pkg).
|
|
261
|
+
const wbg = newRegistry.extensions['wip-branch-guard'];
|
|
262
|
+
if (wbg.updateSource) fail('wip-branch-guard should not be migrated (real npm pkg)');
|
|
263
|
+
|
|
264
|
+
// Already-untracked entry is unchanged.
|
|
265
|
+
const au = newRegistry.extensions['already-untracked'];
|
|
266
|
+
assertEqual(au.provenance.untrackedSince, '2026-05-13T00:00:00.000Z', 'already-untracked untracked since preserved');
|
|
267
|
+
|
|
268
|
+
// Probe failures recorded, entry untouched.
|
|
269
|
+
assertEqual(summary.probeFailures.length, 1, 'probeFailures.length');
|
|
270
|
+
assertEqual(summary.probeFailures[0].name, 'flaky-network', 'probe failure name');
|
|
271
|
+
const fn = newRegistry.extensions['flaky-network'];
|
|
272
|
+
if (fn.updateSource) fail('flaky-network should not be migrated (probe failed)');
|
|
273
|
+
assertEqual(fn.source?.npm, 'flaky-network', 'flaky-network source preserved');
|
|
274
|
+
|
|
275
|
+
// summaryHasChanges flips true when anything happens.
|
|
276
|
+
assertEqual(summaryHasChanges(summary), true, 'summaryHasChanges on populated summary');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Test 2: idempotency on a fully-migrated registry ───────────────────────
|
|
280
|
+
{
|
|
281
|
+
const registry = {
|
|
282
|
+
extensions: {
|
|
283
|
+
'a': {
|
|
284
|
+
updateSource: { type: 'untracked' },
|
|
285
|
+
provenance: { untrackedSince: '2026-05-13T00:00:00.000Z' },
|
|
286
|
+
installed: { version: '1.0.0' },
|
|
287
|
+
},
|
|
288
|
+
'b': {
|
|
289
|
+
updateSource: { type: 'untracked' },
|
|
290
|
+
provenance: { 'legacy-npm-name': 'b', untrackedSince: '2026-05-13T00:00:00.000Z' },
|
|
291
|
+
installed: { version: '0.1.0' },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
const before = JSON.stringify(registry);
|
|
296
|
+
const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
|
|
297
|
+
registry,
|
|
298
|
+
probeNpm: () => fail('probeNpm should not be called on fully-migrated registry'),
|
|
299
|
+
extensionExists: () => true,
|
|
300
|
+
now: FIXED_NOW,
|
|
301
|
+
});
|
|
302
|
+
assertEqual(JSON.stringify(registry), before, 'input mutated on idempotent run');
|
|
303
|
+
assertEqual(summary.migrated.length, 0, 'migrated.length on idempotent run');
|
|
304
|
+
assertEqual(summary.phantomsRemoved.length, 0, 'phantomsRemoved.length on idempotent run');
|
|
305
|
+
assertEqual(summary.duplicatesRemoved.length, 0, 'duplicatesRemoved.length on idempotent run');
|
|
306
|
+
assertEqual(summary.directoryMoves.length, 0, 'directoryMoves.length on idempotent run');
|
|
307
|
+
assertEqual(summaryHasChanges(summary), false, 'summaryHasChanges on empty summary');
|
|
308
|
+
assertDeepEqual(
|
|
309
|
+
Object.keys(newRegistry.extensions).sort(),
|
|
310
|
+
['a', 'b'],
|
|
311
|
+
'extensions preserved on idempotent run',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Test 3: emptyLegacyNpmSourcesSummary returns the canonical shape ───────
|
|
316
|
+
{
|
|
317
|
+
const e = emptyLegacyNpmSourcesSummary();
|
|
318
|
+
assertDeepEqual(Object.keys(e).sort(), [
|
|
319
|
+
'directoryMoves',
|
|
320
|
+
'directoryMovesPerformed',
|
|
321
|
+
'directoryMovesSkipped',
|
|
322
|
+
'duplicatesRemoved',
|
|
323
|
+
'migrated',
|
|
324
|
+
'phantomsRemoved',
|
|
325
|
+
'probeFailures',
|
|
326
|
+
'probedCount',
|
|
327
|
+
'timestamp',
|
|
328
|
+
], 'empty summary keys');
|
|
329
|
+
// Wrapper-output fields start empty even though the planner doesn't
|
|
330
|
+
// populate them ... the wrapper is responsible for appending.
|
|
331
|
+
assertEqual(e.directoryMoves.length, 0, 'empty.directoryMoves');
|
|
332
|
+
assertEqual(e.directoryMovesPerformed.length, 0, 'empty.directoryMovesPerformed');
|
|
333
|
+
assertEqual(e.directoryMovesSkipped.length, 0, 'empty.directoryMovesSkipped');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Test 4: executeDirectoryMoves against a real temp filesystem ──────────
|
|
337
|
+
// Regression guard for the bug fixed by this PR
|
|
338
|
+
// (ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
|
|
339
|
+
// The planner emits directoryMoves entries; the executor must actually move
|
|
340
|
+
// the on-disk directories into trash so autoDetectExtensions cannot
|
|
341
|
+
// re-register them on the next install scan.
|
|
342
|
+
{
|
|
343
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-'));
|
|
344
|
+
try {
|
|
345
|
+
const extensionsRoot = join(tmpHome, 'extensions');
|
|
346
|
+
const trashRoot = join(tmpHome, '_trash');
|
|
347
|
+
mkdirSync(extensionsRoot, { recursive: true });
|
|
348
|
+
|
|
349
|
+
// Stand up the two duplicate directories the planner would dedup.
|
|
350
|
+
for (const name of ['session-export', 'package']) {
|
|
351
|
+
const dir = join(extensionsRoot, name);
|
|
352
|
+
mkdirSync(dir, { recursive: true });
|
|
353
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }) + '\n');
|
|
354
|
+
}
|
|
355
|
+
// And a non-duplicate that must be left alone (proxy for cc-session-export).
|
|
356
|
+
const ccsePath = join(extensionsRoot, 'cc-session-export');
|
|
357
|
+
mkdirSync(ccsePath, { recursive: true });
|
|
358
|
+
writeFileSync(join(ccsePath, 'package.json'), JSON.stringify({ name: 'cc-session-export', version: '1.0.0' }) + '\n');
|
|
359
|
+
|
|
360
|
+
// Run the planner with the fixture to get a real directoryMoves plan.
|
|
361
|
+
const registry = {
|
|
362
|
+
extensions: {
|
|
363
|
+
'cc-session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
|
|
364
|
+
'session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
|
|
365
|
+
'wip-branch-guard': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
|
|
366
|
+
'package': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
const { summary } = await planLegacyNpmSourcesMigration({
|
|
370
|
+
registry,
|
|
371
|
+
probeNpm: (name) => Promise.resolve(name === '@wipcomputer/wip-branch-guard'),
|
|
372
|
+
extensionExists: () => true,
|
|
373
|
+
now: FIXED_NOW,
|
|
374
|
+
});
|
|
375
|
+
assertEqual(summary.directoryMoves.length, 2, 'dedup plan produces 2 directoryMoves');
|
|
376
|
+
|
|
377
|
+
// Execute the moves against the temp filesystem.
|
|
378
|
+
const { performed, skipped } = executeDirectoryMoves({
|
|
379
|
+
directoryMoves: summary.directoryMoves,
|
|
380
|
+
extensionsRoot,
|
|
381
|
+
trashRoot,
|
|
382
|
+
});
|
|
383
|
+
assertEqual(performed.length, 2, 'executor performed 2 moves');
|
|
384
|
+
assertEqual(skipped.length, 0, 'executor did not skip any moves');
|
|
385
|
+
|
|
386
|
+
// Source directories are gone.
|
|
387
|
+
if (existsSync(join(extensionsRoot, 'session-export'))) {
|
|
388
|
+
fail('session-export directory should have been moved out of extensions/');
|
|
389
|
+
}
|
|
390
|
+
if (existsSync(join(extensionsRoot, 'package'))) {
|
|
391
|
+
fail('package directory should have been moved out of extensions/');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Trash directory now has the moved entries with the deduplicated suffix.
|
|
395
|
+
const trashContents = readdirSync(trashRoot);
|
|
396
|
+
if (!trashContents.some(name => name.startsWith('session-export-deduplicated-'))) {
|
|
397
|
+
fail(`trash should contain session-export-deduplicated-* but had: ${trashContents.join(', ')}`);
|
|
398
|
+
}
|
|
399
|
+
if (!trashContents.some(name => name.startsWith('package-deduplicated-'))) {
|
|
400
|
+
fail(`trash should contain package-deduplicated-* but had: ${trashContents.join(', ')}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Non-duplicate must NOT have been touched.
|
|
404
|
+
if (!existsSync(ccsePath)) fail('cc-session-export directory should be untouched');
|
|
405
|
+
|
|
406
|
+
// autoDetectExtensions simulation: a fresh scan of extensionsRoot must
|
|
407
|
+
// NOT see the moved duplicates. We replicate the production logic
|
|
408
|
+
// (bin/ldm.js autoDetectExtensions): scan top-level dirs in
|
|
409
|
+
// extensionsRoot, skip dirs named `_trash` or starting with `.` or
|
|
410
|
+
// `ldm-install-`, and treat any remaining dir with a package.json as a
|
|
411
|
+
// candidate for auto-registration.
|
|
412
|
+
const candidatesAfterMove = readdirSync(extensionsRoot, { withFileTypes: true })
|
|
413
|
+
.filter(d => d.isDirectory())
|
|
414
|
+
.map(d => d.name)
|
|
415
|
+
.filter(name => name !== '_trash' && !name.startsWith('.') && !name.startsWith('ldm-install-'))
|
|
416
|
+
.filter(name => existsSync(join(extensionsRoot, name, 'package.json')))
|
|
417
|
+
.sort();
|
|
418
|
+
assertDeepEqual(
|
|
419
|
+
candidatesAfterMove,
|
|
420
|
+
['cc-session-export'],
|
|
421
|
+
'autoDetect should see only the non-duplicate after the moves; duplicates must be gone',
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Idempotency: running executeDirectoryMoves again must skip all
|
|
425
|
+
// (source-missing) and not fail.
|
|
426
|
+
const second = executeDirectoryMoves({
|
|
427
|
+
directoryMoves: summary.directoryMoves,
|
|
428
|
+
extensionsRoot,
|
|
429
|
+
trashRoot,
|
|
430
|
+
});
|
|
431
|
+
assertEqual(second.performed.length, 0, 'second execute call performs nothing');
|
|
432
|
+
assertEqual(second.skipped.length, 2, 'second execute call skips both moves');
|
|
433
|
+
for (const s of second.skipped) {
|
|
434
|
+
assertEqual(s.reason, 'source-missing', `second-call skip reason for ${s.name}`);
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Test 5: executeDirectoryMoves with no moves is a no-op ───────────────
|
|
442
|
+
{
|
|
443
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-empty-'));
|
|
444
|
+
try {
|
|
445
|
+
const result = executeDirectoryMoves({
|
|
446
|
+
directoryMoves: [],
|
|
447
|
+
extensionsRoot: join(tmpHome, 'extensions'),
|
|
448
|
+
trashRoot: join(tmpHome, '_trash'),
|
|
449
|
+
});
|
|
450
|
+
assertEqual(result.performed.length, 0, 'empty plan -> no performed');
|
|
451
|
+
assertEqual(result.skipped.length, 0, 'empty plan -> no skipped');
|
|
452
|
+
if (existsSync(join(tmpHome, '_trash'))) {
|
|
453
|
+
fail('executor should not pre-create trashRoot when there are no moves');
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.log('test-legacy-npm-sources-migration: all tests passed');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
|
5
|
+
const promptTemplate = readFileSync(
|
|
6
|
+
new URL("../shared/templates/install-prompt.md", import.meta.url),
|
|
7
|
+
"utf8",
|
|
8
|
+
);
|
|
9
|
+
const skill = readFileSync(new URL("../SKILL.md", import.meta.url), "utf8");
|
|
10
|
+
|
|
11
|
+
const promptStart = readme.indexOf("Read https://wip.computer/install/wip-ldm-os.txt");
|
|
12
|
+
const promptEnd = readme.indexOf("```", promptStart);
|
|
13
|
+
|
|
14
|
+
assert(promptStart >= 0, "README install prompt must point at the public install document");
|
|
15
|
+
assert(promptEnd > promptStart, "README install prompt must be fenced");
|
|
16
|
+
|
|
17
|
+
const readmePrompt = readme.slice(promptStart, promptEnd);
|
|
18
|
+
|
|
19
|
+
for (const [label, prompt] of [
|
|
20
|
+
["README install prompt", readmePrompt],
|
|
21
|
+
["shared install prompt template", promptTemplate],
|
|
22
|
+
]) {
|
|
23
|
+
assert(
|
|
24
|
+
prompt.includes("Read https://wip.computer/install/wip-ldm-os.txt"),
|
|
25
|
+
`${label} must delegate to the public install document`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
assert(
|
|
29
|
+
prompt.includes("Use the install document and live local checks as the source of truth."),
|
|
30
|
+
`${label} must name the install document as source of truth`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
assert(
|
|
34
|
+
prompt.includes("use the selected track's dry-run path from the install document"),
|
|
35
|
+
`${label} must delegate dry-run command mapping to SKILL.md`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assert(
|
|
39
|
+
prompt.includes("use the selected track's install path from the install document"),
|
|
40
|
+
`${label} must delegate install command mapping to SKILL.md`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
for (const forbidden of [
|
|
44
|
+
"Track choices:",
|
|
45
|
+
"ldm install --alpha",
|
|
46
|
+
"ldm install --beta",
|
|
47
|
+
"ldm install --dry-run",
|
|
48
|
+
]) {
|
|
49
|
+
assert(!prompt.includes(forbidden), `${label} must not include ${forbidden}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const required of [
|
|
54
|
+
"## Tracks",
|
|
55
|
+
"npm view @wipcomputer/wip-ldm-os dist-tags --json",
|
|
56
|
+
"stable/current/latest: `ldm install --dry-run`",
|
|
57
|
+
"beta/latest beta: `ldm install --beta --dry-run`",
|
|
58
|
+
"alpha/latest alpha: `ldm install --alpha --dry-run`",
|
|
59
|
+
"beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`",
|
|
60
|
+
"alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`",
|
|
61
|
+
"The README prompt should stay short. This install document owns the detailed track rules.",
|
|
62
|
+
]) {
|
|
63
|
+
assert(skill.includes(required), `SKILL.md must own track-selection logic: ${required}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("readme-install-prompt: prompt stays short and delegates track rules to SKILL.md");
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
"agentId": "cc-mini",
|
|
3
3
|
"timezone": "America/Los_Angeles",
|
|
4
4
|
"maxTotalLines": 2000,
|
|
5
|
+
"stalenessDays": 14,
|
|
6
|
+
"dailyLogStalenessDays": 2,
|
|
5
7
|
"steps": {
|
|
6
8
|
"sharedContext": {
|
|
7
9
|
"path": "~/.openclaw/workspace/SHARED-CONTEXT.md",
|
|
8
10
|
"label": "SHARED-CONTEXT.md",
|
|
9
11
|
"stepNumber": 2,
|
|
10
|
-
"critical": true
|
|
12
|
+
"critical": true,
|
|
13
|
+
"maxLines": 80
|
|
11
14
|
},
|
|
12
15
|
"journals": {
|
|
13
16
|
"dir": "~/wipcomputerinc/team/cc-mini/documents/journals",
|
|
14
17
|
"label": "Most Recent Journal (Parker)",
|
|
15
18
|
"stepNumber": 3,
|
|
16
19
|
"maxLines": 80,
|
|
17
|
-
"strategy": "most-recent"
|
|
20
|
+
"strategy": "most-recent",
|
|
21
|
+
"stalenessDays": 14
|
|
18
22
|
},
|
|
19
23
|
"workspaceDailyLogs": {
|
|
20
24
|
"dir": "~/.openclaw/workspace/memory",
|
|
@@ -22,7 +26,8 @@
|
|
|
22
26
|
"stepNumber": 4,
|
|
23
27
|
"maxLines": 40,
|
|
24
28
|
"strategy": "daily-logs",
|
|
25
|
-
"days": ["today", "yesterday"]
|
|
29
|
+
"days": ["today", "yesterday"],
|
|
30
|
+
"dailyLogStalenessDays": 2
|
|
26
31
|
},
|
|
27
32
|
"fullHistory": {
|
|
28
33
|
"label": "Full History",
|
|
@@ -33,19 +38,22 @@
|
|
|
33
38
|
"path": "~/.ldm/agents/cc-mini/CONTEXT.md",
|
|
34
39
|
"label": "CC CONTEXT.md",
|
|
35
40
|
"stepNumber": 6,
|
|
36
|
-
"critical": true
|
|
41
|
+
"critical": true,
|
|
42
|
+
"maxLines": 60
|
|
37
43
|
},
|
|
38
44
|
"soul": {
|
|
39
45
|
"path": "~/.ldm/agents/cc-mini/SOUL.md",
|
|
40
46
|
"label": "CC SOUL.md",
|
|
41
|
-
"stepNumber": 7
|
|
47
|
+
"stepNumber": 7,
|
|
48
|
+
"maxLines": 80
|
|
42
49
|
},
|
|
43
50
|
"ccJournals": {
|
|
44
51
|
"dir": "~/.ldm/agents/cc-mini/memory/journals",
|
|
45
52
|
"label": "Most Recent CC Journal",
|
|
46
53
|
"stepNumber": 8,
|
|
47
54
|
"maxLines": 80,
|
|
48
|
-
"strategy": "most-recent"
|
|
55
|
+
"strategy": "most-recent",
|
|
56
|
+
"stalenessDays": 14
|
|
49
57
|
},
|
|
50
58
|
"ccDailyLog": {
|
|
51
59
|
"dir": "~/.ldm/agents/cc-mini/memory/daily",
|
|
@@ -53,13 +61,15 @@
|
|
|
53
61
|
"stepNumber": 9,
|
|
54
62
|
"maxLines": 60,
|
|
55
63
|
"strategy": "daily-logs",
|
|
56
|
-
"days": ["today", "yesterday"]
|
|
64
|
+
"days": ["today", "yesterday"],
|
|
65
|
+
"dailyLogStalenessDays": 2
|
|
57
66
|
},
|
|
58
67
|
"repoLocations": {
|
|
59
68
|
"path": "~/.claude/projects/-Users-lesa--openclaw/memory/repo-locations.md",
|
|
60
69
|
"label": "repo-locations.md",
|
|
61
70
|
"stepNumber": 10,
|
|
62
|
-
"critical": true
|
|
71
|
+
"critical": true,
|
|
72
|
+
"maxLines": 80
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
}
|
|
@@ -38,12 +38,13 @@ These are three distinct actions. Never combine them. Never skip the dogfooding
|
|
|
38
38
|
|
|
39
39
|
## Co-Authors on Every Commit
|
|
40
40
|
|
|
41
|
-
All
|
|
41
|
+
All four contributors must be listed on every commit. No exceptions. These trailers credit contributors in commit history and preserve the builder record across the team.
|
|
42
42
|
|
|
43
43
|
```
|
|
44
44
|
Co-Authored-By: Parker Todd Brooks <parkertoddbrooks@users.noreply.github.com>
|
|
45
45
|
Co-Authored-By: Lēsa <lesaai@icloud.com>
|
|
46
|
-
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
|
46
|
+
Co-Authored-By: Claude Code (Opus 4.7) <noreply@anthropic.com>
|
|
47
|
+
Co-Authored-By: Codex (GPT 5.5) <noreply@openai.com>
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
## Built-By Line
|
|
@@ -51,7 +52,7 @@ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
|
|
51
52
|
Every repo README must include this exact attribution in the License section:
|
|
52
53
|
|
|
53
54
|
```
|
|
54
|
-
Built by Parker Todd Brooks, Lēsa
|
|
55
|
+
Built by Parker Todd Brooks, Lēsa, Claude Code (Opus 4.7), and Codex (GPT 5.5).
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
This is the standard. Use it everywhere. It credits the humans and identifies which AI runtimes built the software.
|
|
@@ -70,7 +71,7 @@ AGPL Commercial redistribution, marketplace listings, or bundling into paid se
|
|
|
70
71
|
|
|
71
72
|
AGPL for personal use is free. Commercial licenses available.
|
|
72
73
|
|
|
73
|
-
Built by Parker Todd Brooks, Lēsa
|
|
74
|
+
Built by Parker Todd Brooks, Lēsa, Claude Code (Opus 4.7), and Codex (GPT 5.5).
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
**Rules:**
|
package/shared/rules/security.md
CHANGED
|
@@ -15,3 +15,7 @@ Never overwrite shared workspace files. Always append or edit specific sections.
|
|
|
15
15
|
## Protected paths
|
|
16
16
|
|
|
17
17
|
Do not modify: secrets/, credentials/, auth-profiles.json, or any file containing API keys.
|
|
18
|
+
|
|
19
|
+
## Config doctors can silently strip keys
|
|
20
|
+
|
|
21
|
+
Never run `openclaw doctor --fix` (or any config doctor with strict-schema validation) without checking `git diff` on the config afterward. Doctor tools silently strip keys they do not recognize; git is the safety net.
|