@getopencrawl/cli 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -298
- package/bin/opencrawl.cjs +168 -0
- package/package.json +20 -14
- package/bin/opencrawl.mjs +0 -553
- package/browser.mjs +0 -166
- package/src/accounts.test.mjs +0 -223
- package/src/agent.mjs +0 -95
- package/src/agent.test.mjs +0 -58
- package/src/answer-contract.mjs +0 -112
- package/src/answer-view.mjs +0 -118
- package/src/browser.test.mjs +0 -72
- package/src/capture.mjs +0 -273
- package/src/capture.test.mjs +0 -171
- package/src/client.mjs +0 -183
- package/src/client.test.mjs +0 -34
- package/src/connect.mjs +0 -230
- package/src/connect.test.mjs +0 -131
- package/src/context.mjs +0 -34
- package/src/context.test.mjs +0 -32
- package/src/display.mjs +0 -4
- package/src/fixtures/letter-connections.json +0 -59
- package/src/fixtures/product-monitor-contract.json +0 -312
- package/src/fixtures/research-completion-hosted.json +0 -502
- package/src/fixtures/research-completion-v1.json +0 -58
- package/src/fixtures/research-completion-v2.json +0 -33
- package/src/fixtures/research-presentation-hosted.json +0 -720
- package/src/jobs.mjs +0 -117
- package/src/jobs.test.mjs +0 -67
- package/src/mcp-conversations.test.mjs +0 -80
- package/src/monitors.mjs +0 -146
- package/src/monitors.test.mjs +0 -62
- package/src/product-letter-capabilities.mjs +0 -71
- package/src/product-letter-read.mjs +0 -178
- package/src/product-monitor-login.test.mjs +0 -140
- package/src/product-monitors.mjs +0 -321
- package/src/product-monitors.test.mjs +0 -69
- package/src/research-agent-errors.test.mjs +0 -110
- package/src/research-agent-feedback.test.mjs +0 -28
- package/src/research-agent.mjs +0 -164
- package/src/research-agent.test.mjs +0 -231
- package/src/research-answer-contract.mjs +0 -3
- package/src/research-answer-contract.test.mjs +0 -14
- package/src/research-answer-view.mjs +0 -7
- package/src/research-answer-view.test.mjs +0 -27
- package/src/research-completion.mjs +0 -94
- package/src/research-completion.test.mjs +0 -110
- package/src/research-event.mjs +0 -39
- package/src/research-event.test.mjs +0 -57
- package/src/research-presentation.test.mjs +0 -56
- package/src/research.mjs +0 -141
- package/src/research.test.mjs +0 -97
- package/src/sessions.mjs +0 -165
- package/src/sessions.test.mjs +0 -151
- package/src/tasks.mjs +0 -115
- package/src/tasks.test.mjs +0 -83
- package/src/upload-commands.test.mjs +0 -140
- package/src/uploads.mjs +0 -141
- package/src/uploads.test.mjs +0 -64
package/src/jobs.mjs
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { displayLine } from './display.mjs';
|
|
2
|
-
|
|
3
|
-
export const JOBS_HELP = `OpenCrawl · follow background jobs
|
|
4
|
-
|
|
5
|
-
opencrawl jobs list [--status active|completed|failed|cancelled|discarded] [--limit 20] [--offset 0]
|
|
6
|
-
opencrawl jobs show JOB_ID [--json]
|
|
7
|
-
opencrawl jobs wait JOB_ID [--timeout 300] [--json]
|
|
8
|
-
|
|
9
|
-
Wait stops after 1–3600 seconds (default 300). Ctrl+C stops waiting locally.
|
|
10
|
-
It never cancels, retries, or resubmits a job. The job ID remains in the receipt
|
|
11
|
-
on timeout, permission failure, or job failure. Only completed exits successfully.
|
|
12
|
-
Completion is for this job; source scan coverage and provider cleanup are separate.
|
|
13
|
-
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
14
|
-
`;
|
|
15
|
-
export const GLOBAL_OPTIONS = ['account', 'server', 'config-dir', 'json'];
|
|
16
|
-
export const ACTIVE = new Set(['queued', 'running', 'waiting', 'processing', 'pending']);
|
|
17
|
-
export const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'discarded']);
|
|
18
|
-
export function decimal(value, name, min, max, fallback) {
|
|
19
|
-
if (value === undefined && fallback !== undefined) return fallback;
|
|
20
|
-
if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max)
|
|
21
|
-
throw new Error(`--${name} must be a decimal integer between ${min} and ${max}.`);
|
|
22
|
-
return Number(value);
|
|
23
|
-
}
|
|
24
|
-
export function allowedOptions(options, allowed, command) {
|
|
25
|
-
for (const key of Object.keys(options)) if (![...GLOBAL_OPTIONS, ...allowed].includes(key)) throw new Error(`Unsupported ${command} option: --${key}. Run opencrawl ${command} --help.`);
|
|
26
|
-
}
|
|
27
|
-
export function identifier(value, kind = 'job') {
|
|
28
|
-
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(value)) throw new Error(`Supply the exact ${kind} ID from its receipt.`);
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
export function waitOptions(options) {
|
|
32
|
-
if (options.timeout !== undefined && !options.wait) throw new Error('--timeout requires --wait. Use jobs wait to follow an existing job.');
|
|
33
|
-
return decimal(options.timeout, 'timeout', 1, 3600, 300);
|
|
34
|
-
}
|
|
35
|
-
export function requestFailure(error) {
|
|
36
|
-
const status = Number.isInteger(error?.status) ? error.status : null;
|
|
37
|
-
const message = ({401: 'Sign in with a valid key for the intended workspace.', 403: 'This account cannot access this operation.',
|
|
38
|
-
404: 'This job or upload receipt is unavailable to this account. Check the account and saved ID.',
|
|
39
|
-
409: 'The request conflicts with the saved upload details or current source state. Inspect the receipt and source before explicitly retrying; do not start a duplicate upload.',
|
|
40
|
-
413: 'The file exceeds an upload limit.', 422: 'The upload details or content failed validation. Check the file and its scope.',
|
|
41
|
-
429: 'OpenCrawl is busy or a workspace limit was reached. Inspect the receipt before retrying.',
|
|
42
|
-
507: 'Workspace storage admission failed. Inspect the receipt before retrying.'})[status] || (status ? 'OpenCrawl could not complete this request. Inspect its receipt before retrying.' : 'The request outcome could not be confirmed. Check the connection and inspect the saved receipt.');
|
|
43
|
-
return { code: status ? `http_${status}` : 'request_unconfirmed', message, ...(status ? { status } : {}) };
|
|
44
|
-
}
|
|
45
|
-
export function validateJob(job, id = job?.id) {
|
|
46
|
-
if (!job || typeof job.id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(job.id) || job.id !== id || typeof job.status !== 'string' || !ACTIVE.has(job.status) && !TERMINAL.has(job.status)) throw new Error('Server returned an invalid job receipt. No completion was assumed.');
|
|
47
|
-
return job;
|
|
48
|
-
}
|
|
49
|
-
export const getJob = async (client, id, options = {}) => validateJob(await client.request(`/api/v1/jobs/${encodeURIComponent(identifier(id))}`, { maxResponseBytes: 512 * 1024, ...options }), id);
|
|
50
|
-
|
|
51
|
-
// Failures that say nothing about the job itself: the server restarting, a proxy, the network. Polling keeps going.
|
|
52
|
-
const TRANSIENT=new Set([408,425,429,500,502,503,504]);
|
|
53
|
-
export function transient(error){return error?.transient===true||TRANSIENT.has(error?.status);}
|
|
54
|
-
// Wait before the next poll after a transient failure: 2s, 4s, 8s, then every 15s, never past the deadline.
|
|
55
|
-
export async function settle(failures,{signal,now,sleep,deadline}){const next=Math.min(deadline,now()+Math.min(15000,1000*2**failures));while(!signal?.aborted&&now()<next)await sleep(Math.min(100,next-now()));}
|
|
56
|
-
|
|
57
|
-
export async function waitForJob(client, id, { timeout = 300, signal, now = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), onUpdate = () => {}, initial, deadline } = {}) {
|
|
58
|
-
identifier(id); const started = now(); deadline ??= started + timeout * 1000;
|
|
59
|
-
let job = initial ? validateJob(initial, id) : null, failures = 0;
|
|
60
|
-
const result = (outcome, error) => ({ job_id: id, job, wait: { outcome, elapsed_seconds: Math.max(0, Math.round((now() - started) / 1000)), ...(error ? { error } : {}) } });
|
|
61
|
-
while (true) {
|
|
62
|
-
if (signal?.aborted) return result('interrupted');
|
|
63
|
-
if (job && TERMINAL.has(job.status)) return result(job.status);
|
|
64
|
-
const remaining = deadline - now();
|
|
65
|
-
if (remaining <= 0) return result('timeout');
|
|
66
|
-
try { job = await getJob(client, id, { timeout: Math.max(1, Math.min(30000, Math.ceil(remaining))), signal }); failures = 0; }
|
|
67
|
-
catch (error) {
|
|
68
|
-
if (!signal?.aborted && now() < deadline && transient(error)) { await settle(++failures, {signal, now, sleep, deadline}); continue; }
|
|
69
|
-
return result(signal?.aborted ? 'interrupted' : now() >= deadline ? 'timeout' : 'unavailable', requestFailure(error));
|
|
70
|
-
}
|
|
71
|
-
onUpdate(job);
|
|
72
|
-
if (TERMINAL.has(job.status)) return result(job.status);
|
|
73
|
-
// Short sleeps keep Ctrl+C responsive without extra server polling.
|
|
74
|
-
const next = Math.min(deadline, now() + 2000);
|
|
75
|
-
while (!signal?.aborted && now() < next) await sleep(Math.min(100, next - now()));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
export async function withInterrupt(action) {
|
|
79
|
-
const controller = new AbortController(); const stop = () => controller.abort();
|
|
80
|
-
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
|
81
|
-
try { return await action(controller.signal); }
|
|
82
|
-
finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); }
|
|
83
|
-
}
|
|
84
|
-
export function renderJob(job) {
|
|
85
|
-
let text = `${displayLine(job.id)}\t${displayLine(job.status)}\t${displayLine(job.kind)}${job.filename ? '\t' + displayLine(job.filename) : ''}\n`;
|
|
86
|
-
if (job.error) text += `Issue: ${displayLine(job.error)}\n`;
|
|
87
|
-
if (typeof job.indexed_documents === 'number') text += `Indexed documents: ${job.indexed_documents}\n`;
|
|
88
|
-
for (const doc of job.documents || []) text += `${displayLine(doc.id)}\t${displayLine(doc.title)}\n`;
|
|
89
|
-
if (job.documents_truncated) text += 'Additional indexed documents are available in OpenCrawl.\n';
|
|
90
|
-
if (job.coverage) text += `Source listing coverage: ${displayLine(job.coverage.state)} (separate from this job's status).\n`;
|
|
91
|
-
return text;
|
|
92
|
-
}
|
|
93
|
-
export async function jobsCommand(rest, options, client, { stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
|
|
94
|
-
const [command, id, ...extra] = rest;
|
|
95
|
-
if (!['list', 'show', 'wait'].includes(command)) throw new Error('Choose jobs list, show, or wait. Run opencrawl jobs --help.');
|
|
96
|
-
allowedOptions(options, command === 'list' ? ['status', 'limit', 'offset'] : command === 'wait' ? ['timeout'] : [], 'jobs');
|
|
97
|
-
if (extra.length || command === 'list' && id !== undefined) throw new Error('Unexpected jobs argument.');
|
|
98
|
-
if (command === 'list') {
|
|
99
|
-
if (options.status !== undefined && !['active', ...ACTIVE, ...TERMINAL].includes(options.status)) throw new Error('Choose a job status shown in jobs --help.');
|
|
100
|
-
const query = new URLSearchParams({ limit: decimal(options.limit, 'limit', 1, 200, 20), offset: decimal(options.offset, 'offset', 0, 1000000, 0) });
|
|
101
|
-
if (options.status) query.set('status', options.status);
|
|
102
|
-
const data = await client.request('/api/v1/jobs?' + query, { maxResponseBytes: 2 * 1024 * 1024 });
|
|
103
|
-
if (!Array.isArray(data?.items)) throw new Error('Server returned an invalid job list.');
|
|
104
|
-
if (options.json) stdout(JSON.stringify(data, null, 2) + '\n');
|
|
105
|
-
else { stdout(data.items.map(renderJob).join('') || 'No jobs on this page.\n'); if (data.has_more) stderr(`More jobs are available; use --offset ${Number(data.offset) + data.items.length}.\n`); }
|
|
106
|
-
return 0;
|
|
107
|
-
}
|
|
108
|
-
identifier(id);
|
|
109
|
-
if (command === 'show') {
|
|
110
|
-
const job = await getJob(client, id); stdout(options.json ? JSON.stringify(job, null, 2) + '\n' : renderJob(job)); return 0;
|
|
111
|
-
}
|
|
112
|
-
const timeout = decimal(options.timeout, 'timeout', 1, 3600, 300);
|
|
113
|
-
stderr(`Following job ${displayLine(id)} for at most ${timeout} seconds.\n`);
|
|
114
|
-
const result = await withInterrupt(signal => waitForJob(client, id, { timeout, signal }));
|
|
115
|
-
stdout(options.json ? JSON.stringify(result, null, 2) + '\n' : (result.job ? renderJob(result.job) : `Job: ${displayLine(id)}\n`) + `Wait: ${displayLine(result.wait.outcome)}. The job was not cancelled or resubmitted.\n` + (result.wait.error ? result.wait.error.message + '\n' : ''));
|
|
116
|
-
return result.wait.outcome === 'completed' ? 0 : 1;
|
|
117
|
-
}
|
package/src/jobs.test.mjs
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { jobsCommand, waitForJob, waitOptions, renderJob } from './jobs.mjs';
|
|
4
|
-
import { displayText, displayLine } from './display.mjs';
|
|
5
|
-
|
|
6
|
-
const id = 'job_' + 'a'.repeat(32);
|
|
7
|
-
const job = status => ({ id, status, kind: 'upload', indexed_documents: status === 'completed' ? 1 : 0, documents: [] });
|
|
8
|
-
test('job waiting follows exact IDs and stops on terminal status without any mutation', async () => {
|
|
9
|
-
let time = 0; const calls = []; const statuses = ['queued', 'running', 'completed'];
|
|
10
|
-
const result = await waitForJob({ request: async (route, options) => { calls.push([route, options]); return job(statuses.shift()); } }, id, { now: () => time, sleep: async ms => { time += ms; } });
|
|
11
|
-
assert.equal(result.wait.outcome, 'completed'); assert.equal(result.job_id, id); assert.equal(time, 4000);
|
|
12
|
-
assert.equal(calls.length, 3); assert(calls.every(([route, options]) => route === `/api/v1/jobs/${id}` && !options.method && options.timeout <= 30000));
|
|
13
|
-
});
|
|
14
|
-
test('all unsuccessful terminal states retain the job and return unsuccessful wait receipts', async () => {
|
|
15
|
-
for (const status of ['failed', 'cancelled', 'discarded']) {
|
|
16
|
-
const result = await waitForJob({ request: async () => job(status) }, id);
|
|
17
|
-
assert.equal(result.wait.outcome, status); assert.equal(result.job.id, id);
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
test('finite timeout and interruption do not cancel or retry work', async () => {
|
|
21
|
-
let time = 0, calls = 0;
|
|
22
|
-
const result = await waitForJob({ request: async (route, options) => { calls++; assert(options.timeout <= 1000); return job('waiting'); } }, id, { timeout: 1, now: () => time, sleep: async ms => { time += ms; } });
|
|
23
|
-
assert.equal(result.wait.outcome, 'timeout'); assert.equal(calls, 1); assert.equal(result.job_id, id);
|
|
24
|
-
const controller = new AbortController(); controller.abort();
|
|
25
|
-
const interrupted = await waitForJob({ request: async () => { throw Error('must not call'); } }, id, { signal: controller.signal });
|
|
26
|
-
assert.equal(interrupted.wait.outcome, 'interrupted'); assert.equal(interrupted.job_id, id);
|
|
27
|
-
});
|
|
28
|
-
test('permission and unknown failures never imply completion', async () => {
|
|
29
|
-
for (const status of [401,403,404,422,undefined]) {
|
|
30
|
-
let calls = 0;
|
|
31
|
-
const result = await waitForJob({ request: async () => { calls++; const error = Error('RAW PRIVATE BODY'); error.status = status; throw error; } }, id);
|
|
32
|
-
assert.equal(result.wait.outcome, 'unavailable'); assert.equal(result.job_id, id); assert.equal(calls, 1); assert(!JSON.stringify(result).includes('RAW PRIVATE'));
|
|
33
|
-
}
|
|
34
|
-
const result = await waitForJob({ request: async () => job('surprise') }, id);
|
|
35
|
-
assert.equal(result.wait.outcome, 'unavailable'); assert.equal(result.job, null);
|
|
36
|
-
});
|
|
37
|
-
test('jobs validate option bounds before calling the server', async () => {
|
|
38
|
-
const client = { request: async () => { throw Error('unexpected call'); } };
|
|
39
|
-
for (const [rest, options] of [[['wait', id], { timeout: '0' }], [['wait', id], { timeout: '3601' }], [['list'], { limit: '1e2' }], [['show', '../secret'], {}], [['show', id], { retry: true }], [['list', 'extra'], {}]]) {
|
|
40
|
-
await assert.rejects(jobsCommand(rest, options, client), error => !error.message.includes('unexpected call'));
|
|
41
|
-
}
|
|
42
|
-
assert.throws(() => waitOptions({ timeout: '1' }), /requires --wait/);
|
|
43
|
-
});
|
|
44
|
-
test('job human display escapes terminal controls while JSON retains source text', async () => {
|
|
45
|
-
const value = { ...job('failed'), filename: 'report\u001b]52;copy\u0007.pdf', error: 'error\rreplacement\u202e', documents: [{ id: 'doc', title: 'a\nb' }] };
|
|
46
|
-
assert(!renderJob(value).includes('\u001b')); assert(renderJob(value).includes('\\u001b')); assert(renderJob(value).includes('a\\nb'));
|
|
47
|
-
assert.equal(displayText('a\nb\tc\u001b'), 'a\nb\tc\\u001b'); assert.equal(displayLine('a\nb'), 'a\\nb');
|
|
48
|
-
let output = ''; await jobsCommand(['show', id], { json: true }, { request: async () => value }, { stdout: text => { output += text; } });
|
|
49
|
-
assert.equal(JSON.parse(output).filename, value.filename);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('a restart, proxy error or lost connection keeps the wait going until the job finishes', async () => {
|
|
53
|
-
let time = 0, calls = 0;
|
|
54
|
-
const failures = [502, 503, 429, 'network'];
|
|
55
|
-
const result = await waitForJob({ request: async () => {
|
|
56
|
-
calls++;
|
|
57
|
-
const next = failures.shift();
|
|
58
|
-
if (next === undefined) return job('completed');
|
|
59
|
-
const error = Error('RAW PRIVATE BODY'); if (next === 'network') error.transient = true; else error.status = next; throw error;
|
|
60
|
-
} }, id, { now: () => time, sleep: async ms => { time += ms; } });
|
|
61
|
-
assert.equal(result.wait.outcome, 'completed'); assert.equal(calls, 5);
|
|
62
|
-
assert(time >= 2000 + 4000 + 8000 + 15000); // backs off instead of hammering a restarting server
|
|
63
|
-
// Still bounded by the caller's deadline.
|
|
64
|
-
time = 0;
|
|
65
|
-
const stuck = await waitForJob({ request: async () => { const error = Error('x'); error.status = 503; throw error; } }, id, { timeout: 60, now: () => time, sleep: async ms => { time += ms; } });
|
|
66
|
-
assert.equal(stuck.wait.outcome, 'timeout');
|
|
67
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import fs from 'node:fs/promises';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
|
|
10
|
-
const script = fileURLToPath(new URL('../bin/opencrawl.mjs', import.meta.url));
|
|
11
|
-
const names = ['opencrawl_list_conversations', 'opencrawl_read_conversation', 'opencrawl_conversation_context'];
|
|
12
|
-
|
|
13
|
-
test('stdio forwards bounded conversation tools and structured errors without rewriting evidence', async () => {
|
|
14
|
-
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-mcp-conversation-'));
|
|
15
|
-
const calls = [];
|
|
16
|
-
const version = 'a'.repeat(64);
|
|
17
|
-
const selector = { ref: 't2', start_char: 12, end_char: 24 };
|
|
18
|
-
const context = 'Quoted source: 日本語 🌲\n\u001b[31m';
|
|
19
|
-
const receipt = { version, context, selected: [selector], included: [{ ...selector, citation: 'C1' }],
|
|
20
|
-
omitted: [], context_bytes: Buffer.byteLength(context), budget: 700, budget_unit: 'utf8_bytes' };
|
|
21
|
-
const error = { code: 'snapshot_changed', message: 'Read the conversation again before selecting evidence.' };
|
|
22
|
-
const server = http.createServer(async (request, response) => {
|
|
23
|
-
let raw = '';for await (const part of request) raw += part;
|
|
24
|
-
const message = JSON.parse(raw);
|
|
25
|
-
calls.push({ path: request.url, authorization: request.headers.authorization,
|
|
26
|
-
protocol: request.headers['mcp-protocol-version'], message });
|
|
27
|
-
if (!Object.hasOwn(message, 'id')) { response.writeHead(202);response.end();return; }
|
|
28
|
-
let result;
|
|
29
|
-
if (message.method === 'initialize') result = { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } };
|
|
30
|
-
else if (message.method === 'tools/list') result = { tools: names.map(name => ({ name, inputSchema: { type: 'object' } })) };
|
|
31
|
-
else {
|
|
32
|
-
const failed = message.params.arguments.version === 'b'.repeat(64);
|
|
33
|
-
const value = failed ? error : receipt;
|
|
34
|
-
result = { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value, isError: failed };
|
|
35
|
-
}
|
|
36
|
-
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
37
|
-
response.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }));
|
|
38
|
-
});
|
|
39
|
-
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
40
|
-
try {
|
|
41
|
-
for (const token of ['oc_synthetic_api_only', 'oca_synthetic_oauth_only']) {
|
|
42
|
-
const env = { ...process.env };
|
|
43
|
-
for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
|
|
44
|
-
Object.assign(env, { OPENCRAWL_CONFIG_DIR: path.join(directory, 'isolated'),
|
|
45
|
-
OPENCRAWL_URL: `http://127.0.0.1:${server.address().port}`, OPENCRAWL_API_KEY: token });
|
|
46
|
-
const args = { document_id: 'doc_synthetic', version, branch: 'selected', selections: [selector], budget: 700 };
|
|
47
|
-
const messages = [
|
|
48
|
-
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25' } },
|
|
49
|
-
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
50
|
-
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
|
51
|
-
...names.map((name, index) => ({ jsonrpc: '2.0', id: index + 3, method: 'tools/call', params: { name, arguments: args } })),
|
|
52
|
-
{ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: names[2], arguments: { ...args, version: 'b'.repeat(64) } } },
|
|
53
|
-
];
|
|
54
|
-
const before = calls.length;
|
|
55
|
-
const result = await new Promise((resolve, reject) => {
|
|
56
|
-
const child = spawn(process.execPath, [script, 'mcp'], { cwd: directory, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
57
|
-
let stdout = '', stderr = '';const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
|
|
58
|
-
child.stdout.on('data', part => { stdout += part; });child.stderr.on('data', part => { stderr += part; });
|
|
59
|
-
child.once('error', reject);child.once('close', code => { clearTimeout(timer);resolve({ code, stdout, stderr }); });
|
|
60
|
-
child.stdin.end(messages.map(message => JSON.stringify(message)).join('\n') + '\n');
|
|
61
|
-
});
|
|
62
|
-
assert.equal(result.code, 0);assert.equal(result.stderr, '');assert.ok(!result.stdout.includes(token));
|
|
63
|
-
const responses = result.stdout.trim().split('\n').map(line => JSON.parse(line));
|
|
64
|
-
assert.deepEqual(responses.map(value => value.id), [1, 2, 3, 4, 5, 6]);
|
|
65
|
-
assert.deepEqual(responses[1].result.tools.map(tool => tool.name), names);
|
|
66
|
-
for (const response of responses.slice(2, 5)) {
|
|
67
|
-
assert.equal(response.result.isError, false);assert.deepEqual(response.result.structuredContent, receipt);
|
|
68
|
-
assert.deepEqual(JSON.parse(response.result.content[0].text), receipt);
|
|
69
|
-
}
|
|
70
|
-
assert.equal(responses[5].result.isError, true);assert.deepEqual(responses[5].result.structuredContent, error);
|
|
71
|
-
assert.deepEqual(calls.slice(before).map(call => call.message), messages);
|
|
72
|
-
assert.ok(calls.slice(before).every(call => call.path === '/mcp' && call.authorization === `Bearer ${token}`));
|
|
73
|
-
assert.ok(calls.slice(before + 1).every(call => call.protocol === '2025-11-25'));
|
|
74
|
-
}
|
|
75
|
-
await assert.rejects(fs.stat(path.join(directory, 'isolated')), { code: 'ENOENT' });
|
|
76
|
-
} finally {
|
|
77
|
-
await new Promise(resolve => server.close(resolve));
|
|
78
|
-
await fs.rm(directory, { recursive: true, force: true });
|
|
79
|
-
}
|
|
80
|
-
});
|
package/src/monitors.mjs
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { allowedOptions, decimal, validateJob, renderJob } from './jobs.mjs';
|
|
2
|
-
import { displayLine } from './display.mjs';
|
|
3
|
-
|
|
4
|
-
export const MONITORS_HELP = `OpenCrawl · inspect exact public-page observations
|
|
5
|
-
|
|
6
|
-
opencrawl monitors list [--limit 20] [--offset 0] [--json]
|
|
7
|
-
opencrawl monitors show SOURCE_ID [--json]
|
|
8
|
-
opencrawl monitors check SOURCE_ID --generation N [--json]
|
|
9
|
-
opencrawl monitors changes [--source SOURCE_ID] [--limit 20] [--cursor CURSOR] [--json]
|
|
10
|
-
opencrawl monitors context CHANGE_ID --revision HASH [--budget 4096] [--json]
|
|
11
|
-
|
|
12
|
-
Create monitors in OpenCrawl with 1–5 explicit public static URLs. Checks do not
|
|
13
|
-
follow discovered links or execute JavaScript. A failed or missing page never
|
|
14
|
-
proves a job closed or content was removed. Inspect show for each page's latest
|
|
15
|
-
attempt and last good observation. Check queues work; follow its job separately.
|
|
16
|
-
Context requires an exact change ID and immutable version from changes. It
|
|
17
|
-
exports before/after evidence, including observation times, within 256–16384
|
|
18
|
-
UTF-8 bytes. Truncation and omissions go to stderr; stdout is exact context.
|
|
19
|
-
--json preserves the complete receipt. No automatic check or newer substitution.
|
|
20
|
-
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
21
|
-
`;
|
|
22
|
-
const SOURCE = /^src_[a-f0-9]{32}$/, CHANGE = /^mchg_[a-f0-9]{32}$/, HASH = /^[a-f0-9]{64}$/;
|
|
23
|
-
const integer = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
|
|
24
|
-
const id = (value, pattern, kind) => {
|
|
25
|
-
if (typeof value !== 'string' || !pattern.test(value)) throw new Error(`Supply the exact ${kind} ID from its list.`);
|
|
26
|
-
return value;
|
|
27
|
-
};
|
|
28
|
-
const cursor = value => {
|
|
29
|
-
if (typeof value !== 'string' || !value || value.length > 256 || /[\x00-\x20\x7f]/.test(value)) throw new Error('Use the unchanged opaque cursor from the previous changes page.');
|
|
30
|
-
return value;
|
|
31
|
-
};
|
|
32
|
-
export function monitorRequest(rest, options) {
|
|
33
|
-
const [command, value, ...extra] = rest;
|
|
34
|
-
const allowed = {list: ['limit','offset'], show: [], check: ['generation'], changes: ['source','limit','cursor'], context: ['revision','budget']};
|
|
35
|
-
if (!Object.hasOwn(allowed, command)) throw new Error('Choose monitors list, show, check, changes, or context. Run opencrawl monitors --help.');
|
|
36
|
-
allowedOptions(options, allowed[command], 'monitors');
|
|
37
|
-
if (extra.length || ['list','changes'].includes(command) && value !== undefined) throw new Error('Unexpected monitors argument.');
|
|
38
|
-
if (command === 'list') {
|
|
39
|
-
const limit=decimal(options.limit,'limit',1,20,20), offset=decimal(options.offset,'offset',0,1000000,0);
|
|
40
|
-
return {command,limit,offset,route:'/api/v1/monitors?'+new URLSearchParams({limit,offset}),options:{}};
|
|
41
|
-
}
|
|
42
|
-
if (command === 'changes') {
|
|
43
|
-
const limit=decimal(options.limit,'limit',1,20,20), query=new URLSearchParams({limit});
|
|
44
|
-
if (options.source !== undefined) query.set('source_id',id(options.source,SOURCE,'monitor'));
|
|
45
|
-
if (options.cursor !== undefined) query.set('cursor',cursor(options.cursor));
|
|
46
|
-
return {command,limit,source:options.source,route:'/api/v1/monitor-changes?'+query,options:{}};
|
|
47
|
-
}
|
|
48
|
-
if (command === 'context') {
|
|
49
|
-
id(value,CHANGE,'change');
|
|
50
|
-
if (typeof options.revision !== 'string' || !HASH.test(options.revision)) throw new Error('--revision must be the exact 64-character lowercase version hash from changes.');
|
|
51
|
-
const budget=decimal(options.budget,'budget',256,16384,4096), selections=[{change_id:value,version:options.revision}];
|
|
52
|
-
return {command,id:value,selections,budget,route:'/api/v1/monitor-changes/context',options:{method:'POST',body:{selections,budget}}};
|
|
53
|
-
}
|
|
54
|
-
id(value,SOURCE,'monitor');
|
|
55
|
-
return {command,id:value,route:'/api/v1/monitors/'+value+(command==='check'?'/check':''),
|
|
56
|
-
options:command==='check'?{method:'POST',body:{expected_generation:decimal(options.generation,'generation',1,Number.MAX_SAFE_INTEGER)}}:{}};
|
|
57
|
-
}
|
|
58
|
-
function validateMonitor(value, expected = value?.id) {
|
|
59
|
-
if (!value || !SOURCE.test(value.id) || value.id!==expected || value.kind!=='monitor' || typeof value.name!=='string' ||
|
|
60
|
-
!integer(value.generation,1,Number.MAX_SAFE_INTEGER) || !['active','paused','revoked','error'].includes(value.status) ||
|
|
61
|
-
!Array.isArray(value.targets) || value.targets.length<1 || value.targets.length>5 || value.targets.some(target=>
|
|
62
|
-
!target || typeof target.requested_url!=='string' || typeof target.state!=='string' || typeof target.enabled!=='boolean'))
|
|
63
|
-
throw new Error('Server returned an invalid monitor receipt. No check was assumed.');
|
|
64
|
-
return value;
|
|
65
|
-
}
|
|
66
|
-
function validateChange(value, source) {
|
|
67
|
-
if (!value || !CHANGE.test(value.id) || !HASH.test(value.version) || !SOURCE.test(value.source_id) || source && value.source_id!==source ||
|
|
68
|
-
typeof value.title!=='string' || typeof value.final_url!=='string' || typeof value.observed_before!=='string' || typeof value.observed_after!=='string')
|
|
69
|
-
throw new Error('Server returned an invalid or out-of-scope change receipt.');
|
|
70
|
-
return value;
|
|
71
|
-
}
|
|
72
|
-
export function validateChangeContext(value, request) {
|
|
73
|
-
const invalid=()=>{throw new Error('Server returned an invalid historical context receipt. No context was emitted; inspect the change again.');};
|
|
74
|
-
if (!value || typeof value.context!=='string' || value.budget!==request.budget || value.budget_unit!=='utf8_bytes' ||
|
|
75
|
-
Buffer.byteLength(value.context)!==value.context_bytes || value.context_bytes>request.budget ||
|
|
76
|
-
!Array.isArray(value.selections) || value.selections.length!==1 || value.selections[0]?.change_id!==request.id || value.selections[0]?.version!==request.selections[0].version ||
|
|
77
|
-
!Array.isArray(value.included) || value.included.length>2 || !Array.isArray(value.omitted) || value.omitted.length>2 ||
|
|
78
|
-
!Array.isArray(value.warnings) || value.warnings.length>20 || value.warnings.some(w=>typeof w!=='string') ||
|
|
79
|
-
Boolean(value.context)!==Boolean(value.included.length)) invalid();
|
|
80
|
-
const seen=new Set(), labels=new Set(), chars=Array.from(value.context); let lastEnd=0;
|
|
81
|
-
for (const entry of [...value.included,...value.omitted]) {
|
|
82
|
-
if (!entry || entry.change_id!==request.id || entry.version!==request.selections[0].version || !['before','after'].includes(entry.side) || seen.has(entry.side)) invalid();
|
|
83
|
-
seen.add(entry.side);
|
|
84
|
-
}
|
|
85
|
-
if (seen.size!==2 || value.omitted.some(entry=>entry.reason!=='budget')) invalid();
|
|
86
|
-
for (const entry of value.included) {
|
|
87
|
-
const span=entry.context_span, excerpt=entry.excerpt;
|
|
88
|
-
if (typeof excerpt!=='string' || !excerpt || typeof entry.citation!=='string' || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(entry.citation) || labels.has(entry.citation) ||
|
|
89
|
-
!integer(entry.full_length,0,131072) || !integer(entry.start_char,0,entry.full_length) || !integer(entry.end_char,entry.start_char+1,entry.full_length) ||
|
|
90
|
-
Array.from(excerpt).length!==entry.end_char-entry.start_char || typeof entry.truncated!=='boolean' ||
|
|
91
|
-
typeof entry.observed_at!=='string' || typeof entry.url!=='string' || !span || !integer(span.start_char,lastEnd,chars.length) ||
|
|
92
|
-
!integer(span.end_char,span.start_char+1,chars.length) || chars.slice(span.start_char,span.end_char).join('')!==excerpt) invalid();
|
|
93
|
-
labels.add(entry.citation); lastEnd=span.end_char;
|
|
94
|
-
}
|
|
95
|
-
return value;
|
|
96
|
-
}
|
|
97
|
-
export function monitorFailure(error) {
|
|
98
|
-
const status=Number.isInteger(error?.status)?error.status:undefined;
|
|
99
|
-
const codes={monitor_changed:'Monitor settings changed. Run monitors show and review the current generation before explicitly checking again.',
|
|
100
|
-
monitor_not_active:'This monitor is not active. Review its status in OpenCrawl; no check was queued.',
|
|
101
|
-
monitor_change_changed:'The historical version does not match. Inspect changes and explicitly select the intended immutable version; no newer change was substituted.',
|
|
102
|
-
monitor_evidence_changed:'Historical evidence or its access changed. Inspect the change again before explicitly requesting context.'};
|
|
103
|
-
if (status===409 && Object.hasOwn(codes,error.code)) return {code:error.code,status,message:codes[error.code]};
|
|
104
|
-
const message=({401:'Sign in with a valid key for the intended workspace.',403:'This account cannot perform this monitor operation.',
|
|
105
|
-
404:'This monitor or historical change is unavailable to this account, or its retained history has expired. No other evidence was substituted.',
|
|
106
|
-
409:'The monitor or historical selection changed. Inspect it before explicitly trying again.',413:'A monitor or retained-history limit was reached. Review capacity in OpenCrawl.',
|
|
107
|
-
422:'The monitor request is invalid. Check its exact ID, generation, version and byte budget.',429:'OpenCrawl is busy or a workspace limit was reached. Inspect the monitor before checking again.',
|
|
108
|
-
507:'Workspace storage admission failed. Review storage before checking again.'})[status] ||
|
|
109
|
-
'The monitor request outcome could not be confirmed. Inspect its current state before explicitly trying again. No automatic retry was made.';
|
|
110
|
-
return {code:status?`http_${status}`:'request_unconfirmed',message,...(status?{status}:{})};
|
|
111
|
-
}
|
|
112
|
-
export async function monitorsCommand(rest, options, client, {stdout=value=>process.stdout.write(value),stderr=value=>process.stderr.write(value)}={}) {
|
|
113
|
-
const request=monitorRequest(rest,options); let data;
|
|
114
|
-
try {data=await client.request(request.route,{...request.options,maxResponseBytes:1024*1024});}
|
|
115
|
-
catch(error){const failure=monitorFailure(error);if(options.json)stdout(JSON.stringify({error:failure},null,2)+'\n');else stderr(failure.message+'\n');return 1;}
|
|
116
|
-
if(request.command==='list'){
|
|
117
|
-
if(!Array.isArray(data?.items)||data.items.length>request.limit||data.limit!==request.limit||data.offset!==request.offset)throw new Error('Server returned an invalid monitor page.');
|
|
118
|
-
data.items.forEach(value=>validateMonitor(value));
|
|
119
|
-
if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
|
|
120
|
-
else {stdout(data.items.map(value=>`${value.id}\tg${value.generation}\t${value.status}\t${displayLine(value.name)}\n`).join('')||'No monitors on this page.\n');if(data.has_more)stderr(`Next page: --offset ${request.offset+data.items.length}.\n`);}
|
|
121
|
-
}else if(request.command==='show'){
|
|
122
|
-
validateMonitor(data,request.id);
|
|
123
|
-
if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
|
|
124
|
-
else {stdout(`${displayLine(data.name)}\nMonitor: ${data.id}\nGeneration: ${data.generation}; status: ${data.status}\n`);
|
|
125
|
-
for(const target of data.targets)stdout(`${displayLine(target.requested_url)}\n ${displayLine(target.state)}; latest attempt: ${displayLine(target.last_attempt_at||'none')}; last good observation: ${displayLine(target.last_success_at||'none')}\n${target.issue?.message?' '+displayLine(target.issue.message)+'\n':''}`);
|
|
126
|
-
stderr('Only selected static pages are checked. Failure or missing content is not evidence of removal.\n');}
|
|
127
|
-
}else if(request.command==='check'){
|
|
128
|
-
validateJob(data?.job);
|
|
129
|
-
stdout(options.json?JSON.stringify(data,null,2)+'\n':renderJob(data.job));
|
|
130
|
-
if(!options.json)stderr(`Check accepted; this does not confirm page coverage. Follow with jobs wait ${data.job.id}, then monitors show ${request.id}.\n`);
|
|
131
|
-
}else if(request.command==='changes'){
|
|
132
|
-
if(!Array.isArray(data?.items)||data.items.length>request.limit||data.limit!==request.limit||!(data.next_cursor===null||typeof data.next_cursor==='string'))throw new Error('Server returned an invalid change page.');
|
|
133
|
-
data.items.forEach(value=>validateChange(value,request.source));if(data.next_cursor!==null)cursor(data.next_cursor);
|
|
134
|
-
if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
|
|
135
|
-
else {stdout(data.items.map(value=>`${value.id}\t${displayLine(value.title)}\nVersion: ${value.version}\nObserved: ${displayLine(value.observed_before)} → ${displayLine(value.observed_after)}${value.gap?' (failed checks between observations)':''}\n${displayLine(value.final_url)}\n`).join('\n')||'No retained text changes on this page.\n');
|
|
136
|
-
if(data.next_cursor)stderr(`Next page: --cursor ${displayLine(data.next_cursor)}${request.source?' --source '+request.source:''}.\n`);}
|
|
137
|
-
}else{
|
|
138
|
-
validateChangeContext(data,request);
|
|
139
|
-
if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
|
|
140
|
-
else {stderr(`${data.included.length}/2 sides included; ${data.context_bytes}/${data.budget} UTF-8 bytes. Observation times are not remote publication times.\n`);
|
|
141
|
-
for(const entry of data.included)if(entry.truncated)stderr(`${entry.side} excerpt is truncated; inspect the complete retained change in OpenCrawl.\n`);
|
|
142
|
-
for(const entry of data.omitted)stderr(`${entry.side} side omitted because of the byte budget.\n`);
|
|
143
|
-
for(const warning of data.warnings)stderr(displayLine(warning)+'\n');stdout(data.context);}
|
|
144
|
-
}
|
|
145
|
-
return 0;
|
|
146
|
-
}
|
package/src/monitors.test.mjs
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import {test} from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import {monitorRequest,monitorsCommand,validateChangeContext} from './monitors.mjs';
|
|
4
|
-
|
|
5
|
-
const source='src_'+'a'.repeat(32), change='mchg_'+'b'.repeat(32), version='c'.repeat(64);
|
|
6
|
-
const monitor=()=>({id:source,kind:'monitor',name:'Careers\u001b[2J',generation:2,status:'active',targets:[{requested_url:'https://example.com/careers',state:'http_unavailable',enabled:true,last_attempt_at:'2026-01-03T00:00:00Z',last_success_at:'2026-01-02T00:00:00Z',issue:{message:'Unavailable\u001b[2J'}}]});
|
|
7
|
-
const changed=()=>({id:change,version,source_id:source,title:'Team\u001b[2J',final_url:'https://example.com/careers',observed_before:'2026-01-01T00:00:00Z',observed_after:'2026-01-02T00:00:00Z',gap:true});
|
|
8
|
-
const request=()=>monitorRequest(['context',change],{revision:version,budget:'4096'});
|
|
9
|
-
const pack=()=>{
|
|
10
|
-
const before='Aster engineering é🌲\u001b[2J',after='Aster engineering team expanded.';
|
|
11
|
-
const prefix='Untrusted historical observations\n[Before M1B]\n',separator='\n[After M1A]\n';
|
|
12
|
-
const context=prefix+before+separator+after, start=Array.from(prefix).length, second=start+Array.from(before+separator).length;
|
|
13
|
-
return {selections:[{change_id:change,version}],context,context_bytes:Buffer.byteLength(context),budget:4096,budget_unit:'utf8_bytes',estimated_tokens:40,
|
|
14
|
-
included:[['before',before,start,'M1B'],['after',after,second,'M1A']].map(([side,excerpt,offset,citation])=>({change_id:change,version,side,excerpt,start_char:5,end_char:5+Array.from(excerpt).length,full_length:100,truncated:true,citation,context_span:{start_char:offset,end_char:offset+Array.from(excerpt).length},observed_at:'2026-01-01T00:00:00Z',url:'https://example.com/careers'})),omitted:[],warnings:['Check gap\u001b[2J']};
|
|
15
|
-
};
|
|
16
|
-
async function execute(rest,options,data){let stdout='',stderr='';const calls=[];const status=await monitorsCommand(rest,options,{request:async(route,opts)=>{calls.push({route,opts});if(data instanceof Error)throw data;return data;}},{stdout:value=>stdout+=value,stderr:value=>stderr+=value});return{stdout,stderr,calls,status};}
|
|
17
|
-
|
|
18
|
-
test('monitor requests pin generation and exactly one immutable change; never fetch newest',()=>{
|
|
19
|
-
assert.deepEqual(monitorRequest(['check',source],{generation:'2'}).options,{method:'POST',body:{expected_generation:2}});
|
|
20
|
-
assert.deepEqual(request().options.body,{selections:[{change_id:change,version}],budget:4096});
|
|
21
|
-
assert.equal(monitorRequest(['changes'],{source,cursor:'opaque_-',limit:'3'}).route,`/api/v1/monitor-changes?limit=3&source_id=${source}&cursor=opaque_-`);
|
|
22
|
-
assert.equal(monitorRequest(['list'],{limit:'2',offset:'4'}).route,'/api/v1/monitors?limit=2&offset=4');
|
|
23
|
-
for(const generation of [undefined,'0','-1','1e2','02','9007199254740992'])assert.throws(()=>monitorRequest(['check',source],{generation}));
|
|
24
|
-
for(const options of [{},{revision:version.toUpperCase()},{revision:version,budget:'16385'},{revision:version,budget:'255'},{revision:version,source},{revision:version,selection:[]}])assert.throws(()=>monitorRequest(['context',change],options));
|
|
25
|
-
for(const rest of [['context',change,'extra'],['show','../private'],['check'],['list',source],['create']])assert.throws(()=>monitorRequest(rest,{}));
|
|
26
|
-
assert.throws(()=>monitorRequest(['changes'],{cursor:'x'.repeat(257)}));
|
|
27
|
-
});
|
|
28
|
-
test('exact historical stdout and complete JSON receipt preserve Unicode/control bytes',async()=>{
|
|
29
|
-
const data=pack(),result=await execute(['context',change],{revision:version,budget:'4096'},data);
|
|
30
|
-
assert.equal(result.status,0);assert.equal(result.stdout,data.context);assert.equal(result.calls.length,1);assert.deepEqual(result.calls[0].opts.body,request().options.body);
|
|
31
|
-
assert.match(result.stderr,/2\/2 sides included/);assert.match(result.stderr,/truncated/);assert.match(result.stderr,/\\u001b/);assert.ok(!result.stderr.includes('\u001b'));
|
|
32
|
-
const json=await execute(['context',change],{revision:version,json:true},data);assert.deepEqual(JSON.parse(json.stdout),data);assert.equal(json.stderr,'');
|
|
33
|
-
});
|
|
34
|
-
test('empty budget receipt and explicit omitted side do not imply a removal',async()=>{
|
|
35
|
-
const data=pack();data.context='';data.context_bytes=0;data.included=[];data.omitted=['before','after'].map(side=>({change_id:change,version,side,reason:'budget'}));
|
|
36
|
-
const result=await execute(['context',change],{revision:version},data);assert.equal(result.status,0);assert.equal(result.stdout,'');assert.match(result.stderr,/0\/2/);assert.match(result.stderr,/before side omitted/);assert.match(result.stderr,/after side omitted/);
|
|
37
|
-
});
|
|
38
|
-
test('receipt validation refuses changed identities, incomplete partition, incorrect Unicode spans and byte overruns before output',async()=>{
|
|
39
|
-
const original=pack();const cases=[{selections:[]},{selections:[{change_id:change,version:'d'.repeat(64)}]},{budget:5000},{budget_unit:'tokens'},{context_bytes:1},
|
|
40
|
-
{context:'x'.repeat(4097),context_bytes:4097},{included:[original.included[0]],omitted:[]},
|
|
41
|
-
{included:[original.included[0],original.included[0]]},{included:[{...original.included[0],change_id:'mchg_'+'f'.repeat(32)},original.included[1]]},
|
|
42
|
-
{included:[{...original.included[0],context_span:{start_char:0,end_char:2}},original.included[1]]},
|
|
43
|
-
{included:[{...original.included[0],end_char:9000},original.included[1]]},{included:[],omitted:[{change_id:change,version,side:'before',reason:'missing'}]}];
|
|
44
|
-
for(const replacement of cases){let out='';await assert.rejects(monitorsCommand(['context',change],{revision:version},{request:async()=>({...pack(),...replacement})},{stdout:v=>out+=v,stderr:()=>{}}));assert.equal(out,'');}
|
|
45
|
-
validateChangeContext(pack(),request());
|
|
46
|
-
});
|
|
47
|
-
test('monitor displays escape controls and distinguish latest attempt from last good observation',async()=>{
|
|
48
|
-
const show=await execute(['show',source],{},monitor());assert.ok(!show.stdout.includes('\u001b'));assert.match(show.stdout,/\\u001b/);assert.match(show.stdout,/2026-01-03.*last good observation: 2026-01-02/);assert.match(show.stderr,/not evidence of removal/);
|
|
49
|
-
const list=await execute(['list'],{}, {items:[monitor()],limit:20,offset:0,has_more:true});assert.match(list.stderr,/--offset 1/);
|
|
50
|
-
const changes=await execute(['changes'],{source},{items:[changed()],limit:20,next_cursor:'opaque'});assert.match(changes.stdout,/Version: c{64}/);assert.match(changes.stdout,/failed checks/);assert.ok(!changes.stdout.includes('\u001b'));assert.match(changes.stderr,/--source src_/);
|
|
51
|
-
await assert.rejects(execute(['changes'],{source},{items:[{...changed(),source_id:'src_'+'f'.repeat(32)}],limit:20,next_cursor:null}));
|
|
52
|
-
});
|
|
53
|
-
test('check only returns accepted job and never polls or assumes all pages succeeded',async()=>{
|
|
54
|
-
const job={id:'job_test',kind:'monitor',status:'queued'};const result=await execute(['check',source],{generation:'2'},{job});
|
|
55
|
-
assert.equal(result.calls.length,1);assert.match(result.stderr,/does not confirm page coverage/);assert.match(result.stderr,/jobs wait job_test/);assert.match(result.stdout,/queued/);
|
|
56
|
-
});
|
|
57
|
-
test('fixed conflict/auth/retention errors never echo provider payloads or automatically retry',async()=>{
|
|
58
|
-
for(const [status,code,pattern] of [[409,'monitor_changed',/current generation/],[409,'monitor_change_changed',/no newer change/],[409,'monitor_evidence_changed',/access changed/],[409,'monitor_not_active',/not active/],[404,'arbitrary',/retained history/],[401,null,/Sign in/],[500,'secret',/No automatic retry/]]){
|
|
59
|
-
const error=Object.assign(new Error('RAW SECRET\u001b[2J'),{status,code});const result=await execute(['context',change],{revision:version,json:true},error);
|
|
60
|
-
assert.equal(result.status,1);assert.equal(result.calls.length,1);assert.match(JSON.parse(result.stdout).error.message,pattern);assert.equal(result.stderr,'');assert.ok(!result.stdout.includes('SECRET'));
|
|
61
|
-
}
|
|
62
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/** Fixed read-only Letter picker scope. No provider selection or neutral re-render. */
|
|
2
|
-
export const LETTER_CAPABILITIES_ADAPTER='letter-capabilities-read-v1';
|
|
3
|
-
export const LETTER_PICKER='[data-testid="connect-integration"]';
|
|
4
|
-
export const LETTER_PICKER_OPEN='[data-testid="connect-integration-open"]';
|
|
5
|
-
export function isLetterCapabilitiesPolicy(policy,screen){return policy?.version===3&&policy.request_adapter===LETTER_CAPABILITIES_ADAPTER&&screen.url==='https://app.letterstory.com/letterpress?view=connections'&&screen.account_marker?.required===true&&screen.ready_selector===LETTER_PICKER_OPEN&&screen.scope_selector===LETTER_PICKER;}
|
|
6
|
-
|
|
7
|
-
/** Executed in the page before the one fixed opener click. Only finite status leaves. */
|
|
8
|
-
export function letterPickerReady({screen,expected}){
|
|
9
|
-
const visible=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'&&r.width>0&&r.height>0;};
|
|
10
|
-
const all=s=>[...document.querySelectorAll(s)].filter(visible);
|
|
11
|
-
if(location.href!==screen.url)return {error:'scope_mismatch'};
|
|
12
|
-
if(screen.login_selector&&all(screen.login_selector).length)return {error:'session_expired',login:true};
|
|
13
|
-
const markers=all(screen.account_marker.selector);
|
|
14
|
-
if(markers.length!==1||typeof expected!=='string'||!expected||markers[0].innerText.trim()!==expected)return {error:'context_mismatch'};
|
|
15
|
-
const buttons=all('[data-testid="connect-integration-open"]');
|
|
16
|
-
if(buttons.length!==1||buttons[0].tagName!=='BUTTON'||buttons[0].innerText.trim()!=='Connect'||buttons[0].disabled||buttons[0].closest('form')||document.querySelector('[data-testid="connect-integration"]'))return {error:'not_ready'};
|
|
17
|
-
return {ready:true};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** All public text is literal in-place UI text; unknown structure refuses before pixels. */
|
|
21
|
-
export function extractLetterCapabilities({screen,policy,expected,freeze=false}){
|
|
22
|
-
const visible=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'&&r.width>0&&r.height>0;};
|
|
23
|
-
const all=s=>[...document.querySelectorAll(s)].filter(visible);
|
|
24
|
-
const one=s=>{const a=all(s);return a.length===1?a[0]:null;};
|
|
25
|
-
if(location.href!==screen.url)return {error:'scope_mismatch'};
|
|
26
|
-
if(screen.login_selector&&all(screen.login_selector).length)return {error:'session_expired',login:true};
|
|
27
|
-
const marker=one(screen.account_marker.selector)?.innerText.trim();
|
|
28
|
-
if(!marker||marker!==expected)return {error:'context_mismatch'};
|
|
29
|
-
const dialog=one('[data-testid="connect-integration"]');
|
|
30
|
-
if(!dialog||dialog.getAttribute('role')!=='dialog')return {error:'not_ready'};
|
|
31
|
-
const exact=(e,tags)=>e&&[...e.children].map(n=>n.tagName.toUpperCase()).join(',')===tags&&!Array.from(e.childNodes).some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim());
|
|
32
|
-
const leaf=(e,max)=>e&&e.children.length===0&&visible(e)&&e.textContent===e.textContent.trim()&&e.textContent.length>0&&Array.from(e.textContent).length<=max&&!/[\p{C}\n\r]/u.test(e.textContent)&&!/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|https?:\/\/|\b(?:Bearer |sk-|ghp_|AKIA)/i.test(e.textContent);
|
|
33
|
-
if(!exact(dialog,'DIV,DIV,BUTTON'))return {error:'mask_unresolved'};
|
|
34
|
-
const [header,body,close]=dialog.children;
|
|
35
|
-
if(!exact(header,'H2,P')||!leaf(header.children[0],100)||!leaf(header.children[1],100)||header.children[0].textContent!=='Connect an integration'||header.children[1].textContent!=='Connect where you use it.'||!exact(body,'UL')||!exact(close,'SVG,SPAN')||close.children[1]?.textContent!=='Close')return {error:'mask_unresolved'};
|
|
36
|
-
const list=body.children[0],rows=[...list.children];
|
|
37
|
-
if(rows.length<1||rows.length>32||!exact(list,rows.map(()=> 'LI').join(',')))return {error:'mask_unresolved'};
|
|
38
|
-
const masks=[close],publicNodes=[dialog,header,...header.children,body,list],names=new Set(),text=['Connect an integration','Connect where you use it.'];
|
|
39
|
-
const badges=[],logos=[],missingBadgeSlots=[];
|
|
40
|
-
for(const row of rows){
|
|
41
|
-
if(!exact(row,'BUTTON'))return {error:'mask_unresolved'};
|
|
42
|
-
const button=row.children[0];if(!exact(button,'IMG,SPAN')&&!exact(button,'IMG,SPAN,SPAN'))return {error:'mask_unresolved'};
|
|
43
|
-
const [logo,copy,badge]=button.children;
|
|
44
|
-
if(!copy.classList.contains('min-w-0')||!exact(copy,'SPAN,SPAN')||!leaf(copy.children[0],100)||!leaf(copy.children[1],400)||names.has(copy.children[0].textContent))return {error:'mask_unresolved'};
|
|
45
|
-
names.add(copy.children[0].textContent);text.push(copy.children[0].textContent,copy.children[1].textContent);
|
|
46
|
-
masks.push(logo);logos.push(logo);if(badge){masks.push(badge);badges.push(badge);}else missingBadgeSlots.push(button);publicNodes.push(row,button,copy,...copy.children);
|
|
47
|
-
}
|
|
48
|
-
// Unknown controls or pseudo/image content in the public structure are not public fields.
|
|
49
|
-
if(publicNodes.some(e=>!visible(e)||getComputedStyle(e).backgroundImage!=='none'||getComputedStyle(e).listStyleImage!=='none'||['::before','::after','::marker'].some(p=>!['none','normal'].includes(getComputedStyle(e,p).content))))return {error:'mask_unresolved'};
|
|
50
|
-
// Element masks cannot hide direct ancestor text or ancestor-generated paint.
|
|
51
|
-
// Include html: a body-only boundary leaves document-level paint outside the mask set.
|
|
52
|
-
for(let child=dialog,parent=dialog.parentElement;parent;child=parent,parent=parent.parentElement){
|
|
53
|
-
if(Array.from(parent.childNodes).some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim())||getComputedStyle(parent).backgroundImage!=='none'||['::before','::after'].some(p=>!['none','normal'].includes(getComputedStyle(parent,p).content)))return {error:'mask_unresolved'};
|
|
54
|
-
for(const sibling of parent.children)if(sibling!==child)masks.push(sibling);
|
|
55
|
-
}
|
|
56
|
-
for(const selector of [...policy.masks,...policy.noise_regions].map(r=>r.selector)){const matches=all(selector);if(!matches.length||matches.length>100)return {error:'mask_unresolved'};masks.push(...matches);}
|
|
57
|
-
if(masks.some(m=>publicNodes.some(n=>m===n||m.contains(n))))return {error:'mask_unresolved'};
|
|
58
|
-
const value=text.join('\n');if(new TextEncoder().encode(value).length>16384)return {error:'mask_unresolved'};
|
|
59
|
-
if(freeze){
|
|
60
|
-
// Fixed private slots make connection-status strings/logos unable to change public layout.
|
|
61
|
-
// Missing status badges receive the same empty masked slot before pixels or observation.
|
|
62
|
-
for(const button of missingBadgeSlots){const badge=document.createElement('span');badge.style.setProperty('opacity','0','important');button.append(badge);badges.push(badge);masks.push(badge);}
|
|
63
|
-
for(const badge of badges){badge.style.setProperty('width','100px','important');badge.style.setProperty('flex','0 0 100px','important');badge.style.setProperty('overflow','hidden','important');badge.style.setProperty('height','20px','important');badge.style.setProperty('white-space','nowrap','important');}
|
|
64
|
-
for(const logo of logos){logo.style.setProperty('width','24px','important');logo.style.setProperty('height','24px','important');logo.style.setProperty('flex','0 0 24px','important');}
|
|
65
|
-
for(const node of masks)node.style.setProperty('opacity','0','important');
|
|
66
|
-
window.__opencrawlMasks=masks;window.__opencrawlChanged=false;window.__opencrawlObserver?.disconnect();
|
|
67
|
-
window.__opencrawlObserver=new MutationObserver(rows=>{if(rows.length)window.__opencrawlChanged=true;});
|
|
68
|
-
window.__opencrawlObserver.observe(document.documentElement,{attributes:true,childList:true,characterData:true,subtree:true});
|
|
69
|
-
}
|
|
70
|
-
return {text:value,title:screen.label,marker,maskCount:masks.length};
|
|
71
|
-
}
|