@wipcomputer/wip-ldm-os 0.4.85-alpha.9 → 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 +36 -1
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +20 -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-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,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,128 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
CODEX_WS_CLOSE_CODES,
|
|
5
|
+
codexWsFrameByteLength,
|
|
6
|
+
createCodexWsAbuseLimitConfig,
|
|
7
|
+
createCodexWsConnectionGuard,
|
|
8
|
+
formatCodexWsLimitLog,
|
|
9
|
+
isCodexWsAgentDisabled,
|
|
10
|
+
} from "../src/hosted-mcp/codex-relay-ws-abuse-limits.mjs";
|
|
11
|
+
|
|
12
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
13
|
+
const deploy = readFileSync("src/hosted-mcp/deploy.sh", "utf8");
|
|
14
|
+
|
|
15
|
+
function assertContains(source, needle, label) {
|
|
16
|
+
if (!source.includes(needle)) {
|
|
17
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertBefore(source, first, second, label) {
|
|
22
|
+
const firstIndex = source.indexOf(first);
|
|
23
|
+
const secondIndex = firstIndex === -1 ? -1 : source.indexOf(second, firstIndex + first.length);
|
|
24
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
25
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const config = createCodexWsAbuseLimitConfig({
|
|
30
|
+
LDM_CODEX_WS_MAX_FRAME_BYTES: "10",
|
|
31
|
+
LDM_CODEX_WS_RATE_WINDOW_MS: "100",
|
|
32
|
+
LDM_CODEX_WS_MAX_MESSAGES_PER_WINDOW: "2",
|
|
33
|
+
LDM_CODEX_WS_MAX_BYTES_PER_WINDOW: "15",
|
|
34
|
+
LDM_CODEX_WS_MAX_BROWSER_SOCKETS_PER_THREAD: "3",
|
|
35
|
+
LDM_CODEX_WS_IDLE_TTL_MS: "50",
|
|
36
|
+
LDM_CODEX_WS_MAX_MALFORMED_FRAMES: "1",
|
|
37
|
+
LDM_CODEX_WS_MAX_PENDING_BYTES: "20",
|
|
38
|
+
LDM_CODEX_WS_KILL_SWITCH_AGENTS: "acct:blocked, acct:other",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.equal(config.maxFrameBytes, 10);
|
|
42
|
+
assert.equal(config.maxBrowserSocketsPerThread, 3);
|
|
43
|
+
assert.equal(isCodexWsAgentDisabled(config, "acct:blocked"), true);
|
|
44
|
+
assert.equal(isCodexWsAgentDisabled(config, "acct:allowed"), false);
|
|
45
|
+
|
|
46
|
+
let nowMs = 1_000;
|
|
47
|
+
const guard = createCodexWsConnectionGuard({
|
|
48
|
+
config,
|
|
49
|
+
agentId: "acct:allowed",
|
|
50
|
+
now: () => nowMs,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(guard.observeFrame(11).code, CODEX_WS_CLOSE_CODES.oversizedFrame);
|
|
54
|
+
assert.equal(guard.observeFrame(5).ok, true);
|
|
55
|
+
assert.equal(guard.observeFrame(5).ok, true);
|
|
56
|
+
assert.equal(guard.observeFrame(5).reason, "message rate limit");
|
|
57
|
+
|
|
58
|
+
nowMs += 101;
|
|
59
|
+
const byteGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
|
|
60
|
+
assert.equal(byteGuard.observeFrame(8).ok, true);
|
|
61
|
+
assert.equal(byteGuard.observeFrame(8).reason, "byte rate limit");
|
|
62
|
+
|
|
63
|
+
const malformedGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
|
|
64
|
+
assert.equal(malformedGuard.observeMalformed().ok, true);
|
|
65
|
+
assert.equal(malformedGuard.observeMalformed().code, CODEX_WS_CLOSE_CODES.malformedFrames);
|
|
66
|
+
|
|
67
|
+
const pendingGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
|
|
68
|
+
assert.equal(pendingGuard.observePendingBytes(21).code, CODEX_WS_CLOSE_CODES.pendingBytes);
|
|
69
|
+
|
|
70
|
+
const idleGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
|
|
71
|
+
assert.equal(idleGuard.observeFrame(1).ok, true);
|
|
72
|
+
assert.equal(idleGuard.observeIdle(nowMs + 51).code, CODEX_WS_CLOSE_CODES.idleTimeout);
|
|
73
|
+
|
|
74
|
+
const killedGuard = createCodexWsConnectionGuard({ config, agentId: "acct:blocked", now: () => nowMs });
|
|
75
|
+
assert.equal(killedGuard.observeFrame(1).code, CODEX_WS_CLOSE_CODES.operatorDisabled);
|
|
76
|
+
assert.equal(codexWsFrameByteLength(Buffer.from("hello")), 5);
|
|
77
|
+
assert.match(
|
|
78
|
+
formatCodexWsLimitLog({
|
|
79
|
+
agentId: "acct:blocked",
|
|
80
|
+
threadId: "thread-a",
|
|
81
|
+
connectionId: "conn-a",
|
|
82
|
+
reason: "message rate limit",
|
|
83
|
+
}),
|
|
84
|
+
/reason=message rate limit agent=acct:blocked thread=thread-a conn=conn-a/,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
assertContains(server, "import {", "server imports abuse module");
|
|
88
|
+
assertContains(server, "createCodexWsAbuseLimitConfig", "server configures websocket limits");
|
|
89
|
+
assertContains(server, "isCodexWsAgentDisabled(CODEX_WS_ABUSE_LIMITS, identity.agentId)", "server checks operator kill switch");
|
|
90
|
+
assertContains(server, "openBrowserSockets >= CODEX_WS_ABUSE_LIMITS.maxBrowserSocketsPerThread", "server limits browser sockets per thread");
|
|
91
|
+
assertContains(server, "createCodexWsConnectionGuard({", "server creates per-socket guard");
|
|
92
|
+
assertContains(server, "guard.observeFrame(codexWsFrameByteLength(data))", "server observes browser frame size and rate");
|
|
93
|
+
assertContains(server, "guard.observeMalformed()", "server observes malformed browser frames");
|
|
94
|
+
assertContains(server, "guard.observePendingBytes(daemonWs.bufferedAmount || 0)", "server observes pending daemon bytes");
|
|
95
|
+
assertContains(server, "guard.observeIdle()", "server observes idle connections");
|
|
96
|
+
assertContains(server, "closeCodexWsForLimit(ws, guardContext, decision)", "server closes idle sockets by limit");
|
|
97
|
+
assertContains(server, "closeCodexWsForLimit(ws, guardContext, frameDecision)", "server closes frame abuse");
|
|
98
|
+
assertContains(server, "closeCodexWsForLimit(ws, guardContext, malformedDecision)", "server closes malformed abuse");
|
|
99
|
+
assertContains(server, "closeCodexWsForLimit(ws, guardContext, pendingDecision)", "server closes pending byte abuse");
|
|
100
|
+
assertContains(server, "codex-relay-ws-abuse-limits.mjs", "deploy inventory includes abuse module");
|
|
101
|
+
assertContains(deploy, "add_file \"codex-relay-ws-abuse-limits.mjs\"", "deploy copies abuse module");
|
|
102
|
+
|
|
103
|
+
assertBefore(
|
|
104
|
+
server,
|
|
105
|
+
"openBrowserSockets >= CODEX_WS_ABUSE_LIMITS.maxBrowserSocketsPerThread",
|
|
106
|
+
"codexRelayWss.handleUpgrade(req, socket, head, (ws) => {",
|
|
107
|
+
"socket cap should run before websocket upgrade is accepted",
|
|
108
|
+
);
|
|
109
|
+
assertBefore(
|
|
110
|
+
server,
|
|
111
|
+
"const frameDecision = guard.observeFrame(codexWsFrameByteLength(data));",
|
|
112
|
+
"let text = data.toString();",
|
|
113
|
+
"frame limit should run before parsing or forwarding browser data",
|
|
114
|
+
);
|
|
115
|
+
assertBefore(
|
|
116
|
+
server,
|
|
117
|
+
"if (!envelope || typeof envelope !== \"object\" || Array.isArray(envelope)) {",
|
|
118
|
+
"const daemonWs = codexDaemons.get(identity.agentId);",
|
|
119
|
+
"malformed browser frames should not be forwarded",
|
|
120
|
+
);
|
|
121
|
+
assertBefore(
|
|
122
|
+
server,
|
|
123
|
+
"const pendingDecision = guard.observePendingBytes(daemonWs.bufferedAmount || 0);",
|
|
124
|
+
"daemonWs.send(text);",
|
|
125
|
+
"pending byte check should run before forwarding to daemon",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
console.log("crc websocket abuse limit 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');
|