fix(publication)!: retain cleanup owners across export workflows

Require an ArtifactResourceScope for web script exports and share its lifetime with CLI or MCP shutdown. Retain setup-failed output trees in firmware and Wakaru providers. Preserve completed publications when descriptor cleanup fails, returning the full result after a successful retry or as a partial observation while cleanup remains unresolved.
This commit is contained in:
morluto
2026-10-11 05:16:42 +08:00
parent 3f8c51e9b5
commit be75ed5e4b
15 changed files with 801 additions and 166 deletions
+8 -7
View File
@@ -4,6 +4,7 @@ import { isAbsolute } from "node:path";
import { z } from "zod";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { publishWebScripts } from "../browser/assets/PublishWebScripts.js";
import { selectScriptCapture } from "../browser/assets/ScriptCaptureAdapters.js";
import {
@@ -37,11 +38,12 @@ const OPERATION = "export_web_scripts";
/** Export one local capture through the shared CLI/MCP application workflow. */
export const exportWebScripts = async (
rawInput: unknown,
resources: ArtifactResourceScope,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const input = exportWebScriptsInputSchema.safeParse(rawInput);
return input.success
? exportWebScriptsValidated(input.data, options)
? exportWebScriptsValidated(input.data, resources, options)
: err(
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput),
);
@@ -50,6 +52,7 @@ export const exportWebScripts = async (
/** Publish input already parsed by a named adapter contract. */
export const exportWebScriptsValidated = async (
input: ExportWebScriptsInput,
resources: ArtifactResourceScope,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
if (!isAbsolute(input.capture_path) || !isAbsolute(input.output_directory))
@@ -72,12 +75,10 @@ export const exportWebScriptsValidated = async (
if (!loaded.ok) return loaded;
options.signal?.throwIfAborted();
const sha256 = createHash("sha256").update(bytes).digest("hex");
const result = await publishWebScripts(
input,
loaded.value,
sha256,
options.signal,
);
const result = await publishWebScripts(input, loaded.value, sha256, {
resources,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return ok(
createEvidence(
{ path: input.capture_path, sha256, format: "file" },
+93 -64
View File
@@ -3,8 +3,10 @@ import { join } from "node:path";
import { Readable } from "node:stream";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import {
webScriptExportManifestSchema,
webScriptExportResultSchema,
@@ -27,78 +29,105 @@ const LIMITATIONS = [
"Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
];
interface PublishWebScriptsOptions {
readonly resources: ArtifactResourceScope;
readonly signal?: AbortSignal;
}
/** Exclusively publish verified bytes and a manifest, rolling back partial work. */
export const publishWebScripts = async (
input: ExportWebScriptsInput,
capture: SelectedScriptCapture & { readonly sourceEvidenceId: string | null },
captureSha256: string,
signal?: AbortSignal,
): Promise<WebScriptExportResult> => {
const planned = planWebScriptExport(capture.scripts);
const tree = await SafeOutputTree.create(input.output_directory);
try {
signal?.throwIfAborted();
const records: ExportedWebScript[] = [];
for (const [index, item] of planned.entries()) {
signal?.throwIfAborted();
records.push(await publishPlannedScript(tree, item, index, signal));
}
const manifest = webScriptExportManifestSchema.parse({
capture_path: input.capture_path,
capture_sha256: captureSha256,
capture_kind: capture.kind,
source_evidence_id: capture.sourceEvidenceId,
capture_completeness: capture.completeness,
output_directory: tree.outputRoot,
analysis_input: records.some(
({ content }) => content.state === "exported",
)
? { input_path: join(tree.outputRoot, "files"), format: "directory" }
: null,
scripts: records,
limitations: [
...capture.limitations,
...LIMITATIONS,
...(records.some(({ content }) => content.state === "exported")
? []
: [
"No script bytes were exportable. Include script sources in inspect_web_page or select response_body in capture_browser_scenario, then capture again.",
]),
],
});
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const digest = createHash("sha256").update(bytes).digest("hex");
const result = webScriptExportResultSchema.parse({
...manifest,
manifest: {
path: join(tree.outputRoot, "manifest.json"),
sha256: digest,
bytes: bytes.length,
},
});
await tree.write(
"manifest.json",
Readable.from([bytes]),
{ sha256: digest, bytes: bytes.byteLength },
signal,
);
signal?.throwIfAborted();
await tree.commit();
return result;
} catch (cause: unknown) {
options: PublishWebScriptsOptions,
): Promise<WebScriptExportResult> =>
options.resources.run(async () => {
const planned = planWebScriptExport(capture.scripts);
let tree: SafeOutputTree | undefined;
let result: WebScriptExportResult | undefined;
try {
await tree.rollback();
} catch (cleanupCause: unknown) {
throw new WebScriptExportError(
"io",
tree.outputRoot,
`Publication failed (${message(cause)}); rollback failed (${message(cleanupCause)}). Remove the residual output directory before retrying.`,
[tree.outputRoot],
tree = await SafeOutputTree.create(input.output_directory);
options.signal?.throwIfAborted();
const records: ExportedWebScript[] = [];
for (const [index, item] of planned.entries()) {
options.signal?.throwIfAborted();
records.push(
await publishPlannedScript(tree, item, index, options.signal),
);
}
const manifest = webScriptExportManifestSchema.parse({
capture_path: input.capture_path,
capture_sha256: captureSha256,
capture_kind: capture.kind,
source_evidence_id: capture.sourceEvidenceId,
capture_completeness: capture.completeness,
output_directory: tree.outputRoot,
analysis_input: records.some(
({ content }) => content.state === "exported",
)
? { input_path: join(tree.outputRoot, "files"), format: "directory" }
: null,
scripts: records,
limitations: [
...capture.limitations,
...LIMITATIONS,
...(records.some(({ content }) => content.state === "exported")
? []
: [
"No script bytes were exportable. Include script sources in inspect_web_page or select response_body in capture_browser_scenario, then capture again.",
]),
],
});
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const digest = createHash("sha256").update(bytes).digest("hex");
result = webScriptExportResultSchema.parse({
...manifest,
manifest: {
path: join(tree.outputRoot, "manifest.json"),
sha256: digest,
bytes: bytes.length,
},
});
await tree.write(
"manifest.json",
Readable.from([bytes]),
{ sha256: digest, bytes: bytes.byteLength },
options.signal,
);
options.signal?.throwIfAborted();
await tree.commit();
return result;
} catch (cause: unknown) {
if (cause instanceof SafeOutputTreeCreationFailure) tree = cause.tree;
if (tree !== undefined) {
const cleanup = await options.resources.release({
kind: "output-tree",
resource: tree.outputRoot,
tree,
});
if (cleanup.kind === "failed") {
const cleanupCause = cleanup.cause;
const diagnostic = tree.published
? `Publication completed, but descriptor cleanup failed (${message(cleanupCause)}). The published output remains available.`
: `Publication failed (${message(cause)}); rollback failed (${message(cleanupCause)}). Remove the residual output directory before retrying.`;
const error = new WebScriptExportError(
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
tree.outputRoot,
diagnostic,
[tree.outputRoot],
);
if (tree.published && result !== undefined)
error.retainPartialObservation({
kind: "web-script-export",
result,
});
throw error;
}
if (tree.published && result !== undefined) return result;
}
throw cause;
}
throw cause;
}
};
});
const publishPlannedScript = async (
tree: SafeOutputTree,
+37 -4
View File
@@ -1,9 +1,13 @@
import { Cli, z } from "incur";
import { exportWebScripts } from "../application/WebScriptExportService.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Logger } from "pino";
/** Register the local captured-script export CLI workflow. */
@@ -26,10 +30,39 @@ export const registerWebScriptCommands = (
}),
run: ({ args }) =>
logCliCommand(logger, CLI_COMMANDS.exportWebScripts, async () => {
const result = await exportWebScripts({
capture_path: args.capturePath,
output_directory: args.outputDirectory,
});
const resources = new ArtifactResourceScope();
const result = await exportWebScripts(
{
capture_path: args.capturePath,
output_directory: args.outputDirectory,
},
resources,
);
try {
await resources.close();
} catch (cause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cause,
"Web script export resources",
);
const failure = new ProviderCleanupError(
"web-script-export",
cleanup.resources,
{ reason: cleanup.reason },
{ cause, operation: "export_web_scripts" },
);
const error = result.ok
? failure
: analysisErrorWithCleanupFailure(
result.error,
failure,
"export_web_scripts",
);
return {
error: "Script export failed",
...projectAnalysisError(error),
};
}
return result.ok
? result.value
: {
+29 -1
View File
@@ -25,6 +25,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import type { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
import { publishFirmwareExtraction } from "./FirmwarePublication.js";
import {
FIRMWARE_LIMITS,
@@ -48,6 +49,7 @@ import {
type Outcome = Awaited<ReturnType<FirmwareAnalysisPort["execute"]>>;
interface PendingFirmwareCleanup {
supervisor?: ProviderProcessSupervisor;
tree?: SafeOutputTree;
readonly root: PrivateRuntimeRoot;
readonly engine: string;
readonly operation: string;
@@ -183,9 +185,24 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
);
continue;
}
try {
if (pending.tree !== undefined) {
await pending.tree.rollback();
delete pending.tree;
}
} catch (cause: unknown) {
failures.push(
new ProviderCleanupError(
pending.engine,
[path, pending.tree?.outputRoot ?? path],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ operation: pending.operation, cause },
),
);
}
try {
await pending.root.close();
this.#pendingCleanup.delete(path);
if (pending.tree === undefined) this.#pendingCleanup.delete(path);
} catch (cause: unknown) {
// A verified stopped process cannot use this root. Keep ownership for
// later removal without blocking a new operation's disjoint workspace.
@@ -366,6 +383,17 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
selection: target.selection,
engine,
signal,
retainCleanup: (tree) => {
const pending: PendingFirmwareCleanup = this.#pendingCleanup.get(
ownedRoot.path,
) ?? {
root: ownedRoot,
engine: engineName,
operation: request.operation,
};
pending.tree = tree;
this.#pendingCleanup.set(ownedRoot.path, pending);
},
});
raw = { report, execution: processRun, version_execution: versionRun };
}
+24 -2
View File
@@ -2,8 +2,10 @@ import { createReadStream } from "node:fs";
import { join } from "node:path";
import type { z } from "zod";
import { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../artifacts/SafeOutputTreeCreationFailure.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { firmwareResultSchemas } from "../domain/firmware/firmwareAnalysis.js";
import { hashFirmwareFile, type FirmwareEntry } from "./FirmwareFiles.js";
import type { normalizeUnblobReport } from "./FirmwareReports.js";
@@ -16,8 +18,12 @@ export const publishFirmwareExtraction = async (context: {
selection: { offset: number; length: number; sha256: string };
engine: z.infer<typeof firmwareResultSchemas.extract_firmware>["engine"];
signal: AbortSignal;
retainCleanup: (tree: SafeOutputTree) => void;
}) => {
let tree: SafeOutputTree | undefined;
let result:
| z.infer<typeof firmwareResultSchemas.extract_firmware>
| undefined;
try {
const materialized = new Set(
context.entries
@@ -75,7 +81,7 @@ export const publishFirmwareExtraction = async (context: {
original_file_range: null,
});
}
const result = firmwareResultSchemas.extract_firmware.parse({
result = firmwareResultSchemas.extract_firmware.parse({
engine: context.engine,
selection: context.selection,
output_directory: tree.outputRoot,
@@ -93,9 +99,18 @@ export const publishFirmwareExtraction = async (context: {
await tree.commit();
return result;
} catch (cause: unknown) {
if (cause instanceof SafeOutputTreeCreationFailure) tree = cause.tree;
try {
await tree?.rollback();
} catch (cleanupCause: unknown) {
if (tree !== undefined) context.retainCleanup(tree);
const publishedResult =
tree?.published === true && result !== undefined
? {
kind: "firmware" as const,
result: jsonValueSchema.parse(result),
}
: undefined;
throw new ProviderCleanupError(
"unblob",
[context.outputDirectory],
@@ -107,9 +122,16 @@ export const publishFirmwareExtraction = async (context: {
previous_error:
cause instanceof Error ? cause.message : String(cause),
},
{ cause: cleanupCause },
{
cause: cleanupCause,
operation: "extract_firmware",
...(publishedResult === undefined
? {}
: { partialObservation: publishedResult }),
},
);
}
if (tree?.published === true && result !== undefined) return result;
throw cause;
}
};
+60 -18
View File
@@ -7,9 +7,13 @@ import {
type ExecutionOptions,
} from "../../application/AnalysisProvider.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import type { JavaScriptRecoveryInput } from "../../domain/javascript/javascriptRecovery.js";
import {
javascriptRecoveryResultSchema,
type JavaScriptRecoveryInput,
} from "../../domain/javascript/javascriptRecovery.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -94,7 +98,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
if (pending !== undefined) return err(pending);
let root: string | undefined;
let tree: SafeOutputTree | undefined;
let failure: unknown;
let failed: { readonly cause: unknown } | undefined;
let execution: AnalysisExecution | undefined;
const deadline = Date.now() + RECOVERY_LIMITS.timeoutMs;
try {
@@ -110,15 +114,16 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
join(inputs, "bundle.js"),
options?.signal,
);
tree = await SafeOutputTree.create(input.output_directory).catch(
(cause: unknown) => {
throw recoveryInputFailure(
"output_directory",
`Cannot create new recovery directory ${input.output_directory}: ${recoveryFailureMessage(cause)}`,
cause,
);
},
);
try {
tree = await SafeOutputTree.create(input.output_directory);
} catch (cause: unknown) {
if (cause instanceof SafeOutputTreeCreationFailure) tree = cause.tree;
throw recoveryInputFailure(
"output_directory",
`Cannot create new recovery directory ${input.output_directory}: ${recoveryFailureMessage(cause)}`,
cause,
);
}
execution = await prepareWakaruExecution({
root,
tree,
@@ -136,10 +141,12 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
checkRecoveryDeadline(deadline, options?.signal);
await tree.commit();
} catch (cause: unknown) {
failure = cause;
failed = { cause };
}
if (failure !== undefined) {
if (failed !== undefined) {
const failure = failed.cause;
const residuals: string[] = [];
const cleanupFailures: { resource: string; reason: string }[] = [];
const cleanupOwner =
failure instanceof WakaruCleanupFailure
? failure.cleanupOwner
@@ -147,10 +154,14 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
let failedTree: SafeOutputTree | undefined;
try {
await tree?.rollback();
} catch {
} catch (cause: unknown) {
if (tree !== undefined) {
failedTree = tree;
residuals.push(tree.outputRoot);
cleanupFailures.push({
resource: tree.outputRoot,
reason: recoveryFailureMessage(cause),
});
}
}
if (cleanupOwner !== undefined)
@@ -160,8 +171,12 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
try {
await rm(failedRoot, { recursive: true, force: true });
root = undefined;
} catch {
} catch (cause: unknown) {
residuals.push(failedRoot);
cleanupFailures.push({
resource: failedRoot,
reason: recoveryFailureMessage(cause),
});
}
}
if (
@@ -179,10 +194,37 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
}
if (residuals.length > 0)
return err(
new ProviderCleanupError("wakaru", residuals, {
previous_error: recoveryFailureMessage(failure),
}),
new ProviderCleanupError(
"wakaru",
residuals,
{
...(failure instanceof WakaruCleanupFailure
? failure.diagnostics
: {}),
previous_error:
failure instanceof WakaruCleanupFailure
? (failure.diagnostics?.previous_error ?? null)
: recoveryFailureMessage(failure),
cleanup_failures: cleanupFailures,
},
{
operation: OPERATION,
cause: failure,
...(tree?.published && execution !== undefined
? {
partialObservation: {
kind: "javascript-recovery" as const,
result: javascriptRecoveryResultSchema.parse(
execution.result,
),
},
}
: {}),
},
),
);
if (tree?.published === true && execution !== undefined)
return ok(execution);
return err(
failure instanceof AnalysisError
? failure
+7 -7
View File
@@ -211,7 +211,7 @@ export const createServer = (
options.webSourceLocation ?? createWebSourceLocationService(environment);
const javascriptRecovery =
options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment);
const javascriptApplicationResources = new ArtifactResourceScope();
const artifactResources = new ArtifactResourceScope();
const firmwareAnalysis = new FirmwareAnalysisService(
options.firmwareAnalysis ?? createFirmwareAnalysisProvider(environment),
);
@@ -248,7 +248,7 @@ export const createServer = (
evmInterface.close(),
webSourceLocation.close(),
javascriptRecovery.close?.() ?? Promise.resolve(),
javascriptApplicationResources.close(),
artifactResources.close(),
firmwareAnalysis.close(),
options.browserScenarioCapture?.close?.() ?? Promise.resolve(),
])
@@ -289,7 +289,7 @@ export const createServer = (
recordEvidence,
recordEvidenceWithUnknown,
withAdmittedAnalysis: analysisAdmission,
javascriptApplicationResources,
artifactResources,
};
registerBinaryAnalysisTools(toolContext);
const previousOnclose = server.server.onclose;
@@ -501,7 +501,7 @@ const createSessionRecorders = (
});
interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
readonly javascriptApplicationResources: ArtifactResourceScope;
readonly artifactResources: ArtifactResourceScope;
readonly server: EvidenceMcpServer;
readonly session: BinarySessionPort | undefined;
readonly options: CreateServerOptions;
@@ -572,10 +572,10 @@ const registerObservationTools = ({
recordEvidence,
recordEvidenceWithUnknown,
withAdmittedAnalysis,
javascriptApplicationResources,
artifactResources,
}: ServerToolContext): void => {
const common = { logger, recordEvidence, withAdmittedAnalysis };
registerWebScriptTool(server, common);
registerWebScriptTool(server, { ...common, artifactResources });
registerBrowserTools(server, {
...common,
browser: options.browserObservation,
@@ -588,7 +588,7 @@ const registerObservationTools = ({
});
registerElectronTools(server, {
...common,
javascriptApplicationResources,
artifactResources,
evidenceById,
electron: options.electronObservation,
electronActive: options.electronActiveObservation,
+2 -2
View File
@@ -35,7 +35,7 @@ import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
interface ElectronToolRegistration {
readonly javascriptApplicationResources: ArtifactResourceScope;
readonly artifactResources: ArtifactResourceScope;
readonly logger: Logger;
readonly observationLoadFailure?: OptionalProviderLoadFailure | undefined;
readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined;
@@ -106,7 +106,7 @@ export const registerElectronTools = (
async ({ detail, ...request }, { signal, progress }) => {
const analyzed = await analyzeJavaScriptApplicationValidated(
request,
options.javascriptApplicationResources,
options.artifactResources,
{ signal, progress },
);
return detail === "summary" && analyzed.ok
+3 -1
View File
@@ -8,6 +8,7 @@ import { logToolExecution } from "./toolLogging.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
/** Register local script export without requiring a live browser provider. */
export const registerWebScriptTool = (
@@ -15,6 +16,7 @@ export const registerWebScriptTool = (
options: {
readonly logger: Logger;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly artifactResources: ArtifactResourceScope;
readonly withAdmittedAnalysis?: WithAdmittedAnalysis;
},
): void => {
@@ -33,7 +35,7 @@ export const registerWebScriptTool = (
options.logger,
contract.name,
() =>
exportWebScriptsValidated(input, {
exportWebScriptsValidated(input, options.artifactResources, {
signal: context.mcpReq.signal,
}),
);
@@ -9,6 +9,7 @@ import { createJavaScriptRuntimeObservationEvidence } from "../../../src/applica
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js";
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import { inspectElectronPageInputSchema } from "../../../src/domain/javascript/electronObservation.js";
@@ -69,6 +70,8 @@ describe.each(["browser", "electron"] as const)(
"$name through capture and consumers",
async ({ reported, expected }) => {
const root = await fixtureRoot();
const artifactResources = new ArtifactResourceScope();
resources.push(() => artifactResources.close());
const browser = await startFakeCdpBrowser({
...(kind === "electron"
? { electronFileUrl: pathToFileURL(join(root, "index.html")).href }
@@ -129,6 +132,7 @@ describe.each(["browser", "electron"] as const)(
},
selectScriptCapture(result.value),
createHash("sha256").update(captureBytes).digest("hex"),
{ resources: artifactResources },
);
expect(exported.scripts[0]?.source).toMatchObject({
is_module: expected.is_module,
@@ -10,6 +10,7 @@ import {
import { dirname, join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
@@ -19,16 +20,21 @@ import { scriptScenarioFixture } from "../../fixtures/webScriptCapture.js";
const fixture = async (bytes = Buffer.from('import "./dep.js";')) => {
const root = await createTestTempDirectory("rea-module-artifacts-");
const capturePath = join(root, "capture.json");
const resources = new ArtifactResourceScope();
onTestFinished(() => resources.close());
await writeFile(
capturePath,
JSON.stringify(
scriptScenarioFixture([{ url: "https://app.test/main.js", bytes }]),
),
);
const response = await exportWebScripts({
capture_path: capturePath,
output_directory: join(root, "export"),
});
const response = await exportWebScripts(
{
capture_path: capturePath,
output_directory: join(root, "export"),
},
resources,
);
if (!response.ok) throw response.error;
const result = webScriptExportResultSchema.parse(
response.value.normalized_result,
+188 -51
View File
@@ -12,11 +12,14 @@ import {
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it, onTestFinished } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js";
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
@@ -30,12 +33,14 @@ import {
const setup = async (capture: unknown = scriptCaptureEvidenceFixture()) => {
const root = await createTestTempDirectory("rea-web-script-export-");
const resources = new ArtifactResourceScope();
onTestFinished(() => resources.close());
const input = {
capture_path: join(root, "capture.json"),
output_directory: join(root, "export"),
};
await writeFile(input.capture_path, JSON.stringify(capture));
return { root, input };
return { root, input, resources };
};
describe("captured script publication boundary", () => {
@@ -52,8 +57,8 @@ describe("captured script publication boundary", () => {
bytes: Buffer.from("export const marker = 'source-owned';\n"),
},
]);
const { input } = await setup(capture);
const exported = await exportWebScripts(input);
const { input, resources } = await setup(capture);
const exported = await exportWebScripts(input, resources);
if (!exported.ok) throw exported.error;
const result = webScriptExportResultSchema.parse(
exported.value.normalized_result,
@@ -106,20 +111,20 @@ describe("captured script publication boundary", () => {
});
it("links authenticated input Evidence and preserves binary and empty source bytes", async () => {
const { input } = await setup();
const result = await exportWebScripts(input);
const { input, resources } = await setup();
const result = await exportWebScripts(input, resources);
if (!result.ok) throw result.error;
expect(result.value.evidence_links).toEqual([
scriptCaptureEvidenceFixture().evidence_id,
]);
const binary = Buffer.from([255, 0, 254, 1]);
const { input: binaryInput } = await setup(
const { input: binaryInput, resources: binaryResources } = await setup(
scriptScenarioFixture([
{ url: "https://fixture.test/binary.js", bytes: binary },
{ url: "https://fixture.test/empty.js", bytes: Buffer.alloc(0) },
]),
);
const binaryExport = await exportWebScripts(binaryInput);
const binaryExport = await exportWebScripts(binaryInput, binaryResources);
if (!binaryExport.ok) throw binaryExport.error;
const parsed = webScriptExportResultSchema.parse(
binaryExport.value.normalized_result,
@@ -149,8 +154,8 @@ describe("captured script publication boundary", () => {
({ kind }) => kind !== "network-content",
);
capture.events.retained = capture.events.items.length;
const { input } = await setup(capture);
const result = await exportWebScripts(input);
const { input, resources } = await setup(capture);
const result = await exportWebScripts(input, resources);
if (!result.ok) throw result.error;
const parsed = webScriptExportResultSchema.parse(
result.value.normalized_result,
@@ -163,12 +168,14 @@ describe("captured script publication boundary", () => {
describe("captured script publication failures and cleanup", () => {
it("preserves adapter-reported limitations without strengthening their string contract", async () => {
const { input } = await setup();
const { input, resources } = await setup();
const capture = {
...selectScriptCapture(scriptScenarioFixture()),
limitations: ["", "producer-reported limitation"],
};
const result = await publishWebScripts(input, capture, "a".repeat(64));
const result = await publishWebScripts(input, capture, "a".repeat(64), {
resources,
});
expect(result.limitations.slice(0, 2)).toEqual(capture.limitations);
});
@@ -179,9 +186,9 @@ describe("captured script publication failures and cleanup", () => {
])(
"rejects malformed capture bytes before creating output",
async (bytes) => {
const { input } = await setup();
const { input, resources } = await setup();
await writeFile(input.capture_path, bytes);
const result = await exportWebScripts(input);
const result = await exportWebScripts(input, resources);
if (result.ok) throw new Error("Expected invalid input");
expect(result.error._tag).toBe("AnalysisInputError");
await expect(access(input.output_directory)).rejects.toMatchObject({
@@ -191,7 +198,7 @@ describe("captured script publication failures and cleanup", () => {
);
it("preserves an existing destination and refuses output through a symlink", async () => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const marker = join(root, "marker.js");
await writeFile(marker, "keep");
await symlink(
@@ -199,14 +206,14 @@ describe("captured script publication failures and cleanup", () => {
input.output_directory,
process.platform === "win32" ? "junction" : "dir",
);
const result = await exportWebScripts(input);
const result = await exportWebScripts(input, resources);
if (result.ok) throw new Error("Expected exclusive output failure");
expect(result.error.userMessage).toContain("already exists");
expect(await readFile(marker, "utf8")).toBe("keep");
});
it("rolls back only the new owned output when durable byte verification fails", async () => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const capture = selectScriptCapture(
scriptScenarioFixture([
{ url: "https://fixture.test/a.js", bytes: Buffer.from("first") },
@@ -223,16 +230,132 @@ describe("captured script publication failures and cleanup", () => {
].filter((value) => value !== undefined),
};
await expect(
publishWebScripts(input, broken, "a".repeat(64)),
publishWebScripts(input, broken, "a".repeat(64), { resources }),
).rejects.toMatchObject({ reason: "integrity" });
expect(await readdir(root)).toEqual(["capture.json"]);
});
});
describe("captured script output owner retry", () => {
it("retains a setup-failed output tree in the caller resource scope", async () => {
const { input, resources } = await setup();
const capture = selectScriptCapture(scriptScenarioFixture());
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
let cleanupAttempts = 0;
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const rollbackTree = tree.rollback.bind(tree);
vi.spyOn(tree, "rollback").mockImplementation(async () => {
cleanupAttempts += 1;
if (cleanupAttempts === 1)
throw new Error("injected first cleanup failure");
return rollbackTree();
});
throw new SafeOutputTreeCreationFailure(
new Error("injected post-acquisition setup failure"),
tree,
);
});
try {
await expect(
publishWebScripts(input, capture, "a".repeat(64), { resources }),
).rejects.toMatchObject({ cleanupIncomplete: true });
await expect(access(input.output_directory)).resolves.toBeUndefined();
} finally {
create.mockRestore();
}
await resources.close();
expect(cleanupAttempts).toBe(2);
await expect(access(input.output_directory)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("returns completed output when retry closes a descriptor after commit", async () => {
const { input, resources } = await setup();
const capture = selectScriptCapture(scriptScenarioFixture());
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const commit = tree.commit.bind(tree);
vi.spyOn(tree, "commit").mockImplementation(async () => {
await commit();
throw new Error("injected descriptor close failure");
});
return tree;
});
try {
const result = await exportWebScripts(input, resources);
if (!result.ok) throw result.error;
const published = webScriptExportResultSchema.parse(
result.value.normalized_result,
);
expect(published.manifest.path).toBe(
join(input.output_directory, "manifest.json"),
);
await expect(access(published.manifest.path)).resolves.toBeUndefined();
} finally {
create.mockRestore();
}
});
it("retains published output and its result while cleanup remains unresolved", async () => {
const { input, resources } = await setup();
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
let cleanupAttempts = 0;
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const commit = tree.commit.bind(tree);
vi.spyOn(tree, "commit").mockImplementation(async () => {
await commit();
throw new Error("injected descriptor close failure");
});
const rollback = tree.rollback.bind(tree);
vi.spyOn(tree, "rollback").mockImplementation(async () => {
cleanupAttempts += 1;
if (cleanupAttempts === 1)
throw new Error("injected cleanup refusal");
return rollback();
});
return tree;
});
try {
const result = await exportWebScripts(input, resources);
if (result.ok) throw new Error("Expected unresolved cleanup");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.userMessage).not.toContain("Remove the residual");
expect(result.error.partialObservation).toMatchObject({
kind: "web-script-export",
result: {
output_directory: input.output_directory,
manifest: { path: join(input.output_directory, "manifest.json") },
},
});
await expect(
access(join(input.output_directory, "manifest.json")),
).resolves.toBeUndefined();
await resources.close();
expect(cleanupAttempts).toBe(2);
} finally {
create.mockRestore();
}
});
});
describe("captured script publication failures and cleanup", () => {
it("cancels before work and rolls back cancellation after output creation", async () => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const controller = new AbortController();
controller.abort();
const result = await exportWebScripts(input, { signal: controller.signal });
const result = await exportWebScripts(input, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
await expect(
@@ -240,18 +363,21 @@ describe("captured script publication failures and cleanup", () => {
input,
selectScriptCapture(scriptScenarioFixture()),
"a".repeat(64),
controller.signal,
{ resources, signal: controller.signal },
),
).rejects.toBeDefined();
expect(await readdir(root)).toEqual(["capture.json"]);
});
it("identifies a missing capture file and rejected host path syntax", async () => {
const { input } = await setup();
const missing = await exportWebScripts({
...input,
capture_path: `${input.capture_path}.missing`,
});
const { input, resources } = await setup();
const missing = await exportWebScripts(
{
...input,
capture_path: `${input.capture_path}.missing`,
},
resources,
);
if (missing.ok) throw new Error("Expected unavailable input");
expect(projectAnalysisError(missing.error)).toMatchObject({
code: "invalid_request",
@@ -265,10 +391,13 @@ describe("captured script publication failures and cleanup", () => {
],
},
});
const relative = await exportWebScripts({
...input,
capture_path: "capture.json",
});
const relative = await exportWebScripts(
{
...input,
capture_path: "capture.json",
},
resources,
);
if (relative.ok) throw new Error("Expected host path error");
expect(relative.error._tag).toBe("AnalysisInputError");
});
@@ -276,11 +405,14 @@ describe("captured script publication failures and cleanup", () => {
describe("captured script selection failures", () => {
it("accepts a capture symlink to a regular file and preserves its selected path", async () => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const selected = join(root, "selected-capture.json");
await symlink(input.capture_path, selected, "file");
const result = await exportWebScripts({ ...input, capture_path: selected });
const result = await exportWebScripts(
{ ...input, capture_path: selected },
resources,
);
if (!result.ok) throw result.error;
expect(result.value.subject?.local_path).toBe(selected);
expect(
@@ -301,7 +433,7 @@ describe("captured script selection failures", () => {
.each(["a named pipe", "a symlink to a named pipe"])(
"rejects %s without waiting for a writer or creating output",
async (kind) => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const fifoPath = join(root, "capture.pipe");
await promisify(execFile)("mkfifo", [fifoPath]);
const selected =
@@ -311,7 +443,7 @@ describe("captured script selection failures", () => {
if (selected !== fifoPath) await symlink(fifoPath, selected, "file");
const outcome = await readWithoutFifoWriter(fifoPath, () =>
exportWebScripts({ ...input, capture_path: selected }),
exportWebScripts({ ...input, capture_path: selected }, resources),
);
expect(outcome.state).toBe("completed");
if (outcome.state !== "completed")
@@ -342,11 +474,14 @@ describe("captured script selection failures", () => {
it.skipIf(process.platform === "win32")(
"rejects a character device as a capture selection before parsing bytes",
async () => {
const { input } = await setup();
const result = await exportWebScripts({
...input,
capture_path: "/dev/null",
});
const { input, resources } = await setup();
const result = await exportWebScripts(
{
...input,
capture_path: "/dev/null",
},
resources,
);
if (result.ok) throw new Error("Expected invalid capture selection");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
@@ -372,17 +507,16 @@ describe("captured script selection failures", () => {
it.skipIf(process.platform === "win32")(
"honors a pre-aborted capture export before opening a named pipe",
async () => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const fifoPath = join(root, "capture.pipe");
await promisify(execFile)("mkfifo", [fifoPath]);
const controller = new AbortController();
controller.abort();
const outcome = await readWithoutFifoWriter(fifoPath, () =>
exportWebScripts(
{ ...input, capture_path: fifoPath },
{ signal: controller.signal },
),
exportWebScripts({ ...input, capture_path: fifoPath }, resources, {
signal: controller.signal,
}),
);
expect(outcome.state).toBe("completed");
if (outcome.state !== "completed")
@@ -398,14 +532,17 @@ describe("captured script selection failures", () => {
describe("captured script path and permission failures", () => {
it("reports a directory selected as the capture as invalid input", async () => {
const { root, input } = await setup();
const { root, input, resources } = await setup();
const directory = join(root, "captures");
await mkdir(directory);
const result = await exportWebScripts({
...input,
capture_path: directory,
});
const result = await exportWebScripts(
{
...input,
capture_path: directory,
},
resources,
);
if (result.ok) throw new Error("Expected invalid input");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
@@ -427,11 +564,11 @@ describe("captured script path and permission failures", () => {
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an unreadable capture as a host access denial",
async () => {
const { input } = await setup();
const { input, resources } = await setup();
await chmod(input.capture_path, 0o000);
onTestFinished(() => chmod(input.capture_path, 0o600));
const result = await exportWebScripts(input);
const result = await exportWebScripts(input, resources);
if (result.ok) throw new Error("Expected access denial");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "access_denied",
@@ -1,6 +1,6 @@
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { expect, it as test } from "vitest";
import { expect, it as test, vi } from "vitest";
import {
firmwareFixture,
assertFirmwareCleanup,
@@ -9,8 +9,45 @@ import { firmwareResultSchemas } from "../../../src/domain/firmware/firmwareAnal
import { FirmwareAnalysisService } from "../../../src/application/firmware/FirmwareAnalysisService.js";
import { FirmwareProvider } from "../../../src/firmware/FirmwareProvider.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js";
const directoryCloseFailure = vi.hoisted(() => ({
mode: "off" as "off" | "transient" | "persistent",
attempts: 0,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
return {
...filesystem,
open: async (...args: Parameters<typeof filesystem.open>) => {
const handle = await filesystem.open(...args);
if (
directoryCloseFailure.mode !== "off" &&
(await handle.stat()).isDirectory()
) {
const close = handle.close.bind(handle);
let attempted = false;
handle.close = async () => {
directoryCloseFailure.attempts += 1;
if (
directoryCloseFailure.mode === "persistent" ||
(directoryCloseFailure.mode === "transient" && !attempted)
) {
attempted = true;
throw new Error("injected published directory close failure");
}
await close();
};
}
return handle;
},
};
});
const delivery = new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE);
@@ -111,6 +148,146 @@ it("extracts a selected interval with verified file identity and parent-byte lin
await assertFirmwareCleanup(fixture.launches);
});
it("retains a setup-failed publication tree for provider cleanup retry", async () => {
const fixture = await firmwareFixture();
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
let cleanupAttempts = 0;
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const rollbackTree = tree.rollback.bind(tree);
vi.spyOn(tree, "rollback").mockImplementation(async () => {
cleanupAttempts += 1;
if (cleanupAttempts === 1)
throw new Error("injected first cleanup failure");
return rollbackTree();
});
throw new SafeOutputTreeCreationFailure(
new Error("injected post-acquisition setup failure"),
tree,
);
});
try {
const result = await fixture.service.execute("extract_firmware", {
path: fixture.path,
output_directory: fixture.output,
range: { offset: 2, length: 4 },
});
expect(result).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
diagnostics: {
previous_error: expect.stringContaining(
"injected post-acquisition setup failure",
),
},
},
});
await expect(access(fixture.output)).resolves.toBeUndefined();
} finally {
create.mockRestore();
}
await fixture.close();
expect(cleanupAttempts).toBe(2);
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("returns firmware output after a transient published-directory close failure", async () => {
const fixture = await firmwareFixture();
const closeFailure = injectPublishedDirectoryCloseFailure(false);
try {
const result = await fixture.service.execute("extract_firmware", {
path: fixture.path,
output_directory: fixture.output,
range: { offset: 2, length: 4 },
});
if (!result.ok) throw result.error;
const normalized = firmwareResultSchemas.extract_firmware.parse(
result.value.normalized_result,
);
expect(normalized.output_directory).toBe(fixture.output);
expect(await readFile(normalized.files[0]?.path ?? "", "utf8")).toBe(
"firmware=true\n",
);
expect(closeFailure.attempts()).toBeGreaterThanOrEqual(4);
} finally {
closeFailure.restore();
}
});
it("retains the complete firmware result when published output cleanup stays uncertain", async () => {
const fixture = await firmwareFixture();
const closeFailure = injectPublishedDirectoryCloseFailure(true);
try {
const result = await fixture.service.execute("extract_firmware", {
path: fixture.path,
output_directory: fixture.output,
range: { offset: 2, length: 4 },
});
expect(result).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
partialObservation: {
kind: "firmware",
result: {
output_directory: fixture.output,
files: [
expect.objectContaining({
relative_path: "rootfs/config",
size: 14,
}),
],
},
},
},
});
if (result.ok) throw new Error("Expected unresolved descriptor cleanup");
expect(result.error).toBeInstanceOf(ProviderCleanupError);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
partial_observation: {
kind: "firmware",
result: { output_directory: fixture.output },
},
},
});
expect(await readFile(`${fixture.output}/rootfs/config`, "utf8")).toBe(
"firmware=true\n",
);
expect(closeFailure.attempts()).toBeGreaterThanOrEqual(4);
} finally {
closeFailure.restore();
}
await fixture.close();
expect(await readFile(`${fixture.output}/rootfs/config`, "utf8")).toBe(
"firmware=true\n",
);
});
const injectPublishedDirectoryCloseFailure = (persistent: boolean) => {
directoryCloseFailure.attempts = 0;
const commit = SafeOutputTree.prototype.commit;
const spy = vi
.spyOn(SafeOutputTree.prototype, "commit")
.mockImplementation(async function (this: SafeOutputTree) {
directoryCloseFailure.mode = persistent ? "persistent" : "transient";
await commit.call(this);
});
return {
attempts: () => directoryCloseFailure.attempts,
restore: () => {
directoryCloseFailure.mode = "off";
spy.mockRestore();
},
};
};
it.each(["depth", "dependency", "link"])(
"reports partial extraction for %s without losing inline results",
async (mode) => {
@@ -1,6 +1,8 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js";
import { javascriptRecoveryResultSchema } from "../../../src/domain/javascript/javascriptRecovery.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
@@ -73,9 +75,10 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
});
if (result.ok) throw new Error("expected boundary failure");
const error = projectAnalysisError(result.error);
expect(["execution_failure", "unreadable_output"]).toContain(
error.code,
);
expect(
["execution_failure", "unreadable_output"],
JSON.stringify(error),
).toContain(error.code);
expect(error.details).toBeDefined();
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
@@ -220,6 +223,9 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
expect(projectAnalysisError(result.error).code).toBe(
"cleanup_incomplete",
);
expect(result.error).toMatchObject({
diagnostics: { reason: "injected cleanup refusal" },
});
const launch = fixture.launches[0];
if (launch?.cwd === undefined)
throw new Error("missing process ownership");
@@ -246,3 +252,147 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
});
},
);
describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
"Wakaru publication ownership",
() => {
it("retains a setup-failed publication tree for provider cleanup retry", async () => {
const fixture = await recoveryFixture();
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
let cleanupAttempts = 0;
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const rollbackTree = tree.rollback.bind(tree);
vi.spyOn(tree, "rollback").mockImplementation(async () => {
cleanupAttempts += 1;
if (cleanupAttempts === 1)
throw new Error("injected first cleanup failure");
return rollbackTree();
});
throw new SafeOutputTreeCreationFailure(
new Error("injected post-acquisition setup failure"),
tree,
);
});
try {
const result = await fixture.service.recover({
path: fixture.path,
output_directory: fixture.output,
});
if (result.ok) throw new Error("expected setup failure");
expect(projectAnalysisError(result.error).code).toBe(
"cleanup_incomplete",
);
expect(result.error).toMatchObject({
diagnostics: {
cleanup_failures: [
{
resource: fixture.output,
reason: "injected first cleanup failure",
},
],
},
});
await expect(access(fixture.output)).resolves.toBeUndefined();
} finally {
create.mockRestore();
}
if (fixture.provider.close === undefined)
throw new Error("Expected Wakaru cleanup capability");
await fixture.provider.close();
expect(cleanupAttempts).toBe(2);
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("returns completed recovery when retry closes a descriptor after commit", async () => {
const fixture = await recoveryFixture();
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const commit = tree.commit.bind(tree);
vi.spyOn(tree, "commit").mockImplementation(async () => {
await commit();
throw new Error("injected descriptor close failure");
});
return tree;
});
try {
const result = await fixture.service.recover({
path: fixture.path,
output_directory: fixture.output,
});
if (!result.ok) throw result.error;
const published = javascriptRecoveryResultSchema.parse(
result.value.normalized_result,
);
await expect(access(published.manifest.path)).resolves.toBeUndefined();
expect(published.source.published_copy.path).toContain(fixture.output);
} finally {
create.mockRestore();
}
await assertRecoveryCleanup(fixture.launches);
});
it("retains published recovery and its result while cleanup remains unresolved", async () => {
const fixture = await recoveryFixture();
const createTree = SafeOutputTree.create.bind(SafeOutputTree);
let cleanupAttempts = 0;
const create = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (outputRoot, platform) => {
const tree = await createTree(outputRoot, platform);
const commit = tree.commit.bind(tree);
vi.spyOn(tree, "commit").mockImplementation(async () => {
await commit();
throw new Error("injected descriptor close failure");
});
const rollback = tree.rollback.bind(tree);
vi.spyOn(tree, "rollback").mockImplementation(async () => {
cleanupAttempts += 1;
if (cleanupAttempts === 1)
throw new Error("injected cleanup refusal");
return rollback();
});
return tree;
});
try {
const result = await fixture.service.recover({
path: fixture.path,
output_directory: fixture.output,
});
if (result.ok) throw new Error("Expected unresolved cleanup");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.partialObservation).toMatchObject({
kind: "javascript-recovery",
result: {
source: {
published_copy: {
path: expect.stringContaining(fixture.output),
},
},
manifest: { path: expect.stringContaining(fixture.output) },
},
});
await expect(
access(join(fixture.output, "manifest.json")),
).resolves.toBeUndefined();
} finally {
create.mockRestore();
}
if (fixture.provider.close === undefined)
throw new Error("Expected Wakaru cleanup capability");
await fixture.provider.close();
expect(cleanupAttempts).toBe(2);
await expect(
access(join(fixture.output, "manifest.json")),
).resolves.toBeUndefined();
await assertRecoveryCleanup(fixture.launches);
});
},
);
@@ -7,6 +7,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { expect, it, onTestFinished } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { silentLogger } from "../../../src/logger.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { registerWebScriptTool } from "../../../src/server/registerWebScriptTool.js";
@@ -29,14 +30,17 @@ it.skipIf(process.platform === "win32")(
undefined,
new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE),
);
const artifactResources = new ArtifactResourceScope();
registerWebScriptTool(server, {
logger: silentLogger,
recordEvidence: undefined,
artifactResources: artifactResources,
});
const client = new Client({ name: "script-selection-test", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await artifactResources.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();