@wipcomputer/wip-ldm-os 0.4.85-alpha.30 → 0.4.85-alpha.32
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/bin/ldm.js +103 -0
- package/package.json +8 -2
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-pair-login-flow.mjs +76 -1
- package/scripts/test-doctor-hook-dedupe.mjs +188 -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/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/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/app/footer.js +2 -2
- package/src/hosted-mcp/app/kaleidoscope-login.html +486 -42
- package/src/hosted-mcp/app/wip-logo.png +0 -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/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 +662 -35
|
@@ -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');
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
const demo = readFileSync("src/hosted-mcp/demo/index.html", "utf8");
|
|
5
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
6
|
+
|
|
7
|
+
function functionBody(source, name) {
|
|
8
|
+
const start = source.indexOf("async function " + name + "(");
|
|
9
|
+
assert.notEqual(start, -1, "missing function " + name);
|
|
10
|
+
const open = source.indexOf("{", start);
|
|
11
|
+
let depth = 0;
|
|
12
|
+
for (let i = open; i < source.length; i++) {
|
|
13
|
+
if (source[i] === "{") depth += 1;
|
|
14
|
+
if (source[i] === "}") depth -= 1;
|
|
15
|
+
if (depth === 0) return source.slice(open + 1, i);
|
|
16
|
+
}
|
|
17
|
+
throw new Error("unterminated function " + name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function functionSource(source, name) {
|
|
21
|
+
const start = source.indexOf("function " + name + "(");
|
|
22
|
+
assert.notEqual(start, -1, "missing function " + name);
|
|
23
|
+
const open = source.indexOf("{", start);
|
|
24
|
+
let depth = 0;
|
|
25
|
+
for (let i = open; i < source.length; i++) {
|
|
26
|
+
if (source[i] === "{") depth += 1;
|
|
27
|
+
if (source[i] === "}") depth -= 1;
|
|
28
|
+
if (depth === 0) return source.slice(start, i + 1);
|
|
29
|
+
}
|
|
30
|
+
throw new Error("unterminated function " + name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const noThanks = functionBody(demo, "showNoThanksFlow");
|
|
34
|
+
const returningNoThanks = functionBody(demo, "showReturningUserNoThanksFlow");
|
|
35
|
+
const startDemo = functionBody(demo, "startDemo");
|
|
36
|
+
const authFlow = functionBody(demo, "doFaceIDAuth");
|
|
37
|
+
const imageFlow = functionBody(demo, "generateKaleidoscope");
|
|
38
|
+
const oldOutro = functionBody(demo, "showOutro");
|
|
39
|
+
const approvedOutro = functionBody(demo, "showApprovedOutro");
|
|
40
|
+
const returningOutro = functionBody(demo, "showReturningUserOutro");
|
|
41
|
+
|
|
42
|
+
assert.match(startDemo, /Hi, I'm L\\u0113sa\. Welcome to Kaleidoscope\./);
|
|
43
|
+
assert.match(startDemo, /You just created an account with a passkey\. The passkey lives on your phone\./);
|
|
44
|
+
assert.match(startDemo, /Going forward, you can use your phone to log into any Work in Progress Computer service\./);
|
|
45
|
+
assert.match(startDemo, /Anytime I need your permission to do something, I'll ask, and you authorize with your fingerprint or face\. No passwords\. Ever\./);
|
|
46
|
+
assert.match(startDemo, /Hi, " \+ currentAccountLabel\(\) \+ "\. Welcome back to Kaleidoscope\./);
|
|
47
|
+
assert.match(startDemo, /Do you want to try giving me permission with your device to create something that costs money\?/);
|
|
48
|
+
assert.doesNotMatch(startDemo, /getWalletSummary\(\)/);
|
|
49
|
+
assert.doesNotMatch(startDemo, /Your balance is " \+ wallet\.balance/);
|
|
50
|
+
assert.doesNotMatch(startDemo, /Your account is a key on your device, not an email\./);
|
|
51
|
+
assert.doesNotMatch(startDemo, /You just logged in with your passkey/);
|
|
52
|
+
assert.doesNotMatch(startDemo, /And I can use it too/);
|
|
53
|
+
|
|
54
|
+
assert.match(demo, /I have a wallet with " \+ wallet\.balance \+ "\. Do I have your permission to spend " \+ wallet\.cost/);
|
|
55
|
+
assert.match(demo, /addMessage\('Authorizing', 'user'\)/);
|
|
56
|
+
assert.doesNotMatch(demo, /addMessage\('Authorized', 'user'\)/);
|
|
57
|
+
assert.doesNotMatch(demo, /This is the end of the demo\. Tap the icon to start over\./);
|
|
58
|
+
assert.doesNotMatch(demo, /open wip\.computer\/demo/);
|
|
59
|
+
|
|
60
|
+
assert.match(noThanks, /No problem\. Let me show you something beautiful\./);
|
|
61
|
+
assert.match(noThanks, /IS_RETURNING_USER_FLOW/);
|
|
62
|
+
assert.match(noThanks, /showReturningUserNoThanksFlow\(\)/);
|
|
63
|
+
assert.match(noThanks, /generateKaleidoscope\(false, \{ showWalletReceipt: false, showOutro: false \}\)/);
|
|
64
|
+
assert.match(noThanks, /showApprovedOutro\(\)/);
|
|
65
|
+
assert.doesNotMatch(noThanks, /\/demo\/api\/imagine/);
|
|
66
|
+
assert.doesNotMatch(noThanks, /fallback\.jpg/);
|
|
67
|
+
assert.doesNotMatch(noThanks, /Cost:/);
|
|
68
|
+
assert.doesNotMatch(noThanks, /Balance:/);
|
|
69
|
+
|
|
70
|
+
assert.match(returningNoThanks, /No problem\. You can come back anytime\./);
|
|
71
|
+
assert.match(returningNoThanks, /At Work in Progress Computer, we are building the future of AI and human interaction\./);
|
|
72
|
+
assert.match(returningNoThanks, /https:\/\/x\.com\/wipcomputer/);
|
|
73
|
+
assert.match(returningNoThanks, /Made in California by WIP Computer, Inc\. Learning Dreaming Machines\./);
|
|
74
|
+
assert.doesNotMatch(returningNoThanks, /generateKaleidoscope/);
|
|
75
|
+
assert.doesNotMatch(returningNoThanks, /\/demo\/api\/imagine/);
|
|
76
|
+
assert.doesNotMatch(returningNoThanks, /Cost:/);
|
|
77
|
+
assert.doesNotMatch(returningNoThanks, /Balance:/);
|
|
78
|
+
assert.doesNotMatch(returningNoThanks, /passkey/i);
|
|
79
|
+
|
|
80
|
+
assert.match(imageFlow, /var showWalletReceipt = options\.showWalletReceipt !== false;/);
|
|
81
|
+
assert.match(imageFlow, /if \(showWalletReceipt\) \{/);
|
|
82
|
+
assert.match(imageFlow, /body: JSON\.stringify\(\{ prompt: prompt \}\)/);
|
|
83
|
+
assert.doesNotMatch(imageFlow, /mode:/);
|
|
84
|
+
assert.doesNotMatch(server, /no_thanks/i);
|
|
85
|
+
assert.doesNotMatch(server, /Free onboarding generation/);
|
|
86
|
+
|
|
87
|
+
assert.match(oldOutro, /IS_RETURNING_USER_FLOW/);
|
|
88
|
+
assert.match(oldOutro, /showReturningUserOutro\(\)/);
|
|
89
|
+
assert.match(oldOutro, /showApprovedOutro\(\)/);
|
|
90
|
+
assert.match(approvedOutro, /At Work in Progress Computer, we are building the future of AI and human interaction\./);
|
|
91
|
+
assert.match(approvedOutro, /https:\/\/wip\.computer\/demo/);
|
|
92
|
+
assert.match(approvedOutro, /https:\/\/x\.com\/wipcomputer/);
|
|
93
|
+
assert.match(approvedOutro, /Your passkey will keep working after you leave\./);
|
|
94
|
+
assert.doesNotMatch(approvedOutro, /Your account stays active/);
|
|
95
|
+
assert.doesNotMatch(approvedOutro, /When you come back/);
|
|
96
|
+
|
|
97
|
+
assert.match(returningOutro, /At Work in Progress Computer, we are building the future of AI and human interaction\./);
|
|
98
|
+
assert.match(returningOutro, /We believe permission is a conversation\. Your AI asks\. You decide\. One glance, one tap\./);
|
|
99
|
+
assert.match(returningOutro, /https:\/\/x\.com\/wipcomputer/);
|
|
100
|
+
assert.match(returningOutro, /Made in California by WIP Computer, Inc\. Learning Dreaming Machines\./);
|
|
101
|
+
assert.doesNotMatch(returningOutro, /https:\/\/wip\.computer\/demo/);
|
|
102
|
+
assert.doesNotMatch(returningOutro, /log out/);
|
|
103
|
+
assert.doesNotMatch(returningOutro, /Your passkey will keep working after you leave\./);
|
|
104
|
+
assert.doesNotMatch(returningOutro, /Your account stays active/);
|
|
105
|
+
assert.doesNotMatch(returningOutro, /When you come back/);
|
|
106
|
+
|
|
107
|
+
assert.match(authFlow, /That passkey belongs to a different account/);
|
|
108
|
+
assert.match(authFlow, /isSameAccountAuthorization\(result, activeToken, activeTenant\)/);
|
|
109
|
+
assert.doesNotMatch(authFlow, /result\.tenantId !== activeTenant/);
|
|
110
|
+
|
|
111
|
+
const { canonicalAccountId, isSameAccountAuthorization } = Function(
|
|
112
|
+
functionSource(demo, "canonicalAccountId") + "\n"
|
|
113
|
+
+ functionSource(demo, "isSameAccountAuthorization") + "\n"
|
|
114
|
+
+ "return { canonicalAccountId, isSameAccountAuthorization };"
|
|
115
|
+
)();
|
|
116
|
+
|
|
117
|
+
assert.equal(canonicalAccountId("acct:parker-smoke-test"), "parker-smoke-test");
|
|
118
|
+
assert.equal(canonicalAccountId("parker-smoke-test"), "parker-smoke-test");
|
|
119
|
+
assert.equal(canonicalAccountId(" key:abc "), "key:abc");
|
|
120
|
+
assert.equal(canonicalAccountId(""), null);
|
|
121
|
+
assert.equal(canonicalAccountId(null), null);
|
|
122
|
+
|
|
123
|
+
assert.equal(
|
|
124
|
+
isSameAccountAuthorization({ tenantId: "parker-smoke-test", apiKey: "different-token" }, "active-token", "acct:parker-smoke-test"),
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
assert.equal(
|
|
128
|
+
isSameAccountAuthorization({ tenantId: "acct:parker-smoke-test", apiKey: "different-token" }, "active-token", "parker-smoke-test"),
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
assert.equal(
|
|
132
|
+
isSameAccountAuthorization({ tenantId: "acct:other-account", apiKey: "active-token" }, "active-token", "acct:parker-smoke-test"),
|
|
133
|
+
false,
|
|
134
|
+
);
|
|
135
|
+
assert.equal(
|
|
136
|
+
isSameAccountAuthorization({ apiKey: "active-token" }, "active-token", null),
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
assert.equal(
|
|
140
|
+
isSameAccountAuthorization({ apiKey: "other-token" }, "active-token", null),
|
|
141
|
+
false,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
assert.match(demo, /id="kscopeIcon" type="button" onclick="logoutAndSignBackIn\(\)"/);
|
|
145
|
+
assert.match(demo, /sessionStorage\.removeItem\('lesa-token'\)/);
|
|
146
|
+
assert.match(demo, /window\.location\.href = '\/login\?next=\/demo'/);
|
|
147
|
+
|
|
148
|
+
assert.match(server, /const IMAGE_COST_CENTS = 1;/);
|
|
149
|
+
assert.match(server, /const INITIAL_BALANCE_CENTS = 1000;/);
|
|
150
|
+
assert.match(server, /const DEMO_WALLET_RESET_MARKER_FILE = join/);
|
|
151
|
+
assert.match(server, /\.demo-wallet-reset-v0-4-87\.json/);
|
|
152
|
+
assert.match(server, /function walletUserIdForAgent\(agentId\)/);
|
|
153
|
+
assert.match(server, /agentId\.startsWith\("acct:"\) \? agentId\.slice\("acct:"\.length\) : agentId/);
|
|
154
|
+
assert.match(server, /function resetAndNormalizeWalletFileEntries\(wallets\)/);
|
|
155
|
+
assert.match(server, /normalizedWallets\[walletUserIdForAgent\(agentId\)\] = INITIAL_BALANCE_CENTS/);
|
|
156
|
+
assert.match(server, /async function resetExistingDemoWalletsToStarterBalanceOnce\(\)/);
|
|
157
|
+
assert.match(server, /await prisma\.wallet\.updateMany\(\{ data: \{ balance: INITIAL_BALANCE_CENTS \} \}\)/);
|
|
158
|
+
assert.match(server, /saveWalletsToFile\(normalizedWalletFile\.wallets\)/);
|
|
159
|
+
assert.match(server, /prismaCount: prismaResetCount/);
|
|
160
|
+
assert.match(server, /jsonCount: jsonResetCount/);
|
|
161
|
+
assert.match(server, /const walletUserId = walletUserIdForAgent\(agentId\);/);
|
|
162
|
+
assert.match(server, /where: \{ userId: walletUserId \}/);
|
|
163
|
+
assert.match(server, /data: \{ userId: walletUserId, balance: INITIAL_BALANCE_CENTS \}/);
|
|
164
|
+
assert.match(server, /return w\[walletUserId\] !== undefined \? w\[walletUserId\] : INITIAL_BALANCE_CENTS/);
|
|
165
|
+
assert.match(server, /if \(w\[walletUserId\] === undefined\) w\[walletUserId\] = INITIAL_BALANCE_CENTS/);
|
|
166
|
+
assert.match(server, /w\[walletUserId\] = Math\.max\(0, w\[walletUserId\] - cents\)/);
|
|
167
|
+
assert.match(server, /return w\[walletUserId\]/);
|
|
168
|
+
assert.match(server, /await resetExistingDemoWalletsToStarterBalanceOnce\(\);/);
|
|
169
|
+
|
|
170
|
+
console.log("Kaleidoscope onboarding copy checks passed");
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import vm from "node:vm";
|
|
4
|
+
|
|
5
|
+
const serverSource = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
6
|
+
assert.match(serverSource, /startsAt: "2026-05-21T07:00:00\.000Z"/);
|
|
7
|
+
assert.match(serverSource, /keysCreated: 3/);
|
|
8
|
+
assert.match(serverSource, /genericKaleidoscopes: 11/);
|
|
9
|
+
assert.match(serverSource, /imageKaleidoscopes: 3/);
|
|
10
|
+
|
|
11
|
+
const start = serverSource.indexOf("function parseTimestampMs");
|
|
12
|
+
const end = serverSource.indexOf("function loadLiveWallState()");
|
|
13
|
+
assert.notEqual(start, -1, "baseline helper block start not found");
|
|
14
|
+
assert.notEqual(end, -1, "baseline helper block end not found");
|
|
15
|
+
assert.ok(end > start, "baseline helper block is malformed");
|
|
16
|
+
|
|
17
|
+
const helperSource = `
|
|
18
|
+
const KALEIDOSCOPE_PUBLIC_STATS_BASELINE = Object.freeze({
|
|
19
|
+
timezone: "America/Los_Angeles",
|
|
20
|
+
date: "2026-05-21",
|
|
21
|
+
startsAt: "2026-05-21T07:00:00.000Z",
|
|
22
|
+
counts: Object.freeze({
|
|
23
|
+
keysCreated: 3,
|
|
24
|
+
genericKaleidoscopes: 11,
|
|
25
|
+
imageKaleidoscopes: 3,
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
${serverSource.slice(start, end)}
|
|
29
|
+
globalThis.__statsHelpers = {
|
|
30
|
+
KALEIDOSCOPE_PUBLIC_STATS_BASELINE,
|
|
31
|
+
deriveKaleidoscopePublicStats,
|
|
32
|
+
isWipTestHandle,
|
|
33
|
+
createdAtSinceBaseline,
|
|
34
|
+
};
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const context = {
|
|
38
|
+
console,
|
|
39
|
+
Date,
|
|
40
|
+
Object,
|
|
41
|
+
Number,
|
|
42
|
+
String,
|
|
43
|
+
Array,
|
|
44
|
+
isPublicWallImageUrl(value) {
|
|
45
|
+
return typeof value === "string" && value.startsWith("https://wip.computer/media/kaleidoscope/generated/");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
vm.createContext(context);
|
|
50
|
+
vm.runInContext(helperSource, context);
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
KALEIDOSCOPE_PUBLIC_STATS_BASELINE,
|
|
54
|
+
deriveKaleidoscopePublicStats,
|
|
55
|
+
isWipTestHandle,
|
|
56
|
+
createdAtSinceBaseline,
|
|
57
|
+
} = context.__statsHelpers;
|
|
58
|
+
|
|
59
|
+
assert.deepEqual(JSON.parse(JSON.stringify(KALEIDOSCOPE_PUBLIC_STATS_BASELINE.counts)), {
|
|
60
|
+
keysCreated: 3,
|
|
61
|
+
genericKaleidoscopes: 11,
|
|
62
|
+
imageKaleidoscopes: 3,
|
|
63
|
+
});
|
|
64
|
+
assert.equal(KALEIDOSCOPE_PUBLIC_STATS_BASELINE.startsAt, "2026-05-21T07:00:00.000Z");
|
|
65
|
+
assert.equal(isWipTestHandle("wiptest-parker"), true);
|
|
66
|
+
assert.equal(isWipTestHandle("WIPTEST-PARKER"), true);
|
|
67
|
+
assert.equal(isWipTestHandle("passkey-123"), false);
|
|
68
|
+
assert.equal(createdAtSinceBaseline({ createdAt: "2026-05-21T06:59:59.999Z" }), false);
|
|
69
|
+
assert.equal(createdAtSinceBaseline({ createdAt: "2026-05-21T07:00:00.000Z" }), true);
|
|
70
|
+
|
|
71
|
+
const images = [
|
|
72
|
+
{
|
|
73
|
+
url: "https://wip.computer/media/kaleidoscope/generated/prebaseline-generic.jpg",
|
|
74
|
+
kind: "generic",
|
|
75
|
+
createdAt: "2026-05-20T00:00:00.000Z",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
url: "https://wip.computer/media/kaleidoscope/generated/postbaseline-generic.jpg",
|
|
79
|
+
kind: "generic",
|
|
80
|
+
createdAt: "2026-05-21T07:00:00.000Z",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
url: "https://wip.computer/media/kaleidoscope/generated/postbaseline-image.jpg",
|
|
84
|
+
kind: "image",
|
|
85
|
+
createdAt: "2026-05-21T08:00:00.000Z",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
url: "https://wip.computer/media/kaleidoscope/generated/older-image.jpg",
|
|
89
|
+
kind: "image",
|
|
90
|
+
createdAt: "2026-05-20T01:00:00.000Z",
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
const passkeyEntries = [
|
|
94
|
+
{ handle: "passkey-before", createdAt: "2026-05-20T00:00:00.000Z" },
|
|
95
|
+
{ handle: "wiptest-after", createdAt: "2026-05-21T08:00:00.000Z" },
|
|
96
|
+
{ handle: "user-after", createdAt: "2026-05-21T09:00:00.000Z" },
|
|
97
|
+
{ handle: "wiptest-newest", createdAt: "2026-05-21T09:15:00.000Z" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const stats = deriveKaleidoscopePublicStats({
|
|
101
|
+
images,
|
|
102
|
+
passkeyEntries,
|
|
103
|
+
now: new Date("2026-05-21T09:30:00.000Z"),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
assert.equal(images.length, 4, "stats helper must not remove live-wall images");
|
|
107
|
+
assert.equal(stats.genericKaleidoscopes, 12);
|
|
108
|
+
assert.equal(stats.imageKaleidoscopes, 4);
|
|
109
|
+
assert.equal(stats.keysCreated, 4);
|
|
110
|
+
assert.equal(stats.publicWallImages, 4);
|
|
111
|
+
assert.deepEqual(JSON.parse(JSON.stringify(stats.newSinceBaseline)), {
|
|
112
|
+
genericKaleidoscopes: 1,
|
|
113
|
+
imageKaleidoscopes: 1,
|
|
114
|
+
keysCreated: 1,
|
|
115
|
+
total: 3,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(stats.last24Hours.wallImages, 2);
|
|
118
|
+
assert.equal(stats.last24Hours.keysCreated, 1);
|
|
119
|
+
assert.equal(stats.lastCreated, "2026-05-21T09:00:00.000Z");
|
|
120
|
+
|
|
121
|
+
const wiptestOnly = deriveKaleidoscopePublicStats({
|
|
122
|
+
images: [],
|
|
123
|
+
passkeyEntries: [{ handle: "wiptest-only", createdAt: "2026-05-21T10:00:00.000Z" }],
|
|
124
|
+
now: new Date("2026-05-21T10:00:00.000Z"),
|
|
125
|
+
});
|
|
126
|
+
assert.equal(wiptestOnly.keysCreated, 3);
|
|
127
|
+
assert.equal(wiptestOnly.newSinceBaseline.keysCreated, 0);
|
|
128
|
+
|
|
129
|
+
console.log("kaleidoscope public stats baseline tests passed");
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const loginFiles = [
|
|
4
|
+
"src/hosted-mcp/app/kaleidoscope-login.html",
|
|
5
|
+
"src/hosted-mcp/demo/login.html",
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
function assertContains(source, needle, label) {
|
|
9
|
+
if (!source.includes(needle)) {
|
|
10
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertBefore(source, first, second, label) {
|
|
15
|
+
const firstIndex = source.indexOf(first);
|
|
16
|
+
const secondIndex = source.indexOf(second);
|
|
17
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
18
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extractFunction(source, name) {
|
|
23
|
+
const start = source.indexOf(`function ${name}(`);
|
|
24
|
+
if (start === -1) throw new Error(`could not find function ${name}`);
|
|
25
|
+
const bodyStart = source.indexOf("{", start);
|
|
26
|
+
let depth = 0;
|
|
27
|
+
for (let i = bodyStart; i < source.length; i += 1) {
|
|
28
|
+
if (source[i] === "{") depth += 1;
|
|
29
|
+
if (source[i] === "}") depth -= 1;
|
|
30
|
+
if (depth === 0) return source.slice(start, i + 1);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`could not extract function ${name}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertNotContains(source, needle, label) {
|
|
36
|
+
if (source.includes(needle)) {
|
|
37
|
+
throw new Error(`${label} must not contain: ${needle}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const file of loginFiles) {
|
|
42
|
+
const html = readFileSync(file, "utf8");
|
|
43
|
+
const followFunction = extractFunction(html, "followPairNextIfPresent");
|
|
44
|
+
const backFunction = extractFunction(html, "backToLoginAfterQrScan");
|
|
45
|
+
|
|
46
|
+
assertContains(html, 'id="qr-auth-confirm-view"', `${file} confirmation view`);
|
|
47
|
+
assertContains(html, "Your authenticated Kaleidoscope session is available on your other device.", `${file} confirmation headline`);
|
|
48
|
+
assertContains(html, "Back to login", `${file} back button copy`);
|
|
49
|
+
assertNotContains(html, "Open session here", `${file} authenticator completion must not offer local demo open`);
|
|
50
|
+
assertNotContains(html, ">Close</button>", `${file} authenticator completion must not show close action`);
|
|
51
|
+
assertNotContains(html, "Kaleidoscope is ready there.", `${file} removed ready helper copy`);
|
|
52
|
+
assertNotContains(html, "Open Kaleidoscope here", `${file} removed duplicated open copy`);
|
|
53
|
+
assertNotContains(html, "Keep using Kaleidoscope on the other device.", `${file} removed cancel helper copy`);
|
|
54
|
+
assertNotContains(html, "Scan QR Code", `${file} login-page scanner action must be absent`);
|
|
55
|
+
assertNotContains(html, "BarcodeDetector", `${file} login-page scanner detector must be absent`);
|
|
56
|
+
assertNotContains(html, "getUserMedia", `${file} login-page scanner camera code must be absent`);
|
|
57
|
+
assertContains(html, "You can close this page.", `${file} close fallback copy`);
|
|
58
|
+
|
|
59
|
+
assertContains(html, "function showQrAuthenticatorConfirmation(identity, isNewAccount)", `${file} confirmation function`);
|
|
60
|
+
assertContains(html, "function backToLoginAfterQrScan()", `${file} back-to-login function`);
|
|
61
|
+
|
|
62
|
+
assertContains(
|
|
63
|
+
html,
|
|
64
|
+
"if (DEMO_NEXT_REGEX.test(approveResponse.next)) {\n return showQrAuthenticatorConfirmation(identity, isNewAccount === true);\n }",
|
|
65
|
+
`${file} QR authenticator demo next shows confirmation`,
|
|
66
|
+
);
|
|
67
|
+
assertBefore(
|
|
68
|
+
html,
|
|
69
|
+
"return showQrAuthenticatorConfirmation(identity, isNewAccount === true);",
|
|
70
|
+
"next = approveResponse.next;",
|
|
71
|
+
`${file} confirmation intercepts approve next before redirect`,
|
|
72
|
+
);
|
|
73
|
+
assertNotContains(followFunction, "location.replace('/demo');", `${file} QR approve path must not directly enter demo`);
|
|
74
|
+
assertNotContains(followFunction, 'location.href = "/demo";', `${file} QR approve path must not assign demo href`);
|
|
75
|
+
assertNotContains(followFunction, "storeDemoHandoffIfNeeded('/demo'", `${file} QR approve path must not store demo handoff`);
|
|
76
|
+
|
|
77
|
+
assertContains(backFunction, "location.replace('/login?next=/demo');", `${file} back returns to login`);
|
|
78
|
+
assertNotContains(backFunction, "storeDemoHandoffIfNeeded", `${file} back must not store demo handoff`);
|
|
79
|
+
assertNotContains(backFunction, "location.replace('/demo')", `${file} back must not open demo`);
|
|
80
|
+
|
|
81
|
+
assertContains(html, "if (await followPairNextIfPresent(approveResponse, result, true)) return;", `${file} create caller stops after confirmation`);
|
|
82
|
+
assertContains(html, "if (await followPairNextIfPresent(approveResponse, result, false)) return;", `${file} sign-in caller stops after confirmation`);
|
|
83
|
+
assertContains(html, "storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');", `${file} requester QR status still stores handoff`);
|
|
84
|
+
assertContains(html, "location.replace(data.next);", `${file} requester QR status still redirects`);
|
|
85
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, true);", `${file} same-device create still stores demo handoff`);
|
|
86
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, false);", `${file} same-device sign-in still stores demo handoff`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log("kaleidoscope QR authenticator confirmation checks passed");
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
"agentId": "cc-mini",
|
|
3
3
|
"timezone": "America/Los_Angeles",
|
|
4
4
|
"maxTotalLines": 2000,
|
|
5
|
+
"stalenessDays": 14,
|
|
6
|
+
"dailyLogStalenessDays": 2,
|
|
5
7
|
"steps": {
|
|
6
8
|
"sharedContext": {
|
|
7
9
|
"path": "~/.openclaw/workspace/SHARED-CONTEXT.md",
|
|
8
10
|
"label": "SHARED-CONTEXT.md",
|
|
9
11
|
"stepNumber": 2,
|
|
10
|
-
"critical": true
|
|
12
|
+
"critical": true,
|
|
13
|
+
"maxLines": 80
|
|
11
14
|
},
|
|
12
15
|
"journals": {
|
|
13
16
|
"dir": "~/wipcomputerinc/team/cc-mini/documents/journals",
|
|
14
17
|
"label": "Most Recent Journal (Parker)",
|
|
15
18
|
"stepNumber": 3,
|
|
16
19
|
"maxLines": 80,
|
|
17
|
-
"strategy": "most-recent"
|
|
20
|
+
"strategy": "most-recent",
|
|
21
|
+
"stalenessDays": 14
|
|
18
22
|
},
|
|
19
23
|
"workspaceDailyLogs": {
|
|
20
24
|
"dir": "~/.openclaw/workspace/memory",
|
|
@@ -22,7 +26,8 @@
|
|
|
22
26
|
"stepNumber": 4,
|
|
23
27
|
"maxLines": 40,
|
|
24
28
|
"strategy": "daily-logs",
|
|
25
|
-
"days": ["today", "yesterday"]
|
|
29
|
+
"days": ["today", "yesterday"],
|
|
30
|
+
"dailyLogStalenessDays": 2
|
|
26
31
|
},
|
|
27
32
|
"fullHistory": {
|
|
28
33
|
"label": "Full History",
|
|
@@ -33,19 +38,22 @@
|
|
|
33
38
|
"path": "~/.ldm/agents/cc-mini/CONTEXT.md",
|
|
34
39
|
"label": "CC CONTEXT.md",
|
|
35
40
|
"stepNumber": 6,
|
|
36
|
-
"critical": true
|
|
41
|
+
"critical": true,
|
|
42
|
+
"maxLines": 60
|
|
37
43
|
},
|
|
38
44
|
"soul": {
|
|
39
45
|
"path": "~/.ldm/agents/cc-mini/SOUL.md",
|
|
40
46
|
"label": "CC SOUL.md",
|
|
41
|
-
"stepNumber": 7
|
|
47
|
+
"stepNumber": 7,
|
|
48
|
+
"maxLines": 80
|
|
42
49
|
},
|
|
43
50
|
"ccJournals": {
|
|
44
51
|
"dir": "~/.ldm/agents/cc-mini/memory/journals",
|
|
45
52
|
"label": "Most Recent CC Journal",
|
|
46
53
|
"stepNumber": 8,
|
|
47
54
|
"maxLines": 80,
|
|
48
|
-
"strategy": "most-recent"
|
|
55
|
+
"strategy": "most-recent",
|
|
56
|
+
"stalenessDays": 14
|
|
49
57
|
},
|
|
50
58
|
"ccDailyLog": {
|
|
51
59
|
"dir": "~/.ldm/agents/cc-mini/memory/daily",
|
|
@@ -53,13 +61,15 @@
|
|
|
53
61
|
"stepNumber": 9,
|
|
54
62
|
"maxLines": 60,
|
|
55
63
|
"strategy": "daily-logs",
|
|
56
|
-
"days": ["today", "yesterday"]
|
|
64
|
+
"days": ["today", "yesterday"],
|
|
65
|
+
"dailyLogStalenessDays": 2
|
|
57
66
|
},
|
|
58
67
|
"repoLocations": {
|
|
59
68
|
"path": "~/.claude/projects/-Users-lesa--openclaw/memory/repo-locations.md",
|
|
60
69
|
"label": "repo-locations.md",
|
|
61
70
|
"stepNumber": 10,
|
|
62
|
-
"critical": true
|
|
71
|
+
"critical": true,
|
|
72
|
+
"maxLines": 80
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
}
|