@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,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test: configureSessionStartHook() registration semantics.
|
|
3
|
+
//
|
|
4
|
+
// Covers the 2026-07-04 tickets:
|
|
5
|
+
// ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--boot-hook-update-in-place-never-persists.md
|
|
6
|
+
// ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
|
|
7
|
+
//
|
|
8
|
+
// Cases:
|
|
9
|
+
// 1. Fresh install: entry added and persisted.
|
|
10
|
+
// 2. Repeat run: no duplicate, file byte-identical (no write).
|
|
11
|
+
// 3. Changed entry (old command path, old timeout): updated ON DISK.
|
|
12
|
+
// This is the update-in-place-never-persists bug.
|
|
13
|
+
// 4. Duplicate-laden settings (10 boot-hook copies + 1 unrelated hook):
|
|
14
|
+
// collapses to 1 boot entry, unrelated hook preserved, persisted.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
// Note: the temp prefix must not contain "boot-hook" or "shared/boot".
|
|
21
|
+
// Those substrings are the installer's ownership matchers, and the temp
|
|
22
|
+
// HOME path ends up inside every fixture hook command.
|
|
23
|
+
const tempHome = mkdtempSync(join(tmpdir(), 'ldm-bootreg-'));
|
|
24
|
+
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function assert(cond, label, detail = '') {
|
|
27
|
+
if (cond) {
|
|
28
|
+
console.log(` [PASS] ${label}`);
|
|
29
|
+
} else {
|
|
30
|
+
console.log(` [FAIL] ${label}`);
|
|
31
|
+
if (detail) console.log(` ${detail}`);
|
|
32
|
+
failed++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const settingsPath = join(tempHome, '.claude', 'settings.json');
|
|
37
|
+
function seedSettings(obj) {
|
|
38
|
+
mkdirSync(join(tempHome, '.claude'), { recursive: true });
|
|
39
|
+
writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + '\n');
|
|
40
|
+
}
|
|
41
|
+
function readSettings() {
|
|
42
|
+
return JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
function bootEntries(settings) {
|
|
45
|
+
return (settings.hooks?.SessionStart || []).filter((entry) =>
|
|
46
|
+
(entry?.hooks || []).some((h) => h?.command?.includes('boot-hook'))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// HOME must be set before the module import: installer.mjs resolves
|
|
52
|
+
// homedir() into module-level constants at import time.
|
|
53
|
+
process.env.HOME = tempHome;
|
|
54
|
+
const { configureSessionStartHook } = await import('../src/boot/installer.mjs');
|
|
55
|
+
const expectedCommand = `node ${join(tempHome, '.ldm', 'shared', 'boot', 'boot-hook.mjs')}`;
|
|
56
|
+
|
|
57
|
+
console.log('Test 1: fresh install adds and persists the entry');
|
|
58
|
+
{
|
|
59
|
+
seedSettings({ hooks: {} });
|
|
60
|
+
const msg = configureSessionStartHook();
|
|
61
|
+
const s = readSettings();
|
|
62
|
+
assert(/added/.test(msg), `reports added (got: ${msg})`);
|
|
63
|
+
assert(bootEntries(s).length === 1, 'exactly one boot-hook entry on disk');
|
|
64
|
+
assert(s.hooks.SessionStart[0].hooks[0].command === expectedCommand, 'command path is BOOT_DIR boot-hook.mjs');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('Test 2: repeat run is a no-op, byte-identical file');
|
|
68
|
+
{
|
|
69
|
+
const before = readFileSync(settingsPath, 'utf-8');
|
|
70
|
+
const msg = configureSessionStartHook();
|
|
71
|
+
const after = readFileSync(settingsPath, 'utf-8');
|
|
72
|
+
assert(/already configured/.test(msg), `reports already configured (got: ${msg})`);
|
|
73
|
+
assert(before === after, 'settings.json byte-identical after second run');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('Test 3: changed entry is updated ON DISK (the persist bug)');
|
|
77
|
+
{
|
|
78
|
+
seedSettings({
|
|
79
|
+
hooks: {
|
|
80
|
+
SessionStart: [
|
|
81
|
+
{
|
|
82
|
+
matcher: '*',
|
|
83
|
+
hooks: [{ type: 'command', command: 'node /old/path/shared/boot/boot-hook.mjs', timeout: 10 }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const msg = configureSessionStartHook();
|
|
89
|
+
const s = readSettings();
|
|
90
|
+
assert(/updated/.test(msg), `reports updated (got: ${msg})`);
|
|
91
|
+
assert(bootEntries(s).length === 1, 'still exactly one boot-hook entry');
|
|
92
|
+
assert(
|
|
93
|
+
s.hooks.SessionStart[0].hooks[0].command === expectedCommand,
|
|
94
|
+
'new command path persisted to disk',
|
|
95
|
+
`on disk: ${s.hooks.SessionStart[0].hooks[0].command}`
|
|
96
|
+
);
|
|
97
|
+
assert(s.hooks.SessionStart[0].hooks[0].timeout === 15, 'new timeout persisted to disk');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('Test 4: duplicate-laden settings collapse to one, unrelated hooks preserved');
|
|
101
|
+
{
|
|
102
|
+
const bootEntry = {
|
|
103
|
+
matcher: '*',
|
|
104
|
+
hooks: [{ type: 'command', command: expectedCommand, timeout: 15 }],
|
|
105
|
+
};
|
|
106
|
+
const guardEntry = {
|
|
107
|
+
hooks: [{ type: 'command', command: `node ${join(tempHome, '.ldm', 'extensions', 'wip-branch-guard', 'guard.mjs')}`, timeout: 10 }],
|
|
108
|
+
};
|
|
109
|
+
seedSettings({
|
|
110
|
+
hooks: {
|
|
111
|
+
SessionStart: [
|
|
112
|
+
...Array.from({ length: 5 }, () => structuredClone(bootEntry)),
|
|
113
|
+
guardEntry,
|
|
114
|
+
...Array.from({ length: 5 }, () => structuredClone(bootEntry)),
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const msg = configureSessionStartHook();
|
|
119
|
+
const s = readSettings();
|
|
120
|
+
assert(/removed 9 duplicate entries/.test(msg), `reports 9 duplicates removed (got: ${msg})`);
|
|
121
|
+
assert(bootEntries(s).length === 1, 'exactly one boot-hook entry after collapse');
|
|
122
|
+
assert(s.hooks.SessionStart.length === 2, 'boot hook + branch guard remain');
|
|
123
|
+
assert(
|
|
124
|
+
s.hooks.SessionStart.some((e) => e.hooks?.[0]?.command?.includes('wip-branch-guard')),
|
|
125
|
+
'unrelated branch-guard entry preserved'
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (failed > 0) {
|
|
133
|
+
console.log(`\n${failed} assertion(s) failed`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
console.log('\nAll boot-hook registration tests passed');
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test: boot-hook payload trimming (CC speedup master plan, Task 4b).
|
|
3
|
+
//
|
|
4
|
+
// Covers:
|
|
5
|
+
// 1. A step with no maxLines in config gets the code default cap + a
|
|
6
|
+
// truncation marker that names the full path.
|
|
7
|
+
// 2. A per-step maxLines in config overrides the default.
|
|
8
|
+
// 3. A most-recent step whose newest file is older than stalenessDays is
|
|
9
|
+
// NOT injected: a path-only stale line is emitted instead.
|
|
10
|
+
// 4. A fresh most-recent file IS injected in full.
|
|
11
|
+
// 5. The payload ends with a one-line summary (bytes/lines/sections, capped,
|
|
12
|
+
// stale).
|
|
13
|
+
// 6. maxTotalLines still stops the loop early.
|
|
14
|
+
//
|
|
15
|
+
// The hook resolves homedir() into a module-level constant at import time and
|
|
16
|
+
// loads boot-config.json relative to its own file. So each case copies the
|
|
17
|
+
// hook into a temp workdir next to a generated boot-config.json and runs it as
|
|
18
|
+
// a subprocess with HOME pointed at a temp fixture. The hook's dynamic imports
|
|
19
|
+
// of ../../lib/*.mjs fail harmlessly (they are wrapped in try/catch) from the
|
|
20
|
+
// temp location, which is fine: this test only exercises payload assembly.
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, mkdtempSync, copyFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const repo = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
29
|
+
const HOOK_SRC = join(repo, 'src', 'boot', 'boot-hook.mjs');
|
|
30
|
+
|
|
31
|
+
let failed = 0;
|
|
32
|
+
function assert(cond, label, detail = '') {
|
|
33
|
+
if (cond) {
|
|
34
|
+
console.log(` [PASS] ${label}`);
|
|
35
|
+
} else {
|
|
36
|
+
console.log(` [FAIL] ${label}`);
|
|
37
|
+
if (detail) console.log(` ${detail}`);
|
|
38
|
+
failed++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Compute today/yesterday in the hook's timezone so daily-log and fresh-journal
|
|
43
|
+
// filenames match what the hook looks for.
|
|
44
|
+
function ymd(offsetDays = 0) {
|
|
45
|
+
const now = new Date();
|
|
46
|
+
now.setDate(now.getDate() + offsetDays);
|
|
47
|
+
return new Intl.DateTimeFormat('en-CA', {
|
|
48
|
+
timeZone: 'America/Los_Angeles',
|
|
49
|
+
year: 'numeric',
|
|
50
|
+
month: '2-digit',
|
|
51
|
+
day: '2-digit',
|
|
52
|
+
}).format(now);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function lines(n, marker) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (let i = 1; i <= n; i++) out.push(`${marker} line ${i}`);
|
|
58
|
+
return out.join('\n') + '\n';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Run the hook with a given config object and a HOME-populating callback.
|
|
62
|
+
// Returns the injected additionalContext string.
|
|
63
|
+
function runHook(configObj, populateHome) {
|
|
64
|
+
const work = mkdtempSync(join(tmpdir(), 'ldm-bootpay-work-'));
|
|
65
|
+
const home = mkdtempSync(join(tmpdir(), 'ldm-bootpay-home-'));
|
|
66
|
+
try {
|
|
67
|
+
copyFileSync(HOOK_SRC, join(work, 'boot-hook.mjs'));
|
|
68
|
+
writeFileSync(join(work, 'boot-config.json'), JSON.stringify(configObj, null, 2));
|
|
69
|
+
populateHome(home);
|
|
70
|
+
|
|
71
|
+
const stdout = execFileSync('node', [join(work, 'boot-hook.mjs')], {
|
|
72
|
+
input: JSON.stringify({ session_id: 'test', hook_event_name: 'SessionStart', cwd: home }),
|
|
73
|
+
env: { ...process.env, HOME: home },
|
|
74
|
+
encoding: 'utf-8',
|
|
75
|
+
timeout: 30000,
|
|
76
|
+
});
|
|
77
|
+
const parsed = JSON.parse(stdout);
|
|
78
|
+
return parsed?.hookSpecificOutput?.additionalContext || '';
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(work, { recursive: true, force: true });
|
|
81
|
+
rmSync(home, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeHomeFile(home, rel, content) {
|
|
86
|
+
const full = join(home, rel);
|
|
87
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
88
|
+
writeFileSync(full, content);
|
|
89
|
+
return full;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Case 1 + 2 + 5: default cap, config override, payload summary ──
|
|
93
|
+
console.log('Case 1/2/5: default cap, config override, payload summary');
|
|
94
|
+
{
|
|
95
|
+
const config = {
|
|
96
|
+
agentId: 'test-agent',
|
|
97
|
+
timezone: 'America/Los_Angeles',
|
|
98
|
+
maxTotalLines: 2000,
|
|
99
|
+
steps: {
|
|
100
|
+
// no maxLines -> code default for sharedContext is 80
|
|
101
|
+
sharedContext: { path: '~/shared.md', label: 'SHARED', stepNumber: 2, critical: true },
|
|
102
|
+
// no maxLines -> code default for soul is 80
|
|
103
|
+
soul: { path: '~/soul.md', label: 'SOUL', stepNumber: 7 },
|
|
104
|
+
// explicit maxLines overrides
|
|
105
|
+
overridden: { path: '~/capped.md', label: 'CAPPED', stepNumber: 11, maxLines: 5 },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
let soulPath, cappedPath;
|
|
109
|
+
const ctx = runHook(config, (home) => {
|
|
110
|
+
writeHomeFile(home, 'shared.md', lines(20, 'SHARED_BODY')); // under cap, not truncated
|
|
111
|
+
soulPath = writeHomeFile(home, 'soul.md', lines(200, 'SOUL_BODY')); // over default 80
|
|
112
|
+
cappedPath = writeHomeFile(home, 'capped.md', lines(100, 'CAP_BODY')); // over explicit 5
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert(/truncated at 80 of \d+ lines/.test(ctx), 'soul truncated at default cap 80', ctx.match(/truncated at[^\n]*soul[^\n]*/)?.[0] || ctx.slice(0, 300));
|
|
116
|
+
assert(ctx.includes(`Read the rest: ${soulPath}`), 'soul truncation marker names full path');
|
|
117
|
+
assert(/truncated at 5 of \d+ lines/.test(ctx), 'config maxLines=5 override respected');
|
|
118
|
+
assert(ctx.includes(`Read the rest: ${cappedPath}`), 'capped truncation marker names full path');
|
|
119
|
+
assert(/SHARED_BODY line 20\b/.test(ctx), 'under-cap file not truncated (body fully present)');
|
|
120
|
+
const summaryLine = ctx.match(/== Boot payload:[^\n]*/)?.[0] || '';
|
|
121
|
+
assert(/== Boot payload: \d+ bytes, \d+ lines, \d+ sections\./.test(summaryLine), 'payload summary line present', summaryLine);
|
|
122
|
+
const cappedPart = summaryLine.match(/Capped:([^.]*)\./)?.[1] || '';
|
|
123
|
+
assert(/Step 7\b/.test(cappedPart) && /Step 11\b/.test(cappedPart), 'summary lists capped steps 7 and 11', summaryLine);
|
|
124
|
+
assert(/Stale\/path-only: none\./.test(summaryLine), 'summary reports no stale steps', summaryLine);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Case 3: stale most-recent journal emits path-only line ──
|
|
128
|
+
console.log('Case 3: stale journal is path-only, body not injected');
|
|
129
|
+
{
|
|
130
|
+
const config = {
|
|
131
|
+
agentId: 'test-agent',
|
|
132
|
+
timezone: 'America/Los_Angeles',
|
|
133
|
+
maxTotalLines: 2000,
|
|
134
|
+
stalenessDays: 14,
|
|
135
|
+
steps: {
|
|
136
|
+
ccJournals: { dir: '~/journals', label: 'JOURNAL', stepNumber: 8, maxLines: 80, strategy: 'most-recent' },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const staleDate = ymd(-100);
|
|
140
|
+
let journalPath;
|
|
141
|
+
const ctx = runHook(config, (home) => {
|
|
142
|
+
journalPath = writeHomeFile(home, `journals/${staleDate}-journal.md`, lines(50, 'STALE_JOURNAL_BODY'));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
assert(!/STALE_JOURNAL_BODY/.test(ctx), 'stale journal body is NOT injected');
|
|
146
|
+
assert(new RegExp(`stale: newest file is ${staleDate}`).test(ctx), 'stale line names the file date');
|
|
147
|
+
assert(/cutoff 14d/.test(ctx), 'stale line names the cutoff');
|
|
148
|
+
assert(ctx.includes(`Read if needed: ${journalPath}`), 'stale line names the full path');
|
|
149
|
+
assert(/Stale\/path-only: Step 8\b/.test(ctx), 'summary lists stale step 8');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Case 4: fresh most-recent journal is injected in full ──
|
|
153
|
+
console.log('Case 4: fresh journal body IS injected');
|
|
154
|
+
{
|
|
155
|
+
const config = {
|
|
156
|
+
agentId: 'test-agent',
|
|
157
|
+
timezone: 'America/Los_Angeles',
|
|
158
|
+
maxTotalLines: 2000,
|
|
159
|
+
stalenessDays: 14,
|
|
160
|
+
steps: {
|
|
161
|
+
ccJournals: { dir: '~/journals', label: 'JOURNAL', stepNumber: 8, maxLines: 80, strategy: 'most-recent' },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const freshDate = ymd(0);
|
|
165
|
+
const ctx = runHook(config, (home) => {
|
|
166
|
+
writeHomeFile(home, `journals/${freshDate}-journal.md`, lines(10, 'FRESH_JOURNAL_BODY'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert(/FRESH_JOURNAL_BODY line 1\b/.test(ctx), 'fresh journal body injected');
|
|
170
|
+
assert(!/stale:/.test(ctx), 'no stale marker for fresh journal');
|
|
171
|
+
assert(/Stale\/path-only: none\./.test(ctx), 'summary reports no stale steps');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Case 6: maxTotalLines stops the loop early ──
|
|
175
|
+
console.log('Case 6: maxTotalLines respected');
|
|
176
|
+
{
|
|
177
|
+
const config = {
|
|
178
|
+
agentId: 'test-agent',
|
|
179
|
+
timezone: 'America/Los_Angeles',
|
|
180
|
+
maxTotalLines: 5, // tiny: first big step blows past it
|
|
181
|
+
steps: {
|
|
182
|
+
sharedContext: { path: '~/shared.md', label: 'SHARED', stepNumber: 2, critical: true },
|
|
183
|
+
context: { path: '~/context.md', label: 'CONTEXT', stepNumber: 6, critical: true },
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
const ctx = runHook(config, (home) => {
|
|
187
|
+
writeHomeFile(home, 'shared.md', lines(40, 'FIRST_BODY'));
|
|
188
|
+
writeHomeFile(home, 'context.md', lines(40, 'SECOND_BODY'));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert(/FIRST_BODY line 1\b/.test(ctx), 'first step loaded');
|
|
192
|
+
assert(!/SECOND_BODY/.test(ctx), 'second step skipped after maxTotalLines cap');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('');
|
|
196
|
+
if (failed > 0) {
|
|
197
|
+
console.log(`${failed} test(s) failed.`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
console.log('All boot payload trim tests passed.');
|
|
@@ -29,11 +29,11 @@ assertContains("await saveApiKey(apiKey, agentId, { handle: credentialLabel });"
|
|
|
29
29
|
assertContains("p.handle = identity.handle;", "pair stores display handle separately");
|
|
30
30
|
assertContains("handle: identity.handle,", "relay metadata returns display handle");
|
|
31
31
|
assertContains("codexDaemons.has(identity.agentId)", "daemon presence uses tenant id");
|
|
32
|
-
assertContains("
|
|
32
|
+
assertContains("codexDaemonPubkeyRegistry.get(identity.agentId)", "daemon pubkey uses tenant id");
|
|
33
33
|
assertContains("agentId: identity.agentId,", "relay tickets bind tenant id");
|
|
34
34
|
assertContains("handle: identity.handle,", "relay tickets preserve display handle");
|
|
35
35
|
assertContains("codexDaemons.set(identity.agentId, ws);", "daemon ws keyed by tenant id");
|
|
36
|
-
assertContains("const
|
|
36
|
+
assertContains("const webKey = codexRelayKey(identity.agentId, threadId);", "web ws keyed by tenant id");
|
|
37
37
|
assertContains("const daemonWs = codexDaemons.get(identity.agentId);", "web sends to tenant daemon");
|
|
38
38
|
assertNotContains("const agentId = stored.username || (\"passkey-\"", "registration must not use chosen handle as tenant");
|
|
39
39
|
assertNotContains("const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);", "oauth must not reuse chosen handle as tenant");
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
buildCodexBootstrapPayload,
|
|
4
|
+
createCodexDaemonPubkeyRegistry,
|
|
5
|
+
} from "../src/hosted-mcp/codex-relay-e2ee-registry.mjs";
|
|
6
|
+
|
|
7
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
8
|
+
const registrySource = readFileSync("src/hosted-mcp/codex-relay-e2ee-registry.mjs", "utf8");
|
|
9
|
+
const deployScript = readFileSync("src/hosted-mcp/deploy.sh", "utf8");
|
|
10
|
+
|
|
11
|
+
function assertContains(haystack, needle, label) {
|
|
12
|
+
if (!haystack.includes(needle)) {
|
|
13
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assertBefore(haystack, first, second, label) {
|
|
18
|
+
const firstIndex = haystack.indexOf(first);
|
|
19
|
+
const secondIndex = haystack.indexOf(second);
|
|
20
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
21
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assert(condition, label, detail = "") {
|
|
26
|
+
if (!condition) throw new Error(`${label}${detail ? ": " + detail : ""}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bootstrapPayloadFor(registry, identity, threadId, daemonOnline = true) {
|
|
30
|
+
return buildCodexBootstrapPayload({
|
|
31
|
+
identity,
|
|
32
|
+
threadId,
|
|
33
|
+
daemonOnline,
|
|
34
|
+
daemonKey: registry.get(identity.agentId),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createFakePrisma() {
|
|
39
|
+
const rows = new Map();
|
|
40
|
+
return {
|
|
41
|
+
rows,
|
|
42
|
+
async $executeRawUnsafe(sql, tenantId, pubkey, cryptoVersionsJson) {
|
|
43
|
+
if (/CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_keys/.test(sql)) return;
|
|
44
|
+
if (/CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit/.test(sql)) return;
|
|
45
|
+
if (/INSERT INTO codex_daemon_e2ee_keys/.test(sql)) {
|
|
46
|
+
rows.set(tenantId, {
|
|
47
|
+
tenant_id: tenantId,
|
|
48
|
+
pubkey,
|
|
49
|
+
crypto_versions_json: cryptoVersionsJson,
|
|
50
|
+
registered_at: new Date("2026-05-11T17:37:18.000Z"),
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (/INSERT INTO codex_daemon_e2ee_key_audit/.test(sql)) return;
|
|
55
|
+
throw new Error("unexpected fake prisma execute: " + sql);
|
|
56
|
+
},
|
|
57
|
+
async $queryRawUnsafe(sql) {
|
|
58
|
+
if (/FROM codex_daemon_e2ee_keys/.test(sql)) return [...rows.values()];
|
|
59
|
+
throw new Error("unexpected fake prisma query: " + sql);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createSilentLogger() {
|
|
65
|
+
return { log() {}, error() {} };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
assertContains(registrySource, "CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_keys", "persistent key table");
|
|
69
|
+
assertContains(registrySource, "async function loadFromDb()", "boot load helper");
|
|
70
|
+
assertContains(registrySource, "async function persist(agentId, pubkey, cryptoVersions)", "persist helper");
|
|
71
|
+
assertContains(registrySource, "function register(agentId, pubkey, cryptoVersions, source)", "registration helper");
|
|
72
|
+
assertContains(registrySource, "pubkeys.set(agentId, {", "registration updates in-memory bootstrap cache");
|
|
73
|
+
assertContains(registrySource, "return persist(agentId, pubkey, normalizedVersions)", "registration persists after cache update");
|
|
74
|
+
assertContains(server, "await codexDaemonPubkeyRegistry.loadFromDb();", "server boot load call");
|
|
75
|
+
assertContains(server, "await codexDaemonPubkeyRegistry.register(identity.agentId, p.daemon_public_key, p.crypto_versions, \"pair-complete\");", "pair-complete persists key");
|
|
76
|
+
assertContains(server, "if (envelope?.type === \"daemon.identity\") {", "daemon reconnect identity frame");
|
|
77
|
+
assertContains(server, "codexDaemonPubkeyRegistry.register(", "daemon reconnect register call");
|
|
78
|
+
assertContains(server, "buildCodexBootstrapPayload({ identity, threadId, daemonOnline, daemonKey })", "bootstrap uses shared payload builder");
|
|
79
|
+
assertContains(deployScript, "codex-relay-e2ee-registry.mjs", "hosted deploy copies registry module");
|
|
80
|
+
assertBefore(
|
|
81
|
+
server,
|
|
82
|
+
"await codexDaemonPubkeyRegistry.loadFromDb();",
|
|
83
|
+
"function handleCodexBootstrap(req, res, threadId)",
|
|
84
|
+
"persisted keys load before bootstrap handler",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const identity = {
|
|
88
|
+
agentId: "acct:test-user-a",
|
|
89
|
+
tenantId: "acct:test-user-a",
|
|
90
|
+
handle: "Parker smoke test",
|
|
91
|
+
apiKey: "ck-test",
|
|
92
|
+
};
|
|
93
|
+
const threadId = "019dfa1e-0c3d-7f01-86b9-9a22cd452bde";
|
|
94
|
+
|
|
95
|
+
const fakePrisma = createFakePrisma();
|
|
96
|
+
const registryBeforeRestart = createCodexDaemonPubkeyRegistry({
|
|
97
|
+
usePrisma: true,
|
|
98
|
+
prisma: fakePrisma,
|
|
99
|
+
devMode: false,
|
|
100
|
+
logger: createSilentLogger(),
|
|
101
|
+
});
|
|
102
|
+
await registryBeforeRestart.register(identity.agentId, "spki-key-before-restart", ["e2ee-v1"], "pair-complete");
|
|
103
|
+
|
|
104
|
+
const beforeRestartBootstrap = bootstrapPayloadFor(registryBeforeRestart, identity, threadId);
|
|
105
|
+
assert(beforeRestartBootstrap.e2ee_available === true, "bootstrap reports e2ee before restart");
|
|
106
|
+
assert(beforeRestartBootstrap.daemon_public_key === "spki-key-before-restart", "bootstrap returns registered daemon key before restart");
|
|
107
|
+
|
|
108
|
+
const registryAfterRestart = createCodexDaemonPubkeyRegistry({
|
|
109
|
+
usePrisma: true,
|
|
110
|
+
prisma: fakePrisma,
|
|
111
|
+
devMode: false,
|
|
112
|
+
logger: createSilentLogger(),
|
|
113
|
+
});
|
|
114
|
+
await registryAfterRestart.loadFromDb();
|
|
115
|
+
|
|
116
|
+
const afterRestartBootstrap = bootstrapPayloadFor(registryAfterRestart, identity, threadId);
|
|
117
|
+
assert(afterRestartBootstrap.e2ee_available === true, "bootstrap reports e2ee after restart from persisted key");
|
|
118
|
+
assert(afterRestartBootstrap.daemon_public_key === "spki-key-before-restart", "bootstrap restores persisted daemon key after restart");
|
|
119
|
+
assert(afterRestartBootstrap.daemon_crypto_versions?.[0] === "e2ee-v1", "bootstrap restores crypto versions after restart");
|
|
120
|
+
|
|
121
|
+
const emptyFakePrisma = createFakePrisma();
|
|
122
|
+
const registryBeforeReconnect = createCodexDaemonPubkeyRegistry({
|
|
123
|
+
usePrisma: true,
|
|
124
|
+
prisma: emptyFakePrisma,
|
|
125
|
+
devMode: false,
|
|
126
|
+
logger: createSilentLogger(),
|
|
127
|
+
});
|
|
128
|
+
await registryBeforeReconnect.loadFromDb();
|
|
129
|
+
const beforeReconnectBootstrap = bootstrapPayloadFor(registryBeforeReconnect, identity, threadId);
|
|
130
|
+
assert(beforeReconnectBootstrap.e2ee_available === false, "bootstrap is not e2ee available before daemon reconnect when no key exists");
|
|
131
|
+
assert(beforeReconnectBootstrap.daemon_public_key === null, "bootstrap has no daemon key before daemon reconnect");
|
|
132
|
+
|
|
133
|
+
await registryBeforeReconnect.register(identity.agentId, "spki-key-from-daemon-reconnect", [], "daemon-reconnect");
|
|
134
|
+
const afterReconnectBootstrap = bootstrapPayloadFor(registryBeforeReconnect, identity, threadId);
|
|
135
|
+
assert(afterReconnectBootstrap.e2ee_available === true, "bootstrap reports e2ee after daemon reconnect self-heal");
|
|
136
|
+
assert(afterReconnectBootstrap.daemon_public_key === "spki-key-from-daemon-reconnect", "bootstrap returns daemon reconnect key");
|
|
137
|
+
assert(afterReconnectBootstrap.daemon_crypto_versions?.[0] === "e2ee-v1", "daemon reconnect defaults crypto version");
|
|
138
|
+
|
|
139
|
+
const registryAfterReconnectRestart = createCodexDaemonPubkeyRegistry({
|
|
140
|
+
usePrisma: true,
|
|
141
|
+
prisma: emptyFakePrisma,
|
|
142
|
+
devMode: false,
|
|
143
|
+
logger: createSilentLogger(),
|
|
144
|
+
});
|
|
145
|
+
await registryAfterReconnectRestart.loadFromDb();
|
|
146
|
+
const afterReconnectRestartBootstrap = bootstrapPayloadFor(registryAfterReconnectRestart, identity, threadId);
|
|
147
|
+
assert(afterReconnectRestartBootstrap.e2ee_available === true, "daemon reconnect key is persisted for the next restart");
|
|
148
|
+
assert(afterReconnectRestartBootstrap.daemon_public_key === "spki-key-from-daemon-reconnect", "daemon reconnect key survives restart");
|
|
149
|
+
|
|
150
|
+
console.log("crc e2ee key persistence restart regression checks passed");
|
|
@@ -30,11 +30,18 @@ assertContains("return openCodexWebClientsForKey(codexRelayKey(agentId, routeId)
|
|
|
30
30
|
assertContains("const targets = resolveCodexWebClientsForDaemonFrame(identity.agentId, sessionId);", "daemon frames use route resolver");
|
|
31
31
|
assertContains("for (const target of targets) {", "daemon frames send to every resolved target");
|
|
32
32
|
assertContains("if (isCodexE2eeEnvelope(envelope) && envelope.session) {", "web e2ee messages are detected");
|
|
33
|
+
assertContains("envelope.route_thread_id = threadId;", "relay injects ticket-bound thread into e2ee hello");
|
|
34
|
+
assertContains("text = JSON.stringify(envelope);", "relay forwards the route-bound e2ee hello");
|
|
33
35
|
assertContains("registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);", "web e2ee session is registered");
|
|
34
|
-
assertContains("const clientCount = addCodexWebClient(
|
|
35
|
-
assertContains("removeCodexWebClient(
|
|
36
|
+
assertContains("const clientCount = addCodexWebClient(webKey, ws);", "new web connections are added without replacing existing clients");
|
|
37
|
+
assertContains("removeCodexWebClient(webKey, ws);", "close cleanup removes only the closing socket");
|
|
36
38
|
assertContains("removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);", "close cleanup");
|
|
37
39
|
assertContains("if (route.webKey === webKey && (!ws || route.ws === ws)) {", "cleanup only removes routes owned by the closing socket");
|
|
40
|
+
assertBefore(
|
|
41
|
+
"envelope.route_thread_id = threadId;",
|
|
42
|
+
"daemonWs.send(text);",
|
|
43
|
+
"web route thread is injected before forwarding to daemon",
|
|
44
|
+
);
|
|
38
45
|
assertBefore(
|
|
39
46
|
"registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);",
|
|
40
47
|
"daemonWs.send(text);",
|
|
@@ -12,6 +12,12 @@ function assertContains(source, needle, label) {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function assertNotContains(source, needle, label) {
|
|
16
|
+
if (source.includes(needle)) {
|
|
17
|
+
throw new Error(`${label} must not contain: ${needle}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function assertBefore(source, first, second, label) {
|
|
16
22
|
const firstIndex = source.indexOf(first);
|
|
17
23
|
const secondIndex = source.indexOf(second);
|
|
@@ -20,15 +26,74 @@ function assertBefore(source, first, second, label) {
|
|
|
20
26
|
}
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
function extractServerNextSanitizer(source) {
|
|
30
|
+
const consts = [
|
|
31
|
+
source.match(/const PAIR_NEXT_REGEX = .+;/)?.[0],
|
|
32
|
+
source.match(/const REMOTE_CONTROL_NEXT_REGEX = .+;/)?.[0],
|
|
33
|
+
source.match(/const DEMO_NEXT_REGEX = .+;/)?.[0],
|
|
34
|
+
];
|
|
35
|
+
if (consts.some((line) => !line)) {
|
|
36
|
+
throw new Error("server sanitizer test could not extract next regex constants");
|
|
37
|
+
}
|
|
38
|
+
const start = source.indexOf("function sanitizeCrcPairNext(raw)");
|
|
39
|
+
const end = source.indexOf("// POST /api/qr-login", start);
|
|
40
|
+
if (start === -1 || end === -1) {
|
|
41
|
+
throw new Error("server sanitizer test could not extract sanitizeCrcPairNext");
|
|
42
|
+
}
|
|
43
|
+
return Function(`${consts.join("\n")}\n${source.slice(start, end)}\nreturn sanitizeCrcPairNext;`)();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertSanitizedNext(sanitize, value, expected) {
|
|
47
|
+
const actual = sanitize(value);
|
|
48
|
+
if (actual !== expected) {
|
|
49
|
+
throw new Error(`sanitizeCrcPairNext(${JSON.stringify(value)}) expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
23
53
|
assertContains(server, "const PAIR_NEXT_REGEX = /^\\/pair\\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;", "server pair regex");
|
|
24
54
|
assertContains(server, "const REMOTE_CONTROL_NEXT_REGEX = /^\\/codex-remote-control\\/", "server remote-control regex");
|
|
55
|
+
assertContains(server, "const DEMO_NEXT_REGEX = /^\\/demo$/;", "server demo regex");
|
|
25
56
|
assertContains(server, "purpose, // \"pair\" | null", "server stores pair purpose");
|
|
26
|
-
assertContains(server, "next: next || null, // sanitized `/pair/<CODE
|
|
57
|
+
assertContains(server, "next: next || null, // sanitized `/pair/<CODE>`, `/codex-remote-control/<UUID>`, `/demo`, or null", "server stores sanitized next");
|
|
58
|
+
assertContains(server, "DEMO_NEXT_REGEX.test(decoded)", "server allows demo next");
|
|
27
59
|
assertContains(server, "json(res, 200, { status: \"approved\", agentId: entry.agentId });", "server strips desktop pair status");
|
|
28
60
|
assertContains(server, "json(res, 200, { ok: true, next: entry.next });", "server returns next to phone approve");
|
|
61
|
+
assertContains(server, "tenantId: entry.tenantId || null,", "server returns QR login tenant");
|
|
62
|
+
assertContains(server, "const verifiedIdentity = identityForApiKey(apiKey);", "server canonicalizes QR approval identity");
|
|
63
|
+
|
|
64
|
+
const sanitizeCrcPairNext = extractServerNextSanitizer(server);
|
|
65
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demo", "/demo");
|
|
66
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demo/", null);
|
|
67
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demo/../admin", null);
|
|
68
|
+
assertSanitizedNext(sanitizeCrcPairNext, "//evil.com", null);
|
|
69
|
+
assertSanitizedNext(sanitizeCrcPairNext, "https://evil", null);
|
|
70
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demoX", null);
|
|
29
71
|
|
|
30
72
|
for (const file of loginFiles) {
|
|
31
73
|
const html = readFileSync(file, "utf8");
|
|
74
|
+
assertContains(html, "var DEMO_NEXT_REGEX = /^\\/demo$/;", `${file} demo regex`);
|
|
75
|
+
assertContains(html, "function defaultLocalPasskeysOn()", `${file} has local passkey default helper`);
|
|
76
|
+
assertContains(html, "return isMobileDevice();", `${file} defaults local passkeys on for mobile`);
|
|
77
|
+
assertContains(html, "function getLocalPasskeysPreference()", `${file} has local passkey preference helper`);
|
|
78
|
+
assertContains(html, "if (stored === 'on' || stored === 'off') return stored;", `${file} respects stored local passkey preference`);
|
|
79
|
+
assertContains(html, "function needsCustomQR() {\n return !isLocalPasskeysOn();\n}", `${file} uses QR whenever local passkeys are off`);
|
|
80
|
+
assertNotContains(html, "return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();", `${file} must not gate QR by browser or mobile status`);
|
|
81
|
+
assertContains(html, "DEMO_NEXT_REGEX.test(raw)", `${file} allowlists demo next`);
|
|
82
|
+
assertContains(html, "function storeDemoHandoffIfNeeded(next, identity, isNewAccount)", `${file} demo handoff helper`);
|
|
83
|
+
assertContains(html, "sessionStorage.setItem('lesa-token', identity.apiKey);", `${file} stores demo token`);
|
|
84
|
+
assertContains(html, "sessionStorage.setItem('lesa-agent', identity.agentId);", `${file} stores demo agent`);
|
|
85
|
+
assertContains(html, "if (identity.tenantId) sessionStorage.setItem('lesa-tenant', identity.tenantId);", `${file} stores demo tenant`);
|
|
86
|
+
assertContains(html, "if (isNewAccount) sessionStorage.setItem('lesa-new-account', 'true');", `${file} stores demo new-account flag conditionally`);
|
|
87
|
+
assertContains(html, "tenantId: result.tenantId,", `${file} sends tenant to QR approval`);
|
|
88
|
+
assertContains(html, "storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');", `${file} QR status stores demo handoff`);
|
|
89
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', data, qrLoginMode === 'register');", `${file} plain QR success keeps demo handoff`);
|
|
90
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, true);", `${file} plain create success keeps demo handoff`);
|
|
91
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, false);", `${file} plain sign-in success keeps demo handoff`);
|
|
92
|
+
if (html.includes('onclick="sessionStorage.clear();"')) {
|
|
93
|
+
throw new Error(`${file} Try the Demo button must not clear sessionStorage`);
|
|
94
|
+
}
|
|
95
|
+
assertContains(html, "followPairNextIfPresent(approveResponse, result, true)", `${file} create account marks new account`);
|
|
96
|
+
assertContains(html, "followPairNextIfPresent(approveResponse, result, false)", `${file} sign-in does not mark new account`);
|
|
32
97
|
assertContains(html, "function isPairNextOnDesktop()", `${file} desktop pair helper`);
|
|
33
98
|
assertContains(html, "} else if (isPairNextOnDesktop()) {", `${file} auto-start desktop pair QR`);
|
|
34
99
|
assertContains(html, "startQrLogin('', 'signin');", `${file} pair QR uses sign-in mode`);
|
|
@@ -37,4 +102,14 @@ for (const file of loginFiles) {
|
|
|
37
102
|
assertBefore(html, "if (isPairNextOnDesktop() && !qrSessionMode)", "if (needsCustomQR() && !qrSessionMode)", `${file} create button forces pair QR before normal QR fallback`);
|
|
38
103
|
}
|
|
39
104
|
|
|
105
|
+
const appLogin = readFileSync("src/hosted-mcp/app/kaleidoscope-login.html", "utf8");
|
|
106
|
+
assertContains(appLogin, "Local passkeys are on by default on mobile devices.", "app login mobile tooltip copy");
|
|
107
|
+
assertContains(appLogin, "This device's passkeys are used for login and device sync.", "app login mobile tooltip current-device copy");
|
|
108
|
+
assertContains(appLogin, "Turn this off to use or save passkeys on a different device.", "app login mobile tooltip off copy");
|
|
109
|
+
assertContains(appLogin, "Local passkeys are off by default on desktop.", "app login desktop tooltip copy");
|
|
110
|
+
assertContains(appLogin, "Your mobile device's passkeys are used for login and device sync.", "app login desktop tooltip mobile-device copy");
|
|
111
|
+
assertContains(appLogin, "Turn this on to use or save passkeys on this computer.", "app login desktop tooltip on copy");
|
|
112
|
+
assertNotContains(appLogin, "phone's passkeys", "app login tooltip must not say phone passkeys");
|
|
113
|
+
assertNotContains(appLogin, "mobile devices' passkeys", "app login tooltip must not use plural possessive");
|
|
114
|
+
|
|
40
115
|
console.log("crc pair login flow checks passed");
|