leerness 1.36.183 → 1.36.185
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/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/README.ko.md +15 -8
- package/README.md +25 -7
- package/SECURITY.md +66 -66
- package/bin/leerness.js +1184 -206
- package/docs/role-agent-routing-v2.md +411 -0
- package/lib/agents.js +1236 -100
- package/lib/diagnostics.js +184 -184
- package/lib/file-leases.js +845 -0
- package/lib/health.js +350 -350
- package/lib/io.js +152 -1
- package/lib/mcp-tools.js +121 -104
- package/lib/pure-utils.js +52 -4
- package/lib/referee.js +270 -270
- package/lib/review-request.js +339 -339
- package/lib/role-agent-schema.js +1179 -0
- package/lib/role-catalog.js +31 -1
- package/lib/role-fallback.js +1611 -0
- package/lib/role-store.js +730 -0
- package/lib/routing.js +109 -21
- package/lib/session-close.js +49 -1
- package/package.json +10 -5
- package/scripts/command-flags-probe.js +40 -3
- package/scripts/e2e.js +121 -44
- package/scripts/file-lease-probe.js +1139 -0
- package/scripts/i18n-next-cluster-probe.js +436 -5
- package/scripts/installed-cleanroom-probe.js +39 -12
- package/scripts/lint.js +73 -73
- package/scripts/provider-capacity-probe.js +425 -0
- package/scripts/release-runtime-probe.js +16 -1
- package/scripts/role-agent-schema-probe.js +834 -0
- package/scripts/role-fallback-probe.js +2010 -0
- package/scripts/role-store-loader-probe.js +882 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Exact-file leases are an opt-in coordination primitive. This probe deliberately
|
|
5
|
+
// distinguishes them from the discarded ambient scope advisory: only explicit calls
|
|
6
|
+
// may produce a conflict, unrelated files and separate roots remain independent, and
|
|
7
|
+
// read-only list/check calls must not rewrite project state.
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const cp = require('child_process');
|
|
14
|
+
const FL = require('../lib/file-leases');
|
|
15
|
+
|
|
16
|
+
const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
|
|
17
|
+
const LEGACY_DIR = '.' + 'harness';
|
|
18
|
+
const roots = [];
|
|
19
|
+
let total = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
|
|
22
|
+
const controlledEnvKeys = new Set([
|
|
23
|
+
'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
24
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID',
|
|
25
|
+
'LEERNESS_MCP_ADDRESS_EXPLICIT', 'LEERNESS_MCP_PROFILE', 'LEERNESS_NO_SESSION_PRESENCE',
|
|
26
|
+
'CI', 'GITHUB_ACTIONS', 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM',
|
|
27
|
+
].map(key => key.toLowerCase()));
|
|
28
|
+
const baseEnv = {
|
|
29
|
+
...process.env,
|
|
30
|
+
LEERNESS_OFFLINE: '1',
|
|
31
|
+
LEERNESS_NO_PROMPT: '1',
|
|
32
|
+
LEERNESS_NO_AUTOCHCP: '1',
|
|
33
|
+
LEERNESS_NO_AUTO_ROADMAP: '1',
|
|
34
|
+
LEERNESS_NO_AUTO_REVIEW: '1',
|
|
35
|
+
LEERNESS_NO_BANNER: '1',
|
|
36
|
+
};
|
|
37
|
+
for (const key of Object.keys(baseEnv)) {
|
|
38
|
+
if (controlledEnvKeys.has(key.toLowerCase())) delete baseEnv[key];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function check(label, condition, detail = '') {
|
|
42
|
+
total++;
|
|
43
|
+
const ok = !!condition;
|
|
44
|
+
process.stdout.write(`${ok ? '✓' : '✗'} ${label}${!ok && detail ? `\n ${detail}` : ''}\n`);
|
|
45
|
+
if (!ok) failed++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseJson(result) {
|
|
49
|
+
try { return JSON.parse(String(result.stdout || '').trim()); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function run(root, args, options = {}) {
|
|
53
|
+
return cp.spawnSync(process.execPath, [CLI, ...args], {
|
|
54
|
+
cwd: root,
|
|
55
|
+
env: { ...baseEnv, ...(options.env || {}) },
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: options.timeout || 180000,
|
|
58
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runLease(root, args, options = {}) {
|
|
63
|
+
return run(root, ['lease', ...args, '--path', root, '--json'], options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fresh(name) {
|
|
67
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `leerness-file-lease-${name}-`));
|
|
68
|
+
roots.push(root);
|
|
69
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: `lease-${name}`, version: '0.0.0' }) + '\n', 'utf8');
|
|
70
|
+
const initialized = run(root, ['init', root, '--yes', '--minimal', '--language', 'en', '--no-stale-check', '--json']);
|
|
71
|
+
if (initialized.status !== 0) {
|
|
72
|
+
throw new Error(`init failed for ${name}: ${initialized.status}\n${initialized.stdout}\n${initialized.stderr}`);
|
|
73
|
+
}
|
|
74
|
+
return root;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hash(value) {
|
|
78
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function snapshotTree(root, stateDir = '.leerness') {
|
|
82
|
+
const base = path.join(root, stateDir);
|
|
83
|
+
const rows = [];
|
|
84
|
+
function walk(dir) {
|
|
85
|
+
let entries = [];
|
|
86
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
87
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const full = path.join(dir, entry.name);
|
|
90
|
+
const rel = path.relative(base, full).split(path.sep).join('/');
|
|
91
|
+
const lst = fs.lstatSync(full);
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
rows.push({ rel, type: 'dir' });
|
|
94
|
+
walk(full);
|
|
95
|
+
} else if (entry.isSymbolicLink()) {
|
|
96
|
+
rows.push({ rel, type: 'link', target: fs.readlinkSync(full) });
|
|
97
|
+
} else {
|
|
98
|
+
const bytes = fs.readFileSync(full);
|
|
99
|
+
rows.push({ rel, type: 'file', size: bytes.length, sha256: hash(bytes), mtimeMs: lst.mtimeMs });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
walk(base);
|
|
104
|
+
return JSON.stringify(rows);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function storeFile(root) {
|
|
108
|
+
return path.join(root, '.leerness', 'cache', 'file-leases.json');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function storeBytes(root) {
|
|
112
|
+
try { return fs.readFileSync(storeFile(root)); } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeDirLink(target, link) {
|
|
116
|
+
fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rpcCall(name, args, id) {
|
|
120
|
+
return JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runMcp(root, calls) {
|
|
124
|
+
const input = [
|
|
125
|
+
JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: {
|
|
126
|
+
protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'file-lease-probe', version: '1' },
|
|
127
|
+
} }),
|
|
128
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
129
|
+
...calls,
|
|
130
|
+
].join('\n') + '\n';
|
|
131
|
+
const result = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--profile', 'full'], {
|
|
132
|
+
cwd: root,
|
|
133
|
+
input,
|
|
134
|
+
env: baseEnv,
|
|
135
|
+
encoding: 'utf8',
|
|
136
|
+
timeout: 300000,
|
|
137
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
138
|
+
});
|
|
139
|
+
const messages = String(result.stdout || '').split(/\r?\n/).filter(Boolean).map(line => {
|
|
140
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
141
|
+
}).filter(Boolean);
|
|
142
|
+
return { result, messages, byId: id => messages.find(message => message.id === id) || null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toolJson(batch, id) {
|
|
146
|
+
const text = batch.byId(id)?.result?.content?.[0]?.text;
|
|
147
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function runAsync(root, args) {
|
|
151
|
+
return new Promise(resolve => {
|
|
152
|
+
const child = cp.spawn(process.execPath, [CLI, ...args], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
env: baseEnv,
|
|
155
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
156
|
+
windowsHide: true,
|
|
157
|
+
});
|
|
158
|
+
let stdout = '';
|
|
159
|
+
let stderr = '';
|
|
160
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
161
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
162
|
+
child.on('error', error => resolve({ status: null, stdout, stderr, error }));
|
|
163
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
168
|
+
const alpha = 'lease-alpha-session';
|
|
169
|
+
const bravo = 'lease-bravo-session';
|
|
170
|
+
const charlie = 'lease-charlie-session';
|
|
171
|
+
const root = fresh('main');
|
|
172
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
173
|
+
fs.writeFileSync(path.join(root, 'src', 'a.js'), 'module.exports = 1;\n', 'utf8');
|
|
174
|
+
fs.writeFileSync(path.join(root, 'src', 'b.js'), 'module.exports = 2;\n', 'utf8');
|
|
175
|
+
|
|
176
|
+
const beforeRead = snapshotTree(root);
|
|
177
|
+
const emptyList = runLease(root, ['list']);
|
|
178
|
+
const emptyCheck = runLease(root, ['check', 'src/a.js']);
|
|
179
|
+
const afterRead = snapshotTree(root);
|
|
180
|
+
check('list/check are read-only and do not create a lease store',
|
|
181
|
+
emptyList.status === 0 && parseJson(emptyList)?.storeState === 'missing'
|
|
182
|
+
&& emptyCheck.status === 0 && parseJson(emptyCheck)?.available === true
|
|
183
|
+
&& !fs.existsSync(storeFile(root)) && beforeRead === afterRead,
|
|
184
|
+
`list=${emptyList.status}/${emptyList.stdout} check=${emptyCheck.status}/${emptyCheck.stdout}`);
|
|
185
|
+
|
|
186
|
+
const acquiredA = runLease(root, ['acquire', 'src/a.js', '--session', alpha, '--ttl', '300', '--note', 'alpha owns a']);
|
|
187
|
+
const a = parseJson(acquiredA);
|
|
188
|
+
check('session A acquires one exact file',
|
|
189
|
+
acquiredA.status === 0 && a?.action === 'acquired' && /^lease-[0-9a-f]{16}$/.test(a?.lease?.leaseId || '')
|
|
190
|
+
&& a?.lease?.file === 'src/a.js' && a?.lease?.sessionKey === alpha
|
|
191
|
+
&& a?.target?.exists === true && a?.target?.identityObserved === true
|
|
192
|
+
&& a?.advisoryOnly === true && a?.ambientWarnings === false,
|
|
193
|
+
acquiredA.stdout || acquiredA.stderr);
|
|
194
|
+
|
|
195
|
+
const acquiredSibling = runLease(root, ['acquire', 'src/b.js', '--session', bravo, '--ttl', '300']);
|
|
196
|
+
check('unrelated sibling file does not conflict',
|
|
197
|
+
acquiredSibling.status === 0 && parseJson(acquiredSibling)?.action === 'acquired',
|
|
198
|
+
acquiredSibling.stdout || acquiredSibling.stderr);
|
|
199
|
+
|
|
200
|
+
const beforeConflict = storeBytes(root);
|
|
201
|
+
const conflict = runLease(root, ['acquire', 'src/a.js', '--session', bravo, '--ttl', '300']);
|
|
202
|
+
const conflictJson = parseJson(conflict);
|
|
203
|
+
const afterConflict = storeBytes(root);
|
|
204
|
+
check('same exact file conflicts synchronously and preserves store bytes',
|
|
205
|
+
conflict.status === 1 && conflictJson?.code === 'lease_conflict'
|
|
206
|
+
&& conflictJson?.details?.conflict?.sessionKey === alpha
|
|
207
|
+
&& Buffer.compare(beforeConflict, afterConflict) === 0,
|
|
208
|
+
conflict.stdout || conflict.stderr);
|
|
209
|
+
|
|
210
|
+
const renewed = runLease(root, ['acquire', 'src/a.js', '--session', alpha, '--ttl', '600', '--note', 'renewed']);
|
|
211
|
+
const renewedJson = parseJson(renewed);
|
|
212
|
+
check('same-session reacquire renews the existing lease identity',
|
|
213
|
+
renewed.status === 0 && renewedJson?.action === 'renewed'
|
|
214
|
+
&& renewedJson?.lease?.leaseId === a?.lease?.leaseId
|
|
215
|
+
&& renewedJson?.lease?.acquiredAt === a?.lease?.acquiredAt
|
|
216
|
+
&& renewedJson?.lease?.expiresAt !== a?.lease?.expiresAt
|
|
217
|
+
&& renewedJson?.lease?.note === 'renewed',
|
|
218
|
+
renewed.stdout || renewed.stderr);
|
|
219
|
+
|
|
220
|
+
const ownCheck = runLease(root, ['check', 'src/a.js', '--session', alpha]);
|
|
221
|
+
const peerCheck = runLease(root, ['check', 'src/a.js', '--session', bravo]);
|
|
222
|
+
check('check distinguishes self ownership from peer conflict',
|
|
223
|
+
ownCheck.status === 0 && parseJson(ownCheck)?.ownedBySession === true
|
|
224
|
+
&& peerCheck.status === 1 && parseJson(peerCheck)?.available === false
|
|
225
|
+
&& parseJson(peerCheck)?.lease?.sessionKey === alpha,
|
|
226
|
+
`own=${ownCheck.stdout} peer=${peerCheck.stdout}`);
|
|
227
|
+
|
|
228
|
+
const filtered = runLease(root, ['list', '--session', alpha]);
|
|
229
|
+
const allActive = runLease(root, ['list']);
|
|
230
|
+
const ambientSessionList = runLease(root, ['list'], { env: { LEERNESS_SESSION_ID: alpha } });
|
|
231
|
+
const ambientSessionCheck = runLease(root, ['check', 'src/a.js'], { env: { LEERNESS_SESSION_ID: alpha } });
|
|
232
|
+
check('list filters only when --session is explicit and never infers ambient scope',
|
|
233
|
+
filtered.status === 0 && parseJson(filtered)?.shown === 1
|
|
234
|
+
&& parseJson(filtered)?.leases?.[0]?.sessionKey === alpha
|
|
235
|
+
&& allActive.status === 0 && parseJson(allActive)?.active === 2
|
|
236
|
+
&& parseJson(allActive)?.ambientWarnings === false
|
|
237
|
+
&& ambientSessionList.status === 0 && parseJson(ambientSessionList)?.shown === 2
|
|
238
|
+
&& ambientSessionCheck.status === 1 && parseJson(ambientSessionCheck)?.ownedBySession === false,
|
|
239
|
+
`filtered=${filtered.stdout} all=${allActive.stdout} ambient=${ambientSessionList.stdout} check=${ambientSessionCheck.stdout}`);
|
|
240
|
+
|
|
241
|
+
const beforeReadExisting = snapshotTree(root);
|
|
242
|
+
const bytesBeforeRead = storeBytes(root);
|
|
243
|
+
const readList = runLease(root, ['list', '--all', '--language', 'en']);
|
|
244
|
+
const readCheck = runLease(root, ['check', 'src/b.js', '--session', bravo, '--language', 'ko']);
|
|
245
|
+
const bytesAfterRead = storeBytes(root);
|
|
246
|
+
const afterReadExisting = snapshotTree(root);
|
|
247
|
+
check('list/check do not rewrite an existing store or any tracked project state',
|
|
248
|
+
readList.status === 0 && readCheck.status === 0
|
|
249
|
+
&& Buffer.compare(bytesBeforeRead, bytesAfterRead) === 0
|
|
250
|
+
&& beforeReadExisting === afterReadExisting,
|
|
251
|
+
`list=${readList.status} check=${readCheck.status}`);
|
|
252
|
+
|
|
253
|
+
const wrongOwnerBefore = storeBytes(root);
|
|
254
|
+
const wrongRelease = runLease(root, ['release', a.lease.leaseId, '--session', bravo]);
|
|
255
|
+
const wrongOwnerAfter = storeBytes(root);
|
|
256
|
+
check('only the owning session can release a lease',
|
|
257
|
+
wrongRelease.status === 1 && parseJson(wrongRelease)?.code === 'lease_owner_mismatch'
|
|
258
|
+
&& Buffer.compare(wrongOwnerBefore, wrongOwnerAfter) === 0,
|
|
259
|
+
wrongRelease.stdout || wrongRelease.stderr);
|
|
260
|
+
|
|
261
|
+
const controlAction = run(root, ['lease', 'bad\n\u001b[31mFAKE', '--path', root, '--json']);
|
|
262
|
+
const controlPayload = parseJson(controlAction);
|
|
263
|
+
const controlFileBefore = storeBytes(root);
|
|
264
|
+
const controlFile = runLease(root, ['acquire', 'bad\nfile.js', '--session', alpha, '--ttl', '300']);
|
|
265
|
+
const longFile = runLease(root, ['acquire', 'x'.repeat(2049), '--session', alpha, '--ttl', '300']);
|
|
266
|
+
check('lease diagnostics and target paths reject terminal controls and overlong names',
|
|
267
|
+
controlAction.status === 1 && controlPayload?.code === 'unknown_subcommand'
|
|
268
|
+
&& !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(controlPayload?.error || '')
|
|
269
|
+
&& controlFile.status === 1 && parseJson(controlFile)?.code === 'invalid_file'
|
|
270
|
+
&& longFile.status === 1 && parseJson(longFile)?.code === 'invalid_file'
|
|
271
|
+
&& Buffer.compare(controlFileBefore, storeBytes(root)) === 0,
|
|
272
|
+
`action=${controlAction.stdout} file=${controlFile.stdout} long=${longFile.stdout}`);
|
|
273
|
+
|
|
274
|
+
const foreignFlagBefore = snapshotTree(root);
|
|
275
|
+
const foreignSession = run(root, ['status', root, '--session', alpha, '--json']);
|
|
276
|
+
const foreignTtl = run(root, ['status', root, '--ttl', '300', '--json']);
|
|
277
|
+
const foreignFlagAfter = snapshotTree(root);
|
|
278
|
+
check('lease-only flags are rejected by unrelated commands before any write',
|
|
279
|
+
foreignSession.status === 1 && parseJson(foreignSession)?.code === 'unknown_flag'
|
|
280
|
+
&& foreignTtl.status === 1 && parseJson(foreignTtl)?.code === 'unknown_flag'
|
|
281
|
+
&& foreignFlagBefore === foreignFlagAfter,
|
|
282
|
+
`session=${foreignSession.stdout} ttl=${foreignTtl.stdout}`);
|
|
283
|
+
|
|
284
|
+
const invalidBefore = storeBytes(root);
|
|
285
|
+
const duplicateLeaseFlags = [
|
|
286
|
+
runLease(root, ['acquire', 'src/duplicate-session.js', '--session', alpha, '--session', bravo, '--ttl', '300']),
|
|
287
|
+
runLease(root, ['acquire', 'src/duplicate-ttl.js', '--session', alpha, '--ttl', '300', '--ttl', '301']),
|
|
288
|
+
runLease(root, ['acquire', 'src/duplicate-note.js', '--session', alpha, '--ttl', '300', '--note', 'first', '--note', 'second']),
|
|
289
|
+
];
|
|
290
|
+
check('security-sensitive lease values are singletons and duplicates cannot mutate state',
|
|
291
|
+
duplicateLeaseFlags.every(result => result.status === 1 && parseJson(result)?.code === 'duplicate_flag')
|
|
292
|
+
&& Buffer.compare(invalidBefore, storeBytes(root)) === 0,
|
|
293
|
+
duplicateLeaseFlags.map(result => result.stdout || result.stderr).join(' | '));
|
|
294
|
+
const extraArgs = [
|
|
295
|
+
runLease(root, ['list', 'unexpected']),
|
|
296
|
+
runLease(root, ['check', 'src/a.js', 'unexpected', '--session', alpha]),
|
|
297
|
+
runLease(root, ['acquire', 'src/extra.js', 'unexpected', '--session', alpha, '--ttl', '300']),
|
|
298
|
+
];
|
|
299
|
+
const invalidTtls = ['29', '1801', '30.5'].map(ttl => runLease(root,
|
|
300
|
+
['acquire', 'src/ttl.js', '--session', charlie, '--ttl', ttl]));
|
|
301
|
+
const missingSession = runLease(root, ['acquire', 'src/session.js']);
|
|
302
|
+
const invalidAmbientSession = runLease(root, ['acquire', 'src/session-env.js'], {
|
|
303
|
+
env: { LEERNESS_SESSION_ID: 'bad', CODEX_THREAD_ID: alpha },
|
|
304
|
+
});
|
|
305
|
+
const emptyAction = run(root, ['lease', '', '--path', root, '--json']);
|
|
306
|
+
const invalidAfter = storeBytes(root);
|
|
307
|
+
check('arity, TTL bounds, and stable session identity fail closed before mutation',
|
|
308
|
+
extraArgs.every(result => result.status === 1 && parseJson(result)?.code === 'too_many_arguments')
|
|
309
|
+
&& invalidTtls.every(result => result.status === 1 && parseJson(result)?.code === 'invalid_flag_value')
|
|
310
|
+
&& missingSession.status === 1 && parseJson(missingSession)?.code === 'invalid_session_key'
|
|
311
|
+
&& invalidAmbientSession.status === 1 && parseJson(invalidAmbientSession)?.code === 'invalid_session_key'
|
|
312
|
+
&& emptyAction.status === 1 && parseJson(emptyAction)?.code === 'unknown_subcommand'
|
|
313
|
+
&& Buffer.compare(invalidBefore, invalidAfter) === 0,
|
|
314
|
+
`extra=${extraArgs.map(r => r.stdout).join(' | ')} ttls=${invalidTtls.map(r => r.stdout).join(' | ')} session=${missingSession.stdout} env=${invalidAmbientSession.stdout} action=${emptyAction.stdout}`);
|
|
315
|
+
|
|
316
|
+
const released = runLease(root, ['release', a.lease.leaseId, '--session', alpha]);
|
|
317
|
+
const afterReleaseAcquire = runLease(root, ['acquire', 'src/a.js', '--session', bravo, '--ttl', '300']);
|
|
318
|
+
check('owner release immediately makes the exact file available to another session',
|
|
319
|
+
released.status === 0 && parseJson(released)?.action === 'released'
|
|
320
|
+
&& afterReleaseAcquire.status === 0 && parseJson(afterReleaseAcquire)?.lease?.sessionKey === bravo,
|
|
321
|
+
`release=${released.stdout} acquire=${afterReleaseAcquire.stdout}`);
|
|
322
|
+
|
|
323
|
+
const missingFile = runLease(root, ['acquire', 'src/future.js', '--session', charlie, '--ttl', '300']);
|
|
324
|
+
const leadingSpaceName = ' leading-space.js';
|
|
325
|
+
fs.writeFileSync(path.join(root, leadingSpaceName), 'space\n', 'utf8');
|
|
326
|
+
const leadingSpaceLease = runLease(root, ['acquire', leadingSpaceName, '--session', alpha, '--ttl', '300']);
|
|
327
|
+
check('missing targets are supported and exact filename whitespace is preserved',
|
|
328
|
+
missingFile.status === 0 && parseJson(missingFile)?.target?.exists === false
|
|
329
|
+
&& parseJson(missingFile)?.lease?.file === 'src/future.js'
|
|
330
|
+
&& leadingSpaceLease.status === 0 && parseJson(leadingSpaceLease)?.lease?.file === leadingSpaceName,
|
|
331
|
+
`missing=${missingFile.stdout} space=${leadingSpaceLease.stdout}`);
|
|
332
|
+
if (process.platform === 'win32') {
|
|
333
|
+
const windowsAliasBefore = storeBytes(root);
|
|
334
|
+
const trailingDot = runLease(root, ['acquire', 'windows-alias.js.', '--session', alpha, '--ttl', '300']);
|
|
335
|
+
const ads = runLease(root, ['acquire', 'windows-alias.js:stream', '--session', alpha, '--ttl', '300']);
|
|
336
|
+
const invalidCharacters = ['<', '>', '"', '|', '?', '*'].map(character =>
|
|
337
|
+
runLease(root, ['acquire', `windows-${character}-alias.js`, '--session', alpha, '--ttl', '300']));
|
|
338
|
+
check('Windows lexical aliases, invalid filename characters, and alternate data streams are rejected before mutation',
|
|
339
|
+
trailingDot.status === 1 && parseJson(trailingDot)?.code === 'invalid_file'
|
|
340
|
+
&& ads.status === 1 && parseJson(ads)?.code === 'invalid_file'
|
|
341
|
+
&& invalidCharacters.every(result => result.status === 1 && parseJson(result)?.code === 'invalid_file')
|
|
342
|
+
&& Buffer.compare(windowsAliasBefore, storeBytes(root)) === 0,
|
|
343
|
+
`dot=${trailingDot.stdout} ads=${ads.stdout} chars=${invalidCharacters.map(result => result.stdout).join(' | ')}`);
|
|
344
|
+
const caseBase = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-case-sensitive-root-'));
|
|
345
|
+
let caseSensitiveSupported = false;
|
|
346
|
+
let caseEscapeError = null;
|
|
347
|
+
let unicodeCaseEscapeError = null;
|
|
348
|
+
try {
|
|
349
|
+
const enabled = cp.spawnSync('fsutil.exe', ['file', 'SetCaseSensitiveInfo', caseBase, 'enable'], {
|
|
350
|
+
encoding: 'utf8', windowsHide: true, timeout: 10000,
|
|
351
|
+
});
|
|
352
|
+
if (enabled.status === 0) {
|
|
353
|
+
const upperRoot = path.join(caseBase, 'Repo');
|
|
354
|
+
const lowerPeer = path.join(caseBase, 'repo');
|
|
355
|
+
fs.mkdirSync(path.join(upperRoot, '.leerness'), { recursive: true });
|
|
356
|
+
fs.mkdirSync(lowerPeer);
|
|
357
|
+
const peerFile = path.join(lowerPeer, 'outside.js');
|
|
358
|
+
fs.writeFileSync(peerFile, 'outside\n', 'utf8');
|
|
359
|
+
caseSensitiveSupported = fs.existsSync(path.join(caseBase, 'Repo')) && fs.existsSync(path.join(caseBase, 'repo'));
|
|
360
|
+
if (caseSensitiveSupported) {
|
|
361
|
+
try { FL.resolveTarget(upperRoot, peerFile); }
|
|
362
|
+
catch (error) { caseEscapeError = error; }
|
|
363
|
+
const unicodeUpperRoot = path.join(caseBase, '\u00c4');
|
|
364
|
+
const unicodeLowerPeer = path.join(caseBase, '\u00e4');
|
|
365
|
+
fs.mkdirSync(unicodeUpperRoot);
|
|
366
|
+
fs.mkdirSync(unicodeLowerPeer);
|
|
367
|
+
const unicodePeerFile = path.join(unicodeLowerPeer, 'outside.js');
|
|
368
|
+
fs.writeFileSync(unicodePeerFile, 'outside unicode\n', 'utf8');
|
|
369
|
+
try { FL.resolveTarget(unicodeUpperRoot, unicodePeerFile); }
|
|
370
|
+
catch (error) { unicodeCaseEscapeError = error; }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
check('Windows case-sensitive sibling roots cannot bypass project containment',
|
|
374
|
+
!caseSensitiveSupported || (caseEscapeError?.code === 'path_escape'
|
|
375
|
+
&& unicodeCaseEscapeError?.code === 'path_escape'),
|
|
376
|
+
JSON.stringify({ caseSensitiveSupported, error: caseEscapeError && caseEscapeError.code, unicodeError: unicodeCaseEscapeError && unicodeCaseEscapeError.code }));
|
|
377
|
+
} finally {
|
|
378
|
+
fs.rmSync(caseBase, { recursive: true, force: true });
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
const literalBackslash = 'literal\\name.js';
|
|
382
|
+
fs.writeFileSync(path.join(root, literalBackslash), 'backslash\n', 'utf8');
|
|
383
|
+
const backslashLease = runLease(root, ['acquire', literalBackslash, '--session', alpha, '--ttl', '300']);
|
|
384
|
+
const backslashList = runLease(root, ['list', '--all']);
|
|
385
|
+
check('POSIX literal backslashes round-trip without self-invalidating the store',
|
|
386
|
+
backslashLease.status === 0 && parseJson(backslashLease)?.lease?.file === literalBackslash
|
|
387
|
+
&& backslashList.status === 0,
|
|
388
|
+
`lease=${backslashLease.stdout} list=${backslashList.stdout}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const legacyRoot = fresh('legacy-preflight');
|
|
392
|
+
fs.renameSync(path.join(legacyRoot, '.leerness'), path.join(legacyRoot, LEGACY_DIR));
|
|
393
|
+
const legacyBefore = snapshotTree(legacyRoot, LEGACY_DIR);
|
|
394
|
+
const legacyBadFlag = run(legacyRoot, ['lease', 'acquire', 'x.js', '--session', alpha, '--all', '--path', legacyRoot, '--json']);
|
|
395
|
+
const legacyBadTtl = run(legacyRoot, ['lease', 'acquire', 'x.js', '--session', alpha, '--ttl', '29', '--path', legacyRoot, '--json']);
|
|
396
|
+
const legacyBadSession = run(legacyRoot, ['lease', 'acquire', 'x.js', '--session', 'bad', '--ttl', '300', '--path', legacyRoot, '--json']);
|
|
397
|
+
const legacyBadAction = run(legacyRoot, ['lease', 'typo', '--path', legacyRoot, '--json']);
|
|
398
|
+
const legacyMissingFile = run(legacyRoot, ['lease', 'acquire', '--session', alpha, '--path', legacyRoot, '--json']);
|
|
399
|
+
const legacyExtraArg = run(legacyRoot, ['lease', 'list', 'unexpected', '--path', legacyRoot, '--json']);
|
|
400
|
+
const legacyMissingSession = run(legacyRoot, ['lease', 'acquire', 'x.js', '--path', legacyRoot, '--json']);
|
|
401
|
+
const legacyBadTarget = run(legacyRoot, ['lease', 'acquire', '../outside.js', '--session', alpha, '--path', legacyRoot, '--json']);
|
|
402
|
+
const legacyBadLeaseId = run(legacyRoot, ['lease', 'release', 'bad-id', '--session', alpha, '--path', legacyRoot, '--json']);
|
|
403
|
+
check('invalid lease flags, values, actions, and arity are rejected before legacy workspace migration',
|
|
404
|
+
legacyBadFlag.status === 1 && parseJson(legacyBadFlag)?.code === 'unknown_flag'
|
|
405
|
+
&& legacyBadTtl.status === 1 && parseJson(legacyBadTtl)?.code === 'invalid_flag_value'
|
|
406
|
+
&& legacyBadSession.status === 1 && parseJson(legacyBadSession)?.code === 'invalid_flag_value'
|
|
407
|
+
&& legacyBadAction.status === 1 && parseJson(legacyBadAction)?.code === 'unknown_subcommand'
|
|
408
|
+
&& legacyMissingFile.status === 1 && parseJson(legacyMissingFile)?.code === 'missing_file'
|
|
409
|
+
&& legacyExtraArg.status === 1 && parseJson(legacyExtraArg)?.code === 'too_many_arguments'
|
|
410
|
+
&& legacyMissingSession.status === 1 && parseJson(legacyMissingSession)?.code === 'invalid_session_key'
|
|
411
|
+
&& legacyBadTarget.status === 1 && parseJson(legacyBadTarget)?.code === 'path_escape'
|
|
412
|
+
&& legacyBadLeaseId.status === 1 && parseJson(legacyBadLeaseId)?.code === 'invalid_lease_id'
|
|
413
|
+
&& !fs.existsSync(path.join(legacyRoot, '.leerness'))
|
|
414
|
+
&& fs.existsSync(path.join(legacyRoot, LEGACY_DIR))
|
|
415
|
+
&& legacyBefore === snapshotTree(legacyRoot, LEGACY_DIR),
|
|
416
|
+
`flag=${legacyBadFlag.stdout} ttl=${legacyBadTtl.stdout} session=${legacyBadSession.stdout} action=${legacyBadAction.stdout} missing=${legacyMissingFile.stdout} extra=${legacyExtraArg.stdout} missingSession=${legacyMissingSession.stdout} target=${legacyBadTarget.stdout} leaseId=${legacyBadLeaseId.stdout}`);
|
|
417
|
+
|
|
418
|
+
const traversalBefore = storeBytes(root);
|
|
419
|
+
const unsafeFormatting = ['src/bidi-\u202efile.js', 'src/isolate-\u2066file.js', 'src/shorthand-\u{1bca0}file.js']
|
|
420
|
+
.map(file => runLease(root, ['acquire', file, '--session', alpha, '--ttl', '300']));
|
|
421
|
+
check('bidi and default-ignorable path formatting is rejected before mutation',
|
|
422
|
+
unsafeFormatting.every(result => result.status === 1 && parseJson(result)?.code === 'invalid_file')
|
|
423
|
+
&& Buffer.compare(traversalBefore, storeBytes(root)) === 0,
|
|
424
|
+
unsafeFormatting.map(result => result.stdout || result.stderr).join(' | '));
|
|
425
|
+
|
|
426
|
+
const traversal = runLease(root, ['acquire', '../outside.js', '--session', alpha, '--ttl', '300']);
|
|
427
|
+
check('relative traversal is rejected without changing lease state',
|
|
428
|
+
traversal.status === 1 && parseJson(traversal)?.code === 'path_escape'
|
|
429
|
+
&& Buffer.compare(traversalBefore, storeBytes(root)) === 0,
|
|
430
|
+
traversal.stdout || traversal.stderr);
|
|
431
|
+
|
|
432
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-file-lease-outside-'));
|
|
433
|
+
roots.push(outside);
|
|
434
|
+
fs.writeFileSync(path.join(outside, 'outside.js'), 'outside\n', 'utf8');
|
|
435
|
+
const outwardLink = path.join(root, 'outward-link');
|
|
436
|
+
makeDirLink(outside, outwardLink);
|
|
437
|
+
const outward = runLease(root, ['acquire', 'outward-link/outside.js', '--session', alpha, '--ttl', '300']);
|
|
438
|
+
check('outward symlink or junction targets are rejected',
|
|
439
|
+
outward.status === 1 && parseJson(outward)?.code === 'path_escape',
|
|
440
|
+
outward.stdout || outward.stderr);
|
|
441
|
+
|
|
442
|
+
const realDir = path.join(root, 'real-dir');
|
|
443
|
+
fs.mkdirSync(realDir, { recursive: true });
|
|
444
|
+
fs.writeFileSync(path.join(realDir, 'inside.js'), 'inside\n', 'utf8');
|
|
445
|
+
const inwardLink = path.join(root, 'inward-link');
|
|
446
|
+
makeDirLink(realDir, inwardLink);
|
|
447
|
+
const inwardA = runLease(root, ['acquire', 'inward-link/inside.js', '--session', alpha, '--ttl', '300']);
|
|
448
|
+
const inwardB = runLease(root, ['acquire', 'real-dir/inside.js', '--session', bravo, '--ttl', '300']);
|
|
449
|
+
check('inward aliases canonicalize to one exact-file lease',
|
|
450
|
+
inwardA.status === 0 && parseJson(inwardA)?.lease?.file === 'real-dir/inside.js'
|
|
451
|
+
&& inwardB.status === 1 && parseJson(inwardB)?.code === 'lease_conflict',
|
|
452
|
+
`first=${inwardA.stdout} second=${inwardB.stdout}`);
|
|
453
|
+
|
|
454
|
+
let hardlinkSupported = true;
|
|
455
|
+
let hardlinkOk = false;
|
|
456
|
+
const hardOriginal = path.join(root, 'hard-original.js');
|
|
457
|
+
const hardAlias = path.join(root, 'hard-alias.js');
|
|
458
|
+
fs.writeFileSync(hardOriginal, 'hardlink\n', 'utf8');
|
|
459
|
+
try { fs.linkSync(hardOriginal, hardAlias); } catch { hardlinkSupported = false; }
|
|
460
|
+
if (hardlinkSupported) {
|
|
461
|
+
const hardA = runLease(root, ['acquire', 'hard-original.js', '--session', alpha, '--ttl', '300']);
|
|
462
|
+
const hardB = runLease(root, ['acquire', 'hard-alias.js', '--session', bravo, '--ttl', '300']);
|
|
463
|
+
hardlinkOk = hardA.status === 0 && hardB.status === 1 && parseJson(hardB)?.code === 'lease_conflict';
|
|
464
|
+
} else hardlinkOk = true;
|
|
465
|
+
check('hard-link aliases share file identity when the filesystem exposes it', hardlinkOk,
|
|
466
|
+
hardlinkSupported ? 'hard-link alias did not conflict' : 'hard-link creation unavailable; identity path skipped');
|
|
467
|
+
|
|
468
|
+
let appearedAliasSupported = true;
|
|
469
|
+
let appearedAliasOk = false;
|
|
470
|
+
const appearedRoot = fresh('appeared-alias');
|
|
471
|
+
const missingLease = runLease(appearedRoot, ['acquire', 'appeared.js', '--session', alpha, '--ttl', '300']);
|
|
472
|
+
fs.writeFileSync(path.join(appearedRoot, 'appeared.js'), 'appeared\n', 'utf8');
|
|
473
|
+
try { fs.linkSync(path.join(appearedRoot, 'appeared.js'), path.join(appearedRoot, 'appeared-alias.js')); }
|
|
474
|
+
catch { appearedAliasSupported = false; }
|
|
475
|
+
if (appearedAliasSupported) {
|
|
476
|
+
const aliasConflict = runLease(appearedRoot, ['acquire', 'appeared-alias.js', '--session', bravo, '--ttl', '300']);
|
|
477
|
+
const ownerRenew = runLease(appearedRoot, ['acquire', 'appeared-alias.js', '--session', alpha, '--ttl', '300']);
|
|
478
|
+
const afterMerge = runLease(appearedRoot, ['list', '--all']);
|
|
479
|
+
appearedAliasOk = missingLease.status === 0
|
|
480
|
+
&& aliasConflict.status === 1 && parseJson(aliasConflict)?.code === 'lease_conflict'
|
|
481
|
+
&& ownerRenew.status === 0 && parseJson(ownerRenew)?.action === 'renewed'
|
|
482
|
+
&& parseJson(afterMerge)?.totalStored === 1;
|
|
483
|
+
} else appearedAliasOk = true;
|
|
484
|
+
check('a file that appears after leasing cannot evade ownership through a new hard-link alias', appearedAliasOk,
|
|
485
|
+
appearedAliasSupported ? 'appeared-file alias did not converge' : 'hard-link creation unavailable; appeared-file identity path skipped');
|
|
486
|
+
|
|
487
|
+
const replacedRoot = fresh('replaced-alias');
|
|
488
|
+
fs.writeFileSync(path.join(replacedRoot, 'replaced.js'), 'old inode\n', 'utf8');
|
|
489
|
+
const replacedOwner = FL.acquire(replacedRoot, 'replaced.js', alpha, {
|
|
490
|
+
ttlSeconds: 300, idFactory: () => 'abababababababab', withLock: (_target, fn) => fn(),
|
|
491
|
+
});
|
|
492
|
+
const replacement = path.join(replacedRoot, 'replacement.tmp');
|
|
493
|
+
fs.writeFileSync(replacement, 'new inode\n', 'utf8');
|
|
494
|
+
try { fs.renameSync(replacement, path.join(replacedRoot, 'replaced.js')); }
|
|
495
|
+
catch {
|
|
496
|
+
fs.rmSync(path.join(replacedRoot, 'replaced.js'));
|
|
497
|
+
fs.renameSync(replacement, path.join(replacedRoot, 'replaced.js'));
|
|
498
|
+
}
|
|
499
|
+
let replacedLinkSupported = true;
|
|
500
|
+
let replacedAliasError = null;
|
|
501
|
+
try { fs.linkSync(path.join(replacedRoot, 'replaced.js'), path.join(replacedRoot, 'replaced-alias.js')); }
|
|
502
|
+
catch { replacedLinkSupported = false; }
|
|
503
|
+
if (replacedLinkSupported) {
|
|
504
|
+
try {
|
|
505
|
+
FL.acquire(replacedRoot, 'replaced-alias.js', bravo, {
|
|
506
|
+
ttlSeconds: 300, withLock: (_target, fn) => fn(),
|
|
507
|
+
});
|
|
508
|
+
} catch (error) { replacedAliasError = error; }
|
|
509
|
+
}
|
|
510
|
+
check('atomic replacement cannot detach a path lease from a new hard-link alias',
|
|
511
|
+
!replacedLinkSupported || (replacedOwner.action === 'acquired'
|
|
512
|
+
&& replacedAliasError?.code === 'lease_conflict'
|
|
513
|
+
&& replacedAliasError?.details?.conflict?.sessionKey === alpha),
|
|
514
|
+
replacedLinkSupported
|
|
515
|
+
? JSON.stringify(replacedAliasError, Object.getOwnPropertyNames(replacedAliasError || {}))
|
|
516
|
+
: 'hard links unsupported on this filesystem; replacement identity path skipped');
|
|
517
|
+
|
|
518
|
+
const caseRoot = fresh('case-semantics');
|
|
519
|
+
const caseDir = path.join(caseRoot, 'case-dir');
|
|
520
|
+
fs.mkdirSync(caseDir);
|
|
521
|
+
fs.writeFileSync(path.join(caseDir, 'CaseSentinel'), 'case probe\n', 'utf8');
|
|
522
|
+
let caseInsensitive = false;
|
|
523
|
+
try {
|
|
524
|
+
const original = fs.statSync(path.join(caseDir, 'CaseSentinel'), { bigint: true });
|
|
525
|
+
const alternate = fs.statSync(path.join(caseDir, 'caseSentinel'), { bigint: true });
|
|
526
|
+
caseInsensitive = original.dev === alternate.dev && original.ino === alternate.ino;
|
|
527
|
+
} catch {}
|
|
528
|
+
const caseFirst = FL.acquire(caseRoot, 'case-dir/Foo.js', alpha, {
|
|
529
|
+
ttlSeconds: 300, idFactory: () => 'cdcdcdcdcdcdcdcd', withLock: (_target, fn) => fn(),
|
|
530
|
+
});
|
|
531
|
+
let caseSecond = null;
|
|
532
|
+
let caseSecondError = null;
|
|
533
|
+
try {
|
|
534
|
+
caseSecond = FL.acquire(caseRoot, 'case-dir/foo.js', bravo, {
|
|
535
|
+
ttlSeconds: 300, idFactory: () => 'efefefefefefefef', withLock: (_target, fn) => fn(),
|
|
536
|
+
});
|
|
537
|
+
} catch (error) { caseSecondError = error; }
|
|
538
|
+
check('missing-path lease case identity follows the containing filesystem behavior',
|
|
539
|
+
caseFirst.action === 'acquired'
|
|
540
|
+
&& (caseInsensitive
|
|
541
|
+
? caseSecondError?.code === 'lease_conflict'
|
|
542
|
+
: caseSecond?.action === 'acquired'),
|
|
543
|
+
JSON.stringify({ caseInsensitive, caseSecond, caseSecondError }, Object.getOwnPropertyNames(caseSecondError || {})));
|
|
544
|
+
|
|
545
|
+
const emptyCaseRoot = fresh('empty-case-semantics');
|
|
546
|
+
const emptyCaseDir = path.join(emptyCaseRoot, 'empty-dir');
|
|
547
|
+
fs.mkdirSync(emptyCaseDir);
|
|
548
|
+
const emptyCaseFirst = FL.acquire(emptyCaseRoot, 'empty-dir/Foo.js', alpha, {
|
|
549
|
+
ttlSeconds: 300, idFactory: () => 'abababababababab', withLock: (_target, fn) => fn(),
|
|
550
|
+
});
|
|
551
|
+
let emptyCaseError = null;
|
|
552
|
+
try {
|
|
553
|
+
FL.acquire(emptyCaseRoot, 'empty-dir/foo.js', bravo, {
|
|
554
|
+
ttlSeconds: 300, idFactory: () => 'bcbcbcbcbcbcbcbc', withLock: (_target, fn) => fn(),
|
|
555
|
+
});
|
|
556
|
+
} catch (error) { emptyCaseError = error; }
|
|
557
|
+
let unicodeAliasError = null;
|
|
558
|
+
try {
|
|
559
|
+
FL.acquire(emptyCaseRoot, 'empty-dir/Fe\u0301e.js', bravo, {
|
|
560
|
+
ttlSeconds: 300, idFactory: () => 'cacacacacacacaca', withLock: (_target, fn) => fn(),
|
|
561
|
+
});
|
|
562
|
+
FL.acquire(emptyCaseRoot, 'empty-dir/F\u00e9e.js', alpha, {
|
|
563
|
+
ttlSeconds: 300, idFactory: () => 'dadadadadadadada', withLock: (_target, fn) => fn(),
|
|
564
|
+
});
|
|
565
|
+
} catch (error) { unicodeAliasError = error; }
|
|
566
|
+
check('unobservable missing-file case and Unicode aliases conservatively share one lease identity',
|
|
567
|
+
emptyCaseFirst.action === 'acquired'
|
|
568
|
+
&& emptyCaseError?.code === 'lease_conflict'
|
|
569
|
+
&& unicodeAliasError?.code === 'lease_conflict',
|
|
570
|
+
JSON.stringify({ emptyCaseError, unicodeAliasError }, Object.getOwnPropertyNames(emptyCaseError || {})));
|
|
571
|
+
|
|
572
|
+
const caseFoldRoot = fresh('unicode-casefold');
|
|
573
|
+
fs.mkdirSync(path.join(caseFoldRoot, 'empty-dir'));
|
|
574
|
+
const sigmaLease = FL.acquire(caseFoldRoot, 'empty-dir/\u03c3.js', alpha, {
|
|
575
|
+
ttlSeconds: 300, idFactory: () => 'dededededededede', withLock: (_target, fn) => fn(),
|
|
576
|
+
});
|
|
577
|
+
let finalSigmaError = null;
|
|
578
|
+
try {
|
|
579
|
+
FL.acquire(caseFoldRoot, 'empty-dir/\u03c2.js', bravo, {
|
|
580
|
+
ttlSeconds: 300, idFactory: () => 'dfdfdfdfdfdfdfdf', withLock: (_target, fn) => fn(),
|
|
581
|
+
});
|
|
582
|
+
} catch (error) { finalSigmaError = error; }
|
|
583
|
+
check('Unicode case-fold aliases such as sigma and final sigma cannot receive independent leases',
|
|
584
|
+
sigmaLease.action === 'acquired' && finalSigmaError?.code === 'lease_conflict',
|
|
585
|
+
JSON.stringify(finalSigmaError, Object.getOwnPropertyNames(finalSigmaError || {})));
|
|
586
|
+
|
|
587
|
+
const expansionRoot = fresh('unicode-case-expansion');
|
|
588
|
+
const sharpSPath = path.join(expansionRoot, 'straße.js');
|
|
589
|
+
const doubledSPath = path.join(expansionRoot, 'strasse.js');
|
|
590
|
+
fs.writeFileSync(sharpSPath, 'sharp s\n', 'utf8');
|
|
591
|
+
fs.writeFileSync(doubledSPath, 'double s\n', 'utf8');
|
|
592
|
+
const sharpSStat = fs.statSync(sharpSPath, { bigint: true });
|
|
593
|
+
const doubledSStat = fs.statSync(doubledSPath, { bigint: true });
|
|
594
|
+
const expansionNamesAreDistinct = sharpSStat.dev !== doubledSStat.dev || sharpSStat.ino !== doubledSStat.ino;
|
|
595
|
+
const sharpSLease = FL.acquire(expansionRoot, 'straße.js', alpha, {
|
|
596
|
+
ttlSeconds: 300, idFactory: () => 'eeeeeeeeeeeeeeee', withLock: (_target, fn) => fn(),
|
|
597
|
+
});
|
|
598
|
+
let doubledSLease = null;
|
|
599
|
+
let doubledSError = null;
|
|
600
|
+
try {
|
|
601
|
+
doubledSLease = FL.acquire(expansionRoot, 'strasse.js', bravo, {
|
|
602
|
+
ttlSeconds: 300, idFactory: () => 'ffffffffffffffff', withLock: (_target, fn) => fn(),
|
|
603
|
+
});
|
|
604
|
+
} catch (error) { doubledSError = error; }
|
|
605
|
+
check('multi-code-point Unicode expansions do not merge distinct existing filenames',
|
|
606
|
+
sharpSLease.action === 'acquired'
|
|
607
|
+
&& (expansionNamesAreDistinct
|
|
608
|
+
? doubledSLease?.action === 'acquired' && !doubledSError
|
|
609
|
+
: doubledSError?.code === 'lease_conflict'),
|
|
610
|
+
JSON.stringify({ expansionNamesAreDistinct, doubledSLease, doubledSError }, Object.getOwnPropertyNames(doubledSError || {})));
|
|
611
|
+
|
|
612
|
+
const normalizationRoot = fresh('unicode-normalization-existing');
|
|
613
|
+
const composedName = '\u00e9.js';
|
|
614
|
+
const decomposedName = 'e\u0301.js';
|
|
615
|
+
fs.writeFileSync(path.join(normalizationRoot, composedName), 'composed\n', 'utf8');
|
|
616
|
+
fs.writeFileSync(path.join(normalizationRoot, decomposedName), 'decomposed\n', 'utf8');
|
|
617
|
+
const composedStat = fs.statSync(path.join(normalizationRoot, composedName), { bigint: true });
|
|
618
|
+
const decomposedStat = fs.statSync(path.join(normalizationRoot, decomposedName), { bigint: true });
|
|
619
|
+
const normalizationNamesAreDistinct = composedStat.dev !== decomposedStat.dev || composedStat.ino !== decomposedStat.ino;
|
|
620
|
+
const composedLease = FL.acquire(normalizationRoot, composedName, alpha, {
|
|
621
|
+
ttlSeconds: 300, idFactory: () => '3131313131313131', withLock: (_target, fn) => fn(),
|
|
622
|
+
});
|
|
623
|
+
let decomposedLease = null;
|
|
624
|
+
let decomposedError = null;
|
|
625
|
+
try {
|
|
626
|
+
decomposedLease = FL.acquire(normalizationRoot, decomposedName, bravo, {
|
|
627
|
+
ttlSeconds: 300, idFactory: () => '3232323232323232', withLock: (_target, fn) => fn(),
|
|
628
|
+
});
|
|
629
|
+
} catch (error) { decomposedError = error; }
|
|
630
|
+
const normalizationList = FL.list(normalizationRoot, { all: true });
|
|
631
|
+
check('canonically equivalent spellings remain independent when the filesystem exposes distinct existing files',
|
|
632
|
+
composedLease.action === 'acquired'
|
|
633
|
+
&& (normalizationNamesAreDistinct
|
|
634
|
+
? decomposedLease?.action === 'acquired' && !decomposedError && normalizationList.totalStored === 2
|
|
635
|
+
: decomposedError?.code === 'lease_conflict' && normalizationList.totalStored === 1),
|
|
636
|
+
JSON.stringify({ normalizationNamesAreDistinct, decomposedLease, decomposedError, normalizationList }, Object.getOwnPropertyNames(decomposedError || {})));
|
|
637
|
+
|
|
638
|
+
const appearedNormalizationRoot = fresh('unicode-normalization-appeared');
|
|
639
|
+
const missingDecomposedLease = FL.acquire(appearedNormalizationRoot, decomposedName, alpha, {
|
|
640
|
+
ttlSeconds: 300, idFactory: () => '3333333333333333', withLock: (_target, fn) => fn(),
|
|
641
|
+
});
|
|
642
|
+
fs.writeFileSync(path.join(appearedNormalizationRoot, decomposedName), 'appeared decomposed\n', 'utf8');
|
|
643
|
+
fs.writeFileSync(path.join(appearedNormalizationRoot, composedName), 'appeared composed\n', 'utf8');
|
|
644
|
+
const appearedComposedStat = fs.statSync(path.join(appearedNormalizationRoot, composedName), { bigint: true });
|
|
645
|
+
const appearedDecomposedStat = fs.statSync(path.join(appearedNormalizationRoot, decomposedName), { bigint: true });
|
|
646
|
+
const appearedNamesAreDistinct = appearedComposedStat.dev !== appearedDecomposedStat.dev
|
|
647
|
+
|| appearedComposedStat.ino !== appearedDecomposedStat.ino;
|
|
648
|
+
let appearedComposedLease = null;
|
|
649
|
+
let appearedComposedError = null;
|
|
650
|
+
try {
|
|
651
|
+
appearedComposedLease = FL.acquire(appearedNormalizationRoot, composedName, bravo, {
|
|
652
|
+
ttlSeconds: 300, idFactory: () => '3434343434343434', withLock: (_target, fn) => fn(),
|
|
653
|
+
});
|
|
654
|
+
} catch (error) { appearedComposedError = error; }
|
|
655
|
+
const appearedNormalizationList = FL.list(appearedNormalizationRoot, { all: true });
|
|
656
|
+
check('a formerly missing lease records its observed identity before a distinct normalization-colliding file is admitted',
|
|
657
|
+
missingDecomposedLease.action === 'acquired'
|
|
658
|
+
&& (appearedNamesAreDistinct
|
|
659
|
+
? appearedComposedLease?.action === 'acquired' && !appearedComposedError && appearedNormalizationList.totalStored === 2
|
|
660
|
+
: appearedComposedError?.code === 'lease_conflict' && appearedNormalizationList.totalStored === 1),
|
|
661
|
+
JSON.stringify({ appearedNamesAreDistinct, appearedComposedLease, appearedComposedError, appearedNormalizationList }, Object.getOwnPropertyNames(appearedComposedError || {})));
|
|
662
|
+
|
|
663
|
+
const convergedRoot = fresh('converged-peer');
|
|
664
|
+
const convergedA = FL.acquire(convergedRoot, 'converged-a.js', alpha, {
|
|
665
|
+
ttlSeconds: 300, idFactory: () => '1111111111111111', withLock: (_target, fn) => fn(),
|
|
666
|
+
});
|
|
667
|
+
const convergedB = FL.acquire(convergedRoot, 'converged-b.js', bravo, {
|
|
668
|
+
ttlSeconds: 300, idFactory: () => '2222222222222222', withLock: (_target, fn) => fn(),
|
|
669
|
+
});
|
|
670
|
+
fs.writeFileSync(path.join(convergedRoot, 'converged-a.js'), 'same inode\n', 'utf8');
|
|
671
|
+
let convergedLinkSupported = true;
|
|
672
|
+
try { fs.linkSync(path.join(convergedRoot, 'converged-a.js'), path.join(convergedRoot, 'converged-b.js')); }
|
|
673
|
+
catch { convergedLinkSupported = false; }
|
|
674
|
+
if (convergedLinkSupported) {
|
|
675
|
+
const convergedCheck = FL.check(convergedRoot, 'converged-a.js', { sessionKey: alpha });
|
|
676
|
+
let convergedAcquireError = null;
|
|
677
|
+
try {
|
|
678
|
+
FL.acquire(convergedRoot, 'converged-a.js', alpha, {
|
|
679
|
+
ttlSeconds: 300, withLock: (_target, fn) => fn(),
|
|
680
|
+
});
|
|
681
|
+
} catch (error) { convergedAcquireError = error; }
|
|
682
|
+
check('peer ownership dominates a same-session row when missing paths later converge to one hard link',
|
|
683
|
+
convergedA.action === 'acquired' && convergedB.action === 'acquired'
|
|
684
|
+
&& convergedCheck.available === false && convergedCheck.ownedBySession === false
|
|
685
|
+
&& convergedCheck.lease?.sessionKey === bravo
|
|
686
|
+
&& convergedAcquireError?.code === 'lease_conflict'
|
|
687
|
+
&& convergedAcquireError?.details?.conflict?.sessionKey === bravo,
|
|
688
|
+
JSON.stringify({ convergedA, convergedB, convergedCheck, convergedAcquireError }, Object.getOwnPropertyNames(convergedAcquireError || {})));
|
|
689
|
+
} else {
|
|
690
|
+
check('peer ownership dominates a same-session row when missing paths later converge to one hard link', true,
|
|
691
|
+
'hard links unsupported on this filesystem; behavior covered where supported');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const lockSnapshotRoot = fresh('lock-snapshot');
|
|
695
|
+
const lockSnapshotOwner = FL.acquire(lockSnapshotRoot, 'snapshot-a.js', alpha, {
|
|
696
|
+
ttlSeconds: 300, idFactory: () => '4444444444444444', withLock: (_target, fn) => fn(),
|
|
697
|
+
});
|
|
698
|
+
let lockSnapshotLinkSupported = true;
|
|
699
|
+
let lockSnapshotError = null;
|
|
700
|
+
try {
|
|
701
|
+
FL.acquire(lockSnapshotRoot, 'snapshot-b.js', bravo, {
|
|
702
|
+
ttlSeconds: 300,
|
|
703
|
+
withLock: (_target, fn) => {
|
|
704
|
+
fs.writeFileSync(path.join(lockSnapshotRoot, 'snapshot-a.js'), 'snapshot\n', 'utf8');
|
|
705
|
+
try { fs.linkSync(path.join(lockSnapshotRoot, 'snapshot-a.js'), path.join(lockSnapshotRoot, 'snapshot-b.js')); }
|
|
706
|
+
catch { lockSnapshotLinkSupported = false; return fn(); }
|
|
707
|
+
return fn();
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
} catch (error) { lockSnapshotError = error; }
|
|
711
|
+
check('acquire re-observes file identity after lock wait before deciding ownership',
|
|
712
|
+
!lockSnapshotLinkSupported || (lockSnapshotOwner.action === 'acquired'
|
|
713
|
+
&& lockSnapshotError?.code === 'lease_conflict'
|
|
714
|
+
&& lockSnapshotError?.details?.conflict?.sessionKey === alpha),
|
|
715
|
+
lockSnapshotLinkSupported
|
|
716
|
+
? JSON.stringify(lockSnapshotError, Object.getOwnPropertyNames(lockSnapshotError || {}))
|
|
717
|
+
: 'hard links unsupported on this filesystem; behavior covered where supported');
|
|
718
|
+
|
|
719
|
+
const ttlLockRoot = fresh('ttl-lock-start');
|
|
720
|
+
const realDateNow = Date.now;
|
|
721
|
+
const ttlBase = Date.UTC(2026, 0, 2, 0, 0, 0);
|
|
722
|
+
let ttlNow = ttlBase;
|
|
723
|
+
let ttlAfterWait = null;
|
|
724
|
+
try {
|
|
725
|
+
Date.now = () => ttlNow;
|
|
726
|
+
ttlAfterWait = FL.acquire(ttlLockRoot, 'ttl-after-wait.js', alpha, {
|
|
727
|
+
ttlSeconds: 30, idFactory: () => '5555555555555555',
|
|
728
|
+
withLock: (_target, fn) => { ttlNow += 31000; return fn(); },
|
|
729
|
+
});
|
|
730
|
+
} finally { Date.now = realDateNow; }
|
|
731
|
+
check('lease TTL starts after lock waiting rather than expiring before the write',
|
|
732
|
+
ttlAfterWait?.action === 'acquired'
|
|
733
|
+
&& ttlAfterWait?.lease?.acquiredAt === new Date(ttlBase + 31000).toISOString()
|
|
734
|
+
&& ttlAfterWait?.lease?.expiresAt === new Date(ttlBase + 61000).toISOString()
|
|
735
|
+
&& ttlAfterWait?.lease?.remainingSeconds === 30,
|
|
736
|
+
JSON.stringify(ttlAfterWait));
|
|
737
|
+
|
|
738
|
+
let lockError = null;
|
|
739
|
+
try {
|
|
740
|
+
FL.acquire(root, 'lock-error.js', alpha, {
|
|
741
|
+
ttlSeconds: 300,
|
|
742
|
+
withLock: () => { const error = new Error('forced'); error.code = 'E_LOCK_TIMEOUT'; error.waitedMs = 7; throw error; },
|
|
743
|
+
});
|
|
744
|
+
} catch (error) { lockError = error; }
|
|
745
|
+
let releaseLockError = null;
|
|
746
|
+
try {
|
|
747
|
+
FL.acquire(root, 'lock-release-error.js', alpha, {
|
|
748
|
+
ttlSeconds: 300,
|
|
749
|
+
withLock: (_target, fn) => { fn(); const error = new Error('forced release failure'); error.code = 'E_LOCK_RELEASE'; throw error; },
|
|
750
|
+
});
|
|
751
|
+
} catch (error) { releaseLockError = error; }
|
|
752
|
+
check('lock acquisition/release failures have explicit and honest machine errors',
|
|
753
|
+
lockError?.code === 'lease_lock_unavailable' && lockError?.details?.lockCode === 'E_LOCK_TIMEOUT'
|
|
754
|
+
&& lockError?.details?.waitedMs === 7 && lockError?.details?.mutationMayHaveApplied === false
|
|
755
|
+
&& releaseLockError?.code === 'lease_lock_unavailable'
|
|
756
|
+
&& releaseLockError?.details?.lockCode === 'E_LOCK_RELEASE'
|
|
757
|
+
&& releaseLockError?.details?.mutationMayHaveApplied === true,
|
|
758
|
+
JSON.stringify({ lockError, releaseLockError }, Object.getOwnPropertyNames(lockError || {})));
|
|
759
|
+
|
|
760
|
+
const limitRoot = fresh('limit');
|
|
761
|
+
fs.mkdirSync(path.dirname(storeFile(limitRoot)), { recursive: true });
|
|
762
|
+
const limitBaseMs = Date.now();
|
|
763
|
+
const limitAt = new Date(limitBaseMs).toISOString();
|
|
764
|
+
const limitExpires = new Date(limitBaseMs + (1800 * 1000)).toISOString();
|
|
765
|
+
const limitLeases = Array.from({ length: 1000 }, (_, index) => {
|
|
766
|
+
const file = `limit/${String(index).padStart(4, '0')}.js`;
|
|
767
|
+
return {
|
|
768
|
+
leaseId: `lease-${index.toString(16).padStart(16, '0')}`,
|
|
769
|
+
file, pathKey: process.platform === 'win32' ? file.toLowerCase() : file, identityKey: null,
|
|
770
|
+
sessionKey: `limit-session-${String(index).padStart(4, '0')}`,
|
|
771
|
+
acquiredAt: limitAt, renewedAt: limitAt, expiresAt: limitExpires, note: null,
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
fs.writeFileSync(storeFile(limitRoot), JSON.stringify({ schemaVersion: 1, updatedAt: limitAt, leases: limitLeases }, null, 2) + '\n', 'utf8');
|
|
775
|
+
const limitBefore = storeBytes(limitRoot);
|
|
776
|
+
const limitAcquire = runLease(limitRoot, ['acquire', 'overflow.js', '--session', alpha, '--ttl', '300']);
|
|
777
|
+
check('the 1000-row store limit fails closed instead of writing a self-invalid 1001st row',
|
|
778
|
+
limitAcquire.status === 1 && parseJson(limitAcquire)?.code === 'lease_limit_reached'
|
|
779
|
+
&& Buffer.compare(limitBefore, storeBytes(limitRoot)) === 0,
|
|
780
|
+
limitAcquire.stdout || limitAcquire.stderr);
|
|
781
|
+
|
|
782
|
+
const byteLimitRoot = fresh('byte-limit');
|
|
783
|
+
fs.mkdirSync(path.dirname(storeFile(byteLimitRoot)), { recursive: true });
|
|
784
|
+
const byteBaseMs = Date.now();
|
|
785
|
+
const byteAt = new Date(byteBaseMs).toISOString();
|
|
786
|
+
const byteExpires = new Date(byteBaseMs + (1800 * 1000)).toISOString();
|
|
787
|
+
const byteRow = (index, file) => ({
|
|
788
|
+
leaseId: `lease-${(index + 5000).toString(16).padStart(16, '0')}`,
|
|
789
|
+
file, pathKey: process.platform === 'win32' ? file.toLowerCase() : file, identityKey: null,
|
|
790
|
+
sessionKey: `bytes-session-${String(index).padStart(4, '0')}`,
|
|
791
|
+
acquiredAt: byteAt, renewedAt: byteAt, expiresAt: byteExpires, note: null,
|
|
792
|
+
});
|
|
793
|
+
const byteRows = [];
|
|
794
|
+
const prospective = {
|
|
795
|
+
leaseId: 'lease-feedfeedfeedfeed', file: 'overflow-byte.js', pathKey: 'overflow-byte.js', identityKey: null,
|
|
796
|
+
sessionKey: alpha, acquiredAt: byteAt, renewedAt: byteAt,
|
|
797
|
+
expiresAt: new Date(byteBaseMs + (300 * 1000)).toISOString(), note: null,
|
|
798
|
+
};
|
|
799
|
+
const encodeStore = rows => JSON.stringify({ schemaVersion: 1, updatedAt: byteAt, leases: rows }, null, 2) + '\n';
|
|
800
|
+
while (true) {
|
|
801
|
+
const index = byteRows.length;
|
|
802
|
+
const file = `bytes/${String(index).padStart(4, '0')}/${'x'.repeat(1750)}`;
|
|
803
|
+
const candidate = byteRow(index, file);
|
|
804
|
+
if (Buffer.byteLength(encodeStore([...byteRows, candidate, prospective]), 'utf8') > FL.MAX_STORE_BYTES) break;
|
|
805
|
+
byteRows.push(candidate);
|
|
806
|
+
}
|
|
807
|
+
let filler = null;
|
|
808
|
+
for (let length = 1; length <= 2000; length++) {
|
|
809
|
+
const index = byteRows.length;
|
|
810
|
+
const file = `bytes/${String(index).padStart(4, '0')}/${'y'.repeat(length)}`;
|
|
811
|
+
const candidate = byteRow(index, file);
|
|
812
|
+
const currentBytes = Buffer.byteLength(encodeStore([...byteRows, candidate]), 'utf8');
|
|
813
|
+
const nextBytes = Buffer.byteLength(encodeStore([...byteRows, candidate, prospective]), 'utf8');
|
|
814
|
+
if (currentBytes <= FL.MAX_STORE_BYTES && nextBytes > FL.MAX_STORE_BYTES) filler = candidate;
|
|
815
|
+
}
|
|
816
|
+
if (!filler) throw new Error('failed to construct byte-limit fixture');
|
|
817
|
+
byteRows.push(filler);
|
|
818
|
+
const byteText = encodeStore(byteRows);
|
|
819
|
+
fs.writeFileSync(storeFile(byteLimitRoot), byteText, 'utf8');
|
|
820
|
+
const byteBefore = storeBytes(byteLimitRoot);
|
|
821
|
+
let byteLimitError = null;
|
|
822
|
+
try {
|
|
823
|
+
FL.acquire(byteLimitRoot, 'overflow-byte.js', alpha, {
|
|
824
|
+
ttlSeconds: 300, nowMs: byteBaseMs, idFactory: () => 'feedfeedfeedfeed', withLock: (_target, fn) => fn(),
|
|
825
|
+
});
|
|
826
|
+
} catch (error) { byteLimitError = error; }
|
|
827
|
+
check('the byte limit is enforced before a valid update can self-invalidate the next read',
|
|
828
|
+
byteLimitError?.code === 'lease_store_size_limit'
|
|
829
|
+
&& byteLimitError?.details?.maxBytes === FL.MAX_STORE_BYTES
|
|
830
|
+
&& byteLimitError?.details?.attemptedBytes > FL.MAX_STORE_BYTES
|
|
831
|
+
&& byteBefore.length <= FL.MAX_STORE_BYTES
|
|
832
|
+
&& Buffer.compare(byteBefore, storeBytes(byteLimitRoot)) === 0,
|
|
833
|
+
JSON.stringify({ byteLimitError, beforeBytes: byteBefore.length }, Object.getOwnPropertyNames(byteLimitError || {})));
|
|
834
|
+
|
|
835
|
+
const clockRoot = fresh('clock-regression');
|
|
836
|
+
const clockFuture = Date.UTC(2026, 0, 1, 1, 0, 0);
|
|
837
|
+
const clockLease = FL.acquire(clockRoot, 'clock.js', alpha, {
|
|
838
|
+
ttlSeconds: 300, nowMs: clockFuture, idFactory: () => '3333333333333333', withLock: (_target, fn) => fn(),
|
|
839
|
+
});
|
|
840
|
+
const clockBefore = storeBytes(clockRoot);
|
|
841
|
+
let clockError = null;
|
|
842
|
+
try {
|
|
843
|
+
FL.acquire(clockRoot, 'clock.js', alpha, {
|
|
844
|
+
ttlSeconds: 300, nowMs: clockFuture - 1000, withLock: (_target, fn) => fn(),
|
|
845
|
+
});
|
|
846
|
+
} catch (error) { clockError = error; }
|
|
847
|
+
check('a backward clock jump cannot make renewal write a self-invalid timestamp order',
|
|
848
|
+
clockLease.action === 'acquired' && clockError?.code === 'lease_clock_regression'
|
|
849
|
+
&& Buffer.compare(clockBefore, storeBytes(clockRoot)) === 0
|
|
850
|
+
&& FL.list(clockRoot, { nowMs: clockFuture - 1000, all: true }).storeState === 'ok',
|
|
851
|
+
JSON.stringify({ clockLease, clockError }, Object.getOwnPropertyNames(clockError || {})));
|
|
852
|
+
|
|
853
|
+
const expRoot = fresh('expiry');
|
|
854
|
+
fs.writeFileSync(path.join(expRoot, 'expire.js'), 'expire\n', 'utf8');
|
|
855
|
+
const noOpLock = (_target, fn) => fn();
|
|
856
|
+
const t0 = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
857
|
+
const expA = FL.acquire(expRoot, 'expire.js', alpha, {
|
|
858
|
+
ttlSeconds: 30, nowMs: t0, idFactory: () => 'aaaaaaaaaaaaaaaa', withLock: noOpLock,
|
|
859
|
+
});
|
|
860
|
+
const expB = FL.acquire(expRoot, 'expire.js', bravo, {
|
|
861
|
+
ttlSeconds: 30, nowMs: t0 + 31000, idFactory: () => 'bbbbbbbbbbbbbbbb', withLock: noOpLock,
|
|
862
|
+
});
|
|
863
|
+
check('expired lease stops blocking and is pruned only by a successful mutation',
|
|
864
|
+
expA.action === 'acquired' && expB.action === 'acquired' && expB.expiredPruned === 1
|
|
865
|
+
&& expB.lease.sessionKey === bravo && FL.list(expRoot, { nowMs: t0 + 31000, all: true }).totalStored === 1,
|
|
866
|
+
JSON.stringify({ expA, expB }));
|
|
867
|
+
|
|
868
|
+
const staleTopologyRoot = fresh('expired-stale-topology');
|
|
869
|
+
fs.mkdirSync(path.join(staleTopologyRoot, 'slot'));
|
|
870
|
+
fs.writeFileSync(path.join(staleTopologyRoot, 'slot', 'item.js'), 'leased\n', 'utf8');
|
|
871
|
+
FL.acquire(staleTopologyRoot, 'slot/item.js', alpha, {
|
|
872
|
+
ttlSeconds: 30, nowMs: t0, idFactory: () => 'abababababababab', withLock: noOpLock,
|
|
873
|
+
});
|
|
874
|
+
fs.unlinkSync(path.join(staleTopologyRoot, 'slot', 'item.js'));
|
|
875
|
+
fs.rmdirSync(path.join(staleTopologyRoot, 'slot'));
|
|
876
|
+
fs.writeFileSync(path.join(staleTopologyRoot, 'slot'), 'parent became a file\n', 'utf8');
|
|
877
|
+
const unrelatedAfterExpiry = FL.acquire(staleTopologyRoot, 'other.js', bravo, {
|
|
878
|
+
ttlSeconds: 30, nowMs: t0 + 31000, idFactory: () => 'bcbcbcbcbcbcbcbc', withLock: noOpLock,
|
|
879
|
+
});
|
|
880
|
+
check('expired rows with stale path topology cannot poison unrelated lease mutations',
|
|
881
|
+
unrelatedAfterExpiry.action === 'acquired' && unrelatedAfterExpiry.expiredPruned === 1
|
|
882
|
+
&& FL.list(staleTopologyRoot, { nowMs: t0 + 31000, all: true }).totalStored === 1,
|
|
883
|
+
JSON.stringify(unrelatedAfterExpiry));
|
|
884
|
+
|
|
885
|
+
const noCacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-file-lease-no-cache-'));
|
|
886
|
+
roots.push(noCacheRoot);
|
|
887
|
+
fs.mkdirSync(path.join(noCacheRoot, '.leerness'), { recursive: true });
|
|
888
|
+
let releaseError = null;
|
|
889
|
+
try {
|
|
890
|
+
FL.release(noCacheRoot, 'lease-cccccccccccccccc', alpha, { withLock: noOpLock });
|
|
891
|
+
} catch (error) { releaseError = error; }
|
|
892
|
+
check('releasing a missing lease does not create the cache directory',
|
|
893
|
+
releaseError?.code === 'lease_not_found' && !fs.existsSync(path.join(noCacheRoot, '.leerness', 'cache')),
|
|
894
|
+
String(releaseError && releaseError.stack || releaseError));
|
|
895
|
+
|
|
896
|
+
const cacheLinkRoot = fresh('cache-link');
|
|
897
|
+
const cacheLink = path.join(cacheLinkRoot, '.leerness', 'cache');
|
|
898
|
+
const cacheTarget = path.join(cacheLinkRoot, 'tracked-cache-target');
|
|
899
|
+
fs.rmSync(cacheLink, { recursive: true, force: true });
|
|
900
|
+
fs.mkdirSync(cacheTarget, { recursive: true });
|
|
901
|
+
makeDirLink(cacheTarget, cacheLink);
|
|
902
|
+
const cacheLinkAcquire = runLease(cacheLinkRoot, ['acquire', 'x.js', '--session', alpha, '--ttl', '300']);
|
|
903
|
+
check('lease cache symlinks or junctions are rejected before writing tracked project paths',
|
|
904
|
+
cacheLinkAcquire.status === 1 && parseJson(cacheLinkAcquire)?.code === 'cache_symlink'
|
|
905
|
+
&& !fs.existsSync(path.join(cacheTarget, 'file-leases.json')),
|
|
906
|
+
cacheLinkAcquire.stdout || cacheLinkAcquire.stderr);
|
|
907
|
+
|
|
908
|
+
const hardStoreRoot = fresh('hard-store');
|
|
909
|
+
fs.mkdirSync(path.dirname(storeFile(hardStoreRoot)), { recursive: true });
|
|
910
|
+
const hardStorePeer = path.join(hardStoreRoot, 'peer-store.json');
|
|
911
|
+
const hardStoreBytes = Buffer.from(JSON.stringify({ schemaVersion: 1, updatedAt: null, leases: [] }) + '\n');
|
|
912
|
+
fs.writeFileSync(hardStorePeer, hardStoreBytes);
|
|
913
|
+
let hardStoreSupported = true;
|
|
914
|
+
try { fs.linkSync(hardStorePeer, storeFile(hardStoreRoot)); }
|
|
915
|
+
catch { hardStoreSupported = false; }
|
|
916
|
+
const hardStoreList = hardStoreSupported ? runLease(hardStoreRoot, ['list']) : null;
|
|
917
|
+
const hardStoreAcquire = hardStoreSupported
|
|
918
|
+
? runLease(hardStoreRoot, ['acquire', 'x.js', '--session', alpha, '--ttl', '300']) : null;
|
|
919
|
+
check('hard-linked lease stores fail closed without changing either linked path',
|
|
920
|
+
!hardStoreSupported || (hardStoreList.status === 1 && parseJson(hardStoreList)?.code === 'store_hard_link'
|
|
921
|
+
&& hardStoreAcquire.status === 1 && parseJson(hardStoreAcquire)?.code === 'store_hard_link'
|
|
922
|
+
&& Buffer.compare(hardStoreBytes, fs.readFileSync(hardStorePeer)) === 0
|
|
923
|
+
&& Buffer.compare(hardStoreBytes, fs.readFileSync(storeFile(hardStoreRoot))) === 0),
|
|
924
|
+
hardStoreSupported ? `list=${hardStoreList.stdout} acquire=${hardStoreAcquire.stdout}` : 'hard links unsupported');
|
|
925
|
+
|
|
926
|
+
const corruptRoot = fresh('corrupt');
|
|
927
|
+
fs.mkdirSync(path.dirname(storeFile(corruptRoot)), { recursive: true });
|
|
928
|
+
const corruptBytes = Buffer.from('{ broken lease store', 'utf8');
|
|
929
|
+
fs.writeFileSync(storeFile(corruptRoot), corruptBytes);
|
|
930
|
+
const corruptList = runLease(corruptRoot, ['list']);
|
|
931
|
+
const corruptAcquire = runLease(corruptRoot, ['acquire', 'x.js', '--session', alpha, '--ttl', '300']);
|
|
932
|
+
check('corrupt store fails closed for read and write while preserving original bytes',
|
|
933
|
+
corruptList.status === 1 && parseJson(corruptList)?.code === 'store_corrupt'
|
|
934
|
+
&& corruptAcquire.status === 1 && parseJson(corruptAcquire)?.code === 'store_corrupt'
|
|
935
|
+
&& Buffer.compare(corruptBytes, fs.readFileSync(storeFile(corruptRoot))) === 0,
|
|
936
|
+
`list=${corruptList.stdout} acquire=${corruptAcquire.stdout}`);
|
|
937
|
+
|
|
938
|
+
const invalidUtf8Root = fresh('invalid-utf8');
|
|
939
|
+
fs.mkdirSync(path.dirname(storeFile(invalidUtf8Root)), { recursive: true });
|
|
940
|
+
const invalidUtf8When = new Date(t0).toISOString();
|
|
941
|
+
const invalidUtf8Later = new Date(t0 + 60000).toISOString();
|
|
942
|
+
const invalidUtf8Text = JSON.stringify({
|
|
943
|
+
schemaVersion: 1, updatedAt: invalidUtf8When, leases: [{
|
|
944
|
+
leaseId: 'lease-cccccccccccccccc', file: 'utf8.js', pathKey: 'utf8.js', identityKey: null,
|
|
945
|
+
sessionKey: alpha, acquiredAt: invalidUtf8When, renewedAt: invalidUtf8When, expiresAt: invalidUtf8Later, note: 'badXnote',
|
|
946
|
+
}],
|
|
947
|
+
}, null, 2) + '\n';
|
|
948
|
+
const invalidUtf8Bytes = Buffer.from(invalidUtf8Text, 'utf8');
|
|
949
|
+
invalidUtf8Bytes[invalidUtf8Bytes.indexOf(Buffer.from('X'))] = 0xff;
|
|
950
|
+
fs.writeFileSync(storeFile(invalidUtf8Root), invalidUtf8Bytes);
|
|
951
|
+
const invalidUtf8List = runLease(invalidUtf8Root, ['list']);
|
|
952
|
+
const invalidUtf8Acquire = runLease(invalidUtf8Root, ['acquire', 'other.js', '--session', bravo, '--ttl', '300']);
|
|
953
|
+
check('malformed UTF-8 lease stores fail closed and preserve their original bytes',
|
|
954
|
+
invalidUtf8List.status === 1 && parseJson(invalidUtf8List)?.code === 'store_invalid_utf8'
|
|
955
|
+
&& invalidUtf8Acquire.status === 1 && parseJson(invalidUtf8Acquire)?.code === 'store_invalid_utf8'
|
|
956
|
+
&& Buffer.compare(invalidUtf8Bytes, fs.readFileSync(storeFile(invalidUtf8Root))) === 0,
|
|
957
|
+
`list=${invalidUtf8List.stdout} acquire=${invalidUtf8Acquire.stdout}`);
|
|
958
|
+
|
|
959
|
+
const invalidRoot = fresh('invalid');
|
|
960
|
+
fs.mkdirSync(path.dirname(storeFile(invalidRoot)), { recursive: true });
|
|
961
|
+
const when = new Date(t0).toISOString();
|
|
962
|
+
const later = new Date(t0 + 60000).toISOString();
|
|
963
|
+
const invalidStore = Buffer.from(JSON.stringify({
|
|
964
|
+
schemaVersion: 1,
|
|
965
|
+
updatedAt: when,
|
|
966
|
+
leases: [{
|
|
967
|
+
leaseId: 'lease-dddddddddddddddd', file: 'x.js', pathKey: 'x.js', identityKey: null,
|
|
968
|
+
sessionKey: 'bad', acquiredAt: when, renewedAt: when, expiresAt: later, note: null,
|
|
969
|
+
}],
|
|
970
|
+
}, null, 2) + '\n');
|
|
971
|
+
fs.writeFileSync(storeFile(invalidRoot), invalidStore);
|
|
972
|
+
const invalidList = runLease(invalidRoot, ['list']);
|
|
973
|
+
check('invalid stored session identity is reported as store_invalid, not an input error',
|
|
974
|
+
invalidList.status === 1 && parseJson(invalidList)?.code === 'store_invalid'
|
|
975
|
+
&& Buffer.compare(invalidStore, fs.readFileSync(storeFile(invalidRoot))) === 0,
|
|
976
|
+
invalidList.stdout || invalidList.stderr);
|
|
977
|
+
|
|
978
|
+
const overTtlRoot = fresh('over-ttl');
|
|
979
|
+
fs.mkdirSync(path.dirname(storeFile(overTtlRoot)), { recursive: true });
|
|
980
|
+
const overTtlStore = Buffer.from(JSON.stringify({
|
|
981
|
+
schemaVersion: 1, updatedAt: when, leases: [{
|
|
982
|
+
leaseId: 'lease-eeeeeeeeeeeeeeee', file: 'x.js', pathKey: 'x.js', identityKey: null,
|
|
983
|
+
sessionKey: alpha, acquiredAt: when, renewedAt: when,
|
|
984
|
+
expiresAt: new Date(t0 + (1801 * 1000)).toISOString(), note: null,
|
|
985
|
+
}],
|
|
986
|
+
}, null, 2) + '\n');
|
|
987
|
+
fs.writeFileSync(storeFile(overTtlRoot), overTtlStore);
|
|
988
|
+
const overTtlList = runLease(overTtlRoot, ['list']);
|
|
989
|
+
check('tampered overlong stored TTL fails closed and remains byte-exact',
|
|
990
|
+
overTtlList.status === 1 && parseJson(overTtlList)?.code === 'store_invalid'
|
|
991
|
+
&& Buffer.compare(overTtlStore, fs.readFileSync(storeFile(overTtlRoot))) === 0,
|
|
992
|
+
overTtlList.stdout || overTtlList.stderr);
|
|
993
|
+
|
|
994
|
+
const concurrencyRoot = fresh('concurrency');
|
|
995
|
+
fs.writeFileSync(path.join(concurrencyRoot, 'same.js'), 'same\n', 'utf8');
|
|
996
|
+
const concurrent = await Promise.all([
|
|
997
|
+
runAsync(concurrencyRoot, ['lease', 'acquire', 'same.js', '--session', 'concurrent-alpha', '--ttl', '300', '--path', concurrencyRoot, '--json']),
|
|
998
|
+
runAsync(concurrencyRoot, ['lease', 'acquire', 'same.js', '--session', 'concurrent-bravo', '--ttl', '300', '--path', concurrencyRoot, '--json']),
|
|
999
|
+
]);
|
|
1000
|
+
const winners = concurrent.filter(result => result.status === 0);
|
|
1001
|
+
const losers = concurrent.filter(result => result.status === 1 && parseJson(result)?.code === 'lease_conflict');
|
|
1002
|
+
const concurrencyList = runLease(concurrencyRoot, ['list']);
|
|
1003
|
+
check('simultaneous acquisition converges to exactly one owner',
|
|
1004
|
+
winners.length === 1 && losers.length === 1 && parseJson(concurrencyList)?.active === 1,
|
|
1005
|
+
JSON.stringify(concurrent));
|
|
1006
|
+
|
|
1007
|
+
const rootOne = fresh('root-one');
|
|
1008
|
+
const rootTwo = fresh('root-two');
|
|
1009
|
+
fs.writeFileSync(path.join(rootOne, 'same.js'), 'one\n', 'utf8');
|
|
1010
|
+
fs.writeFileSync(path.join(rootTwo, 'same.js'), 'two\n', 'utf8');
|
|
1011
|
+
const one = runLease(rootOne, ['acquire', 'same.js', '--session', alpha, '--ttl', '300']);
|
|
1012
|
+
const two = runLease(rootTwo, ['acquire', 'same.js', '--session', bravo, '--ttl', '300']);
|
|
1013
|
+
check('separate project roots or worktrees remain isolated', one.status === 0 && two.status === 0,
|
|
1014
|
+
`one=${one.stdout} two=${two.stdout}`);
|
|
1015
|
+
|
|
1016
|
+
const ambientLeaseId = parseJson(afterReleaseAcquire)?.lease?.leaseId || '';
|
|
1017
|
+
const statusResult = run(root, ['status', root, '--json']);
|
|
1018
|
+
const handoffResult = run(root, ['handoff', root, '--compact', '--json']);
|
|
1019
|
+
const ambientText = `${statusResult.stdout}\n${handoffResult.stdout}`;
|
|
1020
|
+
check('ordinary status and handoff output contain no ambient lease warning',
|
|
1021
|
+
statusResult.status === 0 && handoffResult.status === 0
|
|
1022
|
+
&& !ambientText.includes(ambientLeaseId)
|
|
1023
|
+
&& !/lease_conflict|file-leases\.json|ambientWarnings/.test(ambientText),
|
|
1024
|
+
ambientText.slice(0, 1000));
|
|
1025
|
+
|
|
1026
|
+
const unsupported = ['scope', 'send', 'inbox'].map(sub => run(root, ['sessions', sub, '--path', root, '--json']));
|
|
1027
|
+
check('discarded sessions scope/send/inbox verbs remain unsupported',
|
|
1028
|
+
unsupported.every(result => result.status === 1 && parseJson(result)?.code === 'unknown_subcommand'),
|
|
1029
|
+
unsupported.map(result => result.stdout || result.stderr).join('\n'));
|
|
1030
|
+
|
|
1031
|
+
const koList = parseJson(runLease(root, ['list', '--language', 'ko']));
|
|
1032
|
+
const enList = parseJson(runLease(root, ['list', '--language', 'en']));
|
|
1033
|
+
const normalizeRemaining = value => JSON.parse(JSON.stringify(value, (key, item) => key === 'remainingSeconds' ? 0 : item));
|
|
1034
|
+
check('lease JSON is locale-independent',
|
|
1035
|
+
!!koList && !!enList && JSON.stringify(normalizeRemaining(koList)) === JSON.stringify(normalizeRemaining(enList)),
|
|
1036
|
+
`ko=${JSON.stringify(koList)} en=${JSON.stringify(enList)}`);
|
|
1037
|
+
|
|
1038
|
+
const catalog = run(root, ['commands', '--json']);
|
|
1039
|
+
const catalogJson = parseJson(catalog);
|
|
1040
|
+
const rows = catalogJson?.categories ? Object.values(catalogJson.categories).flat() : [];
|
|
1041
|
+
const helpEn = run(root, ['--help', '--language', 'en']);
|
|
1042
|
+
const helpKo = run(root, ['--help', '--language', 'ko']);
|
|
1043
|
+
const PU = require('../lib/pure-utils');
|
|
1044
|
+
check('permission policy classifies lease writes without over-classifying reads',
|
|
1045
|
+
PU._requiredTier('lease acquire file.js') === 'safe-write'
|
|
1046
|
+
&& PU._requiredTier('lease release lease-aaaaaaaaaaaaaaaa') === 'safe-write'
|
|
1047
|
+
&& PU._requiredTier('lease list') === 'read-only'
|
|
1048
|
+
&& PU._requiredTier('lease check file.js') === 'read-only'
|
|
1049
|
+
&& PU._requiredTier('lease check reports/release publish/status.js') === 'read-only'
|
|
1050
|
+
&& PU._requiredTier('lease acquire reports/release publish/status.js') === 'safe-write'
|
|
1051
|
+
&& PU._requiredTier('leerness lease check reports/release publish/status.js') === 'read-only'
|
|
1052
|
+
&& PU._requiredTier('npx leerness lease check reports/release publish/status.js') === 'read-only'
|
|
1053
|
+
&& PU._requiredTier('npx -y leerness@1.36.185 lease check reports/release publish/status.js') === 'read-only'
|
|
1054
|
+
&& PU._requiredTier('npx --yes leerness lease acquire reports/release publish/status.js') === 'safe-write');
|
|
1055
|
+
|
|
1056
|
+
check('command catalog and both help locales advertise exact-file lease',
|
|
1057
|
+
catalog.status === 0 && catalogJson?.totalCommands === 100
|
|
1058
|
+
&& rows.some(row => /^lease acquire\|release\|list\|check\b/.test(row.cmd))
|
|
1059
|
+
&& /lease acquire\|release\|list\|check/.test(helpEn.stdout)
|
|
1060
|
+
&& /leerness lease acquire\|release\|list\|check/.test(helpKo.stdout),
|
|
1061
|
+
`catalog=${catalog.stdout.slice(0, 500)} en=${helpEn.stdout.slice(0, 500)} ko=${helpKo.stdout.slice(0, 500)}`);
|
|
1062
|
+
|
|
1063
|
+
const mcpInvalidRoot = fresh('mcp-invalid');
|
|
1064
|
+
const mcpInvalidBefore = snapshotTree(mcpInvalidRoot);
|
|
1065
|
+
const mcpInvalid = runMcp(mcpInvalidRoot, [
|
|
1066
|
+
rpcCall('leerness_file_lease_read', { action: 'list', file: 'ignored.js', path: mcpInvalidRoot }, 2),
|
|
1067
|
+
rpcCall('leerness_file_lease_write', { action: 'acquire', file: 'x.js', leaseId: 'lease-aaaaaaaaaaaaaaaa', sessionKey: 'mcp-invalid-session', path: mcpInvalidRoot }, 3),
|
|
1068
|
+
rpcCall('leerness_file_lease_write', { action: 'release', leaseId: 'lease-aaaaaaaaaaaaaaaa', ttl: 300, sessionKey: 'mcp-invalid-session', path: mcpInvalidRoot }, 4),
|
|
1069
|
+
rpcCall('leerness_file_lease_read', { action: '', path: mcpInvalidRoot }, 5),
|
|
1070
|
+
]);
|
|
1071
|
+
const mcpInvalidAfter = snapshotTree(mcpInvalidRoot);
|
|
1072
|
+
check('MCP lease actions reject ignored or action-incompatible fields before telemetry',
|
|
1073
|
+
[2, 3, 4, 5].every(id => mcpInvalid.byId(id)?.error?.code === -32602)
|
|
1074
|
+
&& mcpInvalidBefore === mcpInvalidAfter,
|
|
1075
|
+
JSON.stringify(mcpInvalid.messages));
|
|
1076
|
+
|
|
1077
|
+
const mcpRoot = fresh('mcp');
|
|
1078
|
+
fs.writeFileSync(path.join(mcpRoot, 'mcp.js'), 'mcp\n', 'utf8');
|
|
1079
|
+
const mcpWrite = runMcp(mcpRoot, [
|
|
1080
|
+
rpcCall('leerness_file_lease_write', {
|
|
1081
|
+
action: 'acquire', file: 'mcp.js', sessionKey: 'mcp-lease-alpha', ttl: 300, path: mcpRoot,
|
|
1082
|
+
}, 2),
|
|
1083
|
+
]);
|
|
1084
|
+
const tools = mcpWrite.byId(1)?.result?.tools || [];
|
|
1085
|
+
const mcpAcquired = toolJson(mcpWrite, 2);
|
|
1086
|
+
const mcpBeforeRead = snapshotTree(mcpRoot);
|
|
1087
|
+
const mcpRead = runMcp(mcpRoot, [
|
|
1088
|
+
rpcCall('leerness_file_lease_read', {
|
|
1089
|
+
action: 'check', file: 'mcp.js', sessionKey: 'mcp-lease-bravo', path: mcpRoot,
|
|
1090
|
+
}, 2),
|
|
1091
|
+
rpcCall('leerness_file_lease_read', {
|
|
1092
|
+
action: 'list', sessionKey: 'mcp-lease-alpha', path: mcpRoot,
|
|
1093
|
+
}, 3),
|
|
1094
|
+
]);
|
|
1095
|
+
const mcpChecked = toolJson(mcpRead, 2);
|
|
1096
|
+
const mcpListed = toolJson(mcpRead, 3);
|
|
1097
|
+
const mcpAfterRead = snapshotTree(mcpRoot);
|
|
1098
|
+
const toolDefs = require('../lib/mcp-tools');
|
|
1099
|
+
check('MCP exposes separate read-only and safe-write exact-file lease surfaces',
|
|
1100
|
+
mcpWrite.result.status === 0 && mcpRead.result.status === 0
|
|
1101
|
+
&& tools.some(tool => tool.name === 'leerness_file_lease_read')
|
|
1102
|
+
&& tools.some(tool => tool.name === 'leerness_file_lease_write')
|
|
1103
|
+
&& toolDefs.find(tool => tool.name === 'leerness_file_lease_read')?.requiredTier === 'read-only'
|
|
1104
|
+
&& toolDefs.find(tool => tool.name === 'leerness_file_lease_write')?.requiredTier === 'safe-write'
|
|
1105
|
+
&& mcpAcquired?.action === 'acquired'
|
|
1106
|
+
&& mcpChecked?.available === false && mcpChecked?.lease?.sessionKey === 'mcp-lease-alpha'
|
|
1107
|
+
&& mcpRead.byId(2)?.result?.isError === false && !mcpRead.byId(2)?.error
|
|
1108
|
+
&& mcpListed?.shown === 1 && mcpListed?.leases?.[0]?.sessionKey === 'mcp-lease-alpha'
|
|
1109
|
+
&& mcpBeforeRead === mcpAfterRead,
|
|
1110
|
+
`write=${JSON.stringify(mcpWrite.byId(2))} check=${JSON.stringify(mcpRead.byId(2))} list=${JSON.stringify(mcpRead.byId(3))}`);
|
|
1111
|
+
|
|
1112
|
+
const paged = runMcp(limitRoot, [
|
|
1113
|
+
rpcCall('leerness_file_lease_read', {
|
|
1114
|
+
action: 'list', all: true, path: limitRoot, _chunkSize: 128,
|
|
1115
|
+
}, 2),
|
|
1116
|
+
rpcCall('leerness_file_lease_read', {
|
|
1117
|
+
action: 'list', all: true, path: limitRoot, _chunkSize: 128, _cursor: '128',
|
|
1118
|
+
}, 3),
|
|
1119
|
+
]);
|
|
1120
|
+
const leaseReadSchema = toolDefs.find(tool => tool.name === 'leerness_file_lease_read')?.inputSchema;
|
|
1121
|
+
check('strict read-only MCP schemas advertise and accept their pagination cursor',
|
|
1122
|
+
leaseReadSchema?.properties?._cursor?.type === 'string'
|
|
1123
|
+
&& leaseReadSchema?.properties?._chunkSize?.type === 'number'
|
|
1124
|
+
&& paged.byId(2)?.result?.nextCursor === '128'
|
|
1125
|
+
&& !paged.byId(3)?.error
|
|
1126
|
+
&& typeof paged.byId(3)?.result?.content?.[0]?.text === 'string'
|
|
1127
|
+
&& paged.byId(3).result.content[0].text.length === 128,
|
|
1128
|
+
JSON.stringify(paged.messages.slice(-2)));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
main().catch(error => {
|
|
1132
|
+
check('probe completed without exception', false, error && error.stack ? error.stack : String(error));
|
|
1133
|
+
}).finally(() => {
|
|
1134
|
+
for (const root of roots.reverse()) {
|
|
1135
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
1136
|
+
}
|
|
1137
|
+
process.stdout.write(`File lease probe: ${total - failed}/${total} passed\n`);
|
|
1138
|
+
process.exitCode = failed ? 1 : 0;
|
|
1139
|
+
});
|