From c76edbebbe1e25c2bf821ec04d08dc851e37626c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:58:50 +0800 Subject: [PATCH 01/29] test(pe): bind acceptance calls to advertised MCP definitions Pass the exact named tool descriptors to SDK callTool for PE resource inspection and session inventory. Avoid full-catalog validator compilation while retaining SDK output validation, every CLI/MCP scenario, cleanup, and the unchanged 30-second deadline. The complete focused test passed locally in 7.1 seconds versus 11.0 seconds before this change; host load and CPU/memory costs were not controlled or measured. check:ci passes. --- .../applications/peResources.test.ts | 107 +++++++----------- 1 file changed, 43 insertions(+), 64 deletions(-) diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index 73a49a144c..202a19e9d4 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -11,14 +11,17 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +type PeResourceCall = ( + arguments_: Record, +) => ReturnType; + const expectPeInputFailureAcrossAdapters = async (input: { readonly cli: TestCli; - readonly client: Client; - readonly toolName: string; + readonly call: PeResourceCall; readonly path: string; readonly reason: "invalid_format" | "invalid_value"; }) => { - const { cli, client, toolName, path, reason } = input; + const { cli, call, path, reason } = input; const result = await cli.run({ arguments: ["inspect-pe-resources", path, "--json"], }); @@ -31,21 +34,17 @@ const expectPeInputFailureAcrossAdapters = async (input: { issues: [{ path: ["path"], reason }], }, }); - const response = await client.callTool({ - name: toolName, - arguments: { path }, - }); + const response = await call({ path }); expect(response.isError).toBe(true); expect(parseMcpToolError(response)).toEqual({ error: result.json }); }; const expectPePaddingFailureAcrossAdapters = async (input: { readonly cli: TestCli; - readonly client: Client; - readonly toolName: string; + readonly call: PeResourceCall; readonly path: string; }) => { - const { cli, client, toolName, path } = input; + const { cli, call, path } = input; const fixture = peResourceFixture(); const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8); fixture.bytes.writeUInt32LE( @@ -58,10 +57,7 @@ const expectPePaddingFailureAcrossAdapters = async (input: { arguments: ["inspect-pe-resources", path, "--json"], }); expect(cliResult.exitCode).not.toBe(0); - const mcpResult = await client.callTool({ - name: toolName, - arguments: { path }, - }); + const mcpResult = await call({ path }); expect(mcpResult.isError).toBe(true); expect(peResourcesSchema.safeParse(mcpResult.structuredContent).success).toBe( false, @@ -70,11 +66,10 @@ const expectPePaddingFailureAcrossAdapters = async (input: { const expectPeInputFailuresAcrossAdapters = async (input: { readonly cli: TestCli; - readonly client: Client; - readonly toolName: string; + readonly call: PeResourceCall; readonly root: string; }) => { - const { cli, client, toolName, root } = input; + const { cli, call, root } = input; const malformedPath = join(root, "malformed.exe"); await writeFile(malformedPath, Buffer.from("not a PE image")); for (const [path, reason] of [ @@ -83,8 +78,7 @@ const expectPeInputFailuresAcrossAdapters = async (input: { ] as const) await expectPeInputFailureAcrossAdapters({ cli, - client, - toolName, + call, path, reason, }); @@ -94,13 +88,10 @@ const contract = toolContract("inspect_pe_resources"); const expectPeWordAlignedRecordsAcrossAdapters = async (input: { readonly cli: TestCli; - readonly client: Client; + readonly call: PeResourceCall; readonly root: string; - readonly definition: Awaited< - ReturnType - >["tools"][number]; }): Promise => { - const { cli, client, root, definition } = input; + const { cli, call, root } = input; for (const plus of [false, true]) { for (const [index, identities] of [ { type: 10, name: 1, language: "AB" }, @@ -131,10 +122,7 @@ const expectPeWordAlignedRecordsAcrossAdapters = async (input: { expect(report.resources[0]?.payload.sha256).toBe( createHash("sha256").update("opaque resource").digest("hex"), ); - const response = await client.callTool( - { name: definition.name, arguments: { path } }, - { toolDefinition: definition }, - ); + const response = await call({ path }); expect(response.isError).not.toBe(true); expect( contract.outputSchema.parse(response.structuredContent) @@ -147,7 +135,7 @@ const expectPeWordAlignedRecordsAcrossAdapters = async (input: { const expectPeMappingOwnershipAcrossAdapters = async ( cli: TestCli, - client: Client, + call: PeResourceCall, root: string, ): Promise => { for (const { name, payloadRva, payloadBytes, section, expectedOffset } of [ @@ -202,8 +190,7 @@ const expectPeMappingOwnershipAcrossAdapters = async ( if (expectedOffset === null) { await expectPeInputFailureAcrossAdapters({ cli, - client, - toolName: contract.name, + call, path, reason: "invalid_format", }); @@ -214,10 +201,7 @@ const expectPeMappingOwnershipAcrossAdapters = async ( }); expect(result.exitCode, name).toBe(0); const evidence = contract.outputSchema.parse(result.json); - const response = await client.callTool({ - name: contract.name, - arguments: { path }, - }); + const response = await call({ path }); expect(response.isError, name).not.toBe(true); const mcp = contract.outputSchema.parse(response.structuredContent); expect(mcp.subject).toEqual(evidence.subject); @@ -245,7 +229,7 @@ const expectPeMappingOwnershipAcrossAdapters = async ( const expectPeInventoriesAcrossAdapters = async ( cli: TestCli, - client: Client, + call: PeResourceCall, root: string, ): Promise => { for (const [plus, empty] of [ @@ -304,10 +288,7 @@ const expectPeInventoriesAcrossAdapters = async ( same_language_resource_index: null, }, ]); - const response = await client.callTool({ - name: contract.name, - arguments: { path }, - }); + const response = await call({ path }); expect(response.isError).not.toBe(true); const mcp = contract.outputSchema.parse(response.structuredContent); expect(mcp.subject).toEqual(evidence.subject); @@ -351,41 +332,45 @@ cliTest( }), }), ); - const session = await client.callTool({ - name: "binary_session", - arguments: {}, - }); + const call: PeResourceCall = (arguments_) => + client.callTool( + { name: definition.name, arguments: arguments_ }, + { toolDefinition: definition }, + ); + const sessionDefinition = inventory.tools.find( + ({ name }) => name === "binary_session", + ); + if (sessionDefinition === undefined) + throw new Error("Missing binary_session tool contract"); + const session = await client.callTool( + { name: sessionDefinition.name, arguments: {} }, + { toolDefinition: sessionDefinition }, + ); expect(JSON.stringify(session.structuredContent)).toContain( "inspect_pe_resources", ); - await expectPeInventoriesAcrossAdapters(cli, client, root); + await expectPeInventoriesAcrossAdapters(cli, call, root); await expectPeWordAlignedRecordsAcrossAdapters({ cli, - client, + call, root, - definition, }); - await expectPeMappingOwnershipAcrossAdapters(cli, client, root); + await expectPeMappingOwnershipAcrossAdapters(cli, call, root); await expectPePaddingFailureAcrossAdapters({ cli, - client, - toolName: contract.name, + call, path: join(root, "raw-padding.exe"), }); await expectPeInputFailuresAcrossAdapters({ cli, - client, - toolName: contract.name, + call, root, }); const absent = peResourceFixture(); absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8); const path = join(root, "absent.exe"); await writeFile(path, absent.bytes); - const empty = await client.callTool({ - name: contract.name, - arguments: { path }, - }); + const empty = await call({ path }); expect(empty.isError).not.toBe(true); expect( contract.outputSchema.parse(empty.structuredContent).normalized_result, @@ -399,10 +384,7 @@ cliTest( { path: "relative.exe" }, { path, extra: true }, ]) { - const rejected = await client.callTool({ - name: contract.name, - arguments: arguments_, - }); + const rejected = await call(arguments_); expect(rejected.isError).toBe(true); expect( contract.outputSchema.safeParse(rejected.structuredContent).success, @@ -412,10 +394,7 @@ cliTest( damaged.bytes.writeUInt32LE(17, damaged.directoryAt + 4); const badPath = join(root, "damaged.exe"); await writeFile(badPath, damaged.bytes); - const rejected = await client.callTool({ - name: contract.name, - arguments: { path: badPath }, - }); + const rejected = await call({ path: badPath }); expect(rejected.isError).toBe(true); expect( contract.outputSchema.safeParse(rejected.structuredContent).success, From 647717c050ff4dbaef83fb1c5555ef5c33979cd4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:01:20 +0800 Subject: [PATCH 02/29] perf(javascript): reuse the source-analysis worker within a process (#5819) * perf(javascript): reuse the source-analysis worker within a process Each analyze-javascript-application call started a new Node worker. That startup, not fixture IO or the shared CLI session, dominated the slow loader-review and factory-completion files. Park one healthy worker on the artifact resource scope and on the CLI process. Stop it when the scope, session, or one-shot process ends. Do not reuse after failure, cancellation, or a different heap or timeout. Co-authored-by: morluto * fix(cli): close the JavaScript worker when dispatch throws Minimal dispatcher runtimes now export a no-op closeCliJavaScriptResources, and CLI dispatch plus output rendering always await that cleanup. A dispatch failure and a cleanup failure are both reported. Co-authored-by: morluto * fix(artifacts): report close-hook failures with retained cleanup When retained cleanup and the scope close hook both fail, the close error keeps both causes and both resource labels. The pending owners and the hook stay registered so the next close retries each path. Co-authored-by: morluto * fix(javascript): stop a replaced analysis worker before the next one starts A heap or timeout change now closes the parked worker before constructing its replacement. The scope retains that process only when the close fails. Co-authored-by: morluto * style: format worker-reuse close and launch helpers Co-authored-by: morluto * fix(cli): report a failed JavaScript worker shutdown before exit Share one in-flight close and drop the CLI scope only after it succeeds, so a later caller retries a worker that is still owned. The one-shot exit callback writes that failure and exits non-zero, while a closed stdout pipe still exits 141. Co-authored-by: morluto * fix(javascript): keep a worker parked during an overlapping limit change Claim the scope slot only when it is still empty after the replaced worker closes. A concurrent analysis that parked another healthy worker keeps that process, and the extra worker is closed through its own port. Co-authored-by: morluto * fix(artifacts): run later close hooks when an earlier hook fails Run every registered close hook on each close. One failure is returned unchanged, and several failures are aggregated so a later hook is not skipped. Co-authored-by: morluto * fix(javascript): fail a limit change when the previous worker stays alive A failed close used to be retained and then ignored, so MCP could return a successful analysis while the old process and its replacement were both owned. The request now fails before the replacement is constructed, and the still-live worker stays owned for a later close. Co-authored-by: morluto --------- Co-authored-by: Cursor Agent Co-authored-by: morluto --- scripts/rea.mjs | 133 ++++++--- .../JavaScriptArtifactReconstruction.ts | 3 +- src/artifacts/ArtifactResourceScope.test.ts | 83 ++++++ src/artifacts/ArtifactResourceScope.ts | 96 +++++- .../javascript/JavaScriptSourceAnalysis.ts | 147 +++++++++- src/cli.ts | 2 + src/cli/javascriptApplicationAnalysis.ts | 43 ++- .../analysis/JavaScriptAnalysisWorker.ts | 48 ++- .../cli/cliJavaScriptResourceClose.test.ts | 66 +++++ tests/boundary/cli/dispatcher.test.ts | 87 ++++-- .../javascriptApplicationService.test.ts | 2 +- .../javascriptSourceAnalysisWorker.test.ts | 274 ++++++++++++++++++ .../javascriptSemanticTraceEvidence.test.ts | 8 +- tests/support/cli/cliCommandSessionWorker.mjs | 7 +- tests/support/cli/compiledCliWorker.mjs | 3 +- 15 files changed, 925 insertions(+), 77 deletions(-) create mode 100644 tests/boundary/cli/cliJavaScriptResourceClose.test.ts diff --git a/scripts/rea.mjs b/scripts/rea.mjs index ab16851541..321c801aad 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -80,7 +80,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { writeStdout(result.output); process.exitCode = result.exitCode; } else { - const { cliConfigurationError, createCli } = + const { cliConfigurationError, closeCliJavaScriptResources, createCli } = await import("../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../dist/cli-runtime/cliResultOutput.js"); @@ -94,47 +94,97 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { const cliArgs = normalizeCliArguments(args); const outputArguments = validateCliOutputArguments(cliArgs); const configurationError = cliConfigurationError(process.env); - // Doctor is the readiness report for this failure. Other commands still stop - // here so they do not run with an unusable log level. - if (configurationError !== undefined && !isDoctorCommand(cliArgs)) { - process.stderr.write(`${configurationError}\n`); - process.exitCode = 1; - } else if (!outputArguments.ok) { - writeStdout(renderCliOutputArgumentError(outputArguments)); - process.exitCode = 1; - } else { - let wroteOutput = false; - const resultOutput = createCliResultOutput(cliArgs, process.stdout); - try { - await createCli(process.env, resultOutput).serve(cliArgs, { - stdout: (output) => { - if (resultOutput?.handled) { - if (resultOutput.failed && !stdoutClosed) - process.stderr.write(output); - return; - } - if (output.length > 0) wroteOutput = true; - writeStdout(output); - }, - // A command failure caused by the closed pipe keeps the pipe's status. - exit: (code) => process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : code), - }); - } catch (cause) { - if (wroteOutput) throw cause; - const recovered = await resultOutput - ?.recoverFormattingFailure(cause) - .catch((writeFailure) => { - // Recovery writes outside Incur's command boundary. A reader that - // declined this document keeps the same quiet SIGPIPE outcome. - if (!stdoutClosed) throw writeFailure; - return true; + let dispatchFailure; + // Incur can exit from the callback below before this function returns. + // That callback owns the status, including a closed-pipe exit of 141. + let exiting = false; + try { + // Doctor is the readiness report for this failure. Other commands still stop + // here so they do not run with an unusable log level. + if (configurationError !== undefined && !isDoctorCommand(cliArgs)) { + process.stderr.write(`${configurationError}\n`); + process.exitCode = 1; + } else if (!outputArguments.ok) { + writeStdout(renderCliOutputArgumentError(outputArguments)); + process.exitCode = 1; + } else { + let wroteOutput = false; + const resultOutput = createCliResultOutput(cliArgs, process.stdout); + try { + await createCli(process.env, resultOutput).serve(cliArgs, { + stdout: (output) => { + if (resultOutput?.handled) { + if (resultOutput.failed && !stdoutClosed) + process.stderr.write(output); + return; + } + if (output.length > 0) wroteOutput = true; + writeStdout(output); + }, + // A command failure caused by the closed pipe keeps the pipe's status. + // Worker shutdown is shared with the dispatch finally below. A failed + // shutdown is reported here because this callback is what exits. + exit: (code) => { + exiting = true; + const status = stdoutClosed ? SIGPIPE_EXIT_STATUS : code; + void Promise.resolve() + .then(() => closeCliJavaScriptResources()) + .then( + () => { + process.exit(status); + }, + (cleanup) => { + process.stderr.write( + `Failed to close the JavaScript analysis worker: ${cleanupFailureText(cleanup)}\n`, + ); + process.exit( + stdoutClosed + ? SIGPIPE_EXIT_STATUS + : status === 0 + ? 1 + : status, + ); + }, + ); + }, }); - if (!recovered) throw cause; - process.exitCode = stdoutClosed ? SIGPIPE_EXIT_STATUS : 1; + } catch (cause) { + if (wroteOutput) throw cause; + const recovered = await resultOutput + ?.recoverFormattingFailure(cause) + .catch((writeFailure) => { + // Recovery writes outside Incur's command boundary. A reader that + // declined this document keeps the same quiet SIGPIPE outcome. + if (!stdoutClosed) throw writeFailure; + return true; + }); + if (!recovered) throw cause; + process.exitCode = stdoutClosed ? SIGPIPE_EXIT_STATUS : 1; + } + if (!wroteOutput && !resultOutput?.handled) + writeStdout(renderEmptyFilteredCliOutput(cliArgs) ?? ""); + } + } catch (cause) { + dispatchFailure = cause; + } finally { + // Serve and output rendering can throw after a worker is parked. Stop it + // on that path too, and keep the dispatch failure when both fail. + try { + await closeCliJavaScriptResources(); + } catch (cleanup) { + // The exit callback reports this same shared failure and then exits. + if (!exiting) { + dispatchFailure = + dispatchFailure === undefined + ? cleanup + : new AggregateError( + [dispatchFailure, cleanup], + "CLI dispatch failed and the JavaScript analysis worker remained owned", + ); + } } - if (!wroteOutput && !resultOutput?.handled) - writeStdout(renderEmptyFilteredCliOutput(cliArgs) ?? ""); } + if (!exiting && dispatchFailure !== undefined) throw dispatchFailure; } /** True when the first positional command is `doctor`, after global output flags. */ @@ -164,6 +214,11 @@ function isDoctorCommand(arguments_) { * with the status a SIGPIPE-terminated writer reports. MCP stdio keeps its own * client lifecycle. */ +function cleanupFailureText(cleanup) { + if (cleanup instanceof Error) return cleanup.stack ?? cleanup.message; + return String(cleanup); +} + function guardClosedStdout() { process.stdout.on("error", (cause) => { if (cause?.code === "EPIPE") { diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 1a2befbd9c..2252e5a6b1 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -158,7 +158,7 @@ export const reconstructJavaScriptArtifact = async ( ? {} : { fileNodes: input.max_semantic_nodes_per_file }), }); - const worker = createJavaScriptSourceAnalysis( + const worker = await createJavaScriptSourceAnalysis( { heapMb: input.max_heap_mb ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb, @@ -169,6 +169,7 @@ export const reconstructJavaScriptArtifact = async ( files.files.filter( (file) => file.kind === "javascript" && file.text.included, ).length, + { scope }, ); let outcome; let cleanupFailure: AnalysisError | undefined; diff --git a/src/artifacts/ArtifactResourceScope.test.ts b/src/artifacts/ArtifactResourceScope.test.ts index f9bffdb185..417e8ee6e7 100644 --- a/src/artifacts/ArtifactResourceScope.test.ts +++ b/src/artifacts/ArtifactResourceScope.test.ts @@ -55,6 +55,89 @@ it("waits for admitted operations and shares concurrent close", async () => { expect(closed).toBe(true); }); +it("stops a close hook even when retained cleanup is still failing", async () => { + const scope = new ArtifactResourceScope(); + let releases = 0; + const owner = { + kind: "lease" as const, + resource: "stuck lease", + lease: { + async release() { + releases += 1; + if (releases < 3) throw new Error("still owned"); + }, + }, + }; + expect(await scope.release(owner)).toMatchObject({ kind: "failed" }); + let hookRuns = 0; + scope.addCloseHook(async () => { + hookRuns += 1; + if (hookRuns === 1) throw new Error("hook still stopping"); + }); + + const closeFailure = await scope.close().then( + () => { + throw new Error("close succeeded while cleanup and the hook failed"); + }, + (cause: unknown) => cause, + ); + expect(closeFailure).toMatchObject({ + message: "Artifact cleanup remains incomplete: stuck lease, close hook", + failedOwners: [owner], + cleanup: { + reason: "still owned; hook still stopping", + resources: ["stuck lease", "close hook"], + }, + }); + expect(closeFailure).toBeInstanceOf(ArtifactResourceRetryFailure); + const causes = (closeFailure as ArtifactResourceRetryFailure).cause; + expect(causes).toBeInstanceOf(AggregateError); + expect( + (causes as AggregateError).errors.map((error) => + error instanceof Error ? error.message : String(error), + ), + ).toEqual([ + "Artifact cleanup remains incomplete: stuck lease", + "hook still stopping", + ]); + expect(releases).toBe(2); + expect(hookRuns).toBe(1); + await scope.close(); + expect(releases).toBe(3); + expect(hookRuns).toBe(2); +}); + +it("runs later close hooks when an earlier hook fails", async () => { + const scope = new ArtifactResourceScope(); + const order: string[] = []; + scope.addCloseHook(async () => { + order.push("first"); + throw new Error("first hook failed"); + }); + scope.addCloseHook(async () => { + order.push("second"); + throw new Error("second hook failed"); + }); + + const failure = await scope.close().then( + () => { + throw new Error("close succeeded while hooks failed"); + }, + (cause: unknown) => cause, + ); + expect(order).toEqual(["first", "second"]); + expect(failure).toBeInstanceOf(AggregateError); + expect( + (failure as AggregateError).errors.map((error) => + error instanceof Error ? error.message : String(error), + ), + ).toEqual(["first hook failed", "second hook failed"]); + + order.length = 0; + await expect(scope.close()).rejects.toBeInstanceOf(AggregateError); + expect(order).toEqual(["first", "second"]); +}); + it("retains the same failed reader owner in a retry failure and retries before admitting work", async () => { const scope = new ArtifactResourceScope(); let closeAttempts = 0; diff --git a/src/artifacts/ArtifactResourceScope.ts b/src/artifacts/ArtifactResourceScope.ts index 9a7cf89e78..1b2848e76a 100644 --- a/src/artifacts/ArtifactResourceScope.ts +++ b/src/artifacts/ArtifactResourceScope.ts @@ -95,6 +95,8 @@ export class ArtifactResourceScope { >(); readonly #active = new Set>(); #draining: Promise | undefined; + #closeInFlight: Promise | undefined; + readonly #closeHooks: Array<() => Promise> = []; #closed = false; get hasPending(): boolean { @@ -203,10 +205,102 @@ export class ArtifactResourceScope { return this.#draining; } + /** + * Run after retained owners are released. Hooks stay registered so a later + * close retries them, and a failing hook does not skip the ones after it. + * Concurrent closes share one in-flight attempt. + */ + addCloseHook(hook: () => Promise): void { + this.#closeHooks.push(hook); + } + /** Stop admission, wait for active operations, and retry every retained owner. */ close(): Promise { this.#closed = true; - return this.retryCleanup(); + if (this.#closeInFlight !== undefined) return this.#closeInFlight; + const attempt = this.#finishClose(); + let published: Promise; + published = attempt.finally(() => { + if (this.#closeInFlight === published) this.#closeInFlight = undefined; + }); + this.#closeInFlight = published; + return published; + } + + /** Every registered hook runs. One failure is returned unchanged; several are aggregated. */ + async #runCloseHooks(): Promise { + const failures: unknown[] = []; + for (const hook of this.#closeHooks) { + try { + await hook(); + } catch (cause: unknown) { + failures.push(cause); + } + } + if (failures.length === 0) return undefined; + if (failures.length === 1) return failures[0]; + return new AggregateError( + failures, + failures + .map((cause) => + cause instanceof Error ? cause.message : String(cause), + ) + .join("; "), + ); + } + + async #finishClose(): Promise { + // A parked analyzer is not a pending owner. Stop it even when another + // retained cleanup is still failing. A later close retries both. + const cleanup = await this.retryCleanup().then( + () => undefined, + (cause: unknown) => cause, + ); + const hookFailure = await this.#runCloseHooks(); + if (cleanup !== undefined && hookFailure !== undefined) + throw this.#composeCloseFailures(cleanup, hookFailure); + if (hookFailure !== undefined) throw hookFailure; + if (cleanup !== undefined) throw cleanup; + } + + /** + * Report both outstanding owners. The pending set and the close hook stay + * registered, so the next close retries each failed path. + */ + #composeCloseFailures( + cleanup: unknown, + hookFailure: unknown, + ): ArtifactReaderFailure { + const retained = ArtifactReaderFailure.cleanupObservation( + cleanup, + "retained artifact cleanup", + ); + const hook = ArtifactReaderFailure.cleanupObservation( + hookFailure, + "close hook", + ); + const resources = [...new Set([...retained.resources, ...hook.resources])]; + const options = { + cause: new AggregateError( + [cleanup, hookFailure], + "Retained cleanup and a close hook both failed", + ), + cleanup: { + reason: `${retained.reason}; ${hook.reason}`, + resources, + }, + }; + const message = `Artifact cleanup remains incomplete: ${resources.join(", ")}`; + if (cleanup instanceof ArtifactResourceRetryFailure) + return new ArtifactResourceRetryFailure( + cleanup.failedOwners.map((owner) => ({ + owner, + lease: this.#lease(owner), + })), + message, + options, + ); + return new ArtifactReaderFailure("unavailable", message, options); } async #drainAndRetry(): Promise { diff --git a/src/artifacts/javascript/JavaScriptSourceAnalysis.ts b/src/artifacts/javascript/JavaScriptSourceAnalysis.ts index ac37fc75f2..e30049e412 100644 --- a/src/artifacts/javascript/JavaScriptSourceAnalysis.ts +++ b/src/artifacts/javascript/JavaScriptSourceAnalysis.ts @@ -3,10 +3,153 @@ import type { JavaScriptSourceAnalysisPort, } from "../../domain/javascript/javascriptSourceAnalysis.js"; import { JavaScriptAnalysisWorker } from "../../javascript/analysis/JavaScriptAnalysisWorker.js"; +import type { spawnOwnedProviderProcess } from "../../process/ProviderProcess.js"; +import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; + +interface RetainedJavaScriptSourceAnalysis { + readonly worker: JavaScriptAnalysisWorker; + readonly limits: JavaScriptSourceAnalysisLimits; + busy: boolean; +} + +const retained = new WeakMap< + ArtifactResourceScope, + RetainedJavaScriptSourceAnalysis +>(); +const closeHookRegistered = new WeakSet(); + +export interface JavaScriptSourceAnalysisAcquisition { + /** Keep a healthy worker until this scope closes. */ + readonly scope?: ArtifactResourceScope; + /** Test seam. Reuse keeps the process already launched for the scope. */ + readonly launcher?: typeof spawnOwnedProviderProcess; +} /** Construct the inert artifact analyzer; resources are acquired on the first source. */ export const createJavaScriptSourceAnalysis = ( limits: JavaScriptSourceAnalysisLimits, sourceCount: number, -): JavaScriptSourceAnalysisPort => - new JavaScriptAnalysisWorker(limits, undefined, sourceCount); + acquisition: JavaScriptSourceAnalysisAcquisition = {}, +): Promise => { + if (acquisition.scope === undefined) + return Promise.resolve( + new JavaScriptAnalysisWorker(limits, acquisition.launcher, sourceCount), + ); + return checkoutJavaScriptSourceAnalysis( + acquisition.scope, + limits, + sourceCount, + acquisition.launcher, + ); +}; + +const checkoutJavaScriptSourceAnalysis = async ( + scope: ArtifactResourceScope, + limits: JavaScriptSourceAnalysisLimits, + sourceCount: number, + launcher: typeof spawnOwnedProviderProcess | undefined, +): Promise => { + registerJavaScriptSourceAnalysisClose(scope); + const current = retained.get(scope); + if (current !== undefined && !current.busy) { + if ( + sameLimits(current.limits, limits) && + current.worker.prepareForAnotherAnalysis(sourceCount) + ) { + current.busy = true; + return new ScopeJavaScriptSourceAnalysis(scope, current.worker); + } + retained.delete(scope); + try { + await current.worker.close(); + } catch (cause) { + // The old process is still alive. Keep it owned and fail this request + // before another worker is constructed, including on MCP where the + // analysis result is returned without a later cleanup retry. + if (retained.get(scope) === undefined) { + retained.set(scope, { + worker: current.worker, + limits: current.limits, + busy: false, + }); + } else { + scope.retain({ + kind: "lease", + resource: "replaced JavaScript source analyzer", + lease: { release: () => current.worker.close() }, + }); + } + throw cause; + } + } + const worker = new JavaScriptAnalysisWorker(limits, launcher, sourceCount); + // A concurrent analysis may have parked another healthy worker while the + // replaced process was closing. Claim the slot only when it is still empty. + // This extra process is closed through the port returned below. + if (retained.get(scope) === undefined) + retained.set(scope, { worker, limits, busy: true }); + return new ScopeJavaScriptSourceAnalysis(scope, worker); +}; + +const registerJavaScriptSourceAnalysisClose = ( + scope: ArtifactResourceScope, +): void => { + if (closeHookRegistered.has(scope)) return; + closeHookRegistered.add(scope); + scope.addCloseHook(async () => { + const current = retained.get(scope); + if (current === undefined) return; + await current.worker.close(); + if (retained.get(scope) === current) retained.delete(scope); + }); +}; + +const sameLimits = ( + left: JavaScriptSourceAnalysisLimits, + right: JavaScriptSourceAnalysisLimits, +): boolean => + left.heapMb === right.heapMb && left.timeoutMs === right.timeoutMs; + +/** + * Park a healthy worker on its scope. The next checkout with the same limits + * reuses that process; scope close is what actually stops it. + */ +class ScopeJavaScriptSourceAnalysis implements JavaScriptSourceAnalysisPort { + #failed = false; + #closed = false; + + constructor( + private readonly scope: ArtifactResourceScope, + private readonly worker: JavaScriptAnalysisWorker, + ) {} + + analyze( + ...args: Parameters + ): ReturnType { + return this.worker.analyze(...args).then((result) => { + if (!result.ok) this.#failed = true; + return result; + }); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + try { + const current = retained.get(this.scope); + if ( + current?.worker === this.worker && + !this.#failed && + this.worker.canReuse() + ) { + current.busy = false; + return; + } + if (current?.worker === this.worker) retained.delete(this.scope); + await this.worker.close(); + } catch (cause) { + this.#closed = false; + throw cause; + } + } +} diff --git a/src/cli.ts b/src/cli.ts index d7e9e72ad9..586942bcd5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -58,6 +58,8 @@ import { import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringCommands.js"; import { registerCutterCommands } from "./cli/cutterCommands.js"; +export { closeCliJavaScriptResources } from "./cli/javascriptApplicationAnalysis.js"; + /** * Describe CLI settings that are invalid before any command runs, as MCP does. * Keep this aligned with PROCESS_START_CONFIGURATION_SETTINGS. diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index e2f171bec4..f4fcc95575 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -7,7 +7,43 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -/** Execute the shared one-shot CLI boundary for static JavaScript analysis. */ +/** + * One scope for every JavaScript analysis in this process. A healthy worker + * stays parked between commands; {@link closeCliJavaScriptResources} stops it. + * Pending cleanup is still retried before each command returns. + */ +let retainedScope: ArtifactResourceScope | undefined; +/** One shutdown shared by the exit callback and the dispatch `finally`. */ +let closing: Promise | undefined; + +const cliAnalysisScope = (): ArtifactResourceScope => { + retainedScope ??= new ArtifactResourceScope(); + return retainedScope; +}; + +/** + * Stop the process-owned JavaScript worker. Callers that overlap share the + * attempt in flight. The scope stays owned when shutdown fails, so the next + * call retries instead of treating the worker as already released. + */ +export const closeCliJavaScriptResources = (): Promise => { + if (closing !== undefined) return closing; + const scope = retainedScope; + if (scope === undefined) return Promise.resolve(); + let published: Promise; + published = scope + .close() + .then(() => { + if (retainedScope === scope) retainedScope = undefined; + }) + .finally(() => { + if (closing === published) closing = undefined; + }); + closing = published; + return published; +}; + +/** Execute the shared CLI boundary for static JavaScript analysis. */ export const runCliJavaScriptApplicationAnalysis = async ( input: unknown, signal?: AbortSignal, @@ -20,13 +56,14 @@ export const runCliJavaScriptApplicationAnalysis = async ( }, { minimumIntervalMs: 0 }, ); - const resources = new ArtifactResourceScope(); + const resources = cliAnalysisScope(); const result = await analyzeJavaScriptApplication(input, resources, { progress, ...(signal === undefined ? {} : { signal }), }); try { - await resources.close(); + // Retry failed owners without stopping a healthy parked worker. + await resources.retryCleanup(); } catch (cause: unknown) { const cleanup = ArtifactReaderFailure.cleanupObservation( cause, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index dea646d263..7f978e2352 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -105,7 +105,7 @@ export class JavaScriptAnalysisWorker { #parentCapacityExhausted = false; #sourcesRemaining: number | undefined; #deadline: ProviderStartupDeadline | undefined; - readonly #resultBudgetBytes: number; + #resultBudgetBytes = 0; /** Null when the runtime enforces the V8 heap flags instead. */ readonly #processMemoryLimitBytes: number | null; readonly #onStdout = (chunk: Buffer | string): void => { @@ -179,8 +179,50 @@ export class JavaScriptAnalysisWorker { this.#processMemoryLimitBytes = javaScriptWorkerMemoryLimitBytes( limits.heapMb, ); - // Reserve host-side control, schema/Evidence work and a maximum record's - // transient copies. Admission also accounts for retained earlier files. + this.#refreshResultBudget(); + } + + /** + * Whether this idle process can analyze another application. A failed, + * cancelled, or still-open job must not be reused. + */ + canReuse(): boolean { + return ( + this.#ready && + this.#activeJobId === undefined && + this.#primaryFailure === undefined && + this.#lastFailure === undefined && + this.#frames.length === 0 && + this.#pending === "" && + this.#runtime.inputFailure === undefined && + !this.#runtime.hasPendingCleanup && + this.#runtime.supervisor !== undefined + ); + } + + /** + * Reset per-analysis admission for one idle process. The worker heap limit + * stays the one selected at launch; callers must not reuse across limits. + */ + prepareForAnotherAnalysis(sourceCount: number): boolean { + if (!this.canReuse()) return false; + if (!Number.isSafeInteger(sourceCount) || sourceCount < 0) + throw new RangeError( + "JavaScript worker source count must be a nonnegative safe integer", + ); + this.#sourcesRemaining = sourceCount; + this.#estimatedRetainedBytes = 0; + this.#parentCapacityExhausted = false; + this.#applicationProjectionFailure = undefined; + this.#refreshResultBudget(); + return true; + } + + /** + * Reserve host-side control, schema/Evidence work, and a maximum record's + * transient copies. Admission also accounts for retained earlier files. + */ + #refreshResultBudget(): void { this.#resultBudgetBytes = Math.max( 0, Math.floor( diff --git a/tests/boundary/cli/cliJavaScriptResourceClose.test.ts b/tests/boundary/cli/cliJavaScriptResourceClose.test.ts new file mode 100644 index 0000000000..2ba0873d39 --- /dev/null +++ b/tests/boundary/cli/cliJavaScriptResourceClose.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, it } from "vitest"; + +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { + closeCliJavaScriptResources, + runCliJavaScriptApplicationAnalysis, +} from "../../../src/cli/javascriptApplicationAnalysis.js"; + +const originalClose = ArtifactResourceScope.prototype.close; + +afterEach(async () => { + ArtifactResourceScope.prototype.close = originalClose; + await closeCliJavaScriptResources(); +}); + +/** Create the process-owned scope without starting a worker. */ +const openCliScope = (): Promise => + runCliJavaScriptApplicationAnalysis({}); + +it("retries JavaScript worker shutdown when the first close fails", async () => { + let calls = 0; + ArtifactResourceScope.prototype.close = function ( + this: ArtifactResourceScope, + ) { + calls += 1; + if (calls === 1) return Promise.reject(new Error("worker still owned")); + return originalClose.call(this); + }; + + await openCliScope(); + await expect(closeCliJavaScriptResources()).rejects.toThrow( + "worker still owned", + ); + expect(calls).toBe(1); + await closeCliJavaScriptResources(); + expect(calls).toBe(2); + await closeCliJavaScriptResources(); + expect(calls).toBe(2); +}); + +it("shares one in-flight JavaScript worker shutdown between callers", async () => { + let calls = 0; + let release = (): void => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + ArtifactResourceScope.prototype.close = function ( + this: ArtifactResourceScope, + ) { + calls += 1; + return gate.then(() => originalClose.call(this)); + }; + + try { + await openCliScope(); + const first = closeCliJavaScriptResources(); + const second = closeCliJavaScriptResources(); + expect(second).toBe(first); + expect(calls).toBe(1); + release(); + await first; + expect(calls).toBe(1); + } finally { + release(); + } +}); diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 316a2cff21..40b46736de 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -4,32 +4,39 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; +import type { TestWorkspace } from "../../support/workspace/workspaceFixture.js"; + +const CLI_OUTPUT_RUNTIME = + "export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n"; + +const installCliRuntime = async ( + workspace: TestWorkspace, + cliSource: string, + resultOutput = "export const createCliResultOutput = () => undefined;\n", +): Promise => { + await workspace.mkdir("scripts"); + await workspace.mkdir("dist/cli-runtime"); + await Promise.all([ + copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")), + workspace.write( + "package.json", + JSON.stringify({ type: "module", version: "1.0.0" }), + ), + workspace.write("dist/cli-runtime/cli.js", cliSource), + workspace.write("dist/cli-runtime/cliOutput.js", CLI_OUTPUT_RUNTIME), + workspace.write("dist/cli-runtime/cliResultOutput.js", resultOutput), + ]); +}; describe("executable dispatcher", () => { workspaceCliTest( "does not append recovery output after delegated stdout has begun", async ({ processes, workspace }) => { - await workspace.mkdir("scripts"); - await workspace.mkdir("dist/cli-runtime"); - await Promise.all([ - copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")), - workspace.write( - "package.json", - JSON.stringify({ type: "module", version: "1.0.0" }), - ), - workspace.write( - "dist/cli-runtime/cli.js", - 'export const cliConfigurationError = () => undefined; export const createCli = () => ({ serve: (_args, options) => { options.stdout("first document\\n"); throw new RangeError("Invalid string length"); } });\n', - ), - workspace.write( - "dist/cli-runtime/cliOutput.js", - "export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", - ), - workspace.write( - "dist/cli-runtime/cliResultOutput.js", - 'export const createCliResultOutput = () => ({ handled: false, failed: false, recoverFormattingFailure: () => { process.stdout.write("unexpected second document"); return true; } });\n', - ), - ]); + await installCliRuntime( + workspace, + 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { options.stdout("first document\\n"); throw new RangeError("Invalid string length"); } });\n', + 'export const createCliResultOutput = () => ({ handled: false, failed: false, recoverFormattingFailure: () => { process.stdout.write("unexpected second document"); return true; } });\n', + ); const result = await processes.run( process.execPath, [workspace.path("scripts/rea.mjs"), "fixture"], @@ -60,7 +67,7 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cli-runtime/cli.js", - 'export const cliConfigurationError = () => undefined; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', + 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', ), workspace.write( "dist/cli-runtime/cliOutput.js", @@ -155,3 +162,39 @@ describe("executable dispatcher", () => { }, ); }); + +describe("executable dispatcher JavaScript shutdown", () => { + workspaceCliTest( + "reports a failed JavaScript worker shutdown instead of exiting successfully", + async ({ processes, workspace }) => { + await installCliRuntime( + workspace, + 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { options.exit(0); } });\n', + ); + const result = await processes.run( + process.execPath, + [workspace.path("scripts/rea.mjs"), "fixture"], + { cwd: workspace.root }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("worker still owned"); + }, + ); + + workspaceCliTest( + "keeps the closed-pipe status when JavaScript worker shutdown fails", + async ({ processes, workspace }) => { + await installCliRuntime( + workspace, + 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { process.stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); options.exit(0); } });\n', + ); + const result = await processes.run( + process.execPath, + [workspace.path("scripts/rea.mjs"), "fixture"], + { cwd: workspace.root }, + ); + expect(result.exitCode).toBe(141); + expect(result.stderr).toContain("worker still owned"); + }, + ); +}); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts index 4dbf49ae0e..af7b0777ca 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts @@ -38,7 +38,7 @@ describe("JavaScript source selection cancellation boundary", () => { controller.signal, sourcePaths, ); - const worker = createJavaScriptSourceAnalysis( + const worker = await createJavaScriptSourceAnalysis( { heapMb: 512, timeoutMs: 30_000 }, 1, ); diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index ff7f56aba6..117230e2e1 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { expect, it } from "vitest"; import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; +import { createJavaScriptSourceAnalysis } from "../../../src/artifacts/javascript/JavaScriptSourceAnalysis.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -90,6 +92,278 @@ it("reuses one real owned analyzer while retaining distinct source identities an } }); +it("reuses one owned worker across analyses on the same resource scope", async () => { + const scope = new ArtifactResourceScope(); + const launches: number[] = []; + const exits = new Map>(); + const launcher: typeof spawnOwnedProviderProcess = async (request) => { + const previous = launches.at(-1); + if (previous !== undefined) await exits.get(previous); + const spawned = await spawnOwnedProviderProcess(request); + const pid = spawned.process.pid; + if (pid === undefined) + throw new Error("Owned analyzer has no observed PID"); + exits.set( + pid, + new Promise((resolve) => { + spawned.process.once("exit", () => { + resolve(); + }); + }), + ); + launches.push(pid); + return spawned; + }; + const limits = { heapMb: 128, timeoutMs: 30_000 }; + const first = source("first.mjs", "export const observed = 1;"); + const second = source("second.mjs", "export const observed = 2;"); + const files = [first, second]; + try { + for (const file of files) { + const worker = await createJavaScriptSourceAnalysis(limits, 1, { + scope, + launcher, + }); + try { + const result = await worker.analyze(file, { + nodes: 100, + unknowns: 100, + }); + if (!result.ok) throw result.error.error; + expect(result.value.javascript.parse_status).toBe("complete"); + } finally { + await worker.close(); + } + } + expect(launches).toHaveLength(1); + const replaced = await createJavaScriptSourceAnalysis( + { heapMb: 256, timeoutMs: 30_000 }, + 1, + { scope, launcher }, + ); + try { + const result = await replaced.analyze(first, { + nodes: 100, + unknowns: 100, + }); + if (!result.ok) throw result.error.error; + expect(result.value.javascript.parse_status).toBe("complete"); + } finally { + await replaced.close(); + } + expect(launches).toHaveLength(2); + await scope.close(); + const reopened = await createJavaScriptSourceAnalysis(limits, 1, { + scope, + launcher, + }); + try { + const result = await reopened.analyze(first, { + nodes: 100, + unknowns: 100, + }); + if (!result.ok) throw result.error.error; + } finally { + await reopened.close(); + } + expect(launches).toHaveLength(3); + } finally { + await scope.close(); + } +}); + +it("keeps a worker parked while a limit change closes the previous process", async () => { + const scope = new ArtifactResourceScope(); + const launches: number[] = []; + const exited = new Set(); + const exits = new Map>(); + const launcher: typeof spawnOwnedProviderProcess = async (request) => { + const spawned = await spawnOwnedProviderProcess(request); + const pid = spawned.process.pid; + if (pid === undefined) + throw new Error("Owned analyzer has no observed PID"); + exits.set( + pid, + new Promise((resolve) => { + spawned.process.once("exit", () => { + exited.add(pid); + resolve(); + }); + }), + ); + launches.push(pid); + return spawned; + }; + const limits = { heapMb: 128, timeoutMs: 30_000 }; + const file = source("parked.mjs", "export const observed = 1;"); + const originalClose = JavaScriptAnalysisWorker.prototype.close; + let releaseClose = (): void => {}; + const gate = new Promise((resolve) => { + releaseClose = resolve; + }); + let closeCalls = 0; + let markCloseStarted = (): void => {}; + const closeStarted = new Promise((resolve) => { + markCloseStarted = resolve; + }); + let replacement: + | ReturnType + | undefined; + try { + const initial = await createJavaScriptSourceAnalysis(limits, 1, { + scope, + launcher, + }); + const first = await initial.analyze(file, { nodes: 100, unknowns: 100 }); + if (!first.ok) throw first.error.error; + await initial.close(); + const replacedPid = launches[0]; + if (replacedPid === undefined) + throw new Error("Replaced analyzer has no observed PID"); + + JavaScriptAnalysisWorker.prototype.close = async function ( + this: JavaScriptAnalysisWorker, + ) { + closeCalls += 1; + if (closeCalls === 1) { + markCloseStarted(); + await gate; + } + return originalClose.call(this); + }; + replacement = createJavaScriptSourceAnalysis( + { heapMb: 256, timeoutMs: 30_000 }, + 1, + { scope, launcher }, + ); + await closeStarted; + + const concurrent = await createJavaScriptSourceAnalysis(limits, 1, { + scope, + launcher, + }); + const concurrentResult = await concurrent.analyze(file, { + nodes: 100, + unknowns: 100, + }); + if (!concurrentResult.ok) throw concurrentResult.error.error; + await concurrent.close(); + const parkedPid = launches[1]; + if (parkedPid === undefined) + throw new Error("Parked analyzer has no observed PID"); + + releaseClose(); + const extra = await replacement; + const extraResult = await extra.analyze(file, { + nodes: 100, + unknowns: 100, + }); + if (!extraResult.ok) throw extraResult.error.error; + const extraPid = launches[2]; + if (extraPid === undefined) + throw new Error("Replacement analyzer has no observed PID"); + await extra.close(); + await exits.get(replacedPid); + await exits.get(extraPid); + expect(exited.has(replacedPid)).toBe(true); + expect(exited.has(extraPid)).toBe(true); + expect(exited.has(parkedPid)).toBe(false); + + await scope.close(); + await exits.get(parkedPid); + expect(exited.has(parkedPid)).toBe(true); + } finally { + JavaScriptAnalysisWorker.prototype.close = originalClose; + releaseClose(); + const port = await replacement?.catch(() => undefined); + await port?.close().catch(() => undefined); + await scope.close(); + } +}); + +it("does not start a replacement when closing the previous worker fails", async () => { + const scope = new ArtifactResourceScope(); + const launches: number[] = []; + const exited = new Set(); + const exits = new Map>(); + const launcher: typeof spawnOwnedProviderProcess = async (request) => { + const previous = launches.at(-1); + if (previous !== undefined) await exits.get(previous); + const spawned = await spawnOwnedProviderProcess(request); + const pid = spawned.process.pid; + if (pid === undefined) + throw new Error("Owned analyzer has no observed PID"); + exits.set( + pid, + new Promise((resolve) => { + spawned.process.once("exit", () => { + exited.add(pid); + resolve(); + }); + }), + ); + launches.push(pid); + return spawned; + }; + const limits = { heapMb: 128, timeoutMs: 30_000 }; + const file = source("replaced.mjs", "export const observed = 1;"); + const originalClose = JavaScriptAnalysisWorker.prototype.close; + let closeCalls = 0; + try { + const initial = await createJavaScriptSourceAnalysis(limits, 1, { + scope, + launcher, + }); + const first = await initial.analyze(file, { nodes: 100, unknowns: 100 }); + if (!first.ok) throw first.error.error; + await initial.close(); + const replacedPid = launches[0]; + if (replacedPid === undefined) + throw new Error("Replaced analyzer has no observed PID"); + + JavaScriptAnalysisWorker.prototype.close = async function ( + this: JavaScriptAnalysisWorker, + ) { + closeCalls += 1; + if (closeCalls === 1) throw new Error("worker still owned"); + return originalClose.call(this); + }; + await expect( + createJavaScriptSourceAnalysis({ heapMb: 256, timeoutMs: 30_000 }, 1, { + scope, + launcher, + }), + ).rejects.toThrow("worker still owned"); + expect(launches).toEqual([replacedPid]); + expect(exited.has(replacedPid)).toBe(false); + + JavaScriptAnalysisWorker.prototype.close = originalClose; + const replacement = await createJavaScriptSourceAnalysis( + { heapMb: 256, timeoutMs: 30_000 }, + 1, + { scope, launcher }, + ); + try { + const result = await replacement.analyze(file, { + nodes: 100, + unknowns: 100, + }); + if (!result.ok) throw result.error.error; + } finally { + await replacement.close(); + } + expect(launches).toHaveLength(2); + expect(exited.has(replacedPid)).toBe(true); + const replacementPid = launches[1]; + if (replacementPid === undefined) + throw new Error("Replacement analyzer has no observed PID"); + expect(exited.has(replacementPid)).toBe(false); + } finally { + JavaScriptAnalysisWorker.prototype.close = originalClose; + await scope.close(); + } +}); + it("reports an actual startup deadline and closes its owned resources before subsequent analysis", async () => { const file = source("app.mjs", "export const observed = 7;"); const expired = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 1 }); diff --git a/tests/boundary/javascriptSemanticTraceEvidence.test.ts b/tests/boundary/javascriptSemanticTraceEvidence.test.ts index 25316c648c..94157c1307 100644 --- a/tests/boundary/javascriptSemanticTraceEvidence.test.ts +++ b/tests/boundary/javascriptSemanticTraceEvidence.test.ts @@ -1,13 +1,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it } from "vitest"; +import { afterAll, expect, it } from "vitest"; import { Ajv2020 } from "ajv/dist/2020.js"; import { createApplicationMcpHarness } from "../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; -import { createCli } from "../../src/cli.js"; +import { closeCliJavaScriptResources, createCli } from "../../src/cli.js"; import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; import type { JsonValue } from "../../src/domain/jsonValue.js"; import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js"; @@ -18,6 +18,10 @@ import { resolveJavaScriptSemanticEvidence } from "../../src/domain/javascript/j const SEMANTIC_GRAPH_ID = `jsrg_${"0".repeat(64)}`; +// In-process serve keeps the CLI worker for this file. Stop it before the +// worker process exits so the private runtime root is not left behind. +afterAll(closeCliJavaScriptResources); + it("reports invalid nested semantic graph Evidence as caller input through CLI and SDK MCP", async () => { const root = await createTestTempDirectory("rea-semantic-trace-input-"); await mkdir(join(root, "src")); diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs index 63ce69542c..683682542a 100644 --- a/tests/support/cli/cliCommandSessionWorker.mjs +++ b/tests/support/cli/cliCommandSessionWorker.mjs @@ -50,7 +50,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.exit(1); } -const { cliConfigurationError, createCli } = +const { cliConfigurationError, closeCliJavaScriptResources, createCli } = await import("../../../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../../../dist/cli-runtime/cliResultOutput.js"); @@ -89,7 +89,10 @@ process.stdin.on("data", (chunk) => { } }); process.stdin.on("end", () => { - chain.finally(() => process.exit(0)); + chain.finally(async () => { + await closeCliJavaScriptResources(); + process.exit(0); + }); }); async function handle(value) { diff --git a/tests/support/cli/compiledCliWorker.mjs b/tests/support/cli/compiledCliWorker.mjs index e8012c7510..50b08093d9 100644 --- a/tests/support/cli/compiledCliWorker.mjs +++ b/tests/support/cli/compiledCliWorker.mjs @@ -1,7 +1,7 @@ import { Writable } from "node:stream"; import { createInterface } from "node:readline"; -const { cliConfigurationError, createCli } = +const { cliConfigurationError, closeCliJavaScriptResources, createCli } = await import("../../../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../../../dist/cli-runtime/cliResultOutput.js"); @@ -137,3 +137,4 @@ for await (const line of lines) { if (line.trim().length === 0) continue; await acceptRequest(line); } +await closeCliJavaScriptResources(); From efab044f303ae3d957d977730bb1628063223236 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:06:44 +0800 Subject: [PATCH 03/29] feat(gpu): inspect static CUDA and HIP code objects (#5813) --- .../skills/reverse-engineer-anything/SKILL.md | 2 + README.md | 1 + docs/gpu-code-objects.md | 166 ++ docs/readme/README_ar.md | 1 + docs/readme/README_de.md | 1 + docs/readme/README_el.md | 1 + docs/readme/README_es.md | 1 + docs/readme/README_fa.md | 1 + docs/readme/README_fr.md | 1 + docs/readme/README_id.md | 1 + docs/readme/README_it.md | 1 + docs/readme/README_ja.md | 1 + docs/readme/README_ko.md | 1 + docs/readme/README_ml.md | 1 + docs/readme/README_pl.md | 1 + docs/readme/README_pt-BR.md | 1 + docs/readme/README_ru.md | 1 + docs/readme/README_th.md | 1 + docs/readme/README_tr.md | 1 + docs/readme/README_uk.md | 1 + docs/readme/README_vi.md | 1 + docs/readme/README_zh-TW.md | 1 + docs/readme/README_zh.md | 1 + .../gpu-code-objects-2026-10-21.json | 1906 +++++++++++++++++ .../gpu-code-objects-2026-10-21.md | 286 +++ licenses/npm-dependencies.txt | 7 +- licenses/npm-runtime-notices.txt | 16 +- package-lock.json | 10 + package.json | 1 + scripts/lib/catalog-builders.mjs | 5 + scripts/lib/catalog-core.mjs | 2 + scripts/lib/module-boundaries.mjs | 1 + scripts/lib/test-projects.mjs | 2 +- src/application/CapabilityInventory.ts | 1 + src/application/gpu/GpuCodeObjectsEvidence.ts | 57 + src/application/gpu/GpuCodeObjectsPort.ts | 20 + src/application/gpu/GpuCodeObjectsService.ts | 97 + src/cli.ts | 2 + src/cli/gpuCommands.ts | 132 ++ src/cliCommandNames.ts | 1 + src/composition/gpu.ts | 4 + src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/gpu/gpuToolContracts.ts | 40 + src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.test.ts | 2 + src/contracts/toolEffects.ts | 1 + src/domain/analysisView/analysisView.ts | 28 + src/domain/analysisView/gpuCodeObjectsView.ts | 421 ++++ src/domain/elfFixedHeader.test.ts | 14 + src/domain/elfFixedHeader.ts | 8 +- src/domain/gpu/gpuCodeObjects.test.ts | 34 + src/domain/gpu/gpuCodeObjects.ts | 391 ++++ src/gpu/AmdGpuMetadata.test.ts | 44 + src/gpu/AmdGpuMetadata.ts | 187 ++ src/gpu/ClangOffloadBundle.ts | 115 + src/gpu/CudaFatbinary.ts | 81 + src/gpu/CudaInfo.test.ts | 29 + src/gpu/CudaInfo.ts | 142 ++ src/gpu/CudaTarget.ts | 46 + src/gpu/GpuCodeObjectsProvider.ts | 334 +++ src/gpu/GpuElfKernels.ts | 491 +++++ src/gpu/GpuInventory.test.ts | 57 + src/gpu/GpuInventory.ts | 575 +++++ src/gpu/GpuObject.ts | 52 + src/gpu/GpuPayloadCodec.test.ts | 21 + src/gpu/GpuPayloadCodec.ts | 55 + src/gpu/GpuSource.ts | 145 ++ src/gpu/Lz4Block.test.ts | 21 + src/gpu/Lz4Block.ts | 75 + src/gpu/PtxKernels.test.ts | 96 + src/gpu/PtxKernels.ts | 397 ++++ src/native/metadata/ElfImage.ts | 264 +-- src/native/metadata/ElfSymbol.ts | 36 + src/native/metadata/NativeSectionDirectory.ts | 336 +++ src/native/metadata/PeImage.ts | 25 +- src/server/createServer.ts | 12 + src/server/registerGpuCodeObjectsTools.ts | 34 + tests/acceptance/gpuCodeObjectsCli.test.ts | 356 +++ tests/boundary/gpu/codeObjects.test.ts | 529 +++++ tests/fixtures/gpu/codeObjects.ts | 301 +++ tests/process-global/gpuFileLifetime.test.ts | 311 +++ 81 files changed, 8598 insertions(+), 251 deletions(-) create mode 100644 docs/gpu-code-objects.md create mode 100644 docs/verification/gpu-code-objects-2026-10-21.json create mode 100644 docs/verification/gpu-code-objects-2026-10-21.md create mode 100644 src/application/gpu/GpuCodeObjectsEvidence.ts create mode 100644 src/application/gpu/GpuCodeObjectsPort.ts create mode 100644 src/application/gpu/GpuCodeObjectsService.ts create mode 100644 src/cli/gpuCommands.ts create mode 100644 src/composition/gpu.ts create mode 100644 src/contracts/gpu/gpuToolContracts.ts create mode 100644 src/domain/analysisView/gpuCodeObjectsView.ts create mode 100644 src/domain/gpu/gpuCodeObjects.test.ts create mode 100644 src/domain/gpu/gpuCodeObjects.ts create mode 100644 src/gpu/AmdGpuMetadata.test.ts create mode 100644 src/gpu/AmdGpuMetadata.ts create mode 100644 src/gpu/ClangOffloadBundle.ts create mode 100644 src/gpu/CudaFatbinary.ts create mode 100644 src/gpu/CudaInfo.test.ts create mode 100644 src/gpu/CudaInfo.ts create mode 100644 src/gpu/CudaTarget.ts create mode 100644 src/gpu/GpuCodeObjectsProvider.ts create mode 100644 src/gpu/GpuElfKernels.ts create mode 100644 src/gpu/GpuInventory.test.ts create mode 100644 src/gpu/GpuInventory.ts create mode 100644 src/gpu/GpuObject.ts create mode 100644 src/gpu/GpuPayloadCodec.test.ts create mode 100644 src/gpu/GpuPayloadCodec.ts create mode 100644 src/gpu/GpuSource.ts create mode 100644 src/gpu/Lz4Block.test.ts create mode 100644 src/gpu/Lz4Block.ts create mode 100644 src/gpu/PtxKernels.test.ts create mode 100644 src/gpu/PtxKernels.ts create mode 100644 src/native/metadata/ElfSymbol.ts create mode 100644 src/native/metadata/NativeSectionDirectory.ts create mode 100644 src/server/registerGpuCodeObjectsTools.ts create mode 100644 tests/acceptance/gpuCodeObjectsCli.test.ts create mode 100644 tests/boundary/gpu/codeObjects.test.ts create mode 100644 tests/fixtures/gpu/codeObjects.ts create mode 100644 tests/process-global/gpuFileLifetime.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 250503e471..6583fb15a2 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -181,6 +181,8 @@ explicit path or endpoint and do not need it. absent metadata does not prove a binary is not Go, and unparsed text records remain explicit. See the [Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md). +- Embedded CUDA/HIP code objects: `inspect_gpu_code_objects`; inventory targets + first, then decode selected payloads and query retained GPU Evidence. - Bundled GGUF or safetensors model declarations: `inspect_model_artifact` with the explicit local file. Default header hashing leaves full-file identity unknown; `hash: "full"` additionally reads all weights for SHA-256. MCP returns diff --git a/README.md b/README.md index ff4777a55b..c47b6399cb 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ Additional tools and host support depend on the target: | Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | | Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required | +| GPU code objects | CUDA/HIP targets, selected kernel parameters and resources, retained PTX text | Built-in static ELF/PE/fatbin/CUBIN/PTX inspection; [GPU code objects](docs/gpu-code-objects.md) | | Model artifacts | GGUF / safetensors metadata, tensor declarations and derived totals | Built-in header inspection; optional full-file hash; [model files](docs/model-artifacts.md) | | IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required | | Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required | diff --git a/docs/gpu-code-objects.md b/docs/gpu-code-objects.md new file mode 100644 index 0000000000..ead60041c9 --- /dev/null +++ b/docs/gpu-code-objects.md @@ -0,0 +1,166 @@ +# Static CUDA and HIP code objects + +`inspect_gpu_code_objects` answers which GPU targets and kernel declarations a +local artifact carries. It reads ELF/PE sections, standalone CUDA fatbins, +CUBIN/PTX and Clang HIP bundles, including CCOB v1/v2/v3. It neither executes the +artifact nor invokes CUDA/ROCm tools. GPU execution, SASS disassembly and host +launch correlation remain outside coverage. + +Start with a target inventory: + +```json +{ "path": "/artifacts/library.so", "view": { "collection": "targets" } } +``` + +Target rows aggregate inventoried payloads and distinguish an unrequested kernel +analysis from a complete or partial one. `objects` pages expose exact object +IDs, compression declarations and encoded/header locations. Fields, exact +filters, batch IDs and stable pagination select the facts delivered to the +caller. IDs are positional within the recorded artifact SHA-256. Selecting `object_ids` +in a new inspection requires `expected_sha256` from that inventory; a changed +file is rejected before parsing selected payloads. + +`target` is the inventory declaration used for selection; `payload_target` is +the independently observed inner declaration when available. Object pages keep +both values, their kind, `target_consistency` and inspection coverage even with +selected fields. +Conflicting base architectures retain both values, mark object/report/target +coverage partial and annotate the affected kernels. CUBIN flags establish a base +architecture: `base-only` explicitly leaves feature-profile agreement unknown, +including `a` variants. A standalone declaration is `single-source`, and +unexamined payloads remain `unexamined`. An exact AMDGPU target is normalized at +its note owner for comparison with a Clang bundle declaration; raw values remain +intact. Unresolved feature comparisons stay `unknown`. + +`cuda_target_header` retains the OS ABI, normalized raw `e_flags` and exact +header location. CUDA OS ABI `0x33` and the observed `0x41` flags layout use +different architecture fields; they are decoded at one ABI-aware owner. +Unrecognized layouts keep the header and an unknown target instead of +inventing an SM label. Header provenance is validated against its CUBIN object. + +Target filters select inventory declarations. An unselected payload's inner +target remains unexamined; an empty filtered result does not establish that +no enclosed code declares that target. Discovering every inner declaration +requires decoding those payloads. + +HIP IDs with one or two separators before the architecture retain the declared +target. If a recognized AMDGPU bundle ID does not expose a target, the same +bounded note-discovery owner used for standalone AMDGPU files establishes it +before filtering. Those directory/note reads share the inspection work budget; +successive queries can select the target returned by inventory. + +Decode selected targets and return a kernel page in the same call: + +```json +{ + "path": "/artifacts/library.so", + "analysis": "kernels", + "targets": ["sm_100"], + "view": { + "collection": "kernels", + "names": ["kernel_name"], + "fields": [ + "name", + "parameter_count", + "parameter_bytes", + "parameters", + "registers", + "shared_bytes" + ] + } +} +``` + +Only selected payloads are decoded. Other inventory entries remain present with +`not-selected` states, and overall payload coverage remains partial. An empty +kernel page proves no matching retained fact; it does not establish that an +unexamined payload contains no kernel. Missing fields remain null. + +Kernel representations have different meanings: + +- PTX reports parameter declarations and virtual registers. Shared bytes cover + supported static declarations inside that entry; they do not establish module + shared memory, dynamic allocation or physical device resources. + Scalar register declarations count each bare or percent-prefixed name and + each parameterized range. Unsupported declaration forms or overflowing totals + retain an unknown count and partial decoding coverage. Unsupported parameter + layouts and unknown shared-memory declarations likewise retain their known + facts, explicit limitations and partial coverage through retained pages. +- CUBIN identifies CUDA entry symbols and reads numeric `nv.info` records and + native section declarations. Global register declarations also cover compiler + versions that leave the older text-section register field empty. +- AMDGPU reads v3+ MessagePack notes. Argument counts include hidden ABI arguments + when present; SGPR/VGPR counts and fixed group/private segment sizes retain + their declared meanings. Legacy YAML metadata remains unsupported. + +Each selected PTX payload becomes a retained UTF-8 text artifact with a SHA-256, +byte length and source coordinates. It is omitted from compact replies. Read it +with `inspect_analysis_view` and the parent Evidence ID returned by the compact +receipt: + +```json +{ + "source": { "kind": "retained-evidence", "evidence_id": "ev_..." }, + "view": { + "kind": "gpu-code-objects", + "collection": "ptx", + "object_id": "...", + "offset": 0, + "limit": 4096 + } +} +``` + +Text offsets use UTF-16 code units and must fall on Unicode scalar boundaries. +Complete Evidence retains the selected text; `export_evidence_bundle` provides +its portable source record. Historical pages never reopen the input file. + +A location with `container_id: null` uses original file offsets. Other locations +use that inventoried object's decoded byte coordinates. Follow its parent, +encoded location and digest to trace decompressed metadata back to a native +section and the original file. Decoded offsets are never presented as physical +file offsets. + +The CLI uses the same service and contracts: + +```sh +rea inspect-gpu-code-objects /artifacts/library.so --analysis kernels \ + --targets '["sm_100"]' --detail summary \ + --view '{"collection":"kernels","names":["kernel_name"]}' \ + --evidence_output /analysis/library-gpu.evidence.json --json +``` + +A full-file SHA-256 always streams every byte. Inventory then reads bounded +native directories and bundle headers; CCOB requires decompression to discover +its inner directory. Kernel analysis reads selected payloads. `bytes_read` +includes hashing and subsequent descriptor reads, so this is not a header-only +hashing workflow. + +CUDA raw LZ4 blocks and Zstd, and CCOB zlib/Zstd are supported. Unknown versions +or codecs retain declarations with explicit limitations. Lengths are checked +before decoding, and CCOB's truncated MD5 is verified against decoded bytes. +Resource limits are one active inspection, 128 MiB per input/output buffer, +512 MiB of payload input, decoded output and AMD note-read work per inspection, +and 16 MiB retained facts including selected PTX. Payload work is reserved before +reading or allocating; a capacity refusal does not reread an aliased payload. +Native tables and decoded section names each have a 16 MiB structural budget; +AMDGPU ELF note discovery admits at most 8 MiB of source note bytes, with +bounded depth and container/string allocations. Standalone AMD targets are read +from those notes before target selection; selected kernel analysis reuses the +same parsed declarations. Pages have a +1 MiB response budget. Resource refusals and interrupted observations preserve +known facts and explicit uncertainty. + +CUDA ELF parsing additionally shares a 262,144 record/symbol visit limit, +128 MiB of cumulative metadata section reads, and a 16 MiB serialized-size +accounting budget for temporary index facts. These limits are checked before +index growth; they are not claims about JavaScript heap size. Equivalent scoped +metadata byte ranges and repeated equal register declarations are coalesced; +conflicting values remain unknown. Parameter sections are parsed once per ELF. + +Missing CUBIN symbol tables or unresolved section names produce `partial` +object, report and target coverage even when some kernel facts survive. AMD note +failures preserve earlier successfully parsed declarations and the original +failure reason. Standalone PTX recognition tries a small header first, then +continues within the existing 16 MiB text budget when leading comments or +whitespace obscure its directives; admitted full-file probe bytes are reused. diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 86d7943131..435ed3e7b5 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -267,6 +267,7 @@ npx rea-agents@latest setup - [التثبيت والإعداد](../installation.md): تسجيل الوكلاء، وتهيئة الموفّرين، والتحديثات، وإلغاء التثبيت. - [الجاهزية واستكشاف الأخطاء](../installation.md#check-readiness-for-your-task): تشخيص وكيل أو محرك تحليل محدد. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI وEvidence](../cli.md): الأوامر، واختيار الموفّر، واللقطات، والاستيراد/التصدير، وحالات الخروج. - [عقود MCP](../mcp-contracts.md) و[تعليمات الوكلاء](../mcp-prompts.md): نتائج الأدوات، والجلسات، والاستقصاءات الموجّهة. diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 13c5a56592..5fddd80ff3 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -265,6 +265,7 @@ Beginne mit den [praxisnahen Anleitungen](https://rea.tools/de/guides/) auf der - [Installation und Einrichtung](../installation.md): Agentenregistrierung, Provider-Konfiguration, Updates und Deinstallation. - [Bereitschaft und Fehlerbehebung](../installation.md#check-readiness-for-your-task): Diagnose eines bestimmten Agenten oder einer Analyse-Engine. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI und Evidence](../cli.md): Befehle, Provider-Auswahl, Snapshots, Import/Export und Exit-Status. - [MCP-Kontrakte](../mcp-contracts.md) und [Agenten-Prompts](../mcp-prompts.md): Werkzeugergebnisse, Sitzungen und geführte Untersuchungen. diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index b7e9e19f7b..93ef1517db 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -278,6 +278,7 @@ npx rea-agents@latest setup - [Εγκατάσταση και ρύθμιση](../installation.md): καταχώριση agents, ρύθμιση παρόχων ανάλυσης, ενημερώσεις και απεγκατάσταση. - [Έλεγχος ετοιμότητας και αντιμετώπιση προβλημάτων](../installation.md#check-readiness-for-your-task): διάγνωση ενός agent ή μιας μηχανής ανάλυσης. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI και Evidence](../cli.md): εντολές, επιλογή παρόχου, στιγμιότυπα, εισαγωγή/εξαγωγή και κωδικοί εξόδου. - [Προδιαγραφές MCP](../mcp-contracts.md) και [prompts για agents](../mcp-prompts.md): αποτελέσματα εργαλείων, συνεδρίες και καθοδηγούμενες διερευνήσεις. diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 26f197ff62..02dc7cda3e 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -265,6 +265,7 @@ Empieza con las [guías prácticas](https://rea.tools/es/guides/) del sitio web. - [Instalación y configuración](../installation.md): registro de agentes, configuración de proveedores, actualizaciones y desinstalación. - [Preparación y resolución de problemas](../installation.md#check-readiness-for-your-task): diagnóstico de un agente o motor de análisis concreto. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI y Evidence](../cli.md): comandos, selección de proveedores, instantáneas, importación/exportación y estados de salida. - [Contratos de MCP](../mcp-contracts.md) y [prompts para agentes](../mcp-prompts.md): resultados de herramientas, sesiones e investigaciones guiadas. diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index fa23c15949..37a3355d96 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -250,6 +250,7 @@ npx rea-agents@latest setup - [نصب و راه‌اندازی](../installation.md): ثبت ایجنت، پیکربندی ارائه‌دهنده، به‌روزرسانی و حذف نصب. - [آمادگی و رفع اشکال](../installation.md#check-readiness-for-your-task): عیب‌یابی یک ایجنت یا موتور تحلیل. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI و شواهد](../cli.md): دستورات، انتخاب ارائه‌دهنده، snapshotها، ورود/خروج داده و وضعیت‌های خروج. - [قراردادهای MCP](../mcp-contracts.md) و [پرامپت‌های ایجنت](../mcp-prompts.md): نتایج ابزارها، نشست‌ها و بررسی‌های هدایت‌شده. diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index db2a7051bb..40083bd489 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -269,6 +269,7 @@ Commencez par les [guides pas à pas](https://rea.tools/fr/guides/) du site web. - [Installation et configuration](../installation.md) : enregistrement des agents, configuration des fournisseurs, mises à jour et désinstallation. - [Vérification et dépannage](../installation.md#check-readiness-for-your-task) : diagnostiquer un agent ou un moteur d'analyse donné. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI et Evidence](../cli.md) : commandes, choix du fournisseur, snapshots, import/export et codes de sortie. - [Contrats MCP](../mcp-contracts.md) et [prompts pour agents](../mcp-prompts.md) : résultats des outils, sessions et investigations guidées. diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index d824477757..b8906ba2b3 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -290,6 +290,7 @@ Untuk mengetahui opsi, prasyarat, dan kontrak hasil secara tepat: - [Instalasi dan penyiapan](../installation.md): pendaftaran agen, konfigurasi penyedia, pembaruan, dan penghapusan instalasi. - [Kesiapan dan pemecahan masalah](../installation.md#check-readiness-for-your-task): mendiagnosis satu agen atau mesin analisis. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI dan Bukti](../cli.md): perintah, pemilihan penyedia, snapshot, impor/ekspor, dan status keluar. - [Kontrak MCP](../mcp-contracts.md) dan [prompt agen](../mcp-prompts.md): hasil alat, sesi, dan investigasi terpandu. diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index 7f951545a2..6831919be8 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -269,6 +269,7 @@ Inizia dalle [guide pratiche](https://rea.tools/it/guides/) del sito web. Per op - [Installazione e configurazione](../installation.md): registrazione agente, configurazione provider, aggiornamenti e disinstallazione. - [Pronto all'uso e risoluzione dei problemi](../installation.md#check-readiness-for-your-task): diagnostica un agente o un motore di analisi. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI ed Evidence](../cli.md): comandi, selezione provider, snapshot, import/export e stati di uscita. - [Contratti MCP](../mcp-contracts.md) e [prompt agente](../mcp-prompts.md): risultati degli strumenti, sessioni e indagini guidate. diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index f4595b6824..325287fa4c 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -267,6 +267,7 @@ npx rea-agents@latest setup - [インストールとセットアップ](../installation.md):エージェントの登録、プロバイダーの設定、更新、アンインストール。 - [準備状況の確認とトラブルシューティング](../installation.md#check-readiness-for-your-task):特定のエージェントや分析エンジンを診断。 +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI と Evidence](../cli.md):コマンド、プロバイダーの選択、スナップショット、インポート/エクスポート、終了ステータス。 - [MCP 契約](../mcp-contracts.md)と[エージェント向けプロンプト](../mcp-prompts.md):ツールの結果、セッション、ガイド付き調査。 diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 3fa5f2569a..462fd08e21 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -280,6 +280,7 @@ npx rea-agents@latest setup - [설치 및 설정](../installation.md): 에이전트 등록, 제공자 설정, 업데이트, 제거. - [준비 상태 확인 및 문제 해결](../installation.md#check-readiness-for-your-task): 특정 에이전트 또는 분석 엔진 진단. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI 및 Evidence](../cli.md): 명령, 제공자 선택, 스냅샷, 가져오기/내보내기, 종료 상태. - [MCP 계약](../mcp-contracts.md) 및 [에이전트 프롬프트](../mcp-prompts.md): 도구 결과, 세션, 안내에 따른 조사. diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index 5e07f3663f..6dd281e900 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -283,6 +283,7 @@ npx rea-agents@latest setup - [Installation and setup](../installation.md): agent registration, provider configuration, updates, uninstall. - [Readiness and troubleshooting](../installation.md#check-readiness-for-your-task): ഒരു agent-ന്റെയോ analysis engine-ന്റെയോ പ്രശ്നങ്ങൾ കണ്ടെത്തുക. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI and Evidence](../cli.md): commands, provider തിരഞ്ഞെടുക്കൽ, snapshots, import/export, exit statuses. - [MCP contracts](../mcp-contracts.md), [agent prompts](../mcp-prompts.md): tool results, sessions, guided investigations. diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 4c692d2364..1ae828eb10 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -265,6 +265,7 @@ Zacznij od [przewodników z przykładami](https://rea.tools/pl/guides/) na stron - [Instalacja i konfiguracja](../installation.md): rejestracja agentów, konfiguracja dostawców analizy, aktualizacje i odinstalowanie. - [Gotowość i rozwiązywanie problemów](../installation.md#check-readiness-for-your-task): diagnostyka jednego agenta lub silnika analizy. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI i Evidence](../cli.md): polecenia, wybór dostawcy, migawki, import/eksport i kody zakończenia. - [Kontrakty MCP](../mcp-contracts.md) i [prompty dla agentów](../mcp-prompts.md): wyniki narzędzi, sesje i prowadzone analizy. diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 019bba1129..1390281366 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -265,6 +265,7 @@ Comece pelos [guias práticos](https://rea.tools/pt-BR/guides/) do site. Para op - [Instalação e configuração](../installation.md): registro de agentes, configuração de provedores, atualizações e desinstalação. - [Prontidão e solução de problemas](../installation.md#check-readiness-for-your-task): diagnóstico de um agente ou motor de análise específico. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI e Evidence](../cli.md): comandos, seleção de provedores, snapshots, importação/exportação e status de saída. - [Contratos MCP](../mcp-contracts.md) e [prompts para agentes](../mcp-prompts.md): resultados das ferramentas, sessões e investigações guiadas. diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 22ff426b8a..42234a8c06 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -265,6 +265,7 @@ npx rea-agents@latest setup - [Установка и настройка](../installation.md): регистрация агентов, настройка провайдеров, обновление и удаление. - [Готовность и устранение неполадок](../installation.md#check-readiness-for-your-task): диагностика конкретного агента или движка анализа. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI и Evidence](../cli.md): команды, выбор провайдера, снимки, импорт/экспорт и коды завершения. - [Контракты MCP](../mcp-contracts.md) и [промпты агентов](../mcp-prompts.md): результаты инструментов, сеансы и исследования с подсказками. diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index fb8c3ff604..f8203910e9 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -265,6 +265,7 @@ npx rea-agents@latest setup - [การติดตั้งและตั้งค่า](../installation.md): การลงทะเบียนเอเจนต์ การตั้งค่าผู้ให้บริการ การอัปเดต และการถอนการติดตั้ง - [ความพร้อมใช้งานและการแก้ไขปัญหา](../installation.md#check-readiness-for-your-task): ตรวจวินิจฉัยเอเจนต์หรือเอนจินวิเคราะห์หนึ่งตัว +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI และ Evidence](../cli.md): คำสั่ง การเลือกผู้ให้บริการ สแนปช็อต การนำเข้า/ส่งออก และรหัสการสิ้นสุด - [สัญญา MCP](../mcp-contracts.md) และ[พรอมป์ต์สำหรับเอเจนต์](../mcp-prompts.md): ผลลัพธ์เครื่องมือ เซสชัน และการตรวจสอบตามคำแนะนำ diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 2cb89a5496..6758279259 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -270,6 +270,7 @@ Web sitesindeki [uygulamalı rehberlerden](https://rea.tools/tr/guides/) başlay - [Yükleme ve kurulum](../installation.md): ajan kaydı, sağlayıcı yapılandırması, güncellemeler ve kaldırma. - [Hazırlık ve sorun giderme](../installation.md#check-readiness-for-your-task): belirli bir ajanı veya analiz motorunu tanılama. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI ve Evidence](../cli.md): komutlar, sağlayıcı seçimi, anlık görüntüler, içe/dışa aktarma ve çıkış durumları. - [MCP sözleşmeleri](../mcp-contracts.md) ve [ajan istemleri](../mcp-prompts.md): araç sonuçları, oturumlar ve yönlendirmeli araştırmalar. diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index eac65f273e..59c29dbe16 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -265,6 +265,7 @@ npx rea-agents@latest setup - [Встановлення й налаштування](../installation.md): реєстрація агентів, конфігурація провайдерів, оновлення та видалення. - [Готовність та усунення неполадок](../installation.md#check-readiness-for-your-task): діагностика конкретного агента або рушія аналізу. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI та Evidence](../cli.md): команди, вибір провайдера, знімки, імпорт/експорт і статуси завершення. - [Контракти MCP](../mcp-contracts.md) і [промпти агентів](../mcp-prompts.md): результати інструментів, сеанси й керовані дослідження. diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index cfba9f3c47..72f66b8bdc 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -265,6 +265,7 @@ Bắt đầu với [hướng dẫn thực hành](https://rea.tools/vi/guides/) t - [Cài đặt và thiết lập](../installation.md): đăng ký tác nhân, cấu hình trình phân tích, cập nhật và gỡ cài đặt. - [Kiểm tra mức sẵn sàng và khắc phục sự cố](../installation.md#check-readiness-for-your-task): chẩn đoán một tác nhân hoặc bộ máy phân tích cụ thể. +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI và Evidence](../cli.md): lệnh, lựa chọn trình phân tích, ảnh chụp trạng thái, nhập/xuất và trạng thái thoát. - [Hợp đồng MCP](../mcp-contracts.md) và [prompt cho tác nhân](../mcp-prompts.md): kết quả công cụ, phiên làm việc và điều tra có hướng dẫn. diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 635bcbc9af..777dc0d58d 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -271,6 +271,7 @@ npx rea-agents@latest setup - [安裝與設定](../installation.md):AI 代理註冊、提供者組態、更新和卸載。 - [就緒檢查與故障排查](../installation.md#check-readiness-for-your-task):診斷特定 AI 代理或分析引擎。 +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI 與 Evidence](../cli.md):命令、提供者選擇、快照、匯入/匯出和退出狀態。 - [MCP 契約](../mcp-contracts.md)和[AI 代理提示詞](../mcp-prompts.md):工具結果、會話和引導式調查。 diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 313262ae53..e7fe365fba 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -271,6 +271,7 @@ npx rea-agents@latest setup - [安装与设置](../installation.md):智能体注册、提供方配置、更新和卸载。 - [就绪检查与故障排查](../installation.md#check-readiness-for-your-task):诊断特定智能体或分析引擎。 +- [CUDA / HIP](../gpu-code-objects.md) - [GGUF / safetensors](../model-artifacts.md) - [CLI 与 Evidence](../cli.md):命令、提供方选择、快照、导入/导出和退出状态。 - [MCP 契约](../mcp-contracts.md)和[智能体提示词](../mcp-prompts.md):工具结果、会话和引导式调查。 diff --git a/docs/verification/gpu-code-objects-2026-10-21.json b/docs/verification/gpu-code-objects-2026-10-21.json new file mode 100644 index 0000000000..ad310ee0fa --- /dev/null +++ b/docs/verification/gpu-code-objects-2026-10-21.json @@ -0,0 +1,1906 @@ +{ + "date": "2026-10-21", + "runtime_source_revision": "c29b98da653658e5e8b2d8c601fb3ed890ed74f8", + "runtime_source_tree": "50940bbf829fd9a757fc1fd5c64296b29bd246a9", + "equivalent_rewritten_revision": "58cccca5123250104033dfde4efcbee89af57f63", + "node": "24.18.0", + "npm": "11.16.0", + "host": "Linux-6.1.0-54-cloud-amd64-x86_64-with-glibc2.36", + "cpu_affinity": [0, 1], + "heap_limit_mib": 3072, + "guard": { + "family_rss_bytes": 8589934592, + "wall_s": 420, + "sample_ms": 20, + "accounting": "Sum payload descendants through all task child lists; RUSAGE_CHILDREN delta for CPU. Observer excluded; sampled peak is approximate." + }, + "published_artifacts": { + "nvjpeg-receipt": { + "url": "https://developer.download.nvidia.com/compute/cuda/redist/libnvjpeg/linux-x86_64/libnvjpeg-linux-x86_64-12.4.0.76-archive.tar.xz", + "manifest": { + "relative_path": "libnvjpeg/linux-x86_64/libnvjpeg-linux-x86_64-12.4.0.76-archive.tar.xz", + "sha256": "ddd8245b2803f5b55211261d7e5d7abf803c05f3b032238d0feaa6e09ea9401d", + "md5": "b9f7806fb279c9cdd0b4a40e4171b87b", + "size": "6378624" + } + }, + "nvjpeg-windows-receipt": { + "url": "https://developer.download.nvidia.com/compute/cuda/redist/libnvjpeg/windows-x86_64/libnvjpeg-windows-x86_64-12.4.0.76-archive.zip", + "archive": { + "relative_path": "libnvjpeg/windows-x86_64/libnvjpeg-windows-x86_64-12.4.0.76-archive.zip", + "sha256": "b253241fc88bf30947b8ee068101aca8930960f113d8ee4a9583de021a79ffa1", + "md5": "d19d13f9bd3dbc9c9361a3c1fee99dd5", + "size": "4667529" + }, + "files": [ + { + "member": "libnvjpeg-windows-x86_64-12.4.0.76-archive/bin/nvjpeg64_12.dll", + "path": "/home/yann/yann/rea-5345-lab/fixtures/nvjpeg64_12.dll", + "size": 7664128, + "sha256": "26559f5319f6b947912daded08680ab248d5af7867529402af050b38b53193ff" + } + ] + }, + "toolchain-receipts": [ + { + "component": "cuda_nvcc", + "url": "https://developer.download.nvidia.com/compute/cuda/redist/cuda_nvcc/linux-x86_64/cuda_nvcc-linux-x86_64-12.9.86-archive.tar.xz", + "sha256": "7a1a5b652e5ef85c82b721d10672fc9a2dbaab44e9bd3c65a69517bf53998c35", + "archive_bytes": 81100244 + }, + { + "component": "cuda_cudart", + "url": "https://developer.download.nvidia.com/compute/cuda/redist/cuda_cudart/linux-x86_64/cuda_cudart-linux-x86_64-12.9.79-archive.tar.xz", + "sha256": "1f6ad42d4f530b24bfa35894ccf6b7209d2354f59101fd62ec4a6192a184ce99", + "archive_bytes": 1514676 + }, + { + "component": "cuda_cuobjdump", + "url": "https://developer.download.nvidia.com/compute/cuda/redist/cuda_cuobjdump/linux-x86_64/cuda_cuobjdump-linux-x86_64-12.9.82-archive.tar.xz", + "sha256": "ee0de40e8c18068bfcc53e73510e7e7a1a80555205347940df67fa525d24452f", + "archive_bytes": 214896 + } + ], + "linux_library": { + "sha256": "27e1eb1834b20db64f99deba379746d8ec46b92975ccb4cdfa06a84d77e4c11e", + "bytes": 9352784, + "prior_full_oracle": { + "cubins": 165, + "ptx": 10, + "native_kernel_resource_tuples": 3720, + "ptx_kernel_declarations": 248, + "result": "All decoded hashes, PTX bytes and native resource tuples matched cuobjdump; final MCP rechecked the 248 sm_100 kernel facts." + } + } + }, + "completed_tasks": { + "final-full": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "final-full", + "full" + ], + "exit_code": 0, + "wall_s": 15.492352435998328, + "cpu_s": 18.220271, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1139601408, + "sample_ms": 20, + "guard_stop": null + }, + "task_receipt": { + "runtime": "/home/yann/yann/rea-5345-gpu-code-objects", + "label": "final-full", + "mode": "full", + "target": "/home/yann/yann/rea-5345-lab/fixtures/libnvjpeg.so.12.4.0.76", + "task": "Read sm_100 parameter layout, physical registers and shared memory for the named kernel, retain source Evidence and repeat the historical query three times", + "answer": [ + { + "name": "_ZN6nvjpeg33dctQuantInvJpegKernelMultiChannelI7ushort2Li1ELi32ELi8EEEvNS_24InvDctParamsMultiChannelE", + "n": 1, + "bytes": 168, + "parameters": [ + { + "ordinal": 0, + "name": null, + "offset": 0, + "size": 168, + "type": null, + "hidden": null + } + ], + "registers": { + "physical": 31, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + }, + "shared": 9216 + } + ], + "objects": 176, + "kernels": 248, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 33, + "payloads": "partial", + "unknown_section_names": 0 + }, + "used_input_declarations_plus_primary_estimated_tokens": 406862, + "all_input_declarations_plus_primary_estimated_tokens": 635381, + "catalog_and_all_calls_estimated_tokens": 1475258, + "token_accounting": "tokenx estimates of advertised declarations, arguments and full SDK replies; initialize and JSON-RPC envelopes omitted; not actual model usage", + "task_wall_s": 14.997796184999999, + "call_times_ms": [ + { + "name": "inspect_gpu_code_objects", + "wall_ms": 8572.237158 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 410.12681299999895 + }, + { + "name": "export_evidence_bundle", + "wall_ms": 38.289639000000534 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 10.732239999999365 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 10.636399999999412 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 9.782919999999649 + } + ] + } + }, + "final-selected": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "final-selected", + "selected" + ], + "exit_code": 0, + "wall_s": 15.442506011997466, + "cpu_s": 18.074447000000003, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1321566208, + "sample_ms": 20, + "guard_stop": null + }, + "task_receipt": { + "runtime": "/home/yann/yann/rea-5345-gpu-code-objects", + "label": "final-selected", + "mode": "selected", + "target": "/home/yann/yann/rea-5345-lab/fixtures/libnvjpeg.so.12.4.0.76", + "task": "Read sm_100 parameter layout, physical registers and shared memory for the named kernel, retain source Evidence and repeat the historical query three times", + "answer": [ + { + "name": "_ZN6nvjpeg33dctQuantInvJpegKernelMultiChannelI7ushort2Li1ELi32ELi8EEEvNS_24InvDctParamsMultiChannelE", + "n": 1, + "bytes": 168, + "parameters": [ + { + "ordinal": 0, + "name": null, + "offset": 0, + "size": 168, + "type": null, + "hidden": null + } + ], + "registers": { + "physical": 31, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + }, + "shared": 9216 + } + ], + "objects": 176, + "kernels": 248, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 33, + "payloads": "partial", + "unknown_section_names": 0 + }, + "used_input_declarations_plus_primary_estimated_tokens": 26106, + "all_input_declarations_plus_primary_estimated_tokens": 254625, + "catalog_and_all_calls_estimated_tokens": 1094502, + "token_accounting": "tokenx estimates of advertised declarations, arguments and full SDK replies; initialize and JSON-RPC envelopes omitted; not actual model usage", + "task_wall_s": 14.924958990999999, + "call_times_ms": [ + { + "name": "inspect_gpu_code_objects", + "wall_ms": 8809.613411 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 435.95342300000084 + }, + { + "name": "export_evidence_bundle", + "wall_ms": 32.59329000000071 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 11.959560000001147 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 11.40408000000025 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 10.63430000000153 + } + ] + } + }, + "final-windows-cli": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/nvjpeg64_12.dll", + "--analysis", + "kernels", + "--detail", + "summary", + "--view", + "{\"collection\":\"targets\"}", + "--evidence_output", + "/home/yann/yann/rea-5345-lab/final-windows-evidence.json", + "--json" + ], + "exit_code": 0, + "wall_s": 4.06407550899894, + "cpu_s": 4.908962, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 503459840, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-producer-fixtures": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "--import", + "tsx", + "/home/yann/yann/rea-5345-lab/probe.mts" + ], + "exit_code": 0, + "wall_s": 0.8731043400002818, + "cpu_s": 1.1189369999999998, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 123265024, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-focused": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/gpuCodeObjectsCli.test.ts", + "tests/boundary/gpu/codeObjects.test.ts", + "tests/process-global/gpuFileLifetime.test.ts", + "src/domain/gpu/gpuCodeObjects.test.ts", + "src/gpu/AmdGpuMetadata.test.ts", + "src/gpu/CudaInfo.test.ts", + "src/gpu/Lz4Block.test.ts", + "src/gpu/PtxKernels.test.ts", + "src/gpu/GpuInventory.test.ts", + "src/gpu/GpuPayloadCodec.test.ts", + "src/domain/elfFixedHeader.test.ts", + "src/native/metadata/PeImage.test.ts", + "src/native/metadata/Il2cppContainer.test.ts", + "tests/boundary/go/goBinaryMappings.test.ts", + "tests/boundary/go/goBinaryImage.test.ts", + "tests/boundary/rust/rustBinaryImage.test.ts", + "src/rust/RustAuditableReader.test.ts", + "tests/boundary/il2cpp/il2cppBinaryFile.test.ts", + "tests/boundary/mcp/outputSchemaProfile.test.ts" + ], + "exit_code": 0, + "wall_s": 75.88229032499657, + "cpu_s": 136.819571, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3480600576, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-check": { + "resource_receipt": { + "command": ["taskset", "-c", "0,1", "npm", "run", "check"], + "exit_code": 0, + "wall_s": 147.5841734400001, + "cpu_s": 292.92577, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 6866296832, + "sample_ms": 20, + "guard_stop": null + } + }, + "qualified-docs-4": { + "resource_receipt": { + "command": ["taskset", "-c", "0,1", "npm", "run", "docs:check"], + "exit_code": 0, + "wall_s": 9.546646034999867, + "cpu_s": 11.63225, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 752164864, + "sample_ms": 20, + "guard_stop": null + } + }, + "schema-isolated": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/boundary/mcp/toolSchemaValidity.test.ts" + ], + "exit_code": 0, + "wall_s": 111.93744548900031, + "cpu_s": 140.419096, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3463942144, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "oracle_results": { + "final-linux-oracle": [ + { + "mode": "final-full", + "kernel_facts": 248, + "result": "All previously oracle-verified fields match; new representation is cuda-elf-metadata; selected decoded hashes and source locations match" + }, + { + "mode": "final-selected", + "kernel_facts": 248, + "result": "All previously oracle-verified fields match; new representation is cuda-elf-metadata; selected decoded hashes and source locations match" + } + ], + "final-windows-oracle": { + "input_sha256": "26559f5319f6b947912daded08680ab248d5af7867529402af050b38b53193ff", + "kernels": 3968, + "cubin_hashes": 165, + "ptx_byte_comparisons": 10, + "native_kernel_resource_tuples": 3720, + "result": "Final public CLI retained facts exactly equal previously independently verified producer-oracle facts", + "oracle_scope": "LLVM PE section coordinates; cuobjdump on extracted fatbin, no direct cuobjdump DLL or Windows runtime verification" + }, + "windows-oracle-receipt": { + "cubin_hashes": 165, + "ptx_byte_comparisons": 10, + "native_kernel_resource_tuples": 3720, + "result": "all matched", + "scope": "PE section coordinates from LLVM; NVIDIA oracle consumes extracted fatbin, not DLL directly" + } + }, + "limits": [ + "No GPU execution or Windows runtime verification.", + "CCOB v2 Zstd has a real LLVM 19 producer oracle; v1/v3 and zlib have portable boundary fixtures. Legacy AMDGPU YAML is unsupported.", + "Fresh MCP servers and disabled Node compile cache; OS caches and unrelated host load uncontrolled. One final pair is not a latency distribution.", + "Tokenx estimates, not model billing. Full SDK replies include text and structured representations; initialization and JSON-RPC envelopes omitted.", + "Earlier all-target complete MCP response exceeded the existing transport guard and did not complete. It is not a baseline. Both completed comparisons select sm_100.", + "A prior combined focused run timed out two schema cases at 30 seconds on two CPUs; the isolated schema suite passed 15/15 without changing timeouts." + ], + "review_followup": { + "source_revision": "09c6062cbd70692e6eb4c7cf53b7a10ba886aeb6", + "source_tree": "8f1db7308f83065ce8415f7ffc5d298b4879ec22", + "before": { + "source_revision": "36b87ed45912d0d53191ae3611b258f140019ab9", + "real_gfx900_matching_selection": { + "exit_code": 0, + "target": null, + "kernel_count": 0 + }, + "malformed_targets_cli_issue_path": ["view"] + }, + "after": { + "real_cli": { + "calls": [ + { + "kind": "inventory", + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/add-gfx900.hsaco", + "--detail", + "complete", + "--json" + ], + "exit_code": 0, + "sha256": "0b1c25b301a7ded9fcac09968f5e140940db16df395867496845623e3c38a672", + "target": "amdgcn-amd-amdhsa--gfx900", + "kernel_count": 0 + }, + { + "kind": "selected", + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/add-gfx900.hsaco", + "--detail", + "complete", + "--json", + "--analysis", + "kernels", + "--targets", + "[\"amdgcn-amd-amdhsa--gfx900\"]" + ], + "exit_code": 0, + "sha256": "0b1c25b301a7ded9fcac09968f5e140940db16df395867496845623e3c38a672", + "target": "amdgcn-amd-amdhsa--gfx900", + "kernel_count": 1 + }, + { + "kind": "unselected", + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/add-gfx900.hsaco", + "--detail", + "complete", + "--json", + "--analysis", + "kernels", + "--targets", + "[\"amdgcn-amd-amdhsa--gfx1100\"]" + ], + "exit_code": 0, + "sha256": "0b1c25b301a7ded9fcac09968f5e140940db16df395867496845623e3c38a672", + "target": "amdgcn-amd-amdhsa--gfx900", + "kernel_count": 0 + } + ], + "oracle": "Clang 14 producer source and independent llvm-readelf metadata output; no GPU execution" + }, + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/review-amd-cli.py" + ], + "exit_code": 0, + "wall_s": 5.9736752989992965, + "cpu_s": 6.950947, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 383623168, + "sample_ms": 20, + "guard_stop": null + }, + "focused_resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/gpuCodeObjectsCli.test.ts", + "tests/boundary/gpu/codeObjects.test.ts", + "tests/process-global/gpuFileLifetime.test.ts", + "src/gpu/AmdGpuMetadata.test.ts" + ], + "exit_code": 0, + "wall_s": 83.7720508450002, + "cpu_s": 138.813059, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3452530688, + "sample_ms": 20, + "guard_stop": null + }, + "focused_tests": { + "files": 4, + "passed": 35 + }, + "catalog_resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/boundary/filesystem/productCatalog.test.ts" + ], + "exit_code": 0, + "wall_s": 74.92947246800031, + "cpu_s": 126.30404600000001, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3469959168, + "sample_ms": 20, + "guard_stop": null + }, + "catalog_tests": { + "files": 1, + "passed": 10 + }, + "nine_real_producer_artifacts": "complete payload coverage; rerun after review fixes", + "producer_resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "--import", + "tsx", + "/home/yann/yann/rea-5345-lab/probe.mts" + ], + "exit_code": 0, + "wall_s": 0.5241675279976334, + "cpu_s": 0.6436419999999999, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 124952576, + "sample_ms": 20, + "guard_stop": null + } + }, + "ownership": "Bounded AMD ELF note parser produces target plus parsed kernel declarations once; inventory discovers target before filtering and selected projection reuses prepared notes. The 8 MiB budget applies to aggregate encoded source-note bytes, before their reads; parsed JS representation expands. Prepared notes live only for one payload inspection. CLI parses each named JSON option and preserves the full parsed input for schema diagnostics.", + "performance_scope": "The paired nvJPEG MCP measurements above remain pinned to their original qualified runtime; this follow-up does not claim a new CUDA speed/memory result." + }, + "bounded_metadata_review": { + "source_revision": "ad1e17add9e9daacf0cb60c33d5bf359cf631eab", + "source_tree": "2d4536b06abab51837cf88bc196d415fe4194832", + "base_revision": "52415975942a4b9c53a817915b313ccc404f280e", + "focused_tests": { + "files": 9, + "passed": 70 + }, + "sdk_schema_tests": { + "files": 1, + "passed": 15 + }, + "tasks": { + "rebased-review-focused": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/gpuCodeObjectsCli.test.ts", + "tests/boundary/gpu/codeObjects.test.ts", + "tests/process-global/gpuFileLifetime.test.ts", + "src/gpu/CudaInfo.test.ts", + "src/gpu/PtxKernels.test.ts", + "src/domain/gpu/gpuCodeObjects.test.ts", + "tests/boundary/filesystem/productCatalog.test.ts", + "src/domain/analysisView/rustBinaryView.test.ts", + "tests/acceptance/rustDependencyViews.test.ts" + ], + "exit_code": 0, + "wall_s": 167.9911166489983, + "cpu_s": 316.988263, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3486601216, + "sample_ms": 20, + "guard_stop": null + } + }, + "revised-schema-isolated": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/boundary/mcp/toolSchemaValidity.test.ts" + ], + "exit_code": 0, + "wall_s": 43.76464435999878, + "cpu_s": 50.095241, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1928617984, + "sample_ms": 20, + "guard_stop": null + } + }, + "revised-full": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "revised-full", + "full" + ], + "exit_code": 0, + "wall_s": 15.4867707600024, + "cpu_s": 18.404025999999998, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1251254272, + "sample_ms": 20, + "guard_stop": null + }, + "task_receipt": { + "runtime": "/home/yann/yann/rea-5345-gpu-code-objects", + "label": "revised-full", + "mode": "full", + "target": "/home/yann/yann/rea-5345-lab/fixtures/libnvjpeg.so.12.4.0.76", + "task": "Read sm_100 parameter layout, physical registers and shared memory for the named kernel, retain source Evidence and repeat the historical query three times", + "answer": [ + { + "name": "_ZN6nvjpeg33dctQuantInvJpegKernelMultiChannelI7ushort2Li1ELi32ELi8EEEvNS_24InvDctParamsMultiChannelE", + "n": 1, + "bytes": 168, + "parameters": [ + { + "ordinal": 0, + "name": null, + "offset": 0, + "size": 168, + "type": null, + "hidden": null + } + ], + "registers": { + "physical": 31, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + }, + "shared": 9216 + } + ], + "objects": 176, + "kernels": 248, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 33, + "payloads": "partial", + "unknown_section_names": 0 + }, + "used_input_declarations_plus_primary_estimated_tokens": 407368, + "all_input_declarations_plus_primary_estimated_tokens": 636495, + "catalog_and_all_calls_estimated_tokens": 1480698, + "token_accounting": "tokenx estimates of advertised declarations, arguments and full SDK replies; initialize and JSON-RPC envelopes omitted; not actual model usage", + "task_wall_s": 14.930120244, + "call_times_ms": [ + { + "name": "inspect_gpu_code_objects", + "wall_ms": 8648.842552000002 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 392.55837599999904 + }, + { + "name": "export_evidence_bundle", + "wall_ms": 32.70938000000024 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 12.31524999999965 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 11.467388999999457 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 11.990239999999176 + } + ] + } + }, + "revised-selected": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "revised-selected", + "selected" + ], + "exit_code": 0, + "wall_s": 15.923008769001171, + "cpu_s": 18.760102, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1201774592, + "sample_ms": 20, + "guard_stop": null + }, + "task_receipt": { + "runtime": "/home/yann/yann/rea-5345-gpu-code-objects", + "label": "revised-selected", + "mode": "selected", + "target": "/home/yann/yann/rea-5345-lab/fixtures/libnvjpeg.so.12.4.0.76", + "task": "Read sm_100 parameter layout, physical registers and shared memory for the named kernel, retain source Evidence and repeat the historical query three times", + "answer": [ + { + "name": "_ZN6nvjpeg33dctQuantInvJpegKernelMultiChannelI7ushort2Li1ELi32ELi8EEEvNS_24InvDctParamsMultiChannelE", + "n": 1, + "bytes": 168, + "parameters": [ + { + "ordinal": 0, + "name": null, + "offset": 0, + "size": 168, + "type": null, + "hidden": null + } + ], + "registers": { + "physical": 31, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + }, + "shared": 9216 + } + ], + "objects": 176, + "kernels": 248, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 33, + "payloads": "partial", + "unknown_section_names": 0 + }, + "used_input_declarations_plus_primary_estimated_tokens": 26612, + "all_input_declarations_plus_primary_estimated_tokens": 255739, + "catalog_and_all_calls_estimated_tokens": 1099942, + "token_accounting": "tokenx estimates of advertised declarations, arguments and full SDK replies; initialize and JSON-RPC envelopes omitted; not actual model usage", + "task_wall_s": 15.387531861, + "call_times_ms": [ + { + "name": "inspect_gpu_code_objects", + "wall_ms": 8730.058778999999 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 402.6264579999988 + }, + { + "name": "export_evidence_bundle", + "wall_ms": 43.726230000000214 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 10.474229000001287 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 10.128700999999637 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 10.954589000000851 + } + ] + } + }, + "revised-windows-cli": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/nvjpeg64_12.dll", + "--analysis", + "kernels", + "--detail", + "summary", + "--view", + "{\"collection\":\"targets\"}", + "--evidence_output", + "/home/yann/yann/rea-5345-lab/revised-windows-evidence.json", + "--json" + ], + "exit_code": 0, + "wall_s": 4.078310482000234, + "cpu_s": 4.879858, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 498380800, + "sample_ms": 20, + "guard_stop": null + } + }, + "revised-edge-cli": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/verify-review-edges.py" + ], + "exit_code": 0, + "wall_s": 5.132059294999635, + "cpu_s": 6.221724999999999, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 405835776, + "sample_ms": 20, + "guard_stop": null + } + }, + "revised-producer-fixtures": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "--import", + "tsx", + "/home/yann/yann/rea-5345-lab/probe.mts" + ], + "exit_code": 0, + "wall_s": 0.501328217000264, + "cpu_s": 0.621808999999999, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 125714432, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "oracle": { + "source": "ad1e17add9e9daacf0cb60c33d5bf359cf631eab", + "windows_cubin_hashes": 165, + "windows_ptx_byte_comparisons": 10, + "unchanged_native_kernel_facts": 3720, + "independently_counted_ptx_register_declarations": 248, + "corrected_ptx_kernel_register_totals": 20, + "virtual_oracle_scope": "Independent Python declaration counts from cuobjdump-extracted PTX, not physical allocation or GPU execution", + "linux_sm100_kernel_facts_per_mcp_task": 248, + "nine_producer_artifacts": "complete payload coverage", + "edge_workflows": "three public CLI cases completed; see review-edge-results.json" + }, + "edge_inputs": [ + { + "path": "/home/yann/yann/rea-5345-lab/fixtures/aliased-cubin", + "bytes": 861896, + "sha256": "dc363e8192d11dcb2d9718f1467b636ab707c10a07e5919a990d9c1db0ff40c9" + }, + { + "path": "/home/yann/yann/rea-5345-lab/fixtures/partial-amdgpu", + "bytes": 608, + "sha256": "f403ac1c38758d4226111daaa4ec9aeb4ea03ff73fad9efe5eb2d1808093ff20" + }, + { + "path": "/home/yann/yann/rea-5345-lab/fixtures/long-header-ptx", + "bytes": 71946, + "sha256": "567068fe43816d4ad7ec6c2de47f3536f5041da974c8fc686a518e3f10445159" + } + ], + "edge_results": [ + { + "case": "aliased-cubin", + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/aliased-cubin", + "--analysis", + "kernels", + "--detail", + "complete", + "--json" + ], + "exit_code": 0, + "sha256": "dc363e8192d11dcb2d9718f1467b636ab707c10a07e5919a990d9c1db0ff40c9", + "inspection": "complete", + "coverage": { + "discovery_scope": "supported-section-names-and-standalone-formats", + "discovery": "complete", + "payloads": "complete", + "examined_sections": 0, + "unknown_section_names": 0 + }, + "kernel": { + "name": "add_one", + "parameter_count": 2, + "parameter_bytes": 12, + "registers": { + "physical": 10, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + } + } + }, + { + "case": "partial-amdgpu", + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/partial-amdgpu", + "--analysis", + "kernels", + "--detail", + "complete", + "--json" + ], + "exit_code": 0, + "sha256": "f403ac1c38758d4226111daaa4ec9aeb4ea03ff73fad9efe5eb2d1808093ff20", + "inspection": "malformed", + "coverage": { + "discovery_scope": "supported-section-names-and-standalone-formats", + "discovery": "complete", + "payloads": "partial", + "examined_sections": 0, + "unknown_section_names": 0 + }, + "kernel": { + "name": "add_one", + "parameter_count": 2, + "parameter_bytes": 12, + "registers": { + "physical": null, + "sgpr": 6, + "vgpr": 2, + "agpr": null, + "virtual": [] + } + } + }, + { + "case": "long-header-ptx", + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/long-header-ptx", + "--analysis", + "kernels", + "--detail", + "complete", + "--json" + ], + "exit_code": 0, + "sha256": "567068fe43816d4ad7ec6c2de47f3536f5041da974c8fc686a518e3f10445159", + "inspection": "complete", + "coverage": { + "discovery_scope": "supported-section-names-and-standalone-formats", + "discovery": "complete", + "payloads": "complete", + "examined_sections": 0, + "unknown_section_names": 0 + }, + "kernel": { + "name": "add_one", + "parameter_count": 2, + "parameter_bytes": 12, + "registers": { + "physical": null, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [ + { + "type": ".b32", + "count": 9 + }, + { + "type": ".b64", + "count": 5 + } + ] + } + } + } + ], + "limitations": [ + "The prior adversarial expansion is source-derived; the unsafe 67-million-entry old path was not executed.", + "Aliased metadata coalesces identical scoped byte declarations, preserving first source coordinates and distinct conflicting values.", + "Index accounting bounds serialized fact size, aggregate visits and reads; it is not a byte-accurate JS heap limit.", + "PTX register totals are source declarations, independently counted from cuobjdump-extracted text; physical allocation remains outside this oracle.", + "Main CI browser termination and wasm optional-commandTimeoutMs assertions failed identically without the GPU changes; fail-fast cancelled shards remain unverified." + ] + }, + "target_consistency_and_cuda_abi": { + "source_revision": "f5be46f9b3b01d04feb778bdafe288b666bd283c", + "source_tree": "8243a36743a332f3f4f37a9fb46a04b547872d4e", + "measurement_revision": "21eaa0d8f0195f182c06a6d85378cbdf99fb43bb", + "measurement_tree": "fcf82a374a9d268b5a34a9b62f07c7722a568c46", + "base_revision": "f801b2fbfd28f4e3713adefddc7bd73db44c284c", + "qualification": { + "shared_header_gpu_native_focused_passed": 251, + "latest_unknown_summary_and_gpu_boundary_passed": 46, + "sdk_schema_passed": 15 + }, + "oracle": { + "source_revision": "21eaa0d8f0195f182c06a6d85378cbdf99fb43bb", + "windows_kernel_facts_equal_prior_corrected_oracle": 3968, + "independent_cubin_target_and_header_checks": 165, + "os_abi_counts": { + "65": 55, + "51": 110 + }, + "false_conflicts": 0, + "ptx_byte_identities": 10, + "independent_virtual_register_totals": "248 PTX kernels verified at prior corrected stage; PTX facts unchanged", + "nine_real_producer_artifacts": "complete payload coverage" + }, + "hip_controls": { + "oracle": "Same real Clang 14 device ELF, independently checked with llvm-readelf; portable alternate wrappers, not a real legacy producer claim", + "cases": [ + { + "wrapper": "double", + "sha256": "34d7fce938cbaa2bdc55300d522698ccf6b4d71694573045378e5e4f39766f16", + "selected_target": "gfx900", + "payload_target": "amdgcn-amd-amdhsa--gfx900", + "target_consistency": "consistent", + "kernel": { + "name": "add_one", + "parameter_count": 2, + "parameter_bytes": 12, + "registers": { + "physical": null, + "sgpr": 6, + "vgpr": 2, + "agpr": null, + "virtual": [] + } + } + }, + { + "wrapper": "single", + "sha256": "a46f62836fcc7f9ed93a0ea1e4b35305dc69839a5b1bf65028dd86bba1df444b", + "selected_target": "gfx900", + "payload_target": "amdgcn-amd-amdhsa--gfx900", + "target_consistency": "consistent", + "kernel": { + "name": "add_one", + "parameter_count": 2, + "parameter_bytes": 12, + "registers": { + "physical": null, + "sgpr": 6, + "vgpr": 2, + "agpr": null, + "virtual": [] + } + } + }, + { + "wrapper": "opaque", + "sha256": "12c7e35d38844f8129991fc87e99f43458173dbcb4a4830d68c296ce6f4027c2", + "selected_target": "amdgcn-amd-amdhsa--gfx900", + "payload_target": "amdgcn-amd-amdhsa--gfx900", + "target_consistency": "single-source", + "kernel": { + "name": "add_one", + "parameter_count": 2, + "parameter_bytes": 12, + "registers": { + "physical": null, + "sgpr": 6, + "vgpr": 2, + "agpr": null, + "virtual": [] + } + } + } + ] + }, + "tasks": { + "cuda-abi-focused": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/gpuCodeObjectsCli.test.ts", + "tests/boundary/gpu/codeObjects.test.ts", + "tests/process-global/gpuFileLifetime.test.ts", + "src/domain/elfFixedHeader.test.ts", + "tests/boundary/go/goBinaryMappings.test.ts", + "tests/boundary/go/goBinaryImage.test.ts", + "tests/boundary/rust/rustBinaryImage.test.ts", + "src/rust/RustAuditableReader.test.ts", + "tests/boundary/il2cpp/il2cppBinaryFile.test.ts" + ], + "exit_code": 0, + "wall_s": 111.51468113699957, + "cpu_s": 182.05457199999998, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3498504192, + "sample_ms": 20, + "guard_stop": null + } + }, + "unknown-target-focused": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/gpuCodeObjectsCli.test.ts", + "tests/boundary/gpu/codeObjects.test.ts" + ], + "exit_code": 0, + "wall_s": 131.12109861799763, + "cpu_s": 216.183547, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3476627456, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-abi-schema": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/boundary/mcp/toolSchemaValidity.test.ts" + ], + "exit_code": 0, + "wall_s": 114.20525343700137, + "cpu_s": 175.515544, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3491016704, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-abi-windows": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/nvjpeg64_12.dll", + "--analysis", + "kernels", + "--detail", + "summary", + "--view", + "{\"collection\":\"targets\"}", + "--evidence_output", + "/home/yann/yann/rea-5345-lab/final-abi-windows-evidence.json", + "--json" + ], + "exit_code": 0, + "wall_s": 5.44585134999943, + "cpu_s": 5.915798000000001, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 498192384, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-abi-full": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "final-abi-full", + "full" + ], + "exit_code": 0, + "wall_s": 21.306950419002533, + "cpu_s": 23.537641, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1146519552, + "sample_ms": 20, + "guard_stop": null + }, + "task_receipt": { + "runtime": "/home/yann/yann/rea-5345-gpu-code-objects", + "label": "final-abi-full", + "mode": "full", + "target": "/home/yann/yann/rea-5345-lab/fixtures/libnvjpeg.so.12.4.0.76", + "task": "Read sm_100 parameter layout, physical registers and shared memory for the named kernel, retain source Evidence and repeat the historical query three times", + "answer": [ + { + "name": "_ZN6nvjpeg33dctQuantInvJpegKernelMultiChannelI7ushort2Li1ELi32ELi8EEEvNS_24InvDctParamsMultiChannelE", + "n": 1, + "bytes": 168, + "parameters": [ + { + "ordinal": 0, + "name": null, + "offset": 0, + "size": 168, + "type": null, + "hidden": null + } + ], + "registers": { + "physical": 31, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + }, + "shared": 9216 + } + ], + "objects": 176, + "kernels": 248, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 33, + "payloads": "partial", + "unknown_section_names": 0 + }, + "used_input_declarations_plus_primary_estimated_tokens": 418967, + "all_input_declarations_plus_primary_estimated_tokens": 648242, + "catalog_and_all_calls_estimated_tokens": 1493041, + "token_accounting": "tokenx estimates of advertised declarations, arguments and full SDK replies; initialize and JSON-RPC envelopes omitted; not actual model usage", + "task_wall_s": 20.560803566, + "call_times_ms": [ + { + "name": "inspect_gpu_code_objects", + "wall_ms": 11339.654018000001 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 638.0042919999978 + }, + { + "name": "export_evidence_bundle", + "wall_ms": 74.49857999999949 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 27.067176999997173 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 23.736125999999786 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 23.241156000000046 + } + ] + } + }, + "final-abi-selected": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "final-abi-selected", + "selected" + ], + "exit_code": 0, + "wall_s": 24.60369418600021, + "cpu_s": 26.454144, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1341333504, + "sample_ms": 20, + "guard_stop": null + }, + "task_receipt": { + "runtime": "/home/yann/yann/rea-5345-gpu-code-objects", + "label": "final-abi-selected", + "mode": "selected", + "target": "/home/yann/yann/rea-5345-lab/fixtures/libnvjpeg.so.12.4.0.76", + "task": "Read sm_100 parameter layout, physical registers and shared memory for the named kernel, retain source Evidence and repeat the historical query three times", + "answer": [ + { + "name": "_ZN6nvjpeg33dctQuantInvJpegKernelMultiChannelI7ushort2Li1ELi32ELi8EEEvNS_24InvDctParamsMultiChannelE", + "n": 1, + "bytes": 168, + "parameters": [ + { + "ordinal": 0, + "name": null, + "offset": 0, + "size": 168, + "type": null, + "hidden": null + } + ], + "registers": { + "physical": 31, + "sgpr": null, + "vgpr": null, + "agpr": null, + "virtual": [] + }, + "shared": 9216 + } + ], + "objects": 176, + "kernels": 248, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 33, + "payloads": "partial", + "unknown_section_names": 0 + }, + "used_input_declarations_plus_primary_estimated_tokens": 26653, + "all_input_declarations_plus_primary_estimated_tokens": 255928, + "catalog_and_all_calls_estimated_tokens": 1100727, + "token_accounting": "tokenx estimates of advertised declarations, arguments and full SDK replies; initialize and JSON-RPC envelopes omitted; not actual model usage", + "task_wall_s": 23.815860039, + "call_times_ms": [ + { + "name": "inspect_gpu_code_objects", + "wall_ms": 13581.298396999999 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 586.4171619999979 + }, + { + "name": "export_evidence_bundle", + "wall_ms": 146.93752499999755 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 21.25048599999718 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 20.08134400000199 + }, + { + "name": "inspect_analysis_view", + "wall_ms": 18.104333999999653 + } + ] + } + }, + "final-abi-hip": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/hip-roundtrip.py" + ], + "exit_code": 0, + "wall_s": 15.695726638998167, + "cpu_s": 16.426953, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 382410752, + "sample_ms": 20, + "guard_stop": null + } + }, + "final-abi-producers": { + "resource_receipt": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "--import", + "tsx", + "/home/yann/yann/rea-5345-lab/probe.mts" + ], + "exit_code": 0, + "wall_s": 0.7437617239993415, + "cpu_s": 0.6657989999999927, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 125800448, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "superseded_diagnostics": { + "revision": "6dd8f0dad26e677118f08dfe407c8d01c3dced34", + "outcome": "Process tasks completed, but 55 modern CUDA objects received false target-conflict facts. These runs are not correctness-qualified performance evidence. The ABI-qualified producer replaces the low-byte-only interpretation.", + "same_code_timing_variability": "The selected diagnostic run used 33.803 CPU s, while its repeat used 18.043 CPU s. No causal attribution or general speedup is established.", + "receipts": { + "target-full": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "target-full", + "full" + ], + "exit_code": 0, + "wall_s": 30.42589916200086, + "cpu_s": 33.988588, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1337733120, + "sample_ms": 20, + "guard_stop": null + }, + "target-selected": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "target-selected", + "selected" + ], + "exit_code": 0, + "wall_s": 29.977477751999686, + "cpu_s": 33.803096, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1163767808, + "sample_ms": 20, + "guard_stop": null + }, + "control-before-selected": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-perf-before", + "control-before-selected", + "selected" + ], + "exit_code": 0, + "wall_s": 16.23110394300238, + "cpu_s": 18.834853000000003, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1337622528, + "sample_ms": 20, + "guard_stop": null + }, + "control-after-selected": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-5345-lab/bench.mjs", + "/home/yann/yann/rea-5345-gpu-code-objects", + "control-after-selected", + "selected" + ], + "exit_code": 0, + "wall_s": 15.375846307000756, + "cpu_s": 18.043155, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1186308096, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "limitations": [ + "Raw OS ABI and flags plus header provenance remain retained. Recognized ABI/layout predicates produce base architecture declarations; unsupported patterns remain unknown/partial. No hardware compatibility or feature-profile proof is claimed.", + "Alternate HIP wrappers contain the same real Clang ELF; they do not establish behavior of a legacy compiler producer.", + "Final normal-workflow measurements precede the last guard that exposes failed target discovery as partial; this guard only changes unresolved observations.", + "Baseline browser and WASM CLI-schema CI failures remain separately reproduced on unmodified main." + ] + }, + "payload_admission_and_ptx_coverage": { + "source_revision": "893e04d22f2316d1c2794048161176d0e307d10a", + "source_tree": "c2cc1a45c911a34de187b6c6220c608604aedc5c", + "qualification": { + "focused_passed": 67, + "additional_source_admission_passed": 3, + "source_run_overlaps_one_existing_focused_case": true + }, + "ownership": "PTX parsing emits kernel facts with typed coverage; inventory forwards it rather than reinterpreting nullable fields. Shared work admission reserves uncompressed input once, compressed input plus output atomically, before payload reads/allocations; AMD note reads use the same owner.", + "sparse_hip_control": { + "aliases": 12, + "payload_size_bytes": 134217728, + "full_payload_reads_observed": 4, + "capacity_without_payload_read": 8, + "retained_kernels": 4, + "before_behavior": "Source-derived: old ordering would read rejected alias payloads before consulting the cumulative allowance; it was not run to exhaustion." + }, + "compiler_control": { + "command": [ + "/home/yann/yann/rea-5345-lab/toolchains/cuda_nvcc-unpacked/cuda_nvcc-linux-x86_64-12.9.86-archive/bin/ptxas", + "-arch=sm_80", + "/home/yann/yann/rea-5345-lab/pointer-parameter.ptx", + "-o", + "/home/yann/yann/rea-5345-lab/pointer-parameter.cubin" + ], + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + "public_cli_oracle": { + "pointer": { + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/pointer-parameter.ptx", + "--analysis", + "kernels", + "--detail", + "summary", + "--view", + "{\"collection\":\"targets\"}", + "--evidence_output", + "/home/yann/yann/rea-5345-lab/ptx-pointer-validated-evidence.json", + "--json" + ], + "artifact": { + "bytes_read": 302, + "file_size": 135, + "path": "/home/yann/yann/rea-5345-lab/pointer-parameter.ptx", + "sha256": "fcabf9ca3bea80be8a9a2e55f1f2235423705609c63fecc7c04924ab468d1dcb" + }, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 0, + "payloads": "partial", + "unknown_section_names": 0 + }, + "kernel": { + "id": "file@0/entry@35", + "limitations": [ + "PTX registers are virtual declarations, not physical allocations. Parameter offsets/sizes and entry-local static shared bytes are derived from supported declarations; module shared bytes, dynamic allocation, device ABI and runtime allocation were not verified.", + "Unsupported PTX parameter declarations or overflowing layouts leave parameter sizes/offsets and the total byte count unknown." + ], + "location": { + "container_id": "file@0", + "offset": 53, + "section_index": null, + "section_name": null, + "size": 81 + }, + "name": "pointer_parameter", + "object_id": "file@0", + "parameter_bytes": null, + "parameter_count": 2, + "parameters": [ + { + "hidden": null, + "name": null, + "offset": null, + "ordinal": 0, + "size": null, + "type": ".u64" + }, + { + "hidden": null, + "name": "n", + "offset": null, + "ordinal": 1, + "size": 4, + "type": ".u32" + } + ], + "private_bytes": null, + "registers": { + "agpr": null, + "physical": null, + "sgpr": null, + "vgpr": null, + "virtual": [] + }, + "representation": "ptx-declarations", + "shared_bytes": 0, + "sources": { + "parameters": { + "container_id": "file@0", + "offset": 53, + "section_index": null, + "section_name": null, + "size": 81 + }, + "private": null, + "registers": null, + "shared": null + }, + "symbol": null + } + }, + "windows": { + "command": [ + "node", + "scripts/rea.mjs", + "inspect-gpu-code-objects", + "/home/yann/yann/rea-5345-lab/fixtures/nvjpeg64_12.dll", + "--analysis", + "kernels", + "--detail", + "summary", + "--view", + "{\"collection\":\"targets\"}", + "--evidence_output", + "/home/yann/yann/rea-5345-lab/final-review-windows-validated-evidence.json", + "--json" + ], + "kernel_count": 3968, + "object_count": 176, + "prior_factual_fields_and_ptx_bytes_exact": true, + "new_partial_ptx_objects": 8, + "new_explicit_parameter_limitations": 59, + "coverage": { + "discovery": "complete", + "discovery_scope": "supported-section-names-and-standalone-formats", + "examined_sections": 8, + "payloads": "partial", + "unknown_section_names": 0 + } + } + }, + "real_producers": { + "count": 9, + "all_payload_coverage": "complete" + }, + "tasks": { + "ptx-coverage-focused": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "src/gpu/PtxKernels.test.ts", + "tests/boundary/gpu/codeObjects.test.ts", + "tests/acceptance/gpuCodeObjectsCli.test.ts" + ], + "exit_code": 0, + "wall_s": 106.22117376599999, + "cpu_s": 171.538923, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3492151296, + "sample_ms": 20, + "guard_stop": null + }, + "payload-budget-focused": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "src/gpu/PtxKernels.test.ts", + "src/gpu/GpuInventory.test.ts", + "tests/boundary/gpu/codeObjects.test.ts", + "tests/acceptance/gpuCodeObjectsCli.test.ts", + "tests/process-global/gpuFileLifetime.test.ts" + ], + "exit_code": 0, + "wall_s": 142.44056975799685, + "cpu_s": 211.52848500000002, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3478200320, + "sample_ms": 20, + "guard_stop": null + }, + "compressed-admission-tests": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/gpu/GpuInventory.test.ts" + ], + "exit_code": 0, + "wall_s": 3.3343061989980924, + "cpu_s": 3.755217, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 477700096, + "sample_ms": 20, + "guard_stop": null + }, + "final-review-producers": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "--import", + "tsx", + "/home/yann/yann/rea-5345-lab/probe.mts" + ], + "exit_code": 0, + "wall_s": 1.1484615720000875, + "cpu_s": 1.099774, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 122732544, + "sample_ms": 20, + "guard_stop": null + }, + "final-review-real-cli": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/verify-final-review.py" + ], + "exit_code": 0, + "wall_s": 10.10232881200136, + "cpu_s": 11.301614, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 512335872, + "sample_ms": 20, + "guard_stop": null + }, + "final-review-check": { + "command": ["taskset", "-c", "0,1", "npm", "run", "check"], + "exit_code": 0, + "wall_s": 135.34464124299848, + "cpu_s": 266.56255, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 5371879424, + "sample_ms": 20, + "guard_stop": null + }, + "final-review-docs": { + "command": ["taskset", "-c", "0,1", "npm", "run", "docs:check"], + "exit_code": 0, + "wall_s": 13.528166624997539, + "cpu_s": 17.260022, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 804327424, + "sample_ms": 20, + "guard_stop": null + } + }, + "failed_validation_attempts": [ + { + "reason": "Verifier treated exported Evidence bundle as a bare record; fixed to read records[0].", + "task": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/verify-final-review.py" + ], + "exit_code": 1, + "wall_s": 4.254547918000753, + "cpu_s": 4.326556, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 382095360, + "sample_ms": 20, + "guard_stop": null + } + }, + { + "reason": "Retry attempted to reuse an existing Evidence export path; CLI refused overwrite. Existing export preserved; new paths used.", + "task": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/verify-final-review.py" + ], + "exit_code": 1, + "wall_s": 5.362011795001308, + "cpu_s": 5.3208850000000005, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 379301888, + "sample_ms": 20, + "guard_stop": null + } + }, + { + "reason": "Prior snapshot comparison wrongly required complete PTX coverage to remain unchanged. All factual fields were equal; 8 PTX objects/59 kernels now correctly expose existing unknown parameter layouts. Updated oracle checks exact facts plus these intended uncertainty changes.", + "task": { + "command": [ + "taskset", + "-c", + "0,1", + "python3", + "/home/yann/yann/rea-5345-lab/verify-final-review.py" + ], + "exit_code": 1, + "wall_s": 18.017960425997444, + "cpu_s": 16.76444, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 507072512, + "sample_ms": 20, + "guard_stop": null + } + } + ], + "performance_scope": "The complete/selected MCP timing and token comparisons above remain pinned to their measured revision. This later change strengthens work admission and uncertainty propagation; no new general latency/CPU/memory improvement is inferred." + } +} diff --git a/docs/verification/gpu-code-objects-2026-10-21.md b/docs/verification/gpu-code-objects-2026-10-21.md new file mode 100644 index 0000000000..d79f85ccc4 --- /dev/null +++ b/docs/verification/gpu-code-objects-2026-10-21.md @@ -0,0 +1,286 @@ +# Static GPU code-object verification + +The [receipts](gpu-code-objects-2026-10-21.json) pin the measured runtime tree, +publisher URLs/digests, actual commands, completed answers and resource costs. +Downloaded binaries, toolchains and complete Evidence bundles remain outside Git. + +## Correctness + +NVIDIA CUDA 12.9.1 redistribution archives were verified against their publisher +manifest. nvcc 12.9.86 compiled a small owned kernel for sm_80 and sm_90a with +uncompressed, size-compressed and speed-compressed fatbins. cuobjdump 12.9.82 +independently confirmed two parameters at offsets 0/8 with sizes 8/4, 12 parameter +bytes, 10 physical registers and architecture-dependent shared memory. Separate +PTX/CUBIN and a host ELF object exercised the same parser boundaries. + +Clang 14 produced a gfx900 HIP bundle and AMDGPU ELF object; llvm-readelf supplied +the MessagePack metadata oracle. An LLVM 19 offload bundler produced a real CCOB +v2 Zstd bundle. All nine producer artifacts were reread on the final runtime +source with complete payload coverage. CCOB v1/v3 and zlib have portable boundary +fixtures, rather than real producer verification. + +Released Linux and Windows nvJPEG 12.4.0.76 artifacts each contain 165 CUBIN and +10 PTX payloads, with 3,968 kernel declarations across 15 target declarations. +Independent extraction matched all 165 decoded CUBIN hashes and all 10 PTX byte +sequences; cuobjdump resource output matched all 3,720 native kernel +name/register/shared-memory tuples. Pre-review MCP workflows rechecked the Linux +sm_100 subset's 248 complete kernel facts against those prior oracle facts. +The pre-review public CLI analyzed the entire Windows DLL, exported complete +Evidence, and reproduced all previously verified facts exactly. + +Windows validation ran on Linux: LLVM independently identified the PE section +coordinates, and cuobjdump consumed its extracted fatbin. cuobjdump did not +recognize the DLL directly. This does not establish Windows runtime behavior, +GPU execution, host launch correlation or legacy AMDGPU YAML support. + +## Initial completed MCP workflows + +Both fresh stdio MCP sessions analyzed only `sm_100` in the same Linux library, +retained 176 inventory objects and 248 kernel facts, queried the same named +kernel, exported Evidence, and repeated the historical query three times. +The complete variant returned the selected-target report; the selected variant +returned a same-call kernel page. Both preserved the exact answer: one parameter +of 168 bytes, 31 physical registers and 9,216 shared bytes. + +| Cost | Complete report | Selected page | +| --------------------------------------------------------------- | ----------------: | -----------------: | +| Used input declarations + three primary calls, estimated tokens | 406,862 | 26,106 | +| All input declarations + primary calls, estimated tokens | 635,381 | 254,625 | +| Full advertised catalog + all six calls, estimated tokens | 1,475,258 | 1,094,502 | +| Process-family elapsed seconds | 15.492 | 15.443 | +| Process-family CPU seconds | 18.220 | 18.074 | +| Sampled peak family RSS bytes | 1,139,601,408 | 1,321,566,208 | +| First inspection milliseconds | 8,572 | 8,810 | +| Three warm retained-page calls, milliseconds | 10.7 / 10.6 / 9.8 | 12.0 / 11.4 / 10.6 | + +Tokenx estimates include advertised schemas, arguments and full SDK replies; +they are not actual model usage. Text and structured reply representations are +both counted. Initialization and JSON-RPC envelopes are unmeasured. Complete +catalog discovery substantially reduces the overall percentage saving. + +These are workflow variants on the same implementation, not a before/after +parser speed benchmark. Node compile caching was disabled, but OS caches and +unrelated host load were uncontrolled. No general latency, CPU or memory +improvement is established. The selected workflow used more peak memory in this +pair. An earlier all-target complete reply exceeded the existing transport guard; +that incomplete attempt is excluded from the completed comparison. + +The full Windows DLL CLI task took 4.064 seconds / 4.909 CPU seconds / +503,459,840 peak family RSS bytes. All profiled completed tasks stayed within +the 8 GiB and 420 second guards, using two CPU affinities. Peak RSS sums payload +descendants every 20 ms and is approximate; the observer is excluded. + +## Regression checks + +- The initial focused run passed 265 tests in 19 files: GPU domain/codec/parser + cases, public CLI/MCP, retained views, output profiles, cancellation and cleanup, + plus shared ELF/PE Go, Rust and IL2CPP consumers. +- Full static checks passed before the catalog/docs-only follow-up; its receipt + records 6,866,296,832 peak family RSS bytes. The final CI run qualifies the PR. +- Documentation/catalog/license checks passed after fixing missing catalog + ownership, translated README links and the new MessagePack dependency notices. +- An earlier combined schema run hit two 30 second timeouts on two CPUs. The + isolated canonical schema suite passed all 15 cases without increasing limits. + This is recorded as a successful isolation, not a proven baseline failure. + +Retention caches are scoped to immutable Evidence through a WeakMap; historical +pages do not reopen files. Live inspections always stream a fresh full-file hash +and revalidate the descriptor/path. No live-state freshness cache is claimed. + +## Review follow-up + +At `36b87ed`, the public CLI reproduced both review findings: selecting +`amdgcn-amd-amdhsa--gfx900` in the real Clang-produced standalone object returned +zero kernels with an unknown target, and malformed `--targets` JSON was reported +at the `view` issue path. + +At `09c6062`, AMD ELF note parsing owns target discovery and prepared kernel +declarations. Inventory reads those bounded notes before selection; selected +analysis reuses them without decoding MessagePack twice. Aggregate encoded note +bytes are limited to 8 MiB before reads, with existing per-value/depth limits; +the parsed JavaScript representation expands beyond that source-byte count. +The prepared declarations live only for the current payload inspection. Named +CLI option parsing also supplies the actual parsed input to schema diagnostics. + +The real public CLI now inventories the gfx900 target, returns the producer's +one kernel/two parameters/12-byte parameter area/6 SGPR/2 VGPR when selected, +and preserves an unselected object without kernel facts for a different target. +Those three complete CLI tasks together took 5.974 seconds / 6.951 CPU seconds / +383,623,168 sampled peak family RSS bytes, including the Python task driver. +All nine producer artifacts were reread with complete payload coverage. + +The review regression run passed 35 cases in four files, including public CLI, +MCP, metadata capacity and cleanup. A further 10 product-catalog cases passed +after adding the missing CLI `analysis` option description identified by CI. +The earlier paired nvJPEG measurements remain pinned to their original runtime; +these follow-ups do not establish a new CUDA performance result. + +## Bounded metadata and partial-fact review + +The latest qualification at `ad1e17a` rebases onto main `5241597`, preserving +both Rust and GPU retained views. All 70 focused cases in nine files passed, +including both public CLI/MCP workflows; the separate SDK-converted canonical +schema suite passed all 15 cases. + +CUDA parsing shares an aggregate 262,144 record/symbol visit budget, 128 MiB +section-read budget and 16 MiB serialized-size accounting budget for index facts. +Equal scoped byte ranges and duplicate register values are coalesced before +index growth; conflicting values survive. Parsed parameter metadata is reused. +The public CLI completed an 861,896-byte CUBIN containing 1,024 headers aliasing +65,536 records and retained the correct physical count. The old 67-million-entry +expansion is source-derived; it was not run to exhaustion. + +Decoder coverage now reaches object, report and target pages: absent CUBIN +symbol tables and unknown section names remain partial. AMD note parsing uses +a tagged Result that retains earlier parsed notes on later malformed, capacity, +unsupported or interrupted reads; retained kernel facts are emitted before the +original failure is propagated. Standalone PTX recognition continues beyond the +64 KiB fast path up to its actual 16 MiB text budget and reuses complete probe +bytes. Unsupported/overflowing register counts stay null instead of low totals. + +The public CLI verified an AMD valid-note/truncated-note input and a PTX file +with a 70 KiB leading comment, in addition to the alias case. Those three tasks +together took 5.132 seconds / 6.222 CPU seconds / 405,835,776 peak family RSS +bytes, including the Python driver. Regression cases additionally cover invalid +MessagePack and unsupported extensions in standalone and bundled AMD objects. + +The revised Windows DLL workflow preserved all 165 decoded CUBIN hashes, all +10 PTX byte identities and all 3,720 native kernel facts. Independently counting +register declarations in cuobjdump-extracted PTX verified all 248 PTX kernels; +20 kernel totals now correctly include previously missed declarations. This +is a virtual-declaration oracle, not physical allocation or GPU execution. +The Linux sm_100 workflows still match all 248 independently checked native +kernel facts. All nine real producer artifacts retain complete payload coverage. + +The latest complete/selected MCP pair performed the same six calls and answered +the same question as the initial pair. Receipts retain both measurement stages. + +| Cost | Complete report | Selected page | +| --------------------------------------------------------- | --------------: | ------------: | +| Used input declarations + primary calls, estimated tokens | 407,368 | 26,612 | +| All input declarations + primary calls, estimated tokens | 636,495 | 255,739 | +| Full catalog + all six calls, estimated tokens | 1,480,698 | 1,099,942 | +| Process-family elapsed seconds | 15.487 | 15.923 | +| Process-family CPU seconds | 18.404 | 18.760 | +| Peak family RSS bytes | 1,251,254,272 | 1,201,774,592 | + +These remain tokenizer estimates and same-code workflow variants. The single +latest pair establishes no general latency/CPU/memory improvement; OS caches +and host contention remain uncontrolled. The Windows CLI rerun completed in +4.078 seconds / 4.880 CPU seconds / 498,380,800 peak family RSS bytes. + +Main CI independently reproduced the browser termination and WASM optional-input +assertion failures without GPU changes. Cancelled fail-fast shards are not +counted as passing. Those baseline failures are outside this feature's fixes. + +## Target declaration and CUDA ABI qualification + +The latest code `f5be46f` is based on main `f801b2f`. Inventory and payload +target declarations remain separate, with typed consistency and inspection +coverage preserved in object pages even with selected fields. A mismatched +outer/inner base target remains partial and annotates affected kernels; CUDA +base-only agreement does not establish agreement on a feature profile. Target +filters select inventory declarations, and unexamined inner targets stay unknown. + +Standalone and unknown-ID bundled AMDGPU files use the same bounded target +discovery owner before filtering. Portable controls wrap the same real Clang +ELF with two-separator, one-separator and opaque HIP IDs. All three inventory +then exact-target CLI workflows retain the verified kernel. They are alternate +wrapper controls, not verification of an actual legacy compiler producer. + +The shared fixed ELF header owner now retains OS ABI. NVIDIA's released CUBINs +include distinct `0x33` and `0x41` flag layouts. The earlier low-byte-only +interpretation falsely produced `sm_2` for 55 modern payloads; that attempted +target-check implementation and its timings are superseded diagnostic evidence, +not a correctness-qualified benchmark. The ABI-qualified decoder retains raw +flags and exact fixed-header coordinates, and leaves unrecognized patterns unknown. +Header ownership is checked when importing portable Evidence. + +All 165 released Windows CUBIN targets and header values were checked against +cuobjdump-extracted files: 110 use OS ABI `0x33`, 55 use `0x41`; no false conflicts +remain. All 3,968 corrected kernel facts and 10 PTX byte identities match the +prior independent oracle. The nine real producer artifacts retain complete +payload coverage. Unsupported layout controls retain their headers and make +unknown target discovery partial in the default MCP summary. + +After the shared-header change, 251 cases in nine files passed, including +Go/Rust/IL2CPP consumers, GPU CLI/MCP and provenance forgery. The last uncertainty +propagation check passed 46 public/boundary cases. The separate SDK-converted +canonical schema suite passed all 15 cases on the current base. + +The latest qualified complete/selected pair retains the same sm_100 question, +answer and six-call workflow. It runs at `21eaa0d`, before the final unresolved- +discovery-only guard. + +| Cost | Complete report | Selected page | +| --------------------------------------------------------- | --------------: | ------------: | +| Used input declarations + primary calls, estimated tokens | 418,967 | 26,653 | +| All input declarations + primary calls, estimated tokens | 648,242 | 255,928 | +| Full catalog + all six calls, estimated tokens | 1,493,041 | 1,100,727 | +| Process-family elapsed seconds | 21.307 | 24.604 | +| Process-family CPU seconds | 23.538 | 26.454 | +| Peak family RSS bytes | 1,146,519,552 | 1,341,333,504 | + +Selected replies save context but do not establish faster analysis. The latest +pair used more elapsed/CPU time and peak memory for selected delivery; variability +is visible across the preserved trials. Fresh process lifetime is controlled; +OS caches, frequency and unrelated load are not. A same-code diagnostic timing +repeat varied substantially, and no cause or persistent speedup is inferred. +The tokenizer/client scope and omitted initialization costs remain as above. + +The final Windows CLI task took 5.446 seconds / 5.916 CPU seconds / 498,192,384 +peak family RSS bytes. The six HIP wrapper CLI tasks took 15.696 seconds / +16.427 CPU seconds / 382,410,752 peak family RSS bytes. All completed tasks stayed +within the 8 GiB family guard. + +## Payload admission and PTX uncertainty follow-up + +At `893e04d`, PTX parsing owns typed per-kernel coverage and limitations for +unknown parameter layouts, shared declarations and virtual register counts. +Inventory forwards that coverage to objects, reports and historical pages. +The old register-only interpretation in inventory was removed. + +A 67-case focused run passed across five files, including parser, public CLI/MCP, +provider boundary and lifecycle tests. Three source admission cases also passed +(one overlaps the focused run). NVIDIA `ptxas 12.9.86` accepted a pointer-parameter +PTX control; the public CLI retained its unknown layout and reported partial +coverage. A released nvJPEG example independently extracted by cuobjdump uses +`.param .u64 .ptr .align 1`; this remains outside supported layout interpretation. + +The revised Windows DLL report retains all 176 object factual fields, all 3,968 +kernel factual fields and all 10 exact PTX byte identities. Eight PTX objects and +59 kernels now expose their pre-existing unknown parameter layouts with partial +coverage and added limitations. The earlier complete payload-coverage claim for +those PTX objects is superseded; decoded numerical facts are unchanged. Nine real +producer artifacts still retain complete payload coverage. + +The shared work owner now reserves uncompressed payload input once, and compressed +input plus decoded output atomically, before reading/allocating. CCOB uses the +same admission owner as payload analysis and AMD note reads. The allowance is +512 MiB of that work, with 128 MiB per input/output buffer. Admitted failed work +remains charged. A real sparse HIP file contains twelve aliases of one 128 MiB +ELF payload: file-read instrumentation observed four full payload reads and eight +capacity refusals with no payload read or decoded digest; the earlier four kernel +facts survive. CUDA and CCOB oversized-output controls likewise prove refusal +before the compressed input read. Old rejected-alias rereads are source-derived; +they were not run to exhaustion. + +The nine-producer pass took 1.148 seconds / 1.100 CPU seconds / 122,732,544 peak +family RSS bytes. The pointer-PTX and released-DLL public CLI tasks together took +10.102 seconds / 11.302 CPU seconds / 512,335,872 peak family RSS bytes. The focused +run took 142.441 seconds / 211.528 CPU seconds / 3,478,200,320 peak family RSS bytes, +including the required artifact build. No resource guard stopped these tasks. +The earlier MCP cost pairs remain pinned to their measured revisions; no new +speedup is inferred from this follow-up. + +Three verifier attempts are retained as unsuccessful in the JSON record: an +incorrect bare-record assumption for the Evidence bundle, refusal to overwrite +an existing export, and a stale expectation that the now-corrected PTX coverage +would stay complete. The successful comparison requires all factual values and +PTX bytes to match while checking the intended uncertainty changes explicitly. + +`npm run check` passed at this source revision, including type/lint/format, module +boundaries and architecture guards: 135.345 seconds / 266.563 CPU seconds / +5,371,879,424 sampled peak family RSS bytes; no resource guard stop. + +`npm run docs:check` also passed, including all five README conformance cases. diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index c2c2280df1..8225eedadf 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 751; runtime: 179. +Locked package entries: 752; runtime: 180. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -690,6 +690,11 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@modelcontextprotocol/server/v/2.3.1 Archive: https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz Integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw== +@msgpack/msgpack@3.1.3 | runtime | ISC + Lockfile path: node_modules/@msgpack/msgpack + Package: https://www.npmjs.com/package/@msgpack/msgpack/v/3.1.3 + Archive: https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz + Integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA== @napi-rs/lzma-linux-x64-gnu@1.5.1 | development, optional | MIT Lockfile path: node_modules/@napi-rs/lzma-linux-x64-gnu Package: https://www.npmjs.com/package/@napi-rs/lzma-linux-x64-gnu/v/1.5.1 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index 0fc82a1312..177220131c 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 4125777159e02cb749e17c959e77f59528cfffe3f7a5220b8a21ce3c49ad3061 +Runtime dependency fingerprint (SHA-256): d0a0286bb8c158a670b019cdb56ee120542cb072bbecf3741c172b5f06b2301b ===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT @@ -1822,6 +1822,20 @@ CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for the full license text. +===== @msgpack/msgpack@3.1.3 ===== +Declared license: ISC +Archive: https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz +Integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA== +Scope: npm runtime dependency, including optional platform packages. + +--- package/LICENSE --- + +Copyright 2019 The MessagePack Community. + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + ===== @pinojs/redact@0.4.0 ===== Declared license: MIT Archive: https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz diff --git a/package-lock.json b/package-lock.json index 67a74129f6..a8d20bd23d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@lydell/node-pty": "1.1.0", "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/server": "2.3.1", + "@msgpack/msgpack": "3.1.3", "@xmldom/xmldom": "0.9.12", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", @@ -2239,6 +2240,15 @@ "node": ">=20" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", diff --git a/package.json b/package.json index 1a752edc22..8aab2719b9 100644 --- a/package.json +++ b/package.json @@ -336,6 +336,7 @@ "@lydell/node-pty": "1.1.0", "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/server": "2.3.1", + "@msgpack/msgpack": "3.1.3", "@xmldom/xmldom": "0.9.12", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index d33c72d10a..cedce74583 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -34,6 +34,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.desktopContracts.DESKTOP_APPLICATION_TOOL_CONTRACTS, ...sources.evmContracts.EVM_TOOL_CONTRACTS, ...sources.goContracts.GO_TOOL_CONTRACTS, + ...sources.gpuContracts.GPU_TOOL_CONTRACTS, ...sources.il2cppContracts.IL2CPP_TOOL_CONTRACTS, ...sources.modelContracts.MODEL_TOOL_CONTRACTS, ...sources.rustContracts.RUST_TOOL_CONTRACTS, @@ -268,6 +269,10 @@ export const providerCatalog = (sources) => { identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY, contracts: sources.goContracts.GO_TOOL_CONTRACTS, }, + { + identity: sources.gpuProvider.GPU_CODE_OBJECTS_PROVIDER_IDENTITY, + contracts: sources.gpuContracts.GPU_TOOL_CONTRACTS, + }, { identity: sources.il2cppProvider.IL2CPP_METADATA_PROVIDER_IDENTITY, contracts: sources.il2cppContracts.IL2CPP_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index ceac794fec..569ffd2b80 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -69,6 +69,8 @@ const SOURCE_PATHS = { evmProvider: "dist/evm/EvmoleRelease.js", goContracts: "dist/contracts/go/goToolContracts.js", goProvider: "dist/go/GoBinaryProvider.js", + gpuContracts: "dist/contracts/gpu/gpuToolContracts.js", + gpuProvider: "dist/gpu/GpuCodeObjectsProvider.js", modelContracts: "dist/contracts/model/modelToolContracts.js", modelProvider: "dist/model/ModelArtifactProvider.js", il2cppContracts: "dist/contracts/il2cpp/il2cppToolContracts.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 41b12f48d3..7577e953f5 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -10,6 +10,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([ "go", "il2cpp", "model", + "gpu", "rust", "wasm", "captures", diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index 2be464ec47..9d73487cee 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 87e2dc711a..d0eabbb0d9 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -415,6 +415,7 @@ const workflowAvailabilityFor = ({ name === "inspect_go_binary" || name === "inspect_il2cpp_metadata" || name === "inspect_model_artifact" || + name === "inspect_gpu_code_objects" || name === "inspect_rust_binary" || name === "inspect_electron_fuses" ) diff --git a/src/application/gpu/GpuCodeObjectsEvidence.ts b/src/application/gpu/GpuCodeObjectsEvidence.ts new file mode 100644 index 0000000000..3f95fcb13a --- /dev/null +++ b/src/application/gpu/GpuCodeObjectsEvidence.ts @@ -0,0 +1,57 @@ +import type { ProviderIdentity } from "../AnalysisProvider.js"; +import type { + GpuCodeObjects, + GpuCodeObjectsInput, + GpuCodeObjectsPartial, +} from "../../domain/gpu/gpuCodeObjects.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; + +const gpuParameters = (parameters: GpuCodeObjectsInput) => ({ + path: parameters.path, + analysis: parameters.analysis, + ...(parameters.object_ids === undefined + ? {} + : { object_ids: parameters.object_ids }), + ...(parameters.targets === undefined ? {} : { targets: parameters.targets }), + ...(parameters.expected_sha256 === undefined + ? {} + : { expected_sha256: parameters.expected_sha256 }), +}); +/** One Evidence interpretation for success and observations retained by file finalization failures. */ +export const createGpuCodeObjectsEvidence = ( + report: GpuCodeObjects, + provider: ProviderIdentity, + parameters: GpuCodeObjectsInput, +): Evidence => + createEvidence( + { + path: report.artifact.path, + sha256: report.artifact.sha256, + format: "file", + }, + provider, + { + operation: "inspect_gpu_code_objects", + parameters: gpuParameters(parameters), + result: report, + confidence: "observed", + locations: [{ kind: "artifact-path", path: report.artifact.path }], + limitations: report.limitations, + }, + ); + +export const createGpuCodeObjectsPartialEvidence = ( + report: GpuCodeObjectsPartial, + provider: ProviderIdentity, + parameters: GpuCodeObjectsInput, +): Evidence => + createEvidence(undefined, provider, { + operation: "inspect_gpu_code_objects", + parameters: gpuParameters(parameters), + result: report, + confidence: "observed", + subjectUnavailableReason: + "The interrupted inspection did not complete final file identity verification; root digest and completeness are unknown.", + locations: [{ kind: "artifact-path", path: report.artifact.path }], + limitations: report.limitations, + }); diff --git a/src/application/gpu/GpuCodeObjectsPort.ts b/src/application/gpu/GpuCodeObjectsPort.ts new file mode 100644 index 0000000000..6efcbd35d8 --- /dev/null +++ b/src/application/gpu/GpuCodeObjectsPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + GpuCodeObjects, + GpuCodeObjectsInput, +} from "../../domain/gpu/gpuCodeObjects.js"; +import type { Result } from "../../domain/result.js"; + +/** Read-only, local GPU declarations; no GPU runtime, target execution or downloads. */ +export interface GpuCodeObjectsPort { + readonly identity: ProviderIdentity; + inspect( + input: GpuCodeObjectsInput, + options?: ExecutionOptions, + ): Promise>; + close(): Promise; +} diff --git a/src/application/gpu/GpuCodeObjectsService.ts b/src/application/gpu/GpuCodeObjectsService.ts new file mode 100644 index 0000000000..cd9d3178a3 --- /dev/null +++ b/src/application/gpu/GpuCodeObjectsService.ts @@ -0,0 +1,97 @@ +import { inspectGpuCodeObjectsInputSchema } from "../../domain/gpu/gpuCodeObjects.js"; +import type { GpuCodeObjectsPort } from "./GpuCodeObjectsPort.js"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { gpuCodeObjectsSchema } from "../../domain/gpu/gpuCodeObjects.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { createGpuCodeObjectsEvidence } from "./GpuCodeObjectsEvidence.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + requireAnalysisViewRetention, + projectRetainedAnalysis, + type AnalysisViewRetention, +} from "../analysisView/AnalysisViewService.js"; + +/** Shared input, Evidence and retained delivery boundary for CLI, MCP and SDK callers. */ +export class GpuCodeObjectsService { + constructor(readonly provider: GpuCodeObjectsPort) {} + close(): Promise { + return this.provider.close(); + } + async inspect( + raw: unknown, + options?: ExecutionOptions, + retention: AnalysisViewRetention = { + evidenceById: undefined, + recordEvidence: undefined, + }, + ): Promise> { + const input = inspectGpuCodeObjectsInputSchema.safeParse(raw); + if (!input.success) + return err( + analysisInputErrorFromIssues( + "inspect_gpu_code_objects", + input.error.issues, + raw, + { cause: input.error }, + ), + ); + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError("inspect_gpu_code_objects", { + cause: options.signal.reason, + }), + ); + const { detail, view, ...parameters } = input.data; + if (detail === "summary") { + const ready = requireAnalysisViewRetention( + "inspect_gpu_code_objects", + retention, + ); + if (!ready.ok) return ready; + } + const inspected = await this.provider.inspect(parameters, options); + if (!inspected.ok) return inspected; + const checked = gpuCodeObjectsSchema.safeParse(inspected.value); + if ( + !checked.success || + checked.data.artifact.path !== parameters.path || + checked.data.analysis !== parameters.analysis || + (parameters.expected_sha256 !== undefined && + checked.data.artifact.sha256 !== parameters.expected_sha256) + ) + return err( + new AnalysisOutputError( + "inspect_gpu_code_objects", + "GPU reader changed the selected input or returned invalid facts", + ), + ); + const report = checked.data; + const evidence = createGpuCodeObjectsEvidence( + report, + this.provider.identity, + parameters, + ); + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError("inspect_gpu_code_objects", { + cause: options.signal.reason, + partialObservation: evidence, + }), + ); + return detail === "summary" + ? projectRetainedAnalysis( + evidence, + retention, + view === undefined + ? { kind: "summary" } + : { kind: "gpu-code-objects", ...view }, + ) + : ok(evidence); + } +} diff --git a/src/cli.ts b/src/cli.ts index 586942bcd5..07b7c1b7f4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,4 @@ +import { registerGpuCommands } from "./cli/gpuCommands.js"; import { registerNativeFunctionMatchingCommands } from "./cli/nativeFunctionMatchingCommands.js"; import { registerCaptureCommands } from "./cli/captureCommands.js"; import { registerSignalCommands } from "./cli/signalCommands.js"; @@ -130,6 +131,7 @@ export const createCli = ( registerGoCommands(cli, logger); registerIl2cppCommands(cli, logger); registerModelCommands(cli, logger); + registerGpuCommands(cli, logger); registerRustCommands(cli, logger); registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); diff --git a/src/cli/gpuCommands.ts b/src/cli/gpuCommands.ts new file mode 100644 index 0000000000..93f18a84c7 --- /dev/null +++ b/src/cli/gpuCommands.ts @@ -0,0 +1,132 @@ +import { inspectGpuCodeObjectsInputSchema } from "../domain/gpu/gpuCodeObjects.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { z } from "incur"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { createGpuCodeObjectsService } from "../composition/gpu.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + deliverCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; + +/** One-shot summaries export the same complete observations used by MCP retained pages. */ +export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => { + cli.command(CLI_COMMANDS.inspectGpuCodeObjects, { + description: + "Inspect embedded CUDA/HIP targets and selected static kernel metadata", + args: z.object({ + path: z + .string() + .describe("Local ELF/PE, CUDA fatbin, CUBIN, PTX or HIP bundle"), + }), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + view: z + .string() + .optional() + .describe( + "JSON GPU object/kernel/PTX page selection; requires --detail summary and --evidence_output", + ), + analysis: z + .enum(["inventory", "kernels"]) + .default("inventory") + .describe("Inventory GPU targets or decode selected kernel metadata"), + expected_sha256: z + .string() + .optional() + .describe("Inventory SHA-256; required with --object_ids"), + object_ids: z + .string() + .optional() + .describe("JSON array of exact object IDs to decode"), + targets: z + .string() + .optional() + .describe("JSON array of exact target declarations to decode"), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectGpuCodeObjects, async () => { + const selectors: Partial< + Record<"object_ids" | "targets" | "view", unknown> + > = {}; + for (const name of ["object_ids", "targets", "view"] as const) { + const value = options[name]; + if (value === undefined) continue; + try { + selectors[name] = JSON.parse(value); + } catch (cause) { + return projectAnalysisError( + new AnalysisInputError("inspect_gpu_code_objects", { cause }, [ + { + path: [name], + reason: "invalid_format", + message: `${name} must be valid JSON`, + }, + ]), + ); + } + } + const rawInput = { + path: absoluteCallerPath(args.path), + analysis: options.analysis, + ...(options.expected_sha256 === undefined + ? {} + : { expected_sha256: options.expected_sha256 }), + ...selectors, + detail: options.detail, + }; + const selected = inspectGpuCodeObjectsInputSchema.safeParse(rawInput); + if (!selected.success) + return projectAnalysisError( + analysisInputErrorFromIssues( + "inspect_gpu_code_objects", + selected.error.issues, + rawInput, + ), + ); + const service = createGpuCodeObjectsService(); + return deliverCliAnalysisEvidence({ + run: () => + service.inspect( + { + path: absoluteCallerPath(args.path), + analysis: selected.data.analysis, + ...(selected.data.expected_sha256 === undefined + ? {} + : { expected_sha256: selected.data.expected_sha256 }), + ...(selected.data.object_ids === undefined + ? {} + : { object_ids: selected.data.object_ids }), + ...(selected.data.targets === undefined + ? {} + : { targets: selected.data.targets }), + detail: "complete", + }, + { signal }, + ), + service, + providerId: "gpu-code-objects", + operation: "inspect_gpu_code_objects", + signal, + detail: options.detail, + evidenceOutput: options.evidence_output, + ...(selected.data.view === undefined + ? {} + : { + view: { + kind: "gpu-code-objects" as const, + ...selected.data.view, + }, + }), + }); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index ce6c6bf837..91ff0fb0eb 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -62,6 +62,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectGoBinary: "inspect-go-binary", inspectIl2cppMetadata: "inspect-il2cpp-metadata", inspectModelArtifact: "inspect-model-artifact", + inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", inspectAnalysisView: "inspect-analysis-view", diff --git a/src/composition/gpu.ts b/src/composition/gpu.ts new file mode 100644 index 0000000000..1bd11a0b1d --- /dev/null +++ b/src/composition/gpu.ts @@ -0,0 +1,4 @@ +import { GpuCodeObjectsService } from "../application/gpu/GpuCodeObjectsService.js"; +import { GpuCodeObjectsProvider } from "../gpu/GpuCodeObjectsProvider.js"; +export const createGpuCodeObjectsService = (): GpuCodeObjectsService => + new GpuCodeObjectsService(new GpuCodeObjectsProvider()); diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index e6a79eb61c..fc073abde9 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/gpu/gpuToolContracts.ts b/src/contracts/gpu/gpuToolContracts.ts new file mode 100644 index 0000000000..8c6d01233d --- /dev/null +++ b/src/contracts/gpu/gpuToolContracts.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { + inspectGpuCodeObjectsInputSchema, + gpuCodeObjectsSchema, +} from "../../domain/gpu/gpuCodeObjects.js"; +import { + gpuCodeObjectsSummaryViewResultSchema, + gpuCodeObjectsPageViewResultSchema, +} from "../../domain/analysisView/analysisView.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +export const GPU_TOOL_CONTRACTS = [ + { + name: "inspect_gpu_code_objects", + ...toolContractMetadata("inspect_gpu_code_objects"), + kind: "artifact-provider", + description: + "Inspect static CUDA/HIP GPU code objects in local ELF/PE hosts, CUDA fatbins, CUBIN, PTX and Clang HIP bundles (CUDA LZ4/Zstd and CCOB v1/v2/v3 zlib/Zstd). Default inventory reads directories; full-file SHA-256 always streams every byte. analysis kernels decodes only selected object_ids/targets (object_ids require expected_sha256 from their inventory), retaining all inventory rows and explicit partial/unknown states. Reports PTX virtual registers, CUBIN physical registers/nv.info parameters/shared memory and AMDGPU MessagePack kernel declarations separately. No GPU tools, target execution, network, SASS disassembly or host launch correlation. Unsupported CUDA compression retains encoded locations. Default summary retains complete Evidence; optional view returns a selected targets/objects/kernels page or PTX text page in this call. inspect_analysis_view kind gpu-code-objects reuses retained facts without reopening files; PTX text uses UTF-16 code-unit pages. Object IDs are positional within artifact.sha256. One active inspection; 128 MiB/object, 512 MiB decoded work, 16 MiB retained facts. Full Evidence includes selected PTX text artifacts with SHA-256 and encoded/decoded provenance.", + inputSchema: inspectGpuCodeObjectsInputSchema, + outputSchema: evidenceResultOf( + z.union([ + gpuCodeObjectsSchema, + gpuCodeObjectsSummaryViewResultSchema, + gpuCodeObjectsPageViewResultSchema, + ]), + ), + examples: [ + { + title: "List embedded GPU targets before selecting payloads", + input: { + path: "/artifacts/application.so", + analysis: "inventory", + detail: "summary", + view: { collection: "targets" }, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 73be894f49..1e2f1be484 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,3 +1,4 @@ +import { GPU_TOOL_CONTRACTS } from "./gpu/gpuToolContracts.js"; import { CAPTURE_TOOL_CONTRACTS } from "./captures/captureToolContracts.js"; import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js"; import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; @@ -51,6 +52,7 @@ export const TOOL_CONTRACTS = [ ...GO_TOOL_CONTRACTS, ...IL2CPP_TOOL_CONTRACTS, ...MODEL_TOOL_CONTRACTS, + ...GPU_TOOL_CONTRACTS, ...RUST_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 2df469c121..29cd1a04e6 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -66,6 +66,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { "inspects the operator-selected adb client; never starts its server", list_adb_devices: "may start the local adb server, which REA does not own", inspect_firmware_regions: "states there is no target execution", + inspect_gpu_code_objects: + "reads static declarations without executing GPU code", inspect_model_artifact: "states that model code and inference are never executed", extract_firmware: "states that extracted code is never executed", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 0e11470c0a..b10480a7cb 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -129,6 +129,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_go_binary: evidence, inspect_il2cpp_metadata: evidence, inspect_model_artifact: evidence, + inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, inspect_sqlite_database: effects({ mutatesSession: true, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 0020f4aec4..bcf6f93c39 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,11 @@ +import { + gpuCodeObjectsViewRequestSchema, + gpuCodeObjectsSummarySchema, + gpuCodeObjectsItemsSchema, + gpuCodeObjectsPtxPageSchema, + projectGpuCodeObjectsView, +} from "./gpuCodeObjectsView.js"; +import { gpuCodeObjectsObjectSchema } from "../gpu/gpuCodeObjects.js"; import { projectModelArtifactView, modelArtifactSummarySchema, @@ -311,6 +319,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ dartAotViewRequestSchema, il2cppMetadataViewRequestSchema, modelArtifactViewRequestSchema, + gpuCodeObjectsViewRequestSchema, ]); // The application owns admission and authenticated Evidence parsing. SDK and @@ -420,6 +429,7 @@ const viewResultBase = { "inspect_dart_aot", "inspect_il2cpp_metadata", "inspect_model_artifact", + "inspect_gpu_code_objects", ...NATIVE_INVENTORY_OPERATIONS, "match_native_functions", ]), @@ -605,6 +615,21 @@ export const dartAotSummaryViewResultSchema = z.strictObject({ summary: dartAotSummarySchema, }); +export const gpuCodeObjectsSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_gpu_code_objects"), + summary: gpuCodeObjectsSummarySchema, +}); +export const gpuCodeObjectsPageViewResultSchema = z.strictObject({ + kind: z.literal("gpu-code-objects"), + view: gpuCodeObjectsViewRequestSchema, + items: gpuCodeObjectsItemsSchema, + ptx: gpuCodeObjectsPtxPageSchema.nullable(), + source_coverage: gpuCodeObjectsObjectSchema.shape.coverage, + ...viewResultBase, + parent_operation: z.literal("inspect_gpu_code_objects"), +}); + export const modelArtifactSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_model_artifact"), @@ -708,6 +733,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ dartAotSummaryViewResultSchema, il2cppMetadataSummaryViewResultSchema, modelArtifactSummaryViewResultSchema, + gpuCodeObjectsSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, nativeFunctionMatchingSummaryViewResultSchema, ]), @@ -857,6 +883,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ parent_operation: z.literal("inspect_dart_aot"), }), modelArtifactPageViewResultSchema, + gpuCodeObjectsPageViewResultSchema, z.strictObject({ kind: z.literal("il2cpp-metadata"), view: il2cppMetadataViewRequestSchema, @@ -997,6 +1024,7 @@ const retainedViewProjectors = new Map([ ["inspect_dart_aot", projectDartAotView], ["inspect_il2cpp_metadata", projectIl2cppMetadataView], ["inspect_model_artifact", projectModelArtifactView], + ["inspect_gpu_code_objects", projectGpuCodeObjectsView], ["match_native_functions", projectNativeFunctionMatchingView], ["inspect_artifact", projectArtifactInventoryView], ["inventory_artifact", projectArtifactInventoryView], diff --git a/src/domain/analysisView/gpuCodeObjectsView.ts b/src/domain/analysisView/gpuCodeObjectsView.ts new file mode 100644 index 0000000000..1b256b756e --- /dev/null +++ b/src/domain/analysisView/gpuCodeObjectsView.ts @@ -0,0 +1,421 @@ +import { isImmutableJsonSnapshot } from "../immutableJson.js"; +import { digestSchema } from "../digests.js"; +import { z } from "zod"; +import { + gpuCodeObjectsSchema, + gpuCodeObjectsObjectSchema, + gpuCodeObjectsSelectionSchema, + gpuObjectSchema, + gpuKernelSchema, + gpuTargetSchema, + type GpuTarget, + type GpuCodeObjects, +} from "../gpu/gpuCodeObjects.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { err, ok, type Result } from "../result.js"; +import type { JsonValue } from "../jsonValue.js"; +import { jsonValueByteLength } from "../jsonValueByteLength.js"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +export const gpuCodeObjectsViewRequestSchema = z.discriminatedUnion( + "collection", + [ + gpuCodeObjectsSelectionSchema.options[0].extend({ + kind: z.literal("gpu-code-objects"), + }), + gpuCodeObjectsSelectionSchema.options[1].extend({ + kind: z.literal("gpu-code-objects"), + }), + gpuCodeObjectsSelectionSchema.options[2].extend({ + kind: z.literal("gpu-code-objects"), + }), + gpuCodeObjectsSelectionSchema.options[3].extend({ + kind: z.literal("gpu-code-objects"), + }), + ], +); +export const gpuCodeObjectsSummarySchema = gpuCodeObjectsObjectSchema + .omit({ objects: true, kernels: true, ptx_artifacts: true }) + .extend({ + counts: z.strictObject({ + objects: z.number().int().nonnegative(), + kernels: z.number().int().nonnegative(), + ptx_artifacts: z.number().int().nonnegative(), + targets: z.number().int().nonnegative(), + }), + target_preview: z.array(z.string()), + }); +export const gpuCodeObjectsItemsSchema = z.union([ + z.array( + gpuObjectSchema.partial().required({ + id: true, + kind: true, + target: true, + location: true, + metadata_location: true, + payload_target: true, + target_consistency: true, + inspection: true, + }), + ), + z.array( + gpuKernelSchema.partial().required({ + id: true, + object_id: true, + location: true, + sources: true, + representation: true, + }), + ), + z.array( + gpuTargetSchema.partial().required({ + id: true, + target: true, + confidence: true, + kernel_coverage: true, + }), + ), +]); +export const gpuCodeObjectsPtxPageSchema = z.strictObject({ + artifact_id: z.string(), + object_id: z.string(), + sha256: digestSchema, + byte_length: z.number().int().nonnegative(), + text: z.string(), + offset: z.number().int().nonnegative(), + offset_unit: z.literal("utf16-code-unit"), +}); +const summarizeGpuTargets = (report: GpuCodeObjects): GpuTarget[] => { + const groups = new Map< + string | null, + { row: GpuTarget; encoded: bigint; decoded: bigint | null } + >(); + const kernelCounts = new Map(); + for (const kernel of report.kernels) + kernelCounts.set( + kernel.object_id, + (kernelCounts.get(kernel.object_id) ?? 0) + 1, + ); + for (const object of report.objects) { + if ( + object.kind === "cuda-fatbin" || + object.kind === "hip-bundle" || + object.kind === "native-section" || + object.kind === "host" + ) + continue; + let group = groups.get(object.target); + if (group === undefined) { + group = { + encoded: 0n, + decoded: 0n, + row: { + id: `target:${JSON.stringify(object.target)}`, + target: object.target, + confidence: "derived", + object_count: 0, + kernel_count: 0, + encoded_bytes: "0", + decoded_bytes: "0", + kernel_coverage: + report.analysis === "inventory" ? "not-requested" : "complete", + by_kind: { ptx: 0, cubin: 0, amdgpu: 0, unknown: 0 }, + }, + }; + groups.set(object.target, group); + } + group.row.object_count++; + group.row.by_kind[object.kind]++; + group.row.kernel_count += kernelCounts.get(object.id) ?? 0; + group.encoded += BigInt(object.encoded_size); + group.decoded = + group.decoded === null || object.decoded_size === null + ? null + : group.decoded + BigInt(object.decoded_size); + if (report.analysis === "kernels" && object.inspection !== "complete") + group.row.kernel_coverage = "partial"; + } + return [...groups.values()].map(({ row, encoded, decoded }) => ({ + ...row, + encoded_bytes: encoded.toString(), + decoded_bytes: decoded?.toString() ?? null, + })); +}; +type CheckedGpu = { + report: z.infer; + byId: Map>; + summary: z.infer; + targets: GpuTarget[]; +}; +// Evidence owns this immutable snapshot. Collection eviction releases the indexes as well. +const checkedGpu = new WeakMap(); +const readGpuFacts = ( + parent: AnalysisViewParent, +): Result => { + const key = parent.normalizedResult; + const reusable = + parent.sealedResult === true && + key !== null && + typeof key === "object" && + isImmutableJsonSnapshot(key); + let checked = reusable ? checkedGpu.get(key) : undefined; + if (checked === undefined) { + const parsed = parseAnalysisViewResult(gpuCodeObjectsSchema, parent); + if (!parsed.ok) return parsed; + const report = reusable + ? (key as z.infer) + : parsed.value; + const { objects, kernels, ptx_artifacts, ...context } = report; + const targets = new Set( + objects.flatMap((row) => (row.target === null ? [] : [row.target])), + ); + checked = { + report, + targets: summarizeGpuTargets(report), + byId: new Map(objects.map((row) => [row.id, row])), + summary: { + ...context, + counts: { + objects: objects.length, + kernels: kernels.length, + ptx_artifacts: ptx_artifacts.length, + targets: targets.size, + }, + target_preview: [...targets] + .slice(0, 8) + .map((target) => target.slice(0, 256)), + }, + }; + if (reusable) checkedGpu.set(key, checked); + } + if ( + parent.subject != null && + (parent.subject.local_path !== checked.report.artifact.path || + parent.subject.digest.sha256 !== checked.report.artifact.sha256) + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: "GPU Evidence subject does not match its recorded artifact", + }, + ]), + ); + return ok(checked); +}; +export const projectGpuCodeObjectsView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "gpu-code-objects") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: "GPU Evidence supports summary and gpu-code-objects pages", + }, + ]), + ); + const parsed = readGpuFacts(parent); + if (!parsed.ok) return parsed; + const { report, byId, summary, targets: targetRows } = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_gpu_code_objects" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + limitations: boundedViewMessages( + [...parent.limitations, ...report.limitations], + parent.evidenceId, + "limitations", + ), + unknowns: [ + "GPU execution, runtime compatibility and host launch sites were not inspected.", + ...(report.coverage.payloads !== "complete" + ? [ + "Kernel facts are incomplete outside successfully decoded selected payloads; consult object inspection states.", + ] + : []), + ], + }; + if (view.kind === "summary") { + return ok({ + kind: "summary", + view, + ...shared, + summary: { ...summary, limitations: shared.limitations }, + coverage: completeWithinViewCoverage( + report.objects.length, + report.objects.length, + ), + }); + } + if (view.collection === "ptx") { + const artifact = report.ptx_artifacts.find( + (row) => row.object_id === view.object_id, + ); + if (artifact === undefined) + return err( + analysisViewInputError([ + { + path: ["view", "object_id"], + reason: "invalid_value", + message: + "No retained PTX artifact exists for this object; inspect a selected PTX payload with analysis kernels", + }, + ]), + ); + const end = Math.min(artifact.text.length, view.offset + view.limit); + const split = (offset: number): boolean => + offset > 0 && + offset < artifact.text.length && + /[\uD800-\uDBFF]/u.test(artifact.text[offset - 1]!) && + /[\uDC00-\uDFFF]/u.test(artifact.text[offset]!); + if (split(view.offset) || split(end)) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "PTX text page must end and start on Unicode scalar boundaries; offsets use UTF-16 code units", + }, + ]), + ); + const text = artifact.text.slice(view.offset, end); + if (jsonValueByteLength(text, 1024 * 1024) > 1024 * 1024) + return err( + new AnalysisResourceConstraintError( + "inspect_analysis_view", + "memory", + "PTX page exceeds the 1 MiB response budget; lower limit", + { maximum_page_bytes: 1024 * 1024 }, + ), + ); + return ok({ + kind: "gpu-code-objects", + view, + ...shared, + source_coverage: report.coverage, + items: [], + ptx: { + artifact_id: artifact.id, + object_id: artifact.object_id, + sha256: artifact.sha256, + byte_length: artifact.byte_length, + text, + offset: view.offset, + offset_unit: "utf16-code-unit", + }, + coverage: pageViewCoverage( + view.offset, + text.length, + artifact.text.length, + ), + }); + } + const targets = + view.targets === undefined ? undefined : new Set(view.targets); + const ids = view.ids === undefined ? undefined : new Set(view.ids); + const objectIds = + view.collection === "kernels" && view.object_ids !== undefined + ? new Set(view.object_ids) + : undefined; + const names = + view.collection === "kernels" && view.names !== undefined + ? new Set(view.names) + : undefined; + const rows = + view.collection === "objects" + ? report.objects + : view.collection === "targets" + ? targetRows + : report.kernels; + const matches = rows.filter( + (row) => + (ids === undefined || ids.has(row.id)) && + (objectIds === undefined || + ("object_id" in row && objectIds.has(row.object_id))) && + (names === undefined || ("name" in row && names.has(row.name))) && + (targets === undefined || + targets.has( + ("target" in row ? row.target : byId.get(row.object_id)?.target) ?? + "", + )), + ); + const defaults = + view.collection === "kernels" + ? new Set([ + "id", + "object_id", + "name", + "parameter_count", + "parameter_bytes", + "registers", + "shared_bytes", + "private_bytes", + "location", + "limitations", + ]) + : undefined; + const fields = + view.fields === undefined ? defaults : new Set(view.fields); + const items = matches + .slice(view.offset, view.offset + view.limit) + .map((row) => { + const projected: Record = {}; + for (const [key, value] of Object.entries(row)) + if ( + key === "id" || + key === "object_id" || + key === "location" || + key === "metadata_location" || + key === "kind" || + key === "target" || + key === "payload_target" || + key === "target_consistency" || + key === "inspection" || + key === "sources" || + key === "representation" || + (view.collection === "targets" && + (key === "target" || + key === "confidence" || + key === "kernel_coverage")) || + fields === undefined || + fields.has(key) + ) + projected[key] = value; + return projected; + }); + if (jsonValueByteLength(items, 1024 * 1024) > 1024 * 1024) + return err( + new AnalysisResourceConstraintError( + "inspect_analysis_view", + "memory", + "GPU page exceeds the 1 MiB response budget; select fewer fields or rows", + { maximum_page_bytes: 1024 * 1024 }, + ), + ); + return ok({ + kind: "gpu-code-objects", + view, + ...shared, + source_coverage: report.coverage, + items: gpuCodeObjectsItemsSchema.parse(items), + ptx: null, + coverage: pageViewCoverage(view.offset, items.length, matches.length), + }); +}; diff --git a/src/domain/elfFixedHeader.test.ts b/src/domain/elfFixedHeader.test.ts index dedc77ce21..9b1a303d02 100644 --- a/src/domain/elfFixedHeader.test.ts +++ b/src/domain/elfFixedHeader.test.ts @@ -18,6 +18,7 @@ it.each([ byteOrder: data === 1 ? "little" : "big", headerSize: cls === 1 ? 52 : 64, machine: cls === 1 ? 3 : 62, + osAbi: 0, architecture: cls === 1 ? "x86" : "x86_64", }, }); @@ -68,3 +69,16 @@ it("preserves an unknown ELF machine as a fact without rejecting the header", () value: { machine: 0xfffe, architecture: null }, }); }); + +it("accepts NVIDIA's vendor ELF version only for the CUDA machine and OS ABI", () => { + const bytes = elf(2, 1, 190); + bytes.writeUInt32LE(0x81, 20); + expect(readElfFixedHeader(bytes).ok).toBe(false); + bytes[7] = 0x33; + expect(readElfFixedHeader(bytes)).toMatchObject({ + ok: true, + value: { machine: 190 }, + }); + bytes.writeUInt16LE(62, 18); + expect(readElfFixedHeader(bytes).ok).toBe(false); +}); diff --git a/src/domain/elfFixedHeader.ts b/src/domain/elfFixedHeader.ts index c91eeb0ce1..38ae7c01da 100644 --- a/src/domain/elfFixedHeader.ts +++ b/src/domain/elfFixedHeader.ts @@ -9,6 +9,7 @@ export interface ElfFixedHeader { readonly byteOrder: "little" | "big"; readonly headerSize: number; readonly machine: number; + readonly osAbi: number; readonly architecture: Exclude< BinaryArchitecture, RawOnlyArchitecture @@ -52,16 +53,19 @@ export const readElfFixedHeader = ( littleEndian ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset); const readU32 = (offset: number): number => littleEndian ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); - if (readU32(20) !== 1) return err("unsupported ELF header version"); + const machine = readU16(18); + // NVIDIA CUBIN uses ELFOSABI_CUDA and a vendor ABI version in e_version. + if (readU32(20) !== 1 && !(machine === 190 && bytes[7] === 0x33)) + return err("unsupported ELF header version"); const headerSize = readU16(elfClass === 32 ? 40 : 52); if (headerSize < minimumSize || headerSize > totalBytes) return err("invalid ELF header size"); - const machine = readU16(18); return ok({ elfClass, byteOrder: littleEndian ? "little" : "big", headerSize, machine, + osAbi: bytes[7]!, architecture: elfArchitecture(machine) ?? null, }); }; diff --git a/src/domain/gpu/gpuCodeObjects.test.ts b/src/domain/gpu/gpuCodeObjects.test.ts new file mode 100644 index 0000000000..4ad9eca74d --- /dev/null +++ b/src/domain/gpu/gpuCodeObjects.test.ts @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { gpuPtxArtifactSchema } from "./gpuCodeObjects.js"; +it("binds retained PTX identity to its actual UTF-8 bytes and source extent", () => { + const text = "\uFEFF// 雪🧪\n.version 8.8\n", + size = Buffer.byteLength(text); + const artifact = { + id: "ptx", + object_id: "object", + sha256: createHash("sha256").update(text).digest("hex"), + media_type: "text/x-ptx", + encoding: "utf-8", + byte_length: size, + text, + location: { + container_id: "object", + offset: 0, + size, + section_index: null, + section_name: null, + }, + }; + expect(gpuPtxArtifactSchema.safeParse(artifact).success).toBe(true); + for (const patch of [ + { sha256: "0".repeat(64) }, + { text: text + "changed" }, + { byte_length: size + 1 }, + { location: { ...artifact.location, size: size + 1 } }, + { text: "\ud800" }, + ]) + expect( + gpuPtxArtifactSchema.safeParse({ ...artifact, ...patch }).success, + ).toBe(false); +}); diff --git a/src/domain/gpu/gpuCodeObjects.ts b/src/domain/gpu/gpuCodeObjects.ts new file mode 100644 index 0000000000..af216fa0a9 --- /dev/null +++ b/src/domain/gpu/gpuCodeObjects.ts @@ -0,0 +1,391 @@ +import { z } from "zod"; +import { createHash } from "node:crypto"; +import { digestSchema } from "../digests.js"; +import { + absoluteLocalPathStringSchema, + localPathStringSchema, +} from "../localPath.js"; +const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +export const gpuLocationSchema = z.strictObject({ + container_id: z.string().nullable(), + offset: count, + size: count, + section_index: count.nullable(), + section_name: z.string().nullable(), +}); +export const gpuObjectSchema = z.strictObject({ + id: z.string(), + parent_id: z.string().nullable(), + kind: z.enum([ + "cuda-fatbin", + "hip-bundle", + "native-section", + "ptx", + "cubin", + "amdgpu", + "host", + "unknown", + ]), + target: z.string().nullable(), + payload_target: z.string().nullable(), + cuda_target_header: z + .strictObject({ + os_abi: z.number().int().min(0).max(255), + flags: z.string().regex(/^0x[0-9a-f]{8}$/u), + location: gpuLocationSchema, + }) + .nullable(), + target_consistency: z.enum([ + "unexamined", + "single-source", + "consistent", + "base-only", + "conflict", + "unknown", + ]), + bundle_id: z.string().nullable(), + compression: z.enum(["none", "zstd", "zlib", "lz4", "unsupported"]), + compression_id: z.string().nullable(), + encoded_size: count, + decoded_size: count.nullable(), + sha256: digestSchema.nullable(), + raw_flags: z.string().nullable(), + location: gpuLocationSchema, + metadata_location: gpuLocationSchema, + inspection: z.enum([ + "inventory", + "complete", + "partial", + "not-selected", + "unsupported", + "malformed", + "capacity", + ]), + limitations: z.array(z.string()), +}); +export const gpuParameterSchema = z.strictObject({ + ordinal: count, + name: z.string().nullable(), + offset: count.nullable(), + size: count.nullable(), + type: z.string().nullable(), + hidden: z.boolean().nullable(), +}); +export const gpuKernelSchema = z.strictObject({ + id: z.string(), + object_id: z.string(), + representation: z.enum([ + "ptx-declarations", + "cuda-elf-metadata", + "amdgpu-metadata", + ]), + name: z.string(), + symbol: z.string().nullable(), + parameter_count: count.nullable(), + parameter_bytes: count.nullable(), + parameters: z.array(gpuParameterSchema), + registers: z.strictObject({ + physical: count.nullable(), + sgpr: count.nullable(), + vgpr: count.nullable(), + agpr: count.nullable(), + virtual: z.array( + z.strictObject({ type: z.string(), count: count.nullable() }), + ), + }), + shared_bytes: count + .nullable() + .describe( + "PTX: entry-local declared static bytes; CUDA ELF: allocated section bytes; AMDGPU: group_segment_fixed_size. Runtime dynamic/module shared allocations are not established by PTX.", + ), + private_bytes: count.nullable(), + location: gpuLocationSchema, + sources: z.strictObject({ + parameters: gpuLocationSchema.nullable(), + registers: gpuLocationSchema.nullable(), + shared: gpuLocationSchema.nullable(), + private: gpuLocationSchema.nullable(), + }), + limitations: z.array(z.string()), +}); +export const gpuPtxArtifactSchema = z + .strictObject({ + id: z.string(), + object_id: z.string(), + sha256: digestSchema, + media_type: z.literal("text/x-ptx"), + encoding: z.literal("utf-8"), + byte_length: count, + text: z.string(), + location: gpuLocationSchema, + }) + .superRefine((artifact, context) => { + if ( + /[\uD800-\uDFFF]/u.test(artifact.text) || + Buffer.byteLength(artifact.text, "utf8") !== artifact.byte_length || + artifact.location.size !== artifact.byte_length + ) + context.addIssue({ + code: "custom", + path: ["byte_length"], + message: "PTX text and its UTF-8 source extent disagree", + }); + if ( + createHash("sha256").update(artifact.text, "utf8").digest("hex") !== + artifact.sha256 + ) + context.addIssue({ + code: "custom", + path: ["sha256"], + message: "PTX digest does not match retained UTF-8 text", + }); + }); +export const gpuCodeObjectsObjectSchema = z.strictObject({ + artifact: z.strictObject({ + path: localPathStringSchema, + file_size: count, + sha256: digestSchema, + bytes_read: count, + }), + format: z.enum(["elf", "pe", "cuda-fatbin", "hip-bundle", "ptx"]), + analysis: z.enum(["inventory", "kernels"]), + objects: z.array(gpuObjectSchema), + kernels: z.array(gpuKernelSchema), + ptx_artifacts: z.array(gpuPtxArtifactSchema), + coverage: z.strictObject({ + discovery_scope: z.literal( + "supported-section-names-and-standalone-formats", + ), + discovery: z.enum(["complete", "partial"]), + payloads: z.enum(["not-requested", "complete", "partial"]), + examined_sections: count, + unknown_section_names: count, + }), + limitations: z.array(z.string()), +}); +/** Validate local identities and coordinate spaces once before consumers build lookup indexes. */ +export const gpuCodeObjectsSchema = gpuCodeObjectsObjectSchema.superRefine( + (report, context) => { + const objects = new Map(); + const issue = (path: (string | number)[], message: string): void => + context.addIssue({ code: "custom", path, message }); + for (let index = 0; index < report.objects.length; index++) { + const row = report.objects[index]!; + if (objects.has(row.id)) + issue(["objects", index, "id"], "GPU object IDs must be unique"); + if (row.parent_id !== null && !objects.has(row.parent_id)) + issue( + ["objects", index, "parent_id"], + "GPU parents must precede their children", + ); + if ( + row.location.container_id !== row.parent_id || + row.metadata_location.container_id !== row.parent_id + ) + issue( + ["objects", index, "location"], + "GPU object ranges must use their parent's coordinate space", + ); + objects.set(row.id, row); + } + const range = (value: GpuLocation, path: (string | number)[]): void => { + const size = + value.container_id === null + ? report.artifact.file_size + : objects.get(value.container_id)?.decoded_size; + if ( + size === undefined || + (size !== null && + (value.offset > size || value.size > size - value.offset)) + ) + issue( + path, + "GPU source range is outside its declared coordinate space", + ); + }; + for (let index = 0; index < report.objects.length; index++) { + const row = report.objects[index]!; + range(row.location, ["objects", index, "location"]); + range(row.metadata_location, ["objects", index, "metadata_location"]); + const header = row.cuda_target_header; + if (header !== null) { + range(header.location, [ + "objects", + index, + "cuda_target_header", + "location", + ]); + if ( + row.kind !== "cubin" || + ![52, 64].includes(header.location.size) || + (header.location.container_id === row.id + ? header.location.offset !== 0 + : header.location.container_id !== row.parent_id || + header.location.offset !== row.location.offset) + ) + issue( + ["objects", index, "cuda_target_header"], + "CUDA header provenance must start at its CUBIN object", + ); + } + } + const kernelIds = new Set(); + for (let index = 0; index < report.kernels.length; index++) { + const row = report.kernels[index]!; + if (kernelIds.has(row.id)) + issue(["kernels", index, "id"], "GPU kernel IDs must be unique"); + kernelIds.add(row.id); + if ( + !objects.has(row.object_id) || + row.location.container_id !== row.object_id + ) + issue( + ["kernels", index, "object_id"], + "GPU kernels require an inventoried object and its coordinate space", + ); + range(row.location, ["kernels", index, "location"]); + for (const [field, source] of Object.entries(row.sources)) + if (source !== null) + range(source, ["kernels", index, "sources", field]); + } + const artifactIds = new Set(); + for (let index = 0; index < report.ptx_artifacts.length; index++) { + const row = report.ptx_artifacts[index]!; + if (artifactIds.has(row.id)) + issue( + ["ptx_artifacts", index, "id"], + "PTX artifact IDs must be unique", + ); + artifactIds.add(row.id); + if ( + objects.get(row.object_id)?.kind !== "ptx" || + row.location.container_id !== row.object_id + ) + issue( + ["ptx_artifacts", index, "object_id"], + "PTX artifacts require an inventoried PTX object", + ); + range(row.location, ["ptx_artifacts", index, "location"]); + } + }, +); +/** Interrupted observations retain facts without claiming final source identity or completeness. */ +export const gpuCodeObjectsPartialSchema = z.strictObject({ + kind: z.literal("gpu-code-objects-partial"), + artifact: z.strictObject({ + path: localPathStringSchema, + observed_file_size: count, + sha256: z.null(), + file_identity: z.literal("unverified"), + }), + analysis: z.enum(["inventory", "kernels"]), + objects: z.array(gpuObjectSchema), + kernels: z.array(gpuKernelSchema), + ptx_artifacts: z.array(gpuPtxArtifactSchema), + coverage: z.strictObject({ + discovery: z.literal("partial"), + payloads: z.literal("partial"), + }), + limitations: z.array(z.string()), +}); +export type GpuCodeObjectsPartial = z.infer; +export const gpuTargetSchema = z.strictObject({ + id: z.string(), + target: z.string().nullable(), + confidence: z.literal("derived"), + object_count: count, + kernel_count: count, + encoded_bytes: z.string().regex(/^(?:0|[1-9][0-9]*)$/u), + decoded_bytes: z + .string() + .regex(/^(?:0|[1-9][0-9]*)$/u) + .nullable(), + kernel_coverage: z.enum(["not-requested", "complete", "partial"]), + by_kind: z.strictObject({ + ptx: count, + cubin: count, + amdgpu: count, + unknown: count, + }), +}); +export type GpuTarget = z.infer; +const selection = { + ids: z.array(z.string()).optional(), + targets: z.array(z.string()).optional(), + offset: count.default(0), + limit: z.number().int().positive().default(32), +}; +export const gpuCodeObjectsSelectionSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...selection, + collection: z.literal("objects"), + fields: z.array(gpuObjectSchema.keyof()).optional(), + }), + z.strictObject({ + ...selection, + collection: z.literal("kernels"), + object_ids: z.array(z.string()).optional(), + names: z.array(z.string()).optional(), + fields: z.array(gpuKernelSchema.keyof()).optional(), + }), + z.strictObject({ + collection: z.literal("ptx"), + object_id: z.string(), + offset: count.default(0), + limit: z.number().int().positive().default(4096), + }), + z.strictObject({ + ...selection, + collection: z.literal("targets"), + fields: z.array(gpuTargetSchema.keyof()).optional(), + }), + ], +); +export const inspectGpuCodeObjectsInputSchema = z + .strictObject({ + path: absoluteLocalPathStringSchema, + analysis: z.enum(["inventory", "kernels"]).default("inventory"), + expected_sha256: digestSchema + .optional() + .describe( + "Require the current full-file digest; mandatory when selecting inventory object_ids", + ), + object_ids: z + .array(z.string()) + .optional() + .describe( + "Decode only these inventory IDs; IDs are scoped to the artifact SHA-256", + ), + targets: z + .array(z.string()) + .optional() + .describe( + "Decode only exact target declarations, preserving all inventory rows", + ), + detail: z.enum(["summary", "complete"]).default("summary"), + view: gpuCodeObjectsSelectionSchema.optional(), + }) + .superRefine((input, context) => { + if (input.object_ids !== undefined && input.expected_sha256 === undefined) + context.addIssue({ + code: "custom", + path: ["expected_sha256"], + message: "object_ids require the artifact SHA-256 from their inventory", + }); + if (input.view !== undefined && input.detail === "complete") + context.addIssue({ + code: "custom", + path: ["view"], + message: "view requires summary detail", + }); + }); +export type GpuCodeObjects = z.infer; +export type GpuObject = z.infer; +export type GpuKernel = z.infer; +export type GpuLocation = z.infer; +export type GpuCodeObjectsInput = Pick< + z.infer, + "path" | "analysis" | "object_ids" | "targets" | "expected_sha256" +>; diff --git a/src/gpu/AmdGpuMetadata.test.ts b/src/gpu/AmdGpuMetadata.test.ts new file mode 100644 index 0000000000..b1d4740530 --- /dev/null +++ b/src/gpu/AmdGpuMetadata.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; +import { encode } from "@msgpack/msgpack"; +import { readAmdMetadata } from "./AmdGpuMetadata.js"; +it.each(["\uFEFFkernel", "\uFEFF" + "kernel".repeat(100)])( + "preserves leading BOMs in MessagePack strings %s", + (name) => { + expect(readAmdMetadata(Buffer.from(encode({ name })))).toEqual({ name }); + }, +); +it("preserves raw string tags separately from binary values", () => { + const binary = Uint8Array.from([0x61, 0x62]); + const read = readAmdMetadata( + Buffer.from(encode({ text: "ab", binary })), + ) as Record; + expect(read.text).toBe("ab"); + expect(read.binary).toBeInstanceOf(Uint8Array); + expect(Array.from(read.binary as Uint8Array)).toEqual(Array.from(binary)); +}); +it("rejects duplicate map keys and malformed UTF-8 before semantic decoding", () => { + expect(() => readAmdMetadata(Buffer.from("82a16101a16102", "hex"))).toThrow( + /duplicate/u, + ); + expect(() => readAmdMetadata(Buffer.from("81a161a1ff", "hex"))).toThrow( + /UTF-8/u, + ); +}); +it("refuses declared container expansion before allocation", () => { + expect(() => readAmdMetadata(Buffer.from("ddffffffff", "hex"))).toThrow( + /allocation/u, + ); +}); +it("preserves long leading-BOM map keys through the public decoder extension point", () => { + const key = "\uFEFF" + "field".repeat(60), + object = { [key]: 123 }; + expect(readAmdMetadata(Buffer.from(encode(object)))).toEqual(object); +}); +it("distinguishes an unsupported extension tag from a forbidden MessagePack token", () => { + expect(() => readAmdMetadata(Buffer.from("d40000", "hex"))).toThrow( + expect.objectContaining({ kind: "unsupported" }), + ); + expect(() => readAmdMetadata(Buffer.from("c1", "hex"))).toThrow( + expect.objectContaining({ kind: "malformed" }), + ); +}); diff --git a/src/gpu/AmdGpuMetadata.ts b/src/gpu/AmdGpuMetadata.ts new file mode 100644 index 0000000000..6baeb6fcd8 --- /dev/null +++ b/src/gpu/AmdGpuMetadata.ts @@ -0,0 +1,187 @@ +import { decode } from "@msgpack/msgpack"; +import { + BinaryResourceFailure, + BinaryFormatFailure, +} from "../native/metadata/BinaryContainer.js"; +import { rejectGpu } from "./GpuSource.js"; + +export const AMD_METADATA_BYTES = 8 * 1024 * 1024; + +/** Validate allocation and nesting costs before the standard decoder allocates containers. */ +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const preflight = (bytes: Buffer): Set => { + const strings = new Set(); + let at = 0, + values = 0; + const take = (size: number): number => { + const start = at; + if (size > bytes.length - at) + rejectGpu("Truncated AMDGPU MessagePack metadata"); + at += size; + return start; + }; + const value = (depth: number): string | undefined => { + if (depth > 32 || ++values > 262144) + throw new BinaryResourceFailure( + "structure", + 262144, + "AMDGPU metadata exceeds depth 32 or 262144 decoded values", + ); + const code = bytes[take(1)]!; + const string = + (code & 0xe0) === 0xa0 || code === 0xd9 || code === 0xda || code === 0xdb; + const map = (code & 0xf0) === 0x80 || code === 0xde || code === 0xdf; + let count = 0, + payload = 0; + if ( + code <= 0x7f || + code >= 0xe0 || + code === 0xc0 || + code === 0xc2 || + code === 0xc3 + ) + return; + if ((code & 0xe0) === 0xa0) payload = code & 31; + else if ((code & 0xf0) === 0x90) count = code & 15; + else if ((code & 0xf0) === 0x80) count = (code & 15) * 2; + else if (code === 0xdc || code === 0xde) + count = bytes.readUInt16BE(take(2)) * (code === 0xde ? 2 : 1); + else if (code === 0xdd || code === 0xdf) + count = bytes.readUInt32BE(take(4)) * (code === 0xdf ? 2 : 1); + else if (code === 0xd9 || code === 0xc4) payload = bytes[take(1)]!; + else if (code === 0xda || code === 0xc5) + payload = bytes.readUInt16BE(take(2)); + else if (code === 0xdb || code === 0xc6) + payload = bytes.readUInt32BE(take(4)); + else { + const widths: Readonly> = { + 0xca: 4, + 0xcb: 8, + 0xcc: 1, + 0xcd: 2, + 0xce: 4, + 0xcf: 8, + 0xd0: 1, + 0xd1: 2, + 0xd2: 4, + 0xd3: 8, + }; + const width = widths[code]; + if (width === undefined) { + if ([0xc7, 0xc8, 0xc9, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8].includes(code)) + throw new BinaryFormatFailure( + "unsupported", + "AMDGPU MessagePack extensions are outside metadata coverage", + ); + rejectGpu("Reserved AMDGPU MessagePack token"); + } + payload = width!; + } + if (count > 262144 - values || payload > 65536) + throw new BinaryResourceFailure( + "structure", + 262144, + "AMDGPU metadata container/string exceeds its allocation budget", + ); + const start = take(payload); + if (string) { + strings.add(`${bytes.byteOffset + start}:${payload}`); + try { + return utf8.decode(bytes.subarray(start, start + payload)); + } catch { + return rejectGpu("AMDGPU metadata contains invalid UTF-8"); + } + } + if (map) { + const keys = new Set(); + for (let index = 0; index < count; index += 2) { + const key = value(depth + 1); + if (key === undefined || keys.has(key)) + rejectGpu("AMDGPU metadata map has non-string or duplicate keys"); + keys.add(key!); + value(depth + 1); + } + } else for (let index = 0; index < count; index++) value(depth + 1); + return undefined; + }; + value(0); + if (at !== bytes.length) rejectGpu("Trailing AMDGPU MessagePack values"); + return strings; +}; +export const readAmdMetadata = (bytes: Buffer): unknown => { + if (bytes.length > AMD_METADATA_BYTES) + throw new BinaryResourceFailure( + "structure", + AMD_METADATA_BYTES, + "AMDGPU metadata exceeds 8 MiB", + ); + const strings = preflight(bytes); + const stringValue = (value: unknown): string => { + if ( + !(value instanceof Uint8Array) || + value.buffer !== bytes.buffer || + !strings.has(`${value.byteOffset}:${value.byteLength}`) + ) + return rejectGpu("AMDGPU metadata map key is not a string"); + return utf8.decode(value); + }; + // rawStrings avoids the standard decoder's native UTF-8 path stripping a + // leading BOM. The preflight retains original type tags, so binary values + // cannot accidentally become strings during this bounded in-place walk. + const normalize = (value: unknown): unknown => { + if (value instanceof Uint8Array) + return value.buffer === bytes.buffer && + strings.has(`${value.byteOffset}:${value.byteLength}`) + ? stringValue(value) + : value; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) + value[index] = normalize(value[index]); + } else if (value !== null && typeof value === "object") + for (const key of Object.keys(value)) { + const record = value as Record; + record[key] = normalize(record[key]); + } + return value; + }; + try { + return normalize( + decode(bytes, { + rawStrings: true, + keyDecoder: { + canBeCached: () => true, + decode: (source, offset, length) => + utf8.decode(source.subarray(offset, offset + length)), + }, + mapKeyConverter: (key) => { + if (typeof key !== "string") + return rejectGpu("AMDGPU metadata map key is not a string"); + return key; + }, + useBigInt64: true, + maxStrLength: 65536, + maxBinLength: 65536, + maxArrayLength: 65536, + maxMapLength: 65536, + maxExtLength: 0, + }), + ); + } catch { + return rejectGpu("Malformed AMDGPU MessagePack metadata"); + } +}; +export const amdRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +export const amdCount = (value: unknown): number | null => { + if (typeof value === "bigint") + return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : null; + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : null; +}; +export const amdString = (value: unknown): string | null => + typeof value === "string" ? value : null; diff --git a/src/gpu/ClangOffloadBundle.ts b/src/gpu/ClangOffloadBundle.ts new file mode 100644 index 0000000000..fdac9390da --- /dev/null +++ b/src/gpu/ClangOffloadBundle.ts @@ -0,0 +1,115 @@ +import { scheduler } from "node:timers/promises"; +import type { GpuObject } from "../domain/gpu/gpuCodeObjects.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { + gpuCount, + GPU_REPORT_BYTES, + rejectGpu, + type GpuSource, +} from "./GpuSource.js"; +import { gpuObject } from "./GpuObject.js"; +export const CLANG_BUNDLE_MAGIC = Buffer.from("__CLANG_OFFLOAD_BUNDLE__"); +export const readClangBundleDirectory = async ( + source: GpuSource, + base: number, + signal?: AbortSignal, +): Promise<{ objects: GpuObject[]; end: number }> => { + const header = await source.read(base, 32), + count = gpuCount(header.readBigUInt64LE(24)); + if (!header.subarray(0, 24).equals(CLANG_BUNDLE_MAGIC)) + rejectGpu("Invalid Clang offload bundle magic"); + if (count > 65536) + throw new BinaryResourceFailure( + "structure", + 65536, + "HIP bundle entry count exceeds the inventory budget", + ); + const objects: GpuObject[] = []; + let position = base + 32; + for (let index = 0; index < count; index++) { + if (index % 256 === 0) { + await scheduler.yield(); + assertArtifactReadActive(signal); + } + const h = await source.read(position, 24), + offset = gpuCount(BigInt(base) + h.readBigUInt64LE(0)), + size = gpuCount(h.readBigUInt64LE(8)), + nameSize = gpuCount(h.readBigUInt64LE(16)); + if (nameSize > 65536 || position - base + 24 + nameSize > GPU_REPORT_BYTES) + throw new BinaryResourceFailure( + "structure", + GPU_REPORT_BYTES, + "HIP directory exceeds 16 MiB or its bundle ID exceeds 65536 bytes", + ); + let name: string; + try { + name = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + await source.read(position + 24, nameSize), + ); + } catch (cause) { + if (cause instanceof TypeError) + return rejectGpu("HIP bundle ID is not valid UTF-8"); + throw cause; + } + if (offset > source.size || size > source.size - offset) + rejectGpu("HIP entry range is outside its bundle"); + const row = gpuObject(source, { + offset, + size, + kind: name.startsWith("host-") + ? "host" + : /^(?:hip|hipv4)-amdgcn-/u.test(name) + ? "amdgpu" + : "unknown", + metadata: { offset: position, size: 24 + nameSize }, + }); + row.bundle_id = name; + row.target = + /-(?:-)?(gfx[a-zA-Z0-9]+(?::[^\s]+)?)$/u.exec(name)?.[1] ?? null; + objects.push(row); + position += 24 + nameSize; + } + let end = position; + for (const row of objects) { + if (row.encoded_size === 0) continue; + if (row.location.offset < position) + rejectGpu("HIP entry overlaps its bundle directory"); + end = Math.max(end, row.location.offset + row.encoded_size); + } + return { objects, end }; +}; +export const readCcobHeader = (bytes: Buffer, remaining: number) => { + if (bytes.length < 20) rejectGpu("CCOB header is truncated"); + const version = bytes.readUInt16LE(4), + method = bytes.readUInt16LE(6); + let width: number, total: number, decoded: number, hash: bigint; + if (version === 1) { + width = 20; + total = remaining; + decoded = bytes.readUInt32LE(8); + hash = bytes.readBigUInt64LE(12); + } else if (version === 2) { + width = 24; + if (bytes.length < width) rejectGpu("CCOB v2 header is truncated"); + total = bytes.readUInt32LE(8); + decoded = bytes.readUInt32LE(12); + hash = bytes.readBigUInt64LE(16); + } else if (version === 3) { + width = 32; + if (bytes.length < width) rejectGpu("CCOB v3 header is truncated"); + total = gpuCount(bytes.readBigUInt64LE(8)); + decoded = gpuCount(bytes.readBigUInt64LE(16)); + hash = bytes.readBigUInt64LE(24); + } else + throw new BinaryFormatFailure( + "unsupported", + `Unsupported CCOB version ${version}`, + ); + if (total < width || total > remaining) + rejectGpu("CCOB declared extent is invalid"); + return { version, method, width, total, decoded, hash }; +}; diff --git a/src/gpu/CudaFatbinary.ts b/src/gpu/CudaFatbinary.ts new file mode 100644 index 0000000000..f64d6f6569 --- /dev/null +++ b/src/gpu/CudaFatbinary.ts @@ -0,0 +1,81 @@ +import type { GpuObject } from "../domain/gpu/gpuCodeObjects.js"; +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { + gpuCount, + nextGpuContainerOffset, + rejectGpu, + type GpuSource, +} from "./GpuSource.js"; +import { gpuObject } from "./GpuObject.js"; +export async function* readCudaFatbinary( + source: GpuSource, + signal?: AbortSignal, +): AsyncGenerator { + let base = 0; + while (base < source.size) { + const header = await source.read(base, 16); + if (header.readUInt32LE(0) !== 0xba55ed50) + rejectGpu("Unexpected bytes between CUDA fatbinary records"); + if (header.readUInt16LE(4) !== 1 || header.readUInt16LE(6) < 16) + rejectGpu("Unsupported CUDA fatbinary header"); + const start = base + header.readUInt16LE(6), + length = gpuCount(header.readBigUInt64LE(8)); + if (start > source.size || length > source.size - start) + rejectGpu("CUDA fatbinary extent is truncated"); + const end = start + length; + for (let position = start; position < end;) { + assertArtifactReadActive(signal); + if (end - position < 64) rejectGpu("CUDA entry header is truncated"); + const h = await source.read(position, 64); + const kind = h.readUInt16LE(0), + version = h.readUInt16LE(2), + width = h.readUInt32LE(4); + const padded = gpuCount(h.readBigUInt64LE(8)), + encoded = h.readUInt32LE(16), + flags = h.readBigUInt64LE(40); + if ( + width < 64 || + width > end - position || + padded > end - position - width || + encoded > padded + ) + rejectGpu("CUDA entry extent is invalid"); + const compressed = (flags & 0xa000n) !== 0n; + const row = gpuObject(source, { + offset: position + width, + size: compressed ? encoded : padded, + kind: kind === 1 ? "ptx" : kind === 2 ? "cubin" : "unknown", + metadata: { offset: position, size: width }, + }); + row.target = `sm_${h.readUInt32LE(28)}${(flags & 0x100000n) !== 0n ? "a" : ""}`; + row.raw_flags = `0x${flags.toString(16)}`; + row.compression = + (flags & 0x8000n) !== 0n && (flags & 0x2000n) === 0n + ? "zstd" + : (flags & 0x2000n) !== 0n && (flags & 0x8000n) === 0n + ? "lz4" + : compressed + ? "unsupported" + : "none"; + row.compression_id = compressed ? `cuda-flags:${row.raw_flags}` : null; + row.decoded_size = compressed ? gpuCount(h.readBigUInt64LE(56)) : padded; + if (compressed && (encoded === 0 || row.decoded_size === 0)) + rejectGpu("Compressed CUDA entry has an empty declared extent"); + if (version !== 0x101) { + row.compression = "unsupported"; + row.target = null; + row.decoded_size = null; + row.limitations.push( + `CUDA entry version 0x${version.toString(16)} is not decoded`, + ); + } + if (row.compression === "unsupported") + row.limitations.push( + "This CUDA compression/version is not supported; original encoded bytes and target declarations remain located", + ); + yield row; + position += width + padded; + } + base = await nextGpuContainerOffset(source, end); + } +} diff --git a/src/gpu/CudaInfo.test.ts b/src/gpu/CudaInfo.test.ts new file mode 100644 index 0000000000..55c402110d --- /dev/null +++ b/src/gpu/CudaInfo.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import { readCudaInfo, readCudaParameters } from "./CudaInfo.js"; +it("retains conflicting parameter-area declarations as an unknown aggregate", () => { + const read = readCudaParameters(Buffer.from("03190c0003191000", "hex"), true); + expect(read.parameter_bytes).toBeNull(); + expect(read.parameter_count).toBeNull(); + expect(read.limitations.join(" ")).toContain("12, 16"); +}); +it("retains observed argument ranges while reporting inconsistent area declarations", () => { + const read = readCudaParameters( + Buffer.from("0319040004170c00000000000000000000f02100", "hex"), + true, + ); + expect(read).toMatchObject({ + parameter_count: 1, + parameter_bytes: 4, + parameters: [{ offset: 0, size: 8 }], + }); + expect(read.limitations.join(" ")).toContain("disagree"); +}); +it("bounds dense numeric record streams without retaining a record array", () => { + const bytes = Buffer.alloc(4 * 262145); + for (let offset = 0; offset < bytes.length; offset += 4) bytes[offset] = 1; + expect(() => { + for (const record of readCudaInfo(bytes, true)) { + if (record.format !== 1) throw new Error("unexpected source format"); + } + }).toThrow(/aggregate record\/symbol visits/u); +}); diff --git a/src/gpu/CudaInfo.ts b/src/gpu/CudaInfo.ts new file mode 100644 index 0000000000..74a3128b8e --- /dev/null +++ b/src/gpu/CudaInfo.ts @@ -0,0 +1,142 @@ +import { + BinaryReader, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; +import { GPU_OBJECT_BYTES, GPU_REPORT_BYTES, rejectGpu } from "./GpuSource.js"; +import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +/** Shared by every metadata record, symbol and temporary index in one CUDA ELF. */ +export class CudaMetadataBudget { + #visits = 0; + #readBytes = 0; + #indexBytes = 0; + visit(): void { + if (++this.#visits > 262144) + throw new BinaryResourceFailure( + "structure", + 262144, + "CUDA metadata exceeds 262144 aggregate record/symbol visits", + ); + } + read(size: number): void { + if (size > GPU_OBJECT_BYTES - this.#readBytes) + throw new BinaryResourceFailure( + "structure", + GPU_OBJECT_BYTES, + "CUDA metadata exceeds 128 MiB aggregate section reads", + ); + this.#readBytes += size; + } + retain(value: JsonValue): void { + const size = jsonValueByteLength(value, GPU_REPORT_BYTES) + 2; + if (size > GPU_REPORT_BYTES - this.#indexBytes) + throw new BinaryResourceFailure( + "structure", + GPU_REPORT_BYTES, + "CUDA metadata exceeds the 16 MiB temporary index budget", + ); + this.#indexBytes += size; + } +} +export interface CudaInfoRecord { + readonly format: number; + readonly attribute: number; + readonly value: number; + readonly offset: number; + readonly length: number; + readonly data: Buffer; +} +/** CUDA nv.info is a numeric record format, not a string-keyed metadata map. */ +export function* readCudaInfo( + bytes: Buffer, + little: boolean, + budget = new CudaMetadataBudget(), +): Generator { + const reader = new BinaryReader(bytes, little); + for (let offset = 0; offset < bytes.length;) { + budget.visit(); + const head = reader.range(offset, 4, "CUDA nv.info record"); + const format = head[0]!, + attribute = head[1]!, + value = reader.u16(offset + 2); + if (format < 1 || format > 4) + rejectGpu("Unknown CUDA nv.info record format"); + const length = format === 4 ? value : 0; + yield { + format, + attribute, + value, + offset, + length: 4 + length, + data: reader.range(offset + 4, length, "CUDA nv.info value"), + }; + offset += 4 + length; + } +} + +/** Kernel argument interpretation owns duplicate and range semantics at the numeric-record boundary. */ +export const readCudaParameters = ( + bytes: Buffer, + little: boolean, + budget = new CudaMetadataBudget(), +) => { + const parameters: import("../domain/gpu/gpuCodeObjects.js").GpuKernel["parameters"] = + []; + const byteDeclarations = new Set(); + const limitations: string[] = []; + let complete = true; + for (const record of readCudaInfo(bytes, little, budget)) { + if (record.format === 3 && record.attribute === 0x19) + byteDeclarations.add(record.value); + if (record.format !== 4 || record.attribute !== 0x17) continue; + if (record.data.length !== 12) { + complete = false; + continue; + } + const r = new BinaryReader(record.data, little); + if (r.u32(0) !== 0) complete = false; + const parameter = { + ordinal: r.u16(4), + name: null, + offset: r.u16(6), + size: r.u32(8) >>> 18, + type: null, + hidden: null, + }; + budget.retain(parameter); + parameters.push(parameter); + } + parameters.sort((a, b) => a.ordinal - b.ordinal); + if (parameters.some((row, index) => row.ordinal !== index)) complete = false; + const parameter_bytes = + byteDeclarations.size === 1 ? [...byteDeclarations][0]! : null; + if (byteDeclarations.size > 1) + limitations.push( + `Conflicting CUDA argument-area byte declarations: ${[...byteDeclarations].join(", ")}; parameter_bytes remains unknown`, + ); + if ( + parameter_bytes !== null && + parameters.some( + (row) => + row.offset! > parameter_bytes || + row.size! > parameter_bytes - row.offset!, + ) + ) + limitations.push( + "CUDA parameter ranges disagree with the declared argument-area length; declarations do not establish a valid ABI layout", + ); + if (!complete) + limitations.push( + "CUDA parameter records have unsupported indices/sizes, duplicate ordinals or gaps; parameter count is unknown", + ); + return { + parameter_count: + complete && (parameters.length > 0 || parameter_bytes === 0) + ? parameters.length + : null, + parameter_bytes, + parameters, + limitations, + }; +}; diff --git a/src/gpu/CudaTarget.ts b/src/gpu/CudaTarget.ts new file mode 100644 index 0000000000..b8f24d231e --- /dev/null +++ b/src/gpu/CudaTarget.ts @@ -0,0 +1,46 @@ +import type { NativeSectionDirectory } from "../native/metadata/NativeSectionDirectory.js"; +import { gpuLocation, type GpuPayloadTarget } from "./GpuObject.js"; +import { rejectGpu, type GpuSource } from "./GpuSource.js"; + +/** ABI-qualified e_flags layouts, checked against NVIDIA producer/cuobjdump oracles. */ +export const readCudaTarget = ( + source: GpuSource, + directory: NativeSectionDirectory, +): GpuPayloadTarget => { + if ( + directory.format !== "elf" || + directory.machine !== 190 || + directory.elf_os_abi === null + ) + return rejectGpu("CUDA target declaration requires a CUDA ELF header"); + const flags = directory.flags; + const legacy = directory.elf_os_abi === 0x33; + const newer = + directory.elf_os_abi === 0x41 && + directory.bits === 64 && + directory.little && + (flags & 0x00ff00ff) === 2; + const architecture = legacy + ? flags & 0xff + : newer + ? (flags >>> 8) & 0xff + : null; + const value = + architecture !== null && architecture >= 10 ? `sm_${architecture}` : null; + const cudaHeader = { + os_abi: directory.elf_os_abi, + flags: `0x${flags.toString(16).padStart(8, "0")}`, + location: gpuLocation(source, 0, directory.bits === 64 ? 64 : 52), + }; + return { + value, + key: value, + scope: "cuda-base", + cudaHeader, + ...(value === null + ? { + limitation: `CUDA ELF target encoding is unknown for OS ABI 0x${directory.elf_os_abi?.toString(16)} and flags ${cudaHeader.flags}; the raw header remains available.`, + } + : {}), + }; +}; diff --git a/src/gpu/GpuCodeObjectsProvider.ts b/src/gpu/GpuCodeObjectsProvider.ts new file mode 100644 index 0000000000..b2107a281a --- /dev/null +++ b/src/gpu/GpuCodeObjectsProvider.ts @@ -0,0 +1,334 @@ +import { lstat, type FileHandle } from "node:fs/promises"; +import type { GpuCodeObjectsPort } from "../application/gpu/GpuCodeObjectsPort.js"; +import { + createGpuCodeObjectsEvidence, + createGpuCodeObjectsPartialEvidence, +} from "../application/gpu/GpuCodeObjectsEvidence.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { + GpuCodeObjects, + GpuCodeObjectsInput, +} from "../domain/gpu/gpuCodeObjects.js"; +import { + gpuCodeObjectsSchema, + gpuCodeObjectsPartialSchema, +} from "../domain/gpu/gpuCodeObjects.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisArtifactChangedError, + AnalysisAccessDeniedError, + AnalysisCancelledError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { BinaryTargetError } from "../domain/configurationErrors.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + withArtifactRegularFile, + bindArtifactFileCleanup, + artifactFilePrimaryFailure, + artifactFileCancellationCause, + ArtifactFileCancelled, + ArtifactFileCleanupFailure, +} from "../artifacts/ArtifactFileOperation.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; +import { + GpuFileSource, + gpuCount, + GPU_OBJECT_BYTES, + GPU_REPORT_BYTES, +} from "./GpuSource.js"; +import { GpuInventory } from "./GpuInventory.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { sameRegularFileState } from "../filesystem/RegularFile.js"; + +export const GPU_CODE_OBJECTS_PROVIDER_IDENTITY = { + id: "gpu-code-objects", + name: "REA static GPU code-object reader", + version: "1", +} as const; +const OPERATION = "inspect_gpu_code_objects"; + +/** One admitted descriptor and bounded report at a time; retained cleanup blocks new work. */ +export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { + readonly identity = GPU_CODE_OBJECTS_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + #busy = false; + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause) { + const error = new ProviderCleanupError( + this.identity.id, + this.#resources.cleanupResources, + {}, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect( + input: GpuCodeObjectsInput, + options?: ExecutionOptions, + ): Promise> { + if (this.#busy) + return err( + new AnalysisResourceConstraintError( + OPERATION, + "capacity", + "One GPU inspection is already active", + { concurrent_inspections: 1 }, + { retryable: true }, + ), + ); + this.#busy = true; + let progress: { inventory: GpuInventory; size: number } | undefined; + try { + return ok( + await this.#resources.run(() => + withArtifactRegularFile( + input.path, + this.#resources, + options?.signal, + (file) => + this.#read(file, input, options?.signal, (value) => { + progress = value; + }), + ), + ), + ); + } catch (cause) { + const primary = artifactFilePrimaryFailure(cause); + let error: AnalysisError; + if (primary instanceof AnalysisError) error = primary; + else if (primary instanceof BinaryResourceFailure) + error = new AnalysisResourceConstraintError( + OPERATION, + "memory", + primary.message, + { + maximum_object_bytes: GPU_OBJECT_BYTES, + maximum_report_bytes: GPU_REPORT_BYTES, + }, + { cause, retryable: false }, + ); + else if (primary instanceof BinaryFormatFailure) { + error = + primary.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + primary.message, + { cause }, + ) + : new BinaryTargetError(input.path, primary.message, { + cause, + constraint: "file_format_rejected", + }); + } else if ( + primary instanceof ArtifactReaderFailure && + primary.reason === "cancelled" + ) + error = new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(primary), + }); + else if (primary instanceof ArtifactFileCancelled) + error = new AnalysisCancelledError(OPERATION, { + cause: primary.fileCancellation.reason, + }); + else if ( + primary instanceof ArtifactReaderFailure && + primary.reason === "integrity" + ) + error = new AnalysisArtifactChangedError( + OPERATION, + input.path, + primary.message, + { cause }, + ); + else if ( + primary instanceof Error && + "code" in primary && + (primary.code === "EACCES" || primary.code === "EPERM") + ) + error = new AnalysisAccessDeniedError( + OPERATION, + input.path, + primary.code, + { cause: primary }, + ); + else if ( + primary instanceof ArtifactReaderFailure && + primary.reason === "limit" + ) + error = new AnalysisResourceConstraintError( + OPERATION, + "memory", + primary.message, + {}, + { cause: primary }, + ); + else if (primary instanceof Error && "code" in primary) + error = new BinaryTargetError(input.path, primary.message, { cause }); + else + error = new ProviderAdapterError(this.identity.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: + primary instanceof Error ? primary.message : String(primary), + }, + }); + if ( + cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined + ) { + const cleanup = new ProviderCleanupError( + this.identity.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + error = analysisErrorWithCleanupFailure(error, cleanup, OPERATION); + } + const completed = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "completed" + ? cause.fileCleanupFailure.outcome.value + : cause instanceof ArtifactFileCancelled + ? cause.fileCancellation.outcome.value + : undefined; + if (completed !== undefined) { + // The descriptor owner retains observations even when its final close fails. + const report = gpuCodeObjectsSchema.safeParse(completed); + if (report.success) + error.retainPartialObservation( + createGpuCodeObjectsEvidence(report.data, this.identity, input), + ); + } + if ( + error.partialObservation === undefined && + progress !== undefined && + progress.inventory.objects.length > 0 + ) { + const partial = gpuCodeObjectsPartialSchema.parse({ + kind: "gpu-code-objects-partial", + artifact: { + path: input.path, + observed_file_size: progress.size, + sha256: null, + file_identity: "unverified", + }, + analysis: input.analysis, + objects: progress.inventory.objects, + kernels: progress.inventory.kernels, + ptx_artifacts: progress.inventory.ptx_artifacts, + coverage: { discovery: "partial", payloads: "partial" }, + limitations: [ + ...progress.inventory.limitations, + "Only completed declarations observed before interruption are retained. Root file identity and completeness were not verified; positional IDs are scoped to this interrupted observation.", + ], + }); + error.retainPartialObservation( + createGpuCodeObjectsPartialEvidence(partial, this.identity, input), + ); + } + bindArtifactFileCleanup(cause, error); + return err(error); + } finally { + this.#busy = false; + } + } + + async #read( + file: FileHandle, + input: GpuCodeObjectsInput, + signal?: AbortSignal, + retainProgress?: (progress: { + inventory: GpuInventory; + size: number; + }) => void, + ): Promise { + const initial = await file.stat({ bigint: true }); + const size = gpuCount(initial.size); + const source = new GpuFileSource(file, size, signal); + const sha256 = await source.digest(); + if (input.expected_sha256 !== undefined && sha256 !== input.expected_sha256) + throw new AnalysisArtifactChangedError( + OPERATION, + input.path, + "GPU source does not match the inventory's selected SHA-256", + ); + const inventory = new GpuInventory(input, signal); + retainProgress?.({ inventory, size }); + const format = await inventory.inspect(source); + const after = await file.stat({ bigint: true }); + const named = await lstat(input.path, { bigint: true }).catch( + (cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code)) + ) + throw new ArtifactReaderFailure( + "integrity", + "Selected GPU path changed during inspection", + { cause }, + ); + throw cause; + }, + ); + if ([after, named].some((state) => !sameRegularFileState(initial, state))) + throw new ArtifactReaderFailure( + "integrity", + "GPU source changed during inspection", + ); + const leaves = inventory.objects.filter( + (row) => + row.kind !== "cuda-fatbin" && + row.kind !== "hip-bundle" && + row.kind !== "native-section" && + row.kind !== "host", + ); + return gpuCodeObjectsSchema.parse({ + artifact: { + path: input.path, + file_size: size, + sha256, + bytes_read: source.bytesRead, + }, + format, + analysis: input.analysis, + objects: inventory.objects, + kernels: inventory.kernels, + ptx_artifacts: inventory.ptx_artifacts, + coverage: { + discovery_scope: "supported-section-names-and-standalone-formats", + discovery: inventory.discovery, + payloads: + input.analysis === "inventory" + ? "not-requested" + : leaves.every((row) => row.inspection === "complete") && + inventory.discovery === "complete" + ? "complete" + : "partial", + examined_sections: inventory.examined_sections, + unknown_section_names: inventory.unknown_section_names, + }, + limitations: [ + ...inventory.limitations, + "Full-file SHA-256 streams every file byte; section and selected payload reads add to artifact.bytes_read. Retained Evidence is historical and never reopens the target.", + ], + }); + } +} diff --git a/src/gpu/GpuElfKernels.ts b/src/gpu/GpuElfKernels.ts new file mode 100644 index 0000000000..6b1caacbcb --- /dev/null +++ b/src/gpu/GpuElfKernels.ts @@ -0,0 +1,491 @@ +import { readCudaTarget } from "./CudaTarget.js"; +import type { GpuPayloadTarget } from "./GpuObject.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + CudaMetadataBudget, + readCudaInfo, + readCudaParameters, +} from "./CudaInfo.js"; +import type { GpuKernel, GpuLocation } from "../domain/gpu/gpuCodeObjects.js"; +import { + BinaryReader, + BinaryFormatFailure, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; +import { + readElfSectionDirectory, + type NativeSection, + type NativeSectionDirectory, +} from "../native/metadata/NativeSectionDirectory.js"; +import { readElfString, readElfSymbol } from "../native/metadata/ElfSymbol.js"; +import { + gpuCount, + readGpuNativeDirectory, + rejectGpu, + type GpuSource, +} from "./GpuSource.js"; +import { + AMD_METADATA_BYTES, + amdCount, + amdRecord, + amdString, + readAmdMetadata, +} from "./AmdGpuMetadata.js"; +const sectionBytes = ( + source: GpuSource, + section: NativeSection, + budget?: CudaMetadataBudget, +): Promise => { + if (!section.file_backed || (section.flags & 0x800n) !== 0n) + rejectGpu("GPU metadata section has no supported uncompressed file range"); + budget?.read(gpuCount(section.size)); + return source.read(gpuCount(section.offset), gpuCount(section.size)); +}; +const site = ( + source: GpuSource, + section: NativeSection, + offset = 0, + size = gpuCount(section.size), +): GpuLocation => ({ + container_id: source.containerId, + offset: gpuCount(section.offset) + offset, + size, + section_index: section.index, + section_name: section.name, +}); +const blankKernel = ( + source: GpuSource, + id: string, + name: string, + location: GpuLocation, +): GpuKernel => ({ + id: `${source.containerId!}/${id}`, + object_id: source.containerId!, + representation: "cuda-elf-metadata", + name, + symbol: name, + parameter_count: null, + parameter_bytes: null, + parameters: [], + registers: { + physical: null, + sgpr: null, + vgpr: null, + agpr: null, + virtual: [], + }, + shared_bytes: null, + private_bytes: null, + location, + sources: { parameters: null, registers: null, shared: null, private: null }, + limitations: [], +}); +export const readGpuElfKernels = async ( + source: GpuSource, + kind: "cubin" | "amdgpu", + onKernel: (kernel: GpuKernel) => void, + preparedAmd?: AmdElfMetadata, + onTarget?: (target: GpuPayloadTarget) => void, +): Promise<{ + limitations: string[]; + coverage: "complete" | "partial"; +}> => { + if (kind === "amdgpu" && preparedAmd !== undefined) + return amdKernels(source, preparedAmd, onKernel, onTarget); + const directory = await readGpuNativeDirectory( + source, + readElfSectionDirectory(source.size), + ); + if (directory.machine !== (kind === "cubin" ? 190 : 224)) + rejectGpu("GPU entry kind disagrees with ELF machine"); + if (kind === "cubin") { + onTarget?.(readCudaTarget(source, directory)); + const coverage = await cudaKernels(source, directory, onKernel); + return { + coverage, + limitations: [ + ...(directory.sections.some((s) => s.name === null) + ? [ + "Some ELF section names are unknown; named metadata coverage is partial", + ] + : []), + ...(directory.sections.some((s) => s.type === 2 || s.type === 11) + ? [] + : [ + "No CUBIN symbol table was available; kernel declaration coverage is unknown", + ]), + ], + }; + } + const metadata = await readAmdElfMetadata(source, directory); + const parsed = amdKernels( + source, + metadata.ok ? metadata.value : metadata.error.partial, + onKernel, + onTarget, + ); + if (!metadata.ok) throw metadata.error.cause; + return parsed; +}; +const indexCudaMetadata = async ( + source: GpuSource, + directory: NativeSectionDirectory, +) => { + const infosByText = new Map(), + sharedByText = new Map(); + const registerFacts = new Map>(); + const budget = new CudaMetadataBudget(), + seenMetadata = new Set(); + for (const row of directory.sections) { + const map = + row.type === 8 && row.name?.startsWith(".nv.shared.") === true + ? sharedByText + : row.type === 0x70000000 && row.name?.startsWith(".nv.info.") === true + ? infosByText + : undefined; + const global = row.type === 0x70000000 && row.name === ".nv.info"; + if (map === undefined && !global) continue; + // The same scoped byte declaration may be aliased by many ELF headers. + const key = `${row.type}:${row.flags}:${row.name}:${row.offset}:${row.size}:${row.link}:${row.info}`; + if (seenMetadata.has(key)) continue; + budget.retain(key); + seenMetadata.add(key); + if (map !== undefined) { + budget.retain({ text: row.info, section: row.index }); + const values = map.get(row.info) ?? []; + values.push(row); + map.set(row.info, values); + } + if (global) { + for (const record of readCudaInfo( + await sectionBytes(source, row, budget), + directory.little, + budget, + )) { + if (record.format !== 4 || record.attribute !== 0x2f) continue; + if (record.data.length !== 8) + rejectGpu("CUDA register count record has an unsupported extent"); + const r = new BinaryReader(record.data, directory.little), + key = `${row.link}:${r.u32(0)}`; + const values = registerFacts.get(key) ?? new Map(); + const value = r.u32(4); + if (!values.has(value)) { + const location = site(source, row, record.offset, record.length); + budget.retain({ symbol: key, value, location }); + values.set(value, location); + registerFacts.set(key, values); + } + } + } + } + const allNamesKnown = directory.sections.every((row) => row.name !== null); + return { + source, + directory, + infosByText, + sharedByText, + registerFacts, + allNamesKnown, + budget, + parameters: new Map>(), + }; +}; +type CudaMetadataIndex = Awaited>; + +const attachCudaResources = ( + kernel: GpuKernel, + text: NativeSection, + index: CudaMetadataIndex, + symbolKey: string, +): void => { + const { source, directory } = index; + const registerRows = index.registerFacts.get(symbolKey); + const registerValues = new Set(registerRows?.keys()); + const sectionRegisters = text.info >>> 24; + kernel.registers.physical = + registerValues.size === 1 + ? [...registerValues][0]! + : registerValues.size === 0 && sectionRegisters !== 0 + ? sectionRegisters + : null; + if (registerValues.size === 1) + kernel.sources.registers = registerRows!.values().next().value!; + else if (sectionRegisters !== 0) + kernel.sources.registers = { + container_id: source.containerId, + offset: text.header_offset, + size: directory.bits === 64 ? 64 : 40, + section_index: text.index, + section_name: text.name, + }; + if ( + registerValues.size > 1 || + (kernel.registers.physical !== null && + sectionRegisters !== 0 && + kernel.registers.physical !== sectionRegisters) + ) { + kernel.registers.physical = null; + kernel.limitations.push( + "Conflicting CUDA register declarations; physical count remains unknown", + ); + } + const shared = index.sharedByText.get(text.index) ?? []; + kernel.shared_bytes = + shared.length === 1 + ? gpuCount(shared[0]!.size) + : shared.length === 0 && index.allNamesKnown + ? 0 + : null; + if (shared.length === 1) + kernel.sources.shared = { + container_id: source.containerId, + offset: shared[0]!.header_offset, + size: directory.bits === 64 ? 64 : 40, + section_index: shared[0]!.index, + section_name: shared[0]!.name, + }; +}; + +const cudaKernels = async ( + source: GpuSource, + directory: NativeSectionDirectory, + onKernel: (kernel: GpuKernel) => void, +): Promise<"complete" | "partial"> => { + const index = await indexCudaMetadata(source, directory); + let complete = index.allNamesKnown; + let symbolTables = 0; + for (const section of directory.sections) { + if (section.type !== 2 && section.type !== 11) continue; + symbolTables++; + const width = gpuCount(section.entry_size), + minimum = directory.bits === 64 ? 24 : 16; + if (width < minimum || section.size % BigInt(width) !== 0n) + rejectGpu("CUBIN symbol table width is invalid"); + const namesSection = directory.sections[section.link]; + if (namesSection === undefined || namesSection.type !== 3) + rejectGpu("CUBIN symbol string table is invalid"); + const names = await sectionBytes(source, namesSection!, index.budget); + const bytes = await sectionBytes(source, section, index.budget); + for (let offset = 0; offset < bytes.length; offset += width) { + index.budget.visit(); + const symbol = readElfSymbol( + bytes.subarray(offset, offset + width), + directory.bits, + directory.little, + ); + // STO_CUDA_ENTRY distinguishes launchable kernels from ordinary device functions. + if (symbol.type !== 2 || (symbol.other & 0x10) === 0) continue; + const text = directory.sections[symbol.section_index]; + if (text === undefined || text.type !== 1 || (text.flags & 4n) === 0n) + rejectGpu("CUBIN kernel symbol has no executable section"); + const kernel = blankKernel( + source, + `sym:${section.index}:${offset / width}`, + readElfString(names, symbol.name_offset), + site(source, section, offset, width), + ); + attachCudaResources( + kernel, + text!, + index, + `${section.index}:${offset / width}`, + ); + const infos = index.infosByText.get(text!.index) ?? []; + if (infos.length === 1) { + const info = infos[0]!; + let decoded = index.parameters.get(info.index); + if (decoded === undefined) { + decoded = readCudaParameters( + await sectionBytes(source, info, index.budget), + directory.little, + index.budget, + ); + index.budget.retain({ parameter_section: info.index }); + index.parameters.set(info.index, decoded); + } + if (decoded.limitations.length > 0) complete = false; + kernel.sources.parameters = site(source, infos[0]!); + kernel.parameter_bytes = decoded.parameter_bytes; + kernel.parameter_count = decoded.parameter_count; + kernel.parameters = decoded.parameters; + kernel.limitations.push(...decoded.limitations); + } else { + complete = false; + kernel.limitations.push( + "Exactly one matching CUDA nv.info parameter section was not available", + ); + } + kernel.limitations.push( + "CUDA metadata fields are declarations from ELF symbols, section headers and nv.info; SASS and runtime behavior were not inspected", + ); + onKernel(kernel); + } + } + return complete && symbolTables > 0 ? "complete" : "partial"; +}; +export interface AmdElfMetadata { + readonly target: string | null; + readonly target_key: string | null; + readonly limitations: string[]; + readonly notes: { + section: NativeSection; + noteOffset: number; + desc: number; + size: number; + kernels: unknown[]; + }[]; +} +/** One bounded parsed owner for target discovery and later selected kernel projection. */ +export const readAmdElfMetadata = async ( + source: GpuSource, + directory: NativeSectionDirectory, +): Promise< + Result< + AmdElfMetadata, + { kind: "incomplete-amd-metadata"; cause: unknown; partial: AmdElfMetadata } + > +> => { + const targets = new Set(), + limitations: string[] = []; + const notes: AmdElfMetadata["notes"] = []; + let retainedBytes = 0; + const observed = (): AmdElfMetadata => { + const target = targets.size === 1 ? [...targets][0]! : null; + const target_key = + target === null + ? null + : (/^amdgcn-amd-amdhsa--(gfx[a-zA-Z0-9]+(?::[^\s]+)?)$/u.exec( + target, + )?.[1] ?? null); + return { target, target_key, limitations, notes }; + }; + try { + if (directory.machine !== 224) + rejectGpu("AMDGPU metadata requires an AMDGPU ELF machine"); + for (const section of directory.sections) { + if (section.type !== 7) continue; + const sectionSize = gpuCount(section.size); + if (sectionSize > AMD_METADATA_BYTES - retainedBytes) + throw new BinaryResourceFailure( + "structure", + AMD_METADATA_BYTES, + "AMDGPU ELF note discovery exceeds the 8 MiB retained metadata budget", + ); + retainedBytes += sectionSize; + const bytes = await sectionBytes(source, section), + r = new BinaryReader(bytes, directory.little); + for (let p = 0; p < bytes.length;) { + if (bytes.length - p < 12) rejectGpu("Truncated ELF note header"); + const namesize = r.u32(p), + size = r.u32(p + 4), + type = r.u32(p + 8); + const nameOffset = p + 12, + desc = nameOffset + Math.ceil(namesize / 4) * 4, + end = desc + Math.ceil(size / 4) * 4; + r.range(nameOffset, namesize, "ELF note vendor"); + r.range(desc, size, "ELF note descriptor"); + if (end > bytes.length) + rejectGpu("ELF note alignment extends outside its section"); + const vendor = bytes + .subarray(nameOffset, nameOffset + namesize) + .toString("latin1") + .replace(/\0$/u, ""); + if (vendor === "AMDGPU" && type === 32) { + const metadata = amdRecord( + readAmdMetadata(bytes.subarray(desc, desc + size)), + ); + const kernels = metadata?.["amdhsa.kernels"]; + if (!Array.isArray(kernels)) + return rejectGpu("AMDGPU metadata has no valid kernel array"); + const target = amdString(metadata!["amdhsa.target"]); + if (target !== null) targets.add(target); + notes.push({ section, noteOffset: p, desc, size, kernels }); + } else if (vendor === "AMD" && (type === 10 || type === 11)) + limitations.push( + "Legacy AMDGPU YAML metadata is outside MessagePack coverage", + ); + p = end; + } + } + if (notes.length === 0) + throw new BinaryFormatFailure( + "unsupported", + "No AMDGPU v3+ MessagePack kernel metadata was available", + ); + return ok(observed()); + } catch (cause) { + return err({ kind: "incomplete-amd-metadata", cause, partial: observed() }); + } +}; + +const amdKernels = ( + source: GpuSource, + prepared: AmdElfMetadata, + onKernel: (kernel: GpuKernel) => void, + onTarget?: (target: GpuPayloadTarget) => void, +): { limitations: string[]; coverage: "complete" } => { + onTarget?.({ + value: prepared.target, + key: prepared.target_key, + scope: "exact", + }); + for (const { + section, + noteOffset, + desc, + size, + kernels: rows, + } of prepared.notes) { + for (let index = 0; index < rows.length; index++) { + const value = amdRecord(rows[index]); + const name = value === null ? null : amdString(value[".name"]); + if (value === null || name === null) + rejectGpu("AMDGPU kernel metadata has no name"); + const kernel = blankKernel( + source, + `note:${section.index}:${noteOffset}:${index}`, + name!, + site(source, section, desc, size), + ); + kernel.representation = "amdgpu-metadata"; + kernel.sources = { + parameters: kernel.location, + registers: kernel.location, + shared: kernel.location, + private: kernel.location, + }; + kernel.symbol = amdString(value![".symbol"]); + kernel.parameter_bytes = amdCount(value![".kernarg_segment_size"]); + kernel.shared_bytes = amdCount(value![".group_segment_fixed_size"]); + kernel.private_bytes = amdCount(value![".private_segment_fixed_size"]); + kernel.registers.sgpr = amdCount(value![".sgpr_count"]); + kernel.registers.vgpr = amdCount(value![".vgpr_count"]); + kernel.registers.agpr = amdCount(value![".agpr_count"]); + const args = value![".args"]; + if (Array.isArray(args)) { + kernel.parameter_count = args.length; + kernel.parameters = args.map((argument, ordinal) => { + const row = amdRecord(argument); + if (row === null) rejectGpu("Malformed AMDGPU argument metadata"); + const kind = amdString(row![".value_kind"]); + return { + ordinal, + name: amdString(row![".name"]), + offset: amdCount(row![".offset"]), + size: amdCount(row![".size"]), + type: amdString(row![".type_name"]), + hidden: kind === null ? null : kind.startsWith("hidden_"), + }; + }); + } + kernel.limitations.push( + "AMDGPU parameter count includes hidden ABI arguments when present; missing fields remain unknown. Kernel metadata is a declaration, not runtime verification.", + ); + onKernel(kernel); + } + } + return { + limitations: prepared.limitations, + coverage: "complete", + }; +}; diff --git a/src/gpu/GpuInventory.test.ts b/src/gpu/GpuInventory.test.ts new file mode 100644 index 0000000000..e98be43042 --- /dev/null +++ b/src/gpu/GpuInventory.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; +import { GpuInventory } from "./GpuInventory.js"; +import { GpuBufferSource } from "./GpuSource.js"; +import { + ccobFixture, + cubinFixture, + cudaFixture, +} from "../../tests/fixtures/gpu/codeObjects.js"; + +it.each(["cuda", "ccob"] as const)( + "refuses oversized %s output before reading compressed input", + async (kind) => { + const bytes = + kind === "cuda" + ? cudaFixture("zstd", [{ kind: 2, target: 80, bytes: cubinFixture() }]) + : ccobFixture(3); + const payloadOffset = kind === "cuda" ? 80 : 32; + bytes.writeBigUInt64LE( + BigInt(128 * 1024 * 1024 + 1), + kind === "cuda" ? 72 : 16, + ); + const source = new GpuBufferSource(bytes, "fixture"), + reads: number[] = [], + inventory = new GpuInventory({ path: "/fixture", analysis: "kernels" }); + await inventory.inspect({ + size: source.size, + containerId: source.containerId, + read: async (offset, size) => { + reads.push(offset); + return source.read(offset, size); + }, + }); + expect(reads).not.toContain(payloadOffset); + expect(inventory.objects[0]).toMatchObject({ + inspection: "capacity", + sha256: null, + }); + expect(inventory.kernels).toEqual([]); + }, +); +it("observes cancellation while walking flat empty bundles in a decoded buffer", async () => { + const empty = Buffer.alloc(32); + empty.write("__CLANG_OFFLOAD_BUNDLE__"); + const bytes = Buffer.concat(Array.from({ length: 4096 }, () => empty)); + const controller = new AbortController(), + reason = { selected: "cancel-buffer-walk" }; + const inventory = new GpuInventory( + { path: "/fixture", analysis: "inventory" }, + controller.signal, + ); + const result = inventory.inspect(new GpuBufferSource(bytes, "fixture")); + setImmediate(() => controller.abort(reason)); + await expect(result).rejects.toMatchObject({ + reason: "cancelled", + cause: reason, + }); +}); diff --git a/src/gpu/GpuInventory.ts b/src/gpu/GpuInventory.ts new file mode 100644 index 0000000000..cebc7a06c9 --- /dev/null +++ b/src/gpu/GpuInventory.ts @@ -0,0 +1,575 @@ +import { + CLANG_BUNDLE_MAGIC, + readClangBundleDirectory, + readCcobHeader, +} from "./ClangOffloadBundle.js"; +import { gpuObject, gpuLocation, type GpuPayloadTarget } from "./GpuObject.js"; +import { readCudaTarget } from "./CudaTarget.js"; +import { readCudaFatbinary } from "./CudaFatbinary.js"; +import { decodeGpuPayload } from "./GpuPayloadCodec.js"; +import { createHash } from "node:crypto"; +import { scheduler } from "node:timers/promises"; +import type { + GpuCodeObjectsInput, + GpuCodeObjects, + GpuObject, + GpuKernel, +} from "../domain/gpu/gpuCodeObjects.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; +import { + readElfSectionDirectory, + readPeSectionDirectory, + type NativeSectionDirectory, +} from "../native/metadata/NativeSectionDirectory.js"; +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { + GpuBufferSource, + checkGpuRange, + GPU_OBJECT_BYTES, + GPU_REPORT_BYTES, + GpuReportCapacityFailure, + gpuCount, + nextGpuContainerOffset, + gpuSha256, + readGpuNativeDirectory, + rejectGpu, + type GpuSource, +} from "./GpuSource.js"; +import { + readGpuElfKernels, + readAmdElfMetadata, + type AmdElfMetadata, +} from "./GpuElfKernels.js"; +import { readPtxKernels, readPtxTarget } from "./PtxKernels.js"; + +/** File declarations only. Every child range stays in its named parent coordinate space. */ +export class GpuInventory { + readonly objects: GpuObject[] = []; + readonly kernels: GpuKernel[] = []; + readonly ptx_artifacts: GpuCodeObjects["ptx_artifacts"] = []; + readonly limitations = [ + "Static declarations only; no GPU execution, SASS disassembly, host launch correlation or runtime compatibility was verified.", + "Object IDs are positional identities scoped to artifact.sha256. Locations inside decoded objects name their containing object; they are not file offsets.", + ]; + discovery: "complete" | "partial" = "complete"; + examined_sections = 0; + unknown_section_names = 0; + #factBytes = 0; + #workBytes = 0; + readonly #outerTargets = new WeakMap(); + readonly #selectedObjects: ReadonlySet | undefined; + readonly #selectedTargets: ReadonlySet | undefined; + constructor( + readonly input: GpuCodeObjectsInput, + readonly signal?: AbortSignal, + ) { + this.#selectedObjects = + input.object_ids === undefined ? undefined : new Set(input.object_ids); + this.#selectedTargets = + input.targets === undefined ? undefined : new Set(input.targets); + this.#retain({ path: input.path, limitations: this.limitations }); + this.#factBytes += 1024; + } + #retain(value: JsonValue): void { + const bytes = jsonValueByteLength(value, GPU_REPORT_BYTES); + if (bytes + 2 > GPU_REPORT_BYTES - this.#factBytes) + throw new GpuReportCapacityFailure(); + this.#factBytes += bytes + 2; + } + #add(row: GpuObject): void { + if (row.raw_flags !== null || row.bundle_id !== null) + this.#outerTargets.set(row, row.target); + this.#retain(row); + this.#retain(" ".repeat(32)); // Reserve all later inspection-state changes. + this.objects.push(row); + } + #decoded(row: GpuObject, sha256: string, size: number): void { + this.#retain({ sha256, decoded_size: size }); + row.sha256 = sha256; + row.decoded_size = size; + } + #objectLimitations(row: GpuObject, messages: string[]): void { + this.#retain(messages); + row.limitations.push(...messages); + } + /** Reserve input/output work atomically, before any payload allocation or read. */ + #budgetWork(...sizes: number[]): void { + const total = sizes.reduce((sum, size) => sum + size, 0); + if ( + sizes.some((size) => size > GPU_OBJECT_BYTES) || + total > 512 * 1024 * 1024 - this.#workBytes + ) + throw new BinaryResourceFailure( + "structure", + GPU_OBJECT_BYTES, + "GPU reads/decoding exceed 128 MiB per buffer or 512 MiB per inspection", + ); + this.#workBytes += total; + } + #selected(row: GpuObject): boolean { + return ( + (this.#selectedObjects === undefined || + this.#selectedObjects.has(row.id)) && + (this.#selectedTargets === undefined || + (row.target !== null && this.#selectedTargets.has(row.target))) + ); + } + async inspect( + source: GpuSource, + depth = 0, + ): Promise { + assertArtifactReadActive(this.signal); + if (depth > 8) + throw new BinaryResourceFailure( + "structure", + 8, + "GPU container nesting exceeds eight levels", + ); + const prefix = await source.read(0, Math.min(32, source.size)); + if (prefix.subarray(0, 4).equals(Buffer.from([0x50, 0xed, 0x55, 0xba]))) { + await this.#cuda(source); + return "cuda-fatbin"; + } + if ( + prefix.subarray(0, CLANG_BUNDLE_MAGIC.length).equals(CLANG_BUNDLE_MAGIC) + ) { + await this.#hip(source, depth); + return "hip-bundle"; + } + if (prefix.subarray(0, 4).toString("ascii") === "CCOB") { + await this.#hip(source, depth); + return "hip-bundle"; + } + if ( + prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) || + prefix.subarray(0, 2).toString("ascii") === "MZ" + ) { + const elf = prefix[0] === 0x7f; + const directory = await readGpuNativeDirectory( + source, + elf + ? readElfSectionDirectory(source.size) + : readPeSectionDirectory(source.size), + ); + if (elf && (directory.machine === 190 || directory.machine === 224)) { + const row = gpuObject(source, { + offset: 0, + size: source.size, + kind: directory.machine === 190 ? "cubin" : "amdgpu", + }); + this.#add(row); + if (directory.machine === 190) { + this.#target(row, readCudaTarget(source, directory)); + } + if (directory.machine === 224) + await this.#amdPayload(source, row, directory); + else await this.#payload(source, row); + } else { + for (const section of directory.sections) { + this.examined_sections++; + if (section.name === null) { + this.unknown_section_names++; + this.discovery = "partial"; + continue; + } + if (!isGpuSection(section.name)) continue; + if ( + !section.file_backed || + (directory.format === "elf" && (section.flags & 0x800n) !== 0n) + ) { + this.discovery = "partial"; + const message = `GPU section ${section.index} has no supported uncompressed file range`; + this.#retain(message); + this.limitations.push(message); + continue; + } + const offset = gpuCount(section.offset), + size = gpuCount(section.size); + const borrowed: GpuSource = { + size, + containerId: source.containerId, + read: (start, length) => source.read(offset + start, length), + }; + // A section is a coordinate space with an explicit physical backing, including PE short names. + const row = gpuObject(source, { + offset: offset, + size: size, + kind: "native-section", + metadata: { + offset: section.header_offset, + size: + directory.format === "elf" + ? directory.bits === 64 + ? 64 + : 40 + : 40, + }, + }); + row.location.section_index = section.index; + row.location.section_name = section.name; + this.#add(row); + const sectionSource: GpuSource = { ...borrowed, containerId: row.id }; + try { + await this.inspect(sectionSource, depth + 1); + row.inspection = "complete"; + } catch (cause) { + this.#retainFailure(row, cause); + this.discovery = "partial"; + } + } + } + return directory.format; + } + // PTX is positively identified by its directives, never by extension or arbitrary embedded text. + let probe = await source.read(0, Math.min(source.size, 64 * 1024)); + let target = readPtxTarget(probe.toString("utf8")); + if ( + target === null && + probe.length < Math.min(source.size, GPU_REPORT_BYTES) + ) { + probe = await source.read(0, Math.min(source.size, GPU_REPORT_BYTES)); + target = readPtxTarget(probe.toString("utf8")); + } + if (target !== null) { + const row = gpuObject(source, { + offset: 0, + size: source.size, + kind: "ptx", + }); + row.target = target; + this.#add(row); + await this.#payload( + probe.length === source.size + ? new GpuBufferSource(probe, source.containerId) + : source, + row, + ); + return "ptx"; + } + if (probe.length < source.size) + throw new BinaryResourceFailure( + "structure", + GPU_REPORT_BYTES, + "Standalone text recognition exceeds the 16 MiB PTX budget before a target was established", + ); + throw new BinaryFormatFailure( + "unsupported", + "No supported CUDA/HIP container or PTX declarations were identified", + ); + } + #retainFailure(row: GpuObject, cause: unknown): void { + if (cause instanceof GpuReportCapacityFailure) throw cause; + if ( + !(cause instanceof BinaryFormatFailure) && + !(cause instanceof BinaryResourceFailure) + ) + throw cause; + row.inspection = + cause instanceof BinaryResourceFailure + ? "capacity" + : cause.kind === "unsupported" + ? "unsupported" + : "malformed"; + this.#objectLimitations(row, [cause.message]); + } + async #payload( + source: GpuSource, + row: GpuObject, + preparedAmd?: AmdElfMetadata, + ): Promise { + if (this.input.analysis !== "kernels") return; + if (!this.#selected(row)) { + row.inspection = "not-selected"; + return; + } + if (row.compression === "unsupported") { + row.inspection = "unsupported"; + return; + } + try { + let coverage: "complete" | "partial" = "complete"; + if ( + row.kind === "ptx" && + (row.decoded_size === null || row.decoded_size > GPU_REPORT_BYTES) + ) + throw new BinaryResourceFailure( + "structure", + GPU_REPORT_BYTES, + "PTX text exceeds the 16 MiB retained report budget", + ); + const compression = row.compression; + const compressed = + compression === "zstd" || + compression === "zlib" || + compression === "lz4"; + if (compressed && row.decoded_size === null) + rejectGpu("Compressed GPU object has no decoded length"); + this.#budgetWork( + row.location.size, + ...(compressed ? [row.decoded_size!] : []), + ); + let bytes = await source.read(row.location.offset, row.location.size); + if (compressed) { + try { + bytes = await decodeGpuPayload( + bytes, + compression, + row.decoded_size!, + this.signal, + ); + } catch (cause) { + if (cause instanceof BinaryResourceFailure) throw cause; + assertArtifactReadActive(this.signal); + throw new BinaryFormatFailure( + "malformed", + "GPU payload decompression failed", + ); + } + } + this.#decoded(row, gpuSha256(bytes), bytes.length); + const decoded = new GpuBufferSource(bytes, row.id); + if (row.kind === "ptx") { + // NVIDIA appends NUL alignment padding to an uncompressed PTX entry. + let length = bytes.length; + if (row.raw_flags !== null) + while (length > 0 && bytes[length - 1] === 0) length--; + const content = bytes.subarray(0, length); + let text: string; + try { + text = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(content); + } catch { + return rejectGpu("PTX is not valid UTF-8 text"); + } + const target = readPtxTarget(text); + const conflict = this.#target(row, { + value: target, + key: target, + scope: "exact", + }); + if (conflict !== undefined) coverage = "partial"; + const parsed = readPtxKernels(text, row.id); + const artifact = { + id: `${row.id}/ptx`, + object_id: row.id, + sha256: gpuSha256(content), + media_type: "text/x-ptx" as const, + encoding: "utf-8" as const, + byte_length: content.length, + text, + location: gpuLocation(decoded, 0, content.length), + }; + this.#retain(artifact); + this.ptx_artifacts.push(artifact); + for (const { kernel, coverage: kernelCoverage } of parsed) { + if (conflict !== undefined) kernel.limitations.push(conflict); + if (kernelCoverage === "partial") coverage = "partial"; + this.#retain(kernel); + this.kernels.push(kernel); + } + } else if (row.kind === "cubin" || row.kind === "amdgpu") { + coverage = await this.#elfFacts(decoded, row, row.kind, preparedAmd); + } else { + row.inspection = "unsupported"; + this.#objectLimitations(row, [ + "Payload kind is outside kernel metadata coverage", + ]); + return; + } + row.inspection = coverage; + } catch (cause) { + this.#retainFailure(row, cause); + } + } + #target(row: GpuObject, payload: GpuPayloadTarget): string | undefined { + const selectedTarget = row.target; + const outer = this.#outerTargets.get(row) ?? null; + const hasOuter = outer !== null; + let state: GpuObject["target_consistency"] = "unknown"; + if (payload.value !== null && !hasOuter) state = "single-source"; + else if (outer !== null && payload.key !== null) { + if (payload.scope === "cuda-base") { + const base = /^sm_([0-9]+)[a-z]*$/u.exec(outer)?.[1]; + const inner = /^sm_([0-9]+)$/u.exec(payload.key)?.[1]; + if (base !== undefined && inner !== undefined) + state = base === inner ? "base-only" : "conflict"; + } else if (outer === payload.key) state = "consistent"; + else if ( + row.kind === "amdgpu" && + outer.split(":")[0] === payload.key.split(":")[0] + ) + state = "unknown"; + else state = "conflict"; + } + this.#retain({ + payload_target: payload.value, + cuda_target_header: payload.cudaHeader ?? null, + target_consistency: state, + target: selectedTarget ?? payload.value, + }); + row.payload_target = payload.value; + row.cuda_target_header = payload.cudaHeader ?? null; + row.target_consistency = state; + if (selectedTarget === null) row.target = payload.value; + if (row.target === null) this.discovery = "partial"; + if (state !== "conflict" && payload.limitation === undefined) return; + const message = + state === "conflict" + ? `Conflicting target declarations: inventory ${outer}; payload ${payload.value}. Target agreement is not established.` + : payload.limitation!; + this.#objectLimitations(row, [message]); + return message; + } + async #amdPayload( + source: GpuSource, + row: GpuObject, + directory?: NativeSectionDirectory, + ): Promise { + const child: GpuSource = { + size: row.location.size, + containerId: row.id, + read: (offset, size) => { + checkGpuRange(row.location.size, offset, size); + this.#budgetWork(size); + return source.read(row.location.offset + offset, size); + }, + }; + try { + const native = + directory ?? + (await readGpuNativeDirectory( + child, + readElfSectionDirectory(child.size), + )); + const inspected = await readAmdElfMetadata(child, native); + const prepared = inspected.ok ? inspected.value : inspected.error.partial; + this.#target(row, { + value: prepared.target, + key: prepared.target_key, + scope: "exact", + }); + if (!inspected.ok) { + this.discovery = "partial"; + if (this.input.analysis === "kernels" && this.#selected(row)) + await this.#elfFacts(child, row, "amdgpu", prepared); + this.#retainFailure(row, inspected.error.cause); + return; + } + await this.#payload(source, row, prepared); + } catch (cause) { + this.discovery = "partial"; + this.#retainFailure(row, cause); + } + } + async #elfFacts( + source: GpuSource, + row: GpuObject, + kind: "cubin" | "amdgpu", + preparedAmd?: AmdElfMetadata, + ): Promise<"complete" | "partial"> { + let conflict: string | undefined; + const parsed = await readGpuElfKernels( + source, + kind, + (kernel) => { + if (conflict !== undefined) kernel.limitations.push(conflict); + this.#retain(kernel); + this.kernels.push(kernel); + }, + preparedAmd, + (target) => { + conflict = this.#target(row, target); + }, + ); + this.#objectLimitations(row, parsed.limitations); + return conflict !== undefined ? "partial" : parsed.coverage; + } + async #cuda(source: GpuSource): Promise { + for await (const row of readCudaFatbinary(source, this.signal)) { + this.#add(row); + await this.#payload(source, row); + } + } + async #hip(source: GpuSource, depth: number): Promise { + let base = 0; + let bundles = 0; + while (base < source.size) { + if (++bundles % 128 === 0) { + await scheduler.yield(); + assertArtifactReadActive(this.signal); + } + const prefix = await source.read(base, Math.min(32, source.size - base)); + if (prefix.subarray(0, 24).equals(CLANG_BUNDLE_MAGIC)) { + const directory = await readClangBundleDirectory( + source, + base, + this.signal, + ); + for (const row of directory.objects) { + this.#add(row); + if (row.kind === "host") row.inspection = "complete"; + else if (row.kind === "amdgpu" && row.target === null) + await this.#amdPayload(source, row); + else await this.#payload(source, row); + } + base = await nextGpuContainerOffset(source, directory.end); + continue; + } + if (prefix.subarray(0, 4).toString("ascii") !== "CCOB") + rejectGpu("Unexpected bytes between Clang offload bundles"); + const header = readCcobHeader(prefix, source.size - base), + { width, total, decoded, hash, version, method } = header; + const row = gpuObject(source, { + offset: base + width, + size: total - width, + kind: "hip-bundle", + metadata: { offset: base, size: width }, + }); + row.compression = + method === 0 ? "zlib" : method === 1 ? "zstd" : "unsupported"; + row.compression_id = `ccob:${version}:${method}`; + row.decoded_size = decoded; + this.#add(row); + if (row.compression === "unsupported") { + row.inspection = "unsupported"; + this.#objectLimitations(row, ["Unsupported CCOB compression method"]); + this.discovery = "partial"; + } else + try { + this.#budgetWork(total - width, decoded); + const data = await decodeGpuPayload( + await source.read(base + width, total - width), + row.compression, + decoded, + this.signal, + ); + if ( + createHash("md5").update(data).digest().readBigUInt64LE(0) !== hash + ) + rejectGpu("CCOB truncated MD5 does not match decoded bytes"); + this.#decoded(row, gpuSha256(data), data.length); + await this.inspect(new GpuBufferSource(data, row.id), depth + 1); + row.inspection = "complete"; + } catch (cause) { + this.#retainFailure(row, cause); + this.discovery = "partial"; + } + base = await nextGpuContainerOffset(source, base + total); + } + } +} +const isGpuSection = (name: string): boolean => + [ + ".nv_fatbin", + ".nv_fatb", + ".hip_fatbin", + ".hip_fat", + ".llvm.offloading", + ].includes(name); diff --git a/src/gpu/GpuObject.ts b/src/gpu/GpuObject.ts new file mode 100644 index 0000000000..01ddf28ac9 --- /dev/null +++ b/src/gpu/GpuObject.ts @@ -0,0 +1,52 @@ +import type { GpuLocation, GpuObject } from "../domain/gpu/gpuCodeObjects.js"; +import type { GpuSource } from "./GpuSource.js"; +export interface GpuPayloadTarget { + readonly value: string | null; + readonly key: string | null; + readonly scope: "exact" | "cuda-base"; + readonly cudaHeader?: GpuObject["cuda_target_header"]; + readonly limitation?: string; +} +export const gpuLocation = ( + source: GpuSource, + offset: number, + size: number, +): GpuLocation => ({ + container_id: source.containerId, + offset, + size, + section_index: null, + section_name: null, +}); +/** Row identity and both encoded/header locations belong to the same coordinate space. */ +export const gpuObject = ( + source: GpuSource, + input: { + offset: number; + size: number; + kind: GpuObject["kind"]; + metadata?: { offset: number; size: number }; + }, +): GpuObject => { + const { offset, size, kind, metadata = { offset, size: 0 } } = input; + return { + id: `${source.containerId ?? "file"}@${metadata.offset.toString(16)}`, + parent_id: source.containerId, + kind, + target: null, + payload_target: null, + cuda_target_header: null, + target_consistency: "unexamined", + bundle_id: null, + compression: "none", + compression_id: null, + encoded_size: size, + decoded_size: size, + sha256: null, + raw_flags: null, + location: gpuLocation(source, offset, size), + metadata_location: gpuLocation(source, metadata.offset, metadata.size), + inspection: "inventory", + limitations: [], + }; +}; diff --git a/src/gpu/GpuPayloadCodec.test.ts b/src/gpu/GpuPayloadCodec.test.ts new file mode 100644 index 0000000000..97fc039ee8 --- /dev/null +++ b/src/gpu/GpuPayloadCodec.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; +import { deflateSync, zstdCompressSync } from "node:zlib"; +import { decodeGpuPayload } from "./GpuPayloadCodec.js"; +it.each(["zlib", "zstd"] as const)( + "requires all declared %s input bytes to belong to the codec stream", + async (codec) => { + const source = Buffer.from("kernel"), + encode = codec === "zstd" ? zstdCompressSync : deflateSync, + compressed = encode(source); + expect(await decodeGpuPayload(compressed, codec, source.length)).toEqual( + source, + ); + await expect( + decodeGpuPayload( + Buffer.concat([compressed, Buffer.from("junk")]), + codec, + source.length, + ), + ).rejects.toMatchObject({ kind: "malformed" }); + }, +); diff --git a/src/gpu/GpuPayloadCodec.ts b/src/gpu/GpuPayloadCodec.ts new file mode 100644 index 0000000000..e3bdb6596a --- /dev/null +++ b/src/gpu/GpuPayloadCodec.ts @@ -0,0 +1,55 @@ +import { constants, inflate, zstdDecompress } from "node:zlib"; +import { promisify } from "node:util"; +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { decodeLz4Block } from "./Lz4Block.js"; +import { rejectGpu } from "./GpuSource.js"; +const unzip = promisify(inflate); +const unzstd = promisify(zstdDecompress); +export const decodeGpuPayload = async ( + bytes: Buffer, + compression: "zlib" | "zstd" | "lz4", + expected: number, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + if (compression === "lz4") return decodeLz4Block(bytes, expected, signal); + let result: unknown; + try { + const maxOutputLength = Math.max(1, expected); + result = await (compression === "zstd" + ? unzstd(bytes, { + maxOutputLength, + info: true, + params: { [constants.ZSTD_d_windowLogMax]: 27 }, + }) + : unzip(bytes, { maxOutputLength, info: true })); + } catch { + assertArtifactReadActive(signal); + return rejectGpu( + "GPU payload decompression failed or exceeded its declared length", + ); + } + assertArtifactReadActive(signal); + if ( + typeof result !== "object" || + result === null || + !("buffer" in result) || + !Buffer.isBuffer(result.buffer) || + !("engine" in result) || + typeof result.engine !== "object" || + result.engine === null || + !("bytesWritten" in result.engine) || + typeof result.engine.bytesWritten !== "number" + ) + throw new Error( + "Built-in GPU decoder omitted its buffer or consumed-input accounting", + ); + const decoded = result.buffer; + if (result.engine.bytesWritten !== bytes.length) + rejectGpu( + "GPU compressed payload contains bytes outside its complete codec stream", + ); + if (decoded.length !== expected) + rejectGpu("GPU decompressed length disagrees with its declaration"); + return decoded; +}; diff --git a/src/gpu/GpuSource.ts b/src/gpu/GpuSource.ts new file mode 100644 index 0000000000..f2e43d839d --- /dev/null +++ b/src/gpu/GpuSource.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import type { FileHandle } from "node:fs/promises"; +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; +import type { + NativeReadRequest, + NativeSectionDirectory, +} from "../native/metadata/NativeSectionDirectory.js"; +export const GPU_OBJECT_BYTES = 128 * 1024 * 1024; +export const GPU_REPORT_BYTES = 16 * 1024 * 1024; +/** A retained-facts refusal aborts discovery; it is never downgraded to one opaque payload. */ +export class GpuReportCapacityFailure extends BinaryResourceFailure { + constructor() { + super( + "structure", + GPU_REPORT_BYTES, + "GPU facts exceed the 16 MiB retained report budget", + ); + } +} +export const rejectGpu = (message: string): never => { + throw new BinaryFormatFailure("malformed", message); +}; +export const gpuCount = (value: bigint): number => { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) + rejectGpu("GPU range exceeds exact host offset precision"); + return Number(value); +}; +export interface GpuSource { + readonly size: number; + readonly containerId: string | null; + read(offset: number, size: number): Promise; +} +export const checkGpuRange = ( + length: number, + offset: number, + size: number, +): void => { + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + offset < 0 || + size < 0 || + offset > length || + size > length - offset + ) + rejectGpu("GPU byte range is outside its containing object"); + if (size > GPU_OBJECT_BYTES) + throw new BinaryResourceFailure( + "structure", + GPU_OBJECT_BYTES, + "GPU object exceeds the 128 MiB decoding budget", + ); +}; +export class GpuFileSource implements GpuSource { + readonly containerId = null; + bytesRead = 0; + constructor( + readonly file: FileHandle, + readonly size: number, + readonly signal?: AbortSignal, + ) {} + async read(offset: number, size: number): Promise { + checkGpuRange(this.size, offset, size); + const bytes = Buffer.alloc(size); + let position = 0; + while (position < size) { + assertArtifactReadActive(this.signal); + const result = await this.file.read( + bytes, + position, + size - position, + offset + position, + ); + if (result.bytesRead === 0) + throw new ArtifactReaderFailure( + "integrity", + "GPU source was truncated during inspection", + ); + position += result.bytesRead; + this.bytesRead += result.bytesRead; + } + return bytes; + } + async digest(): Promise { + const hash = createHash("sha256"); + for (let position = 0; position < this.size;) { + const bytes = await this.read( + position, + Math.min(1024 * 1024, this.size - position), + ); + hash.update(bytes); + position += bytes.length; + } + return hash.digest("hex"); + } +} +export class GpuBufferSource implements GpuSource { + readonly size: number; + constructor( + readonly bytes: Buffer, + readonly containerId: string | null, + ) { + this.size = bytes.length; + } + async read(offset: number, size: number): Promise { + checkGpuRange(this.size, offset, size); + return this.bytes.subarray(offset, offset + size); + } +} +export const readGpuNativeDirectory = async ( + source: GpuSource, + steps: Generator, +): Promise => { + let next = steps.next(); + while (!next.done) { + const { offset, size } = next.value; + next = steps.next(await source.read(offset, size)); + } + return next.value; +}; +export const gpuSha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +export const nextGpuContainerOffset = async ( + source: GpuSource, + start: number, +): Promise => { + let width = 64; + for (let position = start; position < source.size;) { + const bytes = await source.read( + position, + Math.min(width, source.size - position), + ); + const index = bytes.findIndex((byte) => byte !== 0); + if (index >= 0) return position + index; + position += bytes.length; + width = 65536; + } + return source.size; +}; diff --git a/src/gpu/Lz4Block.test.ts b/src/gpu/Lz4Block.test.ts new file mode 100644 index 0000000000..59db865f46 --- /dev/null +++ b/src/gpu/Lz4Block.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; +import { decodeLz4Block } from "./Lz4Block.js"; +it("decodes overlapping matches by repeating the newly produced pattern", async () => { + // The initial match is the same overlap shape observed in nvcc 12.9 speed-mode PTX. + const input = Buffer.from("3c0a2f2f0300504142434445", "hex"); + expect((await decodeLz4Block(input, 24)).toString()).toBe( + "\n//".repeat(6) + "\nABCDE", + ); +}); +it.each(["10000000", "10000100", "f0ff", "f001", "00"])( + "rejects malformed or mismatched block %s", + async (hex) => { + await expect(decodeLz4Block(Buffer.from(hex, "hex"), 8)).rejects.toThrow(); + }, +); +it("preserves caller cancellation before allocation work continues", async () => { + const signal = AbortSignal.abort("caller stopped"); + await expect( + decodeLz4Block(Buffer.from("10aa", "hex"), 1, signal), + ).rejects.toMatchObject({ reason: "cancelled" }); +}); diff --git a/src/gpu/Lz4Block.ts b/src/gpu/Lz4Block.ts new file mode 100644 index 0000000000..2cfab3e948 --- /dev/null +++ b/src/gpu/Lz4Block.ts @@ -0,0 +1,75 @@ +import { scheduler } from "node:timers/promises"; +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { GPU_OBJECT_BYTES, rejectGpu } from "./GpuSource.js"; +import { BinaryResourceFailure } from "../native/metadata/BinaryContainer.js"; +/** Raw LZ4 blocks used by CUDA's 0x2000 mode; not an LZ4 frame decoder. */ +export const decodeLz4Block = async ( + input: Buffer, + expected: number, + signal?: AbortSignal, +): Promise => { + if ( + !Number.isSafeInteger(expected) || + expected < 0 || + expected > GPU_OBJECT_BYTES + ) + throw new BinaryResourceFailure( + "structure", + GPU_OBJECT_BYTES, + "LZ4 decoded length exceeds the object budget", + ); + const output = Buffer.alloc(expected); + let read = 0, + write = 0, + checkpoint = 0; + const byte = (): number => { + if (read >= input.length) return rejectGpu("Truncated LZ4 block"); + return input[read++]!; + }; + const length = (nibble: number, remaining: number): number => { + let total = nibble; + if (nibble === 15) { + let extra: number; + do { + extra = byte(); + if (extra > remaining - total) + rejectGpu("LZ4 length exceeds its declared output"); + total += extra; + } while (extra === 255); + } + if (total > remaining) rejectGpu("LZ4 length exceeds its declared output"); + return total; + }; + while (read < input.length) { + if (write - checkpoint >= 4 * 1024 * 1024) { + await scheduler.yield(); + checkpoint = write; + } + assertArtifactReadActive(signal); + const token = byte(); + const literals = length(token >>> 4, expected - write); + if (literals > input.length - read) + rejectGpu("LZ4 literals exceed their encoded input"); + input.copy(output, write, read, read + literals); + read += literals; + write += literals; + if (read === input.length) break; + const distance = byte() | (byte() << 8); + if (distance === 0 || distance > write) + rejectGpu("LZ4 match has an invalid backward distance"); + const match = length(token & 15, expected - write - 4) + 4; + let copied = Math.min(distance, match); + output.copy(output, write, write - distance, write - distance + copied); + // Grow the already decoded pattern; Buffer.copy alone has memmove rather than LZ4 overlap semantics. + while (copied < match) { + const count = Math.min(copied, match - copied); + output.copy(output, write + copied, write, write + count); + copied += count; + } + write += match; + } + if (write !== expected) + rejectGpu("LZ4 decoded length disagrees with its declaration"); + assertArtifactReadActive(signal); + return output; +}; diff --git a/src/gpu/PtxKernels.test.ts b/src/gpu/PtxKernels.test.ts new file mode 100644 index 0000000000..3b811fcc93 --- /dev/null +++ b/src/gpu/PtxKernels.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { readPtxKernels, readPtxTarget } from "./PtxKernels.js"; +it("recognizes directives outside comments and strings and preserves UTF-8 byte coordinates", () => { + expect(readPtxTarget('".version 8.8\n.target sm_80\n"')).toBeNull(); + const prefix = "// 雪 .entry fake() {}\n.version 8.8\n.target sm_80\n"; + const text = + prefix + ".visible .entry real(.param .u64 x) { .reg .b32 %r<3>; ret; }"; + expect(readPtxTarget(text)).toBe("sm_80"); + const rows = [...readPtxKernels(text, "object")]; + expect(rows).toHaveLength(1); + expect(rows[0]!.coverage).toBe("complete"); + expect(rows[0]!.kernel.location.offset).toBe( + Buffer.byteLength(prefix + ".visible "), + ); + expect(rows[0]!.kernel.registers.virtual).toEqual([ + { type: ".b32", count: 3 }, + ]); +}); +it("derives vector/array parameter declarations and leaves unknown qualifiers unresolved", () => { + const rows = [ + ...readPtxKernels( + ".entry k(.param .v2 .u32 x, .param .align 16 .b8 a[32], .param .u64 .ptr .global y) {ret;}", + "object", + ), + ]; + expect( + rows[0]!.kernel.parameters.map(({ offset, size }) => [offset, size]), + ).toEqual([ + [0, 8], + [16, 32], + [null, null], + ]); + expect(rows[0]!.kernel.parameter_bytes).toBeNull(); + expect(rows[0]!.coverage).toBe("partial"); + expect(rows[0]!.kernel.limitations.join(" ")).toContain( + "parameter declarations", + ); + expect(rows[0]!.kernel.parameter_count).toBe(3); +}); +it("keeps dynamic and compound shared declarations unknown", () => { + const [row] = [ + ...readPtxKernels( + ".entry k() {.shared .u32 a,b; .shared .b8 dynamic[]; ret;}", + "object", + ), + ]; + expect(row!.kernel.shared_bytes).toBeNull(); + expect(row!.coverage).toBe("partial"); + expect(row!.kernel.representation).toBe("ptx-declarations"); +}); +it("sums bare names and every register range while preserving unsupported totals as unknown", () => { + const [row] = [ + ...readPtxKernels( + ".entry k() { .reg .b32 r0, r1; .reg .b32 %r<4>, %s<8>; .reg .u32 %x; .reg .u32 unsupported[2]; .reg .u32 %y; .reg .pred p, %p<2>; ret; }", + "object", + ), + ]; + expect(row!.kernel.registers.virtual).toEqual([ + { type: ".b32", count: 14 }, + { type: ".u32", count: null }, + { type: ".pred", count: 3 }, + ]); + expect(row!.kernel.limitations.join(" ")).toContain("count unknown"); +}); +it("does not round overflowing register totals or accept truncated ranges", () => { + for (const declaration of [ + "%r<9007199254740991>, %s", + "%r<4", + "%r<0>", + "%r,", + ]) { + const [row] = [ + ...readPtxKernels( + `.entry k() { .reg .b32 ${declaration}; ret; }`, + "object", + ), + ]; + expect(row!.kernel.registers.virtual).toEqual([ + { type: ".b32", count: null }, + ]); + } +}); +it.each([ + "/* unfinished", + ".entry broken(", + ".entry k() {", + ".entry k() {.reg .u32 %x}", + ".entry k() {.reg %x;}", +])("rejects incomplete PTX %s", (text) => { + expect(() => [...readPtxKernels(text, "object")]).toThrow(); +}); + +it("does not label unrelated malformed quoted text as a recognized PTX artifact", () => { + expect(readPtxTarget('unrelated "unterminated')).toBeNull(); + expect(readPtxTarget("/* unrelated unterminated")).toBeNull(); +}); diff --git a/src/gpu/PtxKernels.ts b/src/gpu/PtxKernels.ts new file mode 100644 index 0000000000..fcdcc01c8b --- /dev/null +++ b/src/gpu/PtxKernels.ts @@ -0,0 +1,397 @@ +import { BinaryFormatFailure } from "../native/metadata/BinaryContainer.js"; +import type { GpuKernel } from "../domain/gpu/gpuCodeObjects.js"; +import { BinaryResourceFailure } from "../native/metadata/BinaryContainer.js"; +import { GPU_REPORT_BYTES, rejectGpu } from "./GpuSource.js"; +interface Token { + readonly value: string; + readonly start: number; + readonly end: number; +} +/** Byte coordinates are accumulated once, including non-ASCII comments and quoted strings. */ +function* tokens(text: string): Generator { + let at = 0, + byte = 0; + const advance = (): void => { + const code = text.codePointAt(at)!; + at += code > 0xffff ? 2 : 1; + byte += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + }; + while (at < text.length) { + if (/\s/u.test(text[at]!)) { + advance(); + continue; + } + if (text.startsWith("//", at)) { + while (at < text.length && text[at] !== "\n") advance(); + continue; + } + if (text.startsWith("/*", at)) { + advance(); + advance(); + while (at < text.length && !text.startsWith("*/", at)) advance(); + if (at === text.length) rejectGpu("Unterminated PTX comment"); + advance(); + advance(); + continue; + } + const start = byte, + position = at; + if (text[at] === '"') { + advance(); + let closed = false; + while (at < text.length) { + if (text[at] === '"') { + advance(); + closed = true; + break; + } + if (text[at] === "\\") { + advance(); + if (at < text.length) advance(); + } else advance(); + } + if (!closed) rejectGpu("Unterminated PTX string"); + yield { value: '""', start, end: byte }; + continue; + } + if (/[.\w$%@]/u.test(text[at]!)) { + while (at < text.length && /[.\w$%@]/u.test(text[at]!)) advance(); + } else advance(); + if (at - position > 65536) + throw new BinaryResourceFailure( + "structure", + 65536, + "PTX token exceeds the string budget", + ); + yield { value: text.slice(position, at), start, end: byte }; + } +} +const integer = (token: string | undefined): number | null => { + if (token === undefined || !/^(?:0|[1-9][0-9]*)$/u.test(token)) return null; + const value = Number(token); + return Number.isSafeInteger(value) ? value : null; +}; +const sizeOf = (type: string | undefined): number | null => { + const match = /^\.(?:b|u|s|f)(8|16|32|64|128)$/u.exec(type ?? ""); + return match === null ? null : Number(match[1]) / 8; +}; +/** Parse every declared register name/range; unsupported forms poison the type total. */ +const registerDeclaration = ( + values: string[], +): { type: string; count: number | null } => { + const identifier = /^%?[A-Za-z_$][\w$@.]*$/u; + const firstName = values.findIndex((value) => identifier.test(value)); + if (firstName <= 0) + return rejectGpu("PTX register declaration has no type or name"); + const type = values.slice(0, firstName).join(" "); + if (firstName !== 1 || !/^\.(?:pred|[busf](?:8|16|32|64|128))$/u.test(type)) + return { type, count: null }; + let at = firstName, + count = 0; + while (at < values.length) { + if (!identifier.test(values[at++]!)) return { type, count: null }; + let declared = 1; + if (values[at] === "<") { + const range = integer(values[at + 1]); + if (range === null || range === 0 || values[at + 2] !== ">") + return { type, count: null }; + declared = range; + at += 3; + } + if (!Number.isSafeInteger(count + declared)) return { type, count: null }; + count += declared; + if (at === values.length) return { type, count }; + if (values[at++] !== "," || at === values.length) + return { type, count: null }; + } + return { type, count: null }; +}; +const declaration = ( + source: string[], +): { + name: string | null; + type: string | null; + size: number | null; + align: number | null; +} => { + const values = source[0] === ".param" ? source.slice(1) : source; + let at = 0; + const explicit = values[at] === ".align"; + const alignment = explicit ? integer(values[++at]) : null; + if (explicit) at++; + const vector = values[at] === ".v2" ? 2 : values[at] === ".v4" ? 4 : 1; + if (vector !== 1) at++; + const scalar = values[at++], + unit = sizeOf(scalar), + candidate = values[at++]; + const name = + candidate !== undefined && /^[A-Za-z_$][\w$@.]*$/u.test(candidate) + ? candidate + : null; + let size = unit === null ? null : unit * vector; + const type = + scalar === undefined + ? null + : vector === 1 + ? scalar + : `.v${vector} ${scalar}`; + const align = explicit ? alignment : size; + if (values[at] === "[") { + const count = integer(values[at + 1]); + size = + count === null || + size === null || + values[at + 2] !== "]" || + !Number.isSafeInteger(count * size) + ? null + : count * size; + at += 3; + } + if ( + at !== values.length || + name === null || + align === null || + align <= 0 || + !Number.isInteger(Math.log2(align)) + ) + return { name, type, size: null, align: null }; + return { name, type, size, align }; +}; + +/** Entry declarations and virtual resources; no instruction semantics or physical register claims. */ +export function* readPtxKernels( + text: string, + objectId: string, +): Generator<{ kernel: GpuKernel; coverage: "complete" | "partial" }> { + if (Buffer.byteLength(text) > GPU_REPORT_BYTES) + throw new BinaryResourceFailure( + "structure", + GPU_REPORT_BYTES, + "PTX text exceeds the 16 MiB retained report budget", + ); + const iterator = tokens(text); + const next = (): Token => { + const value = iterator.next(); + if (value.done) return rejectGpu("Truncated PTX entry declaration"); + return value.value; + }; + let kernelCount = 0; + let outerDepth = 0; + for (let step = iterator.next(); !step.done; step = iterator.next()) { + const token = step.value; + if (token.value === "{") { + outerDepth++; + continue; + } + if (token.value === "}") { + if (outerDepth === 0) rejectGpu("Unbalanced PTX body"); + outerDepth--; + continue; + } + if (outerDepth !== 0 || token.value !== ".entry") continue; + const name = next(); + if (!/^[A-Za-z_$][\w$@.]*$/u.test(name.value) || next().value !== "(") + rejectGpu("Malformed PTX entry name or parameters"); + const parameters: GpuKernel["parameters"] = []; + let part: string[] = [], + parameterBytes: number | null = 0; + const flush = (): void => { + if (part.length === 0) return; + if (part[0] !== ".param") rejectGpu("Unknown PTX parameter declaration"); + const value = declaration(part); + let offset: number | null = null; + if ( + parameterBytes !== null && + value.size !== null && + value.align !== null && + value.align > 0 && + Number.isSafeInteger(value.align) + ) { + offset = Math.ceil(parameterBytes / value.align) * value.align; + parameterBytes = Number.isSafeInteger(offset + value.size) + ? offset + value.size + : null; + } else parameterBytes = null; + parameters.push({ + ordinal: parameters.length, + name: value.name, + offset, + size: value.size, + type: value.type, + hidden: null, + }); + part = []; + }; + for (let t = next(); t.value !== ")"; t = next()) { + if (t.value === ",") flush(); + else part.push(t.value); + if (part.length > 128 || parameters.length > 65536) + throw new BinaryResourceFailure( + "structure", + 65536, + "PTX parameter declarations exceed the facts budget", + ); + } + flush(); + let t = next(); + // Entry directives can occur between the parameter list and body. + let headerTokens = 0; + while (t.value !== "{" && t.value !== ";") { + if (++headerTokens > 65536) rejectGpu("PTX entry header is too large"); + t = next(); + } + let end = t.end, + shared: number | null = t.value === "{" ? 0 : null; + const virtual = new Map(); + if (t.value === "{") { + let depth = 1; + while (depth > 0) { + t = next(); + end = t.end; + if (t.value === "{") { + depth++; + continue; + } + if (t.value === "}") { + depth--; + continue; + } + if (t.value !== ".reg" && t.value !== ".shared") continue; + const kind = t.value; + const declarationTokens: string[] = []; + for (t = next(); t.value !== ";"; t = next()) { + if (t.value === "}" || declarationTokens.length > 128) + rejectGpu("Malformed PTX resource declaration"); + declarationTokens.push(t.value); + } + if (kind === ".reg") { + const { type, count } = registerDeclaration(declarationTokens); + const previous = virtual.get(type); + const total = + previous === null || count === null + ? null + : (previous ?? 0) + count; + virtual.set( + type, + total === null || !Number.isSafeInteger(total) ? null : total, + ); + } else { + const value = declaration(declarationTokens); + if ( + shared === null || + value.size === null || + value.align === null || + value.align === 0 + ) + shared = null; + else { + const total = + Math.ceil(shared / value.align) * value.align + value.size; + shared = Number.isSafeInteger(total) ? total : null; + } + } + } + } + const kernel: GpuKernel = { + id: `${objectId}/entry@${token.start.toString(16)}`, + object_id: objectId, + representation: "ptx-declarations", + name: name.value, + symbol: null, + parameter_count: parameters.length, + parameter_bytes: parameterBytes, + parameters, + registers: { + physical: null, + sgpr: null, + vgpr: null, + agpr: null, + virtual: [...virtual].map(([type, count]) => ({ type, count })), + }, + shared_bytes: shared, + private_bytes: null, + location: { + container_id: objectId, + offset: token.start, + size: end - token.start, + section_index: null, + section_name: null, + }, + sources: { + parameters: { + container_id: objectId, + offset: token.start, + size: end - token.start, + section_index: null, + section_name: null, + }, + registers: null, + shared: null, + private: null, + }, + limitations: [ + "PTX registers are virtual declarations, not physical allocations. Parameter offsets/sizes and entry-local static shared bytes are derived from supported declarations; module shared bytes, dynamic allocation, device ABI and runtime allocation were not verified.", + ], + }; + let coverage: "complete" | "partial" = "complete"; + if (parameterBytes === null) { + coverage = "partial"; + kernel.limitations.push( + "Unsupported PTX parameter declarations or overflowing layouts leave parameter sizes/offsets and the total byte count unknown.", + ); + } + if (shared === null) { + coverage = "partial"; + kernel.limitations.push( + "Unsupported, dynamic or overflowing PTX shared declarations, or an absent entry body, leave entry-local static shared bytes unknown.", + ); + } + if (kernel.registers.virtual.some((row) => row.count === null)) { + coverage = "partial"; + kernel.limitations.push( + "Unsupported PTX register declarations or overflowing totals leave the affected virtual register count unknown.", + ); + } + if (++kernelCount > 65536) + throw new BinaryResourceFailure( + "structure", + 65536, + "PTX kernel count exceeds the facts budget", + ); + yield { kernel, coverage }; + } + if (outerDepth !== 0) rejectGpu("Unbalanced PTX body"); +} + +/** Positive PTX header recognition uses the same lexer as kernel extraction. */ +export const readPtxTarget = (text: string): string | null => { + const stream = tokens(text); + let version = false, + target: string | null = null; + try { + for (const token of stream) { + if ( + token.value === ".entry" || + token.value === ".func" || + token.value === "{" + ) + break; + if (token.value === ".version") { + const value = stream.next(); + version = !value.done && /^[0-9]+\.[0-9]+$/u.test(value.value.value); + } + if (token.value === ".target") { + const value = stream.next(); + target = + !value.done && /^sm_[0-9]+[a-z]?$/u.test(value.value.value) + ? value.value.value + : null; + } + if (version && target !== null) return target; + } + } catch (cause) { + if (!(cause instanceof BinaryFormatFailure)) throw cause; + // A malformed quote/comment before both directives does not establish PTX identity. + } + return null; +}; diff --git a/src/native/metadata/ElfImage.ts b/src/native/metadata/ElfImage.ts index 71e9f99f3c..86a5251c80 100644 --- a/src/native/metadata/ElfImage.ts +++ b/src/native/metadata/ElfImage.ts @@ -8,89 +8,19 @@ import { type LoadRegion, type ZeroFillMapping, } from "./BinaryContainer.js"; -import { readElfFixedHeader } from "../../domain/elfFixedHeader.js"; +import { readNativeDirectoryFromBuffer } from "./NativeSectionDirectory.js"; /** Read ELF sections and file-backed program mappings without interpreting language metadata. */ export const readElfImage = (bytes: Buffer): BinaryContainer => { - const fixedHeader = readElfFixedHeader(bytes); - if (!fixedHeader.ok) - throw new BinaryFormatFailure("malformed", fixedHeader.error); - const { elfClass: bits, headerSize } = fixedHeader.value; - const reader = new BinaryReader( - bytes, - fixedHeader.value.byteOrder === "little", - ); + const directory = readNativeDirectoryFromBuffer(bytes, "elf"); + const { + bits, + program_offset: phOffset, + program_count: phCount, + program_width: phWidth, + } = directory; + const reader = new BinaryReader(bytes, directory.little); const wide = bits === 64; - const phOffset = reader.word(wide ? 32 : 28, bits); - const shOffset = reader.word(wide ? 40 : 32, bits); - const phWidth = reader.u16(wide ? 54 : 42); - const shWidth = reader.u16(wide ? 58 : 46); - let phCount = reader.u16(wide ? 56 : 44); - let shCount = reader.u16(wide ? 60 : 48); - let namesIndex = reader.u16(wide ? 62 : 50); - if ( - (phCount !== 0 && phOffset < BigInt(headerSize)) || - (shOffset !== 0n && shOffset < BigInt(headerSize)) - ) - throw new BinaryFormatFailure( - "malformed", - "ELF header table overlaps the native header", - ); - if ( - shOffset !== 0n && - (shCount === 0 || phCount === 0xffff || namesIndex === 0xffff) - ) { - if (shWidth < (wide ? 64 : 40)) - throw new BinaryFormatFailure( - "malformed", - "ELF extended section header width is invalid", - ); - const first = reader.fileRange( - shOffset, - BigInt(shWidth), - "ELF section zero", - ).offset; - if (reader.u32(first + 4) !== 0) - throw new BinaryFormatFailure( - "malformed", - "ELF initial section for extended numbering must have SHT_NULL type", - ); - if (shCount === 0) { - const count = reader.word(first + (wide ? 32 : 20), bits); - if (count < 0xff00n || count > BigInt(Number.MAX_SAFE_INTEGER)) - throw new BinaryFormatFailure( - "malformed", - "ELF extended section count is invalid", - ); - shCount = Number(count); - } - if (phCount === 0xffff) { - phCount = reader.u32(first + (wide ? 44 : 28)); - if (phCount < 0xffff) - throw new BinaryFormatFailure( - "malformed", - "ELF extended program count is below its reserved range", - ); - } - if (namesIndex === 0xffff) { - namesIndex = reader.u32(first + (wide ? 40 : 24)); - if (namesIndex < 0xff00) - throw new BinaryFormatFailure( - "malformed", - "ELF extended section name index is below its reserved range", - ); - } - } else if ( - shOffset === 0n && - (shCount !== 0 || namesIndex !== 0 || phCount === 0xffff) - ) { - throw new BinaryFormatFailure("malformed", "ELF section table is missing"); - } - if (phCount !== 0 && phWidth < (wide ? 56 : 32)) - throw new BinaryFormatFailure( - "malformed", - "ELF program header width is invalid", - ); const phStart = reader.table( phOffset, phCount, @@ -135,53 +65,34 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => { if (memorySize > size) zeroFills.push({ address: address + size, size: memorySize - size }); } - if (shCount !== 0) { - if (shWidth < (wide ? 64 : 40) || namesIndex >= shCount) - throw new BinaryFormatFailure( - "malformed", - "ELF section width or name-table index is invalid", - ); - const shStart = reader.table( - shOffset, - shCount, - shWidth, - "ELF section headers", - ); - if (namesIndex !== 0) { - const namesHeader = shStart + namesIndex * shWidth; - if (reader.u32(namesHeader + 4) !== 3) - throw new BinaryFormatFailure( - "malformed", - "ELF section name table is not a string table", - ); - const names = reader.fileRange( - reader.word(namesHeader + (wide ? 24 : 16), bits), - reader.word(namesHeader + (wide ? 32 : 20), bits), - "ELF section names", - ); - for (let index = 0; index < shCount; index++) { - const offset = shStart + index * shWidth; - const nameOffset = reader.u32(offset); - if (nameOffset >= names.size) - throw new BinaryFormatFailure( - "malformed", - "ELF section name offset is outside its string table", - ); - recordElfSection(sections, { - bytes, - names, - nameOffset, - reader, - bits, - wide, - header: offset, - }); - } - } + for (const section of directory.sections) { + if (section.name === "" || section.name_state === "unavailable") continue; + const range = declaredFileRange(bytes.length, section.offset, section.size); + const file_backed = section.type !== 8 && range.file_backed; + const compressed = (section.flags & 0x800n) !== 0n; + const read_only = + section.type === 1 && + file_backed && + !compressed && + (section.flags & 1n) === 0n && + (section.flags & 4n) === 0n && + (section.flags & 2n) !== 0n; + if (section.name === null && !read_only) continue; + sections.push({ + format: "elf", + name: section.name ?? "", + type: section.type, + address: section.address, + declared_range: { offset: section.offset, size: section.size }, + ...range, + file_backed, + compressed, + read_only, + }); } return { format: "elf", - architecture: architectureForElfMachine(fixedHeader.value.machine), + architecture: architectureForElfMachine(directory.machine), bits, byte_order: reader.little ? "little" : "big", mappings, @@ -192,113 +103,6 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => { }; }; -const recordElfSection = ( - sections: ImageSection[], - input: { - readonly bytes: Buffer; - readonly names: { readonly offset: number; readonly size: number }; - readonly nameOffset: number; - readonly reader: BinaryReader; - readonly bits: 32 | 64; - readonly wide: boolean; - readonly header: number; - }, -): void => { - const decoded = elfSectionName(input.bytes, input.names, input.nameOffset); - const header = { - reader: input.reader, - bits: input.bits, - wide: input.wide, - header: input.header, - }; - switch (decoded.status) { - case "named": - sections.push(elfImageSection({ ...header, name: decoded.name })); - return; - case "truncated": { - // Names past this window stay out of .dep-v0 matching. Read-only - // bytes can still hold the only compiler path. - const section = elfImageSection({ ...header, name: "" }); - if (section.read_only) sections.push(section); - return; - } - case "unnamed": - return; - default: { - const unreachable: never = decoded; - return unreachable; - } - } -}; - -const elfSectionName = ( - bytes: Buffer, - names: { readonly offset: number; readonly size: number }, - nameOffset: number, -): - | { readonly status: "named"; readonly name: string } - | { readonly status: "unnamed" } - | { readonly status: "truncated" } => { - const start = names.offset + nameOffset; - const limit = Math.min(start + 64, names.offset + names.size); - const nul = bytes.subarray(start, limit).indexOf(0); - if (nul === 0) return { status: "unnamed" }; - if (nul < 0) return { status: "truncated" }; - return { - status: "named", - name: bytes.toString("latin1", start, start + nul), - }; -}; - -const elfImageSection = ({ - reader, - bits, - wide, - header, - name, -}: { - readonly reader: BinaryReader; - readonly bits: 32 | 64; - readonly wide: boolean; - readonly header: number; - readonly name: string; -}): ImageSection => { - const type = reader.u32(header + 4); - const flags = reader.word(header + 8, bits); - const declared_range = { - offset: reader.word(header + (wide ? 24 : 16), bits), - size: reader.word(header + (wide ? 32 : 20), bits), - }; - const declared = declaredFileRange( - reader.bytes.length, - declared_range.offset, - declared_range.size, - ); - // SHT_NOBITS offsets and sizes do not describe stored bytes. - const range = - type === SHT_NOBITS ? { ...declared, file_backed: false } : declared; - const compressed = (flags & 0x800n) !== 0n; - const readOnly = - type === 1 && - range.file_backed && - !compressed && - (flags & 1n) === 0n && - (flags & 4n) === 0n && - (flags & 2n) !== 0n; - return { - format: "elf", - name, - type, - address: reader.word(header + (wide ? 16 : 12), bits), - declared_range, - ...range, - read_only: readOnly, - compressed, - }; -}; - -const SHT_NOBITS = 8; - /** Map ELF machine identifiers to stable architecture names. */ const architectureForElfMachine = (machine: number): string => { const names = new Map([ diff --git a/src/native/metadata/ElfSymbol.ts b/src/native/metadata/ElfSymbol.ts new file mode 100644 index 0000000000..2729380921 --- /dev/null +++ b/src/native/metadata/ElfSymbol.ts @@ -0,0 +1,36 @@ +import { BinaryReader, BinaryFormatFailure } from "./BinaryContainer.js"; +/** Raw ELF symbol fields; GPU and other consumers own the meaning of processor-specific flags. */ +export const readElfSymbol = ( + bytes: Buffer, + bits: 32 | 64, + little: boolean, +) => { + const r = new BinaryReader(bytes, little); + const wide = bits === 64; + const info = r.range(wide ? 4 : 12, 1, "ELF symbol info")[0]!; + return { + name_offset: r.u32(0), + binding: info >>> 4, + type: info & 15, + other: r.range(wide ? 5 : 13, 1, "ELF symbol other")[0]!, + section_index: r.u16(wide ? 6 : 14), + value: r.word(wide ? 8 : 4, bits), + size: r.word(wide ? 16 : 8, bits), + }; +}; +export const readElfString = (bytes: Buffer, offset: number): string => { + if (offset >= bytes.length) + throw new BinaryFormatFailure( + "malformed", + "ELF string offset is outside its string table", + ); + const end = bytes + .subarray(offset, Math.min(bytes.length, offset + 65536)) + .indexOf(0); + if (end < 0) + throw new BinaryFormatFailure( + "malformed", + "ELF string is unterminated or exceeds the string budget", + ); + return bytes.toString("latin1", offset, offset + end); +}; diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts new file mode 100644 index 0000000000..aa1c890acb --- /dev/null +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -0,0 +1,336 @@ +import { readElfFixedHeader } from "../../domain/elfFixedHeader.js"; +import { + BinaryFormatFailure, + BinaryReader, + BinaryResourceFailure, + declaredFileRange, +} from "./BinaryContainer.js"; + +/** A borrowed positional source. Drivers own admission, cancellation and file identity. */ +export interface NativeReadRequest { + readonly offset: number; + readonly size: number; +} +export interface NativeSection { + readonly index: number; + readonly name: string | null; + readonly name_state: "known" | "unavailable" | "truncated"; + readonly header_offset: number; + readonly offset: bigint; + readonly size: bigint; + readonly address: bigint; + readonly type: number; + readonly flags: bigint; + readonly link: number; + readonly info: number; + readonly entry_size: bigint; + readonly file_backed: boolean; +} +export interface NativeSectionDirectory { + readonly format: "elf" | "pe"; + readonly bits: 32 | 64; + readonly little: boolean; + readonly machine: number; + readonly elf_os_abi: number | null; + readonly flags: number; + readonly program_offset: bigint; + readonly program_count: number; + readonly program_width: number; + readonly sections: readonly NativeSection[]; +} +const STRUCTURE_BYTES = 16 * 1024 * 1024; +const reject = (message: string): never => { + throw new BinaryFormatFailure("malformed", message); +}; +const exact = (value: bigint): number => { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) + reject("Native offset exceeds exact host precision"); + return Number(value); +}; +const request = ( + fileSize: number, + offset: bigint, + size: bigint, +): NativeReadRequest => { + if ( + offset < 0n || + size < 0n || + offset > BigInt(fileSize) || + size > BigInt(fileSize) - offset + ) + reject("Native directory range is truncated or outside the file"); + if (size > BigInt(STRUCTURE_BYTES)) + throw new BinaryResourceFailure( + "structure", + STRUCTURE_BYTES, + "Native directory exceeds the 16 MiB structural decoding budget", + ); + return { offset: exact(offset), size: exact(size) }; +}; + +/** One ELF interpretation for in-memory images and bounded descriptor-backed inspections. */ +export function* readElfSectionDirectory( + fileSize: number, +): Generator { + const prefix = yield request(fileSize, 0n, 16n); + const headerSize = prefix[4] === 1 ? 52 : 64; + const bytes = yield request(fileSize, 0n, BigInt(headerSize)); + const parsed = readElfFixedHeader(bytes, fileSize); + if (!parsed.ok) return reject(parsed.error); + const { elfClass: bits, byteOrder, machine } = parsed.value; + const little = byteOrder === "little"; + const wide = bits === 64; + const r = new BinaryReader(bytes, little); + const program_offset = r.word(wide ? 32 : 28, bits); + const sectionOffset = r.word(wide ? 40 : 32, bits); + const program_width = r.u16(wide ? 54 : 42); + const sectionWidth = r.u16(wide ? 58 : 46); + let program_count = r.u16(wide ? 56 : 44); + let count = r.u16(wide ? 60 : 48); + let namesIndex = r.u16(wide ? 62 : 50); + if ( + (program_count !== 0 && program_offset < BigInt(headerSize)) || + (sectionOffset !== 0n && sectionOffset < BigInt(headerSize)) + ) + reject("ELF header table overlaps the native header"); + if ( + sectionOffset === 0n && + (count !== 0 || namesIndex !== 0 || program_count === 0xffff) + ) + reject("ELF section table is missing"); + if ( + sectionOffset !== 0n && + (count === 0 || namesIndex === 0xffff || program_count === 0xffff) + ) { + if (sectionWidth < (wide ? 64 : 40)) + reject("ELF extended section header width is invalid"); + const first = new BinaryReader( + yield request(fileSize, sectionOffset, BigInt(sectionWidth)), + little, + ); + if (first.u32(4) !== 0) + reject( + "ELF initial section for extended numbering must have SHT_NULL type", + ); + if (count === 0) { + const declared = first.word(wide ? 32 : 20, bits); + if (declared < 0xff00n) reject("ELF extended section count is invalid"); + count = exact(declared); + } + if (namesIndex === 0xffff) { + namesIndex = first.u32(wide ? 40 : 24); + if (namesIndex < 0xff00) + reject("ELF extended section name index is below its reserved range"); + } + if (program_count === 0xffff) { + program_count = first.u32(wide ? 44 : 28); + if (program_count < 0xffff) + reject("ELF extended program count is below its reserved range"); + } + } + if (program_count !== 0) { + if (program_width < (wide ? 56 : 32)) + reject("ELF program header width is invalid"); + request( + fileSize, + program_offset, + BigInt(program_count) * BigInt(program_width), + ); + } + const sections: NativeSection[] = []; + if (count !== 0) { + if (sectionWidth < (wide ? 64 : 40) || namesIndex >= count) + reject("ELF section width or name-table index is invalid"); + const table = new BinaryReader( + yield request( + fileSize, + sectionOffset, + BigInt(count) * BigInt(sectionWidth), + ), + little, + ); + let names: Buffer | null = null; + if (namesIndex !== 0) { + const h = namesIndex * sectionWidth; + if (table.u32(h + 4) !== 3) + reject("ELF section name table is not a string table"); + names = yield request( + fileSize, + table.word(h + (wide ? 24 : 16), bits), + table.word(h + (wide ? 32 : 20), bits), + ); + } + const decodedNames = new Map< + number, + { name: string | null; state: NativeSection["name_state"] } + >(); + let nameBytes = 0; + for (let index = 0; index < count; index++) { + const h = index * sectionWidth; + const nameOffset = table.u32(h); + let name: string | null = null; + let name_state: NativeSection["name_state"] = "unavailable"; + if (names !== null) { + if (nameOffset >= names.length) + reject("ELF section name offset is outside its string table"); + let decoded = decodedNames.get(nameOffset); + if (decoded === undefined) { + const window = names.subarray( + nameOffset, + Math.min(nameOffset + 65536, names.length), + ); + const end = window.indexOf(0); + nameBytes += end < 0 ? window.length : end + 1; + if (nameBytes > STRUCTURE_BYTES) + throw new BinaryResourceFailure( + "structure", + STRUCTURE_BYTES, + "Decoded ELF section names exceed the 16 MiB structural budget", + ); + decoded = { + name: end < 0 ? null : window.toString("latin1", 0, end), + state: end < 0 ? "truncated" : "known", + }; + decodedNames.set(nameOffset, decoded); + } + name_state = decoded.state; + name = decoded.name; + } + const type = table.u32(h + 4); + const offset = table.word(h + (wide ? 24 : 16), bits); + const size = table.word(h + (wide ? 32 : 20), bits); + sections.push({ + index, + name, + name_state, + header_offset: exact(sectionOffset) + h, + offset, + size, + address: table.word(h + (wide ? 16 : 12), bits), + type, + flags: table.word(h + 8, bits), + link: table.u32(h + (wide ? 40 : 24)), + info: table.u32(h + (wide ? 44 : 28)), + entry_size: table.word(h + (wide ? 56 : 36), bits), + file_backed: + type !== 8 && declaredFileRange(fileSize, offset, size).file_backed, + }); + } + } + return { + format: "elf", + elf_os_abi: parsed.value.osAbi, + bits, + little, + machine, + flags: r.u32(wide ? 48 : 36), + program_offset, + program_count, + program_width, + sections, + }; +} + +/** Shared COFF section fields; content extent excludes file-alignment padding. */ +export const readPeSectionHeader = (bytes: Buffer) => { + const r = new BinaryReader(bytes, true); + const name = r.range(0, 8, "PE section name"); + const nul = name.indexOf(0); + const virtual_size = r.u32(8); + const raw_size = r.u32(16); + return { + name: name.toString("latin1", 0, nul < 0 ? name.length : nul), + virtual_size, + virtual_address: r.u32(12), + raw_size, + offset: r.u32(20), + characteristics: r.u32(36), + size: virtual_size === 0 ? raw_size : Math.min(virtual_size, raw_size), + }; +}; + +export function* readPeSectionDirectory( + fileSize: number, +): Generator { + const dos = new BinaryReader(yield request(fileSize, 0n, 64n), true); + if (dos.u16(0) !== 0x5a4d) reject("Missing DOS MZ header"); + const pe = dos.u32(60); + if (pe < 64) reject("PE signature overlaps the DOS header"); + const coff = new BinaryReader(yield request(fileSize, BigInt(pe), 24n), true); + if (coff.u32(0) !== 0x4550) reject("PE signature is invalid"); + const optionalSize = coff.u16(20); + const optional = new BinaryReader( + yield request(fileSize, BigInt(pe + 24), BigInt(optionalSize)), + true, + ); + const magic = optional.u16(0); + const bits = magic === 0x10b ? 32 : magic === 0x20b ? 64 : null; + if (bits === null) + throw new BinaryFormatFailure( + "unsupported", + "Only PE32/PE32+ images are supported", + ); + const minimum = bits === 64 ? 112 : 96; + if ( + optionalSize < minimum || + optional.u32(minimum - 4) * 8 > optionalSize - minimum + ) + reject("PE optional header or data directories are truncated"); + const count = coff.u16(6); + const start = pe + 24 + optionalSize; + const table = yield request(fileSize, BigInt(start), BigInt(count) * 40n); + const sections: NativeSection[] = []; + for (let index = 0; index < count; index++) { + const row = readPeSectionHeader( + table.subarray(index * 40, (index + 1) * 40), + ); + sections.push({ + index, + name: row.name, + name_state: "known", + header_offset: start + index * 40, + offset: BigInt(row.offset), + size: BigInt(row.size), + address: BigInt(row.virtual_address), + type: 0, + flags: BigInt(row.characteristics), + link: 0, + info: 0, + entry_size: 0n, + file_backed: declaredFileRange( + fileSize, + BigInt(row.offset), + BigInt(row.size), + ).file_backed, + }); + } + return { + format: "pe", + elf_os_abi: null, + bits, + little: true, + machine: coff.u16(4), + flags: coff.u16(22), + program_offset: 0n, + program_count: 0, + program_width: 0, + sections, + }; +} + +/** Synchronous driver borrows bytes; file drivers satisfy the identical requests without loading payloads. */ +export const readNativeDirectoryFromBuffer = ( + bytes: Buffer, + format: "elf" | "pe", +): NativeSectionDirectory => { + const steps = + format === "elf" + ? readElfSectionDirectory(bytes.length) + : readPeSectionDirectory(bytes.length); + let next = steps.next(); + while (!next.done) { + const { offset, size } = next.value; + next = steps.next(bytes.subarray(offset, offset + size)); + } + return next.value; +}; diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index f694550c71..b68b1f6eb7 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -1,3 +1,4 @@ +import { readPeSectionHeader } from "./NativeSectionDirectory.js"; import { observeMzWindowsHeader, parseDosMzHeader, @@ -95,16 +96,15 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { const sections: ImageSection[] = []; for (let index = 0; index < count; index++) { const entry = start + index * 40; - const sectionName = peSectionName( - reader.range(entry, 8, "PE section name"), - ); - const virtualSize = reader.u32(entry + 8); - const virtualAddress = reader.u32(entry + 12); - const size = reader.u32(entry + 16); - const offset = reader.u32(entry + 20); - const characteristics = reader.u32(entry + 36); - // SizeOfRawData includes file-alignment padding. VirtualSize is the content. - const contentSize = virtualSize === 0 ? size : Math.min(virtualSize, size); + const { + name: sectionName, + virtual_size: virtualSize, + virtual_address: virtualAddress, + raw_size: size, + offset, + characteristics, + size: contentSize, + } = readPeSectionHeader(reader.range(entry, 40, "PE section header")); sections.push( peImageSection({ reader, @@ -162,11 +162,6 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { }; }; -const peSectionName = (bytes: Buffer): string => { - const nul = bytes.indexOf(0); - return bytes.toString("latin1", 0, nul < 0 ? bytes.length : nul); -}; - const peImageSection = ({ reader, name, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 4d01bb3b7a..c2768c5f90 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,3 +1,5 @@ +import { createGpuCodeObjectsService } from "../composition/gpu.js"; +import { registerGpuCodeObjectsTools } from "./registerGpuCodeObjectsTools.js"; import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "./toolResultEncoding.js"; import { createPacketCaptureService } from "../composition/captures.js"; @@ -332,6 +334,7 @@ export const createServer = ( const goBinary = options.goBinary ?? createGoBinaryService(); const rustBinary = options.rustBinary ?? createRustBinaryService(); const modelArtifact = createModelArtifactService(); + const gpuCodeObjects = createGpuCodeObjectsService(); const il2cppMetadata = options.il2cppMetadata ?? createIl2cppMetadataService(); const peResources = createPeResourcesService(); @@ -420,6 +423,7 @@ export const createServer = ( rustBinary.close(), il2cppMetadata.close(), modelArtifact.close(), + gpuCodeObjects.close(), peResources.close(), electronFuses.close(), webNetworkCapture.close(), @@ -503,6 +507,14 @@ export const createServer = ( analysisAdmission, evidenceById, ); + registerGpuCodeObjectsTools( + server, + gpuCodeObjects, + toolLogger, + recordEvidence, + analysisAdmission, + evidenceById, + ); registerBinaryAnalysisTools(toolContext); registerReverseEngineeringTools( server, diff --git a/src/server/registerGpuCodeObjectsTools.ts b/src/server/registerGpuCodeObjectsTools.ts new file mode 100644 index 0000000000..08d82ba8d0 --- /dev/null +++ b/src/server/registerGpuCodeObjectsTools.ts @@ -0,0 +1,34 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import type { GpuCodeObjectsService } from "../application/gpu/GpuCodeObjectsService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind the named GPU contract to the shared inspection and session Evidence owners. */ +export const registerGpuCodeObjectsTools = ( + server: EvidenceMcpServer, + service: GpuCodeObjectsService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, + evidenceById?: EvidenceLookup, +): void => { + const contract = toolContract("inspect_gpu_code_objects"); + const handler = createEvidenceToolHandler( + server, + (input, options) => + service.inspect(input, options, { recordEvidence, evidenceById }), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/gpuCodeObjectsCli.test.ts b/tests/acceptance/gpuCodeObjectsCli.test.ts new file mode 100644 index 0000000000..088a683ff4 --- /dev/null +++ b/tests/acceptance/gpuCodeObjectsCli.test.ts @@ -0,0 +1,356 @@ +import { expect, it } from "vitest"; +import { writeFile, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { gpuCodeObjectsSchema } from "../../src/domain/gpu/gpuCodeObjects.js"; +import { + gpuCodeObjectsPageViewResultSchema, + gpuCodeObjectsSummaryViewResultSchema, +} from "../../src/domain/analysisView/analysisView.js"; +import { parseEvidence, createEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { cliTest } from "../support/cli/cliFixture.js"; +import { + cudaFixture, + ccobFixture, + ptxFixture, + amdFixture, + cubinFixture, +} from "../fixtures/gpu/codeObjects.js"; + +cliTest( + "reads CUDA/PTX and compressed HIP through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-gpu-cli-"); + for (const [name, bytes] of [ + ["cuda", cudaFixture("zstd")], + ["hip", ccobFixture()], + ["amd", amdFixture()], + ] as const) { + const path = join(root, name); + await writeFile(path, bytes); + const response = await cli.run({ + arguments: [ + "inspect-gpu-code-objects", + path, + "--analysis", + "kernels", + "--detail", + "complete", + ...(name === "amd" + ? ["--targets", '["amdgcn-amd-amdhsa--gfx900"]'] + : []), + "--json", + ], + }); + expect(response.exitCode, response.stderr).toBe(0); + const evidence = parseEvidence( + toolContract("inspect_gpu_code_objects").outputSchema.parse( + response.json, + ), + ); + const report = gpuCodeObjectsSchema.parse(evidence.normalized_result); + expect(report.artifact.sha256).toBe( + createHash("sha256").update(bytes).digest("hex"), + ); + expect(report.kernels).toHaveLength(name === "cuda" ? 2 : 1); + expect(report.kernels[0]!.parameter_bytes).toBe(12); + expect(report.coverage.payloads).toBe("complete"); + } + }, +); + +cliTest( + "attributes malformed selector JSON to its actual CLI option", + async ({ cli }) => { + for (const name of ["object_ids", "targets", "view"]) { + const response = await cli.run({ + arguments: [ + "inspect-gpu-code-objects", + "/not-opened-for-invalid-selectors", + `--${name}`, + "[", + "--json", + ], + }); + expect(response.exitCode).not.toBe(0); + expect(response.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_gpu_code_objects", + issues: [ + { + path: [name], + reason: "invalid_format", + message: `${name} must be valid JSON`, + }, + ], + }, + }); + } + }, +); + +it("preserves incomplete decoder coverage through MCP target and object pages", async () => { + const root = await createTestTempDirectory("rea-gpu-partial-"), + { call } = await connectLocalToolsMcp(); + for (const missing of [ + "section-names", + "symbols", + "later-amd-note", + "target-conflict", + "ptx-parameter", + "ptx-shared", + ]) { + const bytes = + missing === "later-amd-note" + ? amdFixture(false, Buffer.alloc(3)) + : missing === "target-conflict" + ? cudaFixture("none", [ + { + kind: 2, + target: 80, + bytes: cubinFixture({ architecture: 90 }), + }, + ]) + : missing === "ptx-parameter" + ? Buffer.from( + ptxFixture.replace(".u64 output", ".u64 .ptr .global output"), + ) + : missing === "ptx-shared" + ? Buffer.from(ptxFixture.replace("scratch[32]", "scratch[]")) + : cubinFixture(), + path = join(root, missing); + if (missing === "section-names") bytes.writeUInt16LE(0, 62); + else if (missing === "symbols") + bytes.writeUInt32LE(1, Number(bytes.readBigUInt64LE(40)) + 3 * 64 + 4); + await writeFile(path, bytes); + const reply = await call("inspect_gpu_code_objects", { + path, + analysis: "kernels", + view: { collection: "targets" }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const targets = gpuCodeObjectsPageViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(targets.source_coverage.payloads).toBe("partial"); + expect(targets.items).toEqual([ + expect.objectContaining({ + target: + missing === "later-amd-note" ? "amdgcn-amd-amdhsa--gfx900" : "sm_80", + kernel_count: missing === "symbols" ? 0 : 1, + kernel_coverage: "partial", + }), + ]); + const objectReply = await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: targets.parent_evidence_id, + }, + view: { + kind: "gpu-code-objects", + collection: "objects", + fields: ["kind"], + }, + }); + expect(objectReply.isError, JSON.stringify(objectReply)).not.toBe(true); + const objects = gpuCodeObjectsPageViewResultSchema.parse( + parseEvidence(objectReply.structuredContent).normalized_result, + ); + expect(objects.items).toEqual([ + expect.objectContaining({ + inspection: missing === "later-amd-note" ? "malformed" : "partial", + }), + ]); + if (missing === "target-conflict") + expect(objects.items[0]).toMatchObject({ + target: "sm_80", + payload_target: "sm_90", + target_consistency: "conflict", + }); + } +}); + +it("keeps unknown target discovery visible in the default MCP summary", async () => { + const root = await createTestTempDirectory("rea-gpu-unknown-target-"), + path = join(root, "cuda"); + await writeFile(path, cubinFixture({ osAbi: 0x41, flags: 0x06016402 })); + const { call } = await connectLocalToolsMcp(); + const reply = await call("inspect_gpu_code_objects", { path }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const result = gpuCodeObjectsSummaryViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(result.summary.coverage.discovery).toBe("partial"); + expect(result.summary.counts).toMatchObject({ objects: 1, targets: 0 }); +}); + +it("retains selected kernel facts and complete PTX for historical MCP pages after source removal", async () => { + const root = await createTestTempDirectory("rea-gpu-mcp-"), + path = join(root, "application"); + await writeFile(path, cudaFixture("zstd")); + const { call } = await connectLocalToolsMcp(); + const inventoryReply = await call("inspect_gpu_code_objects", { + path, + view: { collection: "objects", fields: ["kind", "target"] }, + }); + expect(inventoryReply.isError, JSON.stringify(inventoryReply)).not.toBe(true); + const inventory = gpuCodeObjectsPageViewResultSchema.parse( + parseEvidence(inventoryReply.structuredContent).normalized_result, + ); + expect(inventory.items).toHaveLength(2); + expect(inventory.items[0]).not.toHaveProperty("sha256"); + expect(inventory.items[0]).toHaveProperty("metadata_location"); + const targetsReply = await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: inventory.parent_evidence_id, + }, + view: { kind: "gpu-code-objects", collection: "targets" }, + }); + expect(targetsReply.isError, JSON.stringify(targetsReply)).not.toBe(true); + const targets = gpuCodeObjectsPageViewResultSchema.parse( + parseEvidence(targetsReply.structuredContent).normalized_result, + ); + expect(targets.items).toHaveLength(2); + expect(targets.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: "sm_80", + object_count: 1, + kernel_count: 0, + kernel_coverage: "not-requested", + }), + expect.objectContaining({ + target: "sm_90", + object_count: 1, + kernel_count: 0, + kernel_coverage: "not-requested", + }), + ]), + ); + const inspectedReply = await call("inspect_gpu_code_objects", { + path, + analysis: "kernels", + view: { + collection: "kernels", + names: ["add_one"], + fields: ["parameter_count", "registers"], + }, + }); + expect(inspectedReply.isError, JSON.stringify(inspectedReply)).not.toBe(true); + const inspected = gpuCodeObjectsPageViewResultSchema.parse( + parseEvidence(inspectedReply.structuredContent).normalized_result, + ); + expect(inspected.items).toHaveLength(2); + expect(inspected.items[0]).not.toHaveProperty("parameters"); + expect(inspected.items[0]).toHaveProperty("sources"); + const objectId = + "object_id" in inspected.items[0]! + ? inspected.items[0]!.object_id + : undefined; + expect(objectId).toBeTypeOf("string"); + await unlink(path); + const source = { + kind: "retained-evidence", + evidence_id: inspected.parent_evidence_id, + }; + const textReply = await call("inspect_analysis_view", { + source, + view: { + kind: "gpu-code-objects", + collection: "ptx", + object_id: objectId, + offset: 0, + limit: 4096, + }, + }); + expect(textReply.isError, JSON.stringify(textReply)).not.toBe(true); + const text = gpuCodeObjectsPageViewResultSchema.parse( + parseEvidence(textReply.structuredContent).normalized_result, + ); + expect(text.ptx!.text).toBe(ptxFixture); + expect(text.ptx!.sha256).toBe( + createHash("sha256").update(ptxFixture).digest("hex"), + ); + const summaryReply = await call("inspect_analysis_view", { + source, + view: { kind: "summary" }, + }); + expect(summaryReply.isError, JSON.stringify(summaryReply)).not.toBe(true); + const summary = gpuCodeObjectsSummaryViewResultSchema.parse( + parseEvidence(summaryReply.structuredContent).normalized_result, + ); + expect(summary.summary.counts).toMatchObject({ + objects: 2, + kernels: 2, + ptx_artifacts: 1, + }); + expect(summary.summary).not.toHaveProperty("ptx_artifacts"); +}); + +it("rejects forged duplicate object IDs and mismatched Evidence subjects through MCP", async () => { + const root = await createTestTempDirectory("rea-gpu-import-"), + path = join(root, "cuda"); + await writeFile(path, cudaFixture()); + const { call } = await connectLocalToolsMcp(); + const reply = await call("inspect_gpu_code_objects", { + path, + analysis: "kernels", + detail: "complete", + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const parent = parseEvidence(reply.structuredContent), + report = gpuCodeObjectsSchema.parse(parent.normalized_result); + const forgedHeaderReport = gpuCodeObjectsSchema.parse( + parent.normalized_result, + ); + forgedHeaderReport.objects[1]!.cuda_target_header!.location.container_id = + forgedHeaderReport.objects[0]!.id; + const forgedHeader = createEvidence(undefined, parent.provider, { + operation: parent.operation, + parameters: parent.parameters, + result: forgedHeaderReport, + }); + expect( + parseMcpToolError( + await call("inspect_analysis_view", { + source: { kind: "inline", evidence: forgedHeader }, + view: { kind: "gpu-code-objects", collection: "objects" }, + }), + ).error, + ).toMatchObject({ code: "invalid_request" }); + report.objects[1]!.id = report.objects[0]!.id; + const forged = createEvidence(undefined, parent.provider, { + operation: "inspect_gpu_code_objects", + parameters: parent.parameters, + result: report, + }); + const failure = parseMcpToolError( + await call("inspect_analysis_view", { + source: { kind: "inline", evidence: forged }, + view: { kind: "gpu-code-objects", collection: "objects" }, + }), + ); + expect(failure.error).toMatchObject({ code: "invalid_request" }); + const mismatched = createEvidence( + { path: join(root, "other-file"), sha256: "f".repeat(64), format: "file" }, + parent.provider, + { + operation: parent.operation, + parameters: parent.parameters, + result: parent.normalized_result, + }, + ); + const identityFailure = parseMcpToolError( + await call("inspect_analysis_view", { + source: { kind: "inline", evidence: mismatched }, + view: { kind: "summary" }, + }), + ); + expect(identityFailure.error).toMatchObject({ code: "invalid_request" }); +}); diff --git a/tests/boundary/gpu/codeObjects.test.ts b/tests/boundary/gpu/codeObjects.test.ts new file mode 100644 index 0000000000..e464fa599b --- /dev/null +++ b/tests/boundary/gpu/codeObjects.test.ts @@ -0,0 +1,529 @@ +import { expect, it, onTestFinished } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { gpuCodeObjectsPartialSchema } from "../../../src/domain/gpu/gpuCodeObjects.js"; +import { GpuCodeObjectsProvider } from "../../../src/gpu/GpuCodeObjectsProvider.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + cudaFixture, + cubinFixture, + ccobFixture, + hipFixture, + peGpuFixture, + elfFixture, + ptxFixture, + amdFixture, +} from "../../fixtures/gpu/codeObjects.js"; +const inspect = async ( + bytes: Buffer, + options: { + analysis?: "inventory" | "kernels"; + targets?: string[]; + object_ids?: string[]; + expected_sha256?: string; + } = {}, +) => { + const root = await createTestTempDirectory("rea-gpu-"), + path = join(root, "no-format-extension"); + await writeFile(path, bytes); + const provider = new GpuCodeObjectsProvider(); + onTestFinished(() => provider.close()); + return provider.inspect({ path, analysis: "kernels", ...options }); +}; +it.each(["none", "zstd"] as const)( + "reads selected CUDA metadata and PTX with %s compression", + async (compression) => { + const result = await inspect(cudaFixture(compression)); + if (!result.ok) throw result.error; + expect(result.value.coverage).toMatchObject({ + discovery: "complete", + payloads: "complete", + }); + expect( + result.value.kernels.map((row) => [ + row.name, + row.parameter_count, + row.parameter_bytes, + row.shared_bytes, + row.registers.physical, + ]), + ).toEqual([ + ["add_one", 2, 12, 32, null], + ["add_one", 2, 12, 1056, 10], + ]); + expect(result.value.ptx_artifacts[0]!.text).toBe(ptxFixture); + expect( + result.value.kernels[1]!.parameters.map((row) => [ + row.ordinal, + row.offset, + row.size, + ]), + ).toEqual([ + [0, 0, 8], + [1, 8, 4], + ]); + }, +); +it.each([1, 2, 3] as const)( + "reads CCOB v%d zlib/Zstd and retains exact decoded provenance", + async (version) => { + for (const method of ["zlib", "zstd"] as const) { + const result = await inspect(ccobFixture(version, method)); + if (!result.ok) throw result.error; + expect(result.value.kernels[0]).toMatchObject({ + name: "add_one", + parameter_count: 2, + parameter_bytes: 12, + registers: { sgpr: 6, vgpr: 2 }, + shared_bytes: 0, + }); + expect(result.value.objects[2]!.location.container_id).toBe( + result.value.objects[0]!.id, + ); + } + }, +); +it("does not decode unselected payloads and keeps stable positional identities", async () => { + const bytes = cudaFixture("zstd"); + const inventory = await inspect(bytes, { analysis: "inventory" }), + selected = await inspect(bytes, { targets: ["sm_90"] }); + if (!inventory.ok) throw inventory.error; + if (!selected.ok) throw selected.error; + expect(inventory.value.kernels).toEqual([]); + expect(selected.value.objects.map((row) => row.id)).toEqual( + inventory.value.objects.map((row) => row.id), + ); + expect(selected.value.ptx_artifacts).toEqual([]); + expect(selected.value.kernels).toHaveLength(1); + expect(selected.value.coverage.payloads).toBe("partial"); +}); +it.each(["cubin", "ptx"] as const)( + "retains conflicting container and %s target declarations as partial facts", + async (kind) => { + const bytes = cudaFixture("none", [ + { + kind: kind === "cubin" ? 2 : 1, + target: 80, + bytes: + kind === "cubin" + ? cubinFixture({ architecture: 90 }) + : Buffer.from(ptxFixture.replace("sm_80", "sm_90")), + }, + ]); + const result = await inspect(bytes, { targets: ["sm_80"] }); + if (!result.ok) throw result.error; + expect(result.value.objects[0]).toMatchObject({ + target: "sm_80", + payload_target: "sm_90", + target_consistency: "conflict", + inspection: "partial", + }); + expect(result.value.coverage.payloads).toBe("partial"); + expect(result.value.kernels).toHaveLength(1); + expect(result.value.kernels[0]!.limitations.join(" ")).toContain( + "inventory sm_80; payload sm_90", + ); + const unexamined = await inspect(bytes, { targets: ["sm_90"] }); + if (!unexamined.ok) throw unexamined.error; + expect(unexamined.value.objects[0]).toMatchObject({ + target: "sm_80", + payload_target: null, + target_consistency: "unexamined", + inspection: "not-selected", + }); + expect(unexamined.value.coverage.payloads).toBe("partial"); + }, +); +it("keeps CUBIN base-only agreement distinct from verified feature-profile agreement", async () => { + const bytes = cudaFixture("none", [ + { kind: 2, target: 90, bytes: cubinFixture({ architecture: 90 }) }, + ]); + bytes.writeBigUInt64LE(bytes.readBigUInt64LE(16 + 40) | 0x100000n, 16 + 40); + const result = await inspect(bytes, { targets: ["sm_90a"] }); + if (!result.ok) throw result.error; + expect(result.value.objects[0]).toMatchObject({ + target: "sm_90a", + payload_target: "sm_90", + target_consistency: "base-only", + inspection: "complete", + }); +}); +it.each([ + [100, 0x06006402], + [101, 0x06006502], + [103, 0x05006702], + [120, 0x06007802], + [121, 0x06007902], +])( + "reads ABI-qualified CUDA target sm_%d from released NVIDIA header flags", + async (architecture, flags) => { + const result = await inspect(cubinFixture({ osAbi: 0x41, flags })); + if (!result.ok) throw result.error; + expect(result.value.objects[0]).toMatchObject({ + target: `sm_${architecture}`, + payload_target: `sm_${architecture}`, + target_consistency: "single-source", + inspection: "complete", + cuda_target_header: { + os_abi: 0x41, + flags: `0x${flags.toString(16).padStart(8, "0")}`, + }, + }); + }, +); +it("keeps unsupported CUDA flag layouts unknown while retaining raw header evidence", async () => { + const result = await inspect( + cubinFixture({ osAbi: 0x41, flags: 0x06016402 }), + ); + if (!result.ok) throw result.error; + expect(result.value.objects[0]).toMatchObject({ + target: null, + payload_target: null, + target_consistency: "unknown", + inspection: "partial", + cuda_target_header: { os_abi: 0x41, flags: "0x06016402" }, + }); + expect(result.value.kernels).toHaveLength(1); + expect(result.value.kernels[0]!.limitations.join(" ")).toContain( + "target encoding is unknown", + ); + const inventory = await inspect( + cubinFixture({ osAbi: 0x41, flags: 0x06016402 }), + { analysis: "inventory" }, + ); + if (!inventory.ok) throw inventory.error; + expect(inventory.value.coverage.discovery).toBe("partial"); +}); +it("discovers standalone AMD targets before exact selection", async () => { + const bytes = amdFixture(), + target = "amdgcn-amd-amdhsa--gfx900"; + const inventory = await inspect(bytes, { analysis: "inventory" }); + const selected = await inspect(bytes, { targets: [target] }); + const skipped = await inspect(bytes, { + targets: ["amdgcn-amd-amdhsa--gfx1100"], + }); + if (!inventory.ok) throw inventory.error; + if (!selected.ok) throw selected.error; + if (!skipped.ok) throw skipped.error; + expect(inventory.value.objects[0]).toMatchObject({ + target, + inspection: "inventory", + sha256: null, + }); + expect(inventory.value.kernels).toEqual([]); + expect(selected.value.objects[0]).toMatchObject({ + target, + inspection: "complete", + id: inventory.value.objects[0]!.id, + }); + expect(selected.value.kernels).toHaveLength(1); + expect(selected.value.kernels[0]).toMatchObject({ + representation: "amdgpu-metadata", + parameter_bytes: 12, + }); + expect(skipped.value.objects[0]).toMatchObject({ + target, + inspection: "not-selected", + sha256: null, + }); + expect(skipped.value.kernels).toEqual([]); + expect(skipped.value.coverage.payloads).toBe("partial"); +}); +it.each([ + "hipv4-amdgcn-amd-amdhsa--gfx900", + "hipv4-amdgcn-amd-amdhsa-gfx900", + "hipv4-amdgcn-amd-amdhsa-opaque", +])("keeps discovered HIP targets selectable for %s", async (targetId) => { + const bytes = hipFixture(amdFixture(), targetId); + const inventory = await inspect(bytes, { analysis: "inventory" }); + if (!inventory.ok) throw inventory.error; + const row = inventory.value.objects.find((value) => value.kind === "amdgpu")!; + expect(row.target).not.toBeNull(); + const selected = await inspect(bytes, { targets: [row.target!] }); + if (!selected.ok) throw selected.error; + expect(selected.value.kernels).toHaveLength(1); + expect( + selected.value.objects.find((value) => value.kind === "amdgpu"), + ).toMatchObject({ + target: row.target, + payload_target: "amdgcn-amd-amdhsa--gfx900", + target_consistency: targetId.endsWith("opaque") + ? "single-source" + : "consistent", + inspection: "complete", + }); +}); +it("bounds standalone AMD note discovery before retaining metadata", async () => { + const result = await inspect( + elfFixture(224, [ + { name: ".note", type: 7, bytes: Buffer.alloc(8 * 1024 * 1024 + 1) }, + ]), + { analysis: "inventory" }, + ); + if (!result.ok) throw result.error; + expect(result.value.objects[0]).toMatchObject({ + target: null, + inspection: "capacity", + }); + expect(result.value.objects[0]!.limitations).toContain( + "AMDGPU ELF note discovery exceeds the 8 MiB retained metadata budget", + ); + expect(result.value.kernels).toEqual([]); +}); +it("reads PE raw-size fallback and ELF-host section locations", async () => { + for (const bytes of [ + peGpuFixture(), + elfFixture(62, [{ name: ".nv_fatbin", bytes: cudaFixture() }]), + ]) { + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.kernels).toHaveLength(2); + expect(result.value.objects[0]!.location.section_index).not.toBeNull(); + expect(result.value.objects[1]!.location.container_id).toBe( + result.value.objects[0]!.id, + ); + } +}); +it("coalesces aliased CUDA metadata before expanding the temporary index", async () => { + const bytes = cubinFixture({ globalRecords: 65536, globalAliases: 1024 }); + expect(bytes.length).toBeLessThan(1024 * 1024); + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.objects[0]!.inspection).toBe("complete"); + expect(result.value.kernels).toHaveLength(1); + expect(result.value.kernels[0]!.registers.physical).toBe(10); +}); +it("retains unknown PTX virtual counts and partial payload coverage", async () => { + const result = await inspect( + Buffer.from(ptxFixture.replace("%r<9>", "r0, r1, %r<4>, %s<8>")), + ); + if (!result.ok) throw result.error; + expect(result.value.kernels[0]!.registers.virtual[0]).toEqual({ + type: ".b32", + count: 14, + }); + const unsupported = await inspect( + Buffer.from(ptxFixture.replace("%r<9>", "%r[4]")), + ); + if (!unsupported.ok) throw unsupported.error; + expect(unsupported.value.objects[0]!.inspection).toBe("partial"); + expect(unsupported.value.coverage.payloads).toBe("partial"); + expect(unsupported.value.kernels[0]!.registers.virtual[0]).toEqual({ + type: ".b32", + count: null, + }); +}); +it.each([ + ["parameter", ".u64 output", ".u64 .ptr .global output"], + ["shared", "scratch[32]", "scratch[]"], +] as const)( + "marks unknown PTX %s declarations partial while retaining known facts", + async (kind, from, to) => { + const bytes = Buffer.from(ptxFixture.replace(from, to)); + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.objects[0]!.inspection).toBe("partial"); + expect(result.value.coverage.payloads).toBe("partial"); + expect(result.value.ptx_artifacts[0]!.text).toBe(bytes.toString()); + const kernel = result.value.kernels[0]!; + expect(kernel.registers.virtual[0]).toEqual({ type: ".b32", count: 9 }); + expect(kernel.limitations.join(" ")).toContain(`PTX ${kind} declarations`); + if (kind === "parameter") { + expect(kernel.parameter_count).toBe(2); + expect(kernel.parameter_bytes).toBeNull(); + expect( + kernel.parameters.map(({ size, offset }) => [size, offset]), + ).toEqual([ + [null, null], + [4, null], + ]); + expect(kernel.shared_bytes).toBe(32); + } else { + expect(kernel.parameter_bytes).toBe(12); + expect(kernel.shared_bytes).toBeNull(); + } + }, +); +it.each(["whitespace", "comment"] as const)( + "recognizes standalone PTX after more than 64 KiB of leading %s", + async (kind) => { + const prefix = + kind === "whitespace" + ? " ".repeat(70 * 1024) + : `/*${"x".repeat(70 * 1024)}*/\n`; + const bytes = Buffer.from(prefix + ptxFixture); + const inventory = await inspect(bytes, { analysis: "inventory" }); + const selected = await inspect(bytes, { targets: ["sm_80"] }); + if (!inventory.ok) throw inventory.error; + if (!selected.ok) throw selected.error; + expect(inventory.value.objects[0]!.target).toBe("sm_80"); + expect(selected.value.kernels).toHaveLength(1); + expect(selected.value.kernels[0]!.location.offset).toBeGreaterThan( + 64 * 1024, + ); + }, +); +it.each(["truncated", "invalid", "extension"] as const)( + "retains earlier AMD kernel declarations after a later %s note", + async (kind) => { + const tail = Buffer.alloc(kind === "truncated" ? 3 : 24); + if (kind !== "truncated") { + tail.writeUInt32LE(7, 0); + tail.writeUInt32LE(kind === "invalid" ? 1 : 3, 4); + tail.writeUInt32LE(32, 8); + tail.write("AMDGPU\0", 12); + tail[20] = kind === "invalid" ? 0xc1 : 0xd4; + } + for (const bytes of [ + amdFixture(false, tail), + hipFixture(amdFixture(false, tail)), + ]) { + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.kernels).toHaveLength(1); + expect(result.value.kernels[0]).toMatchObject({ + name: "add_one", + parameter_bytes: 12, + registers: { sgpr: 6, vgpr: 2 }, + }); + expect( + result.value.objects.find((row) => row.kind === "amdgpu")!.inspection, + ).toBe(kind === "extension" ? "unsupported" : "malformed"); + expect(result.value.coverage.payloads).toBe("partial"); + } + }, +); +it("bounds cumulative CUDA records across separate in-range metadata sections", async () => { + const record = Buffer.from("042f0800010000000a000000", "hex"), + records = Buffer.alloc(record.length * 131073); + for (let offset = 0; offset < records.length; offset += record.length) + record.copy(records, offset); + const result = await inspect( + elfFixture(190, [ + { name: ".nv.info", type: 0x70000000, bytes: records }, + { name: ".nv.info", type: 0x70000000, bytes: records }, + ]), + ); + if (!result.ok) throw result.error; + expect(result.value.objects[0]!.inspection).toBe("capacity"); + expect(result.value.objects[0]!.limitations.join(" ")).toContain( + "aggregate record/symbol visits", + ); +}); +it.each(["section-names", "symbols"] as const)( + "retains partial CUBIN coverage when %s are unavailable", + async (missing) => { + const bytes = cubinFixture(); + if (missing === "section-names") bytes.writeUInt16LE(0, 62); + else bytes.writeUInt32LE(1, Number(bytes.readBigUInt64LE(40)) + 3 * 64 + 4); + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.objects[0]!.inspection).toBe("partial"); + expect(result.value.coverage.payloads).toBe("partial"); + expect(result.value.kernels).toHaveLength(missing === "symbols" ? 0 : 1); + expect(result.value.objects[0]!.limitations.join(" ")).toContain( + missing === "symbols" + ? "kernel declaration coverage is unknown" + : "named metadata coverage is partial", + ); + }, +); +it("preserves parameters for long mangled names and rejects conflicting register counts", async () => { + const name = "kernel_" + "argument".repeat(64); + const result = await inspect( + cubinFixture({ name, textRegisters: 9, globalRegisters: 10 }), + ); + if (!result.ok) throw result.error; + expect(result.value.kernels[0]).toMatchObject({ + name, + parameter_count: 2, + registers: { physical: null }, + }); + expect(result.value.kernels[0]!.limitations.join(" ")).toContain( + "Conflicting", + ); +}); +it("iterates flat concatenated fatbins beyond the genuine container nesting limit", async () => { + const result = await inspect( + Buffer.concat(Array.from({ length: 20 }, () => cudaFixture())), + ); + if (!result.ok) throw result.error; + expect(result.value.objects).toHaveLength(40); + expect(result.value.kernels).toHaveLength(40); + expect(result.value.coverage.discovery).toBe("complete"); +}); +it("reports checksum corruption and unknown compression as partial, retaining known entries", async () => { + const corrupt = ccobFixture(); + corrupt[16] = corrupt[16]! ^ 1; + const result = await inspect(corrupt); + if (!result.ok) throw result.error; + expect(result.value.coverage.discovery).toBe("partial"); + expect(result.value.objects[0]!.inspection).toBe("malformed"); + const unknown = ccobFixture(); + unknown.writeUInt16LE(99, 6); + const unsupported = await inspect(unknown); + if (!unsupported.ok) throw unsupported.error; + expect(unsupported.value.objects[0]!.inspection).toBe("unsupported"); +}); +it("rejects out-of-range bundle entries without guessing a payload", async () => { + const bytes = hipFixture(); + bytes.writeBigUInt64LE(BigInt(bytes.length + 1), 32); + const result = await inspect(bytes); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BinaryTargetError" }, + }); +}); + +it("preserves standalone PTX BOM and trailing NUL bytes in the retained text artifact", async () => { + const bytes = Buffer.from("\uFEFF" + ptxFixture + "\0"); + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(Buffer.from(result.value.ptx_artifacts[0]!.text)).toEqual(bytes); + expect(result.value.ptx_artifacts[0]!.byte_length).toBe(bytes.length); +}); +it("rejects a changed full-file identity before applying positional object selection", async () => { + const bytes = cudaFixture(); + const result = await inspect(bytes, { + expected_sha256: "0".repeat(64), + object_ids: ["file@10"], + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError" }, + }); +}); +it("iterates mixed plain/CCOB bundles without counting flat records as nesting", async () => { + const records = Array.from({ length: 20 }, (_, index) => + index % 2 === 0 ? hipFixture() : ccobFixture(), + ); + const result = await inspect(Buffer.concat(records)); + if (!result.ok) throw result.error; + expect(result.value.kernels).toHaveLength(20); + expect(result.value.coverage.discovery).toBe("complete"); +}); +it("continues at the declared CCOB boundary after an unsupported codec", async () => { + const unknown = ccobFixture(); + unknown.writeUInt16LE(99, 6); + const result = await inspect(Buffer.concat([unknown, ccobFixture()])); + if (!result.ok) throw result.error; + expect(result.value.kernels).toHaveLength(1); + expect(result.value.coverage.discovery).toBe("partial"); +}); + +it("refuses escaped PTX report expansion while preserving bounded interrupted facts", async () => { + const text = ptxFixture + "\n//" + "\u0001".repeat(3 * 1024 * 1024); + const result = await inspect(Buffer.from(text)); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + if (result.ok) throw new Error("expected representation capacity refusal"); + const partial = gpuCodeObjectsPartialSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ); + expect(partial.ptx_artifacts).toEqual([]); + expect(partial.objects).toHaveLength(1); + expect(partial.artifact.sha256).toBeNull(); +}); diff --git a/tests/fixtures/gpu/codeObjects.ts b/tests/fixtures/gpu/codeObjects.ts new file mode 100644 index 0000000000..5eb8165065 --- /dev/null +++ b/tests/fixtures/gpu/codeObjects.ts @@ -0,0 +1,301 @@ +import { createHash } from "node:crypto"; +import { deflateSync, zstdCompressSync } from "node:zlib"; +import { encode } from "@msgpack/msgpack"; +export const ptxFixture = `// Producer-shaped static PTX; portable tests do not invoke a GPU compiler. +.version 8.8 +.target sm_80 +.address_size 64 +.visible .entry add_one(.param .u64 output, .param .u32 value) { +.reg .b32 %r<9>; +.reg .b64 %rd<5>; +.shared .align 4 .b8 scratch[32]; +ret; +} +`; +export interface ElfFixtureSection { + name: string; + type?: number; + flags?: bigint; + bytes?: Buffer; + size?: number; + info?: number; + link?: number; + entrySize?: number; +} +/** Format boundary fixtures, including numeric records checked against nvcc 12.9 cuobjdump. */ +export const elfFixture = ( + machine: number, + sections: ElfFixtureSection[], + version = 1, +): Buffer => { + const names = Buffer.from( + `\0.shstrtab\0${sections.map((row) => row.name).join("\0")}\0`, + ); + const rows = [ + { name: "", type: 0, bytes: Buffer.alloc(0) }, + { name: ".shstrtab", type: 3, bytes: names }, + ...sections, + ]; + let position = 64; + const entries = rows.map((row) => { + position = Math.ceil(position / 8) * 8; + const offset = position; + if (row.type !== 8) position += row.bytes?.length ?? 0; + return { row, offset }; + }); + const table = Math.ceil(position / 8) * 8; + const result = Buffer.alloc(table + rows.length * 64); + result.set([ + 0x7f, + 0x45, + 0x4c, + 0x46, + 2, + 1, + 1, + machine === 190 ? 0x33 : machine === 224 ? 0x40 : 0, + ]); + result.writeUInt16LE(2, 16); + result.writeUInt16LE(machine, 18); + result.writeUInt32LE(version, 20); + result.writeBigUInt64LE(BigInt(table), 40); + result.writeUInt32LE(machine === 190 ? 0x500550 : 0x12c, 48); + result.writeUInt16LE(64, 52); + result.writeUInt16LE(64, 58); + result.writeUInt16LE(rows.length, 60); + result.writeUInt16LE(1, 62); + for (let index = 1; index < entries.length; index++) { + const { row, offset } = entries[index]!, + h = table + index * 64; + result.writeUInt32LE(names.indexOf(Buffer.from(`${row.name}\0`)), h); + result.writeUInt32LE(row.type ?? 1, h + 4); + result.writeBigUInt64LE(row.flags ?? 0n, h + 8); + result.writeBigUInt64LE(BigInt(offset), h + 24); + result.writeBigUInt64LE(BigInt(row.size ?? row.bytes?.length ?? 0), h + 32); + result.writeUInt32LE(row.link ?? 0, h + 40); + result.writeUInt32LE(row.info ?? 0, h + 44); + result.writeBigUInt64LE(BigInt(row.entrySize ?? 0), h + 56); + if (row.type !== 8) row.bytes?.copy(result, offset); + } + return result; +}; +export const cubinFixture = ( + options: { + shared?: number; + textRegisters?: number; + globalRegisters?: number; + name?: string; + globalRecords?: number; + globalAliases?: number; + architecture?: number; + osAbi?: number; + flags?: number; + } = {}, +): Buffer => { + const name = options.name ?? "add_one"; + const symbols = Buffer.alloc(48); + symbols.writeUInt32LE(1, 24); + symbols[28] = 0x12; + symbols[29] = 0x10; + symbols.writeUInt16LE(4, 30); + symbols.writeBigUInt64LE(16n, 40); + const record = Buffer.from("042f0800010000000a000000", "hex"); + record.writeUInt32LE(options.globalRegisters ?? 10, 8); + const global = Buffer.alloc(record.length * (options.globalRecords ?? 1)); + for (let offset = 0; offset < global.length; offset += record.length) + record.copy(global, offset); + const info = Buffer.from( + "03190c0004170c00000000000100080000f0110004170c00000000000000000000f02100", + "hex", + ); + const result = elfFixture( + 190, + [ + { name: ".strtab", type: 3, bytes: Buffer.from(`\0${name}\0`) }, + { name: ".symtab", type: 2, link: 2, entrySize: 24, bytes: symbols }, + { + name: `.text.${name}`, + flags: 0x100006n, + info: ((options.textRegisters ?? 0) << 24) | 1, + bytes: Buffer.alloc(16), + }, + { name: ".nv.info", type: 0x70000000, link: 3, bytes: global }, + { + name: `.nv.info.${name}`, + type: 0x70000000, + link: 3, + info: 4, + bytes: info, + }, + { + name: `.nv.shared.${name}`, + type: 8, + info: 4, + size: options.shared ?? 1056, + }, + ...Array.from({ length: (options.globalAliases ?? 1) - 1 }, () => ({ + name: ".nv.info", + type: 0x70000000, + link: 3, + bytes: Buffer.alloc(0), + })), + ], + 0x81, + ); + if (options.architecture !== undefined) + result.writeUInt32LE( + (result.readUInt32LE(48) & ~0xff) | options.architecture, + 48, + ); + const table = Number(result.readBigUInt64LE(40)); + if (options.osAbi !== undefined) { + result[7] = options.osAbi; + result.writeUInt32LE(1, 20); + } + if (options.flags !== undefined) result.writeUInt32LE(options.flags, 48); + for (let index = 8; index < result.readUInt16LE(60); index++) { + result.writeBigUInt64LE( + result.readBigUInt64LE(table + 5 * 64 + 24), + table + index * 64 + 24, + ); + result.writeBigUInt64LE(BigInt(global.length), table + index * 64 + 32); + } + return result; +}; +export const amdFixture = (hidden = false, trailingNote?: Buffer): Buffer => { + const args = [ + { ".offset": 0, ".size": 8, ".value_kind": "global_buffer" }, + { + ".offset": 8, + ".size": 4, + ".value_kind": hidden ? "hidden_global_offset_x" : "by_value", + }, + ]; + const metadata = Buffer.from( + encode({ + "amdhsa.target": "amdgcn-amd-amdhsa--gfx900", + "amdhsa.version": [1, 1], + "amdhsa.kernels": [ + { + ".name": "add_one", + ".symbol": "add_one.kd", + ".args": args, + ".kernarg_segment_size": 12, + ".group_segment_fixed_size": 0, + ".private_segment_fixed_size": 0, + ".sgpr_count": 6, + ".vgpr_count": 2, + }, + ], + }), + ); + const note = Buffer.alloc(20 + Math.ceil(metadata.length / 4) * 4); + note.writeUInt32LE(7, 0); + note.writeUInt32LE(metadata.length, 4); + note.writeUInt32LE(32, 8); + note.write("AMDGPU\0", 12); + metadata.copy(note, 20); + return elfFixture(224, [ + { + name: ".note", + type: 7, + bytes: + trailingNote === undefined ? note : Buffer.concat([note, trailingNote]), + }, + ]); +}; +export const cudaFixture = ( + compression: "none" | "zstd" = "none", + entries = [ + { kind: 1, target: 80, bytes: Buffer.from(ptxFixture) }, + { kind: 2, target: 90, bytes: cubinFixture({ architecture: 90 }) }, + ], +): Buffer => { + const records = entries.map(({ kind, target, bytes }) => { + const compressed = compression === "zstd", + data = compressed ? zstdCompressSync(bytes) : bytes; + const result = Buffer.alloc(64 + Math.ceil(data.length / 8) * 8); + result.writeUInt16LE(kind, 0); + result.writeUInt16LE(0x101, 2); + result.writeUInt32LE(64, 4); + result.writeBigUInt64LE(BigInt(result.length - 64), 8); + result.writeUInt32LE(compressed ? data.length : 0, 16); + result.writeUInt32LE(target, 28); + result.writeBigUInt64LE(compressed ? 0x8011n : 0x11n, 40); + result.writeBigUInt64LE(compressed ? BigInt(bytes.length) : 0n, 56); + data.copy(result, 64); + return result; + }); + const header = Buffer.alloc(16); + header.writeUInt32LE(0xba55ed50); + header.writeUInt16LE(1, 4); + header.writeUInt16LE(16, 6); + header.writeBigUInt64LE( + BigInt(records.reduce((n, row) => n + row.length, 0)), + 8, + ); + return Buffer.concat([header, ...records]); +}; +export const hipFixture = ( + payload = amdFixture(), + targetId = "hipv4-amdgcn-amd-amdhsa--gfx900", +): Buffer => { + const ids = ["host-x86_64-unknown-linux", targetId]; + const headers = ids.map((id) => Buffer.alloc(24 + Buffer.byteLength(id))); + const start = 32 + headers.reduce((total, row) => total + row.length, 0); + for (let index = 0; index < headers.length; index++) { + const row = headers[index]!; + row.writeBigUInt64LE(BigInt(start)); + row.writeBigUInt64LE(index === 0 ? 0n : BigInt(payload.length), 8); + row.writeBigUInt64LE(BigInt(Buffer.byteLength(ids[index]!)), 16); + row.write(ids[index]!, 24); + } + const head = Buffer.alloc(32); + head.write("__CLANG_OFFLOAD_BUNDLE__"); + head.writeBigUInt64LE(2n, 24); + return Buffer.concat([head, ...headers, payload]); +}; +export const ccobFixture = ( + version: 1 | 2 | 3 = 2, + method: "zlib" | "zstd" = "zstd", + bundle = hipFixture(), +): Buffer => { + const compressed = + method === "zstd" ? zstdCompressSync(bundle) : deflateSync(bundle); + const width = version === 1 ? 20 : version === 2 ? 24 : 32, + head = Buffer.alloc(width); + head.write("CCOB"); + head.writeUInt16LE(version, 4); + head.writeUInt16LE(method === "zstd" ? 1 : 0, 6); + if (version === 3) { + head.writeBigUInt64LE(BigInt(width + compressed.length), 8); + head.writeBigUInt64LE(BigInt(bundle.length), 16); + } else if (version === 2) { + head.writeUInt32LE(width + compressed.length, 8); + head.writeUInt32LE(bundle.length, 12); + } else head.writeUInt32LE(bundle.length, 8); + createHash("md5") + .update(bundle) + .digest() + .copy(head, width - 8, 0, 8); + return Buffer.concat([head, compressed]); +}; +export const peGpuFixture = (payload = cudaFixture()): Buffer => { + const result = Buffer.alloc(512 + payload.length), + pe = 64, + optionalSize = 240, + section = pe + 24 + optionalSize; + result.write("MZ"); + result.writeUInt32LE(pe, 60); + result.write("PE\0\0", pe); + result.writeUInt16LE(0x8664, pe + 4); + result.writeUInt16LE(1, pe + 6); + result.writeUInt16LE(optionalSize, pe + 20); + result.writeUInt16LE(0x20b, pe + 24); + result.write(".nv_fatb", section); + result.writeUInt32LE(payload.length, section + 8); + result.writeUInt32LE(payload.length, section + 16); + result.writeUInt32LE(512, section + 20); + payload.copy(result, 512); + return result; +}; diff --git a/tests/process-global/gpuFileLifetime.test.ts b/tests/process-global/gpuFileLifetime.test.ts new file mode 100644 index 0000000000..3122e74e54 --- /dev/null +++ b/tests/process-global/gpuFileLifetime.test.ts @@ -0,0 +1,311 @@ +import { expect, it, vi } from "vitest"; +import { open, writeFile, unlink, truncate } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { GpuCodeObjectsProvider } from "../../src/gpu/GpuCodeObjectsProvider.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { + gpuCodeObjectsPartialSchema, + gpuCodeObjectsSchema, +} from "../../src/domain/gpu/gpuCodeObjects.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { amdFixture, cudaFixture } from "../fixtures/gpu/codeObjects.js"; +type ReadFile = ( + buffer: Buffer, + offset: number, + length: number, + position: number, +) => Promise<{ bytesRead: number; buffer: Buffer }>; +const fixture = async () => { + const root = await createTestTempDirectory("rea-gpu-lifetime-"), + path = join(root, "cuda"), + bytes = cudaFixture(); + await writeFile(path, bytes); + const handle = await open(path, "r"); + const prototype = Object.getPrototypeOf(handle) as { read: ReadFile }; + await handle.close(); + return { path, bytes, prototype, provider: new GpuCodeObjectsProvider() }; +}; +const interceptClose = ( + prototype: { read: ReadFile }, + override: (close: () => Promise) => Promise, +): (() => void) => { + const read = prototype.read; + let attached = false, + restoreHandle: (() => void) | undefined; + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + if (!attached) { + attached = true; + const close = this.close.bind(this); + const closeSpy = vi + .spyOn(this, "close") + .mockImplementation(() => override(close)); + restoreHandle = () => closeSpy.mockRestore(); + } + return read.apply(this, args); + }); + return () => { + spy.mockRestore(); + restoreHandle?.(); + }; +}; +it("reserves the inspection work budget before reading aliased HIP payloads", async () => { + const root = await createTestTempDirectory("rea-gpu-aliased-hip-"), + path = join(root, "aliased-hip"), + payload = amdFixture(), + payloadSize = 128 * 1024 * 1024, + id = Buffer.from("hipv4-amdgcn-amd-amdhsa--gfx900"), + count = 12, + start = 32 + count * (24 + id.length), + directory = Buffer.alloc(start); + directory.write("__CLANG_OFFLOAD_BUNDLE__"); + directory.writeBigUInt64LE(BigInt(count), 24); + for (let index = 0; index < count; index++) { + const at = 32 + index * (24 + id.length); + directory.writeBigUInt64LE(BigInt(start), at); + directory.writeBigUInt64LE(BigInt(payloadSize), at + 8); + directory.writeBigUInt64LE(BigInt(id.length), at + 16); + id.copy(directory, at + 24); + } + const file = await open(path, "w+"); + try { + await file.write(directory, 0, directory.length, 0); + await file.write(payload, 0, payload.length, start); + await file.truncate(start + payloadSize); + } finally { + await file.close(); + } + const handle = await open(path, "r"), + prototype = Object.getPrototypeOf(handle) as { read: ReadFile }; + await handle.close(); + const read = prototype.read; + let payloadReads = 0; + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + if (args[3] === start && args[2] === payloadSize) payloadReads++; + return read.apply(this, args); + }); + const provider = new GpuCodeObjectsProvider(); + try { + const result = await provider.inspect({ path, analysis: "kernels" }); + if (!result.ok) throw result.error; + expect(payloadReads).toBe(4); + expect(result.value.objects.map((row) => row.inspection)).toEqual([ + ...Array(4).fill("complete"), + ...Array(8).fill("capacity"), + ]); + expect(result.value.coverage.payloads).toBe("partial"); + expect(result.value.kernels).toHaveLength(4); + expect(result.value.objects[4]!.sha256).toBeNull(); + expect(result.value.objects[4]!.limitations.join(" ")).toContain( + "512 MiB per inspection", + ); + } finally { + spy.mockRestore(); + await provider.close(); + } +}); +it("retains completed PTX facts and unknown root identity when a later payload is cancelled", async () => { + const { path, bytes, prototype, provider } = await fixture(); + const controller = new AbortController(), + reason = { selected: "caller-cancel" }; + const read = prototype.read; + const secondHeader = 16 + 64 + Number(bytes.readBigUInt64LE(24)); + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + const result = await read.apply(this, args); + if (args[3] === secondHeader && args[2] === 64) controller.abort(reason); + return result; + }); + try { + const result = await provider.inspect( + { path, analysis: "kernels" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: reason }, + }); + if (result.ok) throw new Error("expected cancellation"); + const evidence = parseEvidence(result.error.partialObservation); + const report = gpuCodeObjectsPartialSchema.parse( + evidence.normalized_result, + ); + expect(report.artifact).toMatchObject({ + sha256: null, + file_identity: "unverified", + }); + expect(report.kernels[0]!.name).toBe("add_one"); + expect(report.ptx_artifacts).toHaveLength(1); + } finally { + spy.mockRestore(); + await provider.close(); + } +}); +it("retains a failed descriptor close, blocks admission until retry and clears the exact cleanup marker", async () => { + const { path, prototype, provider } = await fixture(); + let failures = 2; + const restore = interceptClose(prototype, async (close) => { + if (failures-- > 0) throw new Error("owned close failure"); + await close(); + }); + try { + const result = await provider.inspect({ path, analysis: "kernels" }); + if (result.ok) throw new Error("expected cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect( + gpuCodeObjectsSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ).kernels, + ).toHaveLength(2); + const blocked = await provider.inspect({ path, analysis: "inventory" }); + expect(blocked.ok).toBe(false); + const reopened = await provider.inspect({ path, analysis: "inventory" }); + expect(reopened.ok).toBe(true); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + restore(); + await provider.close(); + } +}); +it("preserves completed observations when caller cancellation arrives during final close", async () => { + const { path, prototype, provider } = await fixture(); + const controller = new AbortController(); + const reason = { selected: "final-close-cancel" }; + const restore = interceptClose(prototype, async (close) => { + await close(); + controller.abort(reason); + }); + try { + const result = await provider.inspect( + { path, analysis: "kernels" }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("expected final-close cancellation"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + expect( + gpuCodeObjectsSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ).kernels, + ).toHaveLength(2); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + restore(); + await provider.close(); + } +}); + +it("joins an admitted file read during shutdown and rejects overlapping work", async () => { + const { path, prototype, provider } = await fixture(); + let entered!: () => void, release!: () => void; + const admitted = new Promise((resolve) => { + entered = resolve; + }), + barrier = new Promise((resolve) => { + release = resolve; + }); + const read = prototype.read; + let blocked = false; + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + if (!blocked) { + blocked = true; + entered(); + await barrier; + } + return read.apply(this, args); + }); + let active: ReturnType | undefined; + let closing: Promise | undefined; + try { + active = provider.inspect({ path, analysis: "kernels" }); + await admitted; + let closed = false; + closing = provider.close().then(() => { + closed = true; + }); + const overlap = await provider.inspect({ path, analysis: "inventory" }); + expect(overlap).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + expect(closed).toBe(false); + release(); + const result = await active; + expect(result.ok).toBe(true); + await closing; + expect(closed).toBe(true); + } finally { + release(); + await active; + await closing; + spy.mockRestore(); + await provider.close(); + } +}); + +it("classifies removal of an admitted named source as changed identity", async () => { + const { path, prototype, provider } = await fixture(); + const read = prototype.read; + let removed = false; + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + const result = await read.apply(this, args); + if (!removed) { + removed = true; + await unlink(path); + } + return result; + }); + try { + const result = await provider.inspect({ path, analysis: "kernels" }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError" }, + }); + } finally { + spy.mockRestore(); + await provider.close(); + } +}); +it("reports a source shortened during the streaming hash as changed identity", async () => { + const { path, prototype, provider } = await fixture(); + await writeFile(path, Buffer.alloc(2 * 1024 * 1024)); + const read = prototype.read; + let shortened = false; + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + const result = await read.apply(this, args); + if (!shortened) { + shortened = true; + await truncate(path, 0); + } + return result; + }); + try { + const result = await provider.inspect({ path, analysis: "inventory" }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError" }, + }); + } finally { + spy.mockRestore(); + await provider.close(); + } +}); From bcfcd8b6e2ee0c44c7ac656688db5ceb61a847d5 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 04:08:49 -0700 Subject: [PATCH 04/29] fix(sqlite): attribute generated-column evaluation failures to the selection (#5879) * fix(sqlite): attribute generated-column evaluation failures to the selection A selected virtual generated column whose expression needs a function the read-only connection lacks (or raises an SQL error for a sampled row) was classified by the worker's catch-all as a malformed database and projected as an invalid_format issue on `path`. Row reads now attribute SQLITE_ERROR from selected cells by probing each selected virtual generated column over the sampled rows, and report a typed capability_unavailable refusal naming the table, each failing column with SQLite's message, and the remaining columns to select. Other causes, including corruption, TOOBIG and NOMEM, keep their classification. Fixes #5719 Co-Authored-By: Claude Opus 5.5 * fix(sqlite): recommend only probed-readable columns after evaluation failures Record every per-column probe failure with its own reason, discover stored columns outside an inventory-scope selection without decoding unrelated malformed names, and present recovery as the structured columns selection instead of shell-quoted command text built from database-supplied names. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/sqlite-databases.md | 7 +- src/sqlite/SqliteDatabaseInspection.ts | 153 ++++++++++++++-- src/sqlite/SqliteDatabaseLimits.ts | 18 ++ src/sqlite/SqliteDatabaseWorker.ts | 38 ++-- src/sqlite/SqliteDatabaseWorkerExecution.ts | 25 ++- tests/acceptance/sqliteDatabaseCli.test.ts | 95 ++++++++++ .../sqlite/sqliteDatabaseInspection.test.ts | 173 ++++++++++++++++++ 7 files changed, 473 insertions(+), 36 deletions(-) diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index 4a9a43e9ca..2d9ad6c76c 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -57,7 +57,12 @@ triggers or arbitrary SQL. Rows are optional. `table` selects one exact ordinary-table name. `columns` (CLI repeated `--column`) selects exact visible column names in the requested order; omission selects all visible columns, including generated columns. -Unselected cells are not evaluated or projected. Malformed column-name bytes +Unselected cells are not evaluated or projected. A selected virtual generated +column whose expression needs a function REA's connection lacks (extensions and +application functions are not loaded), or raises an SQL error for a sampled +row, is refused as `capability_unavailable`. The error names the table and +each failing selected column with SQLite's message, and recommends only columns +whose sampled values were read; stored and other generated cells stay readable. Malformed column-name bytes cause a format refusal when all columns are requested. In inventory scope, an explicit valid subset can still be sampled without decoding unrelated malformed column names. Column selection requires a diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index a41ce65592..12e3030aa5 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -10,9 +10,14 @@ import { } from "../domain/sqlite/sqliteDatabase.js"; import { SQLITE_DATABASE_LIMITS, + SqliteColumnEvaluationFailure, SqliteInspectionFailure, + sqlitePrimaryResultCode, } from "./SqliteDatabaseLimits.js"; +/** SQLite's generic SQL error, raised while compiling or evaluating an expression. */ +const SQLITE_ERROR = 1; + const natural = z .bigint() .nonnegative() @@ -419,6 +424,107 @@ const readSchema = ( return { schema, selectedTable }; }; +/** Byte length of one cell's projected representation, without copying its value. */ +const cellByteLength = (name: string): string => { + const column = quoteIdentifier(name); + return `CASE typeof(${column}) WHEN 'text' THEN length(CAST(${column} AS BLOB)) WHEN 'blob' THEN length(${column}) ELSE 0 END`; +}; + +/** Ordinary and stored generated columns, read without evaluating an expression; skips undecodable names. */ +const storedColumnNames = ( + db: DatabaseSync, + table: string, + encoding: DatabaseEncoding, +): string[] => { + const names: string[] = []; + for (const raw of db + .prepare( + "SELECT CAST(name AS BLOB) AS name FROM temp.pragma_table_xinfo(?, 'main') WHERE hidden IN (0, 3) ORDER BY cid", + ) + .iterate(table)) { + const { name } = z.object({ name: z.instanceof(Uint8Array) }).parse(raw); + try { + names.push(decodeSchemaText(name, encoding, "column name")); + } catch (cause: unknown) { + // An unrelated malformed name cannot be selected exactly, so it is not recommended. + if (!(cause instanceof SqliteInspectionFailure)) throw cause; + } + } + return names; +}; + +/** + * Attribute an SQL error from selected cells to the virtual generated columns + * that raise it on this connection. Each column is probed alone over the same + * sampled rows, so only columns whose probe succeeds are recommended; any + * other cause keeps its original classification. + */ +const columnEvaluationFailure = ( + db: DatabaseSync, + cause: unknown, + selection: { + readonly table: SelectedTableFacts; + readonly columns: readonly string[]; + readonly source: string; + readonly limit: number; + readonly encoding: DatabaseEncoding; + }, +): unknown => { + if (sqlitePrimaryResultCode(cause) !== SQLITE_ERROR) return cause; + const { table, columns, source, limit, encoding } = selection; + // The aggregate evaluates exactly the sampled cells, as the length pass does. + const probe = (name: string): { readonly failure: unknown } | undefined => { + try { + db.prepare( + `SELECT count(${cellByteLength(name)}) FROM (SELECT ${quoteIdentifier(name)} FROM ${source} LIMIT ?)`, + ).get(limit); + return undefined; + } catch (failure: unknown) { + return { failure }; + } + }; + const failures = new Map(); + for (const name of new Set(columns)) { + const failed = probe(name); + if (failed !== undefined) failures.set(name, failed.failure); + } + const generated = new Set( + table.columns + .filter((column) => column.hidden === 2) + .map((column) => column.name), + ); + if ( + ![...failures].some( + ([name, failure]) => + generated.has(name) && + sqlitePrimaryResultCode(failure) === SQLITE_ERROR, + ) + ) + return cause; + const remaining = columns.filter((name) => !failures.has(name)); + const readableStoredColumns = (): string[] => { + try { + return storedColumnNames(db, table.name, encoding).filter( + (name) => !failures.has(name) && probe(name) === undefined, + ); + } catch { + // Discovery only refines recovery; it must not replace the attributed failure. + return []; + } + }; + return new SqliteColumnEvaluationFailure( + `SQLite cannot evaluate selected column(s) of table ${JSON.stringify(table.name)} on REA's read-only connection: ${[ + ...failures, + ] + .map( + ([name, failure]) => + `${JSON.stringify(name)}: ${failure instanceof Error ? failure.message : String(failure)}`, + ) + .join("; ")}`, + remaining.length > 0 ? remaining : readableStoredColumns(), + ); +}; + const readRows = ( db: DatabaseSync, input: InspectSqliteDatabaseInput, @@ -460,19 +566,6 @@ const readRows = ( }) .join(", "); const source = `main.${quoteIdentifier(input.table)} NOT INDEXED`; - const lengths = db.prepare( - `SELECT ${columns - .map((name) => { - const column = quoteIdentifier(name); - return `CASE typeof(${column}) WHEN 'text' THEN length(CAST(${column} AS BLOB)) WHEN 'blob' THEN length(${column}) ELSE 0 END`; - }) - .join(", ")} FROM ${source} LIMIT ?`, - ); - lengths.setReturnArrays(true); - for (const raw of lengths.iterate(limit)) - budget.beforeProjection(cellByteLengths.parse(raw)); - const statement = db.prepare(`SELECT ${projection} FROM ${source} LIMIT ?`); - statement.setReturnArrays(true); const rows: NonNullable = { table: input.table, columns, @@ -482,13 +575,33 @@ const readRows = ( truncated: false, order: "unspecified", }; - for (const raw of statement.iterate(limit)) { - const rawRow = arrayRow.parse(raw); - // Hex expansion already belongs to the retained-output budget, before decoding/copying cells. - budget.beforeDecode(rawRow); - const values = rawRow.map((value) => cell(value, encoding)); - budget.retain(values); - rows.values.push(values); + // SQLite compiles and evaluates only selected cells, so an SQL error here is + // attributed to the selected generated expressions rather than the file. + try { + const lengths = db.prepare( + `SELECT ${columns.map(cellByteLength).join(", ")} FROM ${source} LIMIT ?`, + ); + lengths.setReturnArrays(true); + for (const raw of lengths.iterate(limit)) + budget.beforeProjection(cellByteLengths.parse(raw)); + const statement = db.prepare(`SELECT ${projection} FROM ${source} LIMIT ?`); + statement.setReturnArrays(true); + for (const raw of statement.iterate(limit)) { + const rawRow = arrayRow.parse(raw); + // Hex expansion already belongs to the retained-output budget, before decoding/copying cells. + budget.beforeDecode(rawRow); + const values = rawRow.map((value) => cell(value, encoding)); + budget.retain(values); + rows.values.push(values); + } + } catch (cause: unknown) { + throw columnEvaluationFailure(db, cause, { + table, + columns, + source, + limit, + encoding, + }); } rows.returned_rows = rows.values.length; rows.truncated = diff --git a/src/sqlite/SqliteDatabaseLimits.ts b/src/sqlite/SqliteDatabaseLimits.ts index 50ab3978f2..3b6fbd6c20 100644 --- a/src/sqlite/SqliteDatabaseLimits.ts +++ b/src/sqlite/SqliteDatabaseLimits.ts @@ -12,6 +12,7 @@ export class SqliteInspectionFailure extends Error { | "format" | "selection" | "column-selection" + | "column-evaluation" | "unsupported" | "unavailable" | "output-limit" @@ -21,3 +22,20 @@ export class SqliteInspectionFailure extends Error { super(message); } } +/** Selected generated cells the private connection cannot compute; the database stays readable. */ +export class SqliteColumnEvaluationFailure extends SqliteInspectionFailure { + constructor( + message: string, + /** Selected or stored columns that remain readable without the failed expressions. */ + readonly readableColumns: readonly string[], + ) { + super("column-evaluation", message); + } +} +/** SQLite's primary result code for a native node:sqlite failure, if it carries one. */ +export const sqlitePrimaryResultCode = (cause: unknown): number | undefined => + cause instanceof Error && + "errcode" in cause && + typeof cause.errcode === "number" + ? cause.errcode & 0xff + : undefined; diff --git a/src/sqlite/SqliteDatabaseWorker.ts b/src/sqlite/SqliteDatabaseWorker.ts index ebd2d5bac1..c55528a5d3 100644 --- a/src/sqlite/SqliteDatabaseWorker.ts +++ b/src/sqlite/SqliteDatabaseWorker.ts @@ -4,7 +4,9 @@ import { inspectSqliteDatabaseInputSchema } from "../domain/sqlite/sqliteDatabas import { inspectSqliteDatabaseSnapshot } from "./SqliteDatabaseInspection.js"; import { SQLITE_DATABASE_LIMITS, + SqliteColumnEvaluationFailure, SqliteInspectionFailure, + sqlitePrimaryResultCode, } from "./SqliteDatabaseLimits.js"; const requestSchema = z.strictObject({ @@ -29,20 +31,28 @@ const main = async (): Promise => { ), }; } catch (cause: unknown) { - const code = - cause instanceof Error && "errcode" in cause ? cause.errcode : undefined; - reply = { - ok: false, - reason: - cause instanceof SqliteInspectionFailure - ? cause.reason - : code === 7 - ? "memory" - : code === 18 - ? "output-limit" - : "format", - message: cause instanceof Error ? cause.message : String(cause), - }; + const code = sqlitePrimaryResultCode(cause); + const message = cause instanceof Error ? cause.message : String(cause); + reply = + cause instanceof SqliteColumnEvaluationFailure + ? { + ok: false, + reason: cause.reason, + message, + readable_columns: cause.readableColumns, + } + : { + ok: false, + reason: + cause instanceof SqliteInspectionFailure + ? cause.reason + : code === 7 + ? "memory" + : code === 18 + ? "output-limit" + : "format", + message, + }; } let encoded = JSON.stringify(reply); if (Buffer.byteLength(encoded) > SQLITE_DATABASE_LIMITS.outputBytes) diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index bdd82ad1ee..acabd5666a 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -39,7 +39,7 @@ const { ...inspectionShape } = sqliteDatabaseSchema.shape; const inspectionSchema = z.strictObject(inspectionShape); -const replySchema = z.discriminatedUnion("ok", [ +const replySchema = z.union([ z.strictObject({ ok: z.literal(true), inspection: inspectionSchema }), z.strictObject({ ok: z.literal(false), @@ -54,6 +54,12 @@ const replySchema = z.discriminatedUnion("ok", [ ]), message: z.string(), }), + z.strictObject({ + ok: z.literal(false), + reason: z.literal("column-evaluation"), + message: z.string(), + readable_columns: z.array(z.string()), + }), ]); /** Replaceable launcher retains the shared owned-process supervision boundary. */ export type SqliteWorkerLauncher = NonNullable< @@ -80,6 +86,23 @@ const rejectedReply = ( reply.message, { capturedOutput, userMessage: reply.message }, ); + if (reply.reason === "column-evaluation") { + const readable = reply.readable_columns; + return new AnalysisCapabilityUnavailableError( + "sqlite", + operation, + reply.message, + { + capturedOutput, + userMessage: reply.message, + remediationAction: `The database is readable; only the listed column values failed. REA's SQLite connection loads no extensions or application functions and disables schema trust. ${ + readable.length === 0 + ? "No other readable column was found" + : `Select columns whose values were read: columns: ${JSON.stringify(readable)} (CLI: one --column per name)` + }; omit table and use schema_scope: database to read the stored declarations without evaluating rows. Obtaining the failed values needs a SQLite connection able to evaluate them. Do not repeat the unchanged request.`, + }, + ); + } if (reply.reason === "memory") return new AnalysisResourceConstraintError( operation, diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index d871fe31ab..5ce76ea826 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -796,3 +796,98 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( } }, ); + +cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "attributes an unevaluable generated column to its selection, not the readable database path", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-generated-"); + const path = join(root, "hashes.db"); + const producer = new DatabaseSync(path); + try { + // The producing application supplies a function REA's connection lacks. + producer.function( + "application_digest", + { deterministic: true }, + (value) => (typeof value === "string" ? `digest:${value}` : null), + ); + producer.exec(` + CREATE TABLE t(a TEXT, b TEXT GENERATED ALWAYS AS (application_digest(a)) VIRTUAL); + INSERT INTO t(a) VALUES ('hello'), ('world'); + `); + } finally { + producer.close(); + } + const before = await readFile(path); + const { call } = await connectLocalToolsMcp(); + const message = `SQLite cannot evaluate selected column(s) of table "t" on REA's read-only connection: "b": unknown function: application_digest()`; + const expectedError = { + code: "capability_unavailable", + retryable: false, + message, + details: { provider_id: "sqlite", reason: message }, + remediation: { + action: expect.stringContaining( + 'Select columns whose values were read: columns: ["a"] (CLI: one --column per name)', + ), + }, + }; + for (const columns of [["b", "a"], undefined]) { + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "t", + ...(columns ?? []).flatMap((column) => ["--column", column]), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(1); + expect(result.json).toMatchObject(expectedError); + expect(result.stdout).not.toContain("invalid_format"); + const response = await call("inspect_sqlite_database", { + path, + table: "t", + ...(columns === undefined ? {} : { columns }), + }); + expect(parseMcpToolError(response).error).toMatchObject(expectedError); + } + const base = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "t", + "--column", + "a", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(base.exitCode, base.stdout + base.stderr).toBe(0); + expect(base.json).toMatchObject({ + normalized_result: { rows: { columns: ["a"], returned_rows: 2 } }, + }); + const schema = await call("inspect_sqlite_database", { + path, + schema_scope: "database", + }); + expect(schema.isError).not.toBe(true); + expect(schema.structuredContent).toMatchObject({ + normalized_result: { + rows: null, + schema: { + tables: [ + { + name: "t", + sql: expect.stringContaining("application_digest(a)"), + columns: [{ name: "a" }, { name: "b", hidden: 2 }], + }, + ], + }, + }, + }); + expect(await readFile(path)).toEqual(before); + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index 26fab7d6a4..17af7d5519 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -364,6 +364,179 @@ it("rejects the combined representation of individually small generated cells", } }); +it("attributes compile and row evaluation errors to each failing generated column", async () => { + const workspace = await createTestWorkspace("rea-sqlite-evaluation-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("private.db"); + // Writable schema plants an expression that fails only for the second + // stored row; virtual cells are not stored, so record layout is unchanged. + const producer = new DatabaseSync(path, { defensive: false }); + try { + producer.function("application_digest", { deterministic: true }, (value) => + String(value), + ); + producer.exec(` + CREATE TABLE t( + id INTEGER, + payload TEXT, + digest TEXT GENERATED ALWAYS AS (application_digest(payload)) VIRTUAL, + stored_digest TEXT GENERATED ALWAYS AS (application_digest(payload)) STORED, + field GENERATED ALWAYS AS (payload) VIRTUAL, + upper_payload GENERATED ALWAYS AS (upper(payload)) VIRTUAL + ); + INSERT INTO t(id, payload) VALUES (1, '{"k":1}'), (2, 'not json'); + PRAGMA writable_schema = ON; + UPDATE sqlite_schema + SET sql = replace(sql, 'field GENERATED ALWAYS AS (payload)', 'field GENERATED ALWAYS AS (json_extract(payload, ''$.k''))') + WHERE name = 't'; + PRAGMA writable_schema = OFF; + `); + } finally { + producer.close(); + } + const failure = (input: { columns?: string[]; row_limit?: number }) => { + try { + inspectSqliteDatabaseSnapshot(path, { path, table: "t", ...input }); + } catch (cause: unknown) { + return cause; + } + throw new Error("Expected a generated-column evaluation failure"); + }; + const prefix = `SQLite cannot evaluate selected column(s) of table "t" on REA's read-only connection: `; + expect(failure({})).toMatchObject({ + reason: "column-evaluation", + message: `${prefix}"digest": unknown function: application_digest(); "field": malformed JSON`, + readableColumns: ["id", "payload", "stored_digest", "upper_payload"], + }); + expect(failure({ columns: ["field", "id", "field"] })).toMatchObject({ + message: `${prefix}"field": malformed JSON`, + readableColumns: ["id"], + }); + // With no readable selection left, stored columns remain available without evaluation. + expect(failure({ columns: ["digest"] })).toMatchObject({ + message: `${prefix}"digest": unknown function: application_digest()`, + readableColumns: ["id", "payload", "stored_digest"], + }); + // Only sampled rows are evaluated, and stored or built-in generated cells are read. + expect( + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + columns: ["field", "stored_digest", "upper_payload"], + row_limit: 1, + }).rows, + ).toMatchObject({ + values: [ + [ + { type: "integer", value: "1" }, + { type: "text", value: '{"k":1}' }, + { type: "text", value: '{"K":1}' }, + ], + ], + truncated: true, + }); +}); + +const createApplicationDatabase = async ( + sql: string, + edit?: (bytes: Buffer) => void, +): Promise => { + const workspace = await createTestWorkspace("rea-sqlite-evaluation-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("private.db"); + const producer = new DatabaseSync(path); + try { + // The producing application supplies a function REA's connection lacks. + producer.function("application_digest", { deterministic: true }, (value) => + String(value), + ); + producer.exec(sql); + } finally { + producer.close(); + } + if (edit !== undefined) { + const bytes = await readFile(path); + edit(bytes); + await writeFile(path, bytes); + } + return path; +}; + +it("never recommends a selected column whose own probe fails for another reason", async () => { + const path = await createApplicationDatabase(` + CREATE TABLE t( + id INTEGER, + digest TEXT GENERATED ALWAYS AS (application_digest(id)) VIRTUAL, + payload BLOB GENERATED ALWAYS AS (zeroblob(72 * 1024 * 1024)) VIRTUAL + ); + INSERT INTO t(id) VALUES (1); + `); + expect(() => + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + columns: ["payload", "digest", "id"], + }), + ).toThrow( + expect.objectContaining({ + reason: "column-evaluation", + message: `SQLite cannot evaluate selected column(s) of table "t" on REA's read-only connection: "payload": string or blob too big; "digest": unknown function: application_digest()`, + readableColumns: ["id"], + }), + ); +}); + +it("recommends stored columns outside an inventory-scope selection without decoding malformed names", async () => { + const path = await createApplicationDatabase( + ` + CREATE TABLE t( + id INTEGER, + "col" TEXT, + payload TEXT, + digest TEXT GENERATED ALWAYS AS (application_digest(payload)) VIRTUAL + ); + INSERT INTO t(id, "col", payload) VALUES (1, 'kept', 'value'); + `, + (bytes) => { + const marker = Buffer.from('"col"'); + const offset = bytes.indexOf(marker); + if (offset < 0 || bytes.indexOf(marker, offset + 1) !== -1) + throw new Error("Expected unique authored column marker"); + bytes[offset + 1] = 0xff; + }, + ); + expect(() => + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "inventory", + columns: ["digest"], + }), + ).toThrow( + expect.objectContaining({ + reason: "column-evaluation", + message: `SQLite cannot evaluate selected column(s) of table "t" on REA's read-only connection: "digest": unknown function: application_digest()`, + readableColumns: ["id", "payload"], + }), + ); + // The recommended stored columns are themselves selectable in the same scope. + expect( + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "inventory", + columns: ["id", "payload"], + }).rows, + ).toMatchObject({ + values: [ + [ + { type: "integer", value: "1" }, + { type: "text", value: "value" }, + ], + ], + }); +}); + it("samples above 1000 rows and safe integer limits within the byte budget", async () => { const path = await createDatabase(`CREATE TABLE items(value INTEGER); WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers WHERE value < 1002) From aa53c065387c58b4189d82c47f1f3445fc505e30 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:25:22 +0800 Subject: [PATCH 05/29] feat(firmware): make analysis deadlines recoverable per call Expose per-call firmware analysis budgets with exact recovery guidance, shared CLI/MCP validation and retained actual settings. Keep publication and fixed resource policy independent; preserve typed interruption and one captured-output owner through command and one-shot cleanup. Fixes #5851. Executed verification, real-provider limits, known baseline issues and authorized readiness override are recorded in #5863. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- docs/firmware-analysis.md | 13 +- .../binary/AnalysisClientCleanup.test.ts | 17 +- .../firmware/FirmwareAnalysisService.test.ts | 176 +++++++++--- .../firmware/FirmwareAnalysisService.ts | 17 ++ src/cli/firmwareCommands.ts | 14 +- .../firmware/firmwareToolContracts.ts | 2 +- src/domain/analysisErrorCleanup.test.ts | 64 ++++- src/domain/analysisErrorCleanup.ts | 10 +- src/domain/analysisErrorProjection.ts | 12 +- src/domain/firmware/firmwareAnalysis.ts | 27 +- src/firmware/FirmwareCommand.test.ts | 99 ++++++- src/firmware/FirmwareCommand.ts | 93 ++++-- src/firmware/FirmwareDeadline.test.ts | 56 ++++ src/firmware/FirmwareFailures.ts | 6 +- src/firmware/FirmwareProvider.ts | 9 +- src/firmware/FirmwarePublication.ts | 2 + src/firmware/FirmwareRelease.ts | 2 +- src/sqlite/SqliteDatabaseFailures.ts | 16 +- .../pwntoolsLayoutProvider.test.ts | 18 +- .../composition/directAnalysisCleanup.test.ts | 18 +- .../firmwareFileLifetime.test.ts | 1 + .../firmwarePublicationDeadline.test.ts | 270 +++++++++++++++++- tests/process-global/pwntoolsCleanup.test.ts | 19 +- 23 files changed, 843 insertions(+), 118 deletions(-) diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index b529ac6d0d..b0f92c14dc 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -158,7 +158,13 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture. Supply a stable copy within the allowance. No in-place source modification. - One queued heavy operation per provider instance; Binwalk one thread and Unblob one worker. No automatic parallel extraction engines. -- **120-second** analysis deadline through inventory. Publication and final +- Analysis defaults to **120 seconds**, from tool resolution and input snapshot + through inspection and its final private-workspace cleanup, or extraction + through report consumption and inventory, excluding queue waiting and retained + cleanup admission. Inspection has no publication phase. Select `analysis_timeout_ms` in either MCP operation or + `--analysis-timeout-ms` in either CLI command for slower analysis. Current + results retain the actual budget; an absent historical value means unknown. + Extraction publication and its final cleanup start a separate **120-second** budget; select `publication_timeout_ms` in MCP or `--publication-timeout-ms` in CLI for a slow destination. Valid budgets are integers from 1 through 2,147,483,647 ms (the @@ -168,6 +174,11 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture. both phases and final cleanup. Phase handoff, publication commit and final completion also check monotonic elapsed time, so delayed timer callbacks cannot revive an expired budget. +- Analysis timeout recovery names the selected budget and the analysis control, + not the later publication control. Keep the client call timeout above analysis, + publication and cleanup headroom; keep the input/range/question unchanged. + Resolve any reported incomplete cleanup before retrying. Raising wall time + does not raise fixed CPU, memory, report, diagnostic or input limits. - Inherited **1 GiB per-process address-space** limit, 120 CPU seconds and zero core-dump allowance through `prlimit`. - JSON reports: at most **8 MiB** and valid UTF-8; malformed encoding and diff --git a/src/application/binary/AnalysisClientCleanup.test.ts b/src/application/binary/AnalysisClientCleanup.test.ts index fff856c740..23944836c3 100644 --- a/src/application/binary/AnalysisClientCleanup.test.ts +++ b/src/application/binary/AnalysisClientCleanup.test.ts @@ -1,6 +1,9 @@ import { expect, it } from "vitest"; -import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "../../domain/analysisErrorProjection.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { err } from "../../domain/result.js"; @@ -59,18 +62,26 @@ it("preserves the original explanation and collected output alongside cleanup un reason: "Process termination was not confirmed", }); const combined = analysisErrorWithCleanupFailure(primary, cleanup); - expect(projectAnalysisError(combined)).toMatchObject({ + const projected = projectAnalysisError(combined); + expect(projected).toMatchObject({ code: "cleanup_incomplete", message: primary.userMessage, details: { resources: ["owned-process"], captured_output: primary.capturedOutput, diagnostics: { - primary_error: projectAnalysisError(primary), + primary_error: projectAnalysisErrorWithoutCapturedOutput(primary), cleanup_error: projectAnalysisError(cleanup), }, }, }); + expect(projected).not.toHaveProperty( + "details.diagnostics.primary_error.details.captured_output", + ); + expect(JSON.stringify(projected).split("partial observation")).toHaveLength( + 2, + ); + expect(JSON.stringify(projected).split("native reason")).toHaveLength(2); expect(combined.cause).toBeInstanceOf(AggregateError); expect(combined.cause).toMatchObject({ cause: primary, diff --git a/src/application/firmware/FirmwareAnalysisService.test.ts b/src/application/firmware/FirmwareAnalysisService.test.ts index 91520fd9d0..b060608d18 100644 --- a/src/application/firmware/FirmwareAnalysisService.test.ts +++ b/src/application/firmware/FirmwareAnalysisService.test.ts @@ -2,59 +2,72 @@ import { expect, it } from "vitest"; import { FirmwareAnalysisService } from "./FirmwareAnalysisService.js"; import { createAnalysisExecution } from "../AnalysisProvider.js"; -import { firmwareResultSchemas } from "../../domain/firmware/firmwareAnalysis.js"; +import { + firmwareResultSchemas, + type FirmwareOperation, +} from "../../domain/firmware/firmwareAnalysis.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; +const fixture = ( + operation: FirmwareOperation, + budget: number | null = 120_000, +) => { + const path = "/owned-fixture/input.bin"; + const input = { + path, + ...(operation === "extract_firmware" + ? { output_directory: "/owned-fixture/output" } + : {}), + }; + const engine = { + name: "fixture", + version: "1", + executable_path: "/fixture/engine", + executable_sha256: "b".repeat(64), + source_revision: null, + worker_count: 1, + }; + const normalized = firmwareResultSchemas[operation].parse( + operation === "inspect_firmware_regions" + ? { engine, input_size: 1, regions: [], coverage: "signature_scan" } + : { + engine, + selection: { offset: 0, length: 1, sha256: "a".repeat(64) }, + output_directory: "/owned-fixture/output", + coverage: "complete", + files: [], + unpublished_entries: [], + chunks: [], + derivations: [], + depth_limited_paths: [], + diagnostics: [], + extraction_gaps: [], + extraction_continuations: [], + published_bytes: 0, + }, + ); + const execution = createAnalysisExecution( + budget === null + ? normalized + : { ...normalized, analysis_timeout_ms: budget }, + { id: "fixture", name: "Fixture firmware", version: "1" }, + { + subject: { + path, + format: "file", + sha256: "a".repeat(64), + }, + rawResult: { source: "controlled producer" }, + }, + ); + return { input, execution }; +}; + it.each(["inspect_firmware_regions", "extract_firmware"] as const)( "retains %s Evidence after late cancellation and preserves provider failure precedence", async (operation) => { - const path = "/owned-fixture/input.bin"; - const input = { - path, - ...(operation === "extract_firmware" - ? { output_directory: "/owned-fixture/output" } - : {}), - }; - const engine = { - name: "fixture", - version: "1", - executable_path: "/fixture/engine", - executable_sha256: "b".repeat(64), - source_revision: null, - worker_count: 1, - }; - const normalized = firmwareResultSchemas[operation].parse( - operation === "inspect_firmware_regions" - ? { engine, input_size: 1, regions: [], coverage: "signature_scan" } - : { - engine, - selection: { offset: 0, length: 1, sha256: "a".repeat(64) }, - output_directory: "/owned-fixture/output", - coverage: "complete", - files: [], - unpublished_entries: [], - chunks: [], - derivations: [], - depth_limited_paths: [], - diagnostics: [], - extraction_gaps: [], - extraction_continuations: [], - published_bytes: 0, - }, - ); - const execution = createAnalysisExecution( - normalized, - { id: "fixture", name: "Fixture firmware", version: "1" }, - { - subject: { - path, - format: "file", - sha256: "a".repeat(64), - }, - rawResult: { source: "controlled producer" }, - }, - ); + const { input, execution } = fixture(operation); const control = await new FirmwareAnalysisService({ execute: async () => ok(execution), }).execute(operation, input); @@ -89,3 +102,72 @@ it.each(["inspect_firmware_regions", "extract_firmware"] as const)( ).toEqual(err(failure)); }, ); + +for (const operation of [ + "inspect_firmware_regions", + "extract_firmware", +] as const) { + it(`preserves a selected ${operation} analysis budget independently of publication`, async () => { + const { input, execution } = fixture(operation, 1234); + const service = new FirmwareAnalysisService({ + execute: async (request) => { + expect(request.input).toMatchObject({ analysis_timeout_ms: 1234 }); + if (request.operation === "extract_firmware") + expect(request.input.publication_timeout_ms).toBe(120_000); + return ok(execution); + }, + }); + const result = await service.execute(operation, { + ...input, + analysis_timeout_ms: 1234, + }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + analysis_timeout_ms: 1234, + }); + expect(result.value.parameters).toMatchObject({ + analysis_timeout_ms: 1234, + }); + }); + + it(`retains the known ${operation} default without inventing an explicit input`, async () => { + const { input, execution } = fixture(operation); + const result = await new FirmwareAnalysisService({ + execute: async () => ok(execution), + }).execute(operation, input); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + analysis_timeout_ms: 120_000, + }); + expect(result.value.parameters).not.toHaveProperty("analysis_timeout_ms"); + }); + + it.each([ + { selected: undefined, reported: null }, + { selected: undefined, reported: 1234 }, + { selected: 1234, reported: null }, + { selected: 1234, reported: 120_000 }, + ])( + `rejects missing or changed fresh ${operation} settings: %j`, + async ({ selected, reported }) => { + const { input, execution } = fixture(operation, reported); + const result = await new FirmwareAnalysisService({ + execute: async () => ok(execution), + }).execute(operation, { + ...input, + ...(selected === undefined ? {} : { analysis_timeout_ms: selected }), + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + }, + ); + + it(`keeps historical ${operation} budget omissions unknown`, () => { + const { execution } = fixture(operation, null); + expect( + firmwareResultSchemas[operation].parse(execution.result), + ).not.toHaveProperty("analysis_timeout_ms"); + }); +} diff --git a/src/application/firmware/FirmwareAnalysisService.ts b/src/application/firmware/FirmwareAnalysisService.ts index 0d3344646a..ba1b774d4b 100644 --- a/src/application/firmware/FirmwareAnalysisService.ts +++ b/src/application/firmware/FirmwareAnalysisService.ts @@ -2,6 +2,8 @@ import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js"; import { firmwareRequestSchema, + firmwareExecutionBudgetSchema, + DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS, type FirmwareOperation, } from "../../domain/firmware/firmwareAnalysis.js"; import { @@ -53,6 +55,21 @@ export class FirmwareAnalysisService { "Firmware execution did not bind its source artifact", ), ); + // Providers own normalized facts. Check only fresh execution metadata here, + // without reparsing/copying potentially large extraction inventories. + const budget = firmwareExecutionBudgetSchema.safeParse(execution.result); + if ( + !budget.success || + budget.data.analysis_timeout_ms !== + (parsed.data.input.analysis_timeout_ms ?? + DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS) + ) + return err( + new AnalysisOutputError( + operation, + "Firmware execution did not retain its selected analysis budget", + ), + ); return completeProviderEvidence( createEvidence(execution.subject, execution.provider, { operation, diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 742dd92105..5b0e274cfc 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -46,12 +46,18 @@ export const registerFirmwareCommands = ( description: "Inspect firmware signature candidates without extracting them", args: z.object({ path }), - run: ({ args }) => + options: z.object({ analysisTimeoutMs: extractInput.analysis_timeout_ms }), + run: ({ args, options }) => execute( CLI_COMMANDS.inspectFirmwareRegions, "inspect_firmware_regions", // CLI paths are operator-relative; resolve before shared validation. - { path: absoluteCallerPath(args.path) }, + { + path: absoluteCallerPath(args.path), + ...(options.analysisTimeoutMs === undefined + ? {} + : { analysis_timeout_ms: options.analysisTimeoutMs }), + }, ), }); cli.command(CLI_COMMANDS.extractFirmware, { @@ -74,6 +80,7 @@ export const registerFirmwareCommands = ( maxOutputBytes: extractInput.max_output_bytes, maxOutputFiles: extractInput.max_output_files, publicationTimeoutMs: extractInput.publication_timeout_ms, + analysisTimeoutMs: extractInput.analysis_timeout_ms, }), run: ({ args, options }) => execute(CLI_COMMANDS.extractFirmware, "extract_firmware", { @@ -86,6 +93,9 @@ export const registerFirmwareCommands = ( max_output_bytes: options.maxOutputBytes, max_output_files: options.maxOutputFiles, publication_timeout_ms: options.publicationTimeoutMs, + ...(options.analysisTimeoutMs === undefined + ? {} + : { analysis_timeout_ms: options.analysisTimeoutMs }), }), }); }; diff --git a/src/contracts/firmware/firmwareToolContracts.ts b/src/contracts/firmware/firmwareToolContracts.ts index 00b1e373b3..e458298e27 100644 --- a/src/contracts/firmware/firmwareToolContracts.ts +++ b/src/contracts/firmware/firmwareToolContracts.ts @@ -13,7 +13,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_firmware_regions"), kind: "firmware-provider", description: - "Inspect signature candidates in a local firmware file with Binwalk 3.1.x (verified with 3.1.0). Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool and util-linux prlimit required; 128 MiB input limit, one worker and 120-second deadline.", + "Inspect signature candidates in a local firmware file with Binwalk 3.1.x (verified with 3.1.0). Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool and util-linux prlimit required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.", inputSchema: firmwareInputSchemas.inspect_firmware_regions, outputSchema: evidenceResultOf( firmwareResultSchemas.inspect_firmware_regions, diff --git a/src/domain/analysisErrorCleanup.test.ts b/src/domain/analysisErrorCleanup.test.ts index 41ce5abcfa..3ae95dba17 100644 --- a/src/domain/analysisErrorCleanup.test.ts +++ b/src/domain/analysisErrorCleanup.test.ts @@ -7,7 +7,10 @@ import { AnalysisTimeoutError, } from "./analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "./analysisErrorCleanup.js"; -import { projectAnalysisError } from "./analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "./analysisErrorProjection.js"; import { analysisErrorCategory, analysisErrorRemediationAction, @@ -16,6 +19,60 @@ import { import { ProviderCleanupError } from "./providerCleanupError.js"; import { createEvidence } from "./evidence.js"; +it.each([false, true])( + "keeps each owner's capture without text-based deduplication (matching: %s)", + (matching) => { + const primary = new AnalysisTimeoutError("inspect", 1234, { + capturedOutput: { + stdout: "primary-capture-sentinel", + stderr: "", + truncated: false, + }, + }); + const cleanup = new ProviderCleanupError( + "fixture", + ["owned-worker"], + {}, + { + capturedOutput: { + stdout: matching + ? "primary-capture-sentinel" + : "cleanup-capture-sentinel", + stderr: "", + truncated: true, + }, + }, + ); + const composed = analysisErrorWithCleanupFailure( + primary, + cleanup, + "inspect", + ); + const projected = projectAnalysisError(composed); + expect(projected.details?.captured_output).toEqual(primary.capturedOutput); + expect(projected.details?.diagnostics).toMatchObject({ + primary_error: { + code: "provider_timeout", + details: { timeout_ms: 1234 }, + }, + cleanup_error: { details: { captured_output: cleanup.capturedOutput } }, + }); + expect( + JSON.stringify(projected).split("primary-capture-sentinel"), + ).toHaveLength(matching ? 3 : 2); + if (!matching) + expect( + JSON.stringify(projected).split("cleanup-capture-sentinel"), + ).toHaveLength(2); + expect( + projectAnalysisErrorWithoutCapturedOutput(primary).details, + ).not.toHaveProperty("captured_output"); + expect(projectAnalysisError(primary).details?.captured_output).toEqual( + primary.capturedOutput, + ); + }, +); + it("keeps dependency-owned resources empty after the final owner releases", () => { const file = new ProviderCleanupError("fixture", ["file-owner"], {}); const trace = new ProviderCleanupError("fixture", ["trace-owner"], {}); @@ -113,7 +170,10 @@ it.each([ const projected = projectAnalysisError(composed); expect(projected).toMatchObject(original); expect(projected.details?.cleanup_history).toMatchObject({ - primary_error: original, + primary_error: + primary.capturedOutput === undefined + ? original + : projectAnalysisErrorWithoutCapturedOutput(primary), cleanup_error: { code: "cleanup_incomplete" }, }); expect(projected.details).not.toHaveProperty("cleanup", "incomplete"); diff --git a/src/domain/analysisErrorCleanup.ts b/src/domain/analysisErrorCleanup.ts index ab7b6d43ea..748a92182b 100644 --- a/src/domain/analysisErrorCleanup.ts +++ b/src/domain/analysisErrorCleanup.ts @@ -1,5 +1,8 @@ import { ProviderAdapterError } from "./providerAdapterError.js"; -import { projectAnalysisError } from "./analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "./analysisErrorProjection.js"; import type { AnalysisError } from "./analysisErrorBase.js"; /** Preserve the original lifecycle failure alongside incomplete cleanup. */ @@ -37,7 +40,10 @@ export const analysisErrorWithCleanupFailure = ( ], }, diagnostics: { - primary_error: projectAnalysisError(primary), + primary_error: + primary.capturedOutput === undefined + ? projectAnalysisError(primary) + : projectAnalysisErrorWithoutCapturedOutput(primary), cleanup_error: projected, }, }, diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 43cd286d37..0cd1ffcca0 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -51,6 +51,16 @@ import { /** Project expected failures into exhaustive, secret-safe caller fields. */ export const projectAnalysisError = ( error: AnalysisError, +): AnalysisErrorProjection => projectError(error, true); + +/** Cleanup history references output retained by its enclosing error owner. */ +export const projectAnalysisErrorWithoutCapturedOutput = ( + error: AnalysisError, +): AnalysisErrorProjection => projectError(error, false); + +const projectError = ( + error: AnalysisError, + includeCapturedOutput: boolean, ): AnalysisErrorProjection => { const composed = error; error = resolvedAnalysisError(error); @@ -88,7 +98,7 @@ export const projectAnalysisError = ( primaryDetails?.execution_failure ?? underlyingCode, } : {}), - ...(capturedOutput === undefined + ...(!includeCapturedOutput || capturedOutput === undefined ? {} : { captured_output: { diff --git a/src/domain/firmware/firmwareAnalysis.ts b/src/domain/firmware/firmwareAnalysis.ts index a434af3916..841ba2055f 100644 --- a/src/domain/firmware/firmwareAnalysis.ts +++ b/src/domain/firmware/firmwareAnalysis.ts @@ -5,10 +5,20 @@ import { absoluteLocalPathStringSchema } from "../localPath.js"; const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); const range = z.strictObject({ offset: bytes, length: bytes.positive() }); +/** Default analysis phase budget, excluding queue admission and publication. */ +export const DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS = 120_000; +/** Largest delay supported without overflowing Node timers. */ +export const MAX_FIRMWARE_TIMEOUT_MS = 2_147_483_647; +const timeout = z.number().int().min(1).max(MAX_FIRMWARE_TIMEOUT_MS); const target = { path: absoluteLocalPathStringSchema.describe( "Absolute local firmware file; never executed. Relative paths are rejected.", ), + analysis_timeout_ms: timeout + .optional() + .describe( + "Analysis wall-clock budget in milliseconds (default 120000), from tool resolution through inspection and its final workspace cleanup, or extraction through inventory. Excludes queue admission and extraction publication. Does not raise fixed CPU, memory or byte limits.", + ), }; /** Explicit firmware inspection and extraction intent, independent of engines. */ @@ -45,12 +55,7 @@ export const firmwareInputSchemas = { .max(100_000) .default(10_000) .describe("Staging entry budget, including directories and links"), - publication_timeout_ms: z - .number() - .int() - .min(1) - // Node timers cannot represent longer delays without overflow. - .max(2_147_483_647) + publication_timeout_ms: timeout .default(120_000) .describe( "Publication and final cleanup budget in milliseconds, started after extraction and inventory; increase for a slow destination", @@ -87,6 +92,14 @@ const engine = z.strictObject({ source_revision: z.null(), worker_count: z.literal(1), }); +/** Small execution metadata contract; historical omissions remain unknown. */ +export const firmwareExecutionBudgetSchema = z.object({ + analysis_timeout_ms: timeout + .exactOptional() + .describe( + "Actual analysis phase budget in milliseconds; absent in historical Evidence means unknown", + ), +}); const selection = z.strictObject({ ...range.shape, sha256: digestSchema }); const chunk = z.strictObject({ input_path: z.string(), @@ -134,6 +147,7 @@ export interface FirmwarePartialObservation { /** Portable results retain reported boundaries, unknowns and extraction lineage. */ export const firmwareResultSchemas = { inspect_firmware_regions: z.strictObject({ + ...firmwareExecutionBudgetSchema.shape, engine, input_size: bytes, regions: z.array( @@ -150,6 +164,7 @@ export const firmwareResultSchemas = { coverage: z.literal("signature_scan"), }), extract_firmware: z.strictObject({ + ...firmwareExecutionBudgetSchema.shape, engine, selection, output_directory: z.string(), diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 45b6ac2167..6d8027d6f9 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -1,16 +1,111 @@ import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it as test } from "vitest"; +import { expect, it as test, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { FirmwareFileAccess } from "./FirmwareFiles.js"; -import { resolveFirmwareCommand } from "./FirmwareCommand.js"; +import { + resolveFirmwareCommand, + runFirmwareCommand, +} from "./FirmwareCommand.js"; +import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js"; const it = test.skipIf(process.platform !== "linux"); +it.each([ + { source: "analysis", wrapped: false }, + { source: "caller", wrapped: false }, + { source: "analysis", wrapped: true }, + { source: "caller", wrapped: true }, +] as const)( + "classifies $source cancellation during launcher admission (wrapped: $wrapped)", + async ({ source, wrapped }) => { + const analysis = new AbortController(); + const caller = new AbortController(); + const reason = + source === "analysis" + ? new DOMException("selected deadline", "TimeoutError") + : { caller: "opaque admission cancellation" }; + const retainCleanup = vi.fn(); + const failure: unknown = await runFirmwareCommand({ + engine: "binwalk", + operation: "inspect_firmware_regions", + command: "/usr/bin/true", + limiter: "/usr/bin/true", + args: ["--version"], + cwd: await createTestTempDirectory("rea-firmware-admission-"), + environment: process.env, + signal: AbortSignal.any([analysis.signal, caller.signal]), + deadline: { + phase: "analysis", + signal: analysis.signal, + timeoutMs: 1234, + expiresAtMs: performance.now() + 1234, + }, + launcher: async (options) => { + // Real launcher is paused at its first ownership-preparation await; + // it must reject before acquiring a child after this cancellation. + const admission = wrapped + ? undefined + : spawnOwnedProviderProcess(options); + (source === "analysis" ? analysis : caller).abort(reason); + if (admission !== undefined) return admission; + throw new ExecFileOutputCancelledError(reason); + }, + retainCleanup, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AnalysisError); + if (!(failure instanceof AnalysisError)) + throw new Error("Expected typed admission interruption"); + expect(failure.cause).toBe(reason); + expect(projectAnalysisError(failure)).toMatchObject( + source === "analysis" + ? { + code: "provider_timeout", + details: { timeout_ms: 1234, diagnostics: { phase: "analysis" } }, + remediation: { + action: expect.stringContaining("analysis_timeout_ms"), + }, + } + : { code: "cancelled" }, + ); + expect(retainCleanup).not.toHaveBeenCalled(); + }, +); + +it("does not relabel an unrelated launch failure when a deadline also fires", async () => { + const analysis = new AbortController(); + const failure = new Error("distinct launch refusal"); + await expect( + runFirmwareCommand({ + engine: "binwalk", + operation: "inspect_firmware_regions", + command: "/usr/bin/true", + limiter: "/usr/bin/true", + args: [], + cwd: await createTestTempDirectory("rea-firmware-admission-error-"), + environment: process.env, + signal: analysis.signal, + deadline: { + phase: "analysis", + signal: analysis.signal, + timeoutMs: 1234, + expiresAtMs: performance.now() + 1234, + }, + launcher: async () => { + analysis.abort(); + throw failure; + }, + retainCleanup: vi.fn(), + }), + ).rejects.toBe(failure); +}); + test("names the host and tool requirement of an unconfigured engine", async () => { const refusal: unknown = await resolveFirmwareCommand( {}, diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index d8eef7fef2..1b4362c47d 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -5,12 +5,18 @@ import { constants } from "node:fs"; import { isAbsolute } from "node:path"; import { AnalysisCapabilityUnavailableError, - AnalysisCancelledError, - AnalysisTimeoutError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { projectAnalysisErrorWithoutCapturedOutput } from "../domain/analysisErrorProjection.js"; +import type { FirmwareDeadline } from "./FirmwareDeadline.js"; +import { + firmwareFailure, + firmwareCancellation, + firmwarePhaseInterruption, +} from "./FirmwareFailures.js"; import { spawnOwnedProviderProcess, ProviderProcessSupervisor, @@ -113,14 +119,22 @@ export const runFirmwareCommand = async (context: { cwd: string; environment: Readonly>; signal: AbortSignal; + deadline: FirmwareDeadline; launcher?: FirmwareLauncher; outputBudget?: { root: string; bytes: number; entries: number }; acceptReportedExtractionFailure?: boolean; retainCleanup(supervisor: ProviderProcessSupervisor): void; }) => { const { engine, operation, signal } = context; - if (signal.aborted) - throw new AnalysisCancelledError(operation, { cause: signal.reason }); + const checkInterruption = () => { + const failure = firmwarePhaseInterruption( + operation, + signal, + context.deadline, + ); + if (failure !== undefined) throw failure; + }; + checkInterruption(); const runId = `rea-firmware-${randomUUID()}`; const spawned = await (context.launcher ?? spawnOwnedProviderProcess)({ command: context.limiter, @@ -128,7 +142,7 @@ export const runFirmwareCommand = async (context: { `--as=${FIRMWARE_LIMITS.addressSpaceBytes}`, `--fsize=${context.outputBudget?.bytes ?? FIRMWARE_LIMITS.reportBytes}`, "--core=0", - "--cpu=120", + `--cpu=${FIRMWARE_LIMITS.cpuSeconds}`, "--", context.command, ...context.args, @@ -145,6 +159,19 @@ export const runFirmwareCommand = async (context: { RAYON_NUM_THREADS: "1", NO_COLOR: "1", }, + }).catch((cause: unknown) => { + // Owned admission can reject before a supervisor/child exists. Attribute + // only this signal's raw reason or a linked Node AbortError, never an + // unrelated failure merely because a timer also fired. + if ( + signal.aborted && + (cause === signal.reason || + (cause instanceof Error && + cause.name === "AbortError" && + cause.cause === signal.reason)) + ) + throw firmwareCancellation(operation, signal.reason, context.deadline); + throw cause; }); let diagnosticTruncated = false; const supervisor = new ProviderProcessSupervisor( @@ -178,15 +205,11 @@ export const runFirmwareCommand = async (context: { ); } }; - let failure: unknown; + let failure: AnalysisError | undefined; try { - const deadline = performance.now() + FIRMWARE_LIMITS.timeoutMs; while (!(await supervisor.waitForOutputClose(100))) { checkDiagnostics(); - if (signal.aborted) - throw new AnalysisCancelledError(operation, { cause: signal.reason }); - if (performance.now() >= deadline) - throw new AnalysisTimeoutError(operation, FIRMWARE_LIMITS.timeoutMs); + checkInterruption(); if (context.outputBudget !== undefined) await inventoryFirmwareOutput( context.outputBudget.root, @@ -197,27 +220,45 @@ export const runFirmwareCommand = async (context: { ); } checkDiagnostics(); - if (signal.aborted) - throw new AnalysisCancelledError(operation, { cause: signal.reason }); + checkInterruption(); } catch (cause: unknown) { - failure = cause; + failure = firmwareFailure(cause, context); } const stopped = await supervisor.stop(); + // Cleanup is an awaited part of this phase. Preserve a prior observed failure, + // otherwise retain an interruption that arrived while stopping owned resources. + failure ??= firmwarePhaseInterruption(operation, signal, context.deadline); const snapshot = supervisor.snapshot(); if (stopped.status === "incomplete") { context.retainCleanup(supervisor); - throw new ProviderCleanupError(engine, [runId, context.cwd], { - reason: stopped.reason, - leader_pid: spawned.ownership.leaderPid, - process_group_id: spawned.ownership.processGroupId, - exit_code: snapshot.exitCode ?? null, - signal: snapshot.signal ?? null, - stdout: snapshot.stdout.text, - stderr: snapshot.stderr.text, - stdout_bytes: snapshot.stdout.bytes, - stderr_bytes: snapshot.stderr.bytes, - previous_error: failure instanceof Error ? failure.message : null, - }); + const previous = + failure === undefined + ? undefined + : projectAnalysisErrorWithoutCapturedOutput(failure); + // Retain typed recovery history, but give captured output exactly one owner. + throw new ProviderCleanupError( + engine, + [runId, context.cwd], + { + reason: stopped.reason, + leader_pid: spawned.ownership.leaderPid, + process_group_id: spawned.ownership.processGroupId, + previous_error: previous ?? null, + }, + { + operation, + cleanupPrimary: failure, + capturedOutput: { + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + stdout_bytes: snapshot.stdout.bytes, + stderr_bytes: snapshot.stderr.bytes, + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, + truncated: snapshot.diagnosticTruncated === true, + }, + }, + ); } if (failure !== undefined) throw failure; if ( diff --git a/src/firmware/FirmwareDeadline.test.ts b/src/firmware/FirmwareDeadline.test.ts index 5b953ba996..6e4f568136 100644 --- a/src/firmware/FirmwareDeadline.test.ts +++ b/src/firmware/FirmwareDeadline.test.ts @@ -10,6 +10,31 @@ import { startFirmwareDeadline } from "./FirmwareDeadline.js"; afterEach(() => vi.restoreAllMocks()); +it.each(["inspect_firmware_regions", "extract_firmware"] as const)( + "validates %s analysis timer boundaries and preserves omission", + (operation) => { + const parse = (settings: Record) => + firmwareRequestSchema.parse({ + operation, + input: { + path: "/firmware.bin", + ...(operation === "extract_firmware" + ? { output_directory: "/output" } + : {}), + ...settings, + }, + }); + expect(parse({}).input).not.toHaveProperty("analysis_timeout_ms"); + for (const value of [1, 2_147_483_647]) + expect(parse({ analysis_timeout_ms: value }).input).toHaveProperty( + "analysis_timeout_ms", + value, + ); + for (const value of [0, -1, 0.5, 2_147_483_648, null, "120000"]) + expect(() => parse({ analysis_timeout_ms: value })).toThrow(); + }, +); + it("enforces absolute expiry before the timer callback while preserving observed caller cancellation", () => { const now = vi.spyOn(performance, "now").mockReturnValue(100); const deadline = startFirmwareDeadline("publication", 1_000); @@ -109,3 +134,34 @@ it("keeps a distinct caller cancellation when the phase deadline has also expire }), ).toBe(original); }); + +it.each([1234, 2_147_483_647])( + "names the actual adjustable analysis budget %s without suggesting publication repair", + (timeoutMs) => { + const reason = new Error("analysis deadline"); + const failure = firmwareFailure( + new AnalysisCancelledError("inspect_firmware_regions", { cause: reason }), + { + engine: "binwalk", + operation: "inspect_firmware_regions", + deadline: { + phase: "analysis", + signal: AbortSignal.abort(reason), + timeoutMs, + expiresAtMs: 0, + }, + }, + ); + const projected = projectAnalysisError(failure); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: timeoutMs, diagnostics: { phase: "analysis" } }, + remediation: { action: expect.stringContaining("analysis_timeout_ms") }, + }); + expect(projected.remediation.action).toContain("--analysis-timeout-ms"); + expect(projected.remediation.action).toContain("publication_timeout_ms"); + expect(projected.remediation.action).toContain("CPU"); + if (timeoutMs === 2_147_483_647) + expect(projected.remediation.action).toContain("maximum"); + }, +); diff --git a/src/firmware/FirmwareFailures.ts b/src/firmware/FirmwareFailures.ts index 3aa66ec9b4..4a80be6bc3 100644 --- a/src/firmware/FirmwareFailures.ts +++ b/src/firmware/FirmwareFailures.ts @@ -18,6 +18,7 @@ import type { FirmwareDeadline } from "./FirmwareDeadline.js"; import { firmwareDeadlineElapsed } from "./FirmwareDeadline.js"; import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; import { z } from "zod"; +import { MAX_FIRMWARE_TIMEOUT_MS } from "../domain/firmware/firmwareAnalysis.js"; type FirmwarePublicationProgress = { readonly output_directory: string; @@ -56,7 +57,10 @@ const timeoutFailure = ( remediationAction: "Read any retained publication receipt first. For a retry, choose an absent local output directory or increase publication_timeout_ms (--publication-timeout-ms in the CLI).", } - : {}), + : { + userMessage: `Firmware analysis exceeded its ${deadline.timeoutMs}ms wall-clock budget${operation === "extract_firmware" ? " before publication" : " during inspection or its final workspace cleanup"}.`, + remediationAction: `Retry the same operation and preserve the selected input and analysis question. ${deadline.timeoutMs < MAX_FIRMWARE_TIMEOUT_MS ? `Increase analysis_timeout_ms (--analysis-timeout-ms in the CLI), an integer from 1 to ${MAX_FIRMWARE_TIMEOUT_MS} (default 120000).` : `analysis_timeout_ms (--analysis-timeout-ms in the CLI) is already at the maximum ${MAX_FIRMWARE_TIMEOUT_MS}; do not increase it further.`} Set the client tool-call timeout above the analysis budget plus publication and cleanup headroom. publication_timeout_ms only controls the later publication phase and cannot repair this analysis timeout. Fixed per-process CPU, memory and byte limits are unchanged. If cleanup is incomplete, resolve the reported owned resources before retrying; no server restart is needed after confirmed cleanup.`, + }), }); /** Translate the producing file owner's outcome without guessing from a later signal. */ diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 07a6dfff07..a7a43c2332 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -7,6 +7,7 @@ import { } from "../application/AnalysisProvider.js"; import { firmwareResultSchemas, + DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS, type FirmwareRequest, type FirmwarePartialObservation, } from "../domain/firmware/firmwareAnalysis.js"; @@ -26,7 +27,6 @@ import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import type { SafeOutputTree } from "../artifacts/SafeOutputTree.js"; import { publishFirmwareExtraction } from "./FirmwarePublication.js"; import { - FIRMWARE_LIMITS, BINWALK_PROVIDER_IDENTITY, UNBLOB_PROVIDER_IDENTITY, } from "./FirmwareRelease.js"; @@ -324,7 +324,9 @@ export class FirmwareProvider implements FirmwareAnalysisPort { : AbortSignal.any([options.signal, phase.signal]); // Extraction and publication have separate budgets: publication time depends on the // destination filesystem, so it must not be charged to the extraction deadline. - let deadline = startFirmwareDeadline("analysis", FIRMWARE_LIMITS.timeoutMs); + const analysisTimeoutMs = + request.input.analysis_timeout_ms ?? DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS; + let deadline = startFirmwareDeadline("analysis", analysisTimeoutMs); let signal = withCaller(deadline); let outcome: Outcome; try { @@ -356,6 +358,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { cwd: root?.path ?? "", environment: this.environment, signal, + deadline, retainCleanup: (supervisor) => { this.#pendingCleanup.set(ownedRoot.path, { supervisor, @@ -415,6 +418,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { signal, }); result = firmwareResultSchemas.inspect_firmware_regions.parse({ + analysis_timeout_ms: analysisTimeoutMs, engine, input_size: target.size, regions: normalizeBinwalkReport( @@ -497,6 +501,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { ); signal = withCaller(deadline); result = await publishFirmwareExtraction({ + analysisTimeoutMs, files: this.#files, workspacePath: root.path, deadline, diff --git a/src/firmware/FirmwarePublication.ts b/src/firmware/FirmwarePublication.ts index 78d339daf1..d2313cdfcb 100644 --- a/src/firmware/FirmwarePublication.ts +++ b/src/firmware/FirmwarePublication.ts @@ -91,6 +91,7 @@ export const publishFirmwareExtraction = async (context: { files: FirmwareFileAccess; workspacePath: string; deadline: FirmwareDeadline; + analysisTimeoutMs: number; outputDirectory: string; entries: readonly FirmwareEntry[]; normalized: ReturnType; @@ -169,6 +170,7 @@ export const publishFirmwareExtraction = async (context: { }); } result = firmwareResultSchemas.extract_firmware.parse({ + analysis_timeout_ms: context.analysisTimeoutMs, engine: context.engine, selection: context.selection, output_directory: tree.outputRoot, diff --git a/src/firmware/FirmwareRelease.ts b/src/firmware/FirmwareRelease.ts index 8699013c33..7a62ae98b7 100644 --- a/src/firmware/FirmwareRelease.ts +++ b/src/firmware/FirmwareRelease.ts @@ -31,5 +31,5 @@ export const FIRMWARE_LIMITS = { reportBytes: 8 * 1024 * 1024, diagnosticBytes: 8 * 1024 * 1024, addressSpaceBytes: 1024 * 1024 * 1024, - timeoutMs: 120_000, + cpuSeconds: 120, } as const; diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index 9b2fc071d4..2b26822ae3 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -4,7 +4,10 @@ import { bindArtifactFileCleanup, artifactFileCancellationCause, } from "../artifacts/ArtifactFileOperation.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "../domain/analysisErrorProjection.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -48,15 +51,12 @@ export const createSqliteCleanupError = ( ): ProviderCleanupError => { const capturedOutput = options.capturedOutput ?? options.cleanupPrimary?.capturedOutput; - let previous = + const previous = options.cleanupPrimary === undefined ? null - : projectAnalysisError(options.cleanupPrimary); - // History carries facts/recovery; the wrapper delivers output only once. - if (capturedOutput !== undefined && previous?.details !== undefined) { - const { captured_output: _capturedOutput, ...metadata } = previous.details; - previous = { ...previous, details: metadata }; - } + : capturedOutput === undefined + ? projectAnalysisError(options.cleanupPrimary) + : projectAnalysisErrorWithoutCapturedOutput(options.cleanupPrimary); return new ProviderCleanupError( SQLITE_PROVIDER_IDENTITY.id, resources, diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index a16496b560..bd6a2aa55e 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -123,10 +123,10 @@ it.runIf(!unsupportedHost)( if (result.ok) throw new Error("Expected compounded lifecycle failure"); expect(result.error).toMatchObject({ cleanupIncomplete: true, + capturedOutput: { truncated: true, stderr: "" }, diagnostics: { primary_error: { code: "unreadable_output", - details: { captured_output: { truncated: true, stderr: "" } }, }, cleanup_error: { code: "cleanup_incomplete", @@ -136,6 +136,22 @@ it.runIf(!unsupportedHost)( }, }, }); + const projected = projectAnalysisError(result.error); + expect(projected.details?.captured_output).toEqual( + result.error.capturedOutput, + ); + expect(projected).not.toHaveProperty( + "details.diagnostics.primary_error.details.captured_output", + ); + const output = result.error.capturedOutput?.stdout; + if (!output) throw new Error("Expected retained truncated worker output"); + // Preserve the cleanup owner's separate snapshot, but not a redundant + // captured_output copy in generic primary history. + expect(projected).toHaveProperty( + "details.diagnostics.cleanup_error.details.diagnostics.captured_output.stdout", + output, + ); + expect(JSON.stringify(projected).split(output)).toHaveLength(3); await expect(access(ownedPath)).resolves.toBeUndefined(); cleanupAllowed = true; await provider.close(); diff --git a/tests/composition/directAnalysisCleanup.test.ts b/tests/composition/directAnalysisCleanup.test.ts index 8735177d9b..54a917b9f4 100644 --- a/tests/composition/directAnalysisCleanup.test.ts +++ b/tests/composition/directAnalysisCleanup.test.ts @@ -9,7 +9,10 @@ import { parseConfig } from "../../src/config/parseConfig.js"; import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js"; import type { AnalysisError } from "../../src/domain/analysisErrorBase.js"; import { AnalysisTimeoutError } from "../../src/domain/analysisErrorCore.js"; -import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "../../src/domain/analysisErrorProjection.js"; import { parseEvidence } from "../../src/domain/evidence.js"; import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; import { err, ok } from "../../src/domain/result.js"; @@ -270,15 +273,24 @@ describe("managed one-shot cleanup outcomes", () => { cause: primaryError, errors: [primaryError, cleanupError], }); - expect(projectAnalysisError(result.error)).toMatchObject({ + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ code: "cleanup_incomplete", details: { + captured_output: primaryError.capturedOutput, diagnostics: { - primary_error: projectAnalysisError(primaryError), + primary_error: + projectAnalysisErrorWithoutCapturedOutput(primaryError), cleanup_error: projectAnalysisError(cleanupError), }, }, }); + expect(projected).not.toHaveProperty( + "details.diagnostics.primary_error.details.captured_output", + ); + expect( + JSON.stringify(projected).split("retained observation"), + ).toHaveLength(2); }); it("exposes managed cleanup failure to the CLI error classifier", async () => { diff --git a/tests/process-global/firmwareFileLifetime.test.ts b/tests/process-global/firmwareFileLifetime.test.ts index a0bc4a0047..0b05c688c1 100644 --- a/tests/process-global/firmwareFileLifetime.test.ts +++ b/tests/process-global/firmwareFileLifetime.test.ts @@ -689,6 +689,7 @@ const publication = async ( files: new FirmwareFileAccess(), workspacePath, deadline, + analysisTimeoutMs: 120_000, outputDirectory: join(root, "published"), entries: [], normalized, diff --git a/tests/process-global/firmwarePublicationDeadline.test.ts b/tests/process-global/firmwarePublicationDeadline.test.ts index cbd1692efe..88dbf433c6 100644 --- a/tests/process-global/firmwarePublicationDeadline.test.ts +++ b/tests/process-global/firmwarePublicationDeadline.test.ts @@ -4,8 +4,11 @@ import { afterEach, expect, it, vi } from "vitest"; import * as firmwareFiles from "../../src/firmware/FirmwareFiles.js"; import { SafeOutputTree } from "../../src/artifacts/SafeOutputTree.js"; import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; -import { FIRMWARE_LIMITS } from "../../src/firmware/FirmwareRelease.js"; +import { DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS } from "../../src/domain/firmware/firmwareAnalysis.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js"; +import { finishAnalysisCommand } from "../../src/cli/finishAnalysisCommand.js"; +import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js"; import { firmwareFixture, assertFirmwareCleanup, @@ -36,13 +39,16 @@ test("preserves an already aborted caller reason without launching firmware work await fixture.close(); }); -const phases = (publicationMs: number = FIRMWARE_LIMITS.timeoutMs) => { +const phases = ( + publicationMs: number = DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS, + analysisMs: number = DEFAULT_FIRMWARE_ANALYSIS_TIMEOUT_MS, +) => { const analysis = new AbortController(); const publication = new AbortController(); const budgets: number[] = []; const timeout = AbortSignal.timeout; vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => { - if (budgets.length === 0 && ms === FIRMWARE_LIMITS.timeoutMs) { + if (budgets.length === 0 && ms === analysisMs) { budgets.push(ms); return analysis.signal; } @@ -64,6 +70,43 @@ const phaseClock = () => { }; }; +test("charges inspection workspace cleanup to its selectable analysis budget, without a hidden publication phase", async () => { + const phase = phases(120_000, 1234); + const advance = phaseClock(); + const fixture = await firmwareFixture(); + const close = PrivateRuntimeRoot.prototype.close; + vi.spyOn(PrivateRuntimeRoot.prototype, "close").mockImplementation( + async function (this: PrivateRuntimeRoot) { + await close.call(this); + advance(1235); + }, + ); + const result = await fixture.service.execute("inspect_firmware_regions", { + path: fixture.path, + analysis_timeout_ms: 1234, + }); + if (result.ok) + throw new Error( + "Expected inspection cleanup to exceed its analysis budget", + ); + expect(phase.budgets).toEqual([1234]); + expect(phase.analysis.signal.aborted).toBe(false); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "provider_timeout", + details: { + timeout_ms: 1234, + diagnostics: { phase: "analysis" }, + partial_observation: { + kind: "firmware", + result: { analysis_timeout_ms: 1234, coverage: "signature_scan" }, + }, + }, + remediation: { action: expect.stringContaining("analysis_timeout_ms") }, + }); + await fixture.close(); + await assertFirmwareCleanup(fixture.launches); +}); + test("allows publication after the analysis phase expires", async () => { const phase = phases(); const fixture = await firmwareFixture(); @@ -136,7 +179,7 @@ test.each(["signal", "clock"] as const)( }, ); -test("keeps each queued request's publication budget", async () => { +test("keeps each queued request's independent analysis and publication budgets", async () => { const fixture = await firmwareFixture(); const budgets: number[] = []; const timeout = AbortSignal.timeout; @@ -172,6 +215,7 @@ test("keeps each queued request's publication budget", async () => { path: fixture.path, output_directory: fixture.output, publication_timeout_ms: 123_456, + analysis_timeout_ms: 234_567, }); try { await entered; @@ -179,8 +223,9 @@ test("keeps each queued request's publication budget", async () => { path: fixture.path, output_directory: join(fixture.root, "second-output"), publication_timeout_ms: 654_321, + analysis_timeout_ms: 765_432, }); - expect(budgets).toEqual([120_000, 123_456]); + expect(budgets).toEqual([234_567, 123_456]); release(); const results = await Promise.all([pending, queued]); expect( @@ -190,7 +235,13 @@ test("keeps each queued request's publication budget", async () => { : result.error.message, ), ).toEqual([123_456, 654_321]); - expect(budgets).toEqual([120_000, 123_456, 120_000, 654_321]); + expect( + results.map((result) => result.ok && result.value.normalized_result), + ).toMatchObject([ + { analysis_timeout_ms: 234_567 }, + { analysis_timeout_ms: 765_432 }, + ]); + expect(budgets).toEqual([234_567, 123_456, 765_432, 654_321]); } finally { release(); } @@ -198,6 +249,204 @@ test("keeps each queued request's publication budget", async () => { await assertFirmwareCleanup(fixture.launches); }); +test("uses the selected phase deadline inside an owned command beyond the former 120-second cap", async () => { + const fixture = await firmwareFixture(); + const advance = phaseClock(); + const wait = ProviderProcessSupervisor.prototype.waitForOutputClose; + let advanced = false; + vi.spyOn( + ProviderProcessSupervisor.prototype, + "waitForOutputClose", + ).mockImplementation(async function ( + this: ProviderProcessSupervisor, + ...args + ) { + const closed = await wait.apply(this, args); + if (!advanced) { + advanced = true; + advance(120_001); + return false; + } + return closed; + }); + const result = await fixture.service.execute("inspect_firmware_regions", { + path: fixture.path, + analysis_timeout_ms: 240_000, + }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + analysis_timeout_ms: 240_000, + }); + expect(fixture.launches).toHaveLength(2); + expect( + fixture.launches.every((launch) => launch.args.includes("--cpu=120")), + ).toBe(true); + await fixture.close(); + await assertFirmwareCleanup(fixture.launches); +}); + +test.each([false, true])( + "retains typed analysis timeout recovery through failed command cleanup (one-shot close: %s)", + async (oneShot) => { + const fixture = await firmwareFixture("cleanup-failure"); + const analysis = new AbortController(); + const timeout = AbortSignal.timeout; + const timer = vi + .spyOn(AbortSignal, "timeout") + .mockImplementation((ms) => + ms === 1234 ? analysis.signal : timeout(ms), + ); + const wait = ProviderProcessSupervisor.prototype.waitForOutputClose; + const waiter = vi + .spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose") + .mockImplementation(async function ( + this: ProviderProcessSupervisor, + ...args + ) { + const closed = await wait.apply(this, args); + if (closed) + analysis.abort( + new DOMException("controlled analysis expiry", "TimeoutError"), + ); + return closed; + }); + const result = await fixture.service.execute("inspect_firmware_regions", { + path: fixture.path, + analysis_timeout_ms: 1234, + }); + if (result.ok) throw new Error("Expected timeout with retained cleanup"); + expect(result.error.cleanupPrimary).toMatchObject({ + _tag: "AnalysisTimeoutError", + timeoutMs: 1234, + }); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + previous_error: { + code: "provider_timeout", + details: { timeout_ms: 1234 }, + }, + }, + captured_output: { stdout: expect.stringContaining("binwalk 3.1.0") }, + }, + }); + expect(JSON.stringify(projected).split("binwalk 3.1.0")).toHaveLength(2); + timer.mockRestore(); + waiter.mockRestore(); + if (oneShot) { + const delivered = await finishAnalysisCommand( + async () => result, + fixture.service, + "binwalk", + "inspect_firmware_regions", + new AbortController().signal, + ); + expect(delivered).toMatchObject({ code: "cleanup_incomplete" }); + expect(JSON.stringify(delivered).split("binwalk 3.1.0")).toHaveLength(2); + } + fixture.restoreCleanup(); + if (oneShot) await fixture.close(); + else { + const retried = await fixture.service.execute( + "inspect_firmware_regions", + { + path: fixture.path, + }, + ); + if (!retried.ok) throw retried.error; + } + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: 1234 }, + }); + await fixture.close(); + await assertFirmwareCleanup(fixture.launches); + }, +); + +test.each(["analysis", "clock", "caller", "prior-failure"] as const)( + "retains an interruption observed during incomplete command cleanup: %s", + async (interruption) => { + const phase = phases(120_000, 54_321); + const advance = phaseClock(); + const fixture = await firmwareFixture("cleanup-failure"); + const caller = new AbortController(); + const reason = { caller: "during owned command cleanup" }; + const original = new AnalysisOutputError( + "inspect_firmware_regions", + "earlier command failure", + ); + const stop = ProviderProcessSupervisor.prototype.stop; + const stopper = vi + .spyOn(ProviderProcessSupervisor.prototype, "stop") + .mockImplementation(async function ( + this: ProviderProcessSupervisor, + ...args + ) { + const stopped = await stop.apply(this, args); + if (interruption === "clock") advance(54_322); + else if (interruption === "caller") caller.abort(reason); + else + phase.analysis.abort( + new DOMException("during owned command cleanup", "TimeoutError"), + ); + return stopped; + }); + if (interruption === "prior-failure") + vi.spyOn( + ProviderProcessSupervisor.prototype, + "waitForOutputClose", + ).mockRejectedValueOnce(original); + const result = await fixture.service.execute( + "inspect_firmware_regions", + { + path: fixture.path, + analysis_timeout_ms: 54_321, + }, + { signal: caller.signal }, + ); + if (result.ok) throw new Error("Expected retained command cleanup"); + const expected = + interruption === "caller" + ? "cancelled" + : interruption === "prior-failure" + ? "unreadable_output" + : "provider_timeout"; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { diagnostics: { previous_error: { code: expected } } }, + }); + const primary = result.error.cleanupPrimary; + if (primary === undefined) + throw new Error("Expected typed cleanup primary"); + if (interruption === "prior-failure") expect(primary).toBe(original); + else { + expect(primary._tag).toBe( + interruption === "caller" + ? "AnalysisCancelledError" + : "AnalysisTimeoutError", + ); + if (interruption !== "caller") + expect(projectAnalysisError(primary).details).toMatchObject({ + timeout_ms: 54_321, + diagnostics: { phase: "analysis" }, + }); + if (interruption === "caller") expect(primary.cause).toBe(reason); + if (interruption === "analysis") + expect(primary.cause).toBe(phase.analysis.signal.reason); + } + stopper.mockRestore(); + fixture.restoreCleanup(); + await fixture.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe(expected); + await assertFirmwareCleanup(fixture.launches); + }, +); + test.each(["none", "caller", "publication", "clock"] as const)( "preserves the selected budget, verified progress and rollback at commit: %s", async (interruption) => { @@ -288,7 +537,7 @@ test.each([ ] as const)( "retains committed output and late timeout through workspace cleanup: $expiry, denied: $denied", async ({ expiry, denied }) => { - const phase = phases(654_321); + const phase = phases(654_321, 234_567); const advance = phaseClock(); const fixture = await firmwareFixture(); const commit = SafeOutputTree.prototype.commit; @@ -318,9 +567,11 @@ test.each([ const result = await fixture.service.execute("extract_firmware", { path: fixture.path, output_directory: fixture.output, + analysis_timeout_ms: 234_567, publication_timeout_ms: 654_321, }); if (result.ok) throw new Error("Expected timeout during final cleanup"); + expect(phase.budgets).toEqual([234_567, 654_321]); expect(phase.publication.signal.aborted).toBe(expiry === "signal"); const projected = projectAnalysisError(result.error); expect( @@ -331,7 +582,10 @@ test.each([ }); expect(result.error.partialObservation).toMatchObject({ kind: "firmware", - result: { output_directory: fixture.output }, + result: { + output_directory: fixture.output, + analysis_timeout_ms: 234_567, + }, }); expect(await readFile(join(fixture.output, "rootfs/config"), "utf8")).toBe( "firmware=true\n", diff --git a/tests/process-global/pwntoolsCleanup.test.ts b/tests/process-global/pwntoolsCleanup.test.ts index 6260e65f0d..7735c8ba9a 100644 --- a/tests/process-global/pwntoolsCleanup.test.ts +++ b/tests/process-global/pwntoolsCleanup.test.ts @@ -15,6 +15,7 @@ import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js" import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; import { stopProviderProcessFixture } from "../fixtures/providerProcess.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { createTestWorkspace, removeTestWorkspace, @@ -113,14 +114,30 @@ it.runIf(supportedHost)( throw new Error("Expected output overflow and failed cleanup"); expect(result.error).toMatchObject({ cleanupIncomplete: true, + capturedOutput: { truncated: true }, diagnostics: { primary_error: { code: "unreadable_output", - details: { captured_output: { truncated: true } }, }, cleanup_error: { code: "cleanup_incomplete" }, }, }); + const projected = projectAnalysisError(result.error); + expect(projected.details?.captured_output).toEqual( + result.error.capturedOutput, + ); + expect(projected).not.toHaveProperty( + "details.diagnostics.primary_error.details.captured_output", + ); + const output = result.error.capturedOutput?.stdout; + if (!output) throw new Error("Expected retained truncated worker output"); + // The cleanup owner also retains a bounded worker snapshot for its own + // failed-close receipt. Generic primary history must not add a third copy. + expect(projected).toHaveProperty( + "details.diagnostics.cleanup_error.details.diagnostics.captured_output.stdout", + output, + ); + expect(JSON.stringify(projected).split(output)).toHaveLength(3); if (pid === undefined) throw new Error("The fixture did not acquire a process"); const workerPid = pid; From 33f912919d5884d1019eb7f17b202db1b77296a9 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:44:05 +0800 Subject: [PATCH 06/29] perf(apple): reuse retained Interface Builder graph validation (#5885) Reuse successful owning-schema validation only for exact immutable Evidence snapshots, preserve current parent binding and complete source facts, and collect only requested document pages. Verify real XLD CLI/MCP workflows and archived connector UIDs, including complete pre-change baselines for both admission sizes and a wrong-baseline negative control. Fixes #5855. --- .../interface-builder-oracle.py | 50 + .../interface-builder-views.json | 2039 +++++++++++++++++ .../interface-builder-views.md | 225 ++ .../interface-builder-views.mjs | 238 ++ .../analysisView/interfaceBuilderView.test.ts | 108 +- .../analysisView/interfaceBuilderView.ts | 76 +- src/domain/immutableJson.test.ts | 6 + src/domain/immutableJson.ts | 6 +- 8 files changed, 2722 insertions(+), 26 deletions(-) create mode 100644 experiments/analysis-efficiency/interface-builder-oracle.py create mode 100644 experiments/analysis-efficiency/interface-builder-views.json create mode 100644 experiments/analysis-efficiency/interface-builder-views.md create mode 100644 experiments/analysis-efficiency/interface-builder-views.mjs diff --git a/experiments/analysis-efficiency/interface-builder-oracle.py b/experiments/analysis-efficiency/interface-builder-oracle.py new file mode 100644 index 0000000000..efb08bdb84 --- /dev/null +++ b/experiments/analysis-efficiency/interface-builder-oracle.py @@ -0,0 +1,50 @@ +"""Read archived AppKit connector UIDs independently with Python's plistlib.""" + +import argparse +import hashlib +import json +from pathlib import Path +import plistlib + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + parser.add_argument('--document', required=True) + parser.add_argument('--baseline', type=Path, required=True, + help='Complete pre-change 64-document Evidence; equality control, not the oracle') + parser.add_argument('--small-baseline', type=Path, required=True, + help='Complete pre-change 1-document Evidence') + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + data = (args.bundle / args.document).read_bytes() + objects = plistlib.loads(data)['$objects'] + connections = [] + for index, value in enumerate(objects): + if not isinstance(value, dict) or not {'NSSource', 'NSLabel'} <= value.keys(): + continue + classname = objects[value['$class'].data]['$classname'] + if classname not in ('NSNibOutletConnector', 'NSNibControlConnector'): + continue + connections.append({ + 'archive_index': index, + 'kind': 'outlet' if classname == 'NSNibOutletConnector' else 'action', + 'name': objects[value['NSLabel'].data], + 'source': str(value['NSSource'].data), + 'destination': str(value['NSDestination'].data), + }) + if not connections: + raise ValueError('No supported archived connectors; choose a keyed AppKit document') + args.output.write_text(json.dumps({ + 'document': args.document, + 'archive_sha256': hashlib.sha256(data).hexdigest(), + 'connections': connections, + 'complete_result_paths': { + '1': str(args.small_baseline.resolve()), + '64': str(args.baseline.resolve()), + }, + }, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/experiments/analysis-efficiency/interface-builder-views.json b/experiments/analysis-efficiency/interface-builder-views.json new file mode 100644 index 0000000000..c9df057165 --- /dev/null +++ b/experiments/analysis-efficiency/interface-builder-views.json @@ -0,0 +1,2039 @@ +{ + "baseline": "f801b2fbfd28f4e3713adefddc7bd73db44c284c", + "tiktoken": "0.12.0", + "encoding": "cl100k_base", + "actual_model_usage": null, + "records": [ + { + "case": "base64-a", + "status": "failed", + "failure": "TypeError: Cannot read properties of undefined (reading 'from')\n at file:///home/yann/yann/rea-perf-apple-ui-20261021/experiments/analysis-efficiency/interface-builder-views.mjs:128:34\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)", + "documents_limit": 64, + "wall_seconds": 50.991, + "cpu_seconds": 50.64193, + "family_peak_bytes": 1700118528, + "first_pages_ms": 3388.1796939999986, + "warm_pages_ms": 3668.9415200000003, + "tokens": { + "input_catalog_once": 198492, + "arguments": 942, + "text_replies": 83381, + "transport_catalog": 891652, + "catalog_args_text_total": 282815 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "27e89c9e324c1ab234061d8520f669b83bbf8cbeb49c85e76c2cc00bb124df69", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "65d5460066d1507f2ae73a60ee8123540afec53df6adcad4632a578bb1784e37", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "base64-a.log": "88f0c3e84bca4d4f55c86a5a1d81aa96048542f84942aea77789f93f85d85568" + }, + "cohort": "failed-initial-harness", + "payload_exit_code": 1, + "rpc_elapsed_ms": { + "open_bundle_resources": 17749.195336999997, + "decode_interface_builder": 17337.767231, + "retained:first:nodes:0": 1166.059242999996, + "retained:first:nodes:32": 854.4084359999979, + "retained:first:nodes:64": 685.9963020000068, + "retained:first:edges:0": 681.7157129999978, + "retained:warm:nodes:0": 923.7720520000003, + "retained:warm:nodes:32": 1529.5760379999992, + "retained:warm:nodes:64": 594.8625360000005, + "retained:warm:edges:0": 620.7308940000003 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423 + } + }, + { + "case": "base64-b", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 51.481, + "cpu_seconds": 51.178498, + "family_peak_bytes": 1675866112, + "first_pages_ms": 3956.689886, + "warm_pages_ms": 3489.1726659999986, + "tokens": { + "input_catalog_once": 198492, + "arguments": 973, + "text_replies": 83420, + "transport_catalog": 891652, + "catalog_args_text_total": 282885 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "c111786fafc68ff62d07a933f661c90d397f58da01d801608f6ace78272011f4", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "226068fd310733f1e674065c8e439430404eec6e3f3ebc8c91c30788bf5820c2", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "base64-b.log": "526ad1668a069280386256ae60a3e913e44a6439a35c770a210b689f99c1e7dc" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 17019.739391000003, + "decode_interface_builder": 18137.496282999997, + "retained:first:nodes:0": 1269.5198299999975, + "retained:first:nodes:32": 1097.5174670000051, + "retained:first:nodes:64": 792.1805600000007, + "retained:first:edges:0": 797.4720289999968, + "retained:warm:nodes:0": 1420.7132550000024, + "retained:warm:nodes:32": 770.9841710000037, + "retained:warm:nodes:64": 637.9057259999972, + "retained:warm:edges:0": 659.5695139999953, + "export_evidence_bundle": 326.79149699999834 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 306 + } + }, + { + "case": "candidate64-a", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 23.898, + "cpu_seconds": 23.514812, + "family_peak_bytes": 1699565568, + "first_pages_ms": 286.40415100000246, + "warm_pages_ms": 72.82707799999844, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "843f28d9bdd2413c8c994d147f29ae816f43fa37c8255a8834292d4532775d4c", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "9131211cdce820ad9c8a026d69c305d2691e4608864016be07b9b6749d3bb983", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "candidate64-a.log": "930abb3a688796a2ff7f417c82cf158f20198658dee011aeab89772307e92493" + }, + "cohort": "pre-review-diagnostic", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8675.475145, + "decode_interface_builder": 9938.008954, + "retained:first:nodes:0": 208.56250300000102, + "retained:first:nodes:32": 48.25655899999765, + "retained:first:nodes:64": 11.428400000000693, + "retained:first:edges:0": 18.156689000003098, + "retained:warm:nodes:0": 21.8882900000026, + "retained:warm:nodes:32": 27.01408900000024, + "retained:warm:nodes:64": 11.094749999996566, + "retained:warm:edges:0": 12.829948999999033, + "export_evidence_bundle": 155.1837549999982 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 316 + } + }, + { + "case": "base64-c", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 27.42, + "cpu_seconds": 27.18631, + "family_peak_bytes": 1763991552, + "first_pages_ms": 2393.2728409999945, + "warm_pages_ms": 1661.2933239999948, + "tokens": { + "input_catalog_once": 198492, + "arguments": 973, + "text_replies": 83420, + "transport_catalog": 891652, + "catalog_args_text_total": 282885 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "8ce45652e40ffdfa61d34317e4b32bf10045968561d4ccb7a9ca479d162bb8bd", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "ff4af456c815f854c00d9a820910c298bcacf48d88dffe5d097baa7ac039a115", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "base64-c.log": "bb7b6c5f816f97aef9b5f8e978c979691a093170409cad5270fbfc6ad9931935" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8765.433656000001, + "decode_interface_builder": 9609.179089000001, + "retained:first:nodes:0": 585.3770009999971, + "retained:first:nodes:32": 497.46044299999994, + "retained:first:nodes:64": 448.13410499999736, + "retained:first:edges:0": 862.3012920000001, + "retained:warm:nodes:0": 435.9102149999999, + "retained:warm:nodes:32": 425.75104499999725, + "retained:warm:nodes:64": 423.774136, + "retained:warm:edges:0": 375.8579279999976, + "export_evidence_bundle": 212.5535630000013 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 306 + } + }, + { + "case": "candidate64-b", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 24.29, + "cpu_seconds": 24.09761, + "family_peak_bytes": 1700253696, + "first_pages_ms": 390.3116379999992, + "warm_pages_ms": 90.61565100000007, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "a3c754cd5c3ee411a987e37a65627c76e653efcc8df9db8ea9c2cc9e47d634f9", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "d9d7730cd497caed8c532041e7c48f33485b54a8256c58759c1108ef6fdd5c04", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "candidate64-b.log": "57540073c21d8d33e77c6b129f1e24fd1954502b69ce77a6a458be68aef79a96" + }, + "cohort": "pre-review-diagnostic", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8965.112603, + "decode_interface_builder": 9964.799496, + "retained:first:nodes:0": 315.4974230000007, + "retained:first:nodes:32": 51.19509299999845, + "retained:first:nodes:64": 12.884706000000733, + "retained:first:edges:0": 10.734415999999328, + "retained:warm:nodes:0": 28.8388309999973, + "retained:warm:nodes:32": 26.159010999999737, + "retained:warm:nodes:64": 11.110797000001185, + "retained:warm:edges:0": 24.50701200000185, + "export_evidence_bundle": 178.97883999999976 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 316 + } + }, + { + "case": "base1-a", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 14.521, + "cpu_seconds": 14.27046, + "family_peak_bytes": 962486272, + "first_pages_ms": 393.34008800000083, + "warm_pages_ms": 79.8695029999999, + "tokens": { + "input_catalog_once": 198492, + "arguments": 925, + "text_replies": 89328, + "transport_catalog": 891652, + "catalog_args_text_total": 288745 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "07429bc21fe5e030f358094aba858dc6154387a3f2b989b412c8cc433034b5b1", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "3df9c9da6f7e16ae37a1812ca5d70ec9f01f91a753b0e0c68e63c22ce2f9f3fb", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "base1-a.log": "ae95ec91c29445847697daa9631893f848820cb817f3d410595b212f3a46e0fa" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8811.320081, + "decode_interface_builder": 829.6466140000011, + "retained:first:nodes:0": 253.69791500000065, + "retained:first:nodes:32": 38.915006999999605, + "retained:first:nodes:64": 52.251242000000275, + "retained:first:edges:0": 48.475924000000305, + "retained:warm:nodes:0": 24.635442000000694, + "retained:warm:nodes:32": 22.423201999999947, + "retained:warm:nodes:64": 19.285373999999138, + "retained:warm:edges:0": 13.525485000000117, + "export_evidence_bundle": 24.406651999999667 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 302 + } + }, + { + "case": "candidate1-a", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 14.421, + "cpu_seconds": 14.265759, + "family_peak_bytes": 928161792, + "first_pages_ms": 318.4466739999989, + "warm_pages_ms": 105.88868699999875, + "tokens": { + "input_catalog_once": 198492, + "arguments": 926, + "text_replies": 89329, + "transport_catalog": 891652, + "catalog_args_text_total": 288747 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "e5386301e93fabdf49829e14d1888e7ece665744bafea220bc96d2d006e6364e", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "a8f54c08ce2f6049a7d9d9add90473e791ae23fe643af9eda1694caaba793fb3", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "candidate1-a.log": "1f4b1a6cc47400ba3ebaf3834874f2799a8b47fcb683e9662a1a4f12443fa0c8" + }, + "cohort": "pre-review-diagnostic", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8855.477341000002, + "decode_interface_builder": 830.204776999999, + "retained:first:nodes:0": 240.0143769999995, + "retained:first:nodes:32": 41.57286799999929, + "retained:first:nodes:64": 25.966162000000622, + "retained:first:edges:0": 10.893266999999469, + "retained:warm:nodes:0": 33.69322899999861, + "retained:warm:nodes:32": 52.6659639999998, + "retained:warm:nodes:64": 10.853257000000667, + "retained:warm:edges:0": 8.676236999999674, + "export_evidence_bundle": 20.14914399999907 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 312 + } + }, + { + "case": "base64-d", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 27.817, + "cpu_seconds": 27.589669, + "family_peak_bytes": 1693085696, + "first_pages_ms": 2355.739948999999, + "warm_pages_ms": 1656.8954969999977, + "tokens": { + "input_catalog_once": 198492, + "arguments": 973, + "text_replies": 83420, + "transport_catalog": 891652, + "catalog_args_text_total": 282885 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "6367b5beb60f1157a2d7cedd595226a01c9300903803c0680ff1ef6095434adc", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "9ebb990aca6ffa155e22a1c8f41f46bf711b0211e2a34ba66fb38f7fea257f0e", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "base64-d.log": "8f2b184af69c133e63139ac70adbd4a3d213c958a9a5316ef2c3dad1fccd7f1f" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8940.791294999999, + "decode_interface_builder": 10049.189375, + "retained:first:nodes:0": 622.5075870000001, + "retained:first:nodes:32": 522.2525859999987, + "retained:first:nodes:64": 830.4053469999999, + "retained:first:edges:0": 380.57442900000024, + "retained:warm:nodes:0": 454.59643700000015, + "retained:warm:nodes:32": 413.37309900000037, + "retained:warm:nodes:64": 375.4898209999992, + "retained:warm:edges:0": 413.436139999998, + "export_evidence_bundle": 203.834770999998 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 306 + } + }, + { + "case": "candidate64-c", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 24.389, + "cpu_seconds": 24.168312, + "family_peak_bytes": 1579798528, + "first_pages_ms": 297.56617500000357, + "warm_pages_ms": 79.30373900000268, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "1cb1deda156ef165a42212d9635884c10ab4e2450cb30c75e0b174ff76c42f11", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "eb6687b87d444d828e9383b12477d1b5c233e0d64a1c2034414e432172a624db", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "candidate64-c.log": "3f377d3124d96fc664fad25cce8c694b5de6b548d6f3851b4281a2089e65e4e3" + }, + "cohort": "pre-review-diagnostic", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8493.766014, + "decode_interface_builder": 10724.045096000002, + "retained:first:nodes:0": 216.44189800000095, + "retained:first:nodes:32": 55.42932400000063, + "retained:first:nodes:64": 13.646237000000838, + "retained:first:edges:0": 12.04871600000115, + "retained:warm:nodes:0": 29.29442299999937, + "retained:warm:nodes:32": 19.360114000002795, + "retained:warm:nodes:64": 17.378645000000688, + "retained:warm:edges:0": 13.270556999999826, + "export_evidence_bundle": 157.9878549999994 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 316 + } + }, + { + "case": "base1-b", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 14.426, + "cpu_seconds": 14.189495, + "family_peak_bytes": 1029562368, + "first_pages_ms": 379.44765200000074, + "warm_pages_ms": 87.13169700000071, + "tokens": { + "input_catalog_once": 198492, + "arguments": 925, + "text_replies": 89328, + "transport_catalog": 891652, + "catalog_args_text_total": 288745 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "31bd95098ed36cd40cc5f498679e87ed3b0dba6dc006a33e7ea8a2bde6c17750", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "ec65273e4c705be11fbe0fb020ba80cfafb9c7dc23c70fd98d37c68275bf9b14", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "base1-b.log": "0484dee607396a24cd869a7bf174d3c90d383a31fe495dcdaa16755c18ef1f81" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 9022.373514, + "decode_interface_builder": 415.484332, + "retained:first:nodes:0": 244.64386599999852, + "retained:first:nodes:32": 56.88103500000034, + "retained:first:nodes:64": 59.26247500000136, + "retained:first:edges:0": 18.660276000000522, + "retained:warm:nodes:0": 30.546250999999756, + "retained:warm:nodes:32": 20.56200500000159, + "retained:warm:nodes:64": 17.23850599999969, + "retained:warm:edges:0": 18.784934999999678, + "export_evidence_bundle": 20.133835000000545 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 302 + } + }, + { + "case": "candidate1-b", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 14.322, + "cpu_seconds": 14.063822, + "family_peak_bytes": 910708736, + "first_pages_ms": 312.15903100000105, + "warm_pages_ms": 106.91923399999723, + "tokens": { + "input_catalog_once": 198492, + "arguments": 926, + "text_replies": 89329, + "transport_catalog": 891652, + "catalog_args_text_total": 288747 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "df4cb49ad11f4ea97324ee82216d2c7b6679851d69cfdc6aca622ea3167a7ec8", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "949bee557a7b277022bbee4bfbcb5d4a29db81595bbf3dbc08dcf1cb366e3b39", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "candidate1-b.log": "e730c6b38f4b83eb64d849b42c3f61f2e9926a4afa053a0e7196edd526601c47" + }, + "cohort": "pre-review-diagnostic", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 8700.808704000001, + "decode_interface_builder": 822.1318289999999, + "retained:first:nodes:0": 232.40674000000035, + "retained:first:nodes:32": 42.78948000000128, + "retained:first:nodes:64": 25.622323999999935, + "retained:first:edges:0": 11.340486999999484, + "retained:warm:nodes:0": 30.13187299999845, + "retained:warm:nodes:32": 24.73438399999941, + "retained:warm:nodes:64": 42.265139000001, + "retained:warm:edges:0": 9.787837999998374, + "export_evidence_bundle": 22.565863999998328 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 312 + } + }, + { + "case": "final64-a", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 45.451, + "cpu_seconds": 45.159521, + "family_peak_bytes": 1678360576, + "first_pages_ms": 502.6393830000088, + "warm_pages_ms": 113.75028999999631, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "21d11b8d8e54443ef725ad30e1e7723b1322ab3a1c3ef07b5e0b29528ea9c47b", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "01044e0718834c73146ad23a33776539316af906687b58a5ad93658f76334b03", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "final64-a.log": "15682283ca803c76a6799c72096b9a4c0ba02e1a8e94b4689e849dbe73e0f363" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 17300.406723, + "decode_interface_builder": 19707.669085999998, + "retained:first:nodes:0": 408.7007730000041, + "retained:first:nodes:32": 67.7736040000018, + "retained:first:nodes:64": 11.763998000002175, + "retained:first:edges:0": 14.40100800000073, + "retained:warm:nodes:0": 45.824015999998664, + "retained:warm:nodes:32": 36.86674700000003, + "retained:warm:nodes:64": 17.011309000001347, + "retained:warm:edges:0": 14.048217999996268, + "export_evidence_bundle": 217.4340299999967 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 308 + } + }, + { + "case": "final64-b", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 28.104, + "cpu_seconds": 27.66296, + "family_peak_bytes": 1589579776, + "first_pages_ms": 337.1969790000003, + "warm_pages_ms": 94.25291099999959, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "c456d12d1c433930fe0c367cf682605be30fcdb9e43726db1154569a8aa77067", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "b7aace414d26430deaa5757f168b9518d67013eed774c3ca035f6965e31a2b68", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "final64-b.log": "59f1d03576b569ef8568ce82969d445e1f19d2a7413fea5b91b3679dfefda589" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 9580.544025, + "decode_interface_builder": 10731.996933999999, + "retained:first:nodes:0": 264.14993499999764, + "retained:first:nodes:32": 47.82620600000155, + "retained:first:nodes:64": 12.882488999999623, + "retained:first:edges:0": 12.338349000001472, + "retained:warm:nodes:0": 31.906856999998126, + "retained:warm:nodes:32": 22.65899800000261, + "retained:warm:nodes:64": 20.228237999999692, + "retained:warm:edges:0": 19.458817999999155, + "export_evidence_bundle": 190.28224300000147 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 308 + } + }, + { + "case": "final64-c", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 27.658, + "cpu_seconds": 27.423775, + "family_peak_bytes": 1686052864, + "first_pages_ms": 725.3027760000004, + "warm_pages_ms": 202.0118909999983, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "1eff45931ebf95e8d6a422e5d4bd08669e5b223366f23fd40c7897ec9d854b09", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "62984f96d7479eaed95deebdb2c997791432f6b3f3269c9732d9203840c0dbb6", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "final64-c.log": "8beebdde99e3030c24e99cfbc71afea89f5d6d0353b9970318e0a949b094465e" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 9291.559111999999, + "decode_interface_builder": 12147.919030000001, + "retained:first:nodes:0": 627.6897540000027, + "retained:first:nodes:32": 62.24454499999774, + "retained:first:nodes:64": 15.814807999999175, + "retained:first:edges:0": 19.55366900000081, + "retained:warm:nodes:0": 112.77606000000014, + "retained:warm:nodes:32": 60.271733999998105, + "retained:warm:nodes:64": 13.751129000000219, + "retained:warm:edges:0": 15.212967999999819, + "export_evidence_bundle": 195.4423029999998 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 308 + } + }, + { + "case": "final1-a", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 16.361, + "cpu_seconds": 16.070345, + "family_peak_bytes": 989687808, + "first_pages_ms": 349.94369800000095, + "warm_pages_ms": 101.16938900000059, + "tokens": { + "input_catalog_once": 198492, + "arguments": 926, + "text_replies": 89329, + "transport_catalog": 891652, + "catalog_args_text_total": 288747 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "e61b6d3a30009a38069cec430b92c719f7847da05d472037ec1c3d50857e0b1a", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "94f566a17731de29e711413e03460b271c4d49c7fee0d89e47d38d7cc22e245a", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "final1-a.log": "3cddeda326963098c14f6ee4ab6fdae23566f0f5fc13d893a8595e51067bbcb0" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 9938.202416, + "decode_interface_builder": 945.5195460000014, + "retained:first:nodes:0": 255.7381179999993, + "retained:first:nodes:32": 47.779545000001235, + "retained:first:nodes:64": 34.98892700000033, + "retained:first:edges:0": 11.43710800000008, + "retained:warm:nodes:0": 22.465237000000343, + "retained:warm:nodes:32": 27.447097000000213, + "retained:warm:nodes:64": 11.183578999998645, + "retained:warm:edges:0": 40.07347600000139, + "export_evidence_bundle": 34.000576999998884 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 304 + } + }, + { + "case": "final1-b", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 19.213, + "cpu_seconds": 18.923359, + "family_peak_bytes": 983822336, + "first_pages_ms": 385.12225599999874, + "warm_pages_ms": 121.68651899999895, + "tokens": { + "input_catalog_once": 198492, + "arguments": 926, + "text_replies": 89329, + "transport_catalog": 891652, + "catalog_args_text_total": 288747 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "d7a41f94a218827e802e16a95ee72d0de454f09fc866cc0b7bf41f7d04787b0a", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "c44ec58566bec7d2c9ee7d20242b064d0ac5370162692cec62bc98e7c2ac910b", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "final1-b.log": "0f13e36b1e65d81a8abfa570aea06f0d4cb24a2dd39c3a96701255ec418b0839" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 12526.691116, + "decode_interface_builder": 518.3830240000025, + "retained:first:nodes:0": 278.44313599999805, + "retained:first:nodes:32": 47.781536000002234, + "retained:first:nodes:64": 29.427746999997908, + "retained:first:edges:0": 29.469837000000553, + "retained:warm:nodes:0": 24.411996999999246, + "retained:warm:nodes:32": 34.29762800000026, + "retained:warm:nodes:64": 52.186045999998896, + "retained:warm:edges:0": 10.790848000000551, + "export_evidence_bundle": 26.291368999998667 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 304 + } + }, + { + "case": "paired-base-a", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 42.361, + "cpu_seconds": 41.989455, + "family_peak_bytes": 1726439424, + "first_pages_ms": 2606.3296100000007, + "warm_pages_ms": 2608.246241000008, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "437f78b63b90caeb5fd5585da13880a271c11720f7f4e8da61c396b0c0afed08", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "ad7c5828d91bc3af3ceb3abefacd05fe36ead59b30663f58e6f77372523aa085", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-base-a.log": "e68d8e1dd2816959efa23f5303bcf543e7a28c07170d24e575ed8bb14ae661b2" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 14478.24454, + "decode_interface_builder": 14946.413408000004, + "retained:first:nodes:0": 896.6980340000009, + "retained:first:nodes:32": 612.930088000001, + "retained:first:nodes:64": 512.8796670000011, + "retained:first:edges:0": 583.8218209999977, + "retained:warm:nodes:0": 1305.0322510000042, + "retained:warm:nodes:32": 454.78708200000256, + "retained:warm:nodes:64": 472.8005689999991, + "retained:warm:edges:0": 375.6263390000022, + "export_evidence_bundle": 234.39118099999905 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 316 + } + }, + { + "case": "paired-final-a", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 27.683, + "cpu_seconds": 27.415948, + "family_peak_bytes": 1626423296, + "first_pages_ms": 338.8228429999981, + "warm_pages_ms": 82.13496300000043, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "73952930a8b8e88b8a22d7f70ac8fb56e6252f1919aecbb5bd4a7fef5af9006b", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "adcd0752e09c7d805a0b7fb03def70f0f71126c96c5f3d5cef5955245951b494", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-final-a.log": "b5142a86306593cc784b29338baea6544e039b6c4dec525b7bddbe44f1c21a11" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 10389.192591, + "decode_interface_builder": 11465.374235999998, + "retained:first:nodes:0": 243.36653999999908, + "retained:first:nodes:32": 60.40034600000217, + "retained:first:nodes:64": 13.298558999998932, + "retained:first:edges:0": 21.75739799999792, + "retained:warm:nodes:0": 27.915908000002673, + "retained:warm:nodes:32": 25.82844699999987, + "retained:warm:nodes:64": 13.422219000000041, + "retained:warm:edges:0": 14.968388999997842, + "export_evidence_bundle": 174.43110499999966 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 318 + } + }, + { + "case": "paired-base-b", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 30.798, + "cpu_seconds": 30.53183, + "family_peak_bytes": 1583390720, + "first_pages_ms": 3312.2940770000023, + "warm_pages_ms": 1817.7919000000002, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "840e50593dc87a5d71cf06f8c6e760e39d632c2dc3ba418b2de2cd8e94c1ea30", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "c9b8c7260b7368da3e01026762a2a86dd5e87f1d3d57640e3710c6f8828e9625", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-base-b.log": "2548cb0dd9876190e549421a8f32d3f62962904376529d0cbf3a7140f588441d" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 9854.016906, + "decode_interface_builder": 10634.946204000002, + "retained:first:nodes:0": 828.5348120000017, + "retained:first:nodes:32": 770.8737260000016, + "retained:first:nodes:64": 1141.1423859999995, + "retained:first:edges:0": 571.7431529999994, + "retained:warm:nodes:0": 488.864239999999, + "retained:warm:nodes:32": 459.1659319999999, + "retained:warm:nodes:64": 403.45395700000154, + "retained:warm:edges:0": 466.3077709999998, + "export_evidence_bundle": 209.22893199999817 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 316 + } + }, + { + "case": "paired-final-b", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 25.247, + "cpu_seconds": 24.979939, + "family_peak_bytes": 1592606720, + "first_pages_ms": 299.69509700000344, + "warm_pages_ms": 69.85876400000052, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "948baef8a55068304acb37f89d45a9654f80fbd189b579c4057a3f299054c2bf", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "bdcae254ab669e5658592ca7eeb912b0b57d7aa5d7fd457fcba17d61ed944a9d", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-final-b.log": "5ff421e6b16b56dfdc4294cc8051fc4e03e7b4ae5273ec1f7116661abdc4e5f4" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 9434.522058, + "decode_interface_builder": 10145.091444, + "retained:first:nodes:0": 226.1149919999989, + "retained:first:nodes:32": 47.55704700000206, + "retained:first:nodes:64": 13.987059000002773, + "retained:first:edges:0": 12.03599899999972, + "retained:warm:nodes:0": 18.870499000000564, + "retained:warm:nodes:32": 20.93528900000092, + "retained:warm:nodes:64": 16.08977799999775, + "retained:warm:edges:0": 13.963198000001285, + "export_evidence_bundle": 167.33527700000195 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 73267, + "retained:first:nodes:0": 85591, + "retained:first:nodes:32": 89363, + "retained:first:nodes:64": 61877, + "retained:first:edges:0": 55423, + "retained:warm:nodes:0": 85591, + "retained:warm:nodes:32": 89363, + "retained:warm:nodes:64": 61877, + "retained:warm:edges:0": 55423, + "export_evidence_bundle": 318 + } + }, + { + "case": "paired-small-base-a", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 16.883, + "cpu_seconds": 16.642377, + "family_peak_bytes": 1004916736, + "first_pages_ms": 385.71881999999823, + "warm_pages_ms": 120.08177099999739, + "tokens": { + "input_catalog_once": 198492, + "arguments": 927, + "text_replies": 89330, + "transport_catalog": 891652, + "catalog_args_text_total": 288749 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "69cac3d8dad8756d82c57db0ad74ddd88d2eee7f457982eb78bcfc1a6470a2f8", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "220762862f95028998c5296d71933602dd2958d643b06fd2bedc43d6bb9688f9", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-small-base-a.log": "083e566addc7785558ecb9be86d2de514fbff3c035016e44fc1da4a6a14b8d0d" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 10192.143904, + "decode_interface_builder": 869.8038230000002, + "retained:first:nodes:0": 259.25972, + "retained:first:nodes:32": 42.00718599999891, + "retained:first:nodes:64": 63.25943499999994, + "retained:first:edges:0": 21.192478999999366, + "retained:warm:nodes:0": 63.1786749999992, + "retained:warm:nodes:32": 26.862538000001223, + "retained:warm:nodes:64": 16.07532899999933, + "retained:warm:edges:0": 13.965228999997635, + "export_evidence_bundle": 22.24519899999723 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 326 + } + }, + { + "case": "paired-small-final-a", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 16.988, + "cpu_seconds": 16.720706, + "family_peak_bytes": 991711232, + "first_pages_ms": 324.83837599999606, + "warm_pages_ms": 102.21713199999795, + "tokens": { + "input_catalog_once": 198492, + "arguments": 927, + "text_replies": 89330, + "transport_catalog": 891652, + "catalog_args_text_total": 288749 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "398343d550bb7a11d1c5737d5fb5feda709f740207abe9eab161ea287c4af15b", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "3c4a73568bcc58aa6e0dee8bc3b54277c4cac6053442e62126b2071cf5eada76", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-small-final-a.log": "10d540bd81abb2f170801bdd5aa7b24ff8301986626c57c1ffc489681a55818e" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 10421.354091, + "decode_interface_builder": 935.4700279999997, + "retained:first:nodes:0": 245.61356100000012, + "retained:first:nodes:32": 42.95156700000007, + "retained:first:nodes:64": 25.667897999997876, + "retained:first:edges:0": 10.605349999997998, + "retained:warm:nodes:0": 30.417288000000553, + "retained:warm:nodes:32": 53.03630599999815, + "retained:warm:nodes:64": 10.664878999999928, + "retained:warm:edges:0": 8.098658999999316, + "export_evidence_bundle": 21.154237999999168 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 328 + } + }, + { + "case": "paired-small-base-b", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 18.317, + "cpu_seconds": 18.026515, + "family_peak_bytes": 992854016, + "first_pages_ms": 556.5680170000014, + "warm_pages_ms": 114.84649100000024, + "tokens": { + "input_catalog_once": 198492, + "arguments": 927, + "text_replies": 89330, + "transport_catalog": 891652, + "catalog_args_text_total": 288749 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "e1abb6e9bb3344e75cf503a613f8dd055f5107545649a652861d787e9fb527e7", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "b3c316fbe15a13c5cdf78e5c4913e9bf4dadad2147959225c225d02ebc490a2a", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-small-base-b.log": "a899914b975dc3841b0e172c58ebb9a1934a8784189e97642bb6fdf19a3b7c3f" + }, + "cohort": "baseline", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 11258.444909999998, + "decode_interface_builder": 1147.0202310000004, + "retained:first:nodes:0": 352.18821299999763, + "retained:first:nodes:32": 57.270345000000816, + "retained:first:nodes:64": 79.75677400000131, + "retained:first:edges:0": 67.35268500000166, + "retained:warm:nodes:0": 36.704587000000174, + "retained:warm:nodes:32": 34.813686999998026, + "retained:warm:nodes:64": 25.278568000001542, + "retained:warm:edges:0": 18.0496490000005, + "export_evidence_bundle": 27.995558000002347 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 326 + } + }, + { + "case": "paired-small-final-b", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 20.276, + "cpu_seconds": 20.021178, + "family_peak_bytes": 1016156160, + "first_pages_ms": 491.9646810000013, + "warm_pages_ms": 87.60847399999693, + "tokens": { + "input_catalog_once": 198492, + "arguments": 927, + "text_replies": 89330, + "transport_catalog": 891652, + "catalog_args_text_total": 288749 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "a3bf76fa7df53959df42698ffaabfaf40f15ce5bacb841519f3dfd1e42d10c38", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "cfde9c3e9c89ca30181ca3a7d0a00878daad291d6cb6d2f0dc095b530b6ba069", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "paired-small-final-b.log": "2efb80764db42ebe0e17925db76c5021291dca71b3fea3ce312fb382b1c7e9e5" + }, + "cohort": "final", + "payload_exit_code": 0, + "rpc_elapsed_ms": { + "open_bundle_resources": 13076.700744999998, + "decode_interface_builder": 658.1864890000033, + "retained:first:nodes:0": 325.4082649999982, + "retained:first:nodes:32": 66.75422500000059, + "retained:first:nodes:64": 66.08206400000199, + "retained:first:edges:0": 33.7201270000005, + "retained:warm:nodes:0": 33.28379700000005, + "retained:warm:nodes:32": 25.212798000000475, + "retained:warm:nodes:64": 14.38543899999786, + "retained:warm:edges:0": 14.726439999998547, + "export_evidence_bundle": 26.190567999998166 + }, + "rpc_reply_bytes": { + "open_bundle_resources": 781, + "decode_interface_builder": 43711, + "retained:first:nodes:0": 94151, + "retained:first:nodes:32": 97923, + "retained:first:nodes:64": 70437, + "retained:first:edges:0": 63983, + "retained:warm:nodes:0": 94151, + "retained:warm:nodes:32": 97923, + "retained:warm:nodes:64": 70437, + "retained:warm:edges:0": 63983, + "export_evidence_bundle": 328 + } + } + ], + "pre_review_source_fingerprints": { + "src/domain/analysisView/interfaceBuilderView.ts": "d605205312e9d3240737a13f7ccc08706976021e48b0e5db6e92afdca4c64a96", + "src/domain/immutableJson.ts": "0138c1e41866272ffe8a933b303e77468ee255f3ae9230d13d354b8af4290b3a", + "src/domain/analysisView/interfaceBuilderView.test.ts": "82f19a4bd2f3d2171324ce6f4d5928f670d738662cdd8dfc669cbdeaba5c9dac", + "src/domain/immutableJson.test.ts": "11377201326f068a38bc455b13ca0b38ccbe767db6d768c7d8519186e85fd47d", + "experiments/analysis-efficiency/interface-builder-views.mjs": "caa21f946d9a5499a924db7c3b00efb42cee1cda2a75254f62e764273db41ac4", + "experiments/analysis-efficiency/interface-builder-oracle.py": "421728911c7d46c08a8488c171f1b447a1a43dd35b12a8cde0c739b73f7647e3" + }, + "resource_accounting": "Authenticated payload-exit cgroup counters including observer; final scope counters unavailable", + "scope": "Fresh SDK/server task on prepared artifact including warm query diagnostic and cleanup; acquisition/build/oracle preparation excluded", + "isolated_projection_diagnostic": { + "excludes": "imports, reading/sealing the already decoded parent, authentication, startup, decoder, serialization, retention and cleanup; real public tasks account for those separately", + "source_sha256": "ec9d354ededf9895c123d622176bb5382a8482a4023375d7695573a201477cfe", + "receipt_sha256": "9c3f866e96d93dad5a69c6ec207181235a97a726a8ce6e459408df53fc571b47", + "measurements": { + "status": "passed", + "records": [ + { + "size": 1, + "epoch": 0, + "lane": "baseline", + "times": [ + { + "wall_ms": 68.41303400000004, + "cpu_ms": 70.081 + }, + { + "wall_ms": 29.286256999999978, + "cpu_ms": 29.326 + }, + { + "wall_ms": 43.9494370000001, + "cpu_ms": 44.027 + }, + { + "wall_ms": 43.20122700000002, + "cpu_ms": 42.528 + }, + { + "wall_ms": 44.23856599999999, + "cpu_ms": 48.3 + }, + { + "wall_ms": 24.85953900000004, + "cpu_ms": 24.903 + }, + { + "wall_ms": 25.515979000000016, + "cpu_ms": 25.553 + }, + { + "wall_ms": 17.346677999999997, + "cpu_ms": 17.379 + }, + { + "wall_ms": 37.6827780000001, + "cpu_ms": 37.717 + } + ] + }, + { + "size": 1, + "epoch": 0, + "lane": "final", + "times": [ + { + "wall_ms": 73.70459500000015, + "cpu_ms": 73.755 + }, + { + "wall_ms": 1.2189100000000508, + "cpu_ms": 1.242 + }, + { + "wall_ms": 9.507348999999977, + "cpu_ms": 9.538 + }, + { + "wall_ms": 0.2941000000000713, + "cpu_ms": 0.312 + }, + { + "wall_ms": 0.1800500000001648, + "cpu_ms": 0.192 + }, + { + "wall_ms": 0.19095100000004095, + "cpu_ms": 0.206 + }, + { + "wall_ms": 0.2362799999998515, + "cpu_ms": 0.252 + }, + { + "wall_ms": 0.20059900000001107, + "cpu_ms": 0.212 + }, + { + "wall_ms": 0.17951000000016393, + "cpu_ms": 0.191 + } + ] + }, + { + "size": 1, + "epoch": 1, + "lane": "final", + "times": [ + { + "wall_ms": 18.17219900000009, + "cpu_ms": 18.221 + }, + { + "wall_ms": 0.613589999999931, + "cpu_ms": 0.628 + }, + { + "wall_ms": 0.1300300000000334, + "cpu_ms": 0.135 + }, + { + "wall_ms": 0.43833999999992557, + "cpu_ms": 0.452 + }, + { + "wall_ms": 0.09960999999998421, + "cpu_ms": 0.104 + }, + { + "wall_ms": 0.11517000000003463, + "cpu_ms": 0.122 + }, + { + "wall_ms": 1.2772500000000946, + "cpu_ms": 1.282 + }, + { + "wall_ms": 0.0995700000000852, + "cpu_ms": 0.106 + }, + { + "wall_ms": 0.07568999999989501, + "cpu_ms": 0.08 + } + ] + }, + { + "size": 1, + "epoch": 1, + "lane": "baseline", + "times": [ + { + "wall_ms": 21.308299999999917, + "cpu_ms": 21.355 + }, + { + "wall_ms": 24.813978000000134, + "cpu_ms": 24.834 + }, + { + "wall_ms": 6.929008999999951, + "cpu_ms": 6.947 + }, + { + "wall_ms": 5.681449000000157, + "cpu_ms": 5.7 + }, + { + "wall_ms": 4.449569999999994, + "cpu_ms": 4.468 + }, + { + "wall_ms": 18.29602799999998, + "cpu_ms": 18.343 + }, + { + "wall_ms": 4.77597999999989, + "cpu_ms": 4.794 + }, + { + "wall_ms": 10.4982100000002, + "cpu_ms": 10.512 + }, + { + "wall_ms": 5.67465900000002, + "cpu_ms": 5.69 + } + ] + }, + { + "size": 1, + "epoch": 2, + "lane": "baseline", + "times": [ + { + "wall_ms": 36.07669800000008, + "cpu_ms": 36.108 + }, + { + "wall_ms": 11.09699999999998, + "cpu_ms": 11.112 + }, + { + "wall_ms": 6.764928999999938, + "cpu_ms": 6.782 + }, + { + "wall_ms": 21.68032900000003, + "cpu_ms": 21.696 + }, + { + "wall_ms": 6.783108999999968, + "cpu_ms": 6.797 + }, + { + "wall_ms": 21.953609000000142, + "cpu_ms": 21.849 + }, + { + "wall_ms": 25.07021799999984, + "cpu_ms": 25.102 + }, + { + "wall_ms": 11.641978999999992, + "cpu_ms": 11.653 + }, + { + "wall_ms": 16.247978000000103, + "cpu_ms": 16.285 + } + ] + }, + { + "size": 1, + "epoch": 2, + "lane": "final", + "times": [ + { + "wall_ms": 28.014597999999978, + "cpu_ms": 28.068 + }, + { + "wall_ms": 0.3190300000001116, + "cpu_ms": 0.327 + }, + { + "wall_ms": 0.5170200000000023, + "cpu_ms": 0.532 + }, + { + "wall_ms": 0.2934689999999591, + "cpu_ms": 0.302 + }, + { + "wall_ms": 0.17150000000015098, + "cpu_ms": 0.178 + }, + { + "wall_ms": 0.2591100000001916, + "cpu_ms": 0.264 + }, + { + "wall_ms": 0.2626309999998284, + "cpu_ms": 0.294 + }, + { + "wall_ms": 0.2321799999999712, + "cpu_ms": 0.236 + }, + { + "wall_ms": 0.1327689999998256, + "cpu_ms": 0.138 + } + ] + }, + { + "size": 64, + "epoch": 0, + "lane": "baseline", + "times": [ + { + "wall_ms": 1100.342839, + "cpu_ms": 1093.983 + }, + { + "wall_ms": 708.1302479999999, + "cpu_ms": 707.257 + }, + { + "wall_ms": 721.5706760000003, + "cpu_ms": 721.333 + }, + { + "wall_ms": 810.9676309999995, + "cpu_ms": 810.507 + }, + { + "wall_ms": 713.5866079999996, + "cpu_ms": 713.009 + }, + { + "wall_ms": 657.5378719999999, + "cpu_ms": 655.528 + }, + { + "wall_ms": 684.4311900000002, + "cpu_ms": 684.385 + }, + { + "wall_ms": 566.7142789999998, + "cpu_ms": 566.675 + }, + { + "wall_ms": 499.6365730000007, + "cpu_ms": 499.549 + } + ] + }, + { + "size": 64, + "epoch": 0, + "lane": "final", + "times": [ + { + "wall_ms": 881.4091349999999, + "cpu_ms": 879.842 + }, + { + "wall_ms": 3.981789999999819, + "cpu_ms": 4.005 + }, + { + "wall_ms": 3.931600999998409, + "cpu_ms": 3.944 + }, + { + "wall_ms": 3.212169000000358, + "cpu_ms": 3.224 + }, + { + "wall_ms": 11.921779000000242, + "cpu_ms": 11.94 + }, + { + "wall_ms": 3.441370000000461, + "cpu_ms": 3.473 + }, + { + "wall_ms": 2.952068999999028, + "cpu_ms": 2.97 + }, + { + "wall_ms": 2.792419000001246, + "cpu_ms": 2.806 + }, + { + "wall_ms": 2.468410000001313, + "cpu_ms": 2.484 + } + ] + }, + { + "size": 64, + "epoch": 1, + "lane": "final", + "times": [ + { + "wall_ms": 685.8004799999999, + "cpu_ms": 685.639 + }, + { + "wall_ms": 2.5660700000007637, + "cpu_ms": 2.577 + }, + { + "wall_ms": 3.658269999999902, + "cpu_ms": 3.67 + }, + { + "wall_ms": 8.307368999998289, + "cpu_ms": 8.332 + }, + { + "wall_ms": 0.6905699999988428, + "cpu_ms": 0.703 + }, + { + "wall_ms": 2.457350000000588, + "cpu_ms": 2.471 + }, + { + "wall_ms": 1.5386899999994057, + "cpu_ms": 1.543 + }, + { + "wall_ms": 1.1259989999998652, + "cpu_ms": 1.131 + }, + { + "wall_ms": 0.4890500000001339, + "cpu_ms": 0.492 + } + ] + }, + { + "size": 64, + "epoch": 1, + "lane": "baseline", + "times": [ + { + "wall_ms": 543.0276200000008, + "cpu_ms": 542.928 + }, + { + "wall_ms": 576.3298480000012, + "cpu_ms": 573.412 + }, + { + "wall_ms": 606.2757359999996, + "cpu_ms": 606.276 + }, + { + "wall_ms": 564.9468080000006, + "cpu_ms": 564.96 + }, + { + "wall_ms": 507.8036940000002, + "cpu_ms": 507.732 + }, + { + "wall_ms": 484.5579740000012, + "cpu_ms": 484.485 + }, + { + "wall_ms": 592.9803169999996, + "cpu_ms": 590.128 + }, + { + "wall_ms": 518.292512, + "cpu_ms": 518.214 + }, + { + "wall_ms": 609.3945350000013, + "cpu_ms": 609.412 + } + ] + }, + { + "size": 64, + "epoch": 2, + "lane": "baseline", + "times": [ + { + "wall_ms": 620.1696850000008, + "cpu_ms": 620.167 + }, + { + "wall_ms": 647.038063, + "cpu_ms": 646.998 + }, + { + "wall_ms": 628.229163, + "cpu_ms": 628.178 + }, + { + "wall_ms": 832.7170499999993, + "cpu_ms": 832.658 + }, + { + "wall_ms": 556.3752390000009, + "cpu_ms": 556.295 + }, + { + "wall_ms": 625.9563049999997, + "cpu_ms": 625.621 + }, + { + "wall_ms": 521.6217819999983, + "cpu_ms": 521.616 + }, + { + "wall_ms": 763.3255339999996, + "cpu_ms": 763.213 + }, + { + "wall_ms": 808.9021509999984, + "cpu_ms": 808.738 + } + ] + }, + { + "size": 64, + "epoch": 2, + "lane": "final", + "times": [ + { + "wall_ms": 1067.6547919999975, + "cpu_ms": 1067.543 + }, + { + "wall_ms": 13.563959999999497, + "cpu_ms": 13.554 + }, + { + "wall_ms": 2.0100099999981467, + "cpu_ms": 2.028 + }, + { + "wall_ms": 1.9144300000007206, + "cpu_ms": 1.927 + }, + { + "wall_ms": 0.538540000001376, + "cpu_ms": 0.548 + }, + { + "wall_ms": 2.348549999998795, + "cpu_ms": 2.361 + }, + { + "wall_ms": 1.8924990000014077, + "cpu_ms": 1.904 + }, + { + "wall_ms": 1.8247210000008636, + "cpu_ms": 1.834 + }, + { + "wall_ms": 0.6497290000006615, + "cpu_ms": 0.667 + } + ] + } + ] + }, + "resource_observation": { + "wall_seconds": 24.043, + "cpu_seconds": 23.736371, + "peak_cgroup_bytes": 546844672, + "payload_exit_code": 0, + "payload_cleanup_verified": true, + "payload_counters_verified": true, + "resource_events": { + "memory.events": { + "low": 0, + "high": 0, + "max": 0, + "oom": 0, + "oom_kill": 0, + "oom_group_kill": 0 + }, + "pids.events": { + "max": 0 + } + }, + "final_scope_events": null + } + }, + "public_task_resource_policy": { + "hard_memory_bytes": 4294967296, + "early_stop_bytes": 3221225472, + "cpu_quota_percent": 100, + "cpu_affinity": [15], + "task_ceiling": 192 + }, + "public_task_resource_observations": { + "payload_counters_verified": true, + "payload_cleanup_verified": true, + "abort": null, + "limit_events": { + "memory.events": { + "low": 0, + "high": 0, + "max": 0, + "oom": 0, + "oom_kill": 0, + "oom_group_kill": 0 + }, + "pids.events": { + "max": 0 + } + }, + "final_scope_events": null, + "rpc_error_count": 0 + }, + "post_rebase_verification": { + "revision": "383e19f43cb9be1cd9db4bf22e4ba7bca016f4a2", + "base": "f5410bdc4c86de564ddb24f24df86c41cfdd8bec", + "purpose": "Integration correctness confirmation, excluded from the pinned pre-rebase comparison medians. Complete retained parents equal original baseline for both sizes; complete and portable CLI Evidence also equal baseline/MCP.", + "records": [ + { + "case": "rebased64", + "status": "passed", + "documents_limit": 64, + "wall_seconds": 29.808, + "cpu_seconds": 29.494642, + "family_peak_bytes": 1599897600, + "tokens": { + "input_catalog_once": 198492, + "arguments": 973, + "text_replies": 83420, + "transport_catalog": 891652, + "catalog_args_text_total": 282885 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "21728d09aab31188c85fd2bb68741e8b467121634cc4c76145613a0ff2e7b375", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "2da913b447910ddbd2c8e207ed5316607bbb28a1f282bdb45bafcc4b59a37794", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rebased64.log": "636318779fa44e685a0a2034f6e3e091dc668202534fb33ea6b770b91085047b" + } + }, + { + "case": "rebased1", + "status": "passed", + "documents_limit": 1, + "wall_seconds": 21.098, + "cpu_seconds": 20.773775, + "family_peak_bytes": 1012338688, + "tokens": { + "input_catalog_once": 198492, + "arguments": 925, + "text_replies": 89328, + "transport_catalog": 891652, + "catalog_args_text_total": 288745 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "e4f51a350e0a81bb97875c68ffe338f72946cc949418ef6fd51f25b20fd53811", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "fe05f481070a3e940f6a7a4e5663503a7b176c79e93aa91c21ce7b9ac864395a", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rebased1.log": "06c2cb16cba89a596028cb15c2fdf88d6ad7de3793e83954c65957d2a3e318a2" + } + } + ] + }, + "measured_sources_sha256": { + "src/domain/analysisView/interfaceBuilderView.ts": "54dae141833b6032af49b9be14e32200c789d8e3c77f597fb56fd23a50143623", + "src/domain/immutableJson.ts": "0138c1e41866272ffe8a933b303e77468ee255f3ae9230d13d354b8af4290b3a", + "src/domain/analysisView/interfaceBuilderView.test.ts": "9e8eaec694e386d6af109eb270a7d382c6a60d509d6abcbe39d52af879c439f7", + "src/domain/immutableJson.test.ts": "11377201326f068a38bc455b13ca0b38ccbe767db6d768c7d8519186e85fd47d", + "experiments/analysis-efficiency/interface-builder-views.mjs": "caa21f946d9a5499a924db7c3b00efb42cee1cda2a75254f62e764273db41ac4", + "experiments/analysis-efficiency/interface-builder-oracle.py": "421728911c7d46c08a8488c171f1b447a1a43dd35b12a8cde0c739b73f7647e3" + }, + "current_sources_sha256": { + "src/domain/analysisView/interfaceBuilderView.ts": "54dae141833b6032af49b9be14e32200c789d8e3c77f597fb56fd23a50143623", + "src/domain/immutableJson.ts": "0138c1e41866272ffe8a933b303e77468ee255f3ae9230d13d354b8af4290b3a", + "src/domain/analysisView/interfaceBuilderView.test.ts": "9e8eaec694e386d6af109eb270a7d382c6a60d509d6abcbe39d52af879c439f7", + "src/domain/immutableJson.test.ts": "11377201326f068a38bc455b13ca0b38ccbe767db6d768c7d8519186e85fd47d", + "experiments/analysis-efficiency/interface-builder-views.mjs": "e4c13065259ca96d50e3f94eb9faf10f4d196c4131357d00d52ba8521f62c4d0", + "experiments/analysis-efficiency/interface-builder-oracle.py": "553915cad163514508f47f5f12da6eaaa8c0e8576211330aaa184b3999441597" + }, + "review_verification": { + "purpose": "Require a separate pre-change complete parent for both limits; reject an intentionally wrong 1-document baseline after a successful real task. Production source is unchanged. Excluded from original comparison medians.", + "records": [ + { + "case": "reviewed64", + "status": "passed", + "failure": null, + "documents_limit": 64, + "wall_seconds": 28.383, + "cpu_seconds": 28.015985, + "family_peak_bytes": 1668419584, + "tokens": { + "input_catalog_once": 198492, + "arguments": 974, + "text_replies": 83421, + "transport_catalog": 891652, + "catalog_args_text_total": 282887 + }, + "parent_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "file_sha256": { + "report.json": "4b5965e5c68145f95f63351d29afca8036b1c4372cb1af5dffb8e283b15b2d03", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "a402f0ed756ee4914e5e5f9ec4f78009a881658f6bbf5c712ae670604056afd4", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "reviewed64.log": "b4a0a1ce492cbdebdcecc33ea97cab210e60fb01a3cc0a27c623d3c65f9bac82" + }, + "baseline_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "baseline_file_sha256": "15f7e82e66e51403f2446ad6cbd2fbee66ebd1d847e68569d764b60f6e0755a7" + }, + { + "case": "reviewed1", + "status": "passed", + "failure": null, + "documents_limit": 1, + "wall_seconds": 16.156, + "cpu_seconds": 15.932201, + "family_peak_bytes": 972292096, + "tokens": { + "input_catalog_once": 198492, + "arguments": 926, + "text_replies": 89329, + "transport_catalog": 891652, + "catalog_args_text_total": 288747 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "0c8aa9af5ed2d0a4c7e1a76aaea3b0889965f8c9e52afb5f43fcb2a9245b31ce", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "575ea1a79043f1af406cc835db2b8c2c7d9b40130ec4efe8ebcc1fba3f7d0fd2", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "reviewed1.log": "43d9d0d81bd4794d1ba10c69c7dd9e668645152abfee67b54ce58db46b9bbe6e" + }, + "baseline_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "baseline_file_sha256": "aeebab95f4b0e7c1dc0887cd5494c4a30005d5f4c6e8196449db93499db171b0" + }, + { + "case": "review-negative-small", + "status": "failed", + "failure": "AssertionError [ERR_ASSERTION]: Complete retained Evidence differs from the pre-change 1-document baseline.\n+ actual - expected\n\n+ 'ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639'\n- 'ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89'\n\n at file:///home/yann/yann/rea-perf-apple-ui-20261021/experiments/analysis-efficiency/interface-builder-views.mjs:212:10", + "documents_limit": 1, + "wall_seconds": 25.22, + "cpu_seconds": 24.994988, + "family_peak_bytes": 1021820928, + "tokens": { + "input_catalog_once": 198492, + "arguments": 926, + "text_replies": 89329, + "transport_catalog": 891652, + "catalog_args_text_total": 288747 + }, + "parent_evidence_id": "ev_7a255584cf54617bded9de05000605773a10fdb364c4a5e15e082ecb7c48f639", + "file_sha256": { + "report.json": "dc0ea04dc0db7ac19214894e4897256edb181548129dcf7b0713ba1e0fc97b72", + "catalog.json": "e927d03a1e0d33805583b9e62ff1f297b6d6cfeb5f21e58ed92aa47423772e1c", + "transcript.json": "865c2bf9becf8d5710ccc0ce4366ff0b1ce1a341092b30d6c74babfa99d0dc64", + "server.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "review-negative-small.log": "cc786e8948aee51c7851f65a6603b52b630563520635042c1845b573683452ba" + }, + "baseline_evidence_id": "ev_138e56163e8754e99a47c017105dcb9e9db4d7a0326cbbf5c219c9e2cb725c89", + "baseline_file_sha256": "15f7e82e66e51403f2446ad6cbd2fbee66ebd1d847e68569d764b60f6e0755a7" + } + ] + } +} diff --git a/experiments/analysis-efficiency/interface-builder-views.md b/experiments/analysis-efficiency/interface-builder-views.md new file mode 100644 index 0000000000..b99b5d1495 --- /dev/null +++ b/experiments/analysis-efficiency/interface-builder-views.md @@ -0,0 +1,225 @@ +# Retained Interface Builder graph validation + +Issue #5855. Baseline `f801b2fbfd28f4e3713adefddc7bd73db44c284c`, Linux +x86_64 / AMD EPYC 7B12 / Node 24.18.0 / npm 11.16.0. This is static archive +analysis on Linux, without executing an app or using ibtool or a native +analysis provider. + +## Question and oracle + +Identify the AAC settings outlets and VBR action in the official +[XLD 20250302 app](https://tmkk.undo.jp/xld/index_e.html), including archive +source/destination object identities, connection evidence and limitations. + +- [Official DMG](https://tmkk.undo.jp/xld/xld-20250302.dmg) SHA-256: + `0032a5470ea4e32a11a35b8077ebf4986102891f8eb82743094f2c6621ad8aeb`. +- Selected document: + `Contents/PlugIns/XLDAacOutput.bundle/Contents/Resources/English.lproj/XLDAacOutput.nib`. +- Archive SHA-256: + `4fdbfba451ee64b1d333c96a4f3d1a0c4b543a36b9b3686e1151b51e4dd14c72`. +- Python's independent `plistlib` reads the actual `$objects` table and + `NSNibOutletConnector` / `NSNibControlConnector` records. Observed connections: + +| Kind | Label | Source UID | Destination UID | +| ------ | ------------- | ---------- | --------------- | +| outlet | o_bitrate | 2 | 10 | +| outlet | o_gaplessFlag | 2 | 41 | +| outlet | o_prefPane | 2 | 53 | +| outlet | o_quality | 2 | 62 | +| outlet | o_useVBR | 2 | 58 | +| action | vbrChecked: | 58 | 2 | + +The benchmark checks each connector, both endpoint records, relationship kinds, +archive path/digest and page coverage against these independent facts. It also +compares all returned document rows with the exported complete parent; all +64-document normalized results match the pre-change complete CLI Evidence. +The complete exported parent Evidence is equal across versions for both +admission sizes. The reproduction oracle requires a separate pre-change +complete Evidence file for each size, and the harness compares the entire +parent, including its identity and parameters, for the selected limit. Equality is a preservation control, not an independent proof +of every decoded fact. + +The default 64-document report contains 6,528 nodes and 1,378 edges. The small +control admits one document from the same unmodified real app, with 80 nodes +and 13 edges. Both reports retain truncation and hierarchy limitations. Resource +sessions have no executable subject or executable digest; archive identities +must not be promoted to executable identity. + +## Completed workflow and accounting + +`interface-builder-views.mjs` uses the real SDK and compiled stdio MCP server: +connect, discover the full catalog, open bundle resources, decode with summary +delivery, exhaust three 32-row node pages and one edge page for the selected +document, repeat those same four pages as a warm query diagnostic, export the +complete Evidence, verify the answer and complete parent, and await cleanup. +The repeat is included in all whole-task costs. Each run starts a fresh server +and isolated account. Decoding is the first decode in that server; retained +queries follow the summary's first validation. OS caches were not flushed, so +these are not disk-cold measurements. + +Artifact acquisition/extraction, build time and independent oracle preparation +are outside this analysis task. No actual model session or billed model token +usage was measured. The SDK benchmark answers a fixed factual question; it does +not establish agent usability or general provider performance. + +Experiments ran serially using `resource-run.py --observe-payload-exit`: one CPU +(affinity 15), 100% quota, 4 GiB hard memory / 3 GiB early stop, no swap and a +192-task ceiling. CPU and peak memory cover the SDK, server and observer family. +Peak is cgroup memory.peak, including charged page cache, rather than summed RSS. +Payload-exit cgroup counters are authenticated and cleanup is verified; final +whole-scope counters are unavailable after the transient scope disappears. +Every accepted and failed task has exit/cleanup verification and zero observed +memory high/max/OOM/OOM-kill and PID-limit events. Missing final counters are +not interpreted as zero. + +## Observations + +Medians across all accepted baseline runs and the measured optimized +production-source cohort. The later harness-only review fix does not change +those production files; its revalidation is recorded separately. Five +earlier candidate runs remain labeled pre-review diagnostics and are excluded +from the final-source medians. One failed initial harness run is also retained. +All individual runs, phase timings, token estimates and raw receipt hashes are +in `interface-builder-views.json`. + +| Admitted documents | Revision | Runs | Whole wall s | Family CPU s | Family peak MiB | First four pages ms | Repeated four pages ms | +| ------------------ | -------- | ---- | ------------ | ------------ | --------------- | ------------------- | ---------------------- | +| 64 | baseline | 5 | 30.798 | 30.532 | 1614.7 | 2606.3 | 1817.8 | +| 64 | final | 5 | 27.683 | 27.424 | 1551.1 | 338.8 | 94.3 | +| 1 | baseline | 4 | 15.702 | 15.456 | 952.6 | 389.5 | 101.0 | +| 1 | final | 4 | 18.101 | 17.822 | 944.8 | 367.5 | 101.7 | + +The larger sampled task has about 10% lower whole wall/CPU medians and 94.8% +lower repeated-page latency. Whole-task ranges overlap substantially: +27.420–51.481 s baseline and 25.247–45.451 s final. Startup/initial decoding +vary independently of the retained-query change; these observations do not +establish a general whole-task speedup or stable memory reduction. Both +versions' slow first runs remain included. + +The small sampled task is slower overall (15.3% wall), with no repeat-page +benefit. In the two later interleaved small pairs, baseline took 16.883/18.317 s +and final took 16.988/20.276 s. Do not interpret the earlier unpaired small +measurements as evidence of a cold-start improvement. Startup variance remains +unexplained; this optimization is justified by repeated large-parent queries, +not a claim that every completed task becomes faster. + +An additional production-boundary diagnostic isolates the changed projection +from startup, decoding and transport. It imports both compiled owners, seals +separate clones of the actual exported parent with each owner's snapshot +module, and alternates version order for three epochs. Each epoch projects a +summary and the same eight pages; every resulting payload is equal. Median +projection CPU falls from 157.4 to 30.3 ms for the one-document parent and +6003.5 to 914.7 ms for the 64-document parent. Snapshot preparation, Evidence +authentication and all adapters are excluded from this diagnostic; the public +tasks above account for their whole costs. First large-summary CPU rises from +620.2 to 879.8 ms because representation comparison is paid up front. This is a +reuse tradeoff, not a faster first decode. The diagnostic source/receipt hashes, +per-call wall/CPU times and bounded process-family counters are retained in the +JSON; it does not replace real public workflow evidence. + +Selection still scans retained rows to count every match; no additional +per-document index is retained. The whole original graph and serialization +costs still exist. + +Public input/output contracts and complete parent Evidence are unchanged. +Tokenizer estimates count the entire 212-tool input catalog once, every RPC's +arguments and text response, including warm diagnostics and export. The raw SDK +catalog, including output schemas, is reported separately. Structured content +and model-specific discovery/exposure may incur different costs. The 64-document task estimates are 282,885 baseline and 282,887 for the +initial candidate (198,492 input-catalog tokens in each); the two-token change +comes solely from the export destination. The first ten RPC requests and +replies are exactly equal. Raw SDK catalog exposure estimates 891,652 tokens +including output schemas. Final-source values and every run are retained in +the JSON. There is no claimed token reduction. + +## Ownership and reuse + +The Interface Builder view owner validates an unknown normalized result with +its existing schema. Reuse requires the application's seal plus exact owned +immutable snapshot identity and representation-preserving comparison against +the parsed result. A weak set records only successful snapshots. It retains no +second graph, file-path cache, index, transient errors or failed subject +bindings. Every view still checks its current parent's target binding and +constructs current parent provenance and limitations. Different results, +mutable imports, forged seals and schema normalization revalidate normally. + +Document selection uses the shared matching pager to count all matching rows +while retaining only the requested page. Unfiltered requests keep direct +slicing. Row IDs, ordering, provenance, decoder truncation and unknowns remain +unchanged. CLI and MCP share the same application/domain implementation. + +The snapshot comparison helper's type accepts owning schemas with optional +undefined members; its runtime comparison is unchanged and refuses a parsed +representation that actually adds an undefined field. + +## Reproduction and limits + +Extract the official app without running it. On the baseline, save complete +CLI JSON with `rea decode-interface-builder --format json` and a +separate control with `rea decode-interface-builder --max-documents 1 +--format json` (without profiler logging mixed into either JSON). Then: + +```sh +python3 experiments/analysis-efficiency/interface-builder-oracle.py \ + --bundle '' \ + --document 'Contents/PlugIns/XLDAacOutput.bundle/Contents/Resources/English.lproj/XLDAacOutput.nib' \ + --baseline '' \ + --small-baseline '' --output '' +python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \ + node experiments/analysis-efficiency/interface-builder-views.mjs \ + --runtime '' --input '' \ + --oracle '' --output '' --documents 64 +``` + +Repeat with `--documents 1`. Paths must be absolute. The oracle uses standard +Python only; token estimates use offline tiktoken 0.12.0 / cl100k_base. Count +compact JSON tool name/description/inputSchema entries once, compact +`{name,arguments}` for every transcript event, and every returned text block; +these are estimates, not actual model usage. + +Failed attempts remain visible. `base64-a` completed all eight page RPCs but the +first harness incorrectly expected an action-source `contains` edge. Production +correctly returned `target_action`; the harness was corrected before accepted +runs. That failure's costs/receipt remain in the JSON. An initial typecheck +refused the snapshot helper's unnecessarily narrow generic constraint for the +optional resource root; the constraint was corrected without changing runtime +comparison. Build resource observations with a still-exiting Turbo child are +not qualified task measurements. + +KeepingYouAwake 1.6.8 (official ZIP SHA-256 +`8001a149b4490c008fdac19898bce9902d516c4aa6412a7eb0f9a37443b15c6b`) +was also downloaded and smoke-decoded through the real CLI +(17 documents, 1,369 nodes, 542 edges), but its NIBArchive records cannot be read +by this plistlib oracle. It is not a comparative benchmark or independent NIB +correctness claim. macOS ibtool, runtime UI, other archive variants, globally +complete hierarchy/class interpretation, #5720/#5721 and the remaining source +decoder issues are unverified or unchanged here. + +## Integration verification + +The measured comparison above was pinned to `f801b2fbf`. The branch was then +rebased onto `f5410bdc4` (the separate #5806 scalar-record fix). All six recorded owned-source +fingerprints remained identical across that rebase. On the rebased runtime both real SDK +tasks passed again, with the complete retained parent equal to the original +baseline for both admission sizes. The complete real CLI Evidence and portable +CLI page also equal the baseline/MCP results. These integration runs are recorded +separately in the JSON and excluded from the pinned comparison medians. + +The relevant 12-file suite passed 204 tests after the paging refactor. After +rebasing, another six-file run passed 78 tests covering scalar source facts, +keyed archive number metadata, Interface Builder views, production MCP summary +paging and compiled CLI numeric/hierarchy boundaries. Both cohorts verified +owned cleanup with no observed memory/PID limit events; tests used the bounded +two-CPU, 4 GiB guard. Native ibtool/macOS runtime behavior is not established by +these tests. + +Greptile identified that the original script only enforced the pre-change +comparison for 64 documents; the one-document equality above had been checked +manually. The oracle now requires both baseline files and the harness compares +the selected complete parent, including its Evidence ID and parameters, for +either limit. Both real workflows passed again. An intentional wrong baseline +(the 64-document parent supplied for the 1-document task) is rejected after all +other real-task checks succeed, with cleanup verified. These review checks and +updated harness fingerprints are recorded separately. Production source and +original comparison medians are unchanged; original measured fingerprints are +preserved rather than relabeled as the new harness. diff --git a/experiments/analysis-efficiency/interface-builder-views.mjs b/experiments/analysis-efficiency/interface-builder-views.mjs new file mode 100644 index 0000000000..2b97d9887d --- /dev/null +++ b/experiments/analysis-efficiency/interface-builder-views.mjs @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { finished } from "node:stream/promises"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { createRetainedQueryCaller } from "./retained-query-calls.mjs"; +import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs"; + +const { values } = parseArgs({ + options: { + runtime: { type: "string" }, + input: { type: "string" }, + oracle: { type: "string" }, + output: { type: "string" }, + documents: { type: "string", default: "64" }, + }, +}); +for (const key of ["runtime", "input", "oracle", "output"]) + assert.equal(values[key], resolve(values[key])); +const maxDocuments = Number(values.documents); +assert.ok([1, 64].includes(maxDocuments)); +const oracle = JSON.parse(await readFile(values.oracle, "utf8")); +const baselinePath = oracle.complete_result_paths?.[String(maxDocuments)]; +assert.equal( + typeof baselinePath, + "string", + "Provide a pre-change baseline for the selected document limit.", +); +assert.equal(baselinePath, resolve(baselinePath)); +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +assert.equal( + sha256(await readFile(join(values.input, oracle.document))), + oracle.archive_sha256, +); +const require = createRequire(join(values.runtime, "package.json")); +const { Client } = await import( + pathToFileURL(require.resolve("@modelcontextprotocol/client")) +); +const { StdioClientTransport } = await import( + pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio")) +); +await mkdir(values.output, { mode: 0o700 }); +const account = await mkdtemp(join(tmpdir(), "rea-interface-builder-views-")); +const report = { + question: + "Identify archived AAC settings outlets and the VBR action with source/destination object identities, coverage and complete Evidence access.", + max_documents: maxDocuments, + calls: [], +}; +const client = new Client({ + name: "rea-interface-builder-views", + version: "1", +}); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(values.runtime, "scripts/rea.mjs"), "mcp"], + env: { + HOME: account, + PATH: process.env.PATH, + NODE_OPTIONS: "--max-old-space-size=2048", + UV_THREADPOOL_SIZE: "1", + REA_LOG_LEVEL: "silent", + }, + stderr: "pipe", +}); +const serverLog = createWriteStream(join(values.output, "server.log")); +const serverLogFinished = finished(serverLog); +serverLogFinished.catch(() => {}); +transport.stderr?.pipe(serverLog); +const transcript = []; +const call = createRetainedQueryCaller(client, (receipt, event) => { + report.calls.push(receipt); + transcript.push(event); +}); +async function result(name, input, phase = name) { + const reply = await call(name, input, phase); + assert.ok(!reply.isError, JSON.stringify(reply)); + assert.ok(reply.structuredContent); + assert.equal(reply.content.length, 1); + assert.equal(reply.content[0].type, "text"); + assert.deepEqual(JSON.parse(reply.content[0].text), reply.structuredContent); + return reply.structuredContent; +} +try { + await client.connect(transport); + const catalog = await client.listTools(); + assert.equal(catalog.nextCursor, undefined); + report.catalog_tools = catalog.tools.length; + await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog)); + await result("open_bundle_resources", { path: values.input }); + const initial = await result("decode_interface_builder", { + max_documents: maxDocuments, + detail: "summary", + }); + const summary = initial.normalized_result; + report.parent_evidence_id = summary.parent_evidence_id; + assert.equal(summary.resource_root.executable_sha256, null); + assert.equal(summary.artifact.sha256, null); + assert.equal(summary.summary.truncated, true); + assert.ok( + summary.summary.documents.some( + (d) => + d.relative_path === oracle.document && + d.archive_sha256 === oracle.archive_sha256, + ), + ); + const collections = {}; + for (const pass of ["first", "warm"]) { + for (const collection of ["nodes", "edges"]) { + const rows = []; + let offset = 0; + for (;;) { + const selected = await result( + "inspect_analysis_view", + { + source: { + kind: "retained-evidence", + evidence_id: report.parent_evidence_id, + }, + view: { + kind: "interface-builder", + collection, + document: oracle.document, + offset, + limit: 32, + }, + }, + `retained:${pass}:${collection}:${offset}`, + ); + const page = selected.normalized_result; + assert.equal(page.parent_evidence_id, report.parent_evidence_id); + assert.equal(page.source_truncated, true); + assert.ok(page.unknowns.length > 0); + rows.push(...page.items); + if (page.coverage.exhausted) { + assert.equal(rows.length, page.coverage.total); + break; + } + assert.ok(page.items.length > 0); + offset += page.items.length; + } + if (pass === "warm") assert.deepEqual(rows, collections[collection]); + else collections[collection] = rows; + } + } + const byId = new Map(collections.nodes.map((n) => [n.id, n])); + for (const expected of oracle.connections) { + const connector = collections.nodes.find( + (n) => n.kind === expected.kind && n.name === expected.name, + ); + assert.ok(connector); + const sourceRelation = + expected.kind === "outlet" ? "contains" : "target_action"; + const source = collections.edges.find( + (e) => e.to === connector.id && e.relation === sourceRelation, + ); + assert.equal( + byId.get(source.from).attributes.interface_builder_object_id, + expected.source, + ); + const relation = expected.kind === "outlet" ? "outlet_to" : "target_action"; + const destination = collections.edges.find( + (e) => + e.from === connector.id && + e.relation === relation && + byId.get(e.to)?.attributes.interface_builder_object_id === + expected.destination, + ); + assert.equal( + byId.get(destination.to).attributes.interface_builder_object_id, + expected.destination, + ); + for (const row of [connector, source, destination]) + assert.ok( + row.evidence.some( + (e) => + e.artifact_path === oracle.document && + e.artifact_sha256 === oracle.archive_sha256, + ), + ); + } + report.answer = oracle.connections; + await result("export_evidence_bundle", { + path: join(values.output, "evidence.json"), + }); + const bundle = JSON.parse( + await readFile(join(values.output, "evidence.json"), "utf8"), + ); + const parent = bundle.records.find( + (e) => e.evidence_id === report.parent_evidence_id, + ); + assert.equal(parent.subject, null); + assert.equal(parent.operation, "decode_interface_builder"); + const full = parent.normalized_result; + for (const collection of ["nodes", "edges"]) + assert.deepEqual( + collections[collection], + full.graph[collection].filter((r) => + r.evidence.some((e) => e.artifact_path === oracle.document), + ), + ); + const baselineBytes = await readFile(baselinePath, "utf8"); + const expectedParent = JSON.parse(baselineBytes); + report.baseline_sha256 = sha256(baselineBytes); + report.baseline_evidence_id = expectedParent.evidence_id; + const mismatch = `Complete retained Evidence differs from the pre-change ${maxDocuments}-document baseline.`; + assert.equal(parent.evidence_id, expectedParent.evidence_id, mismatch); + assert.deepEqual(parent, expectedParent, mismatch); + report.available_counts = { + nodes: full.graph.nodes.length, + edges: full.graph.edges.length, + }; + report.oracle_sha256 = sha256(await readFile(values.oracle)); + report.status = "passed"; +} catch (cause) { + report.status = "failed"; + report.failure = String(cause?.stack ?? cause); + process.exitCode = 1; +} finally { + if ( + !(await finalizeRetainedQueryReceipt({ + report, + client, + transport, + account, + serverLog, + serverLogFinished, + output: values.output, + transcript, + })) + ) + process.exitCode = 1; +} diff --git a/src/domain/analysisView/interfaceBuilderView.test.ts b/src/domain/analysisView/interfaceBuilderView.test.ts index b3f02916ea..9ec2dd0bfe 100644 --- a/src/domain/analysisView/interfaceBuilderView.test.ts +++ b/src/domain/analysisView/interfaceBuilderView.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { interfaceBuilderAnalysisSchema } from "../apple/interfaceBuilderGraph.js"; +import { freezeJsonSnapshot } from "../immutableJson.js"; import { analysisViewResultSchema, @@ -102,6 +104,110 @@ const parent = ( limitations: [], }); +describe("Interface Builder immutable interpretation", () => { + it("validates a sealed graph once while checking the current parent binding every time", () => { + const source = { + ...parent(freezeJsonSnapshot(report())), + sealedResult: true, + }; + const parse = vi.spyOn(interfaceBuilderAnalysisSchema, "safeParse"); + try { + expect(projectAnalysisView(source, { kind: "summary" }).ok).toBe(true); + expect( + projectAnalysisView(source, { + kind: "interface-builder", + collection: "nodes", + document: main, + offset: 0, + limit: 1, + }).ok, + ).toBe(true); + expect(parse).toHaveBeenCalledTimes(1); + const changed = { + ...source, + subject: { ...source.subject!, digest: { sha256: "d".repeat(64) } }, + }; + const rejected = projectAnalysisView(changed, { kind: "summary" }); + if (rejected.ok) + throw new Error("expected a subject refusal after reuse"); + expect(rejected.error).toMatchObject({ + issues: [{ path: ["source", "subject"] }], + }); + expect(parse).toHaveBeenCalledTimes(1); + } finally { + parse.mockRestore(); + } + }); + + it("revalidates mutable and shallow-frozen imports despite a claimed seal", () => { + const mutable = report(); + const source = { ...parent(mutable), sealedResult: true }; + expect(projectAnalysisView(source, { kind: "summary" }).ok).toBe(true); + Object.freeze(mutable); + mutable.graph.nodes[0]!.name = ""; + expect(projectAnalysisView(source, { kind: "summary" }).ok).toBe(false); + mutable.graph.nodes[0]!.name = "corrected"; + const corrected = projectAnalysisView(source, { + kind: "interface-builder", + collection: "nodes", + offset: 0, + limit: 1, + }); + if (!corrected.ok) throw corrected.error; + expect(corrected.value).toMatchObject({ items: [{ name: "corrected" }] }); + }); + + it("does not reuse a claimed immutable graph without the owning seal", () => { + const source = parent(freezeJsonSnapshot(report())); + const parse = vi.spyOn(interfaceBuilderAnalysisSchema, "safeParse"); + try { + expect(projectAnalysisView(source, { kind: "summary" }).ok).toBe(true); + expect(projectAnalysisView(source, { kind: "summary" }).ok).toBe(true); + expect(parse).toHaveBeenCalledTimes(2); + } finally { + parse.mockRestore(); + } + }); + + it("counts matching rows once, preserves their order, and exhausts empty or late pages", () => { + const decoded = report(true); + decoded.graph.nodes.splice(1, 0, node("other:middle", "control", second)); + decoded.graph.nodes[0]!.evidence.push(...evidence(main)); + const source = { + ...parent(freezeJsonSnapshot(decoded)), + sealedResult: true, + }; + for (const offset of [0, 1, 2, 20]) { + const result = projectAnalysisView(source, { + kind: "interface-builder", + collection: "nodes", + document: main, + offset, + limit: 1, + }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + items: offset < 2 ? [decoded.graph.nodes[offset === 0 ? 0 : 2]] : [], + source_truncated: true, + coverage: { total: 2, exhausted: offset >= 1 }, + }); + } + const empty = projectAnalysisView(source, { + kind: "interface-builder", + collection: "edges", + document: second, + offset: 0, + limit: 1, + }); + if (!empty.ok) throw empty.error; + expect(empty.value).toMatchObject({ + items: [], + source_truncated: true, + coverage: { total: 0, exhausted: true }, + }); + }); +}); + describe("Interface Builder analysis views", () => { it("keeps resource-root provenance in both summary and detail views without a subject", () => { const source: AnalysisViewParent = { diff --git a/src/domain/analysisView/interfaceBuilderView.ts b/src/domain/analysisView/interfaceBuilderView.ts index a4f49a92e4..7eeef59908 100644 --- a/src/domain/analysisView/interfaceBuilderView.ts +++ b/src/domain/analysisView/interfaceBuilderView.ts @@ -2,11 +2,11 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { uniqueSorted } from "../canonicalOrdering.js"; +import { canBorrowParsedJsonSnapshot } from "../immutableJson.js"; import { interfaceBuilderAnalysisSchema, interfaceBuilderDocumentSchema, } from "../apple/interfaceBuilderGraph.js"; -import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js"; import { nativeInvestigationCoverageSchema, nativeInvestigationEdgeSchema, @@ -15,6 +15,7 @@ import { } from "../native/nativeInvestigationGraph.js"; import { err, ok, type Result } from "../result.js"; import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { selectViewRows } from "./selectViewRows.js"; import type { AnalysisViewParent, AnalysisViewRequest, @@ -96,15 +97,47 @@ const tally = (values: readonly string[]): Record => { }; type InterfaceBuilderReport = z.output; +type InterfaceBuilderPage = z.output; + +const pageRows = (rows: readonly Row[], view: InterfaceBuilderPage) => ({ + rows: rows.slice(view.offset, view.offset + view.limit), + total: rows.length, +}); + +const selectDocumentRows = < + Row extends { readonly evidence: readonly { artifact_path: unknown }[] }, +>( + rows: readonly Row[], + view: InterfaceBuilderPage, + document: string | undefined, +) => + document === undefined + ? pageRows(rows, view) + : selectViewRows( + rows, + view, + (row) => + row.evidence.some((evidence) => evidence.artifact_path === document), + (row) => row, + ); + +// Exact owned snapshot identity, with no second retained graph or live-file cache. +// Weak membership ends with the Evidence lifetime; failed/mutable imports are not reused. +const checkedReports = new WeakSet(); /** Admit a well-formed report bound to its recorded target, if one exists. */ const parseInterfaceBuilderReport = ( parent: AnalysisViewParent, ): Result => { - const parsed = parseAnalysisViewResult( - interfaceBuilderAnalysisSchema, - parent, - ); + const source = parent.normalizedResult; + const reusable = + parent.sealedResult === true && + typeof source === "object" && + source !== null; + const known = reusable && checkedReports.has(source); + const parsed = known + ? ok(source as InterfaceBuilderReport) + : parseAnalysisViewResult(interfaceBuilderAnalysisSchema, parent); if (!parsed.ok) return parsed; const report = parsed.value; if ( @@ -122,6 +155,10 @@ const parseInterfaceBuilderReport = ( }, ]), ); + if (!known && reusable && canBorrowParsedJsonSnapshot(source, report)) { + checkedReports.add(source); + return ok(source); + } return ok(report); }; @@ -229,31 +266,24 @@ export const projectInterfaceBuilderView = ( }, ]), ); - const inDocument = < - Row extends { readonly evidence: readonly { artifact_path: unknown }[] }, - >( - candidates: readonly Row[], - ): readonly Row[] => - document === undefined - ? candidates - : candidates.filter((row) => - row.evidence.some((evidence) => evidence.artifact_path === document), - ); - const rows = + const selected = view.collection === "documents" - ? report.documents + ? pageRows(report.documents, view) : view.collection === "coverage" - ? graph.coverage + ? pageRows(graph.coverage, view) : view.collection === "nodes" - ? inDocument(graph.nodes) - : inDocument(graph.edges); - const items = rows.slice(view.offset, view.offset + view.limit); + ? selectDocumentRows(graph.nodes, view, document) + : selectDocumentRows(graph.edges, view, document); return ok({ kind: "interface-builder", view, - items, + items: selected.rows, ...shared, source_truncated: graph.truncated, - coverage: pageViewCoverage(view.offset, items.length, rows.length), + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), }); }; diff --git a/src/domain/immutableJson.test.ts b/src/domain/immutableJson.test.ts index 068192f104..49caf837b0 100644 --- a/src/domain/immutableJson.test.ts +++ b/src/domain/immutableJson.test.ts @@ -145,6 +145,12 @@ describe("borrowing a parsed JSON snapshot", () => { it("preserves parser defaults, hidden-field removal and enumerability changes", () => { const source = freezeJsonSnapshot({ observed: 1 }); + const optional: { observed: number; optional?: string | undefined } = { + observed: 1, + }; + expect(canBorrowParsedJsonSnapshot(source, optional)).toBe(true); + optional.optional = undefined; + expect(canBorrowParsedJsonSnapshot(source, optional)).toBe(false); expect( canBorrowParsedJsonSnapshot(source, { observed: 1, defaulted: 0 }), ).toBe(false); diff --git a/src/domain/immutableJson.ts b/src/domain/immutableJson.ts index afe8462212..9565fd075c 100644 --- a/src/domain/immutableJson.ts +++ b/src/domain/immutableJson.ts @@ -38,8 +38,10 @@ export const immutableJsonSnapshotMember = ( return member; }; -/** Match a completed snapshot to its parsed representation without recursion. */ -export const canBorrowParsedJsonSnapshot = ( +/** Match a completed snapshot to its parsed representation without recursion. + * Parsed schemas may permit optional undefined members; only exact JSON matches borrow. + */ +export const canBorrowParsedJsonSnapshot = ( source: unknown, parsed: Parsed, ): source is Parsed => { From 905cbaf2398c905e96a1492b6145ece13e91c3cc Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:47:52 +0800 Subject: [PATCH 07/29] perf(ci): use tsrs and reduce PE acceptance startup (#5795) Use pinned tsrs on supported development hosts while retaining stable official TypeScript on Windows and unsupported platforms. Partition compile/typecheck caches by actual compiler and qualify fallback hosts for compiler inputs and lifecycle hooks. Reduce PE acceptance startup through the existing bounded compiled CLI. Retain upstream named MCP definitions, every fixture/assertion and the original 30s deadline. Verify that browser transport loss preserves the observed target. Validation: full qualification and real host lanes in https://github.com/morluto/rea/actions/runs/39590450889 ; 450 focused regressions passed with two documented conditional skips. All 1724 production emit files match SHA-256 and size across both compilers. Current-head Codex/Greptile review found no actionable issues. Measurements and limitations are in the PR. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- .github/workflows/ci.yml | 6 + docs/testing.md | 30 ++- licenses/npm-dependencies.txt | 32 ++- package-lock.json | 118 ++++++++++ package.json | 43 ++-- scripts/ci/plan.mjs | 25 +++ scripts/ci/scopes.mjs | 12 + scripts/run-development-tests.mjs | 2 +- scripts/run-turbo.mjs | 11 + scripts/run-typescript.d.mts | 11 + scripts/run-typescript.mjs | 75 +++++++ scripts/verify-real-browser.mjs | 6 + .../binary/AnalysisProviderRegistry.test.ts | 9 +- .../applications/peResources.test.ts | 21 +- .../boundary/filesystem/ciChangeScope.test.ts | 92 +++++++- .../boundary/filesystem/ciTestReports.test.ts | 2 +- .../filesystem/dependencyInstall.test.ts | 4 +- .../filesystem/staticTaskCache.test.ts | 143 +++++++++++- .../filesystem/typescriptCompiler.test.ts | 206 ++++++++++++++++++ .../boundary/mcp/providerSelectionMcp.test.ts | 5 +- .../process-global/curatedWindowsLane.test.ts | 13 ++ turbo.json | 17 +- 22 files changed, 837 insertions(+), 46 deletions(-) create mode 100644 scripts/run-turbo.mjs create mode 100644 scripts/run-typescript.d.mts create mode 100644 scripts/run-typescript.mjs create mode 100644 tests/boundary/filesystem/typescriptCompiler.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a02b8463c1..fe8214e3d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -819,6 +819,11 @@ jobs: with: save: "true" - run: npm ci + - name: Typecheck with the official Windows compiler + env: + GOMAXPROCS: "2" + GOMEMLIMIT: "3GiB" + run: npm run typecheck -- --checkers 2 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rea-build-cache @@ -849,6 +854,7 @@ jobs: npx vitest run --coverage.enabled=false src/process/capture/FilesystemSnapshot.test.ts tests/process-global/filesystemSnapshotFallbackCancellation.test.ts + tests/boundary/filesystem/typescriptCompiler.test.ts tests/boundary/filesystem/binaryTarget.test.ts tests/boundary/filesystem/safeOutputTree.test.ts tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts diff --git a/docs/testing.md b/docs/testing.md index 4152100bd3..43ba47cde5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,6 +46,24 @@ without executing tests or building. A missing Git base reports how to fetch it or select another revision. Source typechecking needs no compiled runtime or generated test metadata. +Typecheck, runtime compilation, and Frida binding verification share +`scripts/run-typescript.mjs`. The pinned tsrs package supplies Linux x64/arm64 +(GNU and musl) and macOS arm64 compilers. Windows and other unsupported hosts +retain the existing stable official TypeScript compiler. Its upstream version +can differ from tsrs, so both compiler tasks hash the selected package and version +through `scripts/run-turbo.mjs` before cache lookup. Outputs from different +compilers occupy different cache entries. Repository npm scripts and focused-test +preparation use that entrypoint; a raw Turbo compiler task without matching +identity fails instead of publishing an ambiguous successful cache entry. +A missing binary on a supported host also fails without silently falling back. + +The Rust compiler defaults to at most two checker threads. For `--noEmit` checks, +`--maxMemory 3G` also sets a soft memory target; emit builds do not use that target. +Explicit compiler arguments can override the defaults. These controls do not +impose a process-family memory limit. Termux's explicit build, Stryker's compiler +API dependency, and independent fixture compiler oracles keep their existing +TypeScript versions. + `npm run build:cached` compiles the CLI/MCP runtime and its packaged skill. Turbo's strict task environment preserves caller-selected `GOMAXPROCS`, `GOMEMLIMIT`, and `UV_THREADPOOL_SIZE` for resource-constrained builds and @@ -233,6 +251,11 @@ and retain representative compiled CLI parity checks. Every distinct source scenario still runs through the public workflow; session-lifecycle scenarios need their own sessions and cleanup assertions. +When a matrix already holds a named tool definition from live `tools/list`, +pass that definition through the SDK's `toolDefinition` call option. This keeps +the selected output-schema validation without eagerly compiling unrelated +catalog schemas. Re-read definitions if the server advertises a changed list. + Report unavailable authority as a named skip. Await owned cleanup before a verifier reports success. Native process inspectors are released at Vitest worker teardown; fork termination does not run Node exit hooks, and per-file @@ -648,8 +671,11 @@ missing entries are omitted as whole entries, and consumers rebuild cache misses including when every entry is unavailable. Malformed plans, non-file entries, and I/O errors other than disappearance still fail publication. After a trusted static run, superseded cross-run Turbo snapshots for that OS and architecture -are deleted down to the two newest generations. The Windows lane relies on the Linux static job for typechecking. They retain the normal build and prepack -commands, which restore matching outputs or rebuild on a cache miss. Native +are deleted down to the two newest generations. The Windows lane also runs the +official compiler's complete source typecheck with two checker threads, +independently of the accelerated Linux checker. Both lanes retain the normal +build and prepack commands, which restore matching outputs or rebuild on a cache +miss. Native artifacts, dependency installs, package archives, and runtime verification remain host-local. Keep platform-dependent outputs out of this shared cache. diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 8225eedadf..03d4921e06 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 752; runtime: 180. +Locked package entries: 758; runtime: 180. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -1390,6 +1390,31 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@toon-format/toon/v/2.3.1 Archive: https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.1.tgz Integrity: sha512-sWqEMeAJGMda9qRrfPKrX3C4c33CO9SJuvJzzrcBEn5sbDB+k6pSLkDsfN0rVnLQwR97MV3sDgVbcxQNsq8xVw== +@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929 | development, optional | Apache-2.0 + Lockfile path: node_modules/@tsrs/darwin-arm64 + Package: https://www.npmjs.com/package/@tsrs/darwin-arm64/v/0.18.0-ts7.1.0-dev.20260929 + Archive: https://registry.npmjs.org/@tsrs/darwin-arm64/-/darwin-arm64-0.18.0-ts7.1.0-dev.20260929.tgz + Integrity: sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA== +@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929 | development, optional | Apache-2.0 + Lockfile path: node_modules/@tsrs/linux-arm64-gnu + Package: https://www.npmjs.com/package/@tsrs/linux-arm64-gnu/v/0.18.0-ts7.1.0-dev.20260929 + Archive: https://registry.npmjs.org/@tsrs/linux-arm64-gnu/-/linux-arm64-gnu-0.18.0-ts7.1.0-dev.20260929.tgz + Integrity: sha512-zm9/z9yylig68JiZF4Aq0Cm26n09KEU8YoQRlG7tdDWTBNAIViYw8knxnWYsjMWVZ2XiU4mYq1xdrOKuOgABHg== +@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929 | development, optional | Apache-2.0 + Lockfile path: node_modules/@tsrs/linux-arm64-musl + Package: https://www.npmjs.com/package/@tsrs/linux-arm64-musl/v/0.18.0-ts7.1.0-dev.20260929 + Archive: https://registry.npmjs.org/@tsrs/linux-arm64-musl/-/linux-arm64-musl-0.18.0-ts7.1.0-dev.20260929.tgz + Integrity: sha512-Lw7VVU/YQ9JcarODICEiwzMLdFqZprqh5RGTgbS5Uc6I95P635/g5xA1pOC8RVs4efBvAR30J54iDG3l5JEYiw== +@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929 | development, optional | Apache-2.0 + Lockfile path: node_modules/@tsrs/linux-x64-gnu + Package: https://www.npmjs.com/package/@tsrs/linux-x64-gnu/v/0.18.0-ts7.1.0-dev.20260929 + Archive: https://registry.npmjs.org/@tsrs/linux-x64-gnu/-/linux-x64-gnu-0.18.0-ts7.1.0-dev.20260929.tgz + Integrity: sha512-EdBwBgNICGu1r1h23ixdcKwsMo6E9ll7ZsY3mNERNYqE1Pwdtcat99d5E7zB1J+oy/Xk6YqiizaB6nolIjpDTw== +@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929 | development, optional | Apache-2.0 + Lockfile path: node_modules/@tsrs/linux-x64-musl + Package: https://www.npmjs.com/package/@tsrs/linux-x64-musl/v/0.18.0-ts7.1.0-dev.20260929 + Archive: https://registry.npmjs.org/@tsrs/linux-x64-musl/-/linux-x64-musl-0.18.0-ts7.1.0-dev.20260929.tgz + Integrity: sha512-LHk+AOAtXuTlhJhOJ2+elriEIX17ycXljojeNxrrzckOipTG1LksoVWuCSas2nu00EnWAzrdKpt9oqPylUKZfQ== @turbo/darwin-64@2.11.7 | development, optional | MIT Lockfile path: node_modules/@turbo/darwin-64 Package: https://www.npmjs.com/package/@turbo/darwin-64/v/2.11.7 @@ -3400,6 +3425,11 @@ tslib@2.8.1 | development | 0BSD Package: https://www.npmjs.com/package/tslib/v/2.8.1 Archive: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz Integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsrs@0.18.0-ts7.1.0-dev.20260929 | development | Apache-2.0 + Lockfile path: node_modules/tsrs + Package: https://www.npmjs.com/package/tsrs/v/0.18.0-ts7.1.0-dev.20260929 + Archive: https://registry.npmjs.org/tsrs/-/tsrs-0.18.0-ts7.1.0-dev.20260929.tgz + Integrity: sha512-9T0b4Uam/yMFIK16GUx0fUrJqYZnJ/gvSmV9JB8NTyDePvwCI45WFWxyEV/UMV3KfdNRJG/PBsm3fSIB9WmWsA== tsx@4.23.15 | development | MIT Lockfile path: node_modules/tsx Package: https://www.npmjs.com/package/tsx/v/4.23.15 diff --git a/package-lock.json b/package-lock.json index a8d20bd23d..392b35cdbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "oxfmt": "0.72.0", "oxlint": "1.87.0", "rolldown": "1.2.12", + "tsrs": "0.18.0-ts7.1.0-dev.20260929", "tsx": "4.23.15", "turbo": "2.11.7", "typescript": "7.0.2", @@ -4407,6 +4408,103 @@ "integrity": "sha512-sWqEMeAJGMda9qRrfPKrX3C4c33CO9SJuvJzzrcBEn5sbDB+k6pSLkDsfN0rVnLQwR97MV3sDgVbcxQNsq8xVw==", "license": "MIT" }, + "node_modules/@tsrs/darwin-arm64": { + "version": "0.18.0-ts7.1.0-dev.20260929", + "resolved": "https://registry.npmjs.org/@tsrs/darwin-arm64/-/darwin-arm64-0.18.0-ts7.1.0-dev.20260929.tgz", + "integrity": "sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@tsrs/linux-arm64-gnu": { + "version": "0.18.0-ts7.1.0-dev.20260929", + "resolved": "https://registry.npmjs.org/@tsrs/linux-arm64-gnu/-/linux-arm64-gnu-0.18.0-ts7.1.0-dev.20260929.tgz", + "integrity": "sha512-zm9/z9yylig68JiZF4Aq0Cm26n09KEU8YoQRlG7tdDWTBNAIViYw8knxnWYsjMWVZ2XiU4mYq1xdrOKuOgABHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@tsrs/linux-arm64-musl": { + "version": "0.18.0-ts7.1.0-dev.20260929", + "resolved": "https://registry.npmjs.org/@tsrs/linux-arm64-musl/-/linux-arm64-musl-0.18.0-ts7.1.0-dev.20260929.tgz", + "integrity": "sha512-Lw7VVU/YQ9JcarODICEiwzMLdFqZprqh5RGTgbS5Uc6I95P635/g5xA1pOC8RVs4efBvAR30J54iDG3l5JEYiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@tsrs/linux-x64-gnu": { + "version": "0.18.0-ts7.1.0-dev.20260929", + "resolved": "https://registry.npmjs.org/@tsrs/linux-x64-gnu/-/linux-x64-gnu-0.18.0-ts7.1.0-dev.20260929.tgz", + "integrity": "sha512-EdBwBgNICGu1r1h23ixdcKwsMo6E9ll7ZsY3mNERNYqE1Pwdtcat99d5E7zB1J+oy/Xk6YqiizaB6nolIjpDTw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@tsrs/linux-x64-musl": { + "version": "0.18.0-ts7.1.0-dev.20260929", + "resolved": "https://registry.npmjs.org/@tsrs/linux-x64-musl/-/linux-x64-musl-0.18.0-ts7.1.0-dev.20260929.tgz", + "integrity": "sha512-LHk+AOAtXuTlhJhOJ2+elriEIX17ycXljojeNxrrzckOipTG1LksoVWuCSas2nu00EnWAzrdKpt9oqPylUKZfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@turbo/darwin-64": { "version": "2.11.7", "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.11.7.tgz", @@ -9832,6 +9930,26 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsrs": { + "version": "0.18.0-ts7.1.0-dev.20260929", + "resolved": "https://registry.npmjs.org/tsrs/-/tsrs-0.18.0-ts7.1.0-dev.20260929.tgz", + "integrity": "sha512-9T0b4Uam/yMFIK16GUx0fUrJqYZnJ/gvSmV9JB8NTyDePvwCI45WFWxyEV/UMV3KfdNRJG/PBsm3fSIB9WmWsA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsrs": "bin/tsrs" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@tsrs/darwin-arm64": "0.18.0-ts7.1.0-dev.20260929", + "@tsrs/linux-arm64-gnu": "0.18.0-ts7.1.0-dev.20260929", + "@tsrs/linux-arm64-musl": "0.18.0-ts7.1.0-dev.20260929", + "@tsrs/linux-x64-gnu": "0.18.0-ts7.1.0-dev.20260929", + "@tsrs/linux-x64-musl": "0.18.0-ts7.1.0-dev.20260929" + } + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", diff --git a/package.json b/package.json index 8aab2719b9..4b34fdb6e8 100644 --- a/package.json +++ b/package.json @@ -104,31 +104,31 @@ "metadata:generate": "node scripts/run-exclusive.mjs artifacts node scripts/generate-package-metadata.mjs", "metadata:check": "node scripts/generate-package-metadata.mjs --check", "mcp-registry:check": "node scripts/verify-mcp-registry-metadata.mjs", - "mcp-catalog:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:mcp-catalog", - "mcp-catalog:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run mcp-catalog:check:built", + "mcp-catalog:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:mcp-catalog", + "mcp-catalog:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run mcp-catalog:check:built", "prebuild": "npm run deps:check", - "build": "node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills --force", + "build": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:skills --force", "precheck": "npm run deps:check", - "check": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check && npm run verify:architecture-guards", + "check": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check && npm run verify:architecture-guards", "precheck:fast": "npm run deps:check", - "check:fast": "turbo run typecheck lint --output-logs=errors-only", + "check:fast": "node scripts/run-turbo.mjs run typecheck lint --output-logs=errors-only", "precheck:changed": "npm run deps:check", "check:changed": "npm run check:fast && npm run test:changed", "precheck:test": "npm run deps:check", - "check:test": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run", + "check:test": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run", "precheck:pr": "npm run deps:check", - "check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos", + "check:pr": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos", "precheck:ci": "npm run deps:check", "precheck:ci:fast": "npm run deps:check", "precheck:ci:deep": "npm run deps:check", "check:ci": "npm run check:ci:fast && npm run check:ci:deep", "check:ci:fast": "node scripts/run-exclusive.mjs artifacts npm run check:ci:fast:tasks", - "check:ci:fast:tasks": "turbo run typecheck lint:built format:check", + "check:ci:fast:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check", "check:ci:deep": "node scripts/run-exclusive.mjs artifacts npm run check:ci:deep:tasks && npm run verify:architecture-guards && npm run verify:test-discovery && npm run test:python", - "check:ci:deep:tasks": "turbo run knip metadata:check jscpd scan:todos", - "check:ci:tasks": "turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos", + "check:ci:deep:tasks": "node scripts/run-turbo.mjs run knip metadata:check jscpd scan:todos", + "check:ci:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check jscpd scan:todos", "ci:static-context": "node scripts/ci/static-task-context.mjs", - "build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills", + "build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:skills", "build:termux": "node scripts/run-exclusive.mjs artifacts npm run build:termux:run", "build:termux:run": "npm run compile:termux && npm run artifacts:skills", "format": "oxfmt .", @@ -151,7 +151,7 @@ "test:built": "node scripts/ci/test-group.mjs built", "pretest:boundary": "npm run test:prepare", "test:boundary": "vitest run --project boundary --project cli-boundary --project process-boundary --project mcp-boundary --project process-global", - "pretest:mcp": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills artifacts:mcp-catalog", + "pretest:mcp": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:skills artifacts:mcp-catalog", "test:mcp": "vitest run --project mcp-boundary", "pretest:acceptance": "npm run build:cached", "test:acceptance": "vitest run --project acceptance", @@ -174,7 +174,7 @@ "test:watch": "vitest --project domain --project services --project adapters --project composition --project conformance --project evaluation --changed", "pretest:watch:all": "npm run test:prepare", "test:watch:all": "vitest --changed", - "typecheck": "tsc --noEmit", + "typecheck": "node scripts/run-typescript.mjs --noEmit", "verify:test-temp-hygiene": "npm run test:prepare && node scripts/verify-test-temp-hygiene.mjs", "verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs", "verify:hopper:deadlines": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-deadlines.mjs", @@ -213,7 +213,7 @@ "verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs", "verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs", "verify:frida": "npm run build:cached && node scripts/verify-frida.mjs", - "verify:frida:bindings": "tsc --noEmit -p tsconfig.frida-verification.json", + "verify:frida:bindings": "node scripts/run-typescript.mjs --noEmit -p tsconfig.frida-verification.json", "verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs", "verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs", "verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs", @@ -256,17 +256,17 @@ "probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs", "verify:published": "node scripts/verify-published-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", - "evidence:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:managed-evidence", - "evidence:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run evidence:check:built", + "evidence:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:managed-evidence", + "evidence:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run evidence:check:built", "predocs:dev": "npm run docs:generate", "docs:dev": "vitepress dev docs", "predocs:build": "npm run docs:generate", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", "docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare", - "docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts", + "docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts", "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check && npm run licenses:check", - "docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:error-schema", + "docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:error-schema", "prepack": "node scripts/prepack.mjs", "prepare": "node scripts/prepare.mjs", "verify:asset-catalog": "npm run build:cached && node scripts/verify/apple/asset-catalog.mjs", @@ -305,16 +305,16 @@ "verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs", "verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs", "mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check", - "compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs", + "compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs", "compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs", "artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs", "artifacts:skills": "node scripts/generate-skill-metadata.mjs", "artifacts:product-catalog": "node scripts/generate-product-catalog.mjs", "artifacts:managed-evidence": "node scripts/generate-completion-ledger.mjs", "artifacts:error-schema": "node scripts/generate-error-schema.mjs", - "test:prepare": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:mcp-catalog artifacts:product-catalog artifacts:managed-evidence", + "test:prepare": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:mcp-catalog artifacts:product-catalog artifacts:managed-evidence", "evidence:check:built": "node scripts/generate-completion-ledger.mjs --check", - "docs:prepare": "node scripts/generate-package-metadata.mjs && turbo run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema", + "docs:prepare": "node scripts/generate-package-metadata.mjs && node scripts/run-turbo.mjs run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema", "verify:web:qlog": "npm run build:cached && node scripts/verify-qlog-captures.mjs", "licenses:generate": "node scripts/generate-third-party-inventory.mjs", "licenses:check": "node scripts/generate-third-party-inventory.mjs --check" @@ -396,6 +396,7 @@ "oxfmt": "0.72.0", "oxlint": "1.87.0", "rolldown": "1.2.12", + "tsrs": "0.18.0-ts7.1.0-dev.20260929", "tsx": "4.23.15", "turbo": "2.11.7", "typescript": "7.0.2", diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index e7c3de8ddb..fb9260bde4 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -63,6 +63,31 @@ function classifyPackage(base, head) { const after = JSON.parse(git("show", `${head}:package.json`)); const { scripts: beforeScripts, ...beforeRest } = before; const { scripts: afterScripts, ...afterRest } = after; + const compilerDependencies = (metadata) => ({ + dependencies: { + typescript: metadata.dependencies?.typescript, + tsrs: metadata.dependencies?.tsrs, + }, + devDependencies: { + typescript: metadata.devDependencies?.typescript, + tsrs: metadata.devDependencies?.tsrs, + }, + }); + const compilerScripts = [ + "compile", + "typecheck", + "build", + "build:cached", + "test:prepare", + "docs:prepare", + "verify:frida:bindings", + ].flatMap((name) => [name, `pre${name}`, `post${name}`]); + selected.package ||= + JSON.stringify(compilerDependencies(before)) !== + JSON.stringify(compilerDependencies(after)) || + compilerScripts.some( + (name) => beforeScripts?.[name] !== afterScripts?.[name], + ); if (JSON.stringify(beforeRest) !== JSON.stringify(afterRest)) { full("Package metadata or dependencies changed"); return; diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 32a15faaa3..54ebaa7d17 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -92,6 +92,17 @@ export const SCOPES = { ], // Four-host installed-package matrix and the Windows curated lane. package: [ + // Both host compilers must be exercised when their installation, options, + // or entrypoints change; Linux's accelerated checker cannot stand in for Go. + "scripts/run-typescript.*", + "scripts/run-turbo.mjs", + "turbo.json", + "tsconfig.json", + "tsconfig.build.json", + "tsconfig.frida-verification.json", + "package-lock.json", + ".nvmrc", + ".npmrc", ".github/actions/publish-mcp/**", ".github/actions/prepare-release-package/**", ".github/workflows/release-recovery.yml", @@ -135,6 +146,7 @@ export const SCOPES = { "src/application/DshCordis*.ts", "src/cli/interactiveSetup.ts", "src/cli/setupCommands.ts", + "tests/boundary/filesystem/typescriptCompiler.test.ts", "tests/boundary/filesystem/binaryTarget.test.ts", "tests/boundary/filesystem/safeOutputTree.test.ts", "tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts", diff --git a/scripts/run-development-tests.mjs b/scripts/run-development-tests.mjs index d53b59c847..8237f8ad58 100644 --- a/scripts/run-development-tests.mjs +++ b/scripts/run-development-tests.mjs @@ -59,7 +59,7 @@ try { "scripts/run-exclusive.mjs", "artifacts", process.execPath, - resolve("node_modules/turbo/bin/turbo"), + resolve("scripts/run-turbo.mjs"), "run", ...(plan.needsBuild ? ["artifacts:skills"] : []), ...plan.artifactTasks, diff --git a/scripts/run-turbo.mjs b/scripts/run-turbo.mjs new file mode 100644 index 0000000000..0c8404bcf1 --- /dev/null +++ b/scripts/run-turbo.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +import { resolveTypeScriptCompiler } from "./run-typescript.mjs"; + +// Compute the actual host compiler before Turbo hashes or restores its tasks. +process.env.REA_TYPESCRIPT_COMPILER = resolveTypeScriptCompiler().identity; +const require = createRequire(import.meta.url); +await import(pathToFileURL(require.resolve("turbo/bin/turbo")).href); diff --git a/scripts/run-typescript.d.mts b/scripts/run-typescript.d.mts new file mode 100644 index 0000000000..f5bafbab36 --- /dev/null +++ b/scripts/run-typescript.d.mts @@ -0,0 +1,11 @@ +export function selectTypeScriptCompiler( + platform: string, + arch: string, + optionalDependencies: Readonly>, +): "tsrs" | "typescript"; + +export function resolveTypeScriptCompiler(): { + readonly name: "tsrs" | "typescript"; + readonly identity: string; + readonly launcher: string; +}; diff --git a/scripts/run-typescript.mjs b/scripts/run-typescript.mjs new file mode 100644 index 0000000000..1fc0c2351a --- /dev/null +++ b/scripts/run-typescript.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { availableParallelism } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** Use the pinned package's platform inventory; let its launcher select libc. */ +export const selectTypeScriptCompiler = ( + platform, + arch, + optionalDependencies, +) => { + if (platform === "win32") return "typescript"; + const prefix = `@tsrs/${platform}-${arch}`; + return Object.keys(optionalDependencies).some( + (name) => name === prefix || name.startsWith(`${prefix}-`), + ) + ? "tsrs" + : "typescript"; +}; + +export const resolveTypeScriptCompiler = () => { + const require = createRequire(import.meta.url); + const rustPackagePath = require.resolve("tsrs/package.json"); + const rustPackage = JSON.parse(readFileSync(rustPackagePath, "utf8")); + const compiler = selectTypeScriptCompiler( + process.platform, + process.arch, + rustPackage.optionalDependencies, + ); + const packagePath = + compiler === "tsrs" + ? rustPackagePath + : require.resolve("typescript/package.json"); + const metadata = + compiler === "tsrs" + ? rustPackage + : JSON.parse(readFileSync(packagePath, "utf8")); + const command = compiler === "tsrs" ? "tsrs" : "tsc"; + return { + name: compiler, + identity: `${compiler}@${metadata.version}`, + launcher: resolve(dirname(packagePath), metadata.bin[command]), + }; +}; + +const main = async () => { + const compiler = resolveTypeScriptCompiler(); + if ( + process.env.TURBO_HASH !== undefined && + process.env.REA_TYPESCRIPT_COMPILER !== compiler.identity + ) + throw new Error( + "Compiler identity is missing or stale. Run Turbo through scripts/run-turbo.mjs.", + ); + if (compiler.name === "tsrs") { + const args = process.argv.slice(2); + // Rust consumes --maxMemory once; duplicating it becomes an unknown option. + // Preserve explicit resource choices rather than injecting another value. + const defaults = [ + ["--checkers", String(Math.min(2, availableParallelism()))], + ["--maxMemory", "3G"], + ].flatMap(([option, value]) => + args.includes(option) ? [] : [option, value], + ); + process.argv.splice(2, 0, ...defaults); + } + // Delegate execution, diagnostics, status and signals to the package's launcher. + // An unavailable binary on a supported host must fail rather than hide drift. + await import(pathToFileURL(compiler.launcher).href); +}; + +if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 4ba8d1e964..ed5d8f8680 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -555,6 +555,12 @@ async function verifyPageScopedTransport(provider, proxy, origin) { throw new Error( "Page-scoped CDP disconnect must report transport loss without claiming a target crash", ); + const remaining = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ cdp_endpoint: proxy.endpoint }), + ); + if (!remaining.ok) throw remaining.error; + if (!remaining.value.targets.some(({ target_id }) => target_id === target)) + throw new Error("Page-scoped CDP disconnect removed its live target"); } async function browserExecutable() { diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index bd258f3702..a09112ff2f 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -5,6 +5,7 @@ import { createAnalysisExecution, type AnalysisClient, type AnalysisProviderCandidate, + type AnalysisProviderInspectionOptions, type CapabilityDescriptor, type ProviderAvailability, type ProviderIdentity, @@ -825,7 +826,9 @@ it("joins cancelled readiness finalization and shutdown before admitting another const registry = new AnalysisProviderRegistry([ { ...base, - inspectAvailability: async ({ signal } = {}) => { + inspectAvailability: async ({ + signal, + }: AnalysisProviderInspectionOptions = {}) => { calls++; if (calls === 1) { observed = signal; @@ -982,7 +985,9 @@ it("registers selection before async readiness can race shutdown", async () => { const registry = new AnalysisProviderRegistry([ { ...base, - inspectAvailability: async ({ signal } = {}) => { + inspectAvailability: async ({ + signal, + }: AnalysisProviderInspectionOptions = {}) => { calls++; observed = signal; return base.inspectAvailability(); diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index 202a19e9d4..8b606bc011 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -9,7 +9,11 @@ import { peResourcesSchema } from "../../../src/domain/native/peResources.js"; import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { + cliTest, + runFileCli, + type TestCli, +} from "../../support/cli/cliFixture.js"; type PeResourceCall = ( arguments_: Record, @@ -22,7 +26,7 @@ const expectPeInputFailureAcrossAdapters = async (input: { readonly reason: "invalid_format" | "invalid_value"; }) => { const { cli, call, path, reason } = input; - const result = await cli.run({ + const result = await runFileCli(cli, { arguments: ["inspect-pe-resources", path, "--json"], }); expect(result.exitCode).toBe(1); @@ -105,7 +109,7 @@ const expectPeWordAlignedRecordsAcrossAdapters = async (input: { ); const path = join(root, `word-aligned-${plus}-${index}.exe`); await writeFile(path, fixture.bytes); - const result = await cli.run({ + const result = await runFileCli(cli, { arguments: ["inspect-pe-resources", path, "--json"], }); expect(result.exitCode, result.stderr).toBe(0); @@ -196,7 +200,7 @@ const expectPeMappingOwnershipAcrossAdapters = async ( }); continue; } - const result = await cli.run({ + const result = await runFileCli(cli, { arguments: ["inspect-pe-resources", path, "--json"], }); expect(result.exitCode, name).toBe(0); @@ -249,9 +253,14 @@ const expectPeInventoriesAcrossAdapters = async ( `resource${plus ? "64" : "32"}${empty ? "-empty" : ""}.exe`, ); await writeFile(path, fixture.bytes); - const result = await cli.run({ + const invocation = { arguments: ["inspect-pe-resources", path, "--json"], - }); + }; + // Keep an independent public-wrapper process for both PE32 and PE32+. + // The empty-payload variants reuse only compiled-command startup. + const result = await (empty + ? runFileCli(cli, invocation) + : cli.run(invocation)); expect(result.exitCode).toBe(0); const evidence = contract.outputSchema.parse(result.json); const report = peResourcesSchema.parse(evidence.normalized_result); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index d68af5ab0c..44b8044502 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -660,7 +660,7 @@ it.each([ { code: true, runtime: true, - package: false, + package: true, evm: false, browser: false, }, @@ -1155,7 +1155,7 @@ it.each([ { code: true, runtime: true, - package: false, + package: true, browser: false, evm: false, sqlite: false, @@ -1166,7 +1166,7 @@ it.each([ { code: true, runtime: true, - package: false, + package: true, browser: false, evm: false, }, @@ -1496,3 +1496,89 @@ it("never runs a manual-only verifier from an automatic workflow", async () => { ), ).toEqual([]); }); + +it.each([ + "scripts/run-typescript.mjs", + "scripts/run-typescript.d.mts", + "scripts/run-turbo.mjs", + "turbo.json", + "tsconfig.json", + "tsconfig.build.json", + "tsconfig.frida-verification.json", + "package-lock.json", + ".nvmrc", + ".npmrc", +])("selects package hosts for compiler input %s", async (path) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + const plan = await classify(directory, base, head); + expect(plan).toMatchObject({ + tier: "fast", + code: true, + runtime: true, + package: true, + }); + expect(plan.matrix).toEqual({ + include: [ + { os: "ubuntu-latest", platform: "linux", architecture: "x64" }, + { os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" }, + { os: "macos-15", platform: "darwin", architecture: "arm64" }, + ], + }); +}); + +it.each(["typescript", "tsrs"])( + "selects fallback host checks when compiler dependency %s changes", + async (name) => { + const { directory } = await fixture(); + const before = { name: "fixture", devDependencies: { [name]: "1.0.0" } }; + const base = await change( + directory, + ["package.json"], + JSON.stringify(before), + ); + const head = await change( + directory, + ["package.json"], + JSON.stringify({ ...before, devDependencies: { [name]: "2.0.0" } }), + ); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + runtime: true, + package: true, + }); + }, +); + +it.each([ + "compile", + "typecheck", + "build", + "build:cached", + "test:prepare", + "docs:prepare", + "verify:frida:bindings", + "prebuild:cached", + "postcompile", + "pretypecheck", +])( + "selects fallback host checks when compiler command %s changes", + async (name) => { + const { directory } = await fixture(); + const base = await change( + directory, + ["package.json"], + JSON.stringify({ scripts: { [name]: "node scripts/old.mjs" } }), + ); + const head = await change( + directory, + ["package.json"], + JSON.stringify({ scripts: { [name]: "node scripts/new.mjs" } }), + ); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + runtime: true, + package: true, + }); + }, +); diff --git a/tests/boundary/filesystem/ciTestReports.test.ts b/tests/boundary/filesystem/ciTestReports.test.ts index 582b8b77fb..815f736a50 100644 --- a/tests/boundary/filesystem/ciTestReports.test.ts +++ b/tests/boundary/filesystem/ciTestReports.test.ts @@ -364,7 +364,7 @@ it("runs the existing onboarding and Python producer guards from their selected "node scripts/run-exclusive.mjs artifacts npm run check:ci:fast:tasks", ); expect(scripts["check:ci:fast:tasks"]).toBe( - "turbo run typecheck lint:built format:check", + "node scripts/run-turbo.mjs run typecheck lint:built format:check", ); expect(scripts["test:python"]).toContain( "bridge/native/test_rea_lldb_tracer.py", diff --git a/tests/boundary/filesystem/dependencyInstall.test.ts b/tests/boundary/filesystem/dependencyInstall.test.ts index 1eef778637..34609de849 100644 --- a/tests/boundary/filesystem/dependencyInstall.test.ts +++ b/tests/boundary/filesystem/dependencyInstall.test.ts @@ -196,6 +196,8 @@ const createBuildCacheFixture = async (): Promise => { "tsconfig.json", "tsconfig.build.json", "scripts/check-dependency-install.mjs", + "scripts/run-turbo.mjs", + "scripts/run-typescript.mjs", "scripts/run-exclusive.mjs", "scripts/run-exclusive-command.mjs", "scripts/lib/windows-owned-command.mjs", @@ -277,7 +279,7 @@ const runTurbo = (cwd: string, environment: NodeJS.ProcessEnv = process.env) => run( process.execPath, [ - join(cwd, "node_modules", "turbo", "bin", "turbo"), + join(cwd, "scripts", "run-turbo.mjs"), "run", "artifacts:skills", "--output-logs=full", diff --git a/tests/boundary/filesystem/staticTaskCache.test.ts b/tests/boundary/filesystem/staticTaskCache.test.ts index fed6cf8e9c..823dc0a7cd 100644 --- a/tests/boundary/filesystem/staticTaskCache.test.ts +++ b/tests/boundary/filesystem/staticTaskCache.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; @@ -8,9 +9,10 @@ import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { workflowGit } from "../../support/workflowGit.js"; +import { resolveTypeScriptCompiler } from "../../../scripts/run-typescript.mjs"; const repository = fileURLToPath(new URL("../../../", import.meta.url)); -const turbo = join(repository, "node_modules/turbo/bin/turbo"); +const turbo = join(repository, "scripts/run-turbo.mjs"); const reportScript = join(repository, "scripts/ci/static-check-report.mjs"); const rootConfigSchema = z.object({ globalDependencies: z.array(z.string()).optional(), @@ -349,6 +351,13 @@ it.each([ diagnostic: "TS2322", invalidatesCompile: false, }, + { + label: "compiler launcher", + path: "scripts/run-typescript.mjs", + content: 'throw new Error("compiler launcher changed");\n', + diagnostic: "compiler launcher changed", + invalidatesCompile: true, + }, { label: "source declaration", path: "src/types.d.mts", @@ -410,11 +419,15 @@ it.each([ ["compile", "typecheck"], { // Exercise the real compiler phase with the production task inputs. - compile: "tsc -p tsconfig.build.json", - typecheck: "tsc --noEmit", + compile: "node scripts/run-typescript.mjs -p tsconfig.build.json", + typecheck: "node scripts/run-typescript.mjs --noEmit", }, { "#compiler-scope": "./scripts/root-types.d.mts" }, ); + await writeFile( + join(root, "scripts/run-typescript.mjs"), + await readFile(join(repository, "scripts/run-typescript.mjs")), + ); for (const name of [ "tsconfig.json", "tsconfig.build.json", @@ -612,3 +625,125 @@ it("invalidates checker task hashes for Node, ignore, and VitePress config chang `${task} must ignore vitest.config.ts`, ).toBe(initial.get(task)); }); + +it.skipIf(resolveTypeScriptCompiler().name !== "tsrs")( + "does not replay an official compiler success for a Rust compiler error", + async () => { + const root = await createTestTempDirectory("rea-cross-compiler-cache-"); + const cache = await createTestTempDirectory("rea-cross-compiler-store-"); + const require = createRequire(import.meta.url); + await mkdir(join(root, "scripts")); + await mkdir(join(root, "src")); + await mkdir(join(root, "node_modules/tsrs"), { recursive: true }); + for (const name of ["typescript", "turbo"]) + await symlink( + dirname(require.resolve(`${name}/package.json`)), + join(root, "node_modules", name), + "junction", + ); + for (const name of ["run-typescript.mjs", "run-turbo.mjs"]) + await writeFile( + join(root, "scripts", name), + await readFile(join(repository, "scripts", name)), + ); + await writeTaskFixture(root, ["compile", "typecheck"], { + compile: "node scripts/run-typescript.mjs -p tsconfig.build.json", + typecheck: "node scripts/run-typescript.mjs --noEmit", + }); + const rustPath = require.resolve("tsrs/package.json"); + const rust = z + .object({ version: z.string(), bin: z.object({ tsrs: z.string() }) }) + .parse(JSON.parse(await readFile(rustPath, "utf8"))); + const writeInventory = (supported: boolean) => + writeFile( + join(root, "node_modules/tsrs/package.json"), + JSON.stringify({ + name: "tsrs", + version: rust.version, + bin: { tsrs: resolve(dirname(rustPath), rust.bin.tsrs) }, + optionalDependencies: supported + ? { [`@tsrs/${process.platform}-${process.arch}`]: rust.version } + : {}, + }), + ); + await writeInventory(false); + await writeFile( + join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + rootDir: "src", + types: [], + lib: ["ES2023", "DOM"], + target: "ES2023", + module: "NodeNext", + outDir: "dist", + }, + include: ["src/**/*.ts"], + }), + ); + await writeFile( + join(root, "tsconfig.build.json"), + '{"extends":"./tsconfig.json"}\n', + ); + await writeFile( + join(root, ".gitignore"), + "node_modules/\ndist/\n.cache/\n.turbo/\n", + ); + // TS 7.0 accepts this contextual callback; TS 7.1 reports TS7031. + // The source stays identical while only the selected compiler changes. + await writeFile( + join(root, "src/repro.ts"), + [ + "interface Options { signal?: AbortSignal }", + "interface Provider { inspect(options?: Options): Promise }", + "declare const base: Provider;", + "const providers: Provider[] = [{ ...base, inspect: async ({ signal } = {}) => {", + " if (signal?.aborted) throw signal.reason;", + "} }];", + ].join("\n"), + ); + await workflowGit(root, ["init", "--initial-branch=main"]); + await workflowGit(root, ["add", "."]); + await workflowGit(root, ["commit", "-m", "test: seed compiler isolation"]); + const run = (task: string, dryRun = false) => + execute( + join(root, "scripts/run-turbo.mjs"), + [ + "run", + task, + ...(dryRun ? ["--dry=json"] : ["--output-logs=errors-only"]), + "--cache=local:rw", + "--cache-dir", + cache, + ], + root, + ); + const hash = (task: string) => { + const result = run(task, true); + expect(result.status, result.stdout + result.stderr).toBe(0); + const entry = dryRunSchema + .parse(JSON.parse(result.stdout)) + .tasks.find((value) => value.taskId === task); + if (entry === undefined) throw new Error(`Missing compiler task ${task}`); + return entry.hash; + }; + const hashes = new Map(); + for (const task of ["compile", "typecheck"]) { + const cold = run(task); + expect(cold.status, cold.stdout + cold.stderr).toBe(0); + hashes.set(task, hash(task)); + const warm = run(task); + expect(warm.status, warm.stdout + warm.stderr).toBe(0); + expect(warm.stdout).toMatch(/Cached:\s+1 cached/u); + } + await writeInventory(true); + for (const task of ["compile", "typecheck"]) { + const result = run(task); + expect(result.status, result.stdout + result.stderr).toBeGreaterThan(0); + expect(result.stdout + result.stderr).toContain("TS7031"); + expect(result.stdout).toMatch(/Cached:\s+0 cached/u); + expect(hash(task)).not.toBe(hashes.get(task)); + } + }, +); diff --git a/tests/boundary/filesystem/typescriptCompiler.test.ts b/tests/boundary/filesystem/typescriptCompiler.test.ts new file mode 100644 index 0000000000..d49f5b6089 --- /dev/null +++ b/tests/boundary/filesystem/typescriptCompiler.test.ts @@ -0,0 +1,206 @@ +import { spawnSync } from "node:child_process"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { + resolveTypeScriptCompiler, + selectTypeScriptCompiler, +} from "../../../scripts/run-typescript.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const launcher = join(repository, "scripts/run-typescript.mjs"); +const require = createRequire(import.meta.url); +const rustPackage = z + .object({ + version: z.string(), + optionalDependencies: z.record(z.string(), z.string()), + }) + .parse( + JSON.parse(await readFile(require.resolve("tsrs/package.json"), "utf8")), + ); +const officialPackage = z + .object({ version: z.string() }) + .parse( + JSON.parse( + await readFile(require.resolve("typescript/package.json"), "utf8"), + ), + ); +const execute = (script: string, args: string[]) => + spawnSync(process.execPath, [script, ...args], { + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, TURBO_HASH: undefined }, + }); + +it.each([ + ["linux", "x64", "tsrs"], + ["linux", "arm64", "tsrs"], + ["darwin", "arm64", "tsrs"], + ["win32", "x64", "typescript"], + ["win32", "arm64", "typescript"], + ["darwin", "x64", "typescript"], + ["android", "arm64", "typescript"], + ["linux", "riscv64", "typescript"], +])( + "selects %s/%s compiler %s from the pinned inventory", + (platform, arch, compiler) => { + expect( + selectTypeScriptCompiler( + platform, + arch, + rustPackage.optionalDependencies, + ), + ).toBe(compiler); + }, +); + +it("always retains the official compiler on Windows", () => { + expect( + selectTypeScriptCompiler("win32", "x64", { "@tsrs/win32-x64": "future" }), + ).toBe("typescript"); +}); + +it("runs the selected installed compiler", () => { + const result = execute(launcher, ["--version"]); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const compiler = selectTypeScriptCompiler( + process.platform, + process.arch, + rustPackage.optionalDependencies, + ); + expect(result.stdout).toContain( + compiler === "tsrs" + ? `tsrs ${rustPackage.version}` + : `Version ${officialPackage.version}`, + ); +}); + +it("forwards build arguments and preserves real type errors and exit status", async () => { + const root = await createTestTempDirectory("rea-typescript-compiler-"); + const input = join(root, "input.ts"); + await writeFile( + join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + target: "ES2023", + types: [], + outDir: "output", + }, + files: ["input.ts"], + }), + ); + await writeFile(input, "export const value: number = 1;\n"); + const valid = execute(launcher, ["-p", root, "--pretty", "false"]); + expect(valid.error).toBeUndefined(); + expect(valid.status, valid.stdout + valid.stderr).toBe(0); + expect(await readFile(join(root, "output/input.js"), "utf8")).toContain( + "value = 1", + ); + const resourceArgs = + resolveTypeScriptCompiler().name === "tsrs" + ? ["--checkers", "1", "--maxMemory", "1G"] + : ["--checkers", "1"]; + const checked = execute(launcher, ["-p", root, "--noEmit", ...resourceArgs]); + expect(checked.error).toBeUndefined(); + expect(checked.status, checked.stdout + checked.stderr).toBe(0); + await writeFile(input, 'export const value: number = "invalid";\n'); + const invalid = execute(launcher, [ + "-p", + root, + "--noEmit", + "--pretty", + "false", + ]); + const direct = execute(resolveTypeScriptCompiler().launcher, [ + "-p", + root, + "--noEmit", + "--pretty", + "false", + ]); + expect(direct.error).toBeUndefined(); + expect(direct.status, direct.stdout + direct.stderr).toBeGreaterThan(0); + expect(direct.stdout + direct.stderr).toContain("TS2322"); + expect(invalid.error).toBeUndefined(); + expect(invalid.status, invalid.stdout + invalid.stderr).toBe(direct.status); + expect(invalid.stdout + invalid.stderr).toContain("TS2322"); + expect(await readFile(join(root, "output/input.js"), "utf8")).toContain( + "value = 1", + ); +}); + +it.skipIf(process.platform === "win32")( + "does not retry failed supported compilers with the official compiler", + async () => { + const root = await createTestTempDirectory("rea-typescript-failure-"); + const script = join(root, "run-typescript.mjs"); + await copyFile(launcher, script); + for (const name of ["tsrs", "typescript"]) { + const directory = join(root, "node_modules", name); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ + name, + type: "module", + bin: { [name === "tsrs" ? "tsrs" : "tsc"]: "cli.mjs" }, + optionalDependencies: { + [`@tsrs/${process.platform}-${process.arch}`]: "fixture", + }, + }), + ); + await writeFile( + join(directory, "cli.mjs"), + name === "tsrs" + ? 'console.error("supported compiler failed"); process.exitCode = 42;\n' + : 'console.log("unexpected fallback");\n', + ); + } + const result = execute(script, ["--noEmit"]); + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(42); + expect(result.stderr).toContain("supported compiler failed"); + expect(result.stdout).not.toContain("unexpected fallback"); + // The same selection must expose a missing installed launcher as a failure. + await writeFile( + join(root, "node_modules/tsrs/package.json"), + JSON.stringify({ + name: "tsrs", + bin: { tsrs: "missing.mjs" }, + optionalDependencies: { + [`@tsrs/${process.platform}-${process.arch}`]: "fixture", + }, + }), + ); + const missing = execute(script, ["--noEmit"]); + expect(missing.status, missing.stdout + missing.stderr).toBe(1); + expect(missing.stderr).toContain("ERR_MODULE_NOT_FOUND"); + expect(missing.stdout).not.toContain("unexpected fallback"); + }, +); + +it.each([undefined, "typescript@stale"])( + "rejects missing or stale compiler identity %s inside a Turbo task", + (identity) => { + const result = spawnSync(process.execPath, [launcher, "--version"], { + encoding: "utf8", + timeout: 15_000, + env: { + ...process.env, + TURBO_HASH: "compiler-task", + REA_TYPESCRIPT_COMPILER: identity, + }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stderr).toContain("Compiler identity is missing or stale"); + }, +); diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index df484959d2..66d49eec6b 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -12,6 +12,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createAnalysisExecution, type AnalysisProviderCandidate, + type AnalysisProviderInspectionOptions, type CapabilityDescriptor, type ProviderIdentity, } from "../../../src/application/AnalysisProvider.js"; @@ -411,7 +412,9 @@ it.each(["ready", "cancelled", "physical"] as const)( new AnalysisProviderRegistry([ { ...base, - inspectAvailability: async ({ signal } = {}) => { + inspectAvailability: async ({ + signal, + }: AnalysisProviderInspectionOptions = {}) => { enter(); await gate; if (mode === "physical") diff --git a/tests/process-global/curatedWindowsLane.test.ts b/tests/process-global/curatedWindowsLane.test.ts index 637ca8d23a..48a1edda6b 100644 --- a/tests/process-global/curatedWindowsLane.test.ts +++ b/tests/process-global/curatedWindowsLane.test.ts @@ -136,3 +136,16 @@ it("keeps every explicit focused CI test path executable after file moves", asyn ); expect(missing.filter((path) => path !== undefined)).toEqual([]); }, 10_000); + +it("checks the complete source with the fallback compiler before Windows build reuse", async () => { + const workflow = workflowSchema.parse( + parse(await readFile(WORKFLOW, "utf8")), + ); + const steps = workflow.jobs["windows-curated"]?.steps ?? []; + const checked = steps.findIndex( + (step) => step.run === "npm run typecheck -- --checkers 2", + ); + const built = steps.findIndex((step) => step.run === "npm run build:cached"); + expect(checked).toBeGreaterThan(-1); + expect(built).toBeGreaterThan(checked); +}); diff --git a/turbo.json b/turbo.json index 37783a4afd..e3be6d9d9d 100644 --- a/turbo.json +++ b/turbo.json @@ -3,13 +3,20 @@ "cacheMaxAge": "7d", "cacheMaxSize": "500MB", "globalDependencies": [".nvmrc", ".npmrc"], - "globalPassThroughEnv": ["GOMAXPROCS", "GOMEMLIMIT", "UV_THREADPOOL_SIZE"], + "globalPassThroughEnv": [ + "GOMAXPROCS", + "GOMEMLIMIT", + "UV_THREADPOOL_SIZE", + "REA_TYPESCRIPT_COMPILER" + ], "tasks": { "compile": { "dependsOn": ["metadata:check"], "inputs": [ "package.json", "package-lock.json", + "scripts/run-turbo.mjs", + "scripts/run-typescript.mjs", "scripts/check-dependency-install.mjs", "scripts/clean-build-output.mjs", "scripts/bundle-runtime.mjs", @@ -22,12 +29,15 @@ "tsconfig.json", "tsconfig.build.json" ], - "outputs": ["dist/**"] + "outputs": ["dist/**"], + "env": ["REA_TYPESCRIPT_COMPILER"] }, "typecheck": { "inputs": [ "package.json", "package-lock.json", + "scripts/run-turbo.mjs", + "scripts/run-typescript.mjs", "src/**/*.{cts,mts,ts,tsx}", "src/**/package.json", "tests/**/*.{cts,mts,ts,tsx}", @@ -44,7 +54,8 @@ "!docs/.vitepress/cache/**", "!docs/.vitepress/dist/**" ], - "outputs": [] + "outputs": [], + "env": ["REA_TYPESCRIPT_COMPILER"] }, "metadata:check": { "inputs": [ From c2bee63e998c414070f0c5d8b9a83549e260388a Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 04:57:08 -0700 Subject: [PATCH 08/29] fix(evidence): clear staged write cleanup state after a confirmed retry (#5852) The atomic JSON writer reported a failed staging-directory release as cleanup state on the returned EvidenceFileError or AnalysisCancelledError, but never bound that error to the retained staging lease. A later scope retry that removed the stage could not clear the error, so `rea evidence-export` (and other writers sharing the scope, such as --evidence-output delivery) kept reporting cleanup_incomplete and a retained resource after cleanup had completed. Bind each staged-output cleanup failure, and each admission retry failure, to its exact leases so confirmed release restores the primary outcome. Export recovery now reads that bound state instead of a local close flag. A retry that still fails keeps the error incomplete with the same retained stage. Fixes #5842 Co-authored-by: Claude Opus 5.5 --- src/application/EvidenceBundleCommands.ts | 12 +- src/application/JsonFiles.ts | 48 +++++-- .../evidenceExportCleanupRetry.test.ts | 124 ++++++++++++++++++ .../filesystem/jsonOutputOwnership.test.ts | 107 +++++++++++++++ 4 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 tests/boundary/filesystem/evidenceExportCleanupRetry.test.ts diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 1255ea68f8..036f1e21d7 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -46,15 +46,11 @@ export const exportEvidenceBundleCommand = async ( ); if (written.ok) return projectWrite(loaded.value, written); - let cleanupSucceeded = false; - try { - await resources.close(); - cleanupSucceeded = true; - } catch { - // The original error cause retains the same staged-output owner for diagnostics. - } + // Confirmed release resolves the bound error to its primary outcome; a + // failed retry leaves that error incomplete with its exact retained owner. + await resources.close().catch(() => undefined); if ( - cleanupSucceeded && + !written.error.cleanupIncomplete && written.error instanceof EvidenceFileError && written.error.published ) { diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index 2e81dd8fcf..1ae2b2b32b 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -29,6 +29,11 @@ import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; import { RegularFileCleanupFailure } from "./RegularFileRead.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; +import { + type AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -212,13 +217,14 @@ export function writeTextParts( cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined ? cause.cleanup : undefined; - return err( - new EvidenceFileError("write", "io", { - cause, - path: absoluteCallerPath(path), - ...(cleanup === undefined ? {} : { cleanup }), - }), - ); + const error = new EvidenceFileError("write", "io", { + cause, + path: absoluteCallerPath(path), + ...(cleanup === undefined ? {} : { cleanup }), + }); + // A failed admission retry names its exact retained leases. + bindArtifactFileCleanup(cause, error); + return err(error); }); } @@ -268,28 +274,31 @@ const writeTextPartsOwned = async ( resources: [cause.owner.directory], }; if (cause.outcome.ok) - return err( + return stagedCleanupFailure( new EvidenceFileError("write", "io", { cause, path: requestedPath, cleanup, published: { ...cause.outcome.value, path: requestedPath }, }), + cause.lease, ); if (cause.outcome.error instanceof AnalysisCancelledError) - return err( + return stagedCleanupFailure( new AnalysisCancelledError(cause.outcome.error.operation, { cause: cause.outcome.error.cause, cleanupPrimary: cause.outcome.error, cleanup, }), + cause.lease, ); - return err( + return stagedCleanupFailure( new EvidenceFileError("write", missingOrIo(cause.outcome.error), { cause, path: requestedPath, cleanup, }), + cause.lease, ); } if ( @@ -314,6 +323,17 @@ const writeTextPartsOwned = async ( } }; +/** The retained staging lease clears this error only when a retry confirms release. */ +const stagedCleanupFailure = < + Failure extends EvidenceFileError | AnalysisCancelledError, +>( + error: Failure, + lease: AnalysisCleanupLease, +): Result => { + new AnalysisCleanupLeaseGroup(error, [lease]); + return err(error); +}; + /** A missing path or parent is a selection error, not a permission failure. */ const missingOrIo = (cause: unknown): "missing" | "io" => { const code = fileErrorCode(cause); @@ -373,7 +393,12 @@ const publishFile = async ( output: owner, }); if (released.kind === "failed") - throw new StagedOutputCleanupFailure(outcome, released.cause, owner); + throw new StagedOutputCleanupFailure( + outcome, + released.cause, + owner, + released.lease, + ); if (!outcome.ok) throw outcome.error; return outcome.value.bytes; }; @@ -383,6 +408,7 @@ class StagedOutputCleanupFailure extends Error { readonly outcome: Result, readonly cleanupCause: unknown, readonly owner: OwnedStagedOutput, + readonly lease: AnalysisCleanupLease, ) { super("Staged output cleanup failed", { cause: outcome.ok ? cleanupCause : outcome.error, diff --git a/tests/boundary/filesystem/evidenceExportCleanupRetry.test.ts b/tests/boundary/filesystem/evidenceExportCleanupRetry.test.ts new file mode 100644 index 0000000000..2a7d99f71a --- /dev/null +++ b/tests/boundary/filesystem/evidenceExportCleanupRetry.test.ts @@ -0,0 +1,124 @@ +import { readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, vi } from "vitest"; + +import { exportEvidenceBundleCommand } from "../../../src/application/EvidenceBundleCommands.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Host filesystems cannot fail publication and then one staging removal on +// demand; every other export step runs on the real filesystem. +const fault = vi.hoisted(() => ({ + publication: false, + stagingRemovalFailures: 0, + stagingRemovals: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + link: async (...args: Parameters) => { + if (fault.publication) + throw Object.assign(new Error("injected publication EACCES"), { + code: "EACCES", + }); + return filesystem.link(...args); + }, + rmdir: async (...args: Parameters) => { + if (String(args[0]).includes(".rea-write-")) { + fault.stagingRemovals += 1; + if (fault.stagingRemovals <= fault.stagingRemovalFailures) + throw Object.assign(new Error("injected staging removal EBUSY"), { + code: "EBUSY", + }); + } + return filesystem.rmdir(...args); + }, + }; +}); + +const emptyBundle = JSON.stringify({ + artifacts: [], + providers: [], + environments: [], + scenarios: [], + captures: [], + unknowns: [], + records: [], +}); + +it.each([ + { + name: "restores the publication failure after a confirmed staging retry", + publication: true, + stagingRemovalFailures: 1, + stagingRemovals: 2, + code: "execution_failure", + retained: false, + }, + { + name: "keeps cleanup incomplete with its stage when the retry also fails", + publication: true, + stagingRemovalFailures: 2, + stagingRemovals: 2, + code: "cleanup_incomplete", + retained: true, + }, + { + name: "returns the published receipt after a confirmed staging retry", + publication: false, + stagingRemovalFailures: 1, + stagingRemovals: 2, + code: undefined, + retained: false, + }, +])("$name", async (scenario) => { + const root = await createTestTempDirectory("rea-export-cleanup-retry-"); + const source = join(root, "source.json"); + const output = join(root, "out.json"); + await writeFile(source, emptyBundle); + Object.assign(fault, { + publication: scenario.publication, + stagingRemovalFailures: scenario.stagingRemovalFailures, + stagingRemovals: 0, + }); + try { + const result = await exportEvidenceBundleCommand(source, output, false); + expect(fault.stagingRemovals).toBe(scenario.stagingRemovals); + const entries = await readdir(root); + const stages = entries.filter((entry) => entry.startsWith(".rea-write-")); + expect(stages).toHaveLength(scenario.retained ? 1 : 0); + expect(entries.includes("out.json")).toBe(!scenario.publication); + if (scenario.code === undefined) { + expect(result).toMatchObject({ + ok: true, + value: { path: output, records: 0 }, + }); + return; + } + if (result.ok) throw new Error("Expected export failure"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe(scenario.code); + expect(projected.details).toMatchObject({ + operation: "write", + reason: "io", + path: output, + }); + if (!scenario.retained) { + expect(projected.details).not.toHaveProperty("cleanup"); + expect(projected.details).not.toHaveProperty("resources"); + return; + } + expect(projected.details).toMatchObject({ + cleanup: "incomplete", + cleanup_reason: "injected staging removal EBUSY", + execution_failure: "execution_failure", + resources: [join(root, stages[0]!)], + }); + } finally { + Object.assign(fault, { publication: false, stagingRemovalFailures: 0 }); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/boundary/filesystem/jsonOutputOwnership.test.ts b/tests/boundary/filesystem/jsonOutputOwnership.test.ts index 9f25d9ef33..cd83e0b6ac 100644 --- a/tests/boundary/filesystem/jsonOutputOwnership.test.ts +++ b/tests/boundary/filesystem/jsonOutputOwnership.test.ts @@ -82,6 +82,8 @@ it("closes the descriptor and retains staged content when its final identity is await expect(fs.access(destination)).rejects.toThrow(); // A closed descriptor cannot re-establish identity, so retry stays incomplete. await expect(resources.close()).rejects.toThrow(); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([stage]); expect(await fs.readFile(join(stage, "content"), "utf8")).toBe( "owned bytes", ); @@ -179,6 +181,15 @@ it("reports committed output when post-publication staging cleanup remains owned "canonical output", ); await fs.rm(join(cleanupResource, "unexpected")); + await resources.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + const released = projectAnalysisError(result.error); + expect(released).toMatchObject({ + code: "execution_failure", + details: { published: true, published_path: join(root, "bundle.json") }, + }); + expect(released.details).not.toHaveProperty("cleanup"); } finally { await resources.close(); await rm(root, { recursive: true, force: true }); @@ -218,12 +229,108 @@ it("preserves cancellation when staging cleanup also fails", async () => { await expect(fs.access(join(root, "cancelled.json"))).rejects.toThrow(); const stage = requireStages(root)[0]; if (stage !== undefined) await fs.rm(join(root, stage, "unexpected")); + await resources.close(); + expect(requireStages(root)).toEqual([]); + expect(result.error.cleanupIncomplete).toBe(false); + const released = projectAnalysisError(result.error); + expect(released).toMatchObject({ + code: "cancelled", + details: { operation: "export_evidence_bundle", cleanup: "complete" }, + }); + expect(released.details).not.toHaveProperty("resources"); } finally { await resources.close(); await rm(root, { recursive: true, force: true }); } }); +it("restores an unpublished write failure once its retained stage is released", async () => { + const root = await createTestTempDirectory("rea-json-write-retry-"); + const resources = new ArtifactResourceScope(); + const destination = join(root, "failed.json"); + try { + function* failingParts(): Generator { + const stage = requireStages(root)[0]; + if (stage === undefined) throw new Error("Expected staging directory"); + writeFileSync(join(root, stage, "unexpected"), "retain this"); + yield "partial"; + throw new Error("injected staged write failure"); + } + const result = await writeTextParts( + failingParts(), + destination, + false, + resources, + ); + if (result.ok) throw new Error("Expected staged write failure"); + expect(result.error).toBeInstanceOf(EvidenceFileError); + expect(result.error.published).toBeUndefined(); + const stage = result.error.cleanupResources[0]; + if (stage === undefined) throw new Error("Expected retained staging"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { cleanup: "incomplete", resources: [stage] }, + }); + await fs.rm(join(stage, "unexpected")); + await resources.close(); + expect(requireStages(root)).toEqual([]); + await expect(fs.access(destination)).rejects.toThrow(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + const released = projectAnalysisError(result.error); + expect(released).toMatchObject({ + code: "execution_failure", + details: { operation: "write", reason: "io", path: destination }, + }); + expect(released.details).not.toHaveProperty("cleanup"); + expect(released.details).not.toHaveProperty("resources"); + } finally { + await resources.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +it("clears an admission retry failure only after every retained owner releases", async () => { + const root = await createTestTempDirectory("rea-json-admission-retry-"); + const directory = join(root, ".rea-write-owned"); + await mkdir(directory); + const unexpected = join(directory, "unexpected"); + writeFileSync(unexpected, "retain this"); + const resources = new ArtifactResourceScope(); + try { + expect( + await resources.release({ + kind: "staged-output", + resource: directory, + output: await OwnedStagedOutput.create(directory), + }), + ).toMatchObject({ kind: "failed" }); + const destination = join(root, "next.json"); + const result = await writeTextParts( + ["unused"], + destination, + false, + resources, + ); + if (result.ok) throw new Error("Expected admission retry failure"); + expect(result.error).toBeInstanceOf(EvidenceFileError); + expect(result.error.cleanupResources).toEqual([directory]); + await expect(fs.access(destination)).rejects.toThrow(); + // A retry that still fails keeps the same exact owner unresolved. + await expect(resources.retryCleanup()).rejects.toThrow(); + expect(result.error.cleanupIncomplete).toBe(true); + await fs.rm(unexpected); + await resources.close(); + await expect(fs.access(directory)).rejects.toThrow(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe("execution_failure"); + } finally { + await fs.rm(unexpected, { force: true }); + await resources.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + it("refuses to publish a staged path replaced after its descriptor opened", async () => { const root = await createTestTempDirectory("rea-staged-swap-"); const resources = new ArtifactResourceScope(); From 7f9a492a8ded24a561397fe2199559274b7c03f5 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 04:57:28 -0700 Subject: [PATCH 09/29] fix(signals): report unknown decoder help IDs as invalid input (#5858) inspect_signal_decoders turned sigrok's explicit missing-decoder refusal into execution_failure with rea doctor advice, while decode_signal_recording already reported the same refusal as invalid_request on decoders. The sigrok selector classifier now owns the unknown-ID match for both selections: help names the decoder field and points to catalog discovery, decoding keeps decoders/annotations. The provider passes each producer command's caller selection to that one owner instead of per-call closures. Installed decoders that fail to load and refusals naming other IDs remain provider failures. The real-sigrok verifier now asserts the typed refusal for both operations. Fixes #5849 Co-authored-by: Claude Opus 5.5 --- docs/signal-recordings.md | 3 + scripts/verify-signal-recording.mjs | 25 ++++- src/signals/SigrokRecordingProvider.ts | 17 ++-- src/signals/sigrokSelectorRejection.ts | 72 ++++++++++---- .../signals/recordingProvider.test.ts | 97 ++++++++++++++++++- 5 files changed, 179 insertions(+), 35 deletions(-) diff --git a/docs/signal-recordings.md b/docs/signal-recordings.md index 69b14d1935..298d7be54a 100644 --- a/docs/signal-recordings.md +++ b/docs/signal-recordings.md @@ -126,6 +126,9 @@ matching help retains original one-based line numbers. Empty query selects all. returns all remaining matches; CLI `--all` and `--limit` conflict. The window reports examined, matching and returned records and `has_more`. A filtered empty page is not evidence that the installation has no decoders. +When sigrok reports a selected decoder ID as not installed, help and decoding +return `invalid_request` naming `decoder` or `decoders`; omit `decoder` to list +installed IDs. An installed decoder that fails to load remains an execution failure. Discovery refuses more than 65,535 line separators before constructing record objects (65,536-line representation budget), in addition to the 4 MiB output cap. diff --git a/scripts/verify-signal-recording.mjs b/scripts/verify-signal-recording.mjs index 848b4ec134..e093d0aa14 100644 --- a/scripts/verify-signal-recording.mjs +++ b/scripts/verify-signal-recording.mjs @@ -39,6 +39,19 @@ const environment = { TEMP: root.path, }; const client = new Client({ name: "signal-recording-verifier", version: "1" }); +// Real sigrok refusals of an unknown decoder ID must name the caller's field. +const assertRejectedSelector = (response, operation, field) => { + assert.equal(response.isError, true); + const text = response.content.find((item) => item.type === "text")?.text; + const { error } = JSON.parse(text ?? "{}"); + assert.equal(error.code, "invalid_request"); + assert.equal(error.details.operation, operation); + assert.deepEqual( + error.details.issues.map((issue) => issue.path), + [[field]], + ); + assert.equal(error.details.captured_output.exit_code, 1); +}; const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], @@ -244,16 +257,18 @@ try { assert.equal(await readFile(pagedPath, "utf8"), pagedSource); cases++; } - for (const input of [ - { path, decoders: "no_such_decoder" }, - { path: join(root.path, "absent.vcd"), decoders: "uart" }, - { path, decoders: "uart", driver: "demo" }, + for (const [input, rejectedField] of [ + [{ path, decoders: "no_such_decoder" }, "decoders"], + [{ path: join(root.path, "absent.vcd"), decoders: "uart" }], + [{ path, decoders: "uart", driver: "demo" }], ]) { const response = await client.callTool({ name: advertised.name, arguments: input, }); assert.equal(response.isError, true); + if (rejectedField !== undefined) + assertRejectedSelector(response, advertised.name, rejectedField); await client.ping(); cases++; } @@ -378,7 +393,7 @@ try { name: discoveryTool.name, arguments: { decoder: "rea_no_such_decoder" }, }); - assert.equal(unavailable.isError, true); + assertRejectedSelector(unavailable, discoveryTool.name, "decoder"); cases++; await client.close(); assert.deepEqual((await readdir(root.path)).sort(), [ diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index 05417d1af9..f7fb125ff0 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -9,7 +9,7 @@ import { } from "./parseDecoderDiscovery.js"; import { parseSigrokSelectorRejection, - type SigrokSelectorRejection, + type SigrokSelection, } from "./sigrokSelectorRejection.js"; import { BOUNDED_PRODUCER_LIMITS as LIMITS, @@ -172,9 +172,8 @@ export class SigrokRecordingProvider implements SignalRecordingPort { const snapshotPath = join(root.path, "recording"); const execute = async ( args: readonly string[], - rejectedSelector?: ( - stderr: string, - ) => SigrokSelectorRejection | undefined, + // Caller selectors this command loads; their refusals are input errors. + selection?: SigrokSelection, ) => { capturedOutput = undefined; const output = await runOwnedCommand( @@ -232,9 +231,9 @@ export class SigrokRecordingProvider implements SignalRecordingPort { phase = previousPhase; resourcePath = "path" in input ? input.path : command; const rejection = - output.exitCode === 0 + output.exitCode === 0 || selection === undefined ? undefined - : rejectedSelector?.(output.stderr.text); + : parseSigrokSelectorRejection(output.stderr.text, selection); if (rejection !== undefined) throw new AnalysisInputError( operation, @@ -289,7 +288,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { input.decoder === null ? ["--dont-scan", "--list-supported"] : ["--dont-scan", `--protocol-decoders=${input.decoder}`, "--show"]; - const output = await execute(args); + const output = await execute(args, input); const report = signalDecodersSchema.parse({ selection: input, ...parseDecoderDiscovery(output.stdout.text, input), @@ -330,9 +329,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { ? [] : [`--protocol-decoder-annotations=${input.annotations}`]), ]; - const output = await execute(args, (stderr) => - parseSigrokSelectorRejection(stderr, input), - ); + const output = await execute(args, input); phase = "integrity"; resourcePath = snapshotPath; if ( diff --git a/src/signals/sigrokSelectorRejection.ts b/src/signals/sigrokSelectorRejection.ts index d37bfa10f4..42d21840c4 100644 --- a/src/signals/sigrokSelectorRejection.ts +++ b/src/signals/sigrokSelectorRejection.ts @@ -1,51 +1,87 @@ import type { AnalysisInputIssue } from "../domain/analysisErrorCore.js"; +import type { InspectSignalDecodersInput } from "../domain/signals/signalDecoders.js"; import type { DecodeSignalRecordingInput } from "../domain/signals/signalRecording.js"; export type SigrokSelectorRejection = AnalysisInputIssue & { readonly message: string; }; +/** Caller selectors that a sigrok command loads, keyed by their input field. */ +export type SigrokSelection = + | Pick + | Pick; + const rejectedSelector = ( - field: "decoders" | "annotations", + field: "decoder" | "decoders" | "annotations", producerMessage: string, + recovery: string, ): SigrokSelectorRejection => ({ path: [field], reason: "invalid_value", - message: `sigrok-cli rejected the ${field} selector: ${producerMessage} Use inspect_signal_decoders to list installed decoder IDs, or pass a decoder ID to show its options and annotation classes.`, + message: `sigrok-cli rejected the ${field} selector: ${producerMessage} ${recovery}`, }); +const DECODE_RECOVERY = + "Use inspect_signal_decoders to list installed decoder IDs, or pass a decoder ID to show its options and annotation classes."; +const HELP_RECOVERY = + "Omit decoder to list installed decoder IDs, optionally filtered by query, then request help for one of those IDs."; + +/** + * sigrok-cli loads selected decoders before decoding or showing help, so an + * unknown ID yields the same refusal for both commands. A missing module named + * exactly after the decoder separates an unknown ID from an installed decoder + * whose own imports or code fail to load. + */ +const unknownDecoderRefusal = ( + lines: ReadonlySet, + id: string, +): string | undefined => { + const producerMessage = `Failed to load protocol decoder ${id}.`; + return id !== "" && + lines.has(producerMessage) && + lines.has( + `srd: ModuleNotFoundError: Failed to load decoder ${id}: import by name failed: No module named '${id}'`, + ) + ? producerMessage + : undefined; +}; + /** * Recognize only observed sigrok-cli 0.7.2 selector refusals whose rejected * name equals a caller-supplied selector; other failures stay unclassified. */ export const parseSigrokSelectorRejection = ( stderr: string, - input: Pick, + selection: SigrokSelection, ): SigrokSelectorRejection | undefined => { const lines = new Set(stderr.split("\n")); + if ("decoder" in selection) { + const refusal = + selection.decoder === null + ? undefined + : unknownDecoderRefusal(lines, selection.decoder); + return refusal === undefined + ? undefined + : rejectedSelector("decoder", refusal, HELP_RECOVERY); + } // sigrok-cli stacks decoders with "," and appends options with ":". - for (const entry of input.decoders.split(",")) { - const id = entry.split(":")[0] ?? ""; - const producerMessage = `Failed to load protocol decoder ${id}.`; - // A missing module named exactly after the decoder separates an unknown ID - // from an installed decoder whose own imports or code fail to load. - if ( - id !== "" && - lines.has(producerMessage) && - lines.has( - `srd: ModuleNotFoundError: Failed to load decoder ${id}: import by name failed: No module named '${id}'`, - ) - ) - return rejectedSelector("decoders", producerMessage); + for (const entry of selection.decoders.split(",")) { + const refusal = unknownDecoderRefusal(lines, entry.split(":")[0] ?? ""); + if (refusal !== undefined) + return rejectedSelector("decoders", refusal, DECODE_RECOVERY); } // Annotation selections are "decoder=class:class,decoder". - for (const entry of input.annotations?.split(",") ?? []) { + for (const entry of selection.annotations?.split(",") ?? []) { const [decoder = "", classes, ...rest] = entry.split("="); if (decoder === "" || classes === undefined || rest.length > 0) continue; for (const annotation of classes.split(":")) { const producerMessage = `Annotation '${annotation}' not found for protocol decoder '${decoder}'.`; if (annotation !== "" && lines.has(producerMessage)) - return rejectedSelector("annotations", producerMessage); + return rejectedSelector( + "annotations", + producerMessage, + DECODE_RECOVERY, + ); } } return undefined; diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts index d044497f39..078185bec3 100644 --- a/tests/boundary/signals/recordingProvider.test.ts +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -16,6 +16,11 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { Cli } from "incur"; +import { registerSignalCommands } from "../../../src/cli/signalCommands.js"; +import { silentLogger } from "../../../src/logger.js"; // A command-boundary simulator: real process ownership/filesystem, not real sigrok coverage. const fixture = async (mode = "ok", failCleanup = false) => { @@ -300,17 +305,105 @@ it.each([ }, ); +it("reports an unknown decoder ID as the rejected field of each MCP operation", async () => { + const f = await fixture("unknown-decoder"); + const harness = await createApplicationMcpHarness({ + serverOptions: { environment: {}, signalRecording: f.service }, + }); + onTestFinished(harness.close); + const refusal = "Failed to load protocol decoder nosuchdecoder."; + for (const [name, args, field, recovery] of [ + [ + "inspect_signal_decoders", + { decoder: "nosuchdecoder" }, + "decoder", + "Omit decoder to list installed decoder IDs", + ], + [ + "decode_signal_recording", + { path: f.path, decoders: "nosuchdecoder" }, + "decoders", + "Use inspect_signal_decoders to list installed decoder IDs", + ], + ] as const) { + const { error } = parseMcpToolError( + await harness.client.callTool({ name, arguments: args }), + ); + const message = `sigrok-cli rejected the ${field} selector: ${refusal} ${recovery}`; + expect(error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining(message), + recovery: { kind: "inspect_input_contract", operation: name }, + details: { + operation: name, + issues: [ + { + path: [field], + reason: "invalid_value", + message: expect.stringContaining(message), + }, + ], + captured_output: { + exit_code: 1, + stderr: expect.stringContaining(refusal), + }, + }, + }); + } + expect( + (await readdir(f.directory)).filter((name) => name.startsWith("decoder-")), + ).toEqual([]); +}); + +it("reports an unknown decoder help selection as CLI invalid input", async () => { + const f = await fixture("unknown-decoder"); + const cli = Cli.create("signal-refusal-test", { sync: false }); + registerSignalCommands(cli, silentLogger, {}, f.service); + let stdout = ""; + await cli.serve( + [ + "inspect-signal-decoders", + "--decoder", + "nosuchdecoder", + "--format", + "json", + ], + { + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("Omit decoder"), + details: { + operation: "inspect_signal_decoders", + issues: [{ path: ["decoder"], reason: "invalid_value" }], + captured_output: { exit_code: 1 }, + }, + }); +}); + it.each([ // An installed decoder whose own dependency is missing is an installation fault. ["broken-decoder", { decoders: "uart" }], + ["broken-decoder", { decoder: "uart" }], // Refusals naming selectors the caller did not supply stay unclassified. ["unknown-decoder", { decoders: "uart" }], + ["unknown-decoder", { decoder: "uart" }], + ["unknown-decoder", {}], ["unknown-annotation", { decoders: "uart", annotations: "uart=rx-data" }], ] as const)( - "keeps %s outside caller selectors as a provider failure", + "keeps %s outside caller selectors %j as a provider failure", async (mode, selection) => { const f = await fixture(mode); - const result = await f.service.decode({ path: f.path, ...selection }); + const result = + "decoders" in selection + ? await f.service.decode({ path: f.path, ...selection }) + : await f.service.inspectDecoders(selection); if (result.ok) throw new Error("Expected decoder failure"); expect(result.error._tag).toBe("ProviderAdapterError"); }, From d124809950d2e6cafb95f95a9171d44dec3ef5a6 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 04:57:55 -0700 Subject: [PATCH 10/29] fix(android): report a rejected relative JADX JAR instead of an unset one (#5862) A supplied non-absolute REA_JADX_MCP_JAR was folded into the unset case, emitting not_configured with jar_configured: false and dropping the caller-selected value. The JAR parser now keeps the two outcomes distinct: unset stays not_configured, while a supplied non-absolute value (including an empty string) fails with open_options_invalid, jar_configured: true, the exact rejected jar_path and the absolute-path requirement. CLI, MCP and doctor derive their output from that one boundary result. Fixes #5844 Co-authored-by: Claude Opus 5.5 --- src/android/JadxConfiguration.ts | 11 +++- tests/acceptance/nonBinaryComposition.test.ts | 55 +++++++++++++++++++ tests/boundary/android/jadxDoctor.test.ts | 25 +++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index bcc93ad236..ddeeb2cb6c 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -544,13 +544,22 @@ const resolveJar = async ( environment: Readonly>, ): Promise => { const jar = environment.REA_JADX_MCP_JAR; - if (jar === undefined || !isAbsolute(jar)) + if (jar === undefined) throw configurationFailure( "not_configured", JADX_JAR_CONFIGURATION_REMEDIATION, { jar_configured: false }, "jar", ); + // A supplied selection stays configured even when rejected, so callers can + // tell it from an omitted setting and see the exact value REA refused. + if (!isAbsolute(jar)) + throw configurationFailure( + "open_options_invalid", + `REA_JADX_MCP_JAR must be an absolute path; received ${JSON.stringify(jar)}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`, + { jar_configured: true, jar_path: jar, path_requirement: "absolute" }, + "jar", + ); try { await access(jar, constants.R_OK); if (!(await stat(jar)).isFile()) diff --git a/tests/acceptance/nonBinaryComposition.test.ts b/tests/acceptance/nonBinaryComposition.test.ts index d2409723f4..87805a5a1e 100644 --- a/tests/acceptance/nonBinaryComposition.test.ts +++ b/tests/acceptance/nonBinaryComposition.test.ts @@ -76,6 +76,61 @@ for (const { jarConfigured, setting } of [ }, ); +cliTest.skipIf(process.platform === "win32")( + "distinguishes a rejected relative REA_JADX_MCP_JAR from an unset one through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-android-relative-jar-"); + const apk = join(root, "fixture.apk"); + await writeOrderedZip(apk, ["AndroidManifest.xml"]); + const run = async (environment: Record) => { + const cliResult = await cli.run({ + arguments: ["inspect-android-package", apk, "--json"], + environment: { REA_LOG_LEVEL: "silent", ...environment }, + timeoutMs: 10_000, + }); + expect(cliResult.exitCode).toBe(1); + const client = await connect({ REA_LOG_LEVEL: "silent", ...environment }); + const response = await client.callTool({ + name: "inspect_android_package", + arguments: { path: apk }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response)).toEqual({ error: cliResult.json }); + return cliResult.json as { + code: string; + message: string; + details: { diagnostics: Record }; + }; + }; + + const relative = "./tools/jadx-headless-mcp.jar"; + const rejected = await run({ REA_JADX_MCP_JAR: relative }); + expect(rejected.code).toBe("capability_unavailable"); + expect(rejected.details.diagnostics).toEqual({ + jar_configured: true, + jar_path: relative, + path_requirement: "absolute", + failure_kind: "jar", + failure_code: "open_options_invalid", + }); + expect(rejected.message).toContain(`received "${relative}"`); + + const empty = await run({ REA_JADX_MCP_JAR: "" }); + expect(empty.details.diagnostics).toMatchObject({ + jar_configured: true, + jar_path: "", + failure_code: "open_options_invalid", + }); + + const unset = await run({}); + expect(unset.details.diagnostics).toEqual({ + jar_configured: false, + failure_kind: "jar", + failure_code: "not_configured", + }); + }, +); + cliTest.skipIf(process.platform !== "linux")( "preserves the missing firmware command reason through both caller paths", async ({ cli }) => { diff --git a/tests/boundary/android/jadxDoctor.test.ts b/tests/boundary/android/jadxDoctor.test.ts index f5d09866be..f48c86d341 100644 --- a/tests/boundary/android/jadxDoctor.test.ts +++ b/tests/boundary/android/jadxDoctor.test.ts @@ -133,6 +133,31 @@ it("publishes no registration environment when REA_JADX_MCP_JAR is unset", async }); }); +it("reports a relative REA_JADX_MCP_JAR as a rejected selection, not an unset one", async () => { + const inspection = await inspectJadxProvider({ + REA_JADX_MCP_JAR: "tools/jadx-headless-mcp.jar", + }); + + expect(inspection).toMatchObject({ + id: "jadx", + configured: true, + available: false, + registrationEnvironment: {}, + checks: [ + { + name: "configuration", + ok: false, + code: "open_options_invalid", + classification: "config_drift", + }, + ], + }); + expect(inspection.checks[0]?.detail).toContain( + 'REA_JADX_MCP_JAR must be an absolute path; received "tools/jadx-headless-mcp.jar".', + ); + expect(inspection.checks[0]?.remediation).toContain("REA_JADX_MCP_JAR"); +}); + it("publishes no registration environment when the configured JAR fails validation", async () => { const root = await createTestTempDirectory("rea-jadx-doctor-invalid-"); const jar = join(root, "engine.jar"); From ce1f4b1bbaced57529d609f3007a76de583e11c4 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 04:59:09 -0700 Subject: [PATCH 11/29] fix(artifacts): report unknown inventory traversal in inspection coverage (#5867) fix(artifacts): keep unknown inventory traversal out of complete inspection coverage Artifact inspection derived its coverage status only from always-zero omission counts, so an inventory substep with unknown container-entry traversal (for example, an omitted FIFO) yielded complete-within-substeps. Analysis-view summaries also exposed the inventory's typed source_coverage only for PCK and PyInstaller roots. Derive the inspection status from the inventory substep's coverage (unknown traversal is partial), and carry the producer's source_coverage plus the traversal-unknown caveat in every artifact summary. Hash-count fields keep their existing format gating. Fixes #5837 Co-authored-by: Claude Opus 5.5 --- docs/mcp-contracts.md | 4 +- .../analysisView/artifactInventoryView.ts | 20 ++- src/domain/artifactInspection.ts | 5 +- .../mcp/artifactInspectionCoverageMcp.test.ts | 130 ++++++++++++++++++ 4 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 845e8c8719..16543c9b7a 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -355,7 +355,9 @@ page size or export retained Evidence if the view is too large. Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also support summary and inventory views. A summary reports the manifest's expected counts separately from available record counts, observed entry kinds, inventory -Evidence IDs and inspection coverage. An inventory view selects `nodes`, +Evidence IDs, the inventory producer's traversal `source_coverage` and +inspection coverage. Inspection coverage is `partial` when that traversal +coverage is unknown. An inventory view selects `nodes`, `occurrences`, `edges`, or `integrity_contradictions`, with `offset` and `limit` defaulting to 0 and 64. For occurrences, optional `logical_paths` matches exact recorded strings: omission selects all available records; `[]` selects none. diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 730d200116..0748894d14 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -245,17 +245,14 @@ export const projectArtifactInventoryView = ( inventory.occurrences.find((row) => row.logical_path === ".") ?.format_metadata ?? null; const rootMetadata = source.rootMetadata; - const includeCoverage = + // Producer traversal coverage is the owner's fact for every format; missing + // coverage would read as unknown, so every view carries it inline. + const sourceCoverage = { source_coverage: inventory.coverage }; + const includeHashCounts = view.kind === "inventory" || inventory.manifest.root_format === "godot-pck" || inventory.manifest.root_format === "pyinstaller"; - const sourceCoverage = includeCoverage - ? { source_coverage: inventory.coverage } - : {}; - const counts = - includeCoverage || view.kind === "summary" - ? inventoryCounts(source) - : undefined; + const counts = includeHashCounts ? inventoryCounts(source) : undefined; const limitations = uniqueSorted([ ...parent.limitations, ...inventory.limitations, @@ -272,13 +269,12 @@ export const projectArtifactInventoryView = ( limitations, unknowns: [ "Inventory does not establish application runtime behavior or complete format-specific analysis.", - ...(includeCoverage && inventory.coverage.status === "unknown" + ...(inventory.coverage.status === "unknown" ? [ "Producer traversal coverage is unknown; missing matches do not prove source absence.", ] : []), - ...(includeCoverage && - counts !== undefined && + ...(counts !== undefined && (counts.hashes.unavailable > 0 || counts.hashes["not-hashed"] > 0) ? [ "Content hashes are unknown for some recorded files; missing node IDs do not prove content absence.", @@ -325,7 +321,7 @@ export const projectArtifactInventoryView = ( ? {} : { metadata: counts.kinds.metadata }), }, - ...(includeCoverage + ...(includeHashCounts ? { available_hash_status_counts: counts.hashes } : {}), source_complete: inventory.complete, diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts index 95620c4330..d95fe9f169 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -427,10 +427,13 @@ const branch = ( }; }; +// A completed substep establishes only the coverage its producer examined, so +// unknown inventory traversal keeps the inspection partial, never complete. const inspectionCoverage = ( - _inventory: ArtifactInventoryResult, + inventory: ArtifactInventoryResult, omissions: Readonly>, ): ArtifactInspectionResult["coverage"]["status"] => { if (Object.values(omissions).some((count) => count > 0)) return "truncated"; + if (inventory.coverage.status !== "complete") return "partial"; return "complete-within-substeps"; }; diff --git a/tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts b/tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts new file mode 100644 index 0000000000..f2ab203a66 --- /dev/null +++ b/tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts @@ -0,0 +1,130 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { createArtifactBinarySession } from "../../../src/composition/binary.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const TRAVERSAL_UNKNOWN = + "Producer traversal coverage is unknown; missing matches do not prove source absence."; + +const writeBundle = async (withFifo: boolean): Promise => { + const root = await createTestTempDirectory("rea-inspection-coverage-"); + const bundle = join(root, "Fixture.app"); + await mkdir(bundle); + await writeFile( + join(bundle, "Info.plist"), + "CFBundleExecutablemain.js", + ); + await writeFile(join(bundle, "main.js"), "export const value = 1;\n"); + if (withFifo) execFileSync("mkfifo", [join(bundle, "omitted.fifo")]); + return bundle; +}; + +const connect = async () => { + const session = await createArtifactBinarySession(process.env); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "inspection-coverage", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const definition = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_analysis_view", + ); + expect(definition?.outputSchema).toBeDefined(); + const outputSchema: Record = definition!.outputSchema!; + const viewOutput = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(outputSchema); + return { client, server, viewOutput }; +}; + +describe.skipIf(process.platform === "win32")( + "inspect_artifact traversal coverage", + () => { + it.each([ + { + withFifo: true, + status: "partial", + sourceCoverage: { scope: "container-entries", status: "unknown" }, + }, + { + withFifo: false, + status: "complete-within-substeps", + sourceCoverage: { scope: "container-entries", status: "complete" }, + }, + ] as const)( + "derives inspection and summary coverage from inventory traversal (FIFO: $withFifo)", + async ({ withFifo, status, sourceCoverage }) => { + const path = await writeBundle(withFifo); + const { client, server, viewOutput } = await connect(); + try { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const parent = parseEvidence(inspected.structuredContent); + const inspection = artifactInspectionResultSchema.parse( + parent.normalized_result, + ); + const inventory = inspection.substeps[0]!.evidence.normalized_result; + expect(inventory.coverage).toEqual(sourceCoverage); + expect(inspection.coverage.status).toBe(status); + + const summaries = [ + await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "summary" }, + }, + }), + await client.callTool({ + name: "inspect_artifact", + arguments: { detail: "summary" }, + }), + ]; + for (const reply of summaries) { + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + viewOutput(reply.structuredContent), + JSON.stringify(viewOutput.errors), + ).toBe(true); + const view = analysisViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + if (view.kind !== "summary" || !("source_complete" in view.summary)) + throw new Error("Expected an artifact inventory summary"); + expect(view.summary.source_complete).toBe(true); + expect(view.summary.source_coverage).toEqual(sourceCoverage); + expect(view.summary.inspection?.coverage.status).toBe(status); + expect(view.unknowns.includes(TRAVERSAL_UNKNOWN)).toBe(withFifo); + } + } finally { + await client.close(); + await server.close(); + } + }, + 30_000, + ); + }, +); From 5b014a739e64711261275ffd1fce7716916ab617 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 05:06:11 -0700 Subject: [PATCH 12/29] fix(adb): report unrecognized package rows from resolve-adb-packages (#5874) resolve_adb_packages marked coverage partial when pm list packages printed unrecognized rows, but dropped the rows themselves and could still emit a limitation describing an unparsed_lines field the result never carried. ADB parsers now own unrecognized-line facts as a bounded sample (first ten) plus the exact total, and a single provider projection derives unparsed_lines, coverage, and the truncation limitation from those facts for every parsed listing and the package search. The resolver result schema now carries unparsed_lines, matching list_adb_packages through CLI and MCP. Fixes #5843 Co-authored-by: Claude Opus 5.5 --- docs/adb-device-analysis.md | 5 +- src/adb/AdbDeviceOutput.test.ts | 50 ++++--- src/adb/AdbDeviceOutput.ts | 36 ++--- src/adb/AdbDirectoryListing.ts | 17 ++- src/adb/AdbProvider.test.ts | 1 + src/adb/AdbProvider.ts | 131 ++++++++---------- src/adb/AdbShellOutput.ts | 32 +++-- src/adb/AdbUnparsedLines.ts | 27 ++++ src/domain/adb/adbDeviceAnalysis.ts | 1 + .../adbPackageInventoryCoverage.test.ts | 104 ++++++++++++++ tests/boundary/mcp/adbMcp.test.ts | 1 + .../process/adbDirectoryListing.test.ts | 2 +- 12 files changed, 280 insertions(+), 127 deletions(-) create mode 100644 src/adb/AdbUnparsedLines.ts create mode 100644 tests/acceptance/applications/adbPackageInventoryCoverage.test.ts diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 80d5bd99fd..9017317a5a 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -162,8 +162,9 @@ and during shutdown. APKs keep other names report the `unknown` role rather than guessing. - **Partial results keep facts.** Failed individual pulls return successful artifacts with `coverage: "partial"` and per-file `failures`. Listings - that contain unrecognized lines report them as `unparsed_lines` and mark - partial coverage instead of dropping rows. + and package searches that contain unrecognized lines report the first ten + as `unparsed_lines`, state the exact total in a limitation when more were + seen, and mark partial coverage instead of dropping rows. - **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and `uninstall_adb_package` declare `mutatesTarget` and never run implicitly: pushes check for existing device files before transfer and refuse them diff --git a/src/adb/AdbDeviceOutput.test.ts b/src/adb/AdbDeviceOutput.test.ts index 17dec05c38..cd56b7bcf1 100644 --- a/src/adb/AdbDeviceOutput.test.ts +++ b/src/adb/AdbDeviceOutput.test.ts @@ -18,7 +18,7 @@ describe("parseAdbDevicesOutput", () => { ].join("\n"); const { devices, unparsedLines, daemonStarted } = parseAdbDevicesOutput(output); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(daemonStarted).toBe(false); expect(devices).toEqual([ { @@ -88,7 +88,7 @@ describe("ADB daemon startup notices", () => { stderr, ); expect(daemonStarted).toBe(true); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(devices).toHaveLength(1); }); @@ -98,7 +98,7 @@ describe("ADB daemon startup notices", () => { "* daemon started successfully *\r\n", ); expect(daemonStarted).toBe(true); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); }); it("does not treat a daemon startup error containing the success phrase as success", () => { @@ -107,7 +107,7 @@ describe("ADB daemon startup notices", () => { "* daemon not started successfully\r\n", ); expect(daemonStarted).toBe(false); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); }); it("ignores non-startup lines and device strings on stderr", () => { @@ -126,7 +126,7 @@ describe("ADB daemon startup notices", () => { stderr, ); expect(daemonStarted).toBe(false); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(devices).toHaveLength(1); expect(devices[0]?.serial).toBe("emulator-5554"); }); @@ -158,11 +158,14 @@ describe("ADB device states", () => { expect(devices.map(({ serial, state }) => [serial, state])).toEqual([ ["serial-no-permission", "no permissions"], ]); - expect(unparsedLines).toEqual([ - "serial-bad-device deviceXYZ ", - "serial-bad-offline offline-ish", - "serial-bad-auth unauthorizedXYZ", - ]); + expect(unparsedLines).toEqual({ + sample: [ + "serial-bad-device deviceXYZ ", + "serial-bad-offline offline-ish", + "serial-bad-auth unauthorizedXYZ", + ], + total: 3, + }); }); it("returns unrecognized lines instead of dropping them", () => { @@ -172,7 +175,10 @@ describe("ADB device states", () => { "", ].join("\n"); const { unparsedLines } = parseAdbDevicesOutput(output); - expect(unparsedLines).toEqual(["this is not a device line"]); + expect(unparsedLines).toEqual({ + sample: ["this is not a device line"], + total: 1, + }); }); it("reports a non-numeric transport_id as unknown", () => { @@ -259,7 +265,7 @@ describe("parsePackageListOutput", () => { "", ].join("\n"); const { packages, unparsedLines } = parsePackageListOutput(output); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(packages).toEqual([ { package_name: "com.example", @@ -278,7 +284,19 @@ describe("parsePackageListOutput", () => { "package:/data/app/x/base.apk=com.ok\n garbage line \n", ); expect(packages).toHaveLength(1); - expect(unparsedLines).toEqual([" garbage line "]); + expect(unparsedLines).toEqual({ sample: [" garbage line "], total: 1 }); + }); + + it("samples the first ten unrecognized lines and counts every one", () => { + const rows = Array.from( + { length: 11 }, + (_, index) => `row ${String(index + 1)}`, + ); + const { packages, unparsedLines } = parsePackageListOutput( + ["package:/data/app/x/base.apk=com.ok", ...rows].join("\n"), + ); + expect(packages).toHaveLength(1); + expect(unparsedLines).toEqual({ sample: rows.slice(0, 10), total: 11 }); }); }); @@ -291,7 +309,7 @@ describe("parsePmPathOutput", () => { "", ].join("\n"); const { paths, unparsedLines } = parsePmPathOutput(output); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(paths).toEqual([ { device_path: "/data/app/~~a==/com.example.b==/base.apk", @@ -334,7 +352,7 @@ describe("parsePmPathOutput", () => { }, ]); expect(rejectedPaths).toEqual([]); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); }); it("rejects an empty final component without normalizing the source", () => { @@ -349,6 +367,6 @@ describe("parsePmPathOutput", () => { it("retains malformed lines including surrounding whitespace", () => { expect( parsePmPathOutput(" package:/data/app/pkg/base.apk \n").unparsedLines, - ).toEqual([" package:/data/app/pkg/base.apk "]); + ).toEqual({ sample: [" package:/data/app/pkg/base.apk "], total: 1 }); }); }); diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index 2691b57d1d..ec7be399cf 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -2,14 +2,18 @@ * Parsers for the exact adb and Package Manager output formats REA consumes. * * Each parser preserves what the tool actually printed: unparseable lines are - * retained rather than silently dropped, and derived classifications carry - * their evidence basis. + * sampled and counted rather than silently dropped, and derived + * classifications carry their evidence basis. */ import { adbHostFileName, type AdbHostFileNameReason, } from "./AdbHostFileName.js"; +import { + collectUnparsedLines, + type AdbUnparsedLines, +} from "./AdbUnparsedLines.js"; import { ADB_DEVICE_STATES, type AdbDeviceState, @@ -44,18 +48,18 @@ const metadataValue = (remainder: string, key: string): string | null => { /** * Parse `adb devices -l` device lines. The banner, blank lines, and the * daemon startup notices adb prints on first use are recognized separately; - * every other unrecognized line is returned for honest reporting. + * every other unrecognized line is sampled and counted for honest reporting. */ export const parseAdbDevicesOutput = ( stdout: string, stderr = "", ): { devices: readonly AdbDeviceEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; daemonStarted: boolean; } => { const devices: AdbDeviceEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); let daemonStarted = false; for (const rawLine of stdout.split(/\r?\n/u)) { const line = normalizeAdbOutputLine(rawLine); @@ -74,7 +78,7 @@ export const parseAdbDevicesOutput = ( return next === undefined || /\s/u.test(next); }); if (match === null || state === undefined) { - if (rawLine.trim() !== "") unparsedLines.push(rawLine); + if (rawLine.trim() !== "") unparsed.add(rawLine); continue; } const serial = match[1]!; @@ -122,7 +126,7 @@ export const parseAdbDevicesOutput = ( } } } - return { devices, unparsedLines, daemonStarted }; + return { devices, unparsedLines: unparsed.facts(), daemonStarted }; }; /** @@ -184,21 +188,21 @@ export interface AdbPackageEntry { const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u; -/** Parse `pm list packages -f` lines, retaining unrecognized lines. */ +/** Parse `pm list packages -f` lines, sampling and counting unrecognized lines. */ export const parsePackageListOutput = ( stdout: string, ): { packages: readonly AdbPackageEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const packages: AdbPackageEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trim(); if (line === "") continue; const match = PACKAGE_LINE.exec(line); if (match === null) { - unparsedLines.push(rawLine); + unparsed.add(rawLine); continue; } packages.push({ @@ -206,7 +210,7 @@ export const parsePackageListOutput = ( base_apk_device_path: match[1]!, }); } - return { packages, unparsedLines }; + return { packages, unparsedLines: unparsed.facts() }; }; /** Quote raw device paths for prose without allowing bidi or invisible text. */ @@ -248,19 +252,19 @@ export const parsePmPathOutput = ( device_path: string; reason: AdbHostFileNameReason; }[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const paths: AdbPackagePathEntry[] = []; const rejectedPaths: { device_path: string; reason: AdbHostFileNameReason; }[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { if (rawLine === "") continue; const match = /^package:(.*)$/u.exec(rawLine); if (match === null) { - unparsedLines.push(rawLine); + unparsed.add(rawLine); continue; } const devicePath = match[1]!; @@ -284,5 +288,5 @@ export const parsePmPathOutput = ( : "unknown", }); } - return { paths, rejectedPaths, unparsedLines }; + return { paths, rejectedPaths, unparsedLines: unparsed.facts() }; }; diff --git a/src/adb/AdbDirectoryListing.ts b/src/adb/AdbDirectoryListing.ts index a8c055ad6f..6499f199f6 100644 --- a/src/adb/AdbDirectoryListing.ts +++ b/src/adb/AdbDirectoryListing.ts @@ -1,5 +1,10 @@ import { isUtf8 } from "node:buffer"; +import { + collectUnparsedLines, + type AdbUnparsedLines, +} from "./AdbUnparsedLines.js"; + interface AdbDirectoryEntry { readonly name: string; readonly kind: "directory" | "file" | "symlink" | "other"; @@ -71,35 +76,35 @@ export const parseAdbDirectoryListing = ( stdout: Buffer, ): { entries: readonly AdbDirectoryEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const entries: AdbDirectoryEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); let offset = 0; while (offset < stdout.length) { const metadataEnd = stdout.indexOf(0, offset); const nameEnd = metadataEnd < 0 ? -1 : stdout.indexOf(0, metadataEnd + 1); const targetEnd = nameEnd < 0 ? -1 : stdout.indexOf(0, nameEnd + 1); if (targetEnd < 0) { - unparsedLines.push(describeUnparsedRecord(stdout.subarray(offset))); + unparsed.add(describeUnparsedRecord(stdout.subarray(offset))); break; } const rawRecord = stdout.subarray(offset, targetEnd + 1); if (!isUtf8(rawRecord)) { - unparsedLines.push(describeUnparsedRecord(rawRecord)); + unparsed.add(describeUnparsedRecord(rawRecord)); } else { const entry = parseEntry( stdout.toString("utf8", offset, metadataEnd), stdout.toString("utf8", metadataEnd + 1, nameEnd), stdout.toString("utf8", nameEnd + 1, targetEnd), ); - if (entry === null) unparsedLines.push(rawRecord.toString("utf8")); + if (entry === null) unparsed.add(rawRecord.toString("utf8")); else entries.push(entry); } offset = targetEnd + 1; } - return { entries, unparsedLines }; + return { entries, unparsedLines: unparsed.facts() }; }; const describeUnparsedRecord = (record: Buffer): string => diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 59085a5239..23586da063 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1617,6 +1617,7 @@ it("resolves packages by case-insensitive substring", async () => { }, ]); expect(result.exact_match).toBe(false); + expect(result.unparsed_lines).toEqual([]); expect(result.coverage).toBe("complete"); }); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 677aedf379..6a04dcf991 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -121,6 +121,7 @@ import { parseWmDensityOutput, parseWmSizeOutput, } from "./AdbShellOutput.js"; +import type { AdbUnparsedLines } from "./AdbUnparsedLines.js"; /** Stable identity for the ADB device-acquisition provider. */ export const ADB_PROVIDER_IDENTITY = { @@ -152,7 +153,6 @@ const SCREEN_TIMEOUT_MS = 60_000; const SCREEN_MAX_OUTPUT_BYTES = 32 * 1024 * 1024; const BUGREPORT_TIMEOUT_MS = 600_000; const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024; -const UNPARSED_LINES_REPORTED = 10; /** Carry the exact digest owner through caller-specific partial-result mapping. */ class AdbDigestFailure extends Error { @@ -381,23 +381,35 @@ const clientIdentityResult = ( type ClientIdentity = ReturnType; type ProviderIdentity = { id: string; name: string; version: string | null }; -const reported = ( - unparsedLines: readonly string[], - coverage: "complete" | "partial", -) => ({ - unparsed_lines: unparsedLines.slice(0, UNPARSED_LINES_REPORTED), - coverage: - coverage === "partial" || unparsedLines.length > 0 ? "partial" : "complete", -}); - -const unparsedOverflowLimitation = ( - unparsedLines: readonly string[], -): string[] => - unparsedLines.length > UNPARSED_LINES_REPORTED - ? [ - `unparsed_lines reports the first ${String(UNPARSED_LINES_REPORTED)} of ${String(unparsedLines.length)} unrecognized lines`, - ] - : []; +/** + * Build a parsed-listing execution whose `unparsed_lines`, coverage, and + * truncation limitation all derive from the parser's unrecognized-line facts, + * so the limitation can never describe a field the result does not carry. + */ +const parsedListingExecution = ( + result: Readonly>, + unparsed: AdbUnparsedLines, + provider: ProviderIdentity, + limitations: readonly string[] = [], +) => + createAnalysisExecution( + { + ...result, + unparsed_lines: unparsed.sample, + coverage: unparsed.total > 0 ? "partial" : "complete", + }, + provider, + { + limitations: [ + ...limitations, + ...(unparsed.total > unparsed.sample.length + ? [ + `unparsed_lines reports the first ${String(unparsed.sample.length)} of ${String(unparsed.total)} unrecognized lines`, + ] + : []), + ], + }, + ); /** Materialize the caller's output directory, refusing existing files inside. */ const prepareOutputDirectory = async ( @@ -1355,10 +1367,10 @@ export class AdbProvider { ran.value.stdout, ran.value.stderr, ); - if (unparsedLines.length > 0) + if (unparsedLines.total > 0) return err( new AnalysisProtocolError( - `adb devices reported an unrecognized line: ${unparsedLines[0]}`, + `adb devices reported an unrecognized line: ${unparsedLines.sample[0]!}`, ), ); return ok( @@ -1435,21 +1447,18 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( + parsedListingExecution( { client, serial: request.input.serial, scope: request.input.scope, packages, - ...reported(unparsedLines, "complete"), }, + unparsedLines, provider, - { - limitations: [ - "base_apk_device_path is a device filesystem path, not a host-local path", - ...unparsedOverflowLimitation(unparsedLines), - ], - }, + [ + "base_apk_device_path is a device filesystem path, not a host-local path", + ], ), ); } @@ -1603,16 +1612,10 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( - { - client, - serial: request.input.serial, - columns, - processes, - ...reported(unparsedLines, "complete"), - }, + parsedListingExecution( + { client, serial: request.input.serial, columns, processes }, + unparsedLines, provider, - { limitations: unparsedOverflowLimitation(unparsedLines) }, ), ); } @@ -1642,21 +1645,18 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( + parsedListingExecution( { client, serial: request.input.serial, device_path: request.input.device_path, entries, - ...reported(unparsedLines, "complete"), }, + unparsedLines, provider, - { - limitations: [ - "Entry kinds derive from stat permission mode; directory contents and link targets may change during capture", - ...unparsedOverflowLimitation(unparsedLines), - ], - }, + [ + "Entry kinds derive from stat permission mode; directory contents and link targets may change during capture", + ], ), ); } @@ -1670,15 +1670,10 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( - { - client, - serial: request.input.serial, - features, - ...reported(unparsedLines, "complete"), - }, + parsedListingExecution( + { client, serial: request.input.serial, features }, + unparsedLines, provider, - { limitations: unparsedOverflowLimitation(unparsedLines) }, ), ); } @@ -1692,15 +1687,10 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( - { - client, - serial: request.input.serial, - services, - ...reported(unparsedLines, "complete"), - }, + parsedListingExecution( + { client, serial: request.input.serial, services }, + unparsedLines, provider, - { limitations: unparsedOverflowLimitation(unparsedLines) }, ), ); } @@ -1830,7 +1820,7 @@ export class AdbProvider { entry.package_name.toLowerCase().includes(needle), ); return ok( - createAnalysisExecution( + parsedListingExecution( { client, serial: request.input.serial, @@ -1839,15 +1829,12 @@ export class AdbProvider { exact_match: matches.length === 1 && matches[0]!.package_name.toLowerCase() === needle, - coverage: unparsedLines.length > 0 ? "partial" : "complete", }, + unparsedLines, provider, - { - limitations: [ - "Matching is a case-insensitive substring over package names; application display labels are not part of pm output", - ...unparsedOverflowLimitation(unparsedLines), - ], - }, + [ + "Matching is a case-insensitive substring over package names; application display labels are not part of pm output", + ], ), ); } @@ -1952,7 +1939,7 @@ export class AdbProvider { }), ); } - if (parsed.paths.length === 0 && parsed.unparsedLines.length === 0) + if (parsed.paths.length === 0 && parsed.unparsedLines.total === 0) return err( new AnalysisUnsupportedTargetError( operation, @@ -1960,10 +1947,10 @@ export class AdbProvider { `Package Manager reported no APK paths for ${packageName}; it may not be installed for user 0`, ), ); - if (parsed.unparsedLines.length > 0) + if (parsed.unparsedLines.total > 0) return err( new AnalysisProtocolError( - `pm path reported an unrecognized line: ${parsed.unparsedLines[0]}`, + `pm path reported an unrecognized line: ${parsed.unparsedLines.sample[0]!}`, { capturedOutput }, ), ); diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index d5241c9686..8cbbf8095d 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -2,13 +2,17 @@ import type { AdbWindowFocusContext, AdbWindowFocusObservation, } from "../domain/adb/adbDeviceAnalysis.js"; +import { + collectUnparsedLines, + type AdbUnparsedLines, +} from "./AdbUnparsedLines.js"; /** * Parsers for fixed observation commands REA runs through `adb shell`. * * Formats follow toybox output on Android 8+ (verified against an Android 14 - * emulator). Unparseable lines are retained rather than dropped so callers - * can see the format drift instead of silently losing rows. + * emulator). Unparseable lines are sampled and counted rather than dropped so + * callers can see the format drift instead of silently losing rows. */ export interface AdbProcessEntry { @@ -29,10 +33,10 @@ export const parseProcessListOutput = ( ): { columns: string | null; processes: readonly AdbProcessEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const processes: AdbProcessEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); let columns: string | null = null; for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trimEnd(); @@ -43,7 +47,7 @@ export const parseProcessListOutput = ( } const match = PROCESS_LINE.exec(line); if (match === null) { - unparsedLines.push(line); + unparsed.add(line); continue; } processes.push({ @@ -55,7 +59,7 @@ export const parseProcessListOutput = ( name: match[9]!.trim(), }); } - return { columns, processes, unparsedLines }; + return { columns, processes, unparsedLines: unparsed.facts() }; }; export interface AdbFeatureEntry { @@ -73,16 +77,16 @@ export const parseFeatureListOutput = ( stdout: string, ): { features: readonly AdbFeatureEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const features: AdbFeatureEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trim(); if (line === "") continue; const match = FEATURE_LINE.exec(line); if (match === null) { - unparsedLines.push(line); + unparsed.add(line); continue; } features.push({ @@ -90,7 +94,7 @@ export const parseFeatureListOutput = ( version: match[2] === undefined ? null : parseFeatureVersion(match[2]!), }); } - return { features, unparsedLines }; + return { features, unparsedLines: unparsed.facts() }; }; export interface AdbServiceEntry { @@ -105,17 +109,17 @@ export const parseServiceListOutput = ( stdout: string, ): { services: readonly AdbServiceEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const services: AdbServiceEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trimEnd(); if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim())) continue; const match = SERVICE_LINE.exec(line); if (match === null) { - if (line.trim() !== "") unparsedLines.push(line.trim()); + if (line.trim() !== "") unparsed.add(line.trim()); continue; } services.push({ @@ -123,7 +127,7 @@ export const parseServiceListOutput = ( interface: match[2] === "" ? null : match[2]!, }); } - return { services, unparsedLines }; + return { services, unparsedLines: unparsed.facts() }; }; const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u; diff --git a/src/adb/AdbUnparsedLines.ts b/src/adb/AdbUnparsedLines.ts new file mode 100644 index 0000000000..adaabf3d57 --- /dev/null +++ b/src/adb/AdbUnparsedLines.ts @@ -0,0 +1,27 @@ +/** Most unrecognized output lines a parser retains as source evidence. */ +const UNPARSED_LINE_SAMPLE_LIMIT = 10; + +/** + * Unrecognized output lines one parser observed: the first lines verbatim, up + * to {@link UNPARSED_LINE_SAMPLE_LIMIT}, and the exact number observed. + */ +export interface AdbUnparsedLines { + readonly sample: readonly string[]; + readonly total: number; +} + +/** Accumulate unrecognized lines without retaining more than the sample. */ +export const collectUnparsedLines = (): { + add: (line: string) => void; + facts: () => AdbUnparsedLines; +} => { + const sample: string[] = []; + let total = 0; + return { + add: (line) => { + total += 1; + if (sample.length < UNPARSED_LINE_SAMPLE_LIMIT) sample.push(line); + }, + facts: () => ({ sample, total }), + }; +}; diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 07f7b863f5..ec773b7596 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -731,6 +731,7 @@ export const adbResultSchemas = { }), ), exact_match: z.boolean(), + unparsed_lines: z.array(z.string().min(1)), coverage: z.enum(["complete", "partial"]), }), install_adb_package: z.strictObject({ diff --git a/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts b/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts new file mode 100644 index 0000000000..59429c7e46 --- /dev/null +++ b/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts @@ -0,0 +1,104 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const PACKAGE_LINE = "package:/data/app/example/base.apk=com.example.app"; +const unrecognized = (count: number) => + Array.from( + { length: count }, + (_, index) => `unrecognized package row ${String(index + 1)}`, + ); + +cliTest.skipIf(process.platform === "win32")( + "reports unrecognized package rows through package search and listing via CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-adb-package-coverage-"); + const binary = join(root, "adb"); + const inventory = join(root, "packages.txt"); + await writeFile( + binary, + `#!${process.execPath} +const args = process.argv.slice(2); +if (args[0] === 'version') { + console.log('Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture\\nInstalled as ' + process.argv[1]); + process.exit(0); +} +if (args.join(' ') === '-s emulator-5554 shell pm list packages -f') { + process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(inventory)})); + process.exit(0); +} +process.exit(91); +`, + { mode: 0o700 }, + ); + const environment = { + HOME: root, + USERPROFILE: root, + REA_ADB_PATH: binary, + REA_LOG_LEVEL: "silent", + }; + const { call } = await connectLocalToolsMcp(environment); + + for (const count of [0, 1, 11]) { + const rows = unrecognized(count); + await writeFile(inventory, [PACKAGE_LINE, ...rows, ""].join("\n")); + const expected = { + unparsed_lines: rows.slice(0, 10), + coverage: count === 0 ? "complete" : "partial", + }; + const truncation = + "unparsed_lines reports the first 10 of 11 unrecognized lines"; + + for (const [command, operation, tail] of [ + ["resolve-adb-packages", "resolve_adb_packages", ["example"]], + ["list-adb-packages", "list_adb_packages", []], + ] as const) { + const output = await cli.run({ + arguments: [command, "emulator-5554", ...tail, "--json"], + environment, + }); + expect(output.exitCode, output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + expect( + evidence.normalized_result, + `${command} ${String(count)}`, + ).toMatchObject(expected); + if (count === 11) expect(evidence.limitations).toContain(truncation); + else + expect(evidence.limitations).not.toContain( + expect.stringContaining("unparsed_lines"), + ); + + if (operation !== "resolve_adb_packages") continue; + expect(evidence.normalized_result).toMatchObject({ + matches: [ + { + package_name: "com.example.app", + base_apk_device_path: "/data/app/example/base.apk", + }, + ], + exact_match: false, + }); + const reply = await call(operation, { + serial: "emulator-5554", + query: "example", + }); + expect(reply.isError).not.toBe(true); + const structured = toolContract(operation).outputSchema.parse( + reply.structuredContent, + ); + expect(structured.normalized_result).toEqual( + evidence.normalized_result, + ); + expect(structured.limitations).toEqual(evidence.limitations); + } + } + }, +); diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index bb3bb2330e..3ad6b69ede 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -242,6 +242,7 @@ const results: Readonly> = { }, ], exact_match: true, + unparsed_lines: [], coverage: "complete", }, install_adb_package: { diff --git a/tests/boundary/process/adbDirectoryListing.test.ts b/tests/boundary/process/adbDirectoryListing.test.ts index e060144dbe..8d95f689be 100644 --- a/tests/boundary/process/adbDirectoryListing.test.ts +++ b/tests/boundary/process/adbDirectoryListing.test.ts @@ -231,7 +231,7 @@ itPosix( }); expect(parseAdbDirectoryListing(output)).toEqual({ entries: [], - unparsedLines: [], + unparsedLines: { sample: [], total: 0 }, }); expect((await readFile(calls, "utf8")).trim()).toBe("3"); }, From 35d6d5bb0acaf8cd99399c1167dfef309d6add19 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 05:06:28 -0700 Subject: [PATCH 13/29] fix(artifacts): admit ZIP archives after unclaimed leading bytes (#5803) * fix(artifacts): admit ZIP archives after unclaimed leading bytes ZIP recognition required the archive at byte 0, so a ZIP behind a script or self-extractor stub was refused as an unsupported binary format although standard readers accept it. Target resolution and root inventory now locate a trailing single-disk ZIP from its end record, central directory and first local header, whether member offsets include the leading bytes or not. Leading bytes any executable header family claims keep their executable route. Fixes #5732 Co-Authored-By: Claude Opus 5.5 * fix(artifacts): share the root ZIP family across recognition paths The trailing-ZIP fallback named a stub-prefixed root `.app` archive `zip`, while root inventory and the leading-signature route name it `app-pack`. The root mapping now lives once in `zipRootPackageFormat` and both target resolution routes and root inventory use it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/application/BinaryTargetResolver.ts | 59 ++++++- src/artifacts/inventory/classify.ts | 25 ++- src/domain/binaryTarget.ts | 16 ++ src/domain/zipPackageFormat.ts | 87 +++++++++- .../artifactZipLeadingBytes.test.ts | 74 ++++++++ .../filesystem/artifactComparison.test.ts | 2 +- .../boundary/filesystem/zipDetection.test.ts | 160 ++++++++++++++++++ 7 files changed, 405 insertions(+), 18 deletions(-) create mode 100644 tests/acceptance/artifactZipLeadingBytes.test.ts diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 7b7f58afb0..e03dc1e620 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -41,12 +41,14 @@ import { import { hasGodotPckHeader } from "../domain/godotPckFormat.js"; import { hasZipSignature, + locateTrailingZipArchive, isZipPackageFormat, - zipPackageFormatForPath, + zipRootPackageFormat, } from "../domain/zipPackageFormat.js"; import { parseCodeViewData, + claimsExecutableHeader, parseExecutableHeader, parsePeRecord, peDebugDirectoryEntry, @@ -219,12 +221,32 @@ export const parseBinaryTarget = async ( hostArchitecture, signal, ); - if (!detected.ok) + if (!detected.ok) { + // Only bytes no other format claims may lead a located ZIP, so an + // executable with an appended archive keeps its executable route. + if ( + !(await claimsLeadingExecutableHeader( + handle, + metadata.size, + signal, + )) && + (await locateTrailingZipArchive(metadata.size, (position, length) => + readHandleRange(handle, position, length, signal), + )) !== undefined + ) + return ok({ + path, + sourcePath, + sha256: await sha256Handle(handle, path, metadata, signal), + kind: "archive", + format: zipRootPackageFormat(path), + }); return err( new BinaryTargetError(path, detected.error, { constraint: "file_format_rejected", }), ); + } return ok({ path, sourcePath, @@ -356,12 +378,8 @@ const detectArtifactFormat = async ( "WebAssembly v1 artifacts are not supported by the native opening route", { constraint: "wasm_requires_static_inspection" }, ); - if (hasZipSignature(magic.subarray(0, observed.bytesRead))) { - // The HarmonyOS App Pack suffix `.app` is a root-input concept; a nested - // ZIP member ending in `.app` keeps the generic zip classification. - if (lower.endsWith(".app")) return "app-pack"; - return zipPackageFormatForPath(lower) ?? "zip"; - } + if (hasZipSignature(magic.subarray(0, observed.bytesRead))) + return zipRootPackageFormat(lower); if (hasGodotPckHeader(magic.subarray(0, observed.bytesRead))) return "godot-pck"; // Binary plists, including keyed archives, often use other extensions. @@ -446,6 +464,31 @@ const isArchiveFormat = ( isZipPackageFormat(format) || ["asar", "godot-pck", "dmg", "pkg"].includes(format); +/** Whether the leading bytes begin any executable header family, valid or not. */ +const claimsLeadingExecutableHeader = async ( + handle: FileHandle, + size: number, + signal?: AbortSignal, +): Promise => + claimsExecutableHeader( + Buffer.from(await readHandleRange(handle, 0, Math.min(size, 4096), signal)), + size, + ); + +/** Read one bounded range, honoring cancellation around the host read. */ +const readHandleRange = async ( + handle: FileHandle, + position: number, + length: number, + signal?: AbortSignal, +): Promise => { + throwIfTargetResolutionCancelled(signal); + const bytes = Buffer.alloc(length); + const observed = await handle.read(bytes, 0, length, position); + throwIfTargetResolutionCancelled(signal); + return bytes.subarray(0, observed.bytesRead); +}; + const sha256Handle = async ( handle: FileHandle, path: string, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index c36d6e43df..0e20fe67cc 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -8,13 +8,14 @@ import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; import { containsPyInstallerArchive } from "../pyinstaller/CArchiveLayout.js"; import { classifyArtifactContent } from "./ArtifactGraphConstruction.js"; import { abortIfNeeded } from "../ArtifactHash.js"; +import { claimsExecutableHeader } from "../../domain/binaryTarget.js"; import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; import { hasZipSignature, + locateTrailingZipArchive, isZipPackageFormat, - zipPackageFormatForPath, - type ZipPackageFormat, + zipRootPackageFormat, } from "../../domain/zipPackageFormat.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js"; @@ -188,6 +189,20 @@ const classifyRootFormat = async ( }, archiveBytes, ).format; + // Only bytes no other format claims may lead a located ZIP, so an + // executable with an appended archive keeps its executable classification. + if ( + (format === "file" || format === "unknown") && + !claimsExecutableHeader(evidence.prefix, archiveBytes) && + (await locateTrailingZipArchive(archiveBytes, async (position, length) => { + abortIfNeeded(signal); + const bytes = Buffer.alloc(length); + const read = await handle.read(bytes, 0, length, position); + abortIfNeeded(signal); + return bytes.subarray(0, read.bytesRead); + })) !== undefined + ) + return zipRootPackageFormat(lower); if (["elf", "pe", "mach-o", "mach-o-universal"].includes(format)) { const packaged = await containsPyInstallerArchive( handle, @@ -243,12 +258,6 @@ const readRootFatSliceHeaders = async ( return { header, sliceHeadersByOffset }; }; -/** App Pack is a verified ZIP root role; nested .app members retain their observed format. */ -const zipRootPackageFormat = (lower: string): ZipPackageFormat => - lower.endsWith(".app") - ? "app-pack" - : (zipPackageFormatForPath(lower) ?? "zip"); - const hasKolyTrailer = async ( handle: FileHandle, size: number, diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 9b265f2786..406ff509ec 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -75,6 +75,22 @@ export type ExecutableMetadata = readonly codeView?: PeCodeViewRecord; }; +/** + * Whether leading bytes begin an executable header family that + * `parseExecutableHeader` interprets, whether or not that header is valid. + */ +export const claimsExecutableHeader = ( + bytes: Buffer, + fileSize = bytes.length, +): boolean => + (bytes.length >= 4 && + (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) || + [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes( + bytes.readUInt32BE(0), + ))) || + (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) || + readFatContainerHeader(bytes, fileSize).state !== "not-fat"; + /** * Parse supported executable headers without I/O. The caller supplies the host * architecture used for deterministic FAT Mach-O slice selection. diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 6d9fd4663b..26f8af7c16 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -26,6 +26,79 @@ export const hasZipSignature = (bytes: Uint8Array): boolean => (bytes[2] === 0x05 && bytes[3] === 0x06) || (bytes[2] === 0x07 && bytes[3] === 0x08)); +const END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const CENTRAL_DIRECTORY_HEADER = 0x02014b50; +const LOCAL_FILE_HEADER = 0x04034b50; +const END_RECORD_BYTES = 22; +const MAXIMUM_COMMENT_BYTES = 0xffff; +const CENTRAL_HEADER_BYTES = 46; + +/** Positional read of at most `length` bytes; a short result means end of file. */ +export type PositionalRead = ( + position: number, + length: number, +) => Promise; + +/** + * Locate a single-disk ZIP whose end-of-central-directory record ends the + * file after leading non-ZIP bytes, such as a self-extractor or script stub. + * Its central directory and first local header must resolve, whether member + * offsets include the leading bytes or were written before them. Zip64, + * split and empty archives are not located. Returns the leading byte count. + */ +export const locateTrailingZipArchive = async ( + size: number, + read: PositionalRead, +): Promise<{ readonly leadingBytes: number } | undefined> => { + if (!Number.isSafeInteger(size) || size < END_RECORD_BYTES) return undefined; + const tailStart = Math.max( + 0, + size - END_RECORD_BYTES - MAXIMUM_COMMENT_BYTES, + ); + const tail = Buffer.from(await read(tailStart, size - tailStart)); + if (tail.length !== size - tailStart) return undefined; + // The record's comment must end exactly at end of file. + let record = -1; + for (let at = tail.length - END_RECORD_BYTES; at >= 0; at -= 1) + if ( + tail.readUInt32LE(at) === END_OF_CENTRAL_DIRECTORY && + at + END_RECORD_BYTES + tail.readUInt16LE(at + 20) === tail.length + ) { + record = at; + break; + } + if (record < 0) return undefined; + const entriesOnDisk = tail.readUInt16LE(record + 8); + const directoryBytes = tail.readUInt32LE(record + 12); + const declaredDirectory = tail.readUInt32LE(record + 16); + if ( + tail.readUInt16LE(record + 4) !== 0 || + tail.readUInt16LE(record + 6) !== 0 || + entriesOnDisk === 0 || + entriesOnDisk === 0xffff || + entriesOnDisk !== tail.readUInt16LE(record + 10) || + directoryBytes === 0xffffffff || + declaredDirectory === 0xffffffff + ) + return undefined; + const directory = tailStart + record - directoryBytes; + // Offsets written before leading bytes were prepended are shifted by them. + const shift = directory - declaredDirectory; + if (directory < 0 || shift < 0) return undefined; + const header = Buffer.from(await read(directory, CENTRAL_HEADER_BYTES)); + if ( + header.length !== CENTRAL_HEADER_BYTES || + header.readUInt32LE(0) !== CENTRAL_DIRECTORY_HEADER + ) + return undefined; + const firstLocal = header.readUInt32LE(42) + shift; + if (firstLocal === 0 || firstLocal >= directory) return undefined; + const local = Buffer.from(await read(firstLocal, 4)); + return local.length === 4 && local.readUInt32LE(0) === LOCAL_FILE_HEADER + ? { leadingBytes: firstLocal } + : undefined; +}; + /** * Classify a ZIP-backed package by its complete lower-cased path suffix. * @@ -52,6 +125,18 @@ export const zipPackageFormatForPath = ( return undefined; }; +/** + * Classify a verified ZIP root input by its path. The HarmonyOS App Pack + * suffix `.app` applies only to root inputs; a nested ZIP member ending in + * `.app` keeps its observed format. + */ +export const zipRootPackageFormat = (path: string): ZipPackageFormat => { + const lower = path.toLowerCase(); + return lower.endsWith(".app") + ? "app-pack" + : (zipPackageFormatForPath(lower) ?? "zip"); +}; + /** Inventory lists entries, not all bytes surrounding the central directory. */ export const ZIP_NON_ENTRY_LIMITATION = - "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences."; + "ZIP inventory lists central-directory entries only. Non-entry bytes, including leading bytes before the first entry such as a self-extractor stub, an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences."; diff --git a/tests/acceptance/artifactZipLeadingBytes.test.ts b/tests/acceptance/artifactZipLeadingBytes.test.ts new file mode 100644 index 0000000000..4e3add055e --- /dev/null +++ b/tests/acceptance/artifactZipLeadingBytes.test.ts @@ -0,0 +1,74 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { ZIP_NON_ENTRY_LIMITATION } from "../../src/domain/zipPackageFormat.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "inspects and extracts a ZIP after an inert script stub", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-zip-script-stub-"); + const archivePath = join(root, "installer.sh"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("a.txt", new TextReader("hello world\n")); + await writer.add("d/b.txt", new TextReader("x\n")); + // A self-extractor stub prepended to an unchanged archive. + await writeFile( + archivePath, + Buffer.concat([Buffer.from("#!/bin/sh\nexit 0\n"), await writer.close()]), + ); + + const inspected = await cli.run({ + arguments: ["inspect-artifact", archivePath, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + expect(inspection.subject.root_format).toBe("zip"); + const inventoryEvidence = inspection.substeps[0]?.evidence; + if (inventoryEvidence === undefined) + throw new Error("CLI inspection did not retain ZIP inventory Evidence"); + const inventory = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + expect( + inventory.occurrences + .filter(({ entry_kind }) => entry_kind === "file") + .map(({ logical_path, hash_status }) => [logical_path, hash_status]), + ).toEqual([ + [".", "verified"], + ["a.txt", "verified"], + ["d/b.txt", "verified"], + ]); + expect(inventory.limitations).toContain(ZIP_NON_ENTRY_LIMITATION); + + const extracted = await cli.run({ + arguments: ["extract-artifact", archivePath, "--json"], + }); + expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0); + const extraction = artifactExtractionResultSchema.parse( + parseEvidence(extracted.json).normalized_result, + ); + try { + expect( + extraction.artifacts.map(({ relative_path }) => relative_path), + ).toEqual(["a.txt", "d/b.txt"]); + expect( + await readFile(join(extraction.output_root, "a.txt"), "utf8"), + ).toBe("hello world\n"); + } finally { + await rm(extraction.output_root, { force: true, recursive: true }); + } + }, +); diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index 1c3784efdd..b29d4aef6f 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -896,7 +896,7 @@ describe("ZIP non-entry coverage", () => { left.normalized_result, ); expect(inventory.limitations).toContain( - "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.", + "ZIP inventory lists central-directory entries only. Non-entry bytes, including leading bytes before the first entry such as a self-extractor stub, an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.", ); expect( inventory.occurrences.map(({ logical_path }) => logical_path).sort(), diff --git a/tests/boundary/filesystem/zipDetection.test.ts b/tests/boundary/filesystem/zipDetection.test.ts index f7c5d6f1bd..e5b7b09202 100644 --- a/tests/boundary/filesystem/zipDetection.test.ts +++ b/tests/boundary/filesystem/zipDetection.test.ts @@ -184,6 +184,166 @@ describe("nested container family", () => { ); }); +const SCRIPT_STUB = Buffer.from("#!/bin/sh\nexit 0\n"); + +const memberZip = async (): Promise => { + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("a.txt", new TextReader("hello world\n")); + await writer.add("d/b.txt", new TextReader("x\n")); + return Buffer.from(await writer.close()); +}; + +/** Prepend bytes; optionally shift stored offsets as `zip -A` does. */ +const withLeadingBytes = ( + zip: Buffer, + leading: Buffer, + adjustOffsets: boolean, +): Buffer => { + const bytes = Buffer.concat([leading, zip]); + if (!adjustOffsets) return bytes; + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + const directory = bytes.readUInt32LE(end + 16) + leading.length; + bytes.writeUInt32LE(directory, end + 16); + for (let at = directory; at < end;) { + bytes.writeUInt32LE(bytes.readUInt32LE(at + 42) + leading.length, at + 42); + at += + 46 + + bytes.readUInt16LE(at + 28) + + bytes.readUInt16LE(at + 30) + + bytes.readUInt16LE(at + 32); + } + return bytes; +}; + +/** Members by content-derived identity and verification state. */ +const members = async (path: string) => { + const inventory = await inventoryArtifact(path); + return ["a.txt", "d/b.txt"].map((member) => { + const occurrence = artifactOccurrenceAt(inventory, member); + return [member, occurrence.hash_status, occurrence.artifact_id]; + }); +}; + +describe("ZIP archives after leading bytes", () => { + it.each([ + { style: "offsets that include the leading bytes", adjust: true }, + { style: "offsets written before the bytes were prepended", adjust: false }, + ])( + "admits a ZIP after an unclaimed script stub with $style", + async ({ adjust }) => { + const root = await createTestTempDirectory("rea-zip-leading-"); + const plain = join(root, "plain.zip"); + const prefixed = join(root, "installer.bin"); + const zip = await memberZip(); + await writeFile(plain, zip); + await writeFile(prefixed, withLeadingBytes(zip, SCRIPT_STUB, adjust)); + + expect( + (await classifyAndHashRoot(prefixed, false, await lstat(prefixed))) + .format, + ).toBe("zip"); + expect(await parseBinaryTarget(prefixed)).toMatchObject({ + ok: true, + value: { kind: "archive", format: "zip" }, + }); + // Members are the same verified bytes as in the plain archive. + const expected = await members(plain); + expect(expected.map(([, status]) => status)).toEqual([ + "verified", + "verified", + ]); + expect(await members(prefixed)).toEqual(expected); + }, + ); + + it("keeps the root App Pack family for a ZIP after a script stub", async () => { + const root = await createTestTempDirectory("rea-zip-leading-app-pack-"); + const path = join(root, "Example.app"); + await writeFile( + path, + withLeadingBytes(await memberZip(), SCRIPT_STUB, false), + ); + expect( + (await classifyAndHashRoot(path, false, await lstat(path))).format, + ).toBe("app-pack"); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: true, + value: { kind: "archive", format: "app-pack" }, + }); + }); + + it.each([ + { family: "ELF", leading: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]) }, + { + family: "MZ", + leading: Buffer.concat([Buffer.from("MZ"), Buffer.alloc(62)]), + }, + { + family: "Mach-O", + leading: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0]), + }, + ])( + "keeps a $family-led file with an appended ZIP out of the ZIP route", + async ({ leading }) => { + const root = await createTestTempDirectory("rea-zip-claimed-lead-"); + const path = join(root, "program"); + await writeFile(path, withLeadingBytes(await memberZip(), leading, true)); + expect( + (await classifyAndHashRoot(path, false, await lstat(path))).format, + ).not.toBe("zip"); + expect(await parseBinaryTarget(path)).not.toMatchObject({ + ok: true, + value: { format: "zip" }, + }); + }, + ); + + it.each([ + { + shape: "bytes after the end record", + mutate: (bytes: Buffer) => Buffer.concat([bytes, Buffer.from("junk")]), + }, + { + shape: "a corrupt central directory header", + mutate: (bytes: Buffer) => { + bytes.write("XXXX", bytes.indexOf(Buffer.from([0x50, 0x4b, 1, 2]))); + return bytes; + }, + }, + { + shape: "a Zip64 directory offset", + mutate: (bytes: Buffer) => { + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 5, 6])); + bytes.writeUInt32LE(0xffffffff, end + 16); + return bytes; + }, + }, + { + shape: "an empty archive record", + mutate: (bytes: Buffer) => { + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 5, 6])); + bytes.writeUInt16LE(0, end + 8); + bytes.writeUInt16LE(0, end + 10); + return bytes; + }, + }, + ])("does not locate a trailing ZIP with $shape", async ({ mutate }) => { + const root = await createTestTempDirectory("rea-zip-leading-negative-"); + const path = join(root, "input.bin"); + await writeFile( + path, + mutate(withLeadingBytes(await memberZip(), SCRIPT_STUB, true)), + ); + expect( + (await classifyAndHashRoot(path, false, await lstat(path))).format, + ).toBe("file"); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: false, + error: { _tag: "BinaryTargetError" }, + }); + }); +}); + describe("package signature byte identity", () => { it.each([ { extension: "pkg", signature: "xar!", offset: 0, size: 4 }, From 705058f3e2df82503f1c601fc464aea7cbb626b8 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 05:06:50 -0700 Subject: [PATCH 14/29] fix(javascript): count one IPC observation per site-resolved channel (#5810) * fix(javascript): count one IPC observation per site-resolved channel A send with a local constant channel was recorded twice: once unresolved by the syntax pass and once resolved by wrapper substitution, because duplicates were keyed on the channel. A channel the call site determines now resolves that observation in place. A channel supplied through a wrapper argument stays a candidate beside the unresolved observation. Invocation identity now follows the call that supplies the wrapper's literal arguments, so one wrapper call reached through several entries is recorded once. Fixes #5710 Co-Authored-By: Claude Opus 5.5 * fix(javascript): keep wrapper invocations distinct while removing repeats Taking the nearest literal call as invocation identity collapsed separate outer calls that pass different handlers through one wrapper call. Entry identity is restored, and a finding is a repeat only when an identical finding was reached through the same trailing local calls. A registration replaces its unresolved source observation only when the site also determines its handler, so a parameter handler keeps the generic source registration beside per-invocation candidates. Co-Authored-By: Claude Opus 5.5 * fix(javascript): compare site-determined IPC handlers by value In-place resolution compared handler representations, so a constant channel with a named local handler (recorded as an identifier by the syntax pass and as its function by substitution) stayed counted twice. The handler is now site-determined when it evaluates to the same function in the site's lexical environment. A replaced observation also removes the syntax pass's unknown-channel contribution, so a fully resolved file is no longer reported as partial. Co-Authored-By: Claude Opus 5.5 * fix(javascript): keep closures over invocation values as distinct IPC handlers A handler node created in different wrapper activations may capture different values, so the same function node alone does not prove the call site determines the registration. Co-Authored-By: Claude Opus 5.5 * test(javascript): guard optional call paths in closure handler test Co-Authored-By: Claude Opus 5.5 * fix(javascript): decide IPC handler identity from activation captures A closure is one site-determined handler only when it reads no binding of a function this trace activated, such as a wrapper parameter or factory local. Unused bindings in the activation no longer split a known listener, and closures over outer-call values stay distinct when a shorter entry traced the same wrapper call. Co-Authored-By: Claude Opus 5.5 * fix(javascript): treat implicit captures and bundle modules precisely in IPC handler identity An arrow handler reading its enclosing function's arguments or this captures that activation. A bundle module factory runs once per module load, so its bindings are shared by every trace rather than per-invocation captures. Co-Authored-By: Claude Opus 5.5 * fix(javascript): attribute an arrow handler's inherited context to its owner An arrow inherits this and arguments from the nearest non-arrow function, so an activated arrow inside that owner does not make the handler depend on the traced call. Co-Authored-By: Claude Opus 5.5 * fix(javascript): skip suffix scans for per-invocation IPC handlers Check activation capture before scanning recorded call chains, keep the chains of capture-dependent handlers out of the index, append chains in place, and treat an arrow's inherited super and new.target as captures. Co-Authored-By: Claude Opus 5.5 * fix(javascript): supersede nested-entry IPC records and share module-init factories A shorter entry traced alone no longer swallows the outer calls that invoke the same wrapper sequence: the first fuller path takes over its record and separate outer calls stay distinct invocations. A factory activation called at module scope runs once per load, so its closure is shared unless the trace itself makes that call. The resolver now owns bundle module-factory recognition instead of an environment marker. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 10 +- .../electronWrappedCallPaths.test.ts | 318 +++++++++++++++++ .../javascript/electronWrappedCallPaths.ts | 337 +++++++++++++++++- .../javascript/javascriptSymbolicBindings.ts | 7 + .../electronIpcConstantChannelCli.test.ts | 76 ++++ 5 files changed, 735 insertions(+), 13 deletions(-) create mode 100644 tests/acceptance/applications/electronIpcConstantChannelCli.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 9d61a7b1df..c9d97e5927 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -371,7 +371,15 @@ Recognized literal module-table bootstraps also support bounded lexical substitution through module exports, import helper bodies, local wrapper calls, returned closures, object spreads, and callback parameters. Recovered Electron paths retain source ranges, module identities, literal channels and branch -conditions as static candidates. Unknown spreads, reassigned bindings, accessors, +conditions as static candidates. A channel the IPC call site itself +determines, such as a local constant, resolves that call's observation rather +than adding a second one. A channel or registration handler supplied through +a wrapper argument is a candidate beside the call's own unresolved +observation, because callers outside the traced paths may pass other values. +A wrapper call that is both its own entry and reached through another entry +is recorded once when both paths find the same facts; calls that supply +different handlers or channels stay distinct. +Unknown spreads, reassigned bindings, accessors, dynamic channels and unsupported syntax do not become exact implementations. This pass neither executes the bundle nor proves that runtime guards pass. An ambiguous or unresolved handler retains its channel registration but does diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index d52e8f57f6..a930fdce61 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -384,6 +384,324 @@ it("keeps a directly observed renderer operation singular when tracing its expos expect.objectContaining({ entry: "desktop.ping", channel: "ping" }), ); }); +it("resolves a site-determined constant channel in place of its dynamic observation", () => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const PING = "ping"; + contextBridge.exposeInMainWorld("desktop",{ping:()=>ipcRenderer.send(PING)}); + `); + expect(result.electron.ipc).toEqual([ + expect.objectContaining({ + side: "renderer", + operation: "send", + channel: "ping", + resolution_basis: "wrapper-substitution", + }), + ]); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ entry: "desktop.ping", channel: "ping" }), + ); + // The syntax pass's unknown channel is resolved, not left as partial. + expect(result.parse_status).toBe("complete"); +}); +it("resolves a site-determined registration whose handler is a named local", () => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const CH = "ch"; + const handler = () => {}; + contextBridge.exposeInMainWorld("desktop",{on:()=>ipcRenderer.on(CH, handler)}); + `); + expect(result.electron.ipc).toEqual([ + expect.objectContaining({ + side: "renderer", + mode: "listen", + channel: "ch", + handler_kind: "inline-function", + }), + ]); + expect(result.parse_status).toBe("complete"); +}); +it("keeps an argument-supplied channel unresolved beside its resolved candidates", () => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const forward = channel => ipcRenderer.send(channel); + contextBridge.exposeInMainWorld("desktop",{ + a:()=>forward("a"), + b:()=>forward("b"), + any:name=>forward(name), + }); + `); + const sends = result.electron.ipc.filter( + ({ side, operation }) => side === "renderer" && operation === "send", + ); + expect(sends.map(({ channel }) => channel).sort()).toEqual(["a", "b", null]); + expect( + new Set(sends.map(({ location }) => JSON.stringify(location))).size, + ).toBe(1); +}); +it.each([ + [ + "an exposed member", + `const forward = (channel, callback) => ipcRenderer.on(channel, callback); + contextBridge.exposeInMainWorld("desktop",{on:()=>forward("pong",()=>{})});`, + ], + [ + "an enclosing local call", + `const forward = (channel, callback) => ipcRenderer.on(channel, callback); + const notify = kind => forward("pong", () => {}); + notify("ready");`, + ], +])( + "keeps one invocation of a wrapper call also reached through %s", + (_, entry) => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + ${entry} + `); + expect( + result.electron.ipc.filter(({ channel }) => channel === "pong"), + ).toHaveLength(1); + }, +); +it("keeps distinct handlers that separate outer calls pass through one wrapper call", () => { + const result = analyzeJavaScriptStaticSource(` + const e = require("electron"); + function register(channel, handler) { e.ipcMain.on(channel, handler); } + function setup(tag, handler) { register("same", handler); } + setup("first", () => 1); + setup("second", () => 2); + `); + const registrations = result.electron.ipc.filter( + ({ channel }) => channel === "same", + ); + // The wrapper call traced alone has an unresolved handler; each outer call + // supplies its own concrete handler. + expect(registrations.map(({ handler_kind }) => handler_kind).sort()).toEqual([ + "dynamic-expression", + "inline-function", + "inline-function", + ]); + expect( + new Set( + registrations.map(({ handler_location }) => + JSON.stringify(handler_location), + ), + ).size, + ).toBe(3); +}); +it("keeps the source registration when only its channel is site-determined", () => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + const CH = "ch"; + function register(seed, handler) { ipcMain.on(CH, handler); } + register("seed", () => 1); + `); + expect( + result.electron.ipc.map(({ channel, handler_kind, resolution_basis }) => [ + channel, + handler_kind, + resolution_basis ?? "direct", + ]), + ).toEqual([ + [null, "identifier", "direct"], + ["ch", "inline-function", "wrapper-substitution"], + ]); +}); +it.each([ + [ + "a closure over a wrapper parameter", + `function register(seed) { + const handler = () => ipcRenderer.send(seed); + ipcRenderer.on(CH, handler); + }`, + ], + [ + "an arrow function reading its wrapper's arguments", + `function register(seed) { + const handler = () => arguments[0]; + ipcRenderer.on(CH, handler); + }`, + ], + [ + "an arrow function reading its wrapper's new.target", + `function register(seed) { + const handler = () => new.target; + ipcRenderer.on(CH, handler); + }`, + ], + [ + "a closure returned from a factory of a wrapper parameter", + `const make = value => () => value; + function register(seed) { + const handler = make(seed); + ipcRenderer.on(CH, handler); + }`, + ], +])("keeps distinct listeners when one handler node is %s", (_, wrapper) => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch", A = "a", B = "b"; + ${wrapper} + contextBridge.exposeInMainWorld("first", { on: () => register(A) }); + contextBridge.exposeInMainWorld("second", { on: () => register(B) }); + `); + // Each invocation installs its own closure; the direct source observation + // stays for callers this pass does not trace. + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([ + [null, "direct"], + ["ch", "wrapper-substitution"], + ["ch", "wrapper-substitution"], + ]); + expect( + (result.electron.call_paths ?? []) + .filter(({ channel }) => channel === "ch") + .map(({ entry }) => entry) + .sort(), + ).toEqual(["first.on", "second.on"]); +}); +it("resolves a bundle module's handler in place as one shared listener", () => { + const result = analyzeJavaScriptStaticSource( + bundle(`const CH = "ch"; + const handler = () => electron.ipcRenderer.send("reply"); + electron.contextBridge.exposeInMainWorld("api", { + on: () => electron.ipcRenderer.on(CH, handler), + });`), + ); + expect( + result.electron.ipc + .filter(({ mode, module_key }) => mode === "listen" && module_key === "9") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); +}); +it("resolves an arrow handler in place when its inherited this is module scope", () => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + const register = () => { + const handler = () => this; + ipcRenderer.on(CH, handler); + }; + contextBridge.exposeInMainWorld("api", { on: () => register() }); + `); + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); +}); +it("resolves a handler a module-level factory call created once in place", () => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + const CH = "ch"; + const make = () => { const value = Math.random(); return () => value; }; + const handler = make(); + function register(seed) { ipcMain.on(CH, handler); } + register("x"); + `); + expect( + result.electron.ipc.map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); + expect(result.parse_status).toBe("complete"); +}); +it("keeps separate outer calls that pass one shared handler through a wrapper", () => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + const copy = () => {}; + function register(ch, handler) { ipcMain.on(ch, handler); } + function setup(tag) { register("same", copy); } + setup("first"); + setup("second"); + `); + const registrations = result.electron.ipc.filter( + ({ channel }) => channel === "same", + ); + // The shorter entry traced the same wrapper call alone; both outer calls + // supersede it as distinct invocations. + expect(registrations).toHaveLength(2); + expect( + new Set( + registrations.map(({ invocation_location }) => + JSON.stringify(invocation_location), + ), + ).size, + ).toBe(2); +}); +it("keeps a parameterless factory's closure as a per-invocation handler", () => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + const make = () => { const value = Math.random(); return () => value; }; + function register() { + const handler = make(); + ipcRenderer.on(CH, handler); + } + contextBridge.exposeInMainWorld("api", { on: () => register() }); + `); + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ resolution_basis }) => resolution_basis ?? "direct"), + ).toEqual(["direct", "wrapper-substitution"]); +}); +it("resolves a module-level handler in place when the entry binds an unused parameter", () => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + const handler = () => {}; + contextBridge.exposeInMainWorld("api", { + on: unused => ipcRenderer.on(CH, handler), + }); + `); + expect(result.electron.ipc).toEqual([ + expect.objectContaining({ + mode: "listen", + channel: "ch", + resolution_basis: "wrapper-substitution", + }), + ]); + expect(result.parse_status).toBe("complete"); +}); +it.each([ + ["a named local", 'const handler = () => tag; register("same", handler);'], + ["an inline argument", 'register("same", () => tag);'], + [ + "a nested helper", + 'const inner = () => register("same", () => tag); inner();', + ], +])("keeps closures that separate outer calls create through %s", (_, body) => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + function register(ch, handler) { ipcMain.on(ch, handler); } + function setup(tag) { ${body} } + setup("first"); + setup("second"); + `); + const invocations = result.electron.ipc + .filter( + ({ channel, resolution_basis }) => + channel === "same" && resolution_basis === "wrapper-substitution", + ) + .map(({ invocation_location }) => JSON.stringify(invocation_location)); + // Both outer calls stay, beside the shorter entry that traced the same + // wrapper call without either caller's value. + expect(new Set(invocations).size).toBe(3); +}); it("counts one exposed renderer listener callsite once and retains its bridge path", () => { const result = analyzeJavaScriptStaticSource(` const {contextBridge,ipcRenderer}=require("electron"); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 9a9632850e..4226b49b50 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -9,7 +9,11 @@ import { type SymbolicEnvironment, type SymbolicFunction, } from "./javascriptSymbolicValues.js"; -import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; import type { JavaScriptElectronIdentity } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js"; import type { @@ -54,14 +58,79 @@ interface IpcEndpointFacts { // that source registration, not another registration at the same callsite. observedRendererListener: boolean; readonly invocations: Set; + // Call chains of substituted findings, by value and then by final call. + // Chains can only share a suffix when they end in the same call. + readonly reached: Map>; +} + +/** One substituted finding's call chain and the slot holding its record. */ +interface ReachedChain { + calls: readonly string[]; + readonly slot: number; } const ipcInvocationKey = (location: unknown): string => String(JSON.stringify(location)); +/** A finding's facts apart from which entry invoked it. */ +const ipcValueKey = (value: ElectronIpcFinding): string => + JSON.stringify({ ...value, invocation_location: undefined }); + +/** + * Whether two call chains end in the same calls, so one entry traced the same + * physical call sequence with less enclosing context than the other. + */ +const sharesCallSuffix = ( + left: readonly string[], + right: readonly string[], +): boolean => { + const [shorter, longer] = + left.length <= right.length ? [left, right] : [right, left]; + const offset = longer.length - shorter.length; + return shorter.every((call, index) => longer[offset + index] === call); +}; + +/** + * How an identical finding was already reached through the same trailing + * calls. A path with as much or more enclosing context makes this one a + * repeat. A shorter path is a nested entry this path invokes, so this path's + * fuller invocation supersedes its record, while other outer calls through the + * same sequence stay distinct invocations. + */ +const priorPath = ( + facts: IpcEndpointFacts, + value: string, + calls: readonly string[], +): { readonly repeated: boolean; readonly shorter?: ReachedChain } => { + const byLast = facts.reached.get(value); + if (byLast === undefined) return { repeated: false }; + // An empty chain is a suffix of every chain. + const candidates = + calls.length === 0 + ? [...byLast.values()].flat() + : [...(byLast.get(calls.at(-1) ?? "") ?? []), ...(byLast.get("") ?? [])]; + let shorter: ReachedChain | undefined; + for (const prior of candidates) { + if (!sharesCallSuffix(prior.calls, calls)) continue; + if (prior.calls.length >= calls.length) return { repeated: true }; + shorter ??= prior; + } + return shorter === undefined + ? { repeated: false } + : { repeated: false, shorter }; +}; + +/** One physical IPC operation, independent of how its channel was resolved. */ +const ipcSiteKey = (value: ElectronIpcFinding): string => + JSON.stringify([value.side, value.operation, value.location]); + const indexIpcFinding = ( index: Map, value: ElectronIpcFinding, + // A substitution that resolves a directly observed operation inherits its + // observation, so later paths to that operation stay duplicates. + observed = value.resolution_basis === undefined, + reached?: ReachedChain, ): void => { const key = ipcEndpointKey(value); let facts = index.get(key); @@ -70,21 +139,31 @@ const indexIpcFinding = ( observedTransmission: false, observedRendererListener: false, invocations: new Set(), + reached: new Map(), }; index.set(key, facts); } facts.observedTransmission ||= - value.resolution_basis === undefined && - (value.mode === "send" || value.mode === "invoke"); + observed && (value.mode === "send" || value.mode === "invoke"); facts.observedRendererListener ||= - value.resolution_basis === undefined && - value.side === "renderer" && - value.mode === "listen"; + observed && value.side === "renderer" && value.mode === "listen"; facts.invocations.add(ipcInvocationKey(value.invocation_location)); + if (reached !== undefined) { + const valueKey = ipcValueKey(value); + const byLast = + facts.reached.get(valueKey) ?? new Map(); + facts.reached.set(valueKey, byLast); + const last = reached.calls.at(-1) ?? ""; + const chains = byLast.get(last); + if (chains === undefined) byLast.set(last, [reached]); + else chains.push(reached); + } }; interface PathContext { readonly entryNode: t.Node; + /** Source ranges of the local calls this path made since its entry. */ + readonly calls: string[]; readonly entry: string; readonly entryPath?: readonly string[]; readonly entryKind: ElectronCallPath["entry_kind"] | null; @@ -193,13 +272,188 @@ export const collectElectronWrappedCallPaths = ( // Duplicate checks consult only facts at the same endpoint and call site. // Scanning every prior fact made bundles with many registrations quadratic. let ipcByEndpoint: Map | undefined; + // Direct operations whose channel the syntax pass left unresolved, by site. + let unresolvedIpcSites: Map | undefined; const ipcAtEndpoint = (): Map => { if (ipcByEndpoint !== undefined) return ipcByEndpoint; ipcByEndpoint = new Map(); - for (const { value } of accumulator.ipc) + unresolvedIpcSites = new Map(); + for (const [index, { value }] of accumulator.ipc.entries()) { indexIpcFinding(ipcByEndpoint, value); + if (value.resolution_basis === undefined && value.channel === null) + unresolvedIpcSites.set(ipcSiteKey(value), index); + } return ipcByEndpoint; }; + /** The literal channel at an IPC site independent of any invocation. */ + const siteDeterminedChannel = ( + node: t.CallExpression | t.OptionalCallExpression, + ): string | undefined => { + const argument = node.arguments[0]; + if (argument === undefined || t.isSpreadElement(argument)) return undefined; + const variants = symbolicVariants( + resolver.evaluate(argument, resolver.environmentAt(node)), + ); + const only = variants.length === 1 ? variants[0] : undefined; + return only?.kind === "literal" && + typeof only.value === "string" && + !only.conditions?.length + ? only.value + : undefined; + }; + const activationCaptures = new WeakMap(); + /** + * Whether the handler closes over a binding of a function this trace + * activated, such as a wrapper parameter or a factory local. Each activation + * may bind a different value, so such a closure is not one fact the call + * site determines. Bindings of unactivated scopes, such as module or bundle + * factory scope, and the handler's own bindings are shared by every trace. + */ + const capturesActivationBindings = ( + fn: SymbolicFunction, + context: PathContext, + ): boolean => { + const cached = activationCaptures.get(fn); + if (cached !== undefined) return cached; + const { captures, traceDependent } = closesOverActivation(fn, context); + if (!traceDependent) activationCaptures.set(fn, captures); + return captures; + }; + /** + * Whether an activation was called outside every function except bundle + * module factories, such as a factory call in a module-level initializer. + * Unless the trace itself makes that call, it ran once per module load and + * its bindings are shared by every trace. + */ + const calledAtModuleScope = (env: SymbolicEnvironment): boolean => { + const call = env.point; + if (call === undefined || call === env.scopeNode) return false; + for ( + let current = state.parentsByNode.get(call); + current; + current = state.parentsByNode.get(current) + ) + if (t.isFunction(current) && !resolver.isModuleFactory(current)) + return false; + return true; + }; + const closesOverActivation = ( + fn: SymbolicFunction, + context: PathContext, + ): { readonly captures: boolean; readonly traceDependent: boolean } => { + const activated = new Set(); + let traceDependent = false; + const traced = (call: t.Node): boolean => + call === context.entryNode || + context.calls.includes(ipcInvocationKey(range(call))); + for ( + let env: SymbolicEnvironment | undefined = fn.environment; + env; + env = env.parent + ) + if ( + env.activation === undefined || + env.scopeNode === undefined || + resolver.isModuleFactory(env.scopeNode) + ) + continue; + else if (!calledAtModuleScope(env)) activated.add(env.scopeNode); + else { + traceDependent = true; + if (env.point !== undefined && traced(env.point)) + activated.add(env.scopeNode); + } + // Scopes from the handler out to the outermost activated function, and + // whether the function owning an arrow handler's inherited context, such + // as `this` and `arguments`, lies within them. + const enclosing: string[] = []; + let activatedScopes = 0; + let contextOwnerActivated = false; + let contextOwnerFound = !t.isArrowFunctionExpression(fn.node); + for ( + let current = state.parentsByNode.get(fn.node); + current; + current = state.parentsByNode.get(current) + ) { + const scopeId = state.scopeByNode.get(current)?.scopeId; + if (scopeId !== undefined) enclosing.push(scopeId); + if (t.isFunction(current) && !t.isArrowFunctionExpression(current)) + contextOwnerFound = true; + // An activated arrow inside the owner does not rebind its context. + if (activated.has(current)) { + activatedScopes = enclosing.length; + contextOwnerActivated ||= contextOwnerFound; + } + } + if (activatedScopes === 0) return { captures: false, traceDependent }; + const scopes = new Set(enclosing.slice(0, activatedScopes)); + let captures = false; + t.traverseFast(fn.node, (node) => { + if (captures) return; + if ( + t.isThisExpression(node) || + t.isSuper(node) || + (t.isMetaProperty(node) && node.meta.name === "new") + ) { + captures = + contextOwnerActivated && inheritsFunctionContext(node, fn.node); + return; + } + if (!t.isIdentifier(node)) return; + const parent = state.parentsByNode.get(node); + if (parent !== undefined && !t.isReferenced(node, parent)) return; + const binding = resolveSemanticBindingState(state, node, node.name); + captures = + binding === undefined + ? (node.name === "arguments" && + contextOwnerActivated && + inheritsFunctionContext(node, fn.node)) || + semanticResolutionBlocked(state, node, node.name) + : scopes.has(binding.scopeId); + }); + return { captures, traceDependent }; + }; + /** + * Whether `this`, `arguments`, `super` or `new.target` at a node belongs to + * a function enclosing the handler, as it does through arrow functions. + */ + const inheritsFunctionContext = (node: t.Node, handler: t.Function) => { + for ( + let current = state.parentsByNode.get(node); + current; + current = state.parentsByNode.get(current) + ) { + if (t.isFunction(current) && !t.isArrowFunctionExpression(current)) + return false; + if (current === handler) return t.isArrowFunctionExpression(handler); + } + return false; + }; + /** + * Whether a registration's handler at an IPC site is this substituted + * handler independent of any invocation: the same function value in the + * site's lexical environment that closes over no traced activation's + * bindings, or no handler argument at all. + */ + const siteDeterminesHandler = ( + node: t.CallExpression | t.OptionalCallExpression, + fn: SymbolicFunction | undefined, + context: PathContext, + ): boolean => { + const argument = node.arguments[1]; + if (argument === undefined) return fn === undefined; + if (fn === undefined || t.isSpreadElement(argument)) return false; + const variants = symbolicVariants( + resolver.evaluate(argument, resolver.environmentAt(node)), + ); + const only = variants.length === 1 ? variants[0] : undefined; + return ( + only?.kind === "function" && + only.node === fn.node && + !capturesActivationBindings(fn, context) && + !only.conditions?.length + ); + }; const hasConditions = (value: SymbolicValue): boolean => symbolicVariants(value).some((variant) => variant.conditions?.length); function* bridgeMemberFunctions( @@ -404,13 +658,28 @@ export const collectElectronWrappedCallPaths = ( }; // A fully observed transmission stays one source fact when its callback is // traced. Registrations retain invocation identity because handlers compete. + // An entry nested in another entry's calls traces the same physical call + // sequence; an identical finding from it adds no invocation. + const perInvocationHandler = + fn !== undefined && capturesActivationBindings(fn, context); const recorded = ipcAtEndpoint().get(ipcEndpointKey(finding)); + const valueKey = ipcValueKey({ + ...finding, + resolution_basis: "wrapper-substitution", + }); + // A closure over this invocation's bindings is its own handler, even when + // another entry already traced the same calls. + const prior = + recorded === undefined || perInvocationHandler + ? undefined + : priorPath(recorded, valueKey, context.calls); const duplicate = recorded !== undefined && (recorded.observedTransmission || (context.entryKind === "bridge-member" && recorded.observedRendererListener) || - recorded.invocations.has(ipcInvocationKey(invocationLocation))); + recorded.invocations.has(ipcInvocationKey(invocationLocation)) || + prior?.repeated === true); const findingGuarded = context.conditions.length > 0 || hasConditions(first) || guardedDispatch; // Registry facts do not establish every bridge entry's reachability. Only @@ -435,10 +704,51 @@ export const collectElectronWrappedCallPaths = ( ...finding, resolution_basis: "wrapper-substitution", }; - accumulator.ipc.push({ offset: node.start ?? -1, value }); - indexIpcFinding(ipcAtEndpoint(), value); - if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn) - accumulator.unknownFindings += 1; + const shorter = prior?.shorter; + if (shorter !== undefined && recorded !== undefined) { + // This path invokes the sequence a nested entry traced alone, so its + // fuller invocation takes over that record instead of adding another. + accumulator.ipc[shorter.slot] = { offset: node.start ?? -1, value }; + shorter.calls = context.calls; + recorded.invocations.add(ipcInvocationKey(invocationLocation)); + } else { + // A finding the site itself determines, such as one whose channel is a + // local constant, is the same physical operation the syntax pass left + // unresolved, so it resolves that observation. A channel or handler from + // an invocation argument is one candidate; other callers may pass + // values this pass never sees. + const site = ipcSiteKey(value); + const observed = unresolvedIpcSites?.get(site); + const unresolved = + observed !== undefined && + siteDeterminedChannel(node) === channel && + (descriptor.mode === "send" || + descriptor.mode === "invoke" || + siteDeterminesHandler(node, fn, context)) + ? observed + : undefined; + const slot = unresolved ?? accumulator.ipc.length; + if (unresolved === undefined) + accumulator.ipc.push({ offset: node.start ?? -1, value }); + else { + accumulator.ipc[unresolved] = { offset: node.start ?? -1, value }; + unresolvedIpcSites?.delete(site); + // The syntax pass counted this channel as unknown; it is now known. + accumulator.unknownFindings = Math.max( + 0, + accumulator.unknownFindings - 1, + ); + } + indexIpcFinding( + ipcAtEndpoint(), + value, + unresolved !== undefined, + // Its call chain can never deduplicate a later path. + perInvocationHandler ? undefined : { calls: context.calls, slot }, + ); + if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn) + accumulator.unknownFindings += 1; + } } else if ( findingGuarded && (registrationKey === undefined || @@ -580,6 +890,7 @@ export const collectElectronWrappedCallPaths = ( { ...memberContext, entryNode: node, + calls: [], entryKind: "bridge-member", entry, entryPath: [key.value, ...path], @@ -608,6 +919,7 @@ export const collectElectronWrappedCallPaths = ( args, { ...branchContext, + calls: [...branchContext.calls, ipcInvocationKey(range(node))], steps: [ ...branchContext.steps, step(node, "call", callLabel(node.callee)), @@ -713,6 +1025,7 @@ export const collectElectronWrappedCallPaths = ( const env = isolatedSeed?.env ?? resolver.environmentAt(node); let context: PathContext = { entryNode: node, + calls: [], entry: "", entryKind: null, channel: null, diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 0aacc9a09b..613a8dc6b6 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -111,6 +111,7 @@ export class JavaScriptSymbolicBindings { readonly #mutated = new Set(); readonly #electronIdentity: JavaScriptElectronIdentity; readonly #namespaceOwners = new Map(); + readonly #moduleFactories = new WeakSet(); readonly #unsafeExports = new Set(); readonly #aliasesByBinding = new Map(); readonly #bootstrapTables: Array<{ @@ -171,6 +172,11 @@ export class JavaScriptSymbolicBindings { this.#invalidateBootstrapTable(table.tableAliases, table.modules); } + /** Whether a function is a recognized bundle module factory, which runs once per module load. */ + isModuleFactory(node: t.Node): boolean { + return t.isFunction(node) && this.#moduleFactories.has(node); + } + environmentAt(node: t.Node): SymbolicEnvironment { let current: t.Node | undefined = node; while (current) { @@ -1313,6 +1319,7 @@ export class JavaScriptSymbolicBindings { for (const property of bootstrap.table.properties) { const factory = moduleFactory(property); if (!factory) continue; + this.#moduleFactories.add(factory); const moduleKey = modulePropertyName(property); const values = new Map(); const environment: SymbolicEnvironment = { diff --git a/tests/acceptance/applications/electronIpcConstantChannelCli.test.ts b/tests/acceptance/applications/electronIpcConstantChannelCli.test.ts new file mode 100644 index 0000000000..5b5294f067 --- /dev/null +++ b/tests/acceptance/applications/electronIpcConstantChannelCli.test.ts @@ -0,0 +1,76 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + { + shape: "one constant-channel send", + preload: `const PING = "ping"; + contextBridge.exposeInMainWorld("api",{sendPing:()=>ipcRenderer.send(PING)});`, + counts: { operations: 2, renderer_transmissions: 1 }, + dynamic: 0, + }, + { + shape: "two constant-channel sends", + preload: `const PING = "ping"; + contextBridge.exposeInMainWorld("api",{ + sendPing:()=>ipcRenderer.send(PING), + sendAgain:()=>ipcRenderer.send(PING), + });`, + counts: { operations: 3, renderer_transmissions: 2 }, + dynamic: 0, + }, + { + // The helper parameter stays unresolved beside the one resolved candidate. + shape: "a helper-forwarded channel", + preload: `const forward = (ch, v) => ipcRenderer.send(ch, v); + contextBridge.exposeInMainWorld("api",{forwarded:()=>forward("ping")});`, + counts: { operations: 3, renderer_transmissions: 2 }, + dynamic: 1, + }, +])( + "counts each renderer IPC observation once for $shape", + async ({ preload, counts, dynamic }, { cli }) => { + const root = await createTestTempDirectory("rea-electron-ipc-constant-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "ipc-const", + version: "1.0.0", + main: "main.js", + }), + ), + writeFile( + join(root, "main.js"), + `const {app,BrowserWindow,ipcMain}=require("electron"); + ipcMain.on("ping",()=>{}); + app.whenReady().then(()=>new BrowserWindow({webPreferences:{preload:"./preload.js"}}));`, + ), + writeFile( + join(root, "preload.js"), + `const {contextBridge,ipcRenderer}=require("electron"); + ${preload}`, + ), + writeFile(join(root, "index.html"), "\n"), + ]); + + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(response.json); + expect(application.summary.ipc).toMatchObject({ + ...counts, + dynamic_channel_operations: dynamic, + }); + }, +); From 25e1a24a3ed3671a0148757555f18d16c29d936a Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 05:12:55 -0700 Subject: [PATCH 15/29] fix(reconstruction)!: summarize obligation ledgers by cited Evidence authority (#5875) summary.by_evidence_authority bucketed each obligation's source_state, so a shipped-artifact and a controlled-replay ledger returned the same summary and the promised authority breakdown was unanswerable. Derive the buckets from authority_references[].authority using the Evidence authority vocabulary owned by the Evidence schema. An obligation counts once under each distinct authority it cites, so counts can exceed total. Keep the source-state histogram under its own name, by_source_state. The readiness example now derives its summary from the same owner function instead of hand-writing buckets. BREAKING CHANGE: summary.by_evidence_authority keys are now Evidence authorities (shipped-artifact, controlled-replay, historical-reference, external-service, analyst-inference) instead of source states. The previous state counts move to the new required summary.by_source_state. Ledger closure digests change accordingly. Fixes #5838 Co-authored-by: Claude Opus 5.5 --- docs/reconstruction-obligation-ledgers.md | 7 +++ .../ReconstructionObligationLedger.fixture.ts | 60 +++++++++++++++++++ .../reconstructionReadinessExample.ts | 60 ++++--------------- src/domain/evidence.ts | 3 +- ...reconstructionObligationLedgerIntegrity.ts | 26 ++++++-- .../reconstructionObligationLedgerSchemas.ts | 30 ++++++---- .../reconstructionObligationLedgerCli.test.ts | 44 ++++++++++++++ .../reconstructionObligationLedgerMcp.test.ts | 20 ++++++- 8 files changed, 182 insertions(+), 68 deletions(-) diff --git a/docs/reconstruction-obligation-ledgers.md b/docs/reconstruction-obligation-ledgers.md index e3e4b6f0dd..6b930c5349 100644 --- a/docs/reconstruction-obligation-ledgers.md +++ b/docs/reconstruction-obligation-ledgers.md @@ -320,6 +320,13 @@ close a `packaged-process` obligation. A static-only source cannot close a runtime or process claim. Marking a claim `blocked` or `out-of-scope` records the disposition but does not silently count it as verified. +The `summary` counts obligations. `by_status`, `by_application_layer`, and +`by_source_state` place each obligation in exactly one bucket. +`by_evidence_authority` groups obligations by the Evidence `authority` of their +`authority_references`: an obligation counts once under each distinct authority +it cites, so those counts can exceed `total`. A reference's own `state` and the +obligation's `source_state` do not affect its authority bucket. + The tool returns every obligation inline. Its `ledger_id` and `closure_digest` identify the complete result, including summaries, reports, ownership and dependency graphs, Evidence links, and limitations. diff --git a/src/application/ReconstructionObligationLedger.fixture.ts b/src/application/ReconstructionObligationLedger.fixture.ts index ba15782810..ae535025fe 100644 --- a/src/application/ReconstructionObligationLedger.fixture.ts +++ b/src/application/ReconstructionObligationLedger.fixture.ts @@ -149,3 +149,63 @@ export const reviewedObligation = ( disposition: "active", review_evidence_ids: [evidenceId], }); + +const authorityEvidence = ( + label: string, + authority: "shipped-artifact" | "controlled-replay", +) => + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "observe", + parameters: { label }, + result: { present: true }, + confidence: "observed", + authority, + }, + ); + +/** + * Request with one single-reference shipped-artifact obligation and one + * obligation citing two shipped-artifact records and one controlled-replay + * record. Shipped-artifact review references have candidate state while both + * reviewed obligations have reviewed source state. + */ +export const mixedAuthorityRequest = + (): ReconstructionObligationLedgerInput => { + const shipped = authorityEvidence("shipped", "shipped-artifact"); + const otherShipped = authorityEvidence("other-shipped", "shipped-artifact"); + const replay = authorityEvidence("replay", "controlled-replay"); + return request([shipped, otherShipped, replay], { + reviewed_obligations: [ + reviewedObligation("review.shipped", shipped.evidence_id), + { + ...reviewedObligation("review.mixed", shipped.evidence_id), + review_evidence_ids: [ + shipped.evidence_id, + replay.evidence_id, + otherShipped.evidence_id, + ], + }, + ], + }); + }; + +/** Summary buckets expected for {@link mixedAuthorityRequest}. */ +export const MIXED_AUTHORITY_SUMMARY = { + total: 2, + by_evidence_authority: [ + { key: "shipped-artifact", count: 2 }, + { key: "controlled-replay", count: 1 }, + { key: "historical-reference", count: 0 }, + { key: "external-service", count: 0 }, + { key: "analyst-inference", count: 0 }, + ], + by_source_state: [ + { key: "candidate", count: 0 }, + { key: "observed", count: 0 }, + { key: "reviewed", count: 2 }, + { key: "unknown", count: 0 }, + ], +} as const; diff --git a/src/contracts/reconstructionReadinessExample.ts b/src/contracts/reconstructionReadinessExample.ts index 124ce2a058..35e2860079 100644 --- a/src/contracts/reconstructionReadinessExample.ts +++ b/src/contracts/reconstructionReadinessExample.ts @@ -4,7 +4,10 @@ import { READINESS_REQUIRED_CHECKS, READINESS_STAGE_IDS, } from "../domain/reconstructionReadinessJourneyFindings.js"; -import { reconstructionObligationLedgerClosureDigest } from "../domain/reconstructionObligationLedgerIntegrity.js"; +import { + reconstructionObligationLedgerClosureDigest, + summarizeReconstructionObligations, +} from "../domain/reconstructionObligationLedgerIntegrity.js"; import type { ReconstructionObligationLedger } from "../domain/reconstructionObligationLedgerSchemas.js"; import type { ReconstructionReadinessInput } from "../domain/reconstructionReadinessSchemas.js"; @@ -43,60 +46,17 @@ const FIXTURES = [ "incomplete-reconstruction", "broken-runtime", ] as const; -const STATUSES = [ - "unowned", - "characterized", - "implemented", - "verified", - "contradicted", - "blocked", - "out-of-scope", - "unknown", -] as const; -const LAYERS = [ - "cli", - "protocol", - "electron", - "persistence", - "process", - "runtime", - "packaging", - "native-abi", - "application", - "other", -] as const; -const EVIDENCE_STATES = [ - "candidate", - "observed", - "reviewed", - "unknown", -] as const; const READINESS_OBLIGATION_LEDGER = (() => { - const ledger: Omit = { + const facts: Omit< + ReconstructionObligationLedger, + "closure_digest" | "summary" + > = { schema: "ReconstructionObligationLedger", ledger_id: `rol_${HASH}`, status: "ready", coverage: { status: "complete", }, - summary: { - total: 1, - required: 1, - verified: 1, - required_open: 0, - by_status: STATUSES.map((key) => ({ - key, - count: key === "verified" ? 1 : 0, - })), - by_application_layer: LAYERS.map((key) => ({ - key, - count: key === "process" ? 1 : 0, - })), - by_evidence_authority: EVIDENCE_STATES.map((key) => ({ - key, - count: key === "observed" ? 1 : 0, - })), - }, reports: { missing_owner_obligation_ids: [], missing_verifier_obligation_ids: [], @@ -189,6 +149,10 @@ const READINESS_OBLIGATION_LEDGER = (() => { evidence_links: [EVIDENCE_ID], limitations: [], }; + const ledger = { + ...facts, + summary: summarizeReconstructionObligations(facts.obligations), + }; return { ...ledger, closure_digest: reconstructionObligationLedgerClosureDigest(ledger), diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index cf32dee6c1..ad78f74f63 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -112,7 +112,8 @@ export const evidenceLocationSchema = z.discriminatedUnion("kind", [ }), ]); -const evidenceAuthoritySchema = z.enum([ +/** Source authority vocabulary owned by Evidence and reused by its consumers. */ +export const evidenceAuthoritySchema = z.enum([ "shipped-artifact", "controlled-replay", "historical-reference", diff --git a/src/domain/reconstructionObligationLedgerIntegrity.ts b/src/domain/reconstructionObligationLedgerIntegrity.ts index c0f9dd0993..0ca3d88432 100644 --- a/src/domain/reconstructionObligationLedgerIntegrity.ts +++ b/src/domain/reconstructionObligationLedgerIntegrity.ts @@ -1,5 +1,6 @@ import { digestCanonicalValue } from "./canonicalDigest.js"; import { canonicalJson } from "./comparisonSemantics.js"; +import { evidenceAuthoritySchema } from "./evidence.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import type { @@ -48,6 +49,12 @@ export const summarizeReconstructionObligations = ( ({ application_layer }) => application_layer, ), by_evidence_authority: countBy( + obligations, + evidenceAuthoritySchema.options, + ({ authority_references: references }) => + references.map(({ authority }) => authority), + ), + by_source_state: countBy( obligations, ["candidate", "observed", "reviewed", "unknown"], ({ source_state }) => source_state, @@ -201,12 +208,19 @@ export const reconstructionObligationLedgerClosureDigest = (ledger: { "Reconstruction obligation ledger", ); +/** Count items per key; an item counts once under each distinct key it has. */ const countBy = ( items: readonly Item[], values: readonly Value[], - valueFor: (item: Item) => Value, -): { key: Value; count: number }[] => - values.map((value) => ({ - key: value, - count: items.filter((item) => valueFor(item) === value).length, - })); + valuesFor: (item: Item) => Value | readonly Value[], +): { key: Value; count: number }[] => { + const counts = new Map(); + for (const item of items) { + const itemValues = valuesFor(item); + for (const value of new Set( + typeof itemValues === "string" ? [itemValues] : itemValues, + )) + counts.set(value, (counts.get(value) ?? 0) + 1); + } + return values.map((value) => ({ key: value, count: counts.get(value) ?? 0 })); +}; diff --git a/src/domain/reconstructionObligationLedgerSchemas.ts b/src/domain/reconstructionObligationLedgerSchemas.ts index 6d3403c8d1..60ae22646c 100644 --- a/src/domain/reconstructionObligationLedgerSchemas.ts +++ b/src/domain/reconstructionObligationLedgerSchemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { STABLE_IDENTIFIER_PATTERN } from "./stringPatterns.js"; +import { evidenceAuthoritySchema } from "./evidence.js"; import { evidenceBundleSchema } from "./evidenceBundle.js"; import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; @@ -91,6 +92,13 @@ export const obligationOriginalAuthoritySchema = z.enum([ "external", ]); +const obligationEvidenceStateSchema = z.enum([ + "candidate", + "observed", + "reviewed", + "unknown", +]); + const obligationTargetSchema = z.strictObject({ artifact_sha256: digestSchema.nullable(), application_node_id: z @@ -106,14 +114,8 @@ const obligationTargetSchema = z.strictObject({ const authorityReferenceSchema = z.strictObject({ evidence_id: evidenceIdSchema, - authority: z.enum([ - "shipped-artifact", - "controlled-replay", - "historical-reference", - "external-service", - "analyst-inference", - ]), - state: z.enum(["candidate", "observed", "reviewed", "unknown"]), + authority: evidenceAuthoritySchema, + state: obligationEvidenceStateSchema, location: textSchema, }); @@ -235,7 +237,7 @@ export const reconstructionObligationSchema = z.strictObject({ family: textSchema, target: obligationTargetSchema, authority_references: z.array(authorityReferenceSchema).min(1), - source_state: z.enum(["candidate", "observed", "reviewed", "unknown"]), + source_state: obligationEvidenceStateSchema, observed_cases: z.array(observedCaseSchema), required: z.boolean(), required_case_kinds: z.array(obligationCaseKindSchema).min(1), @@ -295,7 +297,8 @@ const obligationSummariesMatch = ( countEntriesMatch( actual.by_evidence_authority, expected.by_evidence_authority, - ); + ) && + countEntriesMatch(actual.by_source_state, expected.by_source_state); export const reconstructionObligationLedgerSchema = z .strictObject({ @@ -313,8 +316,11 @@ export const reconstructionObligationLedgerSchema = z required_open: z.number().int().min(0), by_status: countBySchema(obligationStatusSchema), by_application_layer: countBySchema(obligationLayerSchema), - by_evidence_authority: countBySchema( - z.enum(["candidate", "observed", "reviewed", "unknown"]), + by_evidence_authority: countBySchema(evidenceAuthoritySchema).describe( + "Obligations per Evidence authority. An obligation counts once under each distinct authority among its authority_references, so counts can sum above total.", + ), + by_source_state: countBySchema(obligationEvidenceStateSchema).describe( + "Obligations per source_state; each obligation counts once.", ), }), reports: z.strictObject({ diff --git a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts index 91b101364c..532707c594 100644 --- a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts +++ b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts @@ -5,6 +5,10 @@ import { describe, expect, it } from "vitest"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + MIXED_AUTHORITY_SUMMARY, + mixedAuthorityRequest, +} from "../../../src/application/ReconstructionObligationLedger.fixture.js"; const execute = promisify(execFile); @@ -116,4 +120,44 @@ describe("reconstruction obligation ledger CLI parity", () => { }, }); }, 20_000); + + it("summarizes obligations by their cited Evidence authorities", async () => { + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "build-reconstruction-obligation-ledger", + JSON.stringify(mixedAuthorityRequest()), + "--json", + ], + { cwd: process.cwd(), env: process.env, maxBuffer: 16 * 1_024 * 1_024 }, + ); + const ledger = JSON.parse(stdout).normalized_result; + expect(ledger.summary).toMatchObject(MIXED_AUTHORITY_SUMMARY); + expect( + ledger.obligations.map( + (obligation: { + readonly obligation_id: string; + readonly authority_references: readonly { + readonly authority: string; + readonly state: string; + }[]; + }) => [ + obligation.obligation_id, + obligation.authority_references.map( + ({ authority, state }) => `${authority}/${state}`, + ), + ], + ), + ).toEqual([ + [ + "review.mixed", + expect.arrayContaining([ + "shipped-artifact/candidate", + "controlled-replay/reviewed", + ]), + ], + ["review.shipped", ["shipped-artifact/candidate"]], + ]); + }, 20_000); }); diff --git a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts index c027654ced..dc9795f6ea 100644 --- a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts +++ b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts @@ -7,7 +7,11 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createServer } from "../../../src/server/createServer.js"; -import { reviewedObligation } from "../../../src/application/ReconstructionObligationLedger.fixture.js"; +import { + MIXED_AUTHORITY_SUMMARY, + mixedAuthorityRequest, + reviewedObligation, +} from "../../../src/application/ReconstructionObligationLedger.fixture.js"; import { observed } from "../../fixtures/analysisExecution.js"; type LedgerRequest = ( @@ -155,6 +159,20 @@ describe("reconstruction obligation ledger MCP parity", () => { }); }); + it("summarizes obligations by their cited Evidence authorities", async () => { + const input = mixedAuthorityRequest(); + await withLedgerClient(async (request) => { + const result = await request( + input.evidence_bundle, + input.reviewed_obligations, + ); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + normalized_result: { summary: MIXED_AUTHORITY_SUMMARY }, + }); + }); + }); + it("returns the complete Evidence-backed ledger inline", async () => { const session = createTestBinarySession(() => ({ execute: () => Promise.resolve(observed(null)), From c538a843cb49f7156f86beb3b21501e03255af9a Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:43:56 +0800 Subject: [PATCH 16/29] fix(ci): keep Windows ACL observation in SID form (#5941) Observe private-runtime ACL ownership, trustees and rule count from one SID rule collection. Guard the account-name presentation member through the owning PowerShell type and verify that guard is installed. Keep the original15s probe deadline and every ACL/readonly assertion. Record bounded phase diagnostics and document the observed failure boundary. The repeated historical timeout cause is unconfirmed; no retry or deadline increase is presented as a fix. Validation: complete current-head CI passed40 jobs with one source-only skip; real Windows typecheck, curated/native controls, package and Node24/22 checks passed. PowerShell7.5.2 API/parser experiment and278 routing regressions passed. Full evidence: https://github.com/morluto/rea/actions/runs/39599008194 Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/testing.md | 8 ++++++++ scripts/verify-windows-native.mjs | 33 ++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index 43ba47cde5..e19db750ba 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -679,6 +679,14 @@ miss. Native artifacts, dependency installs, package archives, and runtime verification remain host-local. Keep platform-dependent outputs out of this shared cache. +The Windows native verifier's private-runtime ACL observer reads SID rules once +for both trustee validation and counting. It deliberately rejects the convenience +`Access` property, which resolves account names. Its `private-runtime-acl` stderr +markers report completed phases and elapsed time; inspect these on a timeout +before attributing it to startup, ACL reads, or identity lookup. The original +15-second deadline and all ownership, DACL, and readonly assertions remain in +force. A passing retry alone does not explain a previous timeout. + Record cold and warm preparation separately from fresh test execution when benchmarking the optional `npm run check:pr` gate. Compare multiple runs on the same host, including subprocess and queue costs; a cached pass is not a test diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 70821c0a97..4077639889 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -369,7 +369,38 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe "-NoProfile", "-NonInteractive", "-Command", - "$ErrorActionPreference='Stop'; $p=$env:REA_TEST_PRIVATE_RUNTIME_PATH; $a=Get-Acl -LiteralPath $p; $u=[Security.Principal.WindowsIdentity]::GetCurrent().User; [IO.File]::SetAttributes($p,[IO.FileAttributes]([int][IO.File]::GetAttributes($p) -bor 1)); @{protected=$a.AreAccessRulesProtected; ownerMatchesUser=($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $u.Value); trusteesOnlyUserAndSystem=(@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object {$_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18'}).Count -eq 0); count=@($a.Access).Count; rootReadonly=(([int][IO.File]::GetAttributes($p) -band 1) -ne 0)} | ConvertTo-Json -Compress", + String.raw` + $ErrorActionPreference = 'Stop' + $clock = [Diagnostics.Stopwatch]::StartNew() + function Trace-AclPhase([string]$phase) { + [Console]::Error.WriteLine('private-runtime-acl: ' + $phase + ' ' + $clock.ElapsedMilliseconds + 'ms') + } + Trace-AclPhase 'started' + $p = $env:REA_TEST_PRIVATE_RUNTIME_PATH + $a = Get-Acl -LiteralPath $p + Trace-AclPhase 'acl-read' + $u = [Security.Principal.WindowsIdentity]::GetCurrent().User + $owner = $a.GetOwner([Security.Principal.SecurityIdentifier]) + Trace-AclPhase 'owner-sid-read' + # Guard the independent observer against accidental account-name resolution. + # Access is a PowerShell convenience property that requests NTAccount rules. + Update-TypeData -TypeName ($a.GetType().FullName) -MemberType ScriptProperty -MemberName Access -Value { throw 'ACL observation must use SID rules' } -Force + if ($a.PSObject.Properties['Access'].MemberType -ne [Management.Automation.PSMemberTypes]::ScriptProperty) { + throw 'ACL observation Access guard was not installed' + } + $rules = @($a.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + Trace-AclPhase 'rule-sids-read' + [IO.File]::SetAttributes($p, [IO.FileAttributes]([int][IO.File]::GetAttributes($p) -bor 1)) + Trace-AclPhase 'readonly-set' + @{ + protected = $a.AreAccessRulesProtected + ownerMatchesUser = ($owner.Value -eq $u.Value) + trusteesOnlyUserAndSystem = (@($rules | Where-Object { $_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18' }).Count -eq 0) + count = $rules.Count + rootReadonly = (([int][IO.File]::GetAttributes($p) -band 1) -ne 0) + } | ConvertTo-Json -Compress + Trace-AclPhase 'complete' + `, ], { encoding: "utf8", From 6d356b84344659085f2b912857b5e27fe0160c9b Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 05:51:24 -0700 Subject: [PATCH 17/29] test(ci): keep module mocks in isolated test projects (#5886) * test(ci): keep module mocks in isolated test projects A module mock registered after another file in the same worker loaded the module never applies, so a mocking test in a project that shares one module graph passes or fails with file scheduling. That is how the environment export source tests failed on hosted runs until c324d31b4 removed their mocks. Guard the rule: every tracked test using vi.mock or vi.doMock must belong to an isolated project. Move the last shared-graph case, DirectAnalysisStatFailure, which mocks node:fs/promises, into process-global beside the other module-mocking workflow tests. Co-Authored-By: Claude Opus 5.5 * test(pe): share one compiled CLI process across PE resource inspections The acceptance test started a fresh CLI process for each of its fixtures inside one 30 s test deadline. Hosted runs took 24-27 s and c324d31b4 timed out. Every inspection there reads no stdin and sets no deadline, so use the fixture's file-scoped compiled CLI process. Local time drops from 8.5 s to about 3.9 s with the same assertions. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../filesystem/ciTestProjects.test.ts | 41 +++++++++++++++++++ .../directAnalysisStatFailure.test.ts | 10 ++--- 2 files changed, 46 insertions(+), 5 deletions(-) rename src/application/DirectAnalysisStatFailure.test.ts => tests/process-global/directAnalysisStatFailure.test.ts (90%) diff --git a/tests/boundary/filesystem/ciTestProjects.test.ts b/tests/boundary/filesystem/ciTestProjects.test.ts index 0159a39108..1f20ef2d94 100644 --- a/tests/boundary/filesystem/ciTestProjects.test.ts +++ b/tests/boundary/filesystem/ciTestProjects.test.ts @@ -141,3 +141,44 @@ describe("canonical test-project ownership", () => { }, ); }); + +describe("test module isolation", () => { + it("keeps module mocks out of projects that share one module graph", async () => { + // A mock registered after another file in the same worker loaded the + // module never applies, so the test's outcome depends on file scheduling. + const root = await createTestTempDirectory("rea-vitest-mock-isolation-"); + const script = join(root, "project-isolation.mjs"); + await writeFile( + script, + ` + import config from ${JSON.stringify(pathToFileURL(join(repository, "vitest.config.ts")).href)}; + console.log(JSON.stringify(Object.fromEntries( + config.test.projects.map(project => [project.test.name, project.test.isolate !== false]), + ))); + `, + ); + const result = await execFileOutput( + process.execPath, + ["--import", "tsx", script], + { + cwd: repository, + timeout: 10_000, + maxBuffer: 64 * 1024, + stopSignal: "SIGTERM", + }, + ); + const isolated = JSON.parse(result.stdout) as Record; + const mocking = execFileSync( + "git", + ["grep", "-l", "-E", String.raw`vi\.(mock|doMock)\(`, "--", "*.test.ts"], + { cwd: repository, encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean); + expect(mocking.length).toBeGreaterThan(0); + expect( + mocking.filter((file) => isolated[projectForTest(file).name] !== true), + ).toEqual([]); + }); +}); diff --git a/src/application/DirectAnalysisStatFailure.test.ts b/tests/process-global/directAnalysisStatFailure.test.ts similarity index 90% rename from src/application/DirectAnalysisStatFailure.test.ts rename to tests/process-global/directAnalysisStatFailure.test.ts index 0d82f7b493..510cc8eeac 100644 --- a/src/application/DirectAnalysisStatFailure.test.ts +++ b/tests/process-global/directAnalysisStatFailure.test.ts @@ -14,11 +14,11 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); -import { parseConfig } from "../config/parseConfig.js"; -import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js"; -import type { BinarySession } from "./binary/BinarySession.js"; -import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js"; -import { runProviderAnalysis } from "./DirectAnalysis.js"; +import { parseConfig } from "../../src/config/parseConfig.js"; +import { classifyBinaryTargetFilesystemFailure } from "../../src/application/BinaryTargetResolver.js"; +import type { BinarySession } from "../../src/application/binary/BinarySession.js"; +import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js"; +import { runProviderAnalysis } from "../../src/application/DirectAnalysis.js"; afterEach(() => statFailures.clear()); From 5df59ee1fd630b9062f39e9f375ea850657d0aad Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:51:46 -0700 Subject: [PATCH 18/29] fix(errors): name fixed plist decode allowances instead of host memory in recovery (#5860) fix(errors): derive fixed-allowance recovery from a typed refusal field inspect_plist refused a binary plist above its fixed 64 MiB decode estimate, or nested deeper than 128 levels, with resource "memory" and no recovery, so the shared presentation told the caller to add worker or host memory. Neither raises that allowance. The 64 MiB source-size refusal likewise got the worker file-size advice for evidence replies. AnalysisResourceConstraintError now carries a typed fixedAllowance { name, kind }. fixedAllowanceOptions sets it instead of a prebuilt string, and the presentation derives the recovery from it, so every existing fixed-allowance producer moves to the same field. Operation re-attribution, IDA startup re-attribution and explicit redaction copy the field through one resourceConstraintRecovery helper; redaction still applies to the allowance name. The plist decode, nesting and input refusals use the field. A nesting refusal now reports maximum_nesting_depth instead of the decode byte limit it did not exceed. Fixes #5847 Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 9 ++- .../apple/PropertyListDecodeBudget.ts | 5 +- src/domain/analysisErrorCore.ts | 51 +++++++++++- src/domain/analysisErrorPresentation.ts | 24 +++--- src/domain/explicitSensitiveFailure.ts | 11 +-- src/domain/fixedAllowanceRemediation.ts | 11 ++- src/ida/IdaSessionClient.ts | 25 +++--- src/native/NativePlistSource.ts | 34 +++++--- src/native/parsers/plist.ts | 22 +++++- src/providers/AnalysisClientFailure.ts | 11 +-- .../boundary/captures/captureProvider.test.ts | 5 +- .../nativePlistSelectionFailureCli.test.ts | 77 ++++++++++++++++++- .../boundary/il2cpp/il2cppGhidraSeeds.test.ts | 21 +++-- .../providers/fixedAllowanceRecovery.test.ts | 55 +++++++++++++ .../native/nativePlistBinarySource.test.ts | 70 ++++++++++++++++- .../signals/recordingProvider.test.ts | 5 +- 16 files changed, 364 insertions(+), 72 deletions(-) create mode 100644 tests/boundary/providers/fixedAllowanceRecovery.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ebc5e646c4..c4aaa93b97 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -302,8 +302,13 @@ Binary plist inspection reads the original bytes through an owned file descripto so conversion cannot erase control characters, fractional dates, negative zero, or the high word of a 128-bit integer. It accepts at most 64 MiB of input and reserves an estimated 64 MiB decode budget before parsing; this estimate is not -a hard process-memory bound. Binary-source provenance records the `file` -classification without claiming a `plutil` conversion. +a hard process-memory bound. A refused input reports non-retryable +`resource_constraint` with `reported_limits.maximum_input_bytes`, +`maximum_decoded_bytes` or, for nesting deeper than 128 levels, +`maximum_nesting_depth`. These are fixed allowances: worker limits and host +memory do not raise them, and the unchanged request fails the same way. +Binary-source provenance records the `file` classification without claiming a +`plutil` conversion. A plist explicitly selected through the path input or as the active plist target reports an unreadable or malformed selection as `invalid_request`. A missing or diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts index 45ca4cbd0a..e4b43c1b3c 100644 --- a/src/artifacts/apple/PropertyListDecodeBudget.ts +++ b/src/artifacts/apple/PropertyListDecodeBudget.ts @@ -122,6 +122,9 @@ function* xmlPreflightTokens(xml: string) { } } +/** Deepest binary plist value nesting the reference walk admits. */ +export const MAX_BINARY_PLIST_NESTING_DEPTH = 128; + /** Resource or structural failure found while preflighting a plist. */ export type BinaryPlistExpansionFailure = "cycle" | "depth" | "expansion"; @@ -299,7 +302,7 @@ export const estimateBinaryPlistExpansion = ( continue; } pendingObjectCount -= 1; - if (entry.depth > 128) + if (entry.depth > MAX_BINARY_PLIST_NESTING_DEPTH) throw fail("depth", `binary plist nesting exceeds ${budget}`); if (active.has(entry.object)) throw fail("cycle", `binary plist reference cycle exceeds ${budget}`); diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index a00435b2f8..1a08034263 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "./jsonValue.js"; +import type { FixedAllowance } from "./fixedAllowanceRemediation.js"; import { AnalysisError, type AnalysisErrorOptions, @@ -33,6 +34,12 @@ export const ANALYSIS_RESOURCE_KINDS = [ export class AnalysisResourceConstraintError extends AnalysisError { readonly _tag = "AnalysisResourceConstraintError"; readonly remediationAction: string | undefined; + /** + * The fixed format, decoder, or capture allowance that refused the request. + * Worker limits and host resources do not raise it, so recovery derives from + * this allowance rather than from `resource`. + */ + readonly fixedAllowance: FixedAllowance | undefined; /** * Whether retrying the same request can succeed. A caller-selected bound is * deterministic for the same input, so it is not retryable even when its @@ -45,17 +52,53 @@ export class AnalysisResourceConstraintError extends AnalysisError { readonly resource: (typeof ANALYSIS_RESOURCE_KINDS)[number], readonly reason: string, readonly reportedLimits: Readonly> | null, - options?: AnalysisErrorOptions & { - readonly remediationAction?: string; - readonly retryable?: boolean; - }, + options?: AnalysisErrorOptions & ResourceConstraintRecovery, ) { super(`Resource constraint during ${operation}: ${reason}`, options); this.remediationAction = options?.remediationAction; + this.fixedAllowance = options?.fixedAllowance; this.retryable = options?.retryable; } } +/** Carry one refusal's recovery to an error re-attributed or redacted elsewhere. */ +export const resourceConstraintRecovery = ( + error: AnalysisResourceConstraintError, + text: (value: string) => string = (value) => value, +): ResourceConstraintRecovery => + error.fixedAllowance !== undefined + ? { + fixedAllowance: { + name: text(error.fixedAllowance.name), + kind: error.fixedAllowance.kind, + }, + ...retryable(error), + } + : { + ...(error.remediationAction === undefined + ? {} + : { remediationAction: text(error.remediationAction) }), + ...retryable(error), + }; + +const retryable = ( + error: AnalysisResourceConstraintError, +): { readonly retryable?: boolean } => + error.retryable === undefined ? {} : { retryable: error.retryable }; + +/** Producer-specific recovery, or a fixed allowance whose recovery is derived. */ +export type ResourceConstraintRecovery = + | { + readonly remediationAction?: string; + readonly fixedAllowance?: never; + readonly retryable?: boolean; + } + | { + readonly fixedAllowance: FixedAllowance; + readonly remediationAction?: never; + readonly retryable?: boolean; + }; + /** Provider-neutral invalid analysis input or output at an application boundary. */ export class AnalysisProtocolError extends AnalysisError { readonly _tag = "AnalysisProtocolError"; diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index ac9599e35d..a0e289c570 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -31,6 +31,7 @@ import { ProviderSelectionError } from "./providerSelectionError.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; import { UnknownRegistryError } from "./unknownRegistryError.js"; import { providerRetryAction } from "./providerOperationHealth.js"; +import { fixedAllowanceRemediation } from "./fixedAllowanceRemediation.js"; import { resolvedAnalysisError, type AnalysisError, @@ -67,15 +68,20 @@ export const analysisErrorRemediationAction = ( if (error instanceof AnalysisResourceConstraintError) return ( error.remediationAction ?? - (error.resource === "capacity" - ? "Wait for an active analysis to complete, then retry within the reported admission limits." - : error.resource === "transport" - ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." - : error.resource === "cpu" - ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." - : error.resource === "file-size" - ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." - : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") + (error.fixedAllowance !== undefined + ? fixedAllowanceRemediation( + error.fixedAllowance.name, + error.fixedAllowance.kind, + ) + : error.resource === "capacity" + ? "Wait for an active analysis to complete, then retry within the reported admission limits." + : error.resource === "transport" + ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." + : error.resource === "cpu" + ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." + : error.resource === "file-size" + ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." + : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") ); if ( error instanceof AnalysisTimeoutError && diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 14d680fb2f..5cdd9ae3bd 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -6,6 +6,7 @@ import { AnalysisOutputError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, + resourceConstraintRecovery, AnalysisCancelledError, AnalysisTimeoutError, } from "./analysisErrorCore.js"; @@ -183,15 +184,7 @@ const redactFailure = ( error.resource, text(error.reason), error.reportedLimits === null ? null : diagnostics(error.reportedLimits), - { - ...context, - ...(error.remediationAction === undefined - ? {} - : { remediationAction: text(error.remediationAction) }), - ...(error.retryable === undefined - ? {} - : { retryable: error.retryable }), - }, + { ...context, ...resourceConstraintRecovery(error, text) }, ); if (error instanceof ProviderCleanupError) return new ProviderCleanupError( diff --git a/src/domain/fixedAllowanceRemediation.ts b/src/domain/fixedAllowanceRemediation.ts index a121a7f15d..962c3987d9 100644 --- a/src/domain/fixedAllowanceRemediation.ts +++ b/src/domain/fixedAllowanceRemediation.ts @@ -39,11 +39,18 @@ export const fixedAllowanceRemediation = ( } }; +/** A named deterministic allowance and the recovery family it belongs to. */ +export interface FixedAllowance { + /** Display name, such as "The tshark JSON decoding allowance". */ + readonly name: string; + readonly kind: FixedAllowanceKind; +} + /** Non-retryable options for a fixed allowance. Occupied capacity stays retryable. */ export const fixedAllowanceOptions = ( allowance: string, kind: FixedAllowanceKind, -): { readonly retryable: false; readonly remediationAction: string } => ({ +): { readonly retryable: false; readonly fixedAllowance: FixedAllowance } => ({ retryable: false, - remediationAction: fixedAllowanceRemediation(allowance, kind), + fixedAllowance: { name: allowance, kind }, }); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index ffbd433ce2..52e6882661 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -14,6 +14,7 @@ import { AnalysisInputError, AnalysisTimeoutError, AnalysisResourceConstraintError, + resourceConstraintRecovery, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -530,33 +531,35 @@ export class IdaSessionClient implements AnalysisClient { ...(cause.userMessage === undefined ? {} : { userMessage: cause.userMessage }), - ...(cause.remediationAction === undefined - ? {} - : { remediationAction: cause.remediationAction }), diagnostics: { ...cause.diagnostics, startup_operation: cause.operation, }, }; + const action = + cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }; // Startup is shared, but each accepted operation owns its attribution. // Keep the original cached failure unchanged for later callers. const failure = cause instanceof AnalysisTimeoutError - ? new AnalysisTimeoutError(operation, cause.timeoutMs, options) + ? new AnalysisTimeoutError(operation, cause.timeoutMs, { + ...options, + ...action, + }) : cause instanceof AnalysisResourceConstraintError ? new AnalysisResourceConstraintError( operation, cause.resource, cause.reason, cause.reportedLimits, - { - ...options, - ...(cause.retryable === undefined - ? {} - : { retryable: cause.retryable }), - }, + { ...options, ...resourceConstraintRecovery(cause) }, ) - : new ProviderAdapterError(cause.providerId, operation, options); + : new ProviderAdapterError(cause.providerId, operation, { + ...options, + ...action, + }); throw failure .bindCleanupDependencies([cause]) .bindCleanupResourceOwner(cause); diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index 6b66a9f345..9624b07643 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -33,7 +33,11 @@ import { } from "../artifacts/ArtifactFileOperation.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js"; -import { MAX_NATIVE_PLIST_BYTES } from "./parsers/plist.js"; +import { + MAX_NATIVE_PLIST_BYTES, + NATIVE_PLIST_INPUT_ALLOWANCE, +} from "./parsers/plist.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; /** Resolved lookup identity and selection intent, independent of file readiness. */ export interface NativePlistSelection { @@ -214,15 +218,25 @@ export const nativePlistFailure = ( ); if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") { const resource = cause.boundary ?? "memory"; - return new AnalysisResourceConstraintError( - "inspect_plist", - resource, - cause.message, - resource === "file-size" - ? { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES } - : null, - { cause, capturedOutput, retryable: false }, - ); + return resource === "file-size" + ? new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, + { + cause, + capturedOutput, + ...fixedAllowanceOptions(NATIVE_PLIST_INPUT_ALLOWANCE, "input"), + }, + ) + : new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + null, + { cause, capturedOutput, retryable: false }, + ); } if (selection === undefined) return new ProviderAdapterError(IDENTITY.id, "inspect_plist", { diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index b6863b2e48..76976f5206 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -34,14 +34,22 @@ import { } from "../../artifacts/apple/PlistScalars.js"; import { estimateBinaryPlistExpansion, + MAX_BINARY_PLIST_NESTING_DEPTH, PropertyListDecodeBudgetFailure, } from "../../artifacts/apple/PropertyListDecodeBudget.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; /** Input ceiling matches native command output; projection is bounded separately. */ export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024; const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024; +const mebibytes = (bytes: number): string => + `${String(bytes / (1024 * 1024))} MiB`; + +/** Recovery name for the fixed source-size ceiling. */ +export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} native plist input allowance`; + const plistObject = z.record(z.string(), z.unknown()); /** Stable bundle metadata projected from one plist value. */ @@ -287,8 +295,18 @@ export const parsePlistBinary = ( "inspect_plist", "memory", cause.message, - { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES }, - { cause, retryable: false }, + cause.kind === "depth" + ? { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH } + : { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES }, + { + cause, + ...fixedAllowanceOptions( + cause.kind === "depth" + ? `The ${String(MAX_BINARY_PLIST_NESTING_DEPTH)}-level native binary plist nesting allowance` + : `The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} native binary plist decode allowance`, + "parser", + ), + }, ), ); return err( diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts index 56b24b6102..85c98e8db2 100644 --- a/src/providers/AnalysisClientFailure.ts +++ b/src/providers/AnalysisClientFailure.ts @@ -13,6 +13,7 @@ import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisResourceConstraintError, + resourceConstraintRecovery, AnalysisOutputError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; @@ -237,15 +238,7 @@ export const analysisClientFailureForOperation = ( cause.resource, cause.reason, cause.reportedLimits, - { - ...context, - ...(cause.retryable === undefined - ? {} - : { retryable: cause.retryable }), - ...(cause.remediationAction === undefined - ? {} - : { remediationAction: cause.remediationAction }), - }, + { ...context, ...resourceConstraintRecovery(cause) }, ); else result = new ProviderAdapterError(providerId, operation, { diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index 9265e8120c..f70875b586 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -15,6 +15,7 @@ import { createDeferred } from "../../fixtures/binarySession.js"; import { Cli } from "incur"; import { registerCaptureCommands } from "../../../src/cli/captureCommands.js"; import { silentLogger } from "../../../src/logger.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; // A command-boundary simulator: real process ownership/filesystem, not real TShark coverage. const fixture = async (mode = "ok", failCleanup = false) => { @@ -623,10 +624,10 @@ it.each([ reportedLimits: { output_bytes: 32 * 1024 * 1024 }, retryable: false, }); - expect(result.error.remediationAction).toContain( + expect(analysisErrorRemediationAction(result.error)).toContain( "The retained command-output allowance", ); - expect(result.error.remediationAction).not.toMatch( + expect(analysisErrorRemediationAction(result.error)).not.toMatch( /Review the reported worker memory limits/u, ); } diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts index 81046e68ad..87a9ea15fc 100644 --- a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -1,6 +1,7 @@ -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { buildBinary } from "plist"; import { describe, expect } from "vitest"; import { z } from "zod"; @@ -248,3 +249,77 @@ describe.skipIf(process.platform !== "darwin")( ); }, ); + +describe.skipIf(process.platform !== "darwin")( + "native plist CLI fixed allowances", + () => { + const hostMemoryAdvice = /available host memory|worker file-size limits/u; + + cliTest( + "names the binary plist decode allowance instead of host memory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-plist-decode-limit-"); + const path = join(root, "large.plist"); + // The decode estimate exceeds 64 MiB below the 64 MiB input ceiling. + const source = buildBinary({ + data: Buffer.alloc(22 * 1024 * 1024, 0x78), + }); + await writeFile(path, source); + const runs = []; + for (let attempt = 0; attempt < 2; attempt += 1) + runs.push( + await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }), + ); + const [first, second] = runs; + expect(first?.exitCode).toBe(1); + expect(second?.json).toEqual(first?.json); + expect(first?.json).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: + "The 64 MiB native binary plist decode allowance is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.", + }, + details: { + operation: "inspect_plist", + resource: "memory", + reported_limits: { maximum_decoded_bytes: 64 * 1024 * 1024 }, + }, + }); + expect(Buffer.compare(await readFile(path), source)).toBe(0); + }, + ); + + cliTest( + "names the binary plist input allowance instead of worker file-size limits", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-plist-input-limit-"); + const path = join(root, "huge.plist"); + await writeFile(path, "bplist00"); + await truncate(path, 64 * 1024 * 1024 + 1); + const result = await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: expect.stringContaining( + "Select a suitable complete artifact within the fixed 64 MiB native plist input allowance.", + ), + }, + details: { + resource: "file-size", + reported_limits: { maximum_input_bytes: 64 * 1024 * 1024 }, + }, + }); + expect(JSON.stringify(result.json)).not.toMatch(hostMemoryAdvice); + }, + ); + }, +); diff --git a/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts b/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts index 45b33ed455..28eb34cc0d 100644 --- a/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts +++ b/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts @@ -13,6 +13,7 @@ import { IL2CPP_METADATA_SECTION_NAMES, type Il2cppMetadata, } from "../../../src/domain/il2cpp/il2cppMetadata.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; const type = ( name: string, @@ -188,12 +189,16 @@ it("bounds folded-name retention against the actual Ghidra file admission", () = if (!(caught instanceof AnalysisResourceConstraintError)) throw new Error("Expected seed-file allowance refusal"); expect(caught.message).toMatch(/byte admission/); - expect(caught.remediationAction).toContain( + expect(analysisErrorRemediationAction(caught)).toContain( "The IL2CPP Ghidra seed-file allowance", ); - expect(caught.remediationAction).toContain("fixed byte allowance"); - expect(caught.remediationAction).not.toContain("fixed count"); - expect(caught.remediationAction).not.toContain("seed-name allowance"); + expect(analysisErrorRemediationAction(caught)).toContain( + "fixed byte allowance", + ); + expect(analysisErrorRemediationAction(caught)).not.toContain("fixed count"); + expect(analysisErrorRemediationAction(caught)).not.toContain( + "seed-name allowance", + ); expect(caught.retryable).toBe(false); }); it("refuses cyclic type ancestry before emitting a seed", () => { @@ -229,10 +234,12 @@ it("refuses names the real Ghidra seed parser cannot admit", () => { if (!(caught instanceof AnalysisResourceConstraintError)) throw new Error("Expected seed-name allowance refusal"); expect(caught.message).toMatch(/character admission/); - expect(caught.remediationAction).toContain( + expect(analysisErrorRemediationAction(caught)).toContain( "The IL2CPP Ghidra seed-name allowance", ); - expect(caught.remediationAction).toContain("fixed count"); - expect(caught.remediationAction).not.toContain("fixed byte allowance"); + expect(analysisErrorRemediationAction(caught)).toContain("fixed count"); + expect(analysisErrorRemediationAction(caught)).not.toContain( + "fixed byte allowance", + ); expect(caught.retryable).toBe(false); }); diff --git a/tests/boundary/providers/fixedAllowanceRecovery.test.ts b/tests/boundary/providers/fixedAllowanceRecovery.test.ts new file mode 100644 index 0000000000..b586e6352b --- /dev/null +++ b/tests/boundary/providers/fixedAllowanceRecovery.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; + +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js"; +import { + fixedAllowanceOptions, + fixedAllowanceRemediation, +} from "../../../src/domain/fixedAllowanceRemediation.js"; +import { analysisClientFailureForOperation } from "../../../src/providers/AnalysisClientFailure.js"; + +const refusal = () => + new AnalysisResourceConstraintError( + "provider_stage", + "memory", + "decoded representation exceeds the fixture allowance", + { maximum_decoded_bytes: 64 }, + fixedAllowanceOptions( + "The explicit-secret fixture decode allowance", + "parser", + ), + ); + +it.each([ + { + boundary: "operation attribution", + copy: (error: AnalysisError) => + analysisClientFailureForOperation(error, "fixture", "inspect_fixture"), + allowance: "The explicit-secret fixture decode allowance", + }, + { + boundary: "explicit redaction", + copy: (error: AnalysisError) => + redactExplicitFailure(error, ["explicit-secret"]), + allowance: "The [REDACTED] fixture decode allowance", + }, +])( + "keeps a fixed allowance's derived recovery through $boundary", + ({ copy, allowance }) => { + const copied = copy(refusal()); + expect(copied).toBeInstanceOf(AnalysisResourceConstraintError); + expect(copied).toMatchObject({ + fixedAllowance: { name: allowance, kind: "parser" }, + remediationAction: undefined, + retryable: false, + }); + expect(projectAnalysisError(copied)).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { action: fixedAllowanceRemediation(allowance, "parser") }, + details: { reported_limits: { maximum_decoded_bytes: 64 } }, + }); + }, +); diff --git a/tests/boundary/providers/native/nativePlistBinarySource.test.ts b/tests/boundary/providers/native/nativePlistBinarySource.test.ts index 2e40cba12f..8cb99886a2 100644 --- a/tests/boundary/providers/native/nativePlistBinarySource.test.ts +++ b/tests/boundary/providers/native/nativePlistBinarySource.test.ts @@ -11,9 +11,11 @@ import { import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js"; import { scanBinaryPlistScalars } from "../../../../src/artifacts/apple/PlistScalars.js"; import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js"; -import { buildBinary } from "plist"; +import { buildBinary, type PlistValue } from "plist"; import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { MAX_NATIVE_PLIST_BYTES } from "../../../../src/native/parsers/plist.js"; +import { MAX_BINARY_PLIST_NESTING_DEPTH } from "../../../../src/artifacts/apple/PropertyListDecodeBudget.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { NativeFixtureRunner, nativeMachoTarget, @@ -62,10 +64,76 @@ it("reports a binary source-size refusal as a typed resource limit", async () => resource: "file-size", reportedLimits: { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, retryable: false, + fixedAllowance: { kind: "input" }, }); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: expect.stringContaining( + "within the fixed 64 MiB native plist input allowance", + ), + }, + }); + expect(projected.remediation.action).not.toMatch(/worker file-size limits/u); expect(runner.tools).toEqual(["file"]); }); +const nested = (depth: number): PlistValue => + depth === 0 ? 1 : [nested(depth - 1)]; + +it.each([ + { + budget: "decode", + // Three times the source length exceeds the 64 MiB decode estimate while + // the source stays below the separate 64 MiB input ceiling. + value: { data: Buffer.alloc(22 * 1024 * 1024, 0x78) }, + reason: "binary plist object table exceeds the native plist decode budget", + limits: { maximum_decoded_bytes: 64 * 1024 * 1024 }, + allowance: "The 64 MiB native binary plist decode allowance", + }, + { + budget: "nesting", + value: nested(MAX_BINARY_PLIST_NESTING_DEPTH + 1), + reason: "binary plist nesting exceeds the native plist decode budget", + limits: { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH }, + allowance: "The 128-level native binary plist nesting allowance", + }, +])( + "names the fixed binary plist $budget allowance instead of host memory", + async ({ value, reason, limits, allowance }) => { + const { root, path } = await binaryPath(); + await writeFile(path, buildBinary(value)); + const runner = new BinaryNativeRunner({ + file: "Apple binary property list\n", + }); + const client = binaryClient(root, runner); + + const result = await client.execute("inspect_plist", { path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected decode allowance refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error).toMatchObject({ + resource: "memory", + reason, + reportedLimits: limits, + retryable: false, + fixedAllowance: { name: allowance, kind: "parser" }, + }); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { resource: "memory", reported_limits: limits }, + }); + expect(projected.remediation.action).toBe( + `${allowance} is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.`, + ); + expect(runner.tools).toEqual(["file"]); + }, +); + it("keeps a malformed binary plist as a selected-input failure", async () => { const { root, path } = await binaryPath(); await writeFile(path, Buffer.from("bplist00bad")); diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts index 078185bec3..6aaa72f98b 100644 --- a/tests/boundary/signals/recordingProvider.test.ts +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -16,6 +16,7 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { Cli } from "incur"; @@ -736,10 +737,10 @@ it.each([ resource: "memory", retryable: false, }); - expect(result.error.remediationAction).toContain( + expect(analysisErrorRemediationAction(result.error)).toContain( "The retained command-output allowance", ); - expect(result.error.remediationAction).not.toMatch( + expect(analysisErrorRemediationAction(result.error)).not.toMatch( /Review the reported worker memory limits/u, ); } From 4423b6fff21f0c36880cd5f26db6c9abfcd26ea6 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:52:06 -0700 Subject: [PATCH 19/29] fix(process): reject finite trace variants that only reorder unordered members (#6006) Duplicate-variant validation keyed each finite trace by its JSON form, which preserves the member order of unordered tokens that matching treats as a multiset. Key traces by the observations they accept instead: sort unordered members and expand a token that repeats one event into that event's sequence, keeping token positions and multiplicities significant. Fixes #5916 Co-authored-by: Claude Opus 5.5 --- .../process/processTraceSpecification.test.ts | 46 ++++++++++++++++ .../process/processTraceSpecification.ts | 22 ++++++-- .../mcp/processTraceAdaptersParity.test.ts | 54 +++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/domain/process/processTraceSpecification.test.ts b/src/domain/process/processTraceSpecification.test.ts index ab649d2aa9..edb1520792 100644 --- a/src/domain/process/processTraceSpecification.test.ts +++ b/src/domain/process/processTraceSpecification.test.ts @@ -305,4 +305,50 @@ describe("finite trace specification validation", () => { }).success, ).toBe(false); }); + + it("rejects variants that accept the same traces as an earlier variant", () => { + const base = specification(); + const result = processTraceSpecificationSchema.safeParse({ + events: [ + { ...base.events[0], cardinality: { kind: "exact", count: 2 } }, + base.events[1], + ], + language: { + kind: "finite_traces", + variants: [ + { + id: "repeated", + trace: [{ unordered: ["ready", "ready"] }, "worker"], + }, + { id: "sequence", trace: ["ready", "ready", "worker"] }, + { + id: "leading", + trace: [{ unordered: ["ready", "worker"] }, "ready"], + }, + { + id: "trailing", + trace: ["ready", { unordered: ["worker", "ready"] }], + }, + { + id: "merged", + trace: [{ unordered: ["worker", "ready", "ready"] }], + }, + { + id: "swapped", + trace: ["ready", { unordered: ["ready", "worker"] }], + }, + ], + }, + }); + expect(result.success).toBe(false); + if (result.success) throw new Error("Expected duplicate finite traces"); + expect( + result.error.issues.map(({ path, message }) => ({ path, message })), + ).toEqual( + [1, 5].map((index) => ({ + path: ["language", "variants", index, "trace"], + message: "finite trace variants must not duplicate a trace", + })), + ); + }); }); diff --git a/src/domain/process/processTraceSpecification.ts b/src/domain/process/processTraceSpecification.ts index 4e28774e07..dd40d6108f 100644 --- a/src/domain/process/processTraceSpecification.ts +++ b/src/domain/process/processTraceSpecification.ts @@ -552,6 +552,22 @@ const countVariantEvents = ( return counts; }; +/** + * Keys a finite trace by the observations it accepts. Matching treats each + * unordered token as a multiset, so its member order is not significant and a + * token that repeats one event accepts only that event's sequence. + */ +const finiteTraceKey = ( + trace: readonly (string | { readonly unordered: readonly string[] })[], +): string => + canonicalTraceJson( + trace.flatMap((token): (string | readonly string[])[] => { + if (typeof token === "string") return [token]; + const members = [...token.unordered].sort(); + return members[0] === members.at(-1) ? members : [members]; + }), + ); + const validateFiniteTraces = ( specification: ProcessTraceSpecification, context: z.RefinementCtx, @@ -572,14 +588,14 @@ const validateFiniteTraces = ( path: [...variantPath, "id"], }); variantIds.add(variant.id); - const canonicalTrace = canonicalTraceJson(variant.trace); - if (variantTraces.has(canonicalTrace)) + const traceKey = finiteTraceKey(variant.trace); + if (variantTraces.has(traceKey)) context.addIssue({ code: "custom", message: "finite trace variants must not duplicate a trace", path: [...variantPath, "trace"], }); - variantTraces.add(canonicalTrace); + variantTraces.add(traceKey); for (const [tokenIndex, token] of variant.trace.entries()) { const values = typeof token === "string" ? [token] : token.unordered; for (const [eventIndex, id] of values.entries()) diff --git a/tests/boundary/mcp/processTraceAdaptersParity.test.ts b/tests/boundary/mcp/processTraceAdaptersParity.test.ts index d8c9b6173c..0fa2f948d3 100644 --- a/tests/boundary/mcp/processTraceAdaptersParity.test.ts +++ b/tests/boundary/mcp/processTraceAdaptersParity.test.ts @@ -155,4 +155,58 @@ describe("declared trace adapter parity", () => { expect(text.match(/first undeclared pair/g)).toHaveLength(1); await client.ping(); }); + + it("rejects a variant that only reorders unordered members through CLI and MCP", async () => { + const left = captureEvidence("left"); + const right = captureEvidence("right"); + const reorderedSpecification = { + ...traceSpecification, + language: { + kind: "finite_traces", + variants: [ + { + id: "forward", + trace: ["before", { unordered: ["exit", "settlement"] }, "after"], + }, + { + id: "reverse", + trace: ["before", { unordered: ["settlement", "exit"] }, "after"], + }, + ], + }, + }; + const root = await createTestTempDirectory("rea-trace-duplicates-"); + temporaryRoots.push(root); + const leftPath = join(root, "left.json"); + const rightPath = join(root, "right.json"); + const specificationPath = join(root, "trace.json"); + await Promise.all([ + writeFile(leftPath, JSON.stringify(left)), + writeFile(rightPath, JSON.stringify(right)), + writeFile(specificationPath, JSON.stringify(reorderedSpecification)), + ]); + const message = "finite trace variants must not duplicate a trace"; + expect( + await compareProcessEvidenceFiles(leftPath, rightPath, specificationPath), + ).toMatchObject({ + category: "invalid_input", + details: { + issues: [ + { path: ["trace_spec", "language", "variants", 1, "trace"], message }, + ], + }, + }); + const client = await comparisonClient(left, right); + const response = await client.callTool({ + name: "compare_process_captures", + arguments: { left, right, trace_spec: reorderedSpecification }, + }); + expect(response.isError).toBe(true); + expect( + response.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"), + ).toContain(message); + }); }); From c8d3f8f8ec4efa1e66e25f680e83500d1f390bd3 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:52:40 -0700 Subject: [PATCH 20/29] fix(firmware): keep a missing producer report distinct after exit 0 (#6008) * fix(firmware): keep a missing producer report distinct after exit 0 A firmware engine that exits 0 without writing its requested report now fails with the observed report state (missing, empty, invalid_json, oversized, not_regular_file), the command, arguments, exit status and captured output, plus report-specific recovery instead of generic `rea doctor` advice. REA still does not retry or substitute an empty result. Fixes #5876 Co-Authored-By: Claude Opus 5.5 * fix(firmware): keep missing-report recovery from replaying removed paths The recorded command arguments name REA's private workspace, which is removed after the attempt. Recovery now presents them as provenance and tells callers to replace every private path when reproducing the run. Cover the oversized and not_regular_file report states. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/firmware-analysis.md | 3 + src/firmware/FirmwareCommand.ts | 57 +++++++++----- src/firmware/FirmwareFiles.ts | 59 +++++++++++--- .../firmware/firmwareIntegration.test.ts | 66 +++++++++++----- .../firmware/firmwareReportFiles.test.ts | 76 +++++++++++++++++++ 5 files changed, 210 insertions(+), 51 deletions(-) diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index b0f92c14dc..7cbbc26923 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -183,6 +183,9 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture. 120 CPU seconds and zero core-dump allowance through `prlimit`. - JSON reports: at most **8 MiB** and valid UTF-8; malformed encoding and oversized reports fail without repairing or truncating producer data. + Exit status 0 is not an empty result: a missing, empty, malformed or + oversized report fails with `unreadable_output`, its `report_state`, the + command, exit status and captured output. REA does not retry automatically. Diagnostic output is bounded with exact captured byte counts. - Default staging budget: **256 MiB** and **10,000 entries**, configurable using `max_output_bytes` / `max_output_files` (CLI `--max-output-bytes` / diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 1b4362c47d..b677f79535 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -291,7 +291,12 @@ export const runFirmwareCommand = async (context: { }; }; -/** Preserve tool failure diagnostics when an accepted exit 1 has no usable report. */ +/** + * Consume the report a completed command was asked to write. A successful exit + * status is not analysis Evidence by itself: an absent, empty or malformed report + * fails with the observed report state and command facts, without retrying or + * substituting an empty result. + */ export const readFirmwareCommandReport = async (context: { path: string; engine: string; @@ -308,33 +313,43 @@ export const readFirmwareCommandReport = async (context: { ); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) throw cause; - if (context.execution.exit_code === 1) - throw new ProviderAdapterError(context.engine, context.operation, { + const { engine, operation, execution } = context; + const reportFailure = + cause instanceof AnalysisOutputError + ? cause.reason + : `Report could not be read: ${cause instanceof Error ? cause.message : String(cause)}`; + const diagnostics = { + ...(cause instanceof AnalysisOutputError ? cause.diagnostics : {}), + report_path: context.path, + report_failure: reportFailure, + command: execution.command, + args: execution.args, + resource_limiter: execution.resource_limiter, + exit_code: execution.exit_code ?? null, + signal: execution.signal, + stdout: execution.stdout.text, + stderr: execution.stderr.text, + stdout_bytes: execution.stdout.bytes, + stderr_bytes: execution.stderr.bytes, + }; + if (execution.exit_code === 1) + throw new ProviderAdapterError(engine, operation, { cause, diagnostics: { reason: "Tool exited 1 without a usable analysis report; inspect stderr for startup, sandbox or dependency failures", - report_failure: - cause instanceof Error ? cause.message : String(cause), - exit_code: 1, - signal: context.execution.signal, - stdout: context.execution.stdout.text, - stderr: context.execution.stderr.text, - stdout_bytes: context.execution.stdout.bytes, - stderr_bytes: context.execution.stderr.bytes, + ...diagnostics, }, }); - const diagnostics = `stdout (${context.execution.stdout.bytes} bytes): ${context.execution.stdout.text}; stderr (${context.execution.stderr.bytes} bytes): ${context.execution.stderr.text}`; - if (cause instanceof AnalysisOutputError) - throw new AnalysisOutputError( - context.operation, - `${cause.reason}; ${diagnostics}`, - { cause }, - ); throw new AnalysisOutputError( - context.operation, - `Tool did not produce the expected complete report: ${context.path}; ${diagnostics}`, - { cause }, + operation, + `${engine} exited ${String(execution.exit_code)} without a usable report: ${reportFailure}`, + { + cause, + diagnostics, + userMessage: `${engine} exited successfully but left no usable report (${reportFailure}), so ${operation} produced no Evidence. A successful exit status is not an empty or complete result.`, + remediationAction: `Review diagnostics.report_state, the exit status and captured stdout/stderr. REA did not retry or substitute an empty result. diagnostics.command and diagnostics.args record what ran; their workspace paths are REA-private and removed after the attempt, so they cannot be replayed as-is. Rerun the same REA request to check whether this is intermittent; each attempt uses a fresh workspace. If it recurs, copy the selected input bytes into a new directory and run diagnostics.command with the same flags, replacing every private path with your copy and a new report path, under the documented firmware resource limits; confirm that it writes the requested report. \`rea doctor\` checks configuration only and cannot recover this report.`, + }, ); } }; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 1451af6106..699f276e34 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -188,7 +188,11 @@ export class FirmwareFileAccess { ); } - /** Read a complete bounded report; oversized data is never reported as complete. */ + /** + * Read a complete bounded report; oversized data is never reported as complete. + * Each refusal records its observed report state, so an absent or empty report + * is never conflated with an empty scan or a malformed one. + */ async report( path: string, operation: string, @@ -201,11 +205,14 @@ export class FirmwareFileAccess { FIRMWARE_LIMITS.reportBytes, ); if (bytes === undefined) - throw new AnalysisOutputError( + throw firmwareReportFailure( operation, + "oversized", `Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`, ); assertArtifactReadActive(signal); + if (bytes.length === 0) + throw firmwareReportFailure(operation, "empty", "Report is empty"); try { const value: unknown = JSON.parse( new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( @@ -214,25 +221,57 @@ export class FirmwareFileAccess { ); return value; } catch (cause: unknown) { - throw new AnalysisOutputError( + throw firmwareReportFailure( operation, + "invalid_json", "Tool report is not valid complete JSON", - { cause }, + cause, ); } }).catch((cause: unknown) => { - throw firmwareAdmissionFailure( - cause, - (primary) => - new AnalysisOutputError(operation, "Report is not a regular file", { - cause: primary, - }), + throw mapArtifactFileFailure(cause, (primary) => + primary instanceof NonRegularFileReadError + ? firmwareReportFailure( + operation, + "not_regular_file", + "Report is not a regular file", + primary, + ) + : primary instanceof Error && + "code" in primary && + primary.code === "ENOENT" + ? firmwareReportFailure( + operation, + "missing", + "Report was not created", + primary, + ) + : primary, ); }), ); } } +/** Observed state of a producer report that cannot supply analysis Evidence. */ +type FirmwareReportState = + | "missing" + | "empty" + | "oversized" + | "invalid_json" + | "not_regular_file"; + +const firmwareReportFailure = ( + operation: string, + state: FirmwareReportState, + reason: string, + cause?: unknown, +) => + new AnalysisOutputError(operation, reason, { + cause, + diagnostics: { report_state: state }, + }); + /** Preserve admission semantics inside the same exact owner's physical outcome. */ const firmwareAdmissionFailure = ( cause: unknown, diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index d2f1a22872..13cfadb00d 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -730,7 +730,7 @@ it("retains startup stderr when Unblob exits 1 without a report", async () => { diagnostics: { exit_code: 1, stderr: expect.stringContaining("Landlock sandbox is not available"), - report_failure: expect.stringContaining("ENOENT"), + report_state: "missing", }, }, }); @@ -740,25 +740,51 @@ it("retains startup stderr when Unblob exits 1 without a report", async () => { await assertFirmwareCleanup(fixture.launches); }); -it("distinguishes a successful process with missing output from startup failure", async () => { - const fixture = await firmwareFixture("missing-report"); - const result = await fixture.service.execute("extract_firmware", { - path: fixture.path, - output_directory: fixture.output, - }); - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisOutputError", - reason: expect.stringContaining("expected complete report"), - }, - }); - if (result.ok) throw new Error("Expected missing-output failure"); - expect(projectAnalysisError(result.error)).toMatchObject({ - details: { reason: expect.stringContaining("Producer forgot its report") }, - }); - await assertFirmwareCleanup(fixture.launches); -}); +it.each([ + { operation: "inspect_firmware_regions", engine: "binwalk" }, + { operation: "extract_firmware", engine: "unblob" }, +] as const)( + "keeps $engine's missing report after exit 0 distinct from an empty result", + async ({ operation, engine }) => { + const fixture = await firmwareFixture("missing-report"); + const result = await fixture.service.execute(operation, { + path: fixture.path, + ...(operation === "extract_firmware" + ? { output_directory: fixture.output } + : {}), + }); + if (result.ok) throw new Error("Expected missing-report failure"); + const error = { + code: "unreadable_output", + retryable: false, + message: expect.stringContaining("not an empty or complete result"), + details: { + reason: `${engine} exited 0 without a usable report: Report was not created`, + diagnostics: { + report_state: "missing", + report_path: expect.stringMatching(/report\.json$/), + command: expect.any(String), + args: expect.any(Array), + exit_code: 0, + signal: null, + stderr: "Producer forgot its report\n", + }, + }, + remediation: { + action: expect.stringContaining("did not retry or substitute"), + }, + }; + expect(projectAnalysisError(result.error)).toMatchObject(error); + expect(projectAnalysisError(result.error).message).not.toContain( + "rea doctor", + ); + expect( + delivery.toCallToolResult(result, toolContract(operation)) + .structuredContent, + ).toMatchObject({ error }); + await assertFirmwareCleanup(fixture.launches); + }, +); it("cancels an owned active process and permits the next queued operation", async () => { const fixture = await firmwareFixture("stall"); diff --git a/tests/boundary/firmware/firmwareReportFiles.test.ts b/tests/boundary/firmware/firmwareReportFiles.test.ts index 69360ef25d..7415856728 100644 --- a/tests/boundary/firmware/firmwareReportFiles.test.ts +++ b/tests/boundary/firmware/firmwareReportFiles.test.ts @@ -6,6 +6,10 @@ import { inventoryFirmwareOutput, FirmwareFileAccess, } from "../../../src/firmware/FirmwareFiles.js"; +import { readFirmwareCommandReport } from "../../../src/firmware/FirmwareCommand.js"; +import { FIRMWARE_LIMITS } from "../../../src/firmware/FirmwareRelease.js"; +import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it.each([ @@ -35,6 +39,78 @@ it.each([ }, ); +it.each([ + ["missing", "Report was not created", async () => {}], + ["empty", "Report is empty", (path: string) => writeFile(path, "")], + [ + "invalid_json", + "Tool report is not valid complete JSON", + (path: string) => writeFile(path, '[{"Analysis":'), + ], + [ + "oversized", + `Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`, + (path: string) => + writeFile(path, Buffer.alloc(FIRMWARE_LIMITS.reportBytes + 1, 0x20)), + ], + [ + "not_regular_file", + "Report is not a regular file", + (path: string) => mkdir(path), + ], +] as const)( + "keeps report state %s after a successful exit distinct from an empty scan", + async (state, reason, prepare) => { + const root = await createTestTempDirectory("rea-firmware-report-"); + const path = join(root, "report.json"); + await prepare(path); + const failure: unknown = await readFirmwareCommandReport({ + path, + engine: "binwalk", + operation: "inspect_firmware_regions", + execution: { + stdout: { text: "", bytes: 0, observedBytes: 0 }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exit_code: 0, + signal: null, + command: "/opt/binwalk", + args: ["--quiet", "--log", path, "/work/firmware.bin"], + resource_limiter: "/usr/bin/prlimit", + }, + files: new FirmwareFileAccess(), + }).catch((cause: unknown) => cause); + if (!(failure instanceof AnalysisError)) + throw new Error("Expected a typed report failure"); + const projected = projectAnalysisError(failure); + expect(projected).toMatchObject({ + code: "unreadable_output", + retryable: false, + message: expect.stringContaining("not an empty or complete result"), + details: { + reason: `binwalk exited 0 without a usable report: ${reason}`, + diagnostics: { + report_state: state, + report_path: path, + command: "/opt/binwalk", + args: ["--quiet", "--log", path, "/work/firmware.bin"], + resource_limiter: "/usr/bin/prlimit", + exit_code: 0, + signal: null, + stdout_bytes: 0, + stderr_bytes: 0, + }, + }, + }); + // Generic doctor advice is not presented as a repair for a missing report. + expect(projected.message).not.toContain("rea doctor"); + // Recorded arguments name a removed private workspace; recovery must not + // present them as a replayable command. + expect(projected.remediation.action).toMatch( + /diagnostics\.report_state.*cannot be replayed as-is.*replacing every private path/, + ); + }, +); + it("preserves valid Unicode and a literal replacement character in producer JSON", async () => { const root = await createTestTempDirectory("rea-firmware-report-"); const path = join(root, "report.json"); From 63b43b69bbc28855cdc47e9842efd85d69718a75 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:53:42 -0700 Subject: [PATCH 21/29] fix(javascript): refuse Webpack loaders that can fall off the end (#6007) * fix(javascript): refuse Webpack loaders that can fall off the end The bootstrap integrity proof accepted any loader whose explicit returns all yielded the module's exports. A loader with no returns, or with only the cache-hit return, passed vacuously even though its miss path returns undefined, so a bundled `n(45840).BrowserWindow` became an exact Electron window. Require that the loader body cannot complete normally before the proof checks its returns, so the bundled-call identity stays unresolved while the literal require("electron") reference is preserved. Fixes #5958 Co-Authored-By: Claude Opus 5.5 * test(javascript): cover finally completion in Webpack loader proofs Group the loader completion-path cases in their own suite and add focused finally cases: a completing finally keeps a falling-through catch unproven, a finally returning another value stays unresolved, and a finally returning the exports proves the loader. Co-Authored-By: Claude Opus 5.5 * test(javascript): keep IPC checks for unresolved Webpack loader cases Share the unresolved-identity assertion so the completion-path cases also reject directly attributed IPC observations. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../electronBundledRequireIdentity.test.ts | 99 ++++++++++++++----- .../javascriptBootstrapIntegrity.ts | 46 +++++++-- .../webpackLoaderReturnsCli.test.ts | 62 ++++++++++++ 3 files changed, 173 insertions(+), 34 deletions(-) create mode 100644 tests/acceptance/applications/webpackLoaderReturnsCli.test.ts diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index ff1e187fa8..e3f34043ef 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -18,6 +18,18 @@ const CONSUMER = const electronOf = (source: string) => analyzeJavaScriptStaticSource(source).electron; +const expectUnresolved = (source: string) => { + const electron = electronOf(source); + expect(electron.browser_windows).toEqual([]); + // Only direct observations come from this identity; the separate + // wrapper-substitution path keeps its own resolution basis. + expect( + electron.ipc.filter( + ({ resolution_basis: basis }) => basis !== "wrapper-substitution", + ), + ).toEqual([]); +}; + describe("Electron identity through Webpack externals", () => { it("follows the default-export getter produced by webpack n.n", () => { const source = bootstrap( @@ -142,15 +154,7 @@ describe("Electron identity through Webpack externals", () => { bootstrap(EXTERNAL, CONSUMER.replace("n(45840)", "n(45841)")), ], ])("keeps %s unresolved", (_name, source) => { - const electron = electronOf(source); - expect(electron.browser_windows).toEqual([]); - // Only direct observations come from this identity; the separate - // wrapper-substitution path keeps its own resolution basis. - expect( - electron.ipc.filter( - ({ resolution_basis: basis }) => basis !== "wrapper-substitution", - ), - ).toEqual([]); + expectUnresolved(source); }); }); @@ -214,15 +218,7 @@ describe("Webpack externals with a tampered runtime", () => { ), ], ])("keeps %s unresolved", (_name, source) => { - const electron = electronOf(source); - expect(electron.browser_windows).toEqual([]); - // Only direct observations come from this identity; the separate - // wrapper-substitution path keeps its own resolution basis. - expect( - electron.ipc.filter( - ({ resolution_basis: basis }) => basis !== "wrapper-substitution", - ), - ).toEqual([]); + expectUnresolved(source); }); }); @@ -287,15 +283,7 @@ describe("Webpack externals with a tampered loader or module object", () => { ), ], ])("keeps %s unresolved", (_name, source) => { - const electron = electronOf(source); - expect(electron.browser_windows).toEqual([]); - // Only direct observations come from this identity; the separate - // wrapper-substitution path keeps its own resolution basis. - expect( - electron.ipc.filter( - ({ resolution_basis: basis }) => basis !== "wrapper-substitution", - ), - ).toEqual([]); + expectUnresolved(source); }); it.each([ @@ -331,3 +319,60 @@ describe("Webpack externals with a tampered loader or module object", () => { ]); }); }); + +describe("Webpack externals through the loader's completion paths", () => { + const MISS_RETURN = "return m[t](o,o.exports,n),o.exports}"; + const missPath = (tail: string) => + bootstrap(EXTERNAL, CONSUMER).replace(MISS_RETURN, tail); + + // Falling off the end returns undefined, not the dispatched exports. + it.each([ + ["returns only cached exports", missPath("m[t](o,o.exports,n)}")], + [ + "has no returns", + bootstrap(EXTERNAL, CONSUMER).replace( + `var r=c[t];if(void 0!==r)return r.exports;var o=c[t]={exports:{}};${MISS_RETURN}`, + "var o=c[t]={exports:{}};m[t](o,o.exports,n)}", + ), + ], + [ + "returns from one branch", + missPath("m[t](o,o.exports,n);if(t)return o.exports}"), + ], + [ + "returns from a try whose catch falls through", + missPath("try{return m[t](o,o.exports,n),o.exports}catch(x){}}"), + ], + [ + "falls through a catch past a completing finally", + missPath( + "try{return m[t](o,o.exports,n),o.exports}catch(x){}finally{t}}", + ), + ], + [ + "returns another value from a finally", + missPath( + "try{return m[t](o,o.exports,n),o.exports}finally{return local}}", + ), + ], + ])("keeps a loader that %s unresolved", (_name, source) => { + expectUnresolved(source); + }); + + it.each([ + [ + "returns exports from both branches", + missPath( + "m[t](o,o.exports,n);if(t)return o.exports;else return o.exports}", + ), + ], + [ + "returns exports from a finally after a falling-through catch", + missPath("try{m[t](o,o.exports,n)}catch(x){}finally{return o.exports}}"), + ], + ])("still resolves a loader that %s", (_name, source) => { + expect(electronOf(source).browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + }); +}); diff --git a/src/domain/javascript/javascriptBootstrapIntegrity.ts b/src/domain/javascript/javascriptBootstrapIntegrity.ts index 1e748df414..69043fe079 100644 --- a/src/domain/javascript/javascriptBootstrapIntegrity.ts +++ b/src/domain/javascript/javascriptBootstrapIntegrity.ts @@ -30,11 +30,12 @@ interface LoaderShape { /** * Prove that `loader(key)` returns the exports of the factory declared at - * `table[key]`: the loader returns only the dispatched module's exports or a - * cache entry it created for that module, the table has only literal keys, - * and the table and cache are referenced only by the loader or through the - * runtime's `loader.m`/`loader.c` exposure. Literal-key writes through that - * exposure replace their slot. Returns undefined when any of this is unproven. + * `table[key]`: every normal completion of the loader returns the dispatched + * module's exports or a cache entry it created for that module, the table has + * only literal keys, and the table and cache are referenced only by the loader + * or through the runtime's `loader.m`/`loader.c` exposure. Literal-key writes + * through that exposure replace their slot. Returns undefined when any of this + * is unproven. */ export const proveJavaScriptBootstrapIntegrity = ( call: t.CallExpression, @@ -74,12 +75,14 @@ const loaderShape = ( const parameterId = t.isIdentifier(parameter) ? bindingOf(state, parameter) : undefined; - // A rewritten key would dispatch a different slot than the caller named. + // A rewritten key would dispatch a different slot than the caller named, + // and falling off the end returns undefined instead of the exports. if ( parameterId === undefined || state.bindingsById.get(parameterId)?.definitions.length !== 1 || loader.async || - loader.generator + loader.generator || + completesNormally(loader.body) ) return undefined; const dispatches = new Set(); @@ -129,6 +132,35 @@ const loaderShape = ( : undefined; }; +/** + * Whether control may reach the end of `statement`. Loops, switches and labels + * can absorb a `break`, so they are assumed to complete. + */ +const completesNormally = (statement: t.Statement): boolean => { + if (t.isReturnStatement(statement) || t.isThrowStatement(statement)) + return false; + if (t.isBlockStatement(statement)) + return statement.body.every(completesNormally); + if (t.isIfStatement(statement)) + return ( + statement.alternate === null || + statement.alternate === undefined || + completesNormally(statement.consequent) || + completesNormally(statement.alternate) + ); + if (t.isTryStatement(statement)) + return ( + (statement.finalizer === null || + statement.finalizer === undefined || + completesNormally(statement.finalizer)) && + (completesNormally(statement.block) || + (statement.handler !== null && + statement.handler !== undefined && + completesNormally(statement.handler.body))) + ); + return true; +}; + /** `table[key](module, ...)` or `table[key].call(thisArg, module, ...)`. */ const tableDispatch = ( call: t.CallExpression, diff --git a/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts b/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts new file mode 100644 index 0000000000..8a12905090 --- /dev/null +++ b/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts @@ -0,0 +1,62 @@ +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const CACHE_HIT = ` + var cached = cache[key]; + if (cached !== undefined) return cached.exports;`; + +const loaderSource = (cacheHit: string, missReturn: string) => `(function () { + var modules = { + 45840: function (module) { module.exports = require("electron"); }, + 1: function (module, exports, n) { new (n(45840).BrowserWindow)({}); } + }, cache = {}; + function n(key) {${cacheHit} + var module = (cache[key] = { exports: {} }); + modules[key](module, module.exports, n);${missReturn} + } + n(1); +})();`; + +cliTest.for([ + [ + "the canonical loader", + loaderSource(CACHE_HIT, "\n return module.exports;"), + 1, + ], + ["a loader that returns only cached exports", loaderSource(CACHE_HIT, ""), 0], + ["a loader without returns", loaderSource("", ""), 0], +] as const)( + "attributes the BrowserWindow to Electron only through %s", + async ([, source, windows], { cli }) => { + const root = await createTestTempDirectory("rea-webpack-loader-returns-"); + await writeFixtureFiles(root, { + "main.js": source, + "package.json": JSON.stringify({ name: "fixture", main: "main.js" }), + }); + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(result.summary.browser_windows).toBe(windows); + // The literal external stays observed even when the loader is unproven. + expect( + result.graph.nodes.flatMap(({ observations }) => + observations.map(({ properties }) => properties), + ), + ).toContainEqual( + expect.objectContaining({ + reference_kind: "require", + specifier: "electron", + }), + ); + }, +); From 2319166c2e5e2c0fe54e985363f297b0e3b3419f Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:54:37 -0700 Subject: [PATCH 22/29] fix(reconstruction): keep complete contradictions beside incomplete entries (#6009) The obligation ledger flattened every contradiction entry for an obligation into one Evidence list, so one entry naming absent Evidence suppressed a sibling entry whose Evidence was all present. Group whole manifest entries and evaluate each one: any complete entry still reports `contradiction`, while absent references from any entry stay visible as `contradiction-evidence-missing`. Share the ready packaged-process request fixture with the CLI boundary test, which now covers both entry orders through the public command. Fixes #5925 Co-authored-by: Claude Opus 5.5 --- ...nstructionObligationLedger.closure.test.ts | 58 +---------------- .../ReconstructionObligationLedger.fixture.ts | 57 +++++++++++++++++ ...constructionObligationLedgerDiagnostics.ts | 52 +++++++++------- ...econstructionObligationLedgerEvaluation.ts | 2 +- .../reconstructionObligationLedgerCli.test.ts | 62 +++++++++++++++++++ 5 files changed, 151 insertions(+), 80 deletions(-) diff --git a/src/application/ReconstructionObligationLedger.closure.test.ts b/src/application/ReconstructionObligationLedger.closure.test.ts index d3404ad13f..a0a2ec970a 100644 --- a/src/application/ReconstructionObligationLedger.closure.test.ts +++ b/src/application/ReconstructionObligationLedger.closure.test.ts @@ -1,73 +1,17 @@ import { describe, expect, it } from "vitest"; import { - boundProofEvidence, build, originalCases, processEvidence, proofEvidence, + readyProcessLedgerRequest, request, reviewedObligation, } from "./ReconstructionObligationLedger.fixture.js"; import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; -/** Build a request whose packaged-process obligation closes as ready. */ -const readyProcessLedgerRequest = () => { - const capture = processEvidence(); - const initial = build(request([capture])); - const obligation = initial.obligations[0]; - if (obligation === undefined) throw new Error("Expected process obligation"); - const fixtureIds = obligation.required_case_kinds.map( - (caseKind) => `packaged.${caseKind}`, - ); - const proof = boundProofEvidence("packaged", { - obligation_ids: [obligation.obligation_id], - fixture_ids: fixtureIds, - case_kinds: obligation.required_case_kinds, - verifier_ids: ["verifier.packaged"], - claim_ids: ["claim.process"], - }); - const ledgerRequest = request([capture, proof], { - manifest: { - bindings: [ - { - obligation_id: obligation.obligation_id, - owner: { - module_path: "src/processOwner.ts", - symbol: "runProcess", - owner_sha256: "2".repeat(64), - }, - parser_type: null, - original_cases: originalCases(obligation, capture.evidence_id), - fixtures: obligation.required_case_kinds.map((caseKind) => ({ - fixture_id: `packaged.${caseKind}`, - case_kind: caseKind, - authority: "packaged-process", - evidence_ids: [proof.evidence_id], - })), - verifier: { - verifier_id: "verifier.packaged", - claim_id: "claim.process", - command: "npm run verify:package -- process", - authority: "packaged-process", - status: "pass", - result_evidence_id: proof.evidence_id, - enumerated_obligation_ids: [obligation.obligation_id], - nondeterminism: { - mode: "partial-order", - specification: - "Startup precedes teardown; worker completion is unordered.", - }, - }, - }, - ], - contradictions: [], - }, - }); - return ledgerRequest; -}; - describe("reconstruction obligation ledger", () => { it("emits the complete deterministic ledger and closure identity", () => { const evidence = proofEvidence("review"); diff --git a/src/application/ReconstructionObligationLedger.fixture.ts b/src/application/ReconstructionObligationLedger.fixture.ts index ae535025fe..9d87ea709f 100644 --- a/src/application/ReconstructionObligationLedger.fixture.ts +++ b/src/application/ReconstructionObligationLedger.fixture.ts @@ -120,6 +120,63 @@ export const originalCases = ( location: `/normalized_result/cases/${caseKind}`, })); +/** Build a request whose packaged-process obligation closes as ready. */ +export const readyProcessLedgerRequest = + (): ReconstructionObligationLedgerInput => { + const capture = processEvidence(); + const initial = build(request([capture])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const fixtureIds = obligation.required_case_kinds.map( + (caseKind) => `packaged.${caseKind}`, + ); + const proof = boundProofEvidence("packaged", { + obligation_ids: [obligation.obligation_id], + fixture_ids: fixtureIds, + case_kinds: obligation.required_case_kinds, + verifier_ids: ["verifier.packaged"], + claim_ids: ["claim.process"], + }); + return request([capture, proof], { + manifest: { + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "2".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: + "Startup precedes teardown; worker completion is unordered.", + }, + }, + }, + ], + contradictions: [], + }, + }); + }; + /** Create a minimal reviewed obligation for ledger closure scenarios. */ export const reviewedObligation = ( obligationId: string, diff --git a/src/application/ReconstructionObligationLedgerDiagnostics.ts b/src/application/ReconstructionObligationLedgerDiagnostics.ts index 5d53d8f4ac..25a2f523f6 100644 --- a/src/application/ReconstructionObligationLedgerDiagnostics.ts +++ b/src/application/ReconstructionObligationLedgerDiagnostics.ts @@ -7,6 +7,7 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type Diagnostic = ReconstructionObligation["diagnostics"][number]; type Binding = NonNullable; +type Contradiction = ReconstructionObligationManifest["contradictions"][number]; export const groupManifestBindings = ( manifest: ReconstructionObligationManifest, @@ -22,12 +23,12 @@ export const groupManifestBindings = ( export const groupManifestContradictions = ( manifest: ReconstructionObligationManifest, -): ReadonlyMap => { - const grouped = new Map(); +): ReadonlyMap => { + const grouped = new Map(); for (const contradiction of manifest.contradictions) grouped.set(contradiction.obligation_id, [ ...(grouped.get(contradiction.obligation_id) ?? []), - ...contradiction.evidence_ids, + contradiction, ]); return grouped; }; @@ -59,26 +60,33 @@ export const uniqueObligationDiagnostics = ( export const contradictionDiagnostics = ( obligationId: string, - contradictions: ReadonlyMap, + contradictions: ReadonlyMap, evidenceById: ReadonlyMap, ): Diagnostic[] => { - const evidenceIds = contradictions.get(obligationId) ?? []; - if (evidenceIds.length === 0) return []; - const missing = evidenceIds.filter( - (evidenceId) => !evidenceById.has(evidenceId), + // Each manifest entry is a separate claim. Absent Evidence leaves only its + // own entry unestablished; it must not hide a sibling whose Evidence exists. + const entries = contradictions.get(obligationId) ?? []; + const missing = new Set( + entries.flatMap(({ evidence_ids: ids }) => + ids.filter((evidenceId) => !evidenceById.has(evidenceId)), + ), ); - return missing.length > 0 - ? [ - { - code: "contradiction-evidence-missing", - detail: `Contradiction references missing Evidence: ${missing.join(", ")}.`, - }, - ] - : [ - { - code: "contradiction", - detail: - "Authenticated contradictory Evidence reopens this obligation.", - }, - ]; + const diagnostics: Diagnostic[] = []; + if (missing.size > 0) + addObligationDiagnostic( + diagnostics, + "contradiction-evidence-missing", + `Contradiction references missing Evidence: ${[...missing].join(", ")}.`, + ); + if ( + entries.some(({ evidence_ids: ids }) => + ids.every((evidenceId) => evidenceById.has(evidenceId)), + ) + ) + addObligationDiagnostic( + diagnostics, + "contradiction", + "Authenticated contradictory Evidence reopens this obligation.", + ); + return diagnostics; }; diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts index ea54a3608b..af6be2b371 100644 --- a/src/application/ReconstructionObligationLedgerEvaluation.ts +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -47,7 +47,7 @@ interface EvaluationContext { readonly candidates: ReadonlyMap; readonly evidence: ReadonlyMap; readonly bindings: ReadonlyMap; - readonly contradictions: ReadonlyMap; + readonly contradictions: ReturnType; } export const evaluateReconstructionObligationLedger = ({ diff --git a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts index 532707c594..1392ecbf8c 100644 --- a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts +++ b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts @@ -8,6 +8,7 @@ import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { MIXED_AUTHORITY_SUMMARY, mixedAuthorityRequest, + readyProcessLedgerRequest, } from "../../../src/application/ReconstructionObligationLedger.fixture.js"; const execute = promisify(execFile); @@ -161,3 +162,64 @@ describe("reconstruction obligation ledger CLI parity", () => { ]); }, 20_000); }); + +describe("reconstruction obligation ledger CLI contradictions", () => { + it("keeps a complete contradiction beside an entry naming absent Evidence", async () => { + const input = readyProcessLedgerRequest(); + const obligationId = input.manifest.bindings[0]?.obligation_id; + const [present, other] = input.evidence_bundle.records.map( + ({ evidence_id: id }) => id, + ); + if ( + obligationId === undefined || + present === undefined || + other === undefined + ) + throw new Error("Expected a bound obligation and two Evidence records"); + const absent = `ev_${"f".repeat(64)}`; + const complete = { + obligation_id: obligationId, + evidence_ids: [present, other], + }; + const incomplete = { + obligation_id: obligationId, + evidence_ids: [present, absent], + }; + // Each entry is its own claim; entry order must not decide the outcome. + for (const contradictions of [ + [complete, incomplete], + [incomplete, complete], + ]) { + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "build-reconstruction-obligation-ledger", + JSON.stringify({ + ...input, + manifest: { ...input.manifest, contradictions }, + }), + "--json", + ], + { cwd: process.cwd(), env: process.env, maxBuffer: 16 * 1_024 * 1_024 }, + ); + const ledger = JSON.parse(stdout).normalized_result; + expect(ledger).toMatchObject({ + status: "failed", + reports: { contradicted_obligation_ids: [obligationId] }, + obligations: [ + { + status: "contradicted", + diagnostics: [ + { + code: "contradiction-evidence-missing", + detail: `Contradiction references missing Evidence: ${absent}.`, + }, + { code: "contradiction" }, + ], + }, + ], + }); + } + }, 20_000); +}); From 1f92c773989585a7c0f3255b7a9264789394908c Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:56:38 -0700 Subject: [PATCH 23/29] fix(adb): report an unparsed push checksum as unverified (#6010) fix(adb): derive push digest source from the parsed device digest A zero-exit sha256sum whose output held no recognizable digest produced device_sha256: null with device_digest_source: "device_sha256sum" and dropped the not-device-side-verified limitation. The push receipt now takes one tagged device digest, so the source can only name sha256sum when a digest was parsed; unrecognized output reports "unavailable", the unverified limitation, and the raw command output. The sha256sum parser moves to AdbShellOutput and accepts GNU's escaped-name prefix. Fixes #5924 Co-authored-by: Claude Opus 5.5 --- docs/adb-device-analysis.md | 4 +- src/adb/AdbProvider.test.ts | 94 +++++++++++++++++++++++++++---------- src/adb/AdbProvider.ts | 70 +++++++++++++++++++++------ src/adb/AdbShellOutput.ts | 8 ++++ 4 files changed, 136 insertions(+), 40 deletions(-) diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 9017317a5a..035810baa1 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -172,7 +172,9 @@ and during shutdown. transfer even with `overwrite`; only a clean `test -e` exit of 1 with no output is interpreted as absence. The check is not atomic, so a different process can create the path before `adb push` writes it. When available, the device's - `sha256sum` verifies the transfer; installs preserve the + `sha256sum` verifies the transfer. A missing command or output without a + recognizable digest reports `device_digest_source: "unavailable"` with an + unverified-transfer limitation; installs preserve the device's `Failure [...]` reason verbatim. - **Producer identity stays attached.** Every result embeds the producing adb client (`binary_path`, `path_source`, `version`, `installed_path`). diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 23586da063..8ef84f200a 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1049,34 +1049,78 @@ exit 0`, }, ); -it("pushes a file and verifies the device-side digest", async () => { - const { binary, root } = await writeAdbStub( - () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +const PUSHED_DIGEST = + "8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043"; + +it.each([ + { + answer: "a digest line", + stdout: `${PUSHED_DIGEST} /data/local/tmp/payload.bin\n`, + exit: 0, + verified: true, + }, + { + // GNU sha256sum prefixes a line whose echoed name it escaped. + answer: "an escaped digest line", + stdout: `\\${PUSHED_DIGEST} /data/local/tmp/pay\\\\load.bin\n`, + exit: 0, + verified: true, + }, + { answer: "empty output", stdout: "", exit: 0, verified: false }, + { + answer: "a malformed line", + stdout: `${PUSHED_DIGEST.slice(1)} /data/local/tmp/payload.bin\n`, + exit: 0, + verified: false, + }, + { answer: "a failed command", stdout: "", exit: 127, verified: false }, +])( + "derives the push digest source from $answer", + async ({ stdout, exit, verified }) => { + const { binary, root } = await writeAdbStub( + (stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi -if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043" "$5"; exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then cat "${join(stubRoot, "sha256sum.out")}"; exit ${exit}; fi exit 1`, - ); - const localFile = join(root, "payload.bin"); - await writeFile(localFile, "push-payload"); - const digest = createHash("sha256").update("push-payload").digest("hex"); - const pushed = await evidenceResult(providerOver(binary), { - operation: "push_adb_file", - input: { - serial: "emulator-5554", - local_path: localFile, - device_path: "/data/local/tmp/payload.bin", - overwrite: false, - }, - }); - expect(pushed).toMatchObject({ - bytes: 12, - sha256: digest, - device_sha256: digest, - overwritten: false, - device_digest_source: "device_sha256sum", - }); -}); + ); + await writeFile(join(root, "sha256sum.out"), stdout); + const localFile = join(root, "payload.bin"); + await writeFile(localFile, "push-payload"); + const result = await providerOver(binary).execute({ + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: localFile, + device_path: "/data/local/tmp/payload.bin", + overwrite: false, + }, + }); + if (!result.ok) throw new Error(result.error.message); + expect(result.value.result).toMatchObject({ + bytes: 12, + sha256: PUSHED_DIGEST, + overwritten: false, + ...(verified + ? { + device_sha256: PUSHED_DIGEST, + device_digest_source: "device_sha256sum", + } + : { device_sha256: null, device_digest_source: "unavailable" }), + }); + expect( + result.value.limitations.some((line) => + line.includes("not device-side verified"), + ), + ).toBe(!verified); + // Unrecognized checksum output stays inspectable instead of being dropped. + expect(result.value.rawResult).toEqual( + !verified && exit === 0 + ? { device_sha256sum: { stdout, stderr: "" } } + : null, + ); + }, +); it("pushes the same private bytes that its reported digest identifies", async () => { const { binary, root } = await writeAdbStub( diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 6a04dcf991..1f042d685e 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -117,6 +117,7 @@ import { parseResolveActivityOutput, parseServiceListOutput, parseSettingsGetOutput, + parseSha256sumOutput, parseWindowFocusOutput, parseWmDensityOutput, parseWmSizeOutput, @@ -272,6 +273,21 @@ const adbDigestError = ( type AdbFileDigest = Awaited>; +/** + * A pushed file's device-side digest together with its source, so a receipt + * cannot name `sha256sum` as the source of a digest that was never parsed. + */ +type AdbDeviceDigest = + | { + readonly device_sha256: string; + readonly device_digest_source: "device_sha256sum"; + } + | { + readonly device_sha256: null; + readonly device_digest_source: "unavailable"; + readonly reason: string; + }; + const partialObservationWithDigest = ( observation: | AdbAcquiredFilePartialObservation @@ -2436,9 +2452,13 @@ export class AdbProvider { ); if (!pushed.ok) return pushed; const receipt = ( - deviceSha256: string | null, - source: "device_sha256sum" | "unavailable", - extra: readonly string[] = [], + deviceDigest: AdbDeviceDigest, + rawResult?: { + readonly device_sha256sum: { + readonly stdout: string; + readonly stderr: string; + }; + }, ) => createAnalysisExecution( { @@ -2448,15 +2468,18 @@ export class AdbProvider { device_path: input.device_path, bytes: digest.bytes, sha256: digest.sha256, - device_sha256: deviceSha256, + device_sha256: deviceDigest.device_sha256, overwritten: deviceExists, - device_digest_source: source, + device_digest_source: deviceDigest.device_digest_source, }, provider, { + ...(rawResult === undefined ? {} : { rawResult }), limitations: [ "Push writes a private snapshot of the caller's file to the device", - ...extra, + ...(deviceDigest.device_digest_source === "unavailable" + ? [deviceDigest.reason] + : []), ], }, ); @@ -2471,11 +2494,13 @@ export class AdbProvider { if (!deviceDigest.ok) { const cancelled = deviceDigest.error instanceof AnalysisCancelledError; - const completed = receipt(null, "unavailable", [ - cancelled + const completed = receipt({ + device_sha256: null, + device_digest_source: "unavailable", + reason: cancelled ? "The device-side digest check was cancelled after push completed" : "The device did not answer sha256sum, so the transfer is not device-side verified", - ]); + }); if (cancelled) deviceDigest.error.retainPartialObservation( createEvidence( @@ -2494,11 +2519,28 @@ export class AdbProvider { ); return cancelled ? err(deviceDigest.error) : ok(completed); } - const digestMatch = /^([a-f0-9]{64})\s/u.exec( - deviceDigest.value.stdout, - ); - const completed = receipt(digestMatch?.[1] ?? null, "device_sha256sum"); - if (digestMatch !== null && digestMatch[1] !== digest.sha256) + const parsedDigest = parseSha256sumOutput(deviceDigest.value.stdout); + const completed = + parsedDigest === null + ? receipt( + { + device_sha256: null, + device_digest_source: "unavailable", + reason: + "The device's sha256sum output contained no recognizable digest, so the transfer is not device-side verified", + }, + { + device_sha256sum: { + stdout: deviceDigest.value.stdout, + stderr: deviceDigest.value.stderr, + }, + }, + ) + : receipt({ + device_sha256: parsedDigest, + device_digest_source: "device_sha256sum", + }); + if (parsedDigest !== null && parsedDigest !== digest.sha256) return err( new AnalysisArtifactChangedError( operation, diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 8cbbf8095d..74a832429e 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -381,6 +381,14 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => { return match === null ? null : match[1]!; }; +/** + * Read the digest from one `sha256sum FILE` line, or null when the output + * names none. GNU sha256sum starts the line with a backslash when it escaped + * the echoed name; the digest itself is never escaped. + */ +export const parseSha256sumOutput = (stdout: string): string | null => + /^\\?([a-f0-9]{64}) [ *]/u.exec(stdout)?.[1] ?? null; + /** Read the device's `Failure [REASON]` verdict from package-change output. */ export const packageChangeFailureReason = (output: string): string | null => /Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null; From 4dfa1c67dc89286b3348cd519d66e35d2d3538ac Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:58:31 -0700 Subject: [PATCH 24/29] fix(capture): keep excluded MIME protobuf applicability partial (#6012) * fix(capture): keep excluded MIME protobuf applicability partial Automatic protobuf framing treated an explicitly excluded, conflicting or unavailable Content-Type the same as a known-absent one, so a selected body was skipped and coverage still upgraded to complete. Body metadata now reports whether MIME applicability is established; an unresolved non-empty body adds a record limitation and keeps protobuf_coverage partial. HAR uses its required mimeType, falling back only to a visible header value. Fixes #5926 Co-Authored-By: Claude Opus 5.5 * fix(capture): compare repeated Content-Type declarations by essence Repeated Content-Type headers that differ only by case, whitespace or parameters declare the same media type, yet the raw-string comparison collapsed them to an unknown value and the new applicability check reported partial coverage. Body metadata now compares media-type essences and owns automatic framing selection, so the consumer no longer re-parses the raw value; framing lookup ignores inherited properties. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/web-network-captures.md | 3 + .../history/protobuf/ProtobufCapture.ts | 65 ++++++++--- .../acceptance/tools/protobufCapture.test.ts | 36 ++++++ tests/boundary/browser/sazWebCapture.test.ts | 108 ++++++++++++++++++ 4 files changed, 195 insertions(+), 17 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 456342b30a..e37ea9669d 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -178,6 +178,9 @@ sanitized source even with explicit sensitivity options. If that source pass is interrupted, unchecked bodies are not published as partial Evidence. Framing overrides apply only to format-owned body coordinates and preserve exclusions for the transport declared in MIME metadata. +Explicitly excluded or conflicting MIME metadata is not treated as absent: +automatic framing leaves such a non-empty body uninterpreted, adds a record +limitation and keeps `protobuf_coverage` partial unless `framing` is selected. Unknown or explicitly excluded HTTP coding metadata is not treated as absent: SAZ/native bodies remain opaque until identity coding is established. Raw header values and retained body bytes remain available in source Evidence. diff --git a/src/browser/history/protobuf/ProtobufCapture.ts b/src/browser/history/protobuf/ProtobufCapture.ts index 0eaaf78806..cdd33791e3 100644 --- a/src/browser/history/protobuf/ProtobufCapture.ts +++ b/src/browser/history/protobuf/ProtobufCapture.ts @@ -93,6 +93,10 @@ const excludeBody = ( record.redactions.push({ pointer, reason }); }; +/** A media type's case-insensitive essence; parameters do not select framing. */ +const mediaTypeEssence = (value: string) => + value.split(";", 1)[0]!.trim().toLowerCase(); + /** Format-aware owner of body metadata; callers never reinterpret producer JSON. */ const bodyMetadata = ( record: WebNetworkCaptureRecord, @@ -143,28 +147,47 @@ const bodyMetadata = ( if (typeof name === "string") { const key = name.toLowerCase(); const knownValue = typeof value === "string" ? value : undefined; + const previous = headers.get(key); + // Repeated declarations agree only when known and equal; media types by essence. headers.set( key, - headers.has(key) && headers.get(key) !== knownValue - ? undefined - : knownValue, + !headers.has(key) + ? knownValue + : previous !== undefined && + knownValue !== undefined && + (key === "content-type" + ? mediaTypeEssence(previous) === mediaTypeEssence(knownValue) + : previous === knownValue) + ? previous + : undefined, ); } else headerNamesKnown = false; } } + // A visible declaration selects framing. Absence is established only when every header + // name is known; excluded or conflicting values leave framing applicability unknown. + const header = headers.get("content-type"); + const automaticFraming = (contentType: string | undefined) => { + const essence = + contentType === undefined ? undefined : mediaTypeEssence(contentType); + return essence !== undefined && Object.hasOwn(PROTOBUF_FRAMING, essence) + ? PROTOBUF_FRAMING[essence as keyof typeof PROTOBUF_FRAMING] + : undefined; + }; if (format === "har") { if ( pointer !== `/${side}/${side === "request" ? "postData" : "content"}/text` ) return undefined; - const content = object( + // HAR requires a string mimeType; any other value is an excluded declaration. + const mimeType = object( message[side === "request" ? "postData" : "content"], - ); + )?.mimeType; + const contentType = typeof mimeType === "string" ? mimeType : header; return { - contentType: - typeof content?.mimeType === "string" - ? content.mimeType - : headers.get("content-type"), + contentType, + automaticFraming: automaticFraming(contentType), + contentTypeKnown: contentType !== undefined, httpIdentityKnown: true, httpCodingKnown: true, }; @@ -172,7 +195,11 @@ const bodyMetadata = ( if (pointer !== `/${side}/body` && pointer !== `/${side}/content`) return undefined; return { - contentType: headers.get("content-type"), + contentType: header, + automaticFraming: automaticFraming(header), + contentTypeKnown: + header !== undefined || + (headerNamesKnown && !headers.has("content-type")), httpCodingKnown: headerNamesKnown && ["content-encoding", "transfer-encoding"].every( @@ -257,17 +284,21 @@ export const prepareCaptureProtobuf = async ( incomplete = true; continue; } - const contentType = metadata.contentType; - const essence = contentType?.split(";", 1)[0]?.trim().toLowerCase(); - const automatic = - essence === undefined - ? undefined - : PROTOBUF_FRAMING[essence as keyof typeof PROTOBUF_FRAMING]; + const { contentType, automaticFraming: automatic } = metadata; const framing = options?.framing === undefined || options.framing === "auto" ? automatic : options.framing; - if (framing === undefined) continue; + if (framing === undefined) { + // An empty body has no protobuf content under any framing. + if (!metadata.contentTypeKnown && field.bytes > 0) { + incomplete = true; + record.limitations.push( + "Selected body's MIME metadata is excluded, conflicting or unavailable, so automatic protobuf framing applicability is unknown; its retained bytes were not interpreted. Select explicit framing to decode them.", + ); + } + continue; + } if ( field.bytes > WEB_NETWORK_CAPTURE_LIMITS.inputBytes || field.content_base64.length > diff --git a/tests/acceptance/tools/protobufCapture.test.ts b/tests/acceptance/tools/protobufCapture.test.ts index 46db5f33af..8c725b8de4 100644 --- a/tests/acceptance/tools/protobufCapture.test.ts +++ b/tests/acceptance/tools/protobufCapture.test.ts @@ -629,3 +629,39 @@ captureTest.for( expect(await readFile(path, "utf8")).toBe(original); }, ); + +captureTest( + "keeps automatic framing partial when a HAR mimeType is excluded through MCP", + async ({ mcp }) => { + const root = await createTestTempDirectory("rea-protobuf-mime-"); + const path = join(root, "capture.har"); + await writeFile(path, protobufHar()); + const reply = await mcp.call("inspect_web_network_capture", { + capture_path: path, + format: "har", + record_ordinals: [0], + sensitive_values: ["grpc-web+proto"], + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const report = webNetworkCaptureSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(report.records[0]?.redactions).toContainEqual({ + pointer: "/response/content/mimeType", + reason: "explicit-sensitive-value", + }); + expect(report.records[0]?.binary_fields[0]).toMatchObject({ + pointer: "/response/content/text", + state: "retained", + }); + expect(report.records[0]?.protobuf).toBeUndefined(); + expect(report.records[0]?.limitations).toContainEqual( + expect.stringMatching(/^Selected body's MIME metadata is excluded/), + ); + expect(report.protobuf_coverage).toEqual({ + status: "partial", + selected_records: 1, + inspected_records: 1, + }); + }, +); diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index a325304db7..de1bec10ed 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -289,6 +289,114 @@ it("checks trailer credentials before hidden metadata blocks analysis and withho expect(await readFile(path)).toEqual(bytes); }); +it("keeps automatic framing applicability unknown when SAZ Content-Type is excluded or conflicting", async () => { + const body = protobufFrame(Buffer.from([8, 1])); + const response = (headers: string) => + Buffer.concat([ + Buffer.from( + `HTTP/1.1 200 OK\r\n${headers}Content-Length: ${body.length}\r\n\r\n`, + ), + body, + ]); + const bytes = await archive([ + ["raw/1_s.txt", response("Content-Type: application/grpc\r\n")], + [ + "raw/2_s.txt", + response( + "Content-Type: application/grpc\r\nContent-Type: text/plain\r\n", + ), + ], + ["raw/3_s.txt", response("")], + ["raw/4_s.txt", response("Content-Type: application/octet-stream\r\n")], + [ + "raw/5_s.txt", + response( + "Content-Type: application/grpc\r\nContent-Type: Application/GRPC ; x=1\r\n", + ), + ], + ]); + const path = await fixture(bytes); + const inspect = async (extra: string[]) => + webNetworkCaptureSchema.parse((await cli(path, extra)).normalized_result); + const unknown = expect.stringMatching( + /^Selected body's MIME metadata is excluded, conflicting or unavailable/, + ); + const retainedBody = expect.objectContaining({ + pointer: "/response/body", + state: "retained", + sha256: createHash("sha256").update(body).digest("hex"), + }); + + // Repeated declarations with one media-type essence agree; distinct essences conflict. + const visible = await inspect([ + "--record", + "0", + "--record", + "1", + "--record", + "4", + ]); + const record = (ordinal: number) => + visible.records.find((item) => item.ordinal === ordinal); + for (const ordinal of [0, 4]) { + expect(record(ordinal)?.protobuf).toMatchObject([ + { status: "complete", fields: [{ number: 1, value: "1" }] }, + ]); + expect(record(ordinal)?.limitations).toEqual([]); + } + // The first raw declaration, including its SAZ leading space, remains the reported value. + expect(record(4)?.protobuf?.[0]?.content_type).toBe(" application/grpc"); + expect(record(1)?.protobuf).toBeUndefined(); + expect(record(1)?.limitations).toEqual([unknown]); + expect(visible.protobuf_coverage).toEqual({ + status: "partial", + selected_records: 3, + inspected_records: 3, + }); + + const known = await inspect(["--record", "2", "--record", "3"]); + expect(known.protobuf_coverage?.status).toBe("complete"); + for (const record of known.records) { + expect(record.protobuf).toBeUndefined(); + expect(record.limitations).toEqual([]); + } + + for (const sensitive of ["application/grpc", "Content-Type"]) { + const excluded = await inspect([ + "--record", + "0", + "--sensitive-value", + sensitive, + ]); + expect(excluded.protobuf_coverage, sensitive).toEqual({ + status: "partial", + selected_records: 1, + inspected_records: 1, + }); + expect(excluded.records[0]?.protobuf).toBeUndefined(); + expect(excluded.records[0]?.limitations).toEqual([unknown]); + expect(excluded.records[0]?.binary_fields).toContainEqual(retainedBody); + expect(excluded.records[0]?.redactions).toContainEqual({ + pointer: `/response/headers/0/${sensitive === "Content-Type" ? "name" : "value"}`, + reason: "explicit-sensitive-value", + }); + } + + const explicit = await inspect([ + "--record", + "0", + "--sensitive-value", + "application/grpc", + "--protobuf-framing", + "grpc", + ]); + expect(explicit.protobuf_coverage?.status).toBe("complete"); + expect(explicit.records[0]?.protobuf).toMatchObject([ + { status: "complete", fields: [{ number: 1, value: "1" }] }, + ]); + expect(await readFile(path)).toEqual(bytes); +}); + it("inspects native SAZ through the public CLI and MCP with equal evidence, bytes and archive coordinates", async () => { const bytes = await archive(); const path = await fixture(bytes); From d4c637f4cfcb7ae3b48d0c3047f0e9171d43b26d Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 08:01:45 -0700 Subject: [PATCH 25/29] fix(browser)!: keep malformed console argument inventories unknown (#6016) * fix(browser)!: keep malformed console argument inventories unknown An omitted, null, or non-array Runtime.consoleAPICalled args inventory now reports argument_types as null, text capture as unavailable, and console_events as invalid_protocol_value instead of a complete empty inventory. Malformed argument elements keep their CDP position as "unknown", so retained text values keep their original argument_index. Call-site attribution now uses the frame at position zero: a malformed top stack frame is no longer replaced by its caller. Console events are excluded and request initiator locations are null, both reported as invalid_protocol_value. BREAKING CHANGE: console event argument_types may now be null, and console text_capture.status may now be "unavailable", when the CDP producer omits or malforms the argument inventory. Fixes #5934 Co-Authored-By: Claude Opus 5.5 * fix(browser): report malformed WebMCP registration stacks as incomplete topCallFrame now owns the omitted-versus-malformed decision for the whole stack value: an omitted stack has no call site, while a non-object stack, non-array callFrames, or a malformed top frame is malformed. WebMCP discovery keeps the tool with a null registration_source but records webmcp_tools invalid_protocol_value, so capture comparisons stay unknown instead of confirming a changed source. Console and initiator stacks use the same rule. Refs #5934 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/browser-observation.md | 4 +- src/browser/CdpCaptureEventHandlers.ts | 45 ++- src/browser/CdpCaptureEventHelpers.ts | 45 ++- src/browser/CdpWebMcpDiscovery.ts | 10 +- src/domain/browserObservationSchemas.ts | 9 +- .../cdpConsoleArgumentInventory.test.ts | 278 ++++++++++++++++++ tests/fixtures/fakeCdpBrowserEvents.ts | 21 +- tests/fixtures/fakeCdpBrowserTypes.ts | 2 + 8 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 tests/boundary/browser/cdpConsoleArgumentInventory.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 305de00638..e1adc897ae 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -18,7 +18,7 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate - `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally selected storage key names, redacted storage-content fingerprints, or script sources. - `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence. - `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata. -- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`. +- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values or registration stacks retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`. - `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence. @@ -361,7 +361,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex WebSocket stream inventories and connection counts likewise require complete WebSocket coverage. Redirect resource types use only shared known metadata: an omitted type leaves equality unknown rather than proving a modification. -- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. +- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. - WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index 493925d63f..9519cd785b 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -7,8 +7,8 @@ import { allowedSanitizedUrl, isHttpUrl, numberValue, + admitRecords, recordValue, - recordsValue, scriptMetadataValues, cdpStringValue, type UnknownRecord, @@ -21,11 +21,11 @@ import { consolePrimitive, exclusionReasonForUrl, executionContextKey, - firstCallFrame, initiatorLocation, integerOrNull, isJsonMediaType, cdpFrameIdentity, + topCallFrame, } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js"; import { @@ -305,7 +305,10 @@ export const handleRequestWillBeSent = ( }); } } - const initiatorFrame = initiatorLocation(initiator); + const location = initiatorLocation(initiator); + if (!location.ok) + state.completeness.exclude("network_initiators", "invalid_protocol_value"); + const initiatorFrame = location.ok ? location.value : undefined; const rawInitiatorUrl = cdpStringValue(initiatorFrame?.url); const initiatorUrl = allowedSanitizedUrl( rawInitiatorUrl, @@ -438,7 +441,12 @@ export const handleConsoleAPICalled = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const frame = firstCallFrame(recordValue(params.stackTrace)); + const topFrame = topCallFrame(params.stackTrace); + if (!topFrame.ok) { + state.completeness.exclude("console_events", "invalid_protocol_value"); + return; + } + const frame = topFrame.value; if (frame === undefined) { state.completeness.exclude("console_events", "unattributed_origin"); return; @@ -451,16 +459,26 @@ export const handleConsoleAPICalled = ( ); return; } - const arguments_ = recordsValue(params.args); + // Malformed elements keep their position so retained siblings keep their + // CDP argument indices; a missing inventory is unknown, not empty. + const arguments_ = admitRecords(params.args)?.map((argument) => + recordValue(argument), + ); + const argumentTypes = arguments_?.map((argument) => + cdpStringValue(argument?.type), + ); const timestamp = numberValue(params.timestamp); - if (timestamp === undefined) + if ( + timestamp === undefined || + argumentTypes === undefined || + argumentTypes.includes(undefined) + ) state.completeness.exclude("console_events", "invalid_protocol_value"); state.console.push({ type: cdpStringValue(params.type) ?? "unknown", timestamp: timestamp ?? null, - argument_types: arguments_.map( - (argument) => cdpStringValue(argument.type) ?? "unknown", - ), + argument_types: + argumentTypes?.map((argumentType) => argumentType ?? "unknown") ?? null, url: source.url, line: integerOrNull(frame.lineNumber), column: integerOrNull(frame.columnNumber), @@ -470,7 +488,7 @@ export const handleConsoleAPICalled = ( const captureConsoleText = ( state: CdpCaptureEventsState, - arguments_: readonly UnknownRecord[], + arguments_: readonly (UnknownRecord | undefined)[] | undefined, ): WebPageInspection["console"]["events"][number]["text_capture"] => { if (!state.input.include_console_text) return { @@ -478,10 +496,17 @@ const captureConsoleText = ( values: [], retained_bytes: 0, }; + if (arguments_ === undefined) + return { + status: "unavailable", + values: [], + retained_bytes: 0, + }; const values: WebPageInspection["console"]["events"][number]["text_capture"]["values"] = []; let retainedBytes = 0; for (const [argumentIndex, argument] of arguments_.entries()) { + if (argument === undefined) continue; const primitive = consolePrimitive(argument); if (primitive === undefined) continue; const text = primitive.text; diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index d3e60f2450..e786ba09de 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -1,8 +1,9 @@ +import { err, ok, type Result } from "../domain/result.js"; import { + admitRecords, isHttpUrl, numberValue, recordValue, - recordsValue, cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; @@ -136,20 +137,44 @@ export const cdpFrameIdentity = ( }; }; -export const firstCallFrame = ( - value: UnknownRecord | undefined, -): UnknownRecord | undefined => recordsValue(value?.callFrames)[0]; +export interface MalformedCdpCallFrame { + readonly _tag: "MalformedCdpCallFrame"; +} + +const malformedCallFrame: MalformedCdpCallFrame = { + _tag: "MalformedCdpCallFrame", +}; + +/** + * The call site is the frame at position zero. An omitted stack has no call + * site; a malformed stack or top frame stays unknown rather than promoting a + * caller to the call site. + */ +export const topCallFrame = ( + value: unknown, +): Result => { + if (value === undefined) return ok(undefined); + const stack = recordValue(value); + if (stack === undefined) return err(malformedCallFrame); + const frames = admitRecords(stack.callFrames); + if (frames === undefined) return err(malformedCallFrame); + if (frames.length === 0) return ok(undefined); + const frame = recordValue(frames[0]); + return frame === undefined ? err(malformedCallFrame) : ok(frame); +}; export const initiatorLocation = ( initiator: UnknownRecord | undefined, -): UnknownRecord | undefined => { - let stack = recordValue(initiator?.stack); +): Result => { + let stack = initiator?.stack; for (let depth = 0; depth < 32 && stack !== undefined; depth += 1) { - const frame = firstCallFrame(stack); - if (frame !== undefined) return frame; - stack = recordValue(stack.parent); + const frame = topCallFrame(stack); + if (!frame.ok || frame.value !== undefined) return frame; + stack = recordValue(stack)?.parent; } - return cdpStringValue(initiator?.url) === undefined ? undefined : initiator; + return ok( + cdpStringValue(initiator?.url) === undefined ? undefined : initiator, + ); }; export const exclusionReasonForUrl = ( diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 51bfafc71a..713b207e14 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -20,7 +20,6 @@ import { delayWithCancellation, numberValue, recordValue, - recordsValue, cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; @@ -30,6 +29,7 @@ import { walkFrameTrees, } from "./CdpCaptureDocuments.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; +import { topCallFrame } from "./CdpCaptureEventHelpers.js"; interface DiscoveryContext { readonly connection: CdpConnection; @@ -468,6 +468,9 @@ const normalizeTool = ( if (!declaration.success) completeness.exclude("webmcp_tools", "invalid_protocol_value"); const schema = declaration.success ? declaration.data : null; + const registrationFrame = topCallFrame(value.stackTrace); + if (!registrationFrame.ok) + completeness.exclude("webmcp_tools", "invalid_protocol_value"); return { tool_key: toolKey(frame.url, frameId, name), name, @@ -490,7 +493,7 @@ const normalizeTool = ( autosubmit: annotation("autosubmit"), }, registration_source: registrationSource( - recordValue(value.stackTrace), + registrationFrame.ok ? registrationFrame.value : undefined, new Set(input.allowed_origins), ), trust: "page-declared-untrusted", @@ -498,10 +501,9 @@ const normalizeTool = ( }; const registrationSource = ( - stack: UnknownRecord | undefined, + frame: UnknownRecord | undefined, allowedOrigins: ReadonlySet, ): WebMcpDiscovery["tools"]["items"][number]["registration_source"] => { - const frame = recordsValue(stack?.callFrames)[0]; const url = allowedSanitizedUrl(frame?.url, allowedOrigins); return frame === undefined || url === undefined ? null diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index 5be597f175..6c643ec8b0 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -167,12 +167,17 @@ const browserConsoleEventSchema = z.object({ .describe( "CDP Runtime.Timestamp in milliseconds since Unix epoch; null when the producer value is absent or invalid.", ), - argument_types: z.array(z.string()), + argument_types: z + .array(z.string()) + .nullable() + .describe( + "CDP argument types by argument position; null when the producer argument inventory is absent or not an array.", + ), url: z.string().nullable(), line: z.number().int().min(0).nullable(), column: z.number().int().min(0).nullable(), text_capture: z.object({ - status: z.enum(["not_approved", "included"]), + status: z.enum(["not_approved", "included", "unavailable"]), values: z.array( z.object({ argument_index: z.number().int().min(0), diff --git a/tests/boundary/browser/cdpConsoleArgumentInventory.test.ts b/tests/boundary/browser/cdpConsoleArgumentInventory.test.ts new file mode 100644 index 0000000000..234bf716c5 --- /dev/null +++ b/tests/boundary/browser/cdpConsoleArgumentInventory.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +type CdpCommand = Parameters>[0]; + +const sessionEvent = ( + command: CdpCommand, + method: string, + params: Record, +) => ({ + ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), + method, + params, +}); + +const inspect = async ( + commandEvents: NonNullable, +) => { + const browser = await startFakeCdpBrowser({ commandEvents }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 1, + include_console_text: true, + }), + ); + if (!result.ok) throw result.error; + expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow(); + return { browser, inspection: result.value }; +}; + +const inspectConsole = async ( + fields: Record, + stackTrace?: (origin: string) => unknown, +) => + inspect((command, origin) => + command.method === "Runtime.enable" + ? [ + sessionEvent(command, "Runtime.consoleAPICalled", { + type: "log", + timestamp: 1, + ...fields, + stackTrace: stackTrace?.(origin) ?? { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }), + ] + : undefined, + ); + +const invalidConsoleValue = { + section: "console_events", + reason: "invalid_protocol_value", + count: 1, +}; + +describe("CdpBrowserProvider: console argument inventory", () => { + it.each([ + ["omitted", {}], + ["null", { args: null }], + ["non-array", { args: "invalid" }], + ])( + "keeps an %s argument inventory unknown rather than empty", + async (_label, fields) => { + const { inspection } = await inspectConsole(fields); + + expect(inspection.console.events).toHaveLength(1); + expect(inspection.console.events[0]?.argument_types).toBeNull(); + expect(inspection.console.events[0]?.text_capture).toEqual({ + status: "unavailable", + values: [], + retained_bytes: 0, + }); + expect(inspection.completeness.excluded).toContainEqual( + invalidConsoleValue, + ); + }, + ); + + it.each([ + ["a non-object element", null], + ["an element without a type", { value: "first" }], + ])( + "retains original argument positions around %s", + async (_label, malformed) => { + const { inspection } = await inspectConsole({ + args: [malformed, { type: "string", value: "second" }], + }); + + expect(inspection.console.events[0]?.argument_types).toEqual([ + "unknown", + "string", + ]); + expect(inspection.console.events[0]?.text_capture).toEqual({ + status: "included", + values: [{ argument_index: 1, type: "string", text: "second" }], + retained_bytes: Buffer.byteLength("second"), + }); + expect(inspection.completeness.excluded).toContainEqual( + invalidConsoleValue, + ); + }, + ); + + it.each([ + ["an empty", [], []], + ["a non-primitive", [{ type: "object", subtype: "array" }], ["object"]], + ])( + "reports %s argument inventory as observed", + async (_label, args, argumentTypes) => { + const { inspection } = await inspectConsole({ args }); + + expect(inspection.console.events[0]?.argument_types).toEqual( + argumentTypes, + ); + expect(inspection.console.events[0]?.text_capture).toEqual({ + status: "included", + values: [], + retained_bytes: 0, + }); + expect(inspection.completeness.excluded).not.toContainEqual( + expect.objectContaining({ + section: "console_events", + reason: "invalid_protocol_value", + }), + ); + }, + ); + + it.each([ + [ + "a malformed top frame", + (origin: string) => ({ + callFrames: [ + null, + { url: `${origin}/app.js`, lineNumber: 4, columnNumber: 2 }, + ], + }), + ], + ["a non-array frame inventory", () => ({ callFrames: "invalid" })], + ["a non-object stack", () => "invalid"], + ])( + "does not attribute a console call through %s", + async (_label, stackTrace) => { + const { inspection } = await inspectConsole({ args: [] }, stackTrace); + + expect(inspection.console.events).toEqual([]); + expect(inspection.completeness.excluded).toContainEqual( + invalidConsoleValue, + ); + }, + ); + + it("does not attribute a request initiator to a frame below a malformed top frame", async () => { + const { browser, inspection } = await inspect((command, origin) => + command.method === "Network.enable" + ? [ + sessionEvent(command, "Network.requestWillBeSent", { + requestId: "initiated-request", + type: "Fetch", + request: { url: `${origin}/data.json`, method: "GET" }, + initiator: { + type: "script", + stack: { + callFrames: [ + null, + { url: `${origin}/app.js`, lineNumber: 3, columnNumber: 5 }, + ], + }, + }, + timestamp: 1, + }), + ] + : undefined, + ); + + const request = inspection.network.requests.find( + (item) => item.request_id === "initiated-request", + ); + expect(request?.url).toBe(`${browser.allowedOrigin}/data.json`); + expect(request?.initiator).toEqual({ + type: "script", + url: null, + line: null, + column: null, + }); + expect(inspection.completeness.excluded).toContainEqual({ + section: "network_initiators", + reason: "invalid_protocol_value", + count: 1, + }); + }); +}); + +describe("CdpBrowserProvider: WebMCP registration stacks", () => { + const discover = async ( + stackTrace: (origin: string) => JsonValue | undefined, + ) => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + webMcpStackTrace: stackTrace, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + return { discovery: result.value }; + }; + const invalidToolValue = expect.objectContaining({ + section: "webmcp_tools", + reason: "invalid_protocol_value", + }); + + it.each<[string, (origin: string) => JsonValue]>([ + [ + "a malformed top frame", + (origin) => ({ + callFrames: [ + null, + { url: `${origin}/app.js`, lineNumber: 12, columnNumber: 4 }, + ], + }), + ], + ["a non-array frame inventory", () => ({ callFrames: "invalid" })], + ["a non-object stack", () => "invalid"], + ])( + "keeps the tool but reports %s as incomplete", + async (_label, stackTrace) => { + const { discovery } = await discover(stackTrace); + + expect(discovery.tools.items).toEqual([ + expect.objectContaining({ + name: "search_orders", + registration_source: null, + }), + ]); + expect(discovery.completeness.excluded).toContainEqual(invalidToolValue); + }, + ); + + it.each<[string, JsonValue | undefined]>([ + ["an omitted", undefined], + ["an empty", { callFrames: [] }], + ])("reports %s registration stack as having no source", async (_l, stack) => { + const { discovery } = await discover(() => stack); + + expect(discovery.tools.items).toEqual([ + expect.objectContaining({ + name: "search_orders", + registration_source: null, + }), + ]); + expect(discovery.completeness.excluded).not.toContainEqual( + invalidToolValue, + ); + }); +}); diff --git a/tests/fixtures/fakeCdpBrowserEvents.ts b/tests/fixtures/fakeCdpBrowserEvents.ts index 20d4ef0733..06c1d500c1 100644 --- a/tests/fixtures/fakeCdpBrowserEvents.ts +++ b/tests/fixtures/fakeCdpBrowserEvents.ts @@ -78,15 +78,18 @@ const emitMainWebMcpTools = ( ? options.webMcpConsequential : true, }, - stackTrace: { - callFrames: [ - { - url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, - lineNumber: 12, - columnNumber: 4, - }, - ], - }, + stackTrace: + options.webMcpStackTrace !== undefined + ? options.webMcpStackTrace(`http://127.0.0.1:${String(port)}`) + : { + callFrames: [ + { + url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, + lineNumber: 12, + columnNumber: 4, + }, + ], + }, }, { name: "private_tool", diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 08472f451c..13593b0826 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -102,6 +102,8 @@ export interface FakeOptions { readonly invalidResponseBodyBase64?: boolean; readonly webMcpTools?: boolean; readonly webMcpConsequential?: JsonValue; + /** Replaces the primary tool's registration stack; returning `undefined` omits it. */ + readonly webMcpStackTrace?: (origin: string) => JsonValue | undefined; readonly webMcpSameUrlRegistrations?: "retain" | "remove-second"; readonly webMcpFrameCount?: number; readonly webMcpSchemaPropertyCount?: number; From dc53c11f6d68790a0046159be59f5bdd839ffcf4 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 08:05:30 -0700 Subject: [PATCH 26/29] fix(process): keep structural capture issues alongside semantic ones (#6020) parseProcessCapture re-threw only the custom (semantic) schema issues as a TypeError whenever any existed, discarding structural issues found in the same parse. Throw the schema's ZodError instead so every issue is retained and describeValidationFailure reports the first issue with the remaining count. Reconstruction obligation candidates now use that same formatter instead of the raw error message. Fixes #5957 Co-authored-by: Claude Opus 5.5 --- ...ReconstructionObligationCandidates.test.ts | 36 ++++++++++++ .../ReconstructionObligationCandidates.ts | 8 +-- .../process/processCapture.validation.test.ts | 31 ++++++++--- src/domain/process/processCaptureParsing.ts | 21 ++----- .../processComparisonDiagnosticsMcp.test.ts | 55 ++++++++++++++++++- 5 files changed, 121 insertions(+), 30 deletions(-) diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index 2d01d9c7ee..9cde9c4bb7 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -11,6 +11,8 @@ import { describe, expect, it } from "vitest"; import { createEvidence } from "../domain/evidence.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; import { emptyUnverifiedProcessCapture } from "../domain/process/processCapture.fixture.js"; +import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../domain/process/processCaptureExample.js"; +import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js"; import { parseProcessCapture } from "../domain/process/processCaptureParsing.js"; import { digestProcessCommitment, @@ -178,6 +180,40 @@ describe("reconstruction obligation candidates", () => { `Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`, ); }); + + it("names the first rejected capture field and the remaining issue count", () => { + const { + manifest: { completed_at: _completedAt, ...manifest }, + exit, + ...rest + } = FINALIZED_PROCESS_CAPTURE_EXAMPLE; + const evidence = createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse({ + ...rest, + manifest, + exit: { + ...exit, + finalization: { + ...exit.finalization, + signals: [ + { ...exit.finalization.signals[0], reason: "not allowed here" }, + ], + }, + }, + }), + }); + const generated = deriveReconstructionObligationCandidates( + createEvidenceBundle([evidence]), + [], + ); + + expect(generated.limitations).toEqual([ + `Process Evidence ${evidence.evidence_id} could not generate candidates: at manifest.completed_at: Invalid input: expected string, received undefined (1 more issues)`, + ]); + }); }); it("keeps each semantic candidate bound to its own file and authority context", () => { diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 45d3cbcec7..fb27bdf60f 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -4,6 +4,7 @@ import canonicalize from "canonicalize"; import type { Evidence } from "../domain/evidence.js"; import { + describeValidationFailure, parseEvidenceBundle, type EvidenceBundle, } from "../domain/evidenceBundle.js"; @@ -166,7 +167,7 @@ const deriveApplicationCandidates = ( ); } catch (cause: unknown) { limitations.add( - `Application Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + `Application Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`, ); } }; @@ -258,7 +259,7 @@ const deriveProcessCandidate = ( ); } catch (cause: unknown) { limitations.add( - `Process Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + `Process Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`, ); } }; @@ -464,6 +465,3 @@ const digest = (value: unknown): string => { throw new TypeError("Reconstruction obligation key is not canonical JSON"); return createHash("sha256").update(encoded).digest("hex"); }; - -const errorMessage = (cause: unknown): string => - cause instanceof Error ? cause.message : "unknown validation failure"; diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index ac2e9a7ce7..08749de1f2 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { z } from "zod"; import { digestProcessCommitment } from "./processScenario.js"; import { parseProcessCapture } from "./processCaptureParsing.js"; @@ -12,6 +13,18 @@ import { processCaptureIssues, } from "./processCapture.fixture.js"; +/** Name every rejected field reported by the production parser. */ +const rejectedPaths = (input: unknown): readonly string[] => { + try { + parseProcessCapture(input); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + return cause.issues.map(({ path }) => path.join(".")); + throw cause; + } + throw new Error("Process capture was accepted"); +}; + const captureWithEnumerationOmission = ( omission: FilesystemCoverage["enumeration_omissions"][number], ) => { @@ -71,8 +84,8 @@ it("requires interrupted sampling diagnostics to agree with partial and aggregat truncated: false, }), ).toThrow("truncation_details"); - expect(() => - parseProcessCapture({ + expect( + rejectedPaths({ ...interrupted, truncation_details: { ...interrupted.truncation_details, @@ -82,9 +95,9 @@ it("requires interrupted sampling diagnostics to agree with partial and aggregat }, }, }), - ).toThrow("truncation_details.process"); - expect(() => - parseProcessCapture({ + ).toContain("truncation_details.process"); + expect( + rejectedPaths({ ...interrupted, truncation_details: { ...interrupted.truncation_details, @@ -94,7 +107,7 @@ it("requires interrupted sampling diagnostics to agree with partial and aggregat }, }, }), - ).toThrow("truncation_details.process"); + ).toContain("truncation_details.process"); }); it("limits interruption impact to process comparison", () => { @@ -220,8 +233,8 @@ it("does not infer normalized retention budget from missing scenario defaults", filesystem_depth: 16, }, }; - expect(() => - parseProcessCapture({ + expect( + rejectedPaths({ ...capture, manifest: { ...capture.manifest, @@ -237,7 +250,7 @@ it("does not infer normalized retention budget from missing scenario defaults", }, }, }), - ).toThrow("manifest.scenario.limits.output_bytes"); + ).toContain("manifest.scenario.limits.output_bytes"); }); it("rejects settlement and cleanup combinations that cannot occur", () => { diff --git a/src/domain/process/processCaptureParsing.ts b/src/domain/process/processCaptureParsing.ts index dda070e590..90f3cc6f50 100644 --- a/src/domain/process/processCaptureParsing.ts +++ b/src/domain/process/processCaptureParsing.ts @@ -22,18 +22,9 @@ const attachProof = (capture: UnverifiedProcessCapture): ProcessCapture => { throw new TypeError("Process Capture proof ownership failed"); }; -/** Parse unknown input and reject invalid commitments or semantics. */ -export const parseProcessCapture = (input: unknown): ProcessCapture => { - const parsed = processCaptureSchema.safeParse(input); - if (parsed.success) return attachProof(parsed.data); - const issues = parsed.error.issues.flatMap((issue) => - issue.code === "custom" - ? [{ path: issue.path.join("."), message: issue.message }] - : [], - ); - if (issues.length > 0) - throw new TypeError( - `Invalid process capture: ${issues.map(({ path, message }) => `${path}: ${message}`).join("; ")}`, - ); - throw parsed.error; -}; +/** + * Parse unknown input and reject invalid commitments or semantics. Failures + * throw the schema's ZodError so structural and semantic issues stay together. + */ +export const parseProcessCapture = (input: unknown): ProcessCapture => + attachProof(processCaptureSchema.parse(input)); diff --git a/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts b/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts index f8725a68f0..cff7fda608 100644 --- a/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts @@ -7,7 +7,10 @@ import { expect, it, onTestFinished } from "vitest"; import { compareProcessEvidenceFiles } from "../../../src/application/process/ProcessCli.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; +import { + EMPTY_PROCESS_CAPTURE_EXAMPLE, + FINALIZED_PROCESS_CAPTURE_EXAMPLE, +} from "../../../src/domain/process/processCaptureExample.js"; import { capture, partialSpecification, @@ -519,3 +522,53 @@ it("names the side and constraint when Evidence is not a usable capture", async message: expect.stringContaining("Capture evidence is malformed (at "), }); }); + +it("keeps structural capture issues alongside semantic ones through MCP and the CLI", async () => { + const client = await connect(); + const capture = captureEvidence("left"); + const { + manifest: { completed_at: _completedAt, ...manifest }, + exit, + ...rest + } = FINALIZED_PROCESS_CAPTURE_EXAMPLE; + const mixed = createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse({ + ...rest, + manifest, + exit: { + ...exit, + finalization: { + ...exit.finalization, + signals: [ + { ...exit.finalization.signals[0], reason: "not allowed here" }, + ], + }, + }, + }), + }); + const described = + "(at manifest.completed_at: Invalid input: expected string, received undefined (1 more issues))"; + + const response = await client.callTool({ + name: "compare_process_captures", + arguments: { left: capture, right: mixed }, + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "evidence_integrity_mismatch", + message: expect.stringContaining( + `The right Evidence has an invalid process capture result ${described}`, + ), + }, + }); + expect(await compareViaCli(capture, mixed)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining( + `Capture evidence is malformed ${described}`, + ), + }); +}); From 8e3bf56e11bcb56d0e8754fdfa32a7a9f004ca13 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 08:06:25 -0700 Subject: [PATCH 27/29] fix(electron): record actions skipped after a failed action (#6023) An Electron scenario stopped at the first failed or cancelled action and returned no record for the remaining declared actions, so normalized_result.actions could not account for the whole scenario. Record every remaining action as cancelled with a not-executed error, the representation browser scenarios already use. Both runners now share one helper for that error, which also distinguishes a stop caused by request cancellation from one caused by an action failure. Fixes #5943 Co-authored-by: Claude Opus 5.5 --- docs/browser-scenario-contract.md | 4 +- docs/electron-observation.md | 4 +- .../PlaywrightBrowserScenarioProvider.test.ts | 15 +++- .../PlaywrightBrowserScenarioProvider.ts | 26 ++++--- .../PlaywrightElectronActiveActions.test.ts | 40 ++++++++++ .../PlaywrightElectronActiveActions.ts | 27 ++++++- .../PlaywrightElectronActiveProvider.test.ts | 74 +++++++++++++++++++ src/browser/ScenarioActionSequence.ts | 14 ++++ 8 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 src/browser/ScenarioActionSequence.ts diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 787f91bcfa..0421bb4218 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -212,7 +212,9 @@ statements remain under the attached debugger client's control. The result is Evidence with an initial state followed by one record per declared action. Each step reports action status, elapsed time, sanitized URLs, event bounds, and independently typed capture states for screenshot, DOM, -accessibility, URL, history, and storage. Console, page-error, network, +accessibility, URL, history, and storage. Actions after a failed or cancelled +action are not run and are recorded as `cancelled` with a not-executed error. +Console, page-error, network, WebSocket, frame, worker, popup, and cancelled-download events are attributed to steps. Retained events and requested artifacts are returned inline; omitted events are counted by kind and identify affected network transactions. diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 8248237d12..612dd98f89 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -116,7 +116,9 @@ The CLI and MCP surfaces accept the same schema. For a JSON request file: rea capture-electron-scenario scenario.json --json ``` -The result records action status and targets, correlated app/window/WebContents, +The result records one status and target per declared action; actions after a +failed or cancelled action are not run and are recorded as `cancelled` with a +not-executed error. It also records correlated app/window/WebContents, preload, session, navigation, shell, permission, popup, download, protocol, native-addon, process, and IPC timeline events. IPC channels and argument-shape metadata retain their observed lengths; payload values are never retained. diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts index 88fe1f97d8..842f894b66 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts @@ -362,6 +362,9 @@ describe("PlaywrightBrowserScenarioProvider", () => { expect(result.value.steps[1]?.error).toBe( "fixture [REDACTED] action failure", ); + expect(result.value.steps[2]?.error).toBe( + "not executed after an earlier action failure", + ); expect(session.performCalls).toBe(1); expect(session.closeCalls).toBe(1); expect(result.value.completeness.equality_eligible).toBe(false); @@ -713,7 +716,7 @@ describe("PlaywrightBrowserScenarioProvider request cancellation", () => { const provider = new PlaywrightBrowserScenarioProvider(() => Promise.resolve(session), ); - const result = await provider.captureScenario(scenario(), { + const result = await provider.captureScenario(scenario({ actions: 2 }), { signal: controller.signal, }); expect(result).toMatchObject({ @@ -724,12 +727,20 @@ describe("PlaywrightBrowserScenarioProvider request cancellation", () => { kind: "browser-scenario-observation", capture: { browser: { cleanup: "terminated-owned-process" }, - steps: [{ status: "completed" }, { status: "cancelled" }], + steps: [ + { status: "completed" }, + { status: "cancelled" }, + { + status: "cancelled", + error: "not executed after scenario request cancellation", + }, + ], }, }, }, }); if (result.ok) throw new Error("Expected active scenario cancellation"); + expect(session.performCalls).toBe(1); expect(result.error.cause).toBe(cancellationReason); expect(session.closeCalls).toBe(1); }); diff --git a/src/browser/PlaywrightBrowserScenarioProvider.ts b/src/browser/PlaywrightBrowserScenarioProvider.ts index 65e8fb67cc..018adbebaf 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.ts @@ -39,6 +39,10 @@ import type { BrowserScenarioSessionOpener, BrowserScenarioSessionPort, } from "./BrowserScenarioSessionPort.js"; +import { + notExecutedActionError, + type ScenarioActionStop, +} from "./ScenarioActionSequence.js"; import { browserScenarioCleanupObservation, browserScenarioOperationFailure, @@ -161,14 +165,15 @@ const executeStep = async (input: { readonly scenario: BrowserScenario; readonly action: BrowserScenarioAction; readonly stepIndex: number; - readonly priorFailure: boolean; + readonly stoppedBy: ScenarioActionStop | undefined; readonly signal: AbortSignal | undefined; }): Promise => { - const { session, scenario, action, stepIndex, priorFailure, signal } = input; + const { session, scenario, action, stepIndex, stoppedBy, signal } = input; const beforeUrl = session.sanitizeUrl(session.currentUrl()); const eventStart = session.nextEventSequence(); const requested = requestedForStep(scenario, stepIndex); - if (priorFailure) + if (stoppedBy !== undefined) { + const error = notExecutedActionError(stoppedBy); return createStep({ stepIndex, stepId: action.step_id, @@ -177,14 +182,12 @@ const executeStep = async (input: { elapsedMs: 0, beforeUrl, afterUrl: beforeUrl, - error: "not executed after an earlier action failure", + error, eventStart, eventEnd: session.lastEventSequence(), - artifacts: unavailableArtifacts( - requested, - "action was not executed after an earlier failure", - ), + artifacts: unavailableArtifacts(requested, error), }); + } const actionStartedAt = performance.now(); session.setStep(stepIndex); @@ -270,18 +273,19 @@ const runScenario = async ( signal: options.signal, }), ); - let failed = false; + let stoppedBy: ScenarioActionStop | undefined; for (const [offset, action] of scenario.actions.entries()) { const step = await executeStep({ session, scenario, action, stepIndex: offset + 1, - priorFailure: failed, + stoppedBy, signal: options.signal, }); steps.push(step); - failed ||= step.status !== "completed"; + if (stoppedBy === undefined && step.status !== "completed") + stoppedBy = step.status; } } catch (cause: unknown) { operationOutcome = { ok: false, cause }; diff --git a/src/browser/PlaywrightElectronActiveActions.test.ts b/src/browser/PlaywrightElectronActiveActions.test.ts index d0ba53574b..d07db0c9ff 100644 --- a/src/browser/PlaywrightElectronActiveActions.test.ts +++ b/src/browser/PlaywrightElectronActiveActions.test.ts @@ -410,6 +410,46 @@ it("cancels a wait without window_index while the app has no window", async () = ]); }); +it("records actions after a cancelled action as not executed", async () => { + const controller = new AbortController(); + const reload = vi.fn(async () => undefined); + const page = { + locator: () => ({ + click: () => { + queueMicrotask(() => controller.abort()); + return new Promise(() => undefined); + }, + }), + reload, + }; + + const actions = await runElectronActions( + { windows: () => [page] } as unknown as ElectronApplication, + electronActiveObservationInputSchema.parse({ + executable_path: "/opt/electron", + application_path: "/opt/app/main.js", + application_root: "/opt/app", + actions: [ + { step_id: "open", kind: "click", selector: "#open" }, + { step_id: "reload", kind: "renderer-reload" }, + ], + }), + { signal: controller.signal }, + ); + + expect(reload).not.toHaveBeenCalled(); + expect(actions.map(({ step_id, status }) => [step_id, status])).toEqual([ + ["open", "cancelled"], + ["reload", "cancelled"], + ]); + expect(actions[1]).toMatchObject({ + window_index: 0, + target: null, + elapsed_ms: 0, + error: "not executed after scenario request cancellation", + }); +}); + it("bounds a final state read that is still pending after cancellation", async () => { vi.useFakeTimers(); const controller = new AbortController(); diff --git a/src/browser/PlaywrightElectronActiveActions.ts b/src/browser/PlaywrightElectronActiveActions.ts index 5f6e97b894..5661e5d75c 100644 --- a/src/browser/PlaywrightElectronActiveActions.ts +++ b/src/browser/PlaywrightElectronActiveActions.ts @@ -8,6 +8,10 @@ import { type ElectronActiveObservationInput, type ElectronActiveObservationResult, } from "../domain/javascript/electronActiveObservation.js"; +import { + notExecutedActionError, + type ScenarioActionStop, +} from "./ScenarioActionSequence.js"; const OPERATION = "capture_electron_scenario" as const; const CANCELLED_STATE_READ_TIMEOUT_MS = 5_000; @@ -69,16 +73,33 @@ type ElectronApplicationState = Awaited< ReturnType >; -/** Run explicit actions against provider-owned Electron windows. */ +/** + * Run explicit actions against provider-owned Electron windows. Every declared + * action has one result; actions after a failed or cancelled action are + * recorded as not executed. + */ export const runElectronActions = async ( application: ElectronApplication, input: ElectronActiveObservationInput, options: ExecutionOptions, ): Promise => { const actions: ElectronActions = []; + let stoppedBy: ScenarioActionStop | undefined; for (const action of input.actions) { const actionStartedAt = performance.now(); const windowIndex = actionWindowIndex(action); + if (stoppedBy !== undefined) { + actions.push({ + step_id: action.step_id, + kind: action.kind, + window_index: windowIndex, + target: null, + status: "cancelled", + elapsed_ms: 0, + error: notExecutedActionError(stoppedBy), + }); + continue; + } let selectedWindow: Page | undefined; try { if (options.signal?.aborted === true) @@ -119,20 +140,20 @@ export const runElectronActions = async ( error: null, }); } catch (cause: unknown) { + stoppedBy = options.signal?.aborted ? "cancelled" : "failed"; actions.push({ step_id: action.step_id, kind: action.kind, window_index: windowIndex, target: selectedWindow === undefined ? null : `window:${String(windowIndex)}`, - status: options.signal?.aborted ? "cancelled" : "failed", + status: stoppedBy, elapsed_ms: Math.max( 0, Math.round(performance.now() - actionStartedAt), ), error: safeActionErrorMessage(cause), }); - break; } } return actions; diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 146c4a2528..82ee0dad9c 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -490,6 +490,80 @@ it("cancels a windowless wait action and cleans up the owned application", async expect(result.error.cause).toBe(reason); }); +it("records actions after a failed action as not executed and keeps the capture", async () => { + stubVerifiedElectronCleanup(); + const state = { + windows: [], + metrics: [], + electronVersion: "44.0.0", + hookSnapshot: emptyHookSnapshot(), + }; + vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state); + vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue( + state, + ); + const click = vi.fn(async () => { + throw new Error("strict mode violation"); + }); + const waitForTimeout = vi.fn(async () => undefined); + const page = { locator: () => ({ click }), waitForTimeout }; + const application = { + process: () => ({ pid: 424242 }), + close: vi.fn(async () => undefined), + windows: () => [page], + firstWindow: async () => page, + }; + + const result = await captureWithController( + new AbortController(), + application, + [ + { step_id: "fail", kind: "click", selector: "#duplicate" }, + { step_id: "settle", kind: "wait", duration_ms: 0, window_index: 0 }, + { + step_id: "link", + kind: "deep-link", + delivery: "open-url", + url: "rea-test://open", + }, + ], + ); + + if (!result.ok) throw result.error; + expect(click).toHaveBeenCalledOnce(); + expect(waitForTimeout).not.toHaveBeenCalled(); + expect(application.close).toHaveBeenCalledOnce(); + expect(result.value.actions).toEqual([ + { + step_id: "fail", + kind: "click", + window_index: 0, + target: "window:0", + status: "failed", + elapsed_ms: expect.any(Number), + error: "strict mode violation", + }, + { + step_id: "settle", + kind: "wait", + window_index: 0, + target: null, + status: "cancelled", + elapsed_ms: 0, + error: "not executed after an earlier action failure", + }, + { + step_id: "link", + kind: "deep-link", + window_index: null, + target: null, + status: "cancelled", + elapsed_ms: 0, + error: "not executed after an earlier action failure", + }, + ]); +}); + it("cancels a pending initial state read and cleans up the owned application", async () => { const cleanupSpies = stubVerifiedElectronCleanup(); const controller = new AbortController(); diff --git a/src/browser/ScenarioActionSequence.ts b/src/browser/ScenarioActionSequence.ts new file mode 100644 index 0000000000..7b9d66775d --- /dev/null +++ b/src/browser/ScenarioActionSequence.ts @@ -0,0 +1,14 @@ +/** Status of the earlier action that stopped a declared scenario sequence. */ +export type ScenarioActionStop = "failed" | "cancelled"; + +/** + * Explain a declared action recorded as `cancelled` without running because an + * earlier action stopped the sequence. Scenario producers record every + * remaining action so results account for the whole declared sequence. + */ +export const notExecutedActionError = ( + stoppedBy: ScenarioActionStop, +): string => + stoppedBy === "failed" + ? "not executed after an earlier action failure" + : "not executed after scenario request cancellation"; From f86206535f8ec71014781f9d432a84b01e183e5d Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:28:12 +0800 Subject: [PATCH 28/29] fix(ci): validate artifact MCP replies against named schemas (#6021) Bind artifact lifecycle and analysis calls to their advertised named SDK output schemas, sharing discovery per connection and rebinding on reconnect. Reject schema overrides and ambiguous catalogs, preserve request options, and retain primary failures while attempting all owned cleanup. Add real-stdio boundary regressions for malformed output, discovery reuse, reconnection, schema ambiguity, override rejection and timeout preservation. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- docs/testing.md | 16 ++ scripts/lib/artifact-e2e.mjs | 79 ++++++- scripts/verify-native-calls.mjs | 4 +- scripts/verify/apple/asset-catalog.mjs | 3 +- scripts/verify/apple/keyed-archive.mjs | 3 +- scripts/verify/apple/macho-occurrences.mjs | 3 +- scripts/verify/apple/macos-dylib-e2e.mjs | 3 +- scripts/verify/apple/native-provenance.mjs | 25 +-- .../process/artifactMcpVerifierOutput.test.ts | 126 +++++++++++ tests/fixtures/artifactMcpVerifierProbe.mjs | 209 ++++++++++++++++++ 10 files changed, 447 insertions(+), 24 deletions(-) create mode 100644 tests/boundary/process/artifactMcpVerifierOutput.test.ts create mode 100644 tests/fixtures/artifactMcpVerifierProbe.mjs diff --git a/docs/testing.md b/docs/testing.md index e19db750ba..9376578c4a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -256,6 +256,22 @@ pass that definition through the SDK's `toolDefinition` call option. This keeps the selected output-schema validation without eagerly compiling unrelated catalog schemas. Re-read definitions if the server advertises a changed list. +Artifact verification uses `artifactMcpCall` for both lifecycle and analysis +calls. A fresh SDK client has no held tool listing, so a bare `callTool` can +skip output-schema validation. The helper discovers actual advertised +definitions, rejects missing or ambiguous schemas, and validates the requested +tool without compiling unrelated catalog schemas. REA registers process-static +tool contracts; bindings share one discovery per negotiated connection and are +discarded on reconnect or failed discovery. Keep raw artifact calls on this +shared path and retain explicit request options such as timeouts. +Caller-supplied tool definitions are rejected: the helper owns selection from +the advertised catalog so a stale or permissive schema cannot bypass it. + +The regression fixture changes only `canonical_path` in a real stdio response +to a number. The old helper accepts that response; the named schema must reject +it. This client-boundary fault injection does not establish native Apple +behavior, which remains covered by the macOS verification lane. + Report unavailable authority as a named skip. Await owned cleanup before a verifier reports success. Native process inspectors are released at Vitest worker teardown; fork termination does not run Node exit hooks, and per-file diff --git a/scripts/lib/artifact-e2e.mjs b/scripts/lib/artifact-e2e.mjs index 444639319e..a2ab021fef 100644 --- a/scripts/lib/artifact-e2e.mjs +++ b/scripts/lib/artifact-e2e.mjs @@ -4,6 +4,67 @@ import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { closeVerifierResources } from "./verifier-cleanup.mjs"; + +// REA registers process-static tool contracts. Bind their parsed definitions to +// the SDK's negotiated connection identity, including when a Client reconnects. +const toolBindings = new WeakMap(); + +const advertisedTool = async (client, name) => { + const connection = client.getServerVersion(); + assert.ok(connection, "MCP must be initialized before binding tool schemas"); + let binding = toolBindings.get(client); + if (binding?.connection !== connection) { + binding = { + connection, + definitions: client.listTools().then(({ tools }) => { + const definitions = new Map(); + for (const tool of tools) { + assert.ok( + !definitions.has(tool.name), + `Duplicate MCP tool: ${tool.name}`, + ); + definitions.set(tool.name, tool); + } + return definitions; + }), + }; + toolBindings.set(client, binding); + } + let definitions; + try { + definitions = await binding.definitions; + assert.equal( + client.getServerVersion(), + connection, + "MCP connection changed while binding tool schemas", + ); + } catch (cause) { + if (toolBindings.get(client) === binding) toolBindings.delete(client); + throw cause; + } + const tool = definitions.get(name); + assert.ok(tool, `MCP catalog did not advertise ${name}`); + return tool; +}; + +/** Call an advertised tool with SDK output validation of its actual schema. */ +export async function artifactMcpCall(client, parameters, options = {}) { + options.signal?.throwIfAborted(); + assert.equal( + options.toolDefinition, + undefined, + "Artifact verification owns the advertised schema; pass request options only", + ); + const toolDefinition = await advertisedTool(client, parameters.name); + assert.notEqual( + toolDefinition.outputSchema, + undefined, + `MCP tool omitted its output schema: ${parameters.name}`, + ); + options.signal?.throwIfAborted(); + return client.callTool(parameters, { ...options, toolDefinition }); +} const environment = () => { const compileCache = @@ -66,9 +127,10 @@ const withOpenedArtifactMcp = async (target, openTool, verify) => { stderr: "pipe", }); const client = new Client({ name: "artifact-real-e2e", version: "1" }); + let failure; try { await client.connect(transport); - const opened = await client.callTool({ + const opened = await artifactMcpCall(client, { name: openTool, arguments: { path: target }, }); @@ -76,9 +138,15 @@ const withOpenedArtifactMcp = async (target, openTool, verify) => { if (openTool === "open_bundle_resources") assert.equal(opened.structuredContent?.result?.caller_path, target); await verify(client); + } catch (cause) { + failure = cause; + throw cause; } finally { - await client.close(); - await transport.close(); + toolBindings.delete(client); + await closeVerifierResources( + [() => client.close(), () => transport.close()], + failure, + ); } }; @@ -94,7 +162,10 @@ export async function withBundleResourcesMcp(target, verify) { /** Require a successful structured MCP tool result rather than its text rendering. */ export async function artifactMcpResult(client, name, arguments_ = {}) { - const result = await client.callTool({ name, arguments: arguments_ }); + const result = await artifactMcpCall(client, { + name, + arguments: arguments_, + }); assert.notEqual(result.isError, true, JSON.stringify(result)); assert.ok( result.structuredContent?.normalized_result, diff --git a/scripts/verify-native-calls.mjs b/scripts/verify-native-calls.mjs index 7a22620756..2d43a04573 100644 --- a/scripts/verify-native-calls.mjs +++ b/scripts/verify-native-calls.mjs @@ -10,6 +10,7 @@ import { promisify } from "node:util"; import { artifactCli, artifactMcpResult, + artifactMcpCall, withArtifactMcp, } from "./lib/artifact-e2e.mjs"; @@ -225,7 +226,8 @@ const checkHardenedRuntime = async (directory, binary) => { assert.equal(denied.code, "capability_unavailable"); assert.match(denied.details.reason, /^debugger-attach-denied: /u); await withArtifactMcp(blocked, async (client) => { - const deniedMcp = await client.callTool( + const deniedMcp = await artifactMcpCall( + client, { name: "observe_native_calls", arguments: INPUT, diff --git a/scripts/verify/apple/asset-catalog.mjs b/scripts/verify/apple/asset-catalog.mjs index 4de295f06f..a194d92111 100644 --- a/scripts/verify/apple/asset-catalog.mjs +++ b/scripts/verify/apple/asset-catalog.mjs @@ -9,6 +9,7 @@ import { promisify } from "node:util"; import { artifactCli, artifactMcpResult, + artifactMcpCall, withArtifactMcp, withBundleResourcesMcp, } from "../../lib/artifact-e2e.mjs"; @@ -97,7 +98,7 @@ try { offset: first.next_offset, }); assert.deepEqual([...first.records, ...next.records], cli.records); - const invalid = await client.callTool({ + const invalid = await artifactMcpCall(client, { name: "inspect_asset_catalog", arguments: { offset: -1 }, }); diff --git a/scripts/verify/apple/keyed-archive.mjs b/scripts/verify/apple/keyed-archive.mjs index 471b76ef64..e44929d7e1 100644 --- a/scripts/verify/apple/keyed-archive.mjs +++ b/scripts/verify/apple/keyed-archive.mjs @@ -9,6 +9,7 @@ import { createHash } from "node:crypto"; import { artifactCli, artifactMcpResult, + artifactMcpCall, withArtifactMcp, } from "../../lib/artifact-e2e.mjs"; import { @@ -53,7 +54,7 @@ try { }); assert.deepEqual(page.objects, [graph.objects[1]]); assert.equal(page.next_offset, 2); - const invalid = await client.callTool({ + const invalid = await artifactMcpCall(client, { name: "inspect_keyed_archive", arguments: { path: "../other.plist" }, }); diff --git a/scripts/verify/apple/macho-occurrences.mjs b/scripts/verify/apple/macho-occurrences.mjs index 9a7da4f4e0..d1719af43b 100644 --- a/scripts/verify/apple/macho-occurrences.mjs +++ b/scripts/verify/apple/macho-occurrences.mjs @@ -5,6 +5,7 @@ import { relative, sep } from "node:path"; import { parseArtifactInventoryEvidence } from "../../../dist/domain/artifactInventoryEvidence.js"; import { artifactCliEvidence, + artifactMcpCall, withArtifactMcp, } from "../../lib/artifact-e2e.mjs"; @@ -53,7 +54,7 @@ export async function verifyMachOOccurrences(app, executable) { assert.equal(node.sha256, direct.manifest.root_sha256); assert.equal(node.format, root.artifact_format); await withArtifactMcp(app, async (client) => { - const reply = await client.callTool({ + const reply = await artifactMcpCall(client, { name: "inspect_artifact", arguments: {}, }); diff --git a/scripts/verify/apple/macos-dylib-e2e.mjs b/scripts/verify/apple/macos-dylib-e2e.mjs index d9538c4cce..59a55bb7c1 100644 --- a/scripts/verify/apple/macos-dylib-e2e.mjs +++ b/scripts/verify/apple/macos-dylib-e2e.mjs @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import { artifactCliEvidence, artifactMcpResult, + artifactMcpCall, withArtifactMcp, } from "../../lib/artifact-e2e.mjs"; @@ -180,7 +181,7 @@ export async function verifyDylibResolution(app) { await artifactMcpResult(client, "trace_dylib_resolution"), trace, ); - const invalid = await client.callTool({ + const invalid = await artifactMcpCall(client, { name: "trace_dylib_resolution", arguments: { roots: ["../outside"] }, }); diff --git a/scripts/verify/apple/native-provenance.mjs b/scripts/verify/apple/native-provenance.mjs index 63ae2183b0..a225fafb95 100644 --- a/scripts/verify/apple/native-provenance.mjs +++ b/scripts/verify/apple/native-provenance.mjs @@ -5,6 +5,7 @@ import { readFile } from "node:fs/promises"; import { toolContract } from "../../../dist/contracts/toolContracts.js"; import { artifactCliEvidence, + artifactMcpCall, withArtifactMcp, withBundleResourcesMcp, } from "../../lib/artifact-e2e.mjs"; @@ -27,7 +28,7 @@ export async function verifyNativeProvenance(app, executable) { assert.equal(resourcePlistCli.subject, null); assert.equal(resourcePlistCli.raw_result, null); await withBundleResourcesMcp(app, async (client) => { - const response = await client.callTool({ + const response = await artifactMcpCall(client, { name: "inspect_plist", arguments: {}, }); @@ -58,13 +59,10 @@ export async function verifyNativeProvenance(app, executable) { assert.equal(binaryPlistCli.raw_result, null); assert.equal(binaryPlistCli.normalized_result.resource_root, undefined); await withArtifactMcp(app, async (client) => { - const definitions = new Map( - (await client.listTools()).tools.map((tool) => [tool.name, tool]), - ); - const plistResponse = await client.callTool( - { name: "inspect_plist", arguments: {} }, - { toolDefinition: definitions.get("inspect_plist") }, - ); + const plistResponse = await artifactMcpCall(client, { + name: "inspect_plist", + arguments: {}, + }); assert.notEqual(plistResponse.isError, true, JSON.stringify(plistResponse)); const binaryPlistMcp = toolContract("inspect_plist").outputSchema.parse( plistResponse.structuredContent, @@ -98,13 +96,10 @@ export async function verifyNativeProvenance(app, executable) { arguments_, ), ); - const response = await client.callTool( - { - name: operation, - arguments: operation === "demangle_swift" ? { symbols } : {}, - }, - { toolDefinition: definitions.get(operation) }, - ); + const response = await artifactMcpCall(client, { + name: operation, + arguments: operation === "demangle_swift" ? { symbols } : {}, + }); assert.notEqual(response.isError, true, JSON.stringify(response)); const mcp = toolContract(operation).outputSchema.parse( response.structuredContent, diff --git a/tests/boundary/process/artifactMcpVerifierOutput.test.ts b/tests/boundary/process/artifactMcpVerifierOutput.test.ts new file mode 100644 index 0000000000..91da42a924 --- /dev/null +++ b/tests/boundary/process/artifactMcpVerifierOutput.test.ts @@ -0,0 +1,126 @@ +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const exec = promisify(execFile); +const observationSchema = z.strictObject({ + accepted: z.boolean(), + catalogRequests: z.number().int().nonnegative(), + corruptedReplies: z.number().int().nonnegative(), + toolCalls: z.number().int().nonnegative(), + toolTimeouts: z.array(z.number().nullable()), + error: z + .strictObject({ + name: z.string(), + message: z.string(), + code: z.union([z.number(), z.string()]).nullable(), + }) + .nullable(), +}); + +async function probe(mode: string, name = "Fixture.app") { + const root = await createTestTempDirectory("rea-artifact-mcp-schema-"); + const target = join(root, name); + await mkdir(join(target, "Contents", "Resources"), { recursive: true }); + await writeFile( + join(target, "Contents", "Info.plist"), + 'CFBundleIdentifierdev.rea.schema-probe', + ); + const { stdout } = await exec( + process.execPath, + [ + fileURLToPath( + new URL("../../fixtures/artifactMcpVerifierProbe.mjs", import.meta.url), + ), + mode, + target, + ], + // Retain cleanup time within the existing 30s test budget. + { timeout: 25_000 }, + ); + return observationSchema.parse(JSON.parse(stdout)); +} + +it("validates the real resource admission reply against its advertised schema", async () => { + expect(await probe("healthy", "Fixture.app")).toEqual({ + accepted: true, + catalogRequests: 1, + corruptedReplies: 0, + toolCalls: 1, + toolTimeouts: [null], + error: null, + }); +}); + +it.each(["Fixture.app", "Fixture with spaces 雨.app"])( + "rejects a corrupted canonical path in a real admission reply: %s", + async (name) => { + const result = await probe("corrupt", name); + expect(result.accepted).toBe(false); + expect(result.catalogRequests).toBe(1); + expect(result.corruptedReplies).toBe(1); + expect(result.error).toMatchObject({ + name: "ProtocolError", + code: -32602, + message: expect.stringMatching(/output schema.*canonical_path/u), + }); + }, +); + +it.each(["reuse", "concurrent"])( + "shares one schema discovery within one MCP connection: %s", + async (mode) => { + const result = await probe(mode); + expect(result.accepted).toBe(true); + expect(result.catalogRequests).toBe(1); + expect(result.toolCalls).toBe(mode === "reuse" ? 3 : 2); + }, +); + +it("binds the new advertised schema after reconnecting the same client", async () => { + const result = await probe("reconnect"); + expect(result.accepted).toBe(false); + expect(result.catalogRequests).toBe(2); + expect(result.toolCalls).toBe(2); + expect(result.error).toMatchObject({ + code: -32602, + message: expect.stringContaining("__rea_second_connection"), + }); +}); + +it("rejects an omitted output schema before calling the advertised tool", async () => { + const result = await probe("missing-schema"); + expect(result.accepted).toBe(false); + expect(result.toolCalls).toBe(0); + expect(result.error?.message).toMatch(/omitted its output schema/u); +}); + +it("rejects a supplied schema override before dispatching that call", async () => { + const result = await probe("override"); + expect(result.accepted).toBe(false); + expect(result.toolCalls).toBe(1); + expect(result.error?.message).toMatch(/owns the advertised schema/u); +}); + +it("preserves explicit request timeouts on schema-bound calls", async () => { + const result = await probe("request-options"); + expect(result.accepted).toBe(true); + expect(result.catalogRequests).toBe(1); + expect(result.toolTimeouts).toEqual([null, 12_345]); +}); + +it.each(["duplicate-first", "duplicate-last"])( + "rejects ambiguous named schemas independently of catalog order: %s", + async (mode) => { + const result = await probe(mode); + expect(result.accepted).toBe(false); + expect(result.toolCalls).toBe(0); + expect(result.error?.message).toMatch(/Duplicate MCP tool/u); + }, +); diff --git a/tests/fixtures/artifactMcpVerifierProbe.mjs b/tests/fixtures/artifactMcpVerifierProbe.mjs new file mode 100644 index 0000000000..731d39881f --- /dev/null +++ b/tests/fixtures/artifactMcpVerifierProbe.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import * as artifact from "../../scripts/lib/artifact-e2e.mjs"; +import { closeVerifierResources } from "../../scripts/lib/verifier-cleanup.mjs"; + +const [mode, target] = process.argv.slice(2); +assert.ok( + [ + "healthy", + "corrupt", + "reuse", + "concurrent", + "reconnect", + "missing-schema", + "duplicate-first", + "duplicate-last", + "override", + "request-options", + ].includes(mode), +); +assert.equal(typeof target, "string"); + +// Isolate the fault to this child client. The server, resource admission and +// advertised schema are real; alter only the reply before tool validation. +const request = Client.prototype.request; +let catalogRequests = 0; +let corruptedReplies = 0; +let toolCalls = 0; +let connection = 0; +const toolTimeouts = []; +Client.prototype.request = async function (input, options) { + if (input.method === "tools/list") catalogRequests++; + if (input.method === "tools/call") { + toolCalls++; + toolTimeouts.push(options?.timeout ?? null); + } + const result = await request.call(this, input, options); + if ( + mode === "override" && + input.method === "tools/call" && + input.params.name === "binary_session" + ) { + corruptedReplies++; + return { ...result, structuredContent: { result: 777 } }; + } + if (input.method === "tools/list") { + if (mode === "missing-schema") + return { + ...result, + tools: result.tools.map((tool) => { + if (tool.name !== "open_bundle_resources") return tool; + const { outputSchema: _schema, ...withoutSchema } = tool; + return withoutSchema; + }), + }; + if (mode === "duplicate-first" || mode === "duplicate-last") { + const listed = result.tools.find( + (tool) => tool.name === "open_bundle_resources", + ); + assert.ok(listed); + const duplicate = { + ...listed, + outputSchema: { type: "object", required: ["__rea_wrong_schema"] }, + }; + return { + ...result, + tools: + mode === "duplicate-first" + ? [duplicate, ...result.tools] + : [...result.tools, duplicate], + }; + } + if (mode === "reconnect" && connection === 2) + return { + ...result, + tools: result.tools.map((tool) => + tool.name === "binary_session" + ? { + ...tool, + outputSchema: { + type: "object", + required: ["__rea_second_connection"], + }, + } + : tool, + ), + }; + } + if ( + mode !== "corrupt" || + input.method !== "tools/call" || + input.params.name !== "open_bundle_resources" + ) + return result; + assert.equal( + typeof result.structuredContent?.result?.canonical_path, + "string", + "The real reply omitted the field selected for fault injection", + ); + corruptedReplies++; + return { + ...result, + structuredContent: { + ...result.structuredContent, + result: { ...result.structuredContent.result, canonical_path: 777 }, + }, + }; +}; + +const status = async (client) => { + const result = await artifact.artifactMcpCall(client, { + name: "binary_session", + arguments: {}, + }); + assert.notEqual(result.isError, true, JSON.stringify(result)); +}; + +async function standaloneConnections() { + const client = new Client({ name: "artifact-binding-probe", version: "1" }); + let firstVersion; + for ( + connection = 1; + connection <= (mode === "reconnect" ? 2 : 1); + connection++ + ) { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ + fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url)), + "mcp", + ], + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + REA_LOG_LEVEL: "silent", + }, + stderr: "pipe", + }); + let failure; + try { + await client.connect(transport); + const version = client.getServerVersion(); + assert.ok(version); + if (firstVersion !== undefined) { + assert.notEqual(version, firstVersion); + assert.equal(version.name, firstVersion.name); + assert.equal(version.version, firstVersion.version); + } + firstVersion = version; + if (mode === "concurrent") + await Promise.all([status(client), status(client)]); + else await status(client); + } catch (cause) { + failure = cause; + throw cause; + } finally { + await closeVerifierResources( + [() => client.close(), () => transport.close()], + failure, + ); + } + } +} + +let error; +try { + if (mode === "concurrent" || mode === "reconnect") + await standaloneConnections(); + else + await artifact.withBundleResourcesMcp(target, async (client) => { + if (mode === "reuse") await Promise.all([status(client), status(client)]); + if (mode === "override") + await artifact.artifactMcpCall( + client, + { name: "binary_session", arguments: {} }, + { + toolDefinition: { + name: "binary_session", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + }, + }, + ); + if (mode === "request-options") + await artifact.artifactMcpCall( + client, + { name: "binary_session", arguments: {} }, + { timeout: 12_345 }, + ); + }); +} catch (cause) { + error = { + name: cause instanceof Error ? cause.name : "unknown", + message: cause instanceof Error ? cause.message : String(cause), + code: cause?.code ?? null, + }; +} +process.stdout.write( + JSON.stringify({ + accepted: error === undefined, + catalogRequests, + corruptedReplies, + toolCalls, + toolTimeouts, + error: error ?? null, + }) + "\n", +); From d8a9673fc44fde4bd30ff98c241886e6683580ca Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:46:29 +0800 Subject: [PATCH 29/29] fix(process): name the Swift compile deadline (#6071) Name REA_SWIFTC_COMPILE_TIMEOUT_MS, its effective milliseconds, the 1..2147483647 range, and the omitted 120000 ms default when a Darwin ownership-helper compile times out. A change applies after REA restarts, on the next ownership-helper compile. Producer SIGXCPU and SIGXFSZ recovery says REA retains the tighter inherited limit. Agent-visible fixed caps in this theme say this release has no runtime override. Numbers are unchanged. Refs #5910 Co-authored-by: Cursor Agent Co-authored-by: morluto --- docs/installation.md | 6 +- docs/kuna-provider.md | 2 +- docs/mcp-contracts.md | 14 ++-- docs/native-investigation.md | 6 +- docs/process-capture.md | 9 +++ src/captures/TsharkCaptureProvider.ts | 11 ++- src/config/providerStartupTimeout.ts | 9 ++- src/contracts/officialToolContracts.ts | 4 +- src/contracts/sessionToolContracts.ts | 2 +- src/domain/fixedAllowanceRemediation.ts | 13 ++++ src/domain/process/processScenario.test.ts | 17 ++++- src/domain/process/processScenario.ts | 6 +- src/process/ArtifactAnalysisResources.test.ts | 76 ++++++++++++++++++- src/process/ArtifactAnalysisResources.ts | 29 ++++--- ...arwinProcessRunTokenReader.timeout.test.ts | 16 +++- src/process/DarwinProcessRunTokenReader.ts | 23 +++++- src/process/ProcessOwnership.ts | 21 +++-- .../ProcessOwnership.validation.test.ts | 5 +- src/process/RegexMatcher.ts | 56 ++++++++------ src/process/ownedDebuggerSession.ts | 2 +- src/signals/SigrokRecordingProvider.ts | 12 ++- .../boundary/captures/captureProvider.test.ts | 3 + .../darwinProcessRunTokenReader.test.ts | 27 ++++--- .../process/ownedDebuggerSession.test.ts | 6 +- tests/boundary/process/regexMatcher.test.ts | 38 +++++++++- .../signals/recordingProvider.test.ts | 3 + 26 files changed, 321 insertions(+), 95 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index cbfb3e4db5..0159c5031a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1189,8 +1189,10 @@ truncation markers. Darwin ownership-helper compilation failures also retain the compiler outcome, exit code or signal, and bounded stderr; a timed-out compiler remains distinguishable from a compiler that exited with an error. The helper compile deadline defaults to 120,000 ms; set -`REA_SWIFTC_COMPILE_TIMEOUT_MS` to an integer between 1 and 2,147,483,647 when -a busy host needs longer. +`REA_SWIFTC_COMPILE_TIMEOUT_MS` to an integer between 1 and 2,147,483,647 in +the environment that starts REA when a busy host needs longer. Restart REA to +apply a change; the next ownership-helper compile uses the new value. A +timeout names that effective deadline. On macOS, the Java prerequisite probe waits for this independently bounded preparation before starting its five-second command deadline. Caller cancellation still interrupts preparation, and failed helper cleanup remains owned for retry. diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index 05e5a633bf..bea2403410 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -68,7 +68,7 @@ The background export belongs to the session. `close_binary`, or the end of a CL The assembly label index is scanned once per export reader. Cancelling a request stops its wait without interrupting other readers; closing the session aborts and drains the shared scan before releasing its files. -Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. +Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline and a 128 MiB worker heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. This release has no runtime override for that deadline or heap. Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger text remains in the local export and the error identifies the file and range. `analyze_function` initially returns the complete dossier, which can still be large; later retained views do not remove that first transfer. Prefer the individual text tools for a focused question. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 16543c9b7a..7b6d37328a 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -807,13 +807,13 @@ auto-analysis, bridge connection, and health readiness before returning analysis These deadlines have different owners: -| Deadline | Owner and effect | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MCP initialize | The client bounds transport/REA connection startup, before any target query. | -| Hopper startup | REA allows 120,000 ms by default; `REA_HOPPER_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Startup failure is reported by the first provider query. | -| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. | -| Darwin swiftc helper | REA allows 120,000 ms by default for ownership-helper compilation; `REA_SWIFTC_COMPILE_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. | -| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. | +| Deadline | Owner and effect | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MCP initialize | The client bounds transport/REA connection startup, before any target query. | +| Hopper startup | REA allows 120,000 ms by default; `REA_HOPPER_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Startup failure is reported by the first provider query. | +| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. | +| Darwin swiftc helper | REA allows 120,000 ms by default for ownership-helper compilation; `REA_SWIFTC_COMPILE_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Set it in the environment that starts REA and restart REA; the next ownership-helper compile uses the new value. | +| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. | For [Codex and Kimi registrations](client-configuration.md), setup supplies missing tool deadlines from the known provider allowance plus 120 seconds of diff --git a/docs/native-investigation.md b/docs/native-investigation.md index c4aaa93b97..8f469a5d02 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -202,8 +202,10 @@ Function dossiers retain this same string evidence. Native call edges retain Hopper's partial `CallReference` classification and exact endpoints; detailed reference flags remain unavailable rather than being invented. Regex searches use ECMAScript Unicode syntax in a cancellable worker with a -five-second matching deadline. Deadline or cancellation stops matching while -leaving the Hopper API available. Literal mode retains Unicode casefold matching. +five-second matching deadline and a 128 MiB worker heap. Deadline or +cancellation stops matching while leaving the Hopper API available. This +release has no runtime override for that deadline or heap. Literal mode +retains Unicode casefold matching. ## Bounded static traces diff --git a/docs/process-capture.md b/docs/process-capture.md index 67f81c085f..19047b96c9 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -545,6 +545,15 @@ missing or relative SDK/compiler path fails with an actionable prerequisite error before compilation. An incompatible installation reports the compiler's bounded diagnostics; REA does not repair or replace the toolchain. +The compile deadline is `REA_SWIFTC_COMPILE_TIMEOUT_MS`. An omitted value is +120,000 ms, and accepted values are integers from 1 to 2,147,483,647. REA reads +the variable from the environment that starts the process and keeps that +deadline for every helper compile in that process. Restart REA to apply a +change; the next ownership-helper compile uses the new value. A timeout names +the effective milliseconds. Invalid values fail configuration validation at +startup. Embedded compiler diagnostics are cut at 512 bytes, and this release +has no runtime override for that excerpt. + For example, select paths from your existing Xcode or Command Line Tools installation **before** entering an enforced write boundary: diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 110f0f5dd2..57235f0c56 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -45,7 +45,10 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { createEvidence } from "../domain/evidence.js"; -import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; +import { + fixedAllowanceOptions, + fixedProducerInputAllowance, +} from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { type InspectPacketCaptureInput, @@ -387,7 +390,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { "Packet limits count input packets before display filtering. Filtered results do not establish whole-capture or device traffic absence. Two-pass reassembly cannot recover bytes missing from the recording or beyond the read limit.", "Repeated field values remain ordered string arrays; raw packet bytes are included only when explicitly selected as a supported field. Export index/score metadata are omitted. All stderr, including warnings on exit zero, is retained.", "A private empty personal configuration is used. Installed libraries, global configuration and dissector plugins are trusted and not fully fingerprinted. File-only analysis and disabled name resolution do not constitute an OS sandbox.", - "Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.", + "Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted. REA retains the tighter inherited limit. This release has no runtime override.", ], }), ); @@ -568,9 +571,9 @@ export class TsharkCaptureProvider implements PacketCapturePort { { input_bytes: LIMITS.input_bytes }, { cause, - ...fixedAllowanceOptions( + ...fixedProducerInputAllowance( + LIMITS.input_bytes, "the fixed packet-capture input allowance", - "input", ), }, ); diff --git a/src/config/providerStartupTimeout.ts b/src/config/providerStartupTimeout.ts index eb3b9d52f1..b948ac3658 100644 --- a/src/config/providerStartupTimeout.ts +++ b/src/config/providerStartupTimeout.ts @@ -10,12 +10,15 @@ export const DEFAULT_HOPPER_STARTUP_TIMEOUT_MS = 120_000; /** Fresh Swift SDK cache took ~88s on the supported Intel macOS VM. */ export const DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS = 120_000; -// Node schedules delays above this signed 32-bit boundary as a 1 ms timer. -const MAX_TIMER_DELAY_MS = 2_147_483_647; +/** + * Largest delay Node schedules exactly. Delays above this signed 32-bit + * boundary become a 1 ms timer, so startup and swiftc timeouts stop here. + */ +export const MAX_PROVIDER_STARTUP_TIMEOUT_MS = 2_147_483_647; /** Parse the optional setting without reading the ambient process environment. */ export const providerStartupTimeoutSchema = z .string() .regex(/^[1-9]\d*$/u, "must be a positive decimal integer") .transform(Number) - .pipe(z.number().int().min(1).max(MAX_TIMER_DELAY_MS)); + .pipe(z.number().int().min(1).max(MAX_PROVIDER_STARTUP_TIMEOUT_MS)); diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index c16f1f1357..b5f8a7b7a3 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -200,12 +200,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_procedures", - "Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker; a matching deadline leaves its API usable. Select detail summary for counts and retained native-inventory pages; complete remains the default.", + "Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker with a fixed 5000 ms matching deadline; this release has no runtime override, and a matching deadline leaves its API usable. Select detail summary for counts and retained native-inventory pages; complete remains the default.", z.object({ ...analysisSearchInput, detail: analysisDetailSchema }), ), official( "search_strings", - "Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker; a matching deadline leaves its API usable. Select detail summary for counts and retained native-inventory pages; complete remains the default.", + "Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker with a fixed 5000 ms matching deadline; this release has no runtime override, and a matching deadline leaves its API usable. Select detail summary for counts and retained native-inventory pages; complete remains the default.", z.object({ ...analysisSearchInput, detail: analysisDetailSchema }), ), official( diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 4bb7e44e1a..3be958abdd 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -95,7 +95,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "capture_process_scenario", - "Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. A positive finalization_ms makes a timeout or idle deadline send SIGTERM to the captured root through its start identity, keep observing, and send SIGKILL once after the interval; exit.finalization records each attempt with its delivery result and the observed exit, and cancellation still ends the run at once. The target runs with the current user's permissions; this is not a security sandbox.", + "Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. This release has no runtime override for that cell budget. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. A positive finalization_ms makes a timeout or idle deadline send SIGTERM to the captured root through its start identity, keep observing, and send SIGKILL once after the interval; exit.finalization records each attempt with its delivery result and the observed exit, and cancellation still ends the run at once. The target runs with the current user's permissions; this is not a security sandbox.", processScenarioSchema, ), session( diff --git a/src/domain/fixedAllowanceRemediation.ts b/src/domain/fixedAllowanceRemediation.ts index 962c3987d9..4236a03887 100644 --- a/src/domain/fixedAllowanceRemediation.ts +++ b/src/domain/fixedAllowanceRemediation.ts @@ -54,3 +54,16 @@ export const fixedAllowanceOptions = ( retryable: false, fixedAllowance: { name: allowance, kind }, }); + +/** + * Name a fixed producer input allowance in bytes. Host and inherited limits + * do not raise it, and this release has no runtime override. + */ +export const fixedProducerInputAllowance = ( + bytes: number, + allowance: string, +): { readonly retryable: false; readonly fixedAllowance: FixedAllowance } => + fixedAllowanceOptions( + `${allowance} of ${String(bytes)} bytes (this release has no runtime override)`, + "input", + ); diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts index f6a79f4b54..529fcc2d9a 100644 --- a/src/domain/process/processScenario.test.ts +++ b/src/domain/process/processScenario.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "vitest"; +import { SESSION_TOOL_CONTRACTS } from "../../contracts/sessionToolContracts.js"; import { digestProcessCommitment, parseProcessScenario, @@ -151,6 +152,20 @@ it("admits the default and the exact combined terminal-cell budget", () => { ).toBe(true); }); +it("tells the agent the terminal cell budget has no runtime override", () => { + const description = SESSION_TOOL_CONTRACTS.find( + (tool) => tool.name === "capture_process_scenario", + )?.description; + expect(PROCESS_TERMINAL_CELL_BUDGET).toBe(1_000_000); + expect(description).toContain("1,000,000 combined terminal cells"); + expect(description).toContain( + "This release has no runtime override for that cell budget.", + ); + expect(processScenarioSchema.shape.terminal.unwrap().description).toContain( + "This release has no runtime override.", + ); +}); + it("rejects an oversized initial buffer without clamping caller dimensions", () => { const input = { executable: "/bin/true", @@ -165,7 +180,7 @@ it("rejects an oversized initial buffer without clamping caller dimensions", () code: "custom", path: ["terminal"], message: expect.stringContaining( - `${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`, + `${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells. This release has no runtime override.`, ), }), ); diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index bc91d5b16e..bfaab26727 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -164,7 +164,7 @@ export const processScenarioSchema = z scrollback: z.number().int().safe().nonnegative().default(1_000), }) .describe( - "The visible terminal and scrollback together must use at most 1,000,000 xterm renderer cells: columns × (rows + scrollback). Resize events use the same scrollback and limit.", + "The visible terminal and scrollback together must use at most 1,000,000 xterm renderer cells: columns × (rows + scrollback). Resize events use the same scrollback and limit. This release has no runtime override.", ) .default({ columns: 80, rows: 24, scrollback: 1_000 }), events: z @@ -268,7 +268,7 @@ export const processScenarioSchema = z context.addIssue({ code: "custom", path: ["terminal"], - message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`, + message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells. This release has no runtime override.`, }); for (let index = 1; index < scenario.events.length; index += 1) { const event = scenario.events[index]; @@ -293,7 +293,7 @@ export const processScenarioSchema = z context.addIssue({ code: "custom", path: ["events", index], - message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback`, + message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback. This release has no runtime override.`, }); if (event.at_ms > scenario.timeout_ms) { context.addIssue({ diff --git a/src/process/ArtifactAnalysisResources.test.ts b/src/process/ArtifactAnalysisResources.test.ts index 2e59422afb..ba0c4ef382 100644 --- a/src/process/ArtifactAnalysisResources.test.ts +++ b/src/process/ArtifactAnalysisResources.test.ts @@ -491,6 +491,77 @@ it.each([false, true])( }, ); +it("names the fixed producer command deadline and says it has no override", async () => { + const resources = new ArtifactAnalysisResources( + "fixture", + "inspect_packet_fields", + readStableArtifact, + readLinuxResourceLimits, + { message: "busy", details: {} }, + ); + const failure = new OwnedCommandFailure("timeout", "command timed out", null); + try { + const error = resources.commandFailure( + failure, + { command_timeout_ms: 30_000, output_bytes: 1024 }, + undefined, + ); + const projected = projectAnalysisError(error); + expect(projected.message).toContain("30000 ms"); + expect(projected.remediation.action).toContain( + "this release has no runtime override", + ); + expect(projected.remediation.action).not.toContain( + "REA_SWIFTC_COMPILE_TIMEOUT_MS", + ); + } finally { + await resources.close(); + } +}); + +it("says REA retains the tighter inherited limit when a producer signal fires", async () => { + const resources = new ArtifactAnalysisResources( + "fixture", + "inspect_packet_fields", + readStableArtifact, + readLinuxResourceLimits, + { message: "busy", details: {} }, + ); + const empty = { text: "", bytes: 0, observedBytes: 0 }; + const failure = new OwnedCommandFailure("process", "producer signal", { + stdout: empty, + stderr: empty, + exitCode: null, + signal: "SIGXCPU", + }); + try { + const error = resources.commandFailure( + failure, + { command_timeout_ms: 30_000, output_bytes: 4_194_304 }, + { + address_space_bytes: 1_073_741_824, + file_bytes: 67_108_864, + cpu_seconds: 30, + }, + ); + const projected = projectAnalysisError(error); + expect(projected.remediation.action).toContain( + "REA retains the tighter inherited limit", + ); + expect(projected.remediation.action).toContain("1073741824 bytes"); + expect(projected.remediation.action).toContain("67108864 bytes"); + expect(projected.remediation.action).toContain("30 CPU seconds"); + expect(projected.remediation.action).toContain( + "This release has no runtime override", + ); + expect(projected.details).toMatchObject({ + reported_limits: { cpu_seconds: 30 }, + }); + } finally { + await resources.close(); + } +}); + it("treats a fixed command-output cap as a non-retryable capture allowance", async () => { const resources = new ArtifactAnalysisResources( "fixture", @@ -519,7 +590,10 @@ it("treats a fixed command-output cap as a non-retryable capture allowance", asy const projected = projectAnalysisError(error); expect(projected.retryable).toBe(false); expect(projected.remediation.action).toContain( - "The retained command-output allowance", + "The retained command-output allowance of 1024 bytes", + ); + expect(projected.remediation.action).toContain( + "this release has no runtime override", ); expect(projected.remediation.action).not.toMatch( /Review the reported worker memory limits/u, diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index 29168213a1..16292d3073 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -352,11 +352,11 @@ export class ArtifactAnalysisResources { cause.cancellation === undefined ? cause : cause.cancellation.reason, }); if (cause.reason === "timeout") - return new AnalysisTimeoutError( - operation, - limits.command_timeout_ms, - detail, - ); + return new AnalysisTimeoutError(operation, limits.command_timeout_ms, { + ...detail, + userMessage: `The owned producer command reached its fixed ${String(limits.command_timeout_ms)} ms deadline. This is not a client-side timeout; extending only the MCP client's deadline does not change this budget.`, + remediationAction: `REA's fixed producer command deadline is ${String(limits.command_timeout_ms)} ms and this release has no runtime override. Inspect any partial output and cleanup before retrying. Repeating the unchanged request reaches the same deadline.`, + }); if (cause.reason === "output-limit") return new AnalysisResourceConstraintError( operation, @@ -366,7 +366,7 @@ export class ArtifactAnalysisResources { { ...detail, ...fixedAllowanceOptions( - "The retained command-output allowance", + `The retained command-output allowance of ${String(limits.output_bytes)} bytes (this release has no runtime override)`, "capture", ), }, @@ -375,14 +375,23 @@ export class ArtifactAnalysisResources { cause.reason === "process" && (cause.snapshot?.signal === "SIGXFSZ" || cause.snapshot?.signal === "SIGXCPU") - ) + ) { + const signal = cause.snapshot.signal; + const reported = + effectiveLimits === undefined + ? "The effective inherited limit was not reported." + : `The effective inherited limits are ${String(effectiveLimits.address_space_bytes)} bytes of address space, ${String(effectiveLimits.file_bytes)} bytes per file, and ${String(effectiveLimits.cpu_seconds)} CPU seconds.`; return new AnalysisResourceConstraintError( operation, - cause.snapshot.signal === "SIGXFSZ" ? "file-size" : "cpu", - `Producer terminated with ${cause.snapshot.signal}; the signal's exact cause is not independently established.`, + signal === "SIGXFSZ" ? "file-size" : "cpu", + `Producer terminated with ${signal}; the signal's exact cause is not independently established.`, effectiveLimits === undefined ? null : { ...effectiveLimits }, - detail, + { + ...detail, + remediationAction: `${reported} REA retains the tighter inherited limit. This release has no runtime override for that ceiling. A smaller artifact may finish inside the retained limit. Repeating the unchanged request fails the same way.`, + }, ); + } return new AnalysisOutputError(operation, cause.message, detail); } diff --git a/src/process/DarwinProcessRunTokenReader.timeout.test.ts b/src/process/DarwinProcessRunTokenReader.timeout.test.ts index 224b496006..84147d66fe 100644 --- a/src/process/DarwinProcessRunTokenReader.timeout.test.ts +++ b/src/process/DarwinProcessRunTokenReader.timeout.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveSwiftcCompileTimeoutMs } from "./DarwinProcessRunTokenReader.js"; +import { + resolveSwiftcCompileTimeoutMs, + swiftcCompileTimeoutRecovery, +} from "./DarwinProcessRunTokenReader.js"; +import { MAX_PROVIDER_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js"; describe("resolveSwiftcCompileTimeoutMs", () => { it("keeps the historical default when unset", () => { @@ -31,4 +35,14 @@ describe("resolveSwiftcCompileTimeoutMs", () => { }), ).toBe(120_000); }); + + it("names the launch-environment deadline, range, and restart on a timeout", () => { + const recovery = swiftcCompileTimeoutRecovery(300_000); + expect(recovery).toContain("REA_SWIFTC_COMPILE_TIMEOUT_MS"); + expect(recovery).toContain("300000 ms"); + expect(recovery).toContain(`1..${String(MAX_PROVIDER_STARTUP_TIMEOUT_MS)}`); + expect(recovery).toContain("120000 ms"); + expect(recovery).toContain("restart REA"); + expect(recovery).toContain("next ownership-helper compile"); + }); }); diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index feb04bac97..6ac608aebf 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS, + MAX_PROVIDER_STARTUP_TIMEOUT_MS, providerStartupTimeoutSchema, } from "../config/providerStartupTimeout.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -105,7 +106,9 @@ export const SWIFTC_INTERRUPT = "SIGINT"; * Swift SDK cache on the supported Intel macOS VM (about 88s), so compilation * stays bounded independently of short provider version probes. Invalid ambient * values fall back to the default; `parseConfig` still rejects them at CLI/MCP - * admission. + * admission. The resolved value is captured when the reader is created. + * Production creates that reader from the launch environment at process start, + * so a change applies after REA restarts, on the next ownership-helper compile. */ export const resolveSwiftcCompileTimeoutMs = ( options: { @@ -121,6 +124,10 @@ export const resolveSwiftcCompileTimeoutMs = ( return parsed.success ? parsed.data : DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS; }; +/** Name the existing swiftc deadline when a helper compile times out. */ +export const swiftcCompileTimeoutRecovery = (timeoutMs: number): string => + `The effective REA_SWIFTC_COMPILE_TIMEOUT_MS value is ${String(timeoutMs)} ms (integer range 1..${String(MAX_PROVIDER_STARTUP_TIMEOUT_MS)}; omitted default ${String(DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS)} ms). Set it in the environment that starts REA and restart REA; the next ownership-helper compile uses the new value.`; + /** Bound on compiler diagnostics embedded in the inspection failure text. */ const STDERR_EXCERPT_LIMIT = 512; @@ -272,8 +279,11 @@ const compilerOutcomeDescription = ( if (outcome === undefined) return `compiler result: ${String(fallbackCode)}`; if (outcome.outcome === "exited") return `compiler result: exit code ${String(outcome.code)}`; - if (outcome.outcome === "timed_out") - return `compiler result: timed out after ${String(outcome.timeoutMs)} ms`; + if (outcome.outcome === "timed_out") { + if (outcome.timeoutMs === undefined) + return "compiler result: timed out. The effective REA_SWIFTC_COMPILE_TIMEOUT_MS value was not reported."; + return `compiler result: timed out after ${String(outcome.timeoutMs)} ms. ${swiftcCompileTimeoutRecovery(outcome.timeoutMs)}`; + } if (outcome.outcome === "signaled") return `compiler result: stopped by signal ${outcome.signal ?? "an unknown signal"}`; return `compiler result: ${String(outcome.code)}`; @@ -302,8 +312,13 @@ const compilerInspectionError = ( stderr.truncated || outcome?.stderrTruncated === true || outcome?.outputTruncated === true; + const truncationNote = !truncated + ? "" + : stderr.truncated + ? `… [diagnostics truncated at ${String(STDERR_EXCERPT_LIMIT)} bytes; this release has no runtime override]` + : "… [diagnostics truncated; this release has no runtime override]"; const suffix = diagnostics - ? `: ${diagnostics}${truncated ? "… [diagnostics truncated]" : ""}` + ? `: ${diagnostics}${truncationNote}` : outcome === undefined ? `: ${cause instanceof Error ? cause.message : String(cause)}` : ": no compiler diagnostics were captured"; diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 5ca6d021aa..bbc1d5f3bc 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -328,6 +328,13 @@ const mergeUnverifiedObservations = ( return { ...result, unverified: merged }; }; +/** + * Times one group may be revalidated in a single cleanup. The second pass + * covers a group that reappears after it was already processed. Another + * appearance stays unresolved instead of reporting success. + */ +const OWNED_GROUP_REVALIDATION_LIMIT = 2; + /** Token-validate every rooted POSIX process group before signaling any. */ export const cleanupOwnedProcessGroup = async ( ownership: OwnedProcessGroup, @@ -360,7 +367,7 @@ export const cleanupOwnedProcessGroup = async ( return mergeUnverifiedObservations( { cleaned: false, - reason: `owned process group ${String(processGroupId)} reappeared after revalidation`, + reason: `owned process group ${String(processGroupId)} reappeared after ${String(OWNED_GROUP_REVALIDATION_LIMIT)} revalidation passes. This release has no runtime override.`, }, plan.unverified, ); @@ -380,7 +387,7 @@ export const cleanupOwnedProcessGroup = async ( return mergeUnverifiedObservations( { cleaned: false, - reason: `owned process group ${String(groupId)} reappeared after revalidation`, + reason: `owned process group ${String(groupId)} reappeared after ${String(OWNED_GROUP_REVALIDATION_LIMIT)} revalidation passes. This release has no runtime override.`, }, plan.unverified, ); @@ -631,13 +638,6 @@ const isExitRefreshCandidate = ( ); }; -/** - * Times one group may be revalidated in a single cleanup. The second pass - * covers a group that reappears after it was already processed. Another - * appearance stays unresolved instead of reporting success. - */ -const OWNED_GROUP_REVALIDATION_LIMIT = 2; - /** * Token-check rows that exist only in the post-exit table. A prior PID, group, * or ancestry link is handled by the caller; these rows have none of those. @@ -716,8 +716,7 @@ const classifyUnlinkedExitRows = async ( if (confirmations > EXIT_REFRESH_CONFIRMATION_LIMIT) return { kind: "uncertain", - reason: - "process ownership could not be revalidated: exit refresh confirmation limit exceeded", + reason: `process ownership could not be revalidated: the exit refresh confirmation limit of ${String(EXIT_REFRESH_CONFIRMATION_LIMIT)} reads was exceeded. This release has no runtime override.`, }; // The confirmation read is its own table. A PID classified on the previous // table, including a readable foreign token, does not identify the process diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index e7fa1ce7e9..7466c5fe84 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -592,7 +592,7 @@ describe("identityless exit refresh keeps later confirmation rows", () => { ).resolves.toMatchObject({ cleaned: false, reason: - "process ownership could not be revalidated: exit refresh confirmation limit exceeded", + "process ownership could not be revalidated: the exit refresh confirmation limit of 8 reads was exceeded. This release has no runtime override.", }); expect(signalGroup).not.toHaveBeenCalled(); }); @@ -875,7 +875,8 @@ describe("identityless cleanup revalidates a group that reappears", () => { ), ).resolves.toMatchObject({ cleaned: false, - reason: "owned process group 101 reappeared after revalidation", + reason: + "owned process group 101 reappeared after 2 revalidation passes. This release has no runtime override.", }); expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]); }); diff --git a/src/process/RegexMatcher.ts b/src/process/RegexMatcher.ts index 215c4bdfbd..67873134e4 100644 --- a/src/process/RegexMatcher.ts +++ b/src/process/RegexMatcher.ts @@ -14,7 +14,37 @@ import { err, ok, type Result } from "../domain/result.js"; const MATCHING_DEADLINE_MS = 5_000; const WORKER_HEAP_MIB = 128; /** Shared capability text for the supervised ECMAScript matching boundary. */ -export const REGEX_MATCHING_LIMITATION = `Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a ${String(MATCHING_DEADLINE_MS / 1000)}-second matching deadline; matching failure leaves the service available.`; +export const REGEX_MATCHING_LIMITATION = `Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a ${String(MATCHING_DEADLINE_MS / 1000)}-second matching deadline and a ${String(WORKER_HEAP_MIB)} MiB worker heap. This release has no runtime override for either cap; matching failure leaves the service available.`; + +/** Fixed regex deadline. The worker heap and this deadline are separate caps. */ +export const regexMatchingDeadlineError = ( + operation: string, +): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + operation, + "cpu", + `Regex matching exceeded its supervised deadline of ${String(MATCHING_DEADLINE_MS)} ms. This release has no runtime override. The service remains available.`, + { matching_deadline_ms: MATCHING_DEADLINE_MS }, + { + remediationAction: `The regex matching deadline is ${String(MATCHING_DEADLINE_MS)} ms and this release has no runtime override. Simplify the ECMAScript Unicode regex or use literal mode, then retry in the same session.`, + }, + ); + +/** Fixed regex worker heap. Inherited limits and host memory do not raise it. */ +export const regexWorkerHeapError = ( + operation: string, + cause: Error, +): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + operation, + "memory", + `Regex worker exhausted its ${String(WORKER_HEAP_MIB)} MiB heap budget. This release has no runtime override.`, + { worker_heap_mib: WORKER_HEAP_MIB }, + { + cause, + remediationAction: `The regex worker heap is ${String(WORKER_HEAP_MIB)} MiB and this release has no runtime override. Inherited limits and additional host memory do not raise it. Simplify the pattern or use literal mode.`, + }, + ); const replySchema = z.discriminatedUnion("status", [ z.strictObject({ status: z.literal("matched"), @@ -84,21 +114,7 @@ export const matchRegexValues = async (input: { const onAbort = () => finish(err(new AnalysisCancelledError(input.operation))); const timer = setTimeout( - () => - finish( - err( - new AnalysisResourceConstraintError( - input.operation, - "cpu", - "Regex matching exceeded its supervised deadline; the service remains available.", - { matching_deadline_ms: MATCHING_DEADLINE_MS }, - { - remediationAction: - "Simplify the ECMAScript Unicode regex or use literal mode, then retry in the same session.", - }, - ), - ), - ), + () => finish(err(regexMatchingDeadlineError(input.operation))), MATCHING_DEADLINE_MS, ); input.signal?.addEventListener("abort", onAbort, { once: true }); @@ -144,13 +160,7 @@ export const matchRegexValues = async (input: { finish( err( cause.code === "ERR_WORKER_OUT_OF_MEMORY" - ? new AnalysisResourceConstraintError( - input.operation, - "memory", - "Regex worker exhausted its heap budget", - { worker_heap_mib: WORKER_HEAP_MIB }, - { cause }, - ) + ? regexWorkerHeapError(input.operation, cause) : new AnalysisOutputError( input.operation, `Regex worker failed: ${cause.message}`, diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index 3e67e85f0d..55f29531e7 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -233,7 +233,7 @@ export const stopOwnedDebuggerProcess = async ( if (detached === undefined) return { status: "incomplete", - reason: `Owned ${debuggerName} inferior detach was not verified within ${DETACH_TIMEOUT_MS} ms.`, + reason: `Owned ${debuggerName} inferior detach was not verified within ${String(DETACH_TIMEOUT_MS)} ms. This release has no runtime override.`, }; if (!isVerifiedDetachResult(detached)) { detachOutcomes.set(session, "unconfirmed"); diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index f7fb125ff0..d7343dae80 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -47,7 +47,10 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { createEvidence } from "../domain/evidence.js"; -import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; +import { + fixedAllowanceOptions, + fixedProducerInputAllowance, +} from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { windowAnnotationText } from "../domain/signals/annotationTextWindow.js"; import { @@ -370,7 +373,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { "Annotations are the selected decoder's interpretation of the recorded samples and caller-selected settings, not independently verified protocol semantics. Empty output does not establish absence of traffic.", "Sample-number ranges and display text are preserved verbatim, not converted to timestamps or parsed as a lossless byte stream. All producer stderr is retained, including warnings on exit zero.", "Only a private copy of the selected file is decoded. Hardware discovery/acquisition and target execution are not requested. Caller-supplied tools, plugins and decoder modules are trusted; this is not an OS sandbox.", - "Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.", + "Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted. REA retains the tighter inherited limit. This release has no runtime override.", ], }), ); @@ -545,7 +548,10 @@ export class SigrokRecordingProvider implements SignalRecordingPort { { input_bytes: LIMITS.input_bytes }, { cause, - ...fixedAllowanceOptions("the fixed sigrok input allowance", "input"), + ...fixedProducerInputAllowance( + LIMITS.input_bytes, + "the fixed sigrok input allowance", + ), }, ); if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index f70875b586..b4540b1797 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -478,6 +478,9 @@ it("preserves termination signal and effective file ceiling as a resource failur reportedLimits: { file_bytes: 1048576 }, capturedOutput: { signal: "SIGXFSZ", stderr: "before file limit\n" }, }); + expect(analysisErrorRemediationAction(result.error)).toContain( + "REA retains the tighter inherited limit", + ); }); it("rejects oversized selector lists and UTF-8 argv expansion before provider work", async () => { diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index dd7ae558ad..6e5e264bb3 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -27,6 +27,7 @@ import { createDarwinProcessRunTokenReader, DarwinProcessOwnershipInspectionError, SWIFTC_INTERRUPT, + swiftcCompileTimeoutRecovery, } from "../../../src/process/DarwinProcessRunTokenReader.js"; import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; import { @@ -219,7 +220,9 @@ it.skipIf(process.platform === "win32")( expect(failure.message).toContain( "(compiler result: exit code 1): module cache is not writable", ); - expect(failure.message).toContain("[diagnostics truncated]"); + expect(failure.message).toContain( + "[diagnostics truncated at 512 bytes; this release has no runtime override]", + ); const normalized = normalizeCaptureFailure(failure, undefined); if (!(normalized instanceof ProcessCaptureError)) throw new Error("Expected a normalized process capture error"); @@ -260,14 +263,18 @@ it.skipIf(process.platform === "win32")( compileTimeoutMs: 3_000, }); try { - await expect(preparationFailure(reader)).resolves.toMatchObject({ - message: - "macOS process ownership helper compilation failed via xcrun (compiler result: timed out after 3000 ms): error: PCH was compiled with module cache path that differs", - compilerOutcome: { - outcome: "timed_out", - code: "ETIMEDOUT", - timeoutMs: 3_000, - }, + const failure = await preparationFailure(reader); + expect(failure.message).toContain( + "macOS process ownership helper compilation failed via xcrun (compiler result: timed out after 3000 ms.", + ); + expect(failure.message).toContain(swiftcCompileTimeoutRecovery(3_000)); + expect(failure.message).toContain( + "error: PCH was compiled with module cache path that differs", + ); + expect(failure.compilerOutcome).toMatchObject({ + outcome: "timed_out", + code: "ETIMEDOUT", + timeoutMs: 3_000, }); } finally { await reader.close(); @@ -329,7 +336,7 @@ it.skipIf(process.platform === "win32")( expect(outcome.stderrBytes).toBe(601); expect(outcome.stderrTruncated).toBe(false); expect(failure.message).toContain( - `: ${"x".repeat(512)}… [diagnostics truncated]`, + `: ${"x".repeat(512)}… [diagnostics truncated at 512 bytes; this release has no runtime override]`, ); const excerpt = failure.message.split("): ")[1]?.split("…")[0]; expect(excerpt).toBe("x".repeat(512)); diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts index c7801ba8af..a03bea48fc 100644 --- a/tests/boundary/process/ownedDebuggerSession.test.ts +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -723,7 +723,8 @@ it("retains an owned debugger when inferior detach does not settle", async () => await vi.advanceTimersByTimeAsync(1_000); expect(await attempt).toEqual({ status: "incomplete", - reason: "Owned GDB inferior detach was not verified within 1000 ms.", + reason: + "Owned GDB inferior detach was not verified within 1000 ms. This release has no runtime override.", }); const retry = stopOwnedDebuggerProcess(session, "GDB", { platform: "linux", @@ -731,7 +732,8 @@ it("retains an owned debugger when inferior detach does not settle", async () => await vi.advanceTimersByTimeAsync(1_000); expect(await retry).toEqual({ status: "incomplete", - reason: "Owned GDB inferior detach was not verified within 1000 ms.", + reason: + "Owned GDB inferior detach was not verified within 1000 ms. This release has no runtime override.", }); expect(detachCalls).toBe(1); expect(signals).toEqual([]); diff --git a/tests/boundary/process/regexMatcher.test.ts b/tests/boundary/process/regexMatcher.test.ts index 0666faf154..47319e33ee 100644 --- a/tests/boundary/process/regexMatcher.test.ts +++ b/tests/boundary/process/regexMatcher.test.ts @@ -2,7 +2,43 @@ import { expect, it } from "vitest"; import { ok } from "../../../src/domain/result.js"; import { HopperRegexSearch } from "../../../src/hopper/HopperRegexSearch.js"; -import { matchRegexValues } from "../../../src/process/RegexMatcher.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../../../src/contracts/officialToolContracts.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + matchRegexValues, + REGEX_MATCHING_LIMITATION, + regexMatchingDeadlineError, + regexWorkerHeapError, +} from "../../../src/process/RegexMatcher.js"; + +it("names the fixed regex deadline and heap and says neither has an override", () => { + expect(REGEX_MATCHING_LIMITATION).toContain("5-second matching deadline"); + expect(REGEX_MATCHING_LIMITATION).toContain("128 MiB"); + expect(REGEX_MATCHING_LIMITATION).toContain( + "This release has no runtime override", + ); + const deadline = projectAnalysisError( + regexMatchingDeadlineError("search_procedures"), + ); + expect(deadline.message).toContain("5000 ms"); + expect(deadline.remediation.action).toContain( + "this release has no runtime override", + ); + const heap = projectAnalysisError( + regexWorkerHeapError("search_procedures", new Error("heap")), + ); + expect(heap.message).toContain("128 MiB"); + expect(heap.remediation.action).toContain( + "additional host memory do not raise it", + ); + for (const name of ["search_procedures", "search_strings"] as const) { + const description = OFFICIAL_TOOL_CONTRACTS.find( + (tool) => tool.name === name, + )?.description; + expect(description).toContain("5000 ms"); + expect(description).toContain("no runtime override"); + } +}); it("preserves Unicode matching order and refuses a pre-cancelled match", async () => { expect( diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts index 6aaa72f98b..7f4fc5541c 100644 --- a/tests/boundary/signals/recordingProvider.test.ts +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -609,6 +609,9 @@ it("preserves termination signal and effective file ceiling as a resource failur reportedLimits: { file_bytes: 1048576 }, capturedOutput: { signal: "SIGXFSZ", stderr: "before file limit\n" }, }); + expect(analysisErrorRemediationAction(result.error)).toContain( + "REA retains the tighter inherited limit", + ); }); it("discovers decoders without reading a recording and retains installation provenance", async () => {