From d2260e375148af4c2e7584a79f9175a98619964a Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 13 Oct 2026 20:31:23 +0000 Subject: [PATCH 1/4] Refresh macOS OCR smoke for current resumable workflow --- docs/LOCAL_OCR.md | 17 ++++ scripts/local-ocr-smoke-contract.mjs | 49 +++++++++++ scripts/macos-local-ocr-smoke.mjs | 92 ++++++++++++++++---- scripts/node-test-files.mjs | 4 + test/local-ocr-smoke-contract-native.test.js | 46 ++++++++++ 5 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 scripts/local-ocr-smoke-contract.mjs create mode 100644 test/local-ocr-smoke-contract-native.test.js diff --git a/docs/LOCAL_OCR.md b/docs/LOCAL_OCR.md index f7d8e5ea..08d36065 100644 --- a/docs/LOCAL_OCR.md +++ b/docs/LOCAL_OCR.md @@ -115,6 +115,23 @@ 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 --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 catalog and all four configured +OCR tools. 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 diff --git a/scripts/local-ocr-smoke-contract.mjs b/scripts/local-ocr-smoke-contract.mjs new file mode 100644 index 00000000..25ba0f89 --- /dev/null +++ b/scripts/local-ocr-smoke-contract.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; + +// The qualification follows the current source inventory, not a historical +// tool count. Optional tools must match the reviewed definitions exactly. +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(observed.outputSchema && observed.outputSchema.type === "object", "Optional output schema missing"); + } + } + 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"); +} diff --git a/scripts/macos-local-ocr-smoke.mjs b/scripts/macos-local-ocr-smoke.mjs index 6653ebac..61cd2f4a 100644 --- a/scripts/macos-local-ocr-smoke.mjs +++ b/scripts/macos-local-ocr-smoke.mjs @@ -8,16 +8,25 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; 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 { assertLocalOcrSmokeCatalog, 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}`); @@ -37,6 +46,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]; const opened = []; async function connect(label) { const transport = new StdioClientTransport({ @@ -53,13 +65,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/); @@ -70,8 +98,7 @@ 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); @@ -100,23 +127,54 @@ 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, 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 }); diff --git a/scripts/node-test-files.mjs b/scripts/node-test-files.mjs index 9adf7c2d..c170e521 100644 --- a/scripts/node-test-files.mjs +++ b/scripts/node-test-files.mjs @@ -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, diff --git a/test/local-ocr-smoke-contract-native.test.js b/test/local-ocr-smoke-contract-native.test.js new file mode 100644 index 00000000..e7532164 --- /dev/null +++ b/test/local-ocr-smoke-contract-native.test.js @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { assertLocalOcrSmokeCatalog, assertLocalOcrSmokeProgress } from "../scripts/local-ocr-smoke-contract.mjs"; + +const defaults = [{ name: "read_pdf_content" }, { name: "get_pdf_identity" }]; +const optional = ["propose_pdf_ocr", "start_pdf_ocr_job", "resume_pdf_ocr_job", "read_pdf_ocr_job"] + .map(name => ({ name, inputSchema: { type: "object", additionalProperties: false }, + annotations: { readOnlyHint: name === "read_pdf_ocr_job" } })); +const configured = [...defaults, ...optional.map(tool => ({ ...tool, outputSchema: { type: "object" } }))]; + +test("smoke follows exact current default and four optional tool names without historical counts", () => { + assert.equal(assertLocalOcrSmokeCatalog(defaults, defaults, optional, false), 2); + assert.equal(assertLocalOcrSmokeCatalog([...configured].reverse(), defaults, optional, true), 6); +}); +for (const [name, mutate] of [ + ["missing job tool", tools => tools.pop()], + ["extra tool", tools => tools.push({ name: "unexpected" })], + ["duplicate tool", tools => tools.push(tools[0])], + ["optional schema drift", tools => { tools[2].inputSchema.additionalProperties = true; }], + ["optional annotation drift", tools => { tools[2].annotations.readOnlyHint = true; }], + ["missing output schema", tools => { delete tools[2].outputSchema; }], +]) { + test(`catalog rejects ${name}`, () => { + const tools = structuredClone(configured); mutate(tools); + assert.throws(() => assertLocalOcrSmokeCatalog(tools, defaults, optional, true)); + }); +} +test("default catalog cannot silently enable OCR", () => { + assert.throws(() => assertLocalOcrSmokeCatalog(configured, defaults, optional, false)); +}); + +const expectation = { pages: [1, 2], completed: [1], processed: 1, sourceSha256: "a".repeat(64), + job: { job_id: "fixture-job", job_identity_sha256: "b".repeat(64) } }; +const paused = { ...expectation.job, verification_status: "unverified_ocr_proposals", source_pdf_sha256: expectation.sourceSha256, + page_count: 2, completed_count: 1, remaining_count: 1, completed_pages: [{ page_number: 1 }], remaining_pages: [2], + processed_this_call: 1, failed_page: null, failure_code: null, status: "ocr_job_paused" }; +test("progress accepts the exact retained page partition", () => { + assertLocalOcrSmokeProgress(paused, expectation); +}); +for (const [key, value] of Object.entries({ verification_status: "verified", source_pdf_sha256: "c".repeat(64), + job_identity_sha256: "c".repeat(64), completed_count: 2, remaining_pages: [], processed_this_call: 0, + failed_page: 2, status: "ocr_job_complete" })) { + test(`progress rejects substituted ${key}`, () => { + assert.throws(() => assertLocalOcrSmokeProgress({ ...paused, [key]: value }, expectation)); + }); +} From a9c18409e6433489c37466d49bb71baf29fb0733 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 13 Oct 2026 20:34:31 +0000 Subject: [PATCH 2/4] Bind OCR smoke schemas and negative-control reasons --- docs/LOCAL_OCR.md | 5 +-- scripts/local-ocr-smoke-contract.mjs | 7 ++-- scripts/macos-local-ocr-smoke.mjs | 37 +++++++++++++++++--- test/local-ocr-smoke-contract-native.test.js | 4 ++- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/LOCAL_OCR.md b/docs/LOCAL_OCR.md index 08d36065..72917ff5 100644 --- a/docs/LOCAL_OCR.md +++ b/docs/LOCAL_OCR.md @@ -122,8 +122,9 @@ server without touching a desktop application's configuration: node scripts/macos-local-ocr-smoke.mjs --pdf /absolute/two-page-scan.pdf --expect-source-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 catalog and all four configured -OCR tools. It retains one page proposal, creates a two-page job without OCR, +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 diff --git a/scripts/local-ocr-smoke-contract.mjs b/scripts/local-ocr-smoke-contract.mjs index 25ba0f89..7f42b5ac 100644 --- a/scripts/local-ocr-smoke-contract.mjs +++ b/scripts/local-ocr-smoke-contract.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -// The qualification follows the current source inventory, not a historical -// tool count. Optional tools must match the reviewed definitions exactly. +// 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"); @@ -22,7 +22,8 @@ export function assertLocalOcrSmokeCatalog(tools, manifestTools, 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(observed.outputSchema && observed.outputSchema.type === "object", "Optional output schema missing"); + assert(definition.outputSchema?.type === "object", "Expected optional output schema missing"); + assert.deepEqual(observed.outputSchema, definition.outputSchema, "Optional output schema drift"); } } return expected.length; diff --git a/scripts/macos-local-ocr-smoke.mjs b/scripts/macos-local-ocr-smoke.mjs index 61cd2f4a..b9248632 100644 --- a/scripts/macos-local-ocr-smoke.mjs +++ b/scripts/macos-local-ocr-smoke.mjs @@ -3,13 +3,16 @@ // 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, 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, assertLocalOcrSmokeProgress } from "./local-ocr-smoke-contract.mjs"; const options = {}; @@ -34,7 +37,8 @@ for (const key of ["--pdf", "--python", "--helper", "--encrypted-pdf", "--output 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"); @@ -48,7 +52,7 @@ 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]; +const optionalTools = [LOCAL_OCR_TOOL_DEFINITION, ...LOCAL_OCR_JOB_TOOL_DEFINITIONS].map(withToolOutputSchema); const opened = []; async function connect(label) { const transport = new StdioClientTransport({ @@ -102,19 +106,41 @@ try { 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) { + assert.equal(error.code, 1, "Encryption control failed for an unexpected reason"); + assert.match(error.stderr, /Optional OCR does not accept encrypted PDFs/u); + assert.equal(error.stdout, ""); + 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" }); @@ -173,6 +199,7 @@ try { 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, 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, diff --git a/test/local-ocr-smoke-contract-native.test.js b/test/local-ocr-smoke-contract-native.test.js index e7532164..e6c53eae 100644 --- a/test/local-ocr-smoke-contract-native.test.js +++ b/test/local-ocr-smoke-contract-native.test.js @@ -5,8 +5,9 @@ import { assertLocalOcrSmokeCatalog, assertLocalOcrSmokeProgress } from "../scri const defaults = [{ name: "read_pdf_content" }, { name: "get_pdf_identity" }]; const optional = ["propose_pdf_ocr", "start_pdf_ocr_job", "resume_pdf_ocr_job", "read_pdf_ocr_job"] .map(name => ({ name, inputSchema: { type: "object", additionalProperties: false }, + outputSchema: { type: "object", required: ["status"] }, annotations: { readOnlyHint: name === "read_pdf_ocr_job" } })); -const configured = [...defaults, ...optional.map(tool => ({ ...tool, outputSchema: { type: "object" } }))]; +const configured = [...defaults, ...optional]; test("smoke follows exact current default and four optional tool names without historical counts", () => { assert.equal(assertLocalOcrSmokeCatalog(defaults, defaults, optional, false), 2); @@ -19,6 +20,7 @@ for (const [name, mutate] of [ ["optional schema drift", tools => { tools[2].inputSchema.additionalProperties = true; }], ["optional annotation drift", tools => { tools[2].annotations.readOnlyHint = true; }], ["missing output schema", tools => { delete tools[2].outputSchema; }], + ["valid-shape output schema substitution", tools => { tools[2].outputSchema.required = ["forged"]; }], ]) { test(`catalog rejects ${name}`, () => { const tools = structuredClone(configured); mutate(tools); From 4e589118f311284bc9fefa49cdaeff8716e50a8d Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 13 Oct 2026 20:36:23 +0000 Subject: [PATCH 3/4] Check the exact adapter encryption failure contract --- scripts/local-ocr-smoke-contract.mjs | 7 +++++++ scripts/macos-local-ocr-smoke.mjs | 6 ++---- test/local-ocr-smoke-contract-native.test.js | 14 +++++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/local-ocr-smoke-contract.mjs b/scripts/local-ocr-smoke-contract.mjs index 7f42b5ac..3f4dcba2 100644 --- a/scripts/local-ocr-smoke-contract.mjs +++ b/scripts/local-ocr-smoke-contract.mjs @@ -1,5 +1,12 @@ 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) { diff --git a/scripts/macos-local-ocr-smoke.mjs b/scripts/macos-local-ocr-smoke.mjs index b9248632..ce0f11b5 100644 --- a/scripts/macos-local-ocr-smoke.mjs +++ b/scripts/macos-local-ocr-smoke.mjs @@ -13,7 +13,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.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, assertLocalOcrSmokeProgress } from "./local-ocr-smoke-contract.mjs"; +import { assertLocalOcrSmokeCatalog, assertLocalOcrSmokeEncryptedRefusal, assertLocalOcrSmokeProgress } from "./local-ocr-smoke-contract.mjs"; const options = {}; const requiredKeys = ["--pdf", "--expect-source-sha256", "--python", "--helper", "--encrypted-pdf", "--output-dir"]; @@ -129,9 +129,7 @@ try { { timeout: 15_000, maxBuffer: 256 * 1024, env: { PATH: "/usr/bin:/bin", PYTHONDONTWRITEBYTECODE: "1" } }); assert.fail("Encrypted adapter fixture unexpectedly succeeded"); } catch (error) { - assert.equal(error.code, 1, "Encryption control failed for an unexpected reason"); - assert.match(error.stderr, /Optional OCR does not accept encrypted PDFs/u); - assert.equal(error.stdout, ""); + assertLocalOcrSmokeEncryptedRefusal(error); encryptedDiagnostic = error.stderr; } await assert.rejects(fs.stat(path.join(root, "encrypted-negative-output")), { code: "ENOENT" }); diff --git a/test/local-ocr-smoke-contract-native.test.js b/test/local-ocr-smoke-contract-native.test.js index e6c53eae..4e2474b4 100644 --- a/test/local-ocr-smoke-contract-native.test.js +++ b/test/local-ocr-smoke-contract-native.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { assertLocalOcrSmokeCatalog, assertLocalOcrSmokeProgress } from "../scripts/local-ocr-smoke-contract.mjs"; +import { assertLocalOcrSmokeCatalog, assertLocalOcrSmokeEncryptedRefusal, assertLocalOcrSmokeProgress } from "../scripts/local-ocr-smoke-contract.mjs"; const defaults = [{ name: "read_pdf_content" }, { name: "get_pdf_identity" }]; const optional = ["propose_pdf_ocr", "start_pdf_ocr_job", "resume_pdf_ocr_job", "read_pdf_ocr_job"] @@ -9,6 +9,18 @@ const optional = ["propose_pdf_ocr", "start_pdf_ocr_job", "resume_pdf_ocr_job", annotations: { readOnlyHint: name === "read_pdf_ocr_job" } })); const configured = [...defaults, ...optional]; +const encryptedRefusal = { code: 2, stdout: "", stderr: JSON.stringify({ + status: "ocr_proposal_failed", message: "Optional OCR does not accept encrypted PDFs" }) }; +test("encryption control requires the real helper exit code and exact reason", () => { + assertLocalOcrSmokeEncryptedRefusal(encryptedRefusal); +}); +for (const [key, value] of Object.entries({ code: 1, stdout: "success", stderr: JSON.stringify({ + status: "ocr_proposal_failed", message: "Optional renderer unavailable" }) })) { + test(`encryption control rejects unrelated ${key}`, () => { + assert.throws(() => assertLocalOcrSmokeEncryptedRefusal({ ...encryptedRefusal, [key]: value })); + }); +} + test("smoke follows exact current default and four optional tool names without historical counts", () => { assert.equal(assertLocalOcrSmokeCatalog(defaults, defaults, optional, false), 2); assert.equal(assertLocalOcrSmokeCatalog([...configured].reverse(), defaults, optional, true), 6); From 7eb5a42cc2349af616ae8ac7867c0634917b86e0 Mon Sep 17 00:00:00 2001 From: silverstein Date: Tue, 13 Oct 2026 20:37:21 +0000 Subject: [PATCH 4/4] Keep native runner partitions aligned with OCR smoke regressions --- test/test-runner-contract.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test-runner-contract.test.js b/test/test-runner-contract.test.js index 13a003bd..7502dcab 100644 --- a/test/test-runner-contract.test.js +++ b/test/test-runner-contract.test.js @@ -541,6 +541,7 @@ describe("aggregate test-runner contract", () => { it("uses exact, explicit native-suite partitions on each release platform", () => { expect(nodeTestFilesForPlatform("win32")).toEqual([ + "test/local-ocr-smoke-contract-native.test.js", "test/deep-malformed-native-v2-contract.test.js", "test/deep-malformed-native-v3-contract.test.js", "test/deep-malformed-native-v4-contract.test.js", @@ -549,6 +550,7 @@ describe("aggregate test-runner contract", () => { "test/deep-malformed-native-windows-portable.test.js", ]); const posixFiles = [ + "test/local-ocr-smoke-contract-native.test.js", "test/local-ocr-tools-native.test.js", "test/deep-malformed-native-v2-contract.test.js", "test/deep-malformed-native-v2-mechanisms.test.js",