Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/LOCAL_OCR.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ are not accepted as completed proposals and are not silently deleted.

## Limits

Current-source macOS Vision qualification can also exercise the actual stdio
server without touching a desktop application's configuration:

```sh
node scripts/macos-local-ocr-smoke.mjs --pdf /absolute/two-page-scan.pdf --expect-source-sha256 <sha256> --python /absolute/venv/bin/python --helper /absolute/local-ocr-proposal.py --encrypted-pdf /absolute/encrypted-fixture.pdf --output-dir /absolute/new-private-evidence --job-pages 1,2
```

The check requires the exact current default tool names and all four configured
OCR tools with their source input/output schemas and annotations. It retains
one page proposal, creates a two-page job without OCR,
processes its first page, closes its own server, and resumes the second page
in a fresh server process. Repeating the completed resume must do no new page
work. Retained image, proposal, commit and review hashes are checked, and the
original PDF must stay unchanged. Omitting `--job-pages` performs the single-page
check only. Missing consent, stale source identity and encrypted input must
be refused. This is local protocol and engine evidence, not installed Claude
or ChatGPT acceptance, independent word verification or general scan accuracy.

- One active OCR page operation per server and a shared 120-second
generation/replay budget per page with process-group termination. The
single-page tool keeps its existing contract; a job resume handles at most
Expand Down
57 changes: 57 additions & 0 deletions scripts/local-ocr-smoke-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";

export function assertLocalOcrSmokeEncryptedRefusal(error) {
assert.equal(error.code, 2, "Encryption control failed for an unexpected reason");
assert.equal(error.stdout, "");
assert.deepEqual(JSON.parse(error.stderr), { status: "ocr_proposal_failed",
message: "Optional OCR does not accept encrypted PDFs" });
}

// Check the current default name inventory, not a historical tool count.
// Optional input/output schemas and annotations match the source definitions.
export function assertLocalOcrSmokeCatalog(tools, manifestTools, optionalTools, enabled) {
const names = definitions => {
assert(Array.isArray(definitions) && definitions.length > 0, "Tool inventory must be nonempty");
const result = definitions.map(tool => {
assert(typeof tool.name === "string" && tool.name.length > 0, "Invalid tool name");
return tool.name;
});
assert.equal(new Set(result).size, result.length, "Duplicate tool name");
return result.sort();
};
const defaults = names(manifestTools);
const optional = names(optionalTools);
assert(!optional.some(name => defaults.includes(name)), "Optional tool in default inventory");
const expected = enabled ? [...defaults, ...optional].sort() : defaults;
assert.deepEqual(names(tools), expected, "Live tool catalog differs from the current source inventory");
if (enabled) {
for (const definition of optionalTools) {
const observed = tools.find(tool => tool.name === definition.name);
assert.deepEqual(observed.inputSchema, definition.inputSchema, "Optional input schema drift");
assert.deepEqual(observed.annotations, definition.annotations, "Optional annotations drift");
assert(definition.outputSchema?.type === "object", "Expected optional output schema missing");
assert.deepEqual(observed.outputSchema, definition.outputSchema, "Optional output schema drift");
}
}
return expected.length;
}

export function assertLocalOcrSmokeProgress(result, { pages, completed, processed, sourceSha256, job }) {
assert(result && result.verification_status === "unverified_ocr_proposals", "OCR words must remain unverified");
assert.equal(result.source_pdf_sha256, sourceSha256);
if (job) {
assert.equal(result.job_id, job.job_id);
assert.equal(result.job_identity_sha256, job.job_identity_sha256);
}
const remaining = pages.filter(page => !completed.includes(page));
assert.equal(result.page_count, pages.length);
assert.equal(result.completed_count, completed.length);
assert.equal(result.remaining_count, remaining.length);
assert.deepEqual(result.completed_pages.map(page => page.page_number), completed);
assert.deepEqual(result.remaining_pages, remaining);
assert.equal(result.processed_this_call, processed);
assert.equal(result.failed_page, null, "An OCR page failed");
assert.equal(result.failure_code, null, "An OCR page failed");
assert.equal(result.status, remaining.length === 0 ? "ocr_job_complete"
: completed.length === 0 ? "ocr_job_ready" : "ocr_job_paused");
}
125 changes: 104 additions & 21 deletions scripts/macos-local-ocr-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,42 @@
// Isolated stdio qualification, not a Claude Desktop installation or host test.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { LOCAL_OCR_HELPER_SHA256 } from "../server/local-ocr-tools.js";
import { LOCAL_OCR_HELPER_SHA256, LOCAL_OCR_TOOL_DEFINITION } from "../server/local-ocr-tools.js";
import { LOCAL_OCR_JOB_TOOL_DEFINITIONS } from "../server/local-ocr-jobs.js";
import { withToolOutputSchema } from "../server/output-schemas.js";
import { assertLocalOcrSmokeCatalog, assertLocalOcrSmokeEncryptedRefusal, assertLocalOcrSmokeProgress } from "./local-ocr-smoke-contract.mjs";

const options = {};
const keys = new Set(["--pdf", "--expect-source-sha256", "--python", "--helper", "--encrypted-pdf", "--output-dir"]);
const requiredKeys = ["--pdf", "--expect-source-sha256", "--python", "--helper", "--encrypted-pdf", "--output-dir"];
const keys = new Set([...requiredKeys, "--job-pages"]);
for (let index = 2; index < process.argv.length; index += 2) {
const key = process.argv[index];
assert(keys.has(key) && !(key in options) && process.argv[index + 1], "Unknown, duplicate or missing smoke argument");
options[key] = process.argv[index + 1];
}
for (const key of keys) assert(options[key], `Required ${key}`);
for (const key of requiredKeys) assert(options[key], `Required ${key}`);
let jobPages;
if (options["--job-pages"]) {
assert(/^[1-9][0-9]*,[1-9][0-9]*$/u.test(options["--job-pages"]), "Use two distinct ascending job pages");
jobPages = options["--job-pages"].split(",").map(Number);
assert(jobPages[0] < jobPages[1] && jobPages[1] <= 10000, "Use two distinct ascending job pages");
}
assert(process.platform === "darwin", "Real OCR smoke requires macOS");
for (const key of ["--pdf", "--python", "--helper", "--encrypted-pdf", "--output-dir"]) {
assert(path.isAbsolute(options[key]), `Absolute path required: ${key}`);
}
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
const sourceBefore = await fs.readFile(options["--pdf"]);
assert.equal(sha(sourceBefore), options["--expect-source-sha256"]);
assert.equal(sha(await fs.readFile(options["--helper"])), LOCAL_OCR_HELPER_SHA256);
const helperBytes = await fs.readFile(options["--helper"]);
assert.equal(sha(helperBytes), LOCAL_OCR_HELPER_SHA256);
const root = options["--output-dir"];
await fs.mkdir(root, { mode: 0o700 }); // No reuse of a previous smoke.
const home = path.join(root, "home");
Expand All @@ -37,6 +50,9 @@ const config = { allowedDirectories: [...new Set([path.dirname(options["--pdf"])
const writeConfig = () => fs.writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
await writeConfig();
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const manifestBytes = await fs.readFile(path.join(repo, "manifest.json"));
const manifestTools = JSON.parse(manifestBytes).tools;
const optionalTools = [LOCAL_OCR_TOOL_DEFINITION, ...LOCAL_OCR_JOB_TOOL_DEFINITIONS].map(withToolOutputSchema);
const opened = [];
async function connect(label) {
const transport = new StdioClientTransport({
Expand All @@ -53,13 +69,29 @@ const text = response => response.content?.filter(item => item.type === "text").
const request = { pdf_path: options["--pdf"], page: 1, expected_source_sha256: sha(sourceBefore), confirm_local_ocr: true };
const jobsPath = path.join(profiles, "local-ocr-proposals");
const jobs = async () => fs.readdir(jobsPath).catch(error => { if (error.code === "ENOENT") return []; throw error; });
async function retainedPage(result) {
assert.equal(sha(await fs.readFile(result.review_html_path)), result.review_html_sha256);
assert.equal(sha(await fs.readFile(path.join(result.proposal_directory, "render.png"))), result.render_png_sha256);
const proposalBytes = await fs.readFile(path.join(result.proposal_directory, "proposal.json"));
const proposal = JSON.parse(proposalBytes);
const commit = JSON.parse(await fs.readFile(path.join(result.proposal_directory, "commit.json"), "utf8"));
assert.equal(commit.proposal_file_sha256, sha(proposalBytes));
assert.equal(commit.proposal_sha256, result.proposal_sha256);
assert.equal(commit.render_png_sha256, result.render_png_sha256);
assert.equal(proposal.source_pdf_sha256, request.expected_source_sha256);
assert.equal(proposal.page_number, result.page_number);
assert.equal(proposal.proposal_sha256, result.proposal_sha256);
assert.equal(proposal.proposals.length, result.observation_count);
for (const file of [result.review_html_path, ...["proposal.json", "commit.json", "render.png"].map(name => path.join(result.proposal_directory, name))]) {
const stat = await fs.lstat(file); assert(stat.isFile() && !stat.isSymbolicLink()); assert.equal(stat.mode & 0o777, 0o600);
}
return proposal;
}
try {
const disabled = await connect("disabled");
const defaultTools = (await disabled.listTools()).tools;
assert.equal(defaultTools.length, 57);
assert(!defaultTools.some(tool => tool.name === "propose_pdf_ocr"));
const defaultCount = assertLocalOcrSmokeCatalog(defaultTools, manifestTools, optionalTools, false);
const defaultDigest = sha(JSON.stringify(defaultTools));
assert.equal(defaultDigest, "2edabecaa5aa61e9ee533f8811180e8661a9423dddaee8ba59fec19cb846ab7d");
const disabledResult = await disabled.callTool({ name: "propose_pdf_ocr", arguments: request });
assert.equal(disabledResult.isError, true);
assert.match(text(disabledResult), /not configured/);
Expand All @@ -70,24 +102,43 @@ try {
await writeConfig();
const enabled = await connect("enabled");
const enabledTools = (await enabled.listTools()).tools;
assert.equal(enabledTools.length, 58);
assert(enabledTools.some(tool => tool.name === "propose_pdf_ocr" && tool.outputSchema));
const configuredCount = assertLocalOcrSmokeCatalog(enabledTools, manifestTools, optionalTools, true);
const identity = await enabled.callTool({ name: "get_pdf_identity", arguments: { pdf_path: request.pdf_path } });
assert.notEqual(identity.isError, true);
assert.equal(identity.structuredContent.sha256, request.expected_source_sha256);
for (const arguments_ of [
{ ...request, confirm_local_ocr: false },
{ ...request, expected_source_sha256: "0".repeat(64) },
for (const [arguments_, reason] of [
[{ ...request, confirm_local_ocr: false }, /confirm_local_ocr|confirmation/iu],
[{ ...request, expected_source_sha256: "0".repeat(64) }, /source bytes do not match/iu],
]) {
const result = await enabled.callTool({ name: "propose_pdf_ocr", arguments: arguments_ });
assert.equal(result.isError, true);
assert.match(text(result), reason);
assert.deepEqual(await jobs(), []);
}
const encrypted = await fs.readFile(options["--encrypted-pdf"]);
// The public wrapper intentionally gives generic adapter diagnostics. Prove
// this fixture's exact encryption refusal independently, using hashed helper
// bytes and no host credentials, before labelling the negative control.
const negativeHelper = path.join(root, "encrypted-negative-adapter.py");
await fs.writeFile(negativeHelper, helperBytes, { flag: "wx", mode: 0o600 });
let encryptedDiagnostic;
try {
await promisify(execFile)(options["--python"], ["-I", "-B", negativeHelper,
"--pdf", options["--encrypted-pdf"], "--page", "1", "--expect-source-sha256", sha(encrypted),
"--output-dir", path.join(root, "encrypted-negative-output")],
{ timeout: 15_000, maxBuffer: 256 * 1024, env: { PATH: "/usr/bin:/bin", PYTHONDONTWRITEBYTECODE: "1" } });
assert.fail("Encrypted adapter fixture unexpectedly succeeded");
} catch (error) {
assertLocalOcrSmokeEncryptedRefusal(error);
encryptedDiagnostic = error.stderr;
}
await assert.rejects(fs.stat(path.join(root, "encrypted-negative-output")), { code: "ENOENT" });
await fs.writeFile(path.join(root, "encrypted-adapter.stderr"), encryptedDiagnostic, { flag: "wx", mode: 0o600 });
const encryptionResult = await enabled.callTool({ name: "propose_pdf_ocr", arguments: {
...request, pdf_path: options["--encrypted-pdf"], expected_source_sha256: sha(encrypted),
} });
assert.equal(encryptionResult.isError, true);
assert.match(text(encryptionResult), /Optional local OCR adapter failed/u);
const failedJobs = await jobs();
assert.equal(failedJobs.length, 1);
await assert.rejects(fs.stat(path.join(jobsPath, failedJobs[0], "proposal/commit.json")), { code: "ENOENT" });
Expand All @@ -100,23 +151,55 @@ try {
assert(result.observation_count > 0);
assert.equal(result.returned_observation_count + result.omitted_observation_count, result.observation_count);
assert.match(text(response), /UNVERIFIED/);
const proposalBytes = await fs.readFile(path.join(result.proposal_directory, "proposal.json"));
const proposal = JSON.parse(proposalBytes);
const proposal = await retainedPage(result);
assert.deepEqual(result.proposals, proposal.proposals.slice(0, 200));
assert.equal(proposal.proposal_sha256, result.proposal_sha256);
assert.equal(sha(await fs.readFile(result.review_html_path)), result.review_html_sha256);
assert.equal(sha(await fs.readFile(path.join(result.proposal_directory, "render.png"))), result.render_png_sha256);
for (const file of [result.review_html_path, ...["proposal.json", "commit.json", "render.png"].map(name => path.join(result.proposal_directory, name))]) {
const stat = await fs.lstat(file); assert(stat.isFile() && !stat.isSymbolicLink()); assert.equal(stat.mode & 0o777, 0o600);
let jobReceipt = null;
if (jobPages) {
const call = async (client, name, args, timeout = 125_000) => {
const response = await client.callTool({ name, arguments: args }, undefined, { timeout });
assert.notEqual(response.isError, true, text(response));
return response.structuredContent;
};
const beforeStart = (await jobs()).sort();
const started = await call(enabled, "start_pdf_ocr_job", { pdf_path: request.pdf_path,
pages: jobPages, expected_source_sha256: request.expected_source_sha256, confirm_local_ocr: true });
const progress = { pages: jobPages, sourceSha256: request.expected_source_sha256, job: started };
assertLocalOcrSmokeProgress(started, { ...progress, completed: [], processed: 0 });
assert.deepEqual((await jobs()).sort(), beforeStart, "Job creation must not run OCR");
const jobArgs = { job_id: started.job_id, expected_job_identity_sha256: started.job_identity_sha256 };
const denied = await enabled.callTool({ name: "resume_pdf_ocr_job", arguments: { ...jobArgs, confirm_local_ocr: false } });
assert.equal(denied.isError, true);
const first = await call(enabled, "resume_pdf_ocr_job", { ...jobArgs, confirm_local_ocr: true });
assertLocalOcrSmokeProgress(first, { ...progress, completed: [jobPages[0]], processed: 1 });
await retainedPage(first.completed_pages[0]);
await enabled.close(); // A new server process must use the retained checkpoint.
const restarted = await connect("resumed");
assertLocalOcrSmokeCatalog((await restarted.listTools()).tools, manifestTools, optionalTools, true);
const read = await call(restarted, "read_pdf_ocr_job", jobArgs);
assertLocalOcrSmokeProgress(read, { ...progress, completed: [jobPages[0]], processed: 0 });
assert.deepEqual(read.completed_pages, first.completed_pages);
const complete = await call(restarted, "resume_pdf_ocr_job", { ...jobArgs, confirm_local_ocr: true });
assertLocalOcrSmokeProgress(complete, { ...progress, completed: jobPages, processed: 1 });
assert.deepEqual(complete.completed_pages[0], first.completed_pages[0]);
for (const page of complete.completed_pages) await retainedPage(page);
const beforeRepeated = (await jobs()).sort();
const repeated = await call(restarted, "resume_pdf_ocr_job", { ...jobArgs, confirm_local_ocr: true });
assertLocalOcrSmokeProgress(repeated, { ...progress, completed: jobPages, processed: 0 });
assert.deepEqual(repeated.completed_pages, complete.completed_pages);
assert.deepEqual((await jobs()).sort(), beforeRepeated, "Completed resume must not create a new OCR attempt");
jobReceipt = { started, first, after_server_restart: read, completed: complete, repeated,
resumed_across_server_processes: true, completed_pages_not_rerun: true };
}
assert.equal(sha(await fs.readFile(request.pdf_path)), sha(sourceBefore));
const receipt = {
schema_version: 1, scope: "isolated_source_stdio_real_macos_ocr_not_installed_desktop",
observed_at: new Date().toISOString(), platform: process.platform, arch: process.arch, node_version: process.version,
default_tool_count: 57, default_tool_contract_sha256: defaultDigest, configured_tool_count: 58,
default_tool_count: defaultCount, default_tool_contract_sha256: defaultDigest, configured_tool_count: configuredCount,
manifest_sha256: sha(manifestBytes), optional_tool_names: optionalTools.map(tool => tool.name),
source_pdf_sha256: sha(sourceBefore), encrypted_fixture_sha256: sha(encrypted), encrypted_fixture_rejected: true,
encrypted_adapter_stderr_sha256: sha(encryptedDiagnostic),
explicit_confirmation_and_stale_source_rejected: true, original_source_unchanged: true,
result, engine: proposal.engine, source_server_sha256: sha(await fs.readFile(path.join(repo, "server/index.js"))),
result, job: jobReceipt, engine: proposal.engine, source_server_sha256: sha(await fs.readFile(path.join(repo, "server/index.js"))),
installed_desktop_qualified: false, word_correctness_verified: false,
};
await fs.writeFile(path.join(root, "receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`, { flag: "wx", mode: 0o600 });
Expand Down
4 changes: 4 additions & 0 deletions scripts/node-test-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ const RELEASE_PLATFORMS = Object.freeze(["darwin", "linux", "win32"]);
const POSIX_PLATFORMS = Object.freeze(["darwin", "linux"]);

export const NODE_TEST_SUITES = Object.freeze([
{
file: "test/local-ocr-smoke-contract-native.test.js",
platforms: RELEASE_PLATFORMS,
},
{
file: "test/local-ocr-tools-native.test.js",
platforms: POSIX_PLATFORMS,
Expand Down
Loading
Loading