@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.21

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 (37) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +8 -5
  3. package/bin/ldm.js +169 -65
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/package.json +13 -2
  9. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  10. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  11. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  12. package/scripts/test-crc-pair-login-flow.mjs +40 -0
  13. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  14. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  15. package/scripts/test-install-prompt-policy.mjs +60 -0
  16. package/scripts/test-installer-skill-directory.mjs +55 -0
  17. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  18. package/scripts/test-installer-target-self-update.mjs +131 -0
  19. package/scripts/test-ldm-status-timeout.mjs +80 -0
  20. package/shared/templates/install-prompt.md +20 -2
  21. package/src/hosted-mcp/README.md +15 -0
  22. package/src/hosted-mcp/app/footer.js +74 -0
  23. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  24. package/src/hosted-mcp/app/pair.html +165 -57
  25. package/src/hosted-mcp/app/sprites.png +0 -0
  26. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  27. package/src/hosted-mcp/demo/index.html +3 -7
  28. package/src/hosted-mcp/demo/login.html +318 -20
  29. package/src/hosted-mcp/deploy.sh +307 -56
  30. package/src/hosted-mcp/docs/self-host.md +268 -0
  31. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  32. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  33. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  34. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  35. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  36. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  37. package/src/hosted-mcp/server.mjs +963 -146
@@ -0,0 +1,164 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ codexDaemonPubkeyFingerprint,
4
+ createCodexDaemonPubkeyRegistry,
5
+ evaluateCodexDaemonReconnectPubkey,
6
+ } from "../src/hosted-mcp/codex-relay-e2ee-registry.mjs";
7
+
8
+ const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
9
+ const pairHtml = readFileSync("src/hosted-mcp/app/pair.html", "utf8");
10
+ const loginHtml = readFileSync("src/hosted-mcp/app/kaleidoscope-login.html", "utf8");
11
+ const registrySource = readFileSync("src/hosted-mcp/codex-relay-e2ee-registry.mjs", "utf8");
12
+ const ticket = readFileSync("ai/product/bugs/codex-remote-control/2026-05-05--codex--remote-control-pair-relink-audit-and-rotation.md", "utf8");
13
+
14
+ function assertContains(haystack, needle, label) {
15
+ if (!haystack.includes(needle)) {
16
+ throw new Error(`${label} missing expected text: ${needle}`);
17
+ }
18
+ }
19
+
20
+ function assert(condition, label, detail = "") {
21
+ if (!condition) throw new Error(`${label}${detail ? ": " + detail : ""}`);
22
+ }
23
+
24
+ function createSilentLogger() {
25
+ return { log() {}, error() {} };
26
+ }
27
+
28
+ assertContains(server, "const CODEX_PAIR_PRESENCE_TTL_MS = 2 * 60 * 1000;", "short pair presence ttl");
29
+ assertContains(server, "const codexPairPresenceTokens = new Map();", "pair presence token store");
30
+ assertContains(server, "function generateCodexPairPresenceToken(agentId)", "pair presence token mint");
31
+ assertContains(server, "function consumeCodexPairPresenceToken(token, agentId)", "pair presence token consume");
32
+ assertContains(server, "codex_pair_presence_token: generateCodexPairPresenceToken(agentId)", "registration mints pair presence token");
33
+ assertContains(server, "codex_pair_presence_token: generateCodexPairPresenceToken(entry.agentId)", "authentication mints pair presence token");
34
+ assertContains(server, 'error: "fresh_presence_required"', "pair-complete fresh presence rejection");
35
+ assertContains(server, "consumeCodexPairPresenceToken(pairPresenceToken, identity.agentId)", "pair-complete consumes pair presence token");
36
+ assertContains(server, 'json(res, 404, { error: "invalid or already-used code" });', "pair code reuse rejection");
37
+ assertContains(server, 'json(res, 410, { error: "code expired or already used" });', "pair code expiry rejection");
38
+ assertContains(server, "invalidateCodexBrowserSessionsForAgent(identity.agentId, \"daemon key replaced\")", "daemon replacement invalidates stale browser sessions");
39
+ assertContains(server, "evaluateCodexDaemonReconnectPubkey(", "daemon reconnect checks existing key policy");
40
+ assertContains(server, "daemon key change requires fresh pair", "changed daemon reconnect key requires pair flow");
41
+ assertContains(server, "daemonIdentityAccepted = activateCodexDaemonWs();", "daemon only becomes active after identity is accepted");
42
+ assertContains(server, "daemon already online", "duplicate daemon cannot evict an online daemon");
43
+ assertContains(server, "daemon identity required", "daemon frames require identity before routing");
44
+ assertContains(server, "p.replaced_daemon_key = !!daemonKeyResult?.replaced;", "pair state records replacement status");
45
+ assertContains(server, "replaced_daemon_key: !!p.replaced_daemon_key", "pair-status exposes relink replacement status");
46
+ assertContains(pairHtml, "codex_pair_presence_token: getPairPresenceToken()", "pair page sends pair presence token");
47
+ assertContains(pairHtml, "fresh_presence_required", "pair page handles fresh presence error");
48
+ assertContains(pairHtml, "Remote Control relinked this laptop.", "pair page gives relink message");
49
+ assertContains(loginHtml, "wip_codex_pair_presence_token", "login carries pair presence token into pair page");
50
+ assertContains(registrySource, "CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit", "pair audit table");
51
+ assertContains(registrySource, "old_pubkey_fingerprint", "audit stores old key fingerprint");
52
+ assertContains(registrySource, "new_pubkey_fingerprint", "audit stores new key fingerprint");
53
+ assertContains(ticket, "status: done", "ticket marked done");
54
+
55
+ const oldFingerprint = codexDaemonPubkeyFingerprint("old-spki-key");
56
+ const newFingerprint = codexDaemonPubkeyFingerprint("new-spki-key");
57
+ assert(oldFingerprint && oldFingerprint.startsWith("sha256:"), "fingerprint has sha256 prefix");
58
+ assert(oldFingerprint !== newFingerprint, "fingerprint changes when daemon key changes");
59
+
60
+ const registry = createCodexDaemonPubkeyRegistry({
61
+ usePrisma: false,
62
+ devMode: false,
63
+ logger: createSilentLogger(),
64
+ });
65
+ const first = await registry.register("acct:test-user-a", "old-spki-key", ["e2ee-v1"], "pair-complete");
66
+ assert(first.registered === true, "first pair registers key");
67
+ assert(first.replaced === false, "first pair is not replacement");
68
+ const second = await registry.register("acct:test-user-a", "new-spki-key", ["e2ee-v1"], "pair-complete");
69
+ assert(second.registered === true, "relink registers new key");
70
+ assert(second.replaced === true, "relink replacement is detected");
71
+ assert(second.old_fingerprint === oldFingerprint, "relink reports old fingerprint");
72
+ assert(second.new_fingerprint === newFingerprint, "relink reports new fingerprint");
73
+ assert(registry.auditLog.length === 2, "registry keeps audit entries");
74
+ assert(registry.auditLog[1].replaced === true, "audit marks replacement");
75
+ assert(registry.auditLog[1].old_pubkey_fingerprint === oldFingerprint, "audit stores old fingerprint");
76
+ assert(registry.auditLog[1].new_pubkey_fingerprint === newFingerprint, "audit stores new fingerprint");
77
+
78
+ const firstReconnectPolicy = evaluateCodexDaemonReconnectPubkey(null, "daemon-reconnect-key");
79
+ assert(firstReconnectPolicy.allowed === true, "daemon reconnect can self-heal when no key is registered");
80
+ assert(firstReconnectPolicy.replaced === false, "first daemon reconnect is not a replacement");
81
+ const sameReconnectPolicy = evaluateCodexDaemonReconnectPubkey({ pubkey: "daemon-reconnect-key" }, "daemon-reconnect-key");
82
+ assert(sameReconnectPolicy.allowed === true, "daemon reconnect can re-register the same key");
83
+ assert(sameReconnectPolicy.replaced === false, "same-key daemon reconnect is not a replacement");
84
+ const changedReconnectPolicy = evaluateCodexDaemonReconnectPubkey({ pubkey: "daemon-reconnect-key" }, "attacker-reconnect-key");
85
+ assert(changedReconnectPolicy.allowed === false, "daemon reconnect cannot replace an existing key");
86
+ assert(changedReconnectPolicy.reason === "fresh_pair_required", "changed daemon reconnect requires fresh pair");
87
+ assert(changedReconnectPolicy.replaced === true, "changed daemon reconnect is detected as replacement");
88
+ assert(changedReconnectPolicy.old_fingerprint === codexDaemonPubkeyFingerprint("daemon-reconnect-key"), "changed reconnect reports old fingerprint");
89
+ assert(changedReconnectPolicy.new_fingerprint === codexDaemonPubkeyFingerprint("attacker-reconnect-key"), "changed reconnect reports new fingerprint");
90
+ const invalidReconnectPolicy = evaluateCodexDaemonReconnectPubkey({ pubkey: "daemon-reconnect-key" }, "");
91
+ assert(invalidReconnectPolicy.allowed === false, "daemon reconnect rejects missing pubkey");
92
+ assert(invalidReconnectPolicy.reason === "invalid_daemon_pubkey", "missing daemon reconnect pubkey has explicit reason");
93
+ const oversizedReconnectPolicy = evaluateCodexDaemonReconnectPubkey(null, "x".repeat(1025));
94
+ assert(oversizedReconnectPolicy.allowed === false, "daemon reconnect rejects oversized pubkey");
95
+ assert(oversizedReconnectPolicy.reason === "invalid_daemon_pubkey", "oversized daemon reconnect pubkey has explicit reason");
96
+
97
+ const executeCalls = [];
98
+ const persistedRegistry = createCodexDaemonPubkeyRegistry({
99
+ usePrisma: true,
100
+ devMode: false,
101
+ logger: createSilentLogger(),
102
+ prisma: {
103
+ async $executeRawUnsafe(sql, ...args) {
104
+ executeCalls.push({ sql, args });
105
+ return 1;
106
+ },
107
+ },
108
+ });
109
+ await persistedRegistry.register("acct:test-user-b", "persisted-spki-key", ["e2ee-v1"], "daemon-reconnect");
110
+ const auditInsert = executeCalls.find((call) => call.sql.includes("INSERT INTO codex_daemon_e2ee_key_audit"));
111
+ assert(auditInsert, "audit insert executes for persisted registry");
112
+ assert(auditInsert.sql.includes("$7::timestamptz"), "audit insert casts registered_at parameter to timestamptz");
113
+ assert(typeof auditInsert.args[6] === "string" && auditInsert.args[6].includes("T"), "audit insert passes ISO registered_at value");
114
+
115
+ function pairCompleteModel({ hasDaemonPublicKey, pairPresenceOk, previousPubkey, nextPubkey }) {
116
+ if (hasDaemonPublicKey && !pairPresenceOk) return { code: 403, error: "fresh_presence_required" };
117
+ const replaced = !!(previousPubkey && nextPubkey && previousPubkey !== nextPubkey);
118
+ return { code: 200, replaced };
119
+ }
120
+
121
+ assert(
122
+ pairCompleteModel({
123
+ hasDaemonPublicKey: true,
124
+ pairPresenceOk: false,
125
+ previousPubkey: "old",
126
+ nextPubkey: "new",
127
+ }).code === 403,
128
+ "ck token alone cannot replace daemon key",
129
+ );
130
+ assert(
131
+ pairCompleteModel({
132
+ hasDaemonPublicKey: true,
133
+ pairPresenceOk: true,
134
+ previousPubkey: "old",
135
+ nextPubkey: "new",
136
+ }).replaced === true,
137
+ "fresh pair presence permits relink",
138
+ );
139
+ assert(
140
+ pairCompleteModel({
141
+ hasDaemonPublicKey: true,
142
+ pairPresenceOk: true,
143
+ previousPubkey: null,
144
+ nextPubkey: "new",
145
+ }).replaced === false,
146
+ "fresh pair presence permits first pair",
147
+ );
148
+
149
+ function pairCodeModel(pair, codeKnown, now) {
150
+ if (!codeKnown) return { code: 404, error: "invalid or already-used code" };
151
+ if (!pair || pair.status !== "pending" || now > pair.expires) {
152
+ return { code: 410, error: "code expired or already used" };
153
+ }
154
+ pair.status = "completed";
155
+ return { code: 200 };
156
+ }
157
+
158
+ const pair = { status: "pending", expires: 100 };
159
+ assert(pairCodeModel(pair, true, 10).code === 200, "first pair-complete succeeds");
160
+ assert(pairCodeModel(pair, true, 20).code === 410, "pair code reuse fails");
161
+ assert(pairCodeModel({ status: "pending", expires: 100 }, true, 200).code === 410, "expired pair code fails");
162
+ assert(pairCodeModel(null, false, 10).code === 404, "unknown pair code fails");
163
+
164
+ console.log("crc pair relink audit and rotation checks passed");
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
4
+
5
+ function assertContains(needle, label) {
6
+ if (!server.includes(needle)) {
7
+ throw new Error(`${label} missing expected text: ${needle}`);
8
+ }
9
+ }
10
+
11
+ function assertNotContains(needle, label) {
12
+ if (server.includes(needle)) {
13
+ throw new Error(`${label} still contains forbidden text: ${needle}`);
14
+ }
15
+ }
16
+
17
+ assertContains("function generateCodexPairPollToken()", "pair poll token generator");
18
+ assertContains('return "ppt_" + randomBytes(32).toString("base64url");', "pair poll token entropy");
19
+ assertContains("function getBearerToken(req)", "bearer token helper");
20
+ assertContains("const pollToken = generateCodexPairPollToken();", "pair-init mints poll token");
21
+ assertContains("poll_token: pollToken,", "pair state stores poll token");
22
+ assertContains("poll_token_used: false,", "pair state tracks token consumption");
23
+ assertContains("pair_poll_token: pollToken,", "pair-init returns poll token to daemon");
24
+ assertContains('json(res, 401, { error: "pair_poll_token_expired" });', "expired token rejected");
25
+ assertContains('json(res, 401, { error: "invalid_pair_poll_token" });', "missing or wrong token rejected");
26
+ assertContains("if (!pollToken || pollToken !== p.poll_token || p.poll_token_used)", "pair-status validates token");
27
+ assertContains("p.poll_token_used = true;", "completed credential response consumes token");
28
+
29
+ function pairStatusModel(pair, bearer, now) {
30
+ if (now > pair.expires) return { code: 401, body: { error: "pair_poll_token_expired" } };
31
+ if (!bearer || bearer !== pair.poll_token || pair.poll_token_used) {
32
+ return { code: 401, body: { error: "invalid_pair_poll_token" } };
33
+ }
34
+ if (pair.status === "completed") {
35
+ pair.poll_token_used = true;
36
+ return { code: 200, body: { status: "completed", api_key: pair.apiKey, handle: pair.handle } };
37
+ }
38
+ return { code: 200, body: { status: pair.status } };
39
+ }
40
+
41
+ const pair = {
42
+ status: "pending",
43
+ expires: 10_000,
44
+ poll_token: "ppt_good",
45
+ poll_token_used: false,
46
+ apiKey: "ck_secret",
47
+ handle: "Parker",
48
+ };
49
+
50
+ if (pairStatusModel({ ...pair }, null, 1).code !== 401) {
51
+ throw new Error("missing poll token should fail");
52
+ }
53
+ if (pairStatusModel({ ...pair }, "ppt_wrong", 1).code !== 401) {
54
+ throw new Error("wrong poll token should fail");
55
+ }
56
+ if (pairStatusModel({ ...pair }, "ppt_good", 20_000).code !== 401) {
57
+ throw new Error("expired poll token should fail");
58
+ }
59
+ if (pairStatusModel({ ...pair }, "ppt_good", 1).body.status !== "pending") {
60
+ throw new Error("correct poll token should return pending before completion");
61
+ }
62
+
63
+ const completedPair = { ...pair, status: "completed" };
64
+ const completed = pairStatusModel(completedPair, "ppt_good", 1);
65
+ if (completed.code !== 200 || completed.body.api_key !== "ck_secret") {
66
+ throw new Error("correct poll token should return completed credential once");
67
+ }
68
+ const replay = pairStatusModel(completedPair, "ppt_good", 1);
69
+ if (replay.code !== 401) {
70
+ throw new Error("reused completed poll token should fail");
71
+ }
72
+
73
+ console.log("crc pair-status poll token checks passed");
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const repoRoot = fileURLToPath(new URL("..", import.meta.url));
6
+
7
+ const files = [
8
+ "README.md",
9
+ "SKILL.md",
10
+ "shared/templates/install-prompt.md",
11
+ ];
12
+
13
+ const contents = Object.fromEntries(
14
+ files.map((file) => [file, readFileSync(join(repoRoot, file), "utf8")]),
15
+ );
16
+
17
+ const failures = [];
18
+
19
+ for (const file of ["README.md", "shared/templates/install-prompt.md"]) {
20
+ const text = contents[file];
21
+ for (const phrase of [
22
+ "Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.",
23
+ "If installed: run `ldm status`",
24
+ "If yes to dry run, run `ldm install --dry-run`.",
25
+ "`npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`",
26
+ "Then run:\n`ldm init --dry-run`",
27
+ "If I say install, run:\n`ldm init`",
28
+ ]) {
29
+ if (!text.includes(phrase)) {
30
+ failures.push(`${file} missing install prompt phrase: ${phrase}`);
31
+ }
32
+ }
33
+ if (text.includes("If it is, run ldm install --dry-run")) {
34
+ failures.push(`${file} still tells installed users to start with ldm install --dry-run`);
35
+ }
36
+ }
37
+
38
+ const skill = contents["SKILL.md"];
39
+ for (const phrase of [
40
+ "Do not run GitHub release commands during the install-state flow.",
41
+ "Do not run `gh release list` or `gh release view` unless the user explicitly asks for release notes.",
42
+ "Use the output of `ldm status`, installed package metadata, and npm metadata.",
43
+ "Do not use GitHub release commands here.",
44
+ ]) {
45
+ if (!skill.includes(phrase)) {
46
+ failures.push(`SKILL.md missing install policy phrase: ${phrase}`);
47
+ }
48
+ }
49
+
50
+ if (/gh release (list|view) --repo/.test(skill)) {
51
+ failures.push("SKILL.md still includes concrete gh release commands in the install flow");
52
+ }
53
+
54
+ if (failures.length > 0) {
55
+ console.error("install prompt policy checks failed:");
56
+ for (const failure of failures) console.error(` - ${failure}`);
57
+ process.exit(1);
58
+ }
59
+
60
+ console.log("install-prompt-policy: LDM OS prompt and install doc agree");
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ const home = mkdtempSync(join(tmpdir(), 'ldm-skill-dir-home-'));
7
+ const source = mkdtempSync(join(tmpdir(), 'ldm-skill-dir-source-'));
8
+
9
+ function assert(condition, message) {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ try {
14
+ process.env.HOME = home;
15
+
16
+ for (const dir of ['.claude', '.openclaw', '.codex', '.agents']) {
17
+ mkdirSync(join(home, dir), { recursive: true });
18
+ }
19
+
20
+ const skillDir = join(source, 'skills', 'wip-ai-chat-ui');
21
+ mkdirSync(join(skillDir, 'references'), { recursive: true });
22
+ mkdirSync(join(skillDir, 'agents'), { recursive: true });
23
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: wip-ai-chat-ui\ndescription: "test skill"\n---\n\n# Test Skill\n');
24
+ writeFileSync(join(skillDir, 'references', 'stack.md'), '# Stack\n');
25
+ writeFileSync(join(skillDir, 'agents', 'openai.yaml'), 'display_name: "WIP AI Chat UI"\n');
26
+
27
+ const { detectInterfacesJSON } = await import('../lib/detect.mjs');
28
+ const detected = detectInterfacesJSON(source);
29
+ assert(detected.interfaceCount === 1, 'skill directory repo should expose one interface');
30
+ assert(detected.interfaces.skill?.skills?.[0]?.name === 'wip-ai-chat-ui', 'skill directory name should be detected');
31
+
32
+ const { installFromPath } = await import('../lib/deploy.mjs');
33
+ const result = await installFromPath(source);
34
+ assert(result.interfaces === 1, 'skill directory install should process one interface');
35
+
36
+ for (const target of [
37
+ join(home, '.claude', 'skills', 'wip-ai-chat-ui'),
38
+ join(home, '.openclaw', 'skills', 'wip-ai-chat-ui'),
39
+ join(home, '.codex', 'skills', 'wip-ai-chat-ui'),
40
+ join(home, '.agents', 'skills', 'wip-ai-chat-ui'),
41
+ ]) {
42
+ assert(existsSync(join(target, 'SKILL.md')), `${target} should include SKILL.md`);
43
+ assert(existsSync(join(target, 'references', 'stack.md')), `${target} should include references`);
44
+ assert(existsSync(join(target, 'agents', 'openai.yaml')), `${target} should include agents metadata`);
45
+ assert(!lstatSync(target).isSymbolicLink(), `${target} should be a deployed directory, not a symlink`);
46
+ }
47
+
48
+ const codexSkill = readFileSync(join(home, '.codex', 'skills', 'wip-ai-chat-ui', 'SKILL.md'), 'utf8');
49
+ assert(codexSkill.includes('name: wip-ai-chat-ui'), 'Codex target should contain the expected skill');
50
+
51
+ console.log('installer skill directory regression passed');
52
+ } finally {
53
+ rmSync(home, { recursive: true, force: true });
54
+ rmSync(source, { recursive: true, force: true });
55
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ const home = mkdtempSync(join(tmpdir(), 'ldm-skill-dry-run-home-'));
7
+ const source = mkdtempSync(join(tmpdir(), 'ldm-skill-dry-run-source-'));
8
+
9
+ function assert(condition, message) {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ try {
14
+ process.env.HOME = home;
15
+
16
+ for (const dir of ['.claude', '.openclaw', '.codex', '.agents']) {
17
+ mkdirSync(join(home, dir), { recursive: true });
18
+ }
19
+
20
+ const workspace = join(home, 'workspace');
21
+ mkdirSync(workspace, { recursive: true });
22
+ mkdirSync(join(home, '.ldm'), { recursive: true });
23
+ writeFileSync(join(home, '.ldm', 'config.json'), JSON.stringify({
24
+ workspace,
25
+ harnesses: {
26
+ 'claude-code': {
27
+ detected: true,
28
+ home: join(home, '.claude'),
29
+ skills: join(home, '.claude', 'skills'),
30
+ },
31
+ openclaw: {
32
+ detected: true,
33
+ home: join(home, '.openclaw'),
34
+ skills: join(home, '.openclaw', 'skills'),
35
+ },
36
+ codex: {
37
+ detected: true,
38
+ home: join(home, '.codex'),
39
+ skills: join(home, '.codex', 'skills'),
40
+ },
41
+ 'wip-agents': {
42
+ detected: true,
43
+ home: join(home, '.agents'),
44
+ skills: join(home, '.agents', 'skills'),
45
+ },
46
+ },
47
+ }, null, 2));
48
+
49
+ mkdirSync(join(source, 'references'), { recursive: true });
50
+ mkdirSync(join(source, 'agents'), { recursive: true });
51
+ writeFileSync(join(source, 'package.json'), JSON.stringify({
52
+ name: '@wipcomputer/wip-ai-chat-ui',
53
+ version: '0.1.1',
54
+ }, null, 2));
55
+ writeFileSync(join(source, 'SKILL.md'), '---\nname: wip-ai-chat-ui\ndescription: "test skill"\n---\n\n# Test Skill\n');
56
+ writeFileSync(join(source, 'references', 'stack.md'), '# Stack\n');
57
+ writeFileSync(join(source, 'agents', 'openai.yaml'), 'display_name: "WIP AI Chat UI"\n');
58
+
59
+ const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
60
+
61
+ const lines = [];
62
+ const originalLog = console.log;
63
+ console.log = (...args) => lines.push(args.join(' '));
64
+
65
+ try {
66
+ setFlags({ dryRun: true, jsonOutput: false });
67
+ const result = await installFromPath(source);
68
+ assert(result.interfaces === 1, 'dry run should process one skill interface');
69
+ } finally {
70
+ console.log = originalLog;
71
+ }
72
+
73
+ const output = lines.join('\n');
74
+
75
+ for (const expected of [
76
+ 'Would copy:',
77
+ '- SKILL.md',
78
+ '- references/',
79
+ '- agents/',
80
+ 'Permanent copy:',
81
+ join(home, '.ldm', 'extensions', 'wip-ai-chat-ui', 'SKILL.md'),
82
+ join(home, '.ldm', 'extensions', 'wip-ai-chat-ui', 'references/'),
83
+ 'Agent skill targets:',
84
+ `claude-code: ${join(home, '.claude', 'skills', 'wip-ai-chat-ui')}`,
85
+ join(home, '.claude', 'skills', 'wip-ai-chat-ui', 'SKILL.md'),
86
+ `openclaw: ${join(home, '.openclaw', 'skills', 'wip-ai-chat-ui')}`,
87
+ join(home, '.openclaw', 'skills', 'wip-ai-chat-ui', 'references/'),
88
+ `codex: ${join(home, '.codex', 'skills', 'wip-ai-chat-ui')}`,
89
+ `wip-agents: ${join(home, '.agents', 'skills', 'wip-ai-chat-ui')}`,
90
+ 'Workspace docs target:',
91
+ `${join(workspace, 'settings', 'docs', 'skills', 'wip-ai-chat-ui')} (references/ only)`,
92
+ ]) {
93
+ assert(output.includes(expected), `dry-run output should include ${expected}\n\n${output}`);
94
+ }
95
+
96
+ console.log('installer skill dry-run destinations regression passed');
97
+ } finally {
98
+ rmSync(home, { recursive: true, force: true });
99
+ rmSync(source, { recursive: true, force: true });
100
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const cli = readFileSync(join(root, 'bin', 'ldm.js'), 'utf8');
10
+
11
+ const helperName = 'function maybeSelfUpdateLdmCliBeforeInstall()';
12
+ const helperIdx = cli.indexOf(helperName);
13
+ if (helperIdx === -1) {
14
+ throw new Error('Missing shared LDM OS self-update preflight helper');
15
+ }
16
+
17
+ const helperBlock = cli.slice(helperIdx, cli.indexOf('// ── Dead backup trigger cleanup', helperIdx));
18
+ if (!helperBlock.includes('Dry run only: continuing with v${PKG_VERSION}.')) {
19
+ throw new Error('Self-update helper must warn on dry run without updating');
20
+ }
21
+
22
+ if (!helperBlock.includes("execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`")) {
23
+ throw new Error('Self-update helper must update LDM OS before real installs');
24
+ }
25
+
26
+ if (!helperBlock.includes("spawnSync('ldm'")) {
27
+ throw new Error('Self-update helper must re-run the original install command without shell joining args');
28
+ }
29
+
30
+ if (helperBlock.includes('process.argv.slice(2).join')) {
31
+ throw new Error('Self-update helper must preserve argv boundaries when re-running install');
32
+ }
33
+
34
+ const cmdInstallIdx = cli.indexOf('async function cmdInstall()');
35
+ const lockIdx = cli.indexOf('acquireInstallLock()', cmdInstallIdx);
36
+ const initIdx = cli.indexOf('LDM OS not initialized. Running init first', cmdInstallIdx);
37
+ const targetIdx = cli.indexOf('// Find the target (skip flags)', cmdInstallIdx);
38
+ const preflightCallIdx = cli.indexOf('maybeSelfUpdateLdmCliBeforeInstall();', cmdInstallIdx);
39
+ if (cmdInstallIdx === -1 || targetIdx === -1 || preflightCallIdx === -1) {
40
+ throw new Error('Could not find cmdInstall self-update placement');
41
+ }
42
+
43
+ if (preflightCallIdx > lockIdx || preflightCallIdx > initIdx) {
44
+ throw new Error('Self-update preflight must run before lock acquisition and init work');
45
+ }
46
+
47
+ if (preflightCallIdx > targetIdx) {
48
+ throw new Error('Self-update preflight must run before target resolution so app installs are covered');
49
+ }
50
+
51
+ const catalogIdx = cli.indexOf('async function cmdInstallCatalog()');
52
+ const oldCatalogBlock = cli.indexOf('Self-update: check if CLI itself is outdated', catalogIdx);
53
+ const autoDetectIdx = cli.indexOf('autoDetectExtensions();', catalogIdx);
54
+ if (oldCatalogBlock !== -1 && oldCatalogBlock < autoDetectIdx) {
55
+ throw new Error('Catalog install should not own the only self-update block');
56
+ }
57
+
58
+ const tempRoot = mkdtempSync(join(tmpdir(), 'ldm-target-self-update-'));
59
+ try {
60
+ const home = join(tempRoot, 'home');
61
+ const fakeBin = join(tempRoot, 'bin');
62
+ const target = join(tempRoot, 'target skill with spaces');
63
+
64
+ mkdirSync(join(home, '.ldm'), { recursive: true });
65
+ writeFileSync(join(home, '.ldm', 'version.json'), JSON.stringify({
66
+ version: '0.0.0',
67
+ installed: new Date().toISOString(),
68
+ updated: new Date().toISOString(),
69
+ }, null, 2) + '\n');
70
+
71
+ mkdirSync(fakeBin, { recursive: true });
72
+ const fakeNpm = join(fakeBin, 'npm');
73
+ writeFileSync(fakeNpm, `#!/usr/bin/env bash
74
+ if [ "$1" = "view" ] && [ "$2" = "@wipcomputer/wip-ldm-os" ] && [ "$3" = "dist-tags.alpha" ]; then
75
+ echo "99.0.0-alpha.1"
76
+ exit 0
77
+ fi
78
+ echo "unexpected npm command: $*" >&2
79
+ exit 64
80
+ `);
81
+ chmodSync(fakeNpm, 0o755);
82
+
83
+ mkdirSync(target, { recursive: true });
84
+ writeFileSync(join(target, 'SKILL.md'), `---
85
+ name: test-target-skill
86
+ description: Test target skill for installer self-update dry-run checks.
87
+ ---
88
+
89
+ # Test Target Skill
90
+ `);
91
+
92
+ const result = spawnSync(process.execPath, [
93
+ join(root, 'bin', 'ldm.js'),
94
+ 'install',
95
+ '--alpha',
96
+ '--dry-run',
97
+ target,
98
+ ], {
99
+ cwd: root,
100
+ encoding: 'utf8',
101
+ env: {
102
+ ...process.env,
103
+ HOME: home,
104
+ PATH: `${fakeBin}:${process.env.PATH || ''}`,
105
+ },
106
+ });
107
+
108
+ if (result.status !== 0) {
109
+ throw new Error(`Runtime dry-run exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
110
+ }
111
+
112
+ if (!result.stdout.includes('LDM OS CLI v')) {
113
+ throw new Error(`Runtime dry-run did not print the LDM OS skew warning\nstdout:\n${result.stdout}`);
114
+ }
115
+
116
+ if (!result.stdout.includes('-> v99.0.0-alpha.1 (alpha track) is available.')) {
117
+ throw new Error(`Runtime dry-run did not include the selected alpha track version\nstdout:\n${result.stdout}`);
118
+ }
119
+
120
+ if (!result.stdout.includes('Dry run only: continuing with v')) {
121
+ throw new Error(`Runtime dry-run did not say it would continue without updating\nstdout:\n${result.stdout}`);
122
+ }
123
+
124
+ if (!result.stdout.includes('Installing: target skill with spaces (dry run)')) {
125
+ throw new Error(`Runtime dry-run did not continue to the targeted install preview\nstdout:\n${result.stdout}`);
126
+ }
127
+ } finally {
128
+ rmSync(tempRoot, { recursive: true, force: true });
129
+ }
130
+
131
+ console.log('targeted install self-update regression checks passed');
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const tempRoot = mkdtempSync(join(tmpdir(), 'ldm-status-timeout-'));
10
+ const sourceVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
11
+
12
+ function assert(condition, message) {
13
+ if (!condition) throw new Error(message);
14
+ }
15
+
16
+ try {
17
+ const home = join(tempRoot, 'home');
18
+ const fakeBin = join(tempRoot, 'bin');
19
+ const extensions = join(home, '.ldm', 'extensions');
20
+
21
+ mkdirSync(extensions, { recursive: true });
22
+ writeFileSync(join(home, '.ldm', 'version.json'), JSON.stringify({
23
+ version: '0.0.0-test',
24
+ installed: '2026-05-12T00:00:00.000Z',
25
+ updated: '2026-05-12T00:00:00.000Z',
26
+ }, null, 2) + '\n');
27
+ writeFileSync(join(extensions, 'registry.json'), JSON.stringify({
28
+ extensions: {
29
+ 'hung-extension': {
30
+ source: { npm: 'hung-extension' },
31
+ installed: { version: '1.0.0' },
32
+ },
33
+ 'second-extension': {
34
+ source: { npm: 'second-extension' },
35
+ installed: { version: '1.0.0' },
36
+ },
37
+ },
38
+ }, null, 2) + '\n');
39
+
40
+ mkdirSync(fakeBin, { recursive: true });
41
+ const fakeNpm = join(fakeBin, 'npm');
42
+ writeFileSync(fakeNpm, `#!/usr/bin/env bash
43
+ if [ "$1" = "view" ]; then
44
+ sleep 2
45
+ echo "9.9.9"
46
+ exit 0
47
+ fi
48
+ echo "unexpected npm command: $*" >&2
49
+ exit 64
50
+ `);
51
+ chmodSync(fakeNpm, 0o755);
52
+
53
+ const startedAt = Date.now();
54
+ const result = spawnSync(process.execPath, [join(root, 'bin', 'ldm.js'), 'status'], {
55
+ cwd: root,
56
+ encoding: 'utf8',
57
+ timeout: 3000,
58
+ env: {
59
+ ...process.env,
60
+ HOME: home,
61
+ PATH: `${fakeBin}:${process.env.PATH || ''}`,
62
+ LDM_STATUS_NPM_TIMEOUT_MS: '75',
63
+ LDM_STATUS_TOTAL_BUDGET_MS: '250',
64
+ },
65
+ });
66
+ const elapsedMs = Date.now() - startedAt;
67
+
68
+ assert(result.status === 0, `ldm status exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
69
+ assert(elapsedMs < 2500, `ldm status should return before the process timeout; elapsed ${elapsedMs}ms`);
70
+ assert(result.stdout.includes(`LDM OS v${sourceVersion}`), `status should print installed LDM OS version\n${result.stdout}`);
71
+ assert(result.stdout.includes('Extensions: 2'), `status should print extension count\n${result.stdout}`);
72
+ assert(result.stdout.includes('Checking updates:'), `status should show progress before update checks\n${result.stdout}`);
73
+ assert(result.stdout.includes('hung-extension: checking npm'), `status should print the extension name before probing it\n${result.stdout}`);
74
+ assert(result.stdout.includes('Update checks skipped:'), `status should report skipped checks instead of hanging\n${result.stdout}`);
75
+ assert(result.stdout.includes('hung-extension: [timeout] hung-extension'), `hung extension should be reported as a timeout\n${result.stdout}`);
76
+ } finally {
77
+ rmSync(tempRoot, { recursive: true, force: true });
78
+ }
79
+
80
+ console.log('ldm status timeout regression passed');