Compare commits

...
Author SHA1 Message Date
Cursor Agentandmorluto a6380ee11f fix(hopper): pass the host distribution into install-host tests
#3422 requires systemLinuxHopperInstallHost to take the caller's
distribution reader. These launcher and package tests still called the
old one-argument form, so typecheck failed on main.

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 18:51:11 +00:00
Cursor Agentandmorluto 3140f8c002 fix(reconstruction): dedupe residual unknowns with uniqueSorted
The squash of #3826 left residual_unknown_ids calling uniqueBy after the
local helper was removed. Identity-keyed strings already use uniqueSorted
for the sibling fields in the same merge.

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 18:44:58 +00:00
mpratsakiandClaude Opus 5.5 011082201d fix(il2cpp): declare that Ghidra seed output never overwrites
main added Il2cppGhidraSeedWriter after this branch started. Its
--ghidra-seeds destination is documented as never replaced and has no
overwrite option, so a refusal from the writer's exclusive publication
must not suggest allowing overwrite. Pass the "never" policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:43:15 +00:00
mpratsakiandClaude Opus 5.5 d4b12d8b93 fix(evidence): stop offering overwrite for outputs that never overwrite
CLI summary delivery (--evidence_output) never replaces an existing
bundle, but its refusal shared the generic "Choose another path or allow
overwrite" message and remediation. inspect-artifact and the other
--evidence_output commands then rejected the suggested --overwrite flag.

The writers took a boolean, so a caller that declined replacement and an
operation that cannot replace looked the same. They now take an
OverwritePolicy ("replace" | "refuse" | "never"), and an existing-file
refusal records the policy that produced it. CLI delivery declares
"never"; evidence-export, export_evidence_bundle and snapshot saves map
their overwrite input to "replace" or "refuse" and keep their guidance.
The projected details are unchanged.

Fixes #5408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:43:15 +00:00
15 changed files with 140 additions and 44 deletions

No files matched your search

+9 -3
View File
@@ -4,6 +4,7 @@ import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
import {
EvidenceFileError,
type OverwritePolicy,
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
@@ -63,7 +64,11 @@ export const exportEvidenceBundleCommand = async (
if (!loaded.ok) return loaded;
return projectWrite(
loaded.value,
await persistEvidenceBundleCommand(loaded.value, outputPath, overwrite),
await persistEvidenceBundleCommand(
loaded.value,
outputPath,
overwrite ? "replace" : "refuse",
),
);
};
@@ -130,10 +135,11 @@ export const publishEvidenceDelivery = async (input: {
readonly signal?: AbortSignal | undefined;
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
const { signal, operation } = input;
// Delivery outputs have no overwrite option; refusals must not offer one.
const written = await persistEvidenceBundleCommand(
input.bundle,
input.outputPath,
false,
"never",
signal,
operation,
);
@@ -204,7 +210,7 @@ export const inspectAnalysisViewFromBundle = async (
const persistEvidenceBundleCommand = async (
bundle: EvidenceBundle,
outputPath: string,
overwrite: boolean,
overwrite: OverwritePolicy,
signal?: AbortSignal,
operation?: string,
): Promise<Result<PublishedEvidenceFile, AnalysisError>> => {
+2 -1
View File
@@ -11,6 +11,7 @@ import {
import {
EvidenceFileError,
EvidenceIntegrityError,
type OverwritePolicy,
} from "../domain/evidenceErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -121,7 +122,7 @@ const readBundle = async (
export const writeEvidenceBundle = async (
bundle: EvidenceBundle,
path: string,
overwrite: boolean,
overwrite: OverwritePolicy,
resources: ArtifactResourceScope,
signal?: AbortSignal,
operation = "export_evidence_bundle",
+14 -9
View File
@@ -11,6 +11,7 @@ import { basename, dirname, resolve } from "node:path";
import {
EvidenceFileError,
type OverwritePolicy,
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import {
@@ -208,7 +209,7 @@ const isJsonReadCancellation = (
export const writeTextFile = async (
encoded: string,
path: string,
overwrite: boolean,
overwrite: OverwritePolicy,
resources: ArtifactResourceScope,
): Promise<TextWriteResult<EvidenceFileError>> =>
writeTextParts([encoded], path, overwrite, resources);
@@ -217,20 +218,20 @@ export const writeTextFile = async (
export function writeTextParts(
parts: Iterable<string>,
path: string,
overwrite: boolean,
overwrite: OverwritePolicy,
resources: ArtifactResourceScope,
): Promise<TextWriteResult<EvidenceFileError>>;
export function writeTextParts(
parts: Iterable<string>,
path: string,
overwrite: boolean,
overwrite: OverwritePolicy,
resources: ArtifactResourceScope,
cancellation: TextWriteCancellation | undefined,
): Promise<TextWriteResult>;
export function writeTextParts(
parts: Iterable<string>,
path: string,
overwrite: boolean,
overwrite: OverwritePolicy,
resources: ArtifactResourceScope,
cancellation?: TextWriteCancellation,
): Promise<TextWriteResult> {
@@ -257,7 +258,7 @@ export function writeTextParts(
const writeTextPartsOwned = async (
parts: Iterable<string>,
path: string,
overwrite: boolean,
overwrite: OverwritePolicy,
resources: ArtifactResourceScope,
cancellation?: TextWriteCancellation,
): Promise<TextWriteResult> => {
@@ -275,9 +276,12 @@ const writeTextPartsOwned = async (
throw cause;
});
if (existing !== undefined) {
if (!overwrite)
if (overwrite !== "replace")
return err(
new EvidenceFileError("write", "exists", { path: requestedPath }),
new EvidenceFileError("write", "exists", {
path: requestedPath,
overwrite,
}),
);
if (!existing.isFile() || existing.isSymbolicLink())
return err(
@@ -288,7 +292,7 @@ const writeTextPartsOwned = async (
const bytes = await publishFile(
destination,
parts,
overwrite,
overwrite === "replace",
resources,
cancellation,
);
@@ -333,11 +337,12 @@ const writeTextPartsOwned = async (
cause.operation === cancellation.operation
)
return err(cause);
if (!overwrite && fileErrorCode(cause) === "EEXIST")
if (overwrite !== "replace" && fileErrorCode(cause) === "EEXIST")
return err(
new EvidenceFileError("write", "exists", {
cause,
path: requestedPath,
overwrite,
}),
);
return err(
+9 -5
View File
@@ -9,6 +9,7 @@ import {
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
readLinuxDistribution,
supportsReaHost,
LinuxHopperTemporaryCleanupOwner,
systemLinuxHopperInstallHost,
@@ -379,11 +380,14 @@ describe("Linux Hopper cleanup and failure details", () => {
const directory = await createTestTempDirectory("rea-hopper-status-");
const launcher = join(directory, "Hopper");
await writeFile(launcher, "fixture launcher");
const host = systemLinuxHopperInstallHost({
...process.env,
HOME: directory,
XDG_CACHE_HOME: join(directory, "cache"),
});
const host = systemLinuxHopperInstallHost(
{
...process.env,
HOME: directory,
XDG_CACHE_HOME: join(directory, "cache"),
},
() => readLinuxDistribution(process.arch),
);
const command = vi
.spyOn(nativeCommands, "execFileOutput")
.mockResolvedValue({
@@ -6,9 +6,12 @@ import { err, ok } from "../domain/result.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import {
linuxHopperInstallDisclosure,
readLinuxDistribution,
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
const hostDistribution = () => readLinuxDistribution(process.arch);
const missingPython = {
interpreter: "/usr/bin/python3",
version: [3, 14],
@@ -80,7 +83,10 @@ describe("Linux Hopper package runtime", () => {
const directory = await createTestTempDirectory(
"rea-linux-python-install-",
);
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
hostDistribution,
);
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValue(commandSuccess);
@@ -115,7 +121,10 @@ describe("Linux Hopper package runtime", () => {
const directory = await createTestTempDirectory(
"rea-linux-python-failure-",
);
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
hostDistribution,
);
const failed = {
exitCode: 67,
stdout: "original package output",
@@ -153,7 +162,10 @@ describe("Linux Hopper package mapping", () => {
"/usr/lib/x86_64-linux-gnu/libpython3.13.so.1.0",
])("keeps an unproven APT mapping unknown: %s", async (path) => {
const directory = await createTestTempDirectory("rea-python-package-map-");
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
hostDistribution,
);
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValue(commandSuccess);
@@ -201,7 +213,10 @@ describe("Linux Hopper package cancellation and disclosure", () => {
const directory = await createTestTempDirectory(
"rea-python-install-cancel-",
);
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
hostDistribution,
);
const controller = new AbortController();
const cause = new Error("caller cancelled at " + phase);
let commandCount = 0;
@@ -444,10 +444,10 @@ const addCandidate = (
...current.authority_references,
...candidate.authority_references,
]),
residual_unknown_ids: uniqueBy(
[...current.residual_unknown_ids, ...candidate.residual_unknown_ids],
(item) => item,
),
residual_unknown_ids: uniqueSorted([
...current.residual_unknown_ids,
...candidate.residual_unknown_ids,
]),
// Each observation keeps its own unresolved authority and next steps.
unavailable_authority: uniqueSorted([
...current.unavailable_authority,
@@ -135,7 +135,12 @@ export const writeAnalysisSnapshot = async (
new EvidenceIntegrityError(snapshotValidationMessage(cause), { cause }),
);
}
return writeTextFile(encoded, path, overwrite, resources);
return writeTextFile(
encoded,
path,
overwrite ? "replace" : "refuse",
resources,
);
};
const snapshotValidationMessage = (cause: unknown): string =>
+4 -1
View File
@@ -488,6 +488,7 @@ const evidenceFileMessage = ({
operation,
reason,
published,
overwrite,
}: EvidenceFileError): string => {
if (published !== undefined)
return "The output file was published, but staging cleanup remains incomplete.";
@@ -498,7 +499,9 @@ const evidenceFileMessage = ({
if (reason === "not-file")
return "Evidence path does not point to a regular file. Choose a file and try again.";
if (reason === "exists")
return "Evidence file already exists. Choose another path or allow overwrite.";
return overwrite === "never"
? "Evidence file already exists, and this operation never overwrites it. Choose another path and try again."
: "Evidence file already exists. Choose another path or allow overwrite.";
if (reason === "invalid-json")
return "Evidence file is not valid JSON. Repair or recreate the file and try again.";
return "Evidence file could not be accessed. Check file permissions and try again.";
@@ -1020,6 +1020,29 @@ describe("analysis error projection: evidence files", () => {
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
},
);
it.each([
[
"refuse",
"Evidence file already exists. Choose another path or allow overwrite.",
],
[
"never",
"Evidence file already exists, and this operation never overwrites it. Choose another path and try again.",
],
] as const)(
"recovers an existing destination refused by the %s policy",
(overwrite, message) => {
expect(
projectAnalysisError(
new EvidenceFileError("write", "exists", {
path: "/selected.json",
overwrite,
}),
),
).toMatchObject({ message, remediation: { action: message } });
},
);
});
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
+10
View File
@@ -10,6 +10,12 @@ export interface PublishedEvidenceFile {
readonly bytes: number;
}
/**
* How a write treats an existing destination: replace it, refuse it while the
* caller may still request replacement, or never replace it for this operation.
*/
export type OverwritePolicy = "replace" | "refuse" | "never";
/** Evidence identity, schema, or bundle manifests failed integrity checks. */
export class EvidenceIntegrityError extends AnalysisError {
readonly _tag = "EvidenceIntegrityError";
@@ -54,6 +60,8 @@ export class EvidenceFileError extends AnalysisError {
readonly published: PublishedEvidenceFile | undefined;
/** Schema issues relative to the selected file's JSON root, never the request envelope. */
readonly contentIssues: readonly AnalysisInputIssue[] | undefined;
/** Policy that refused an existing destination; `never` offers no replacement. */
readonly overwrite: Exclude<OverwritePolicy, "replace"> | undefined;
constructor(
readonly operation: "read" | "write",
@@ -69,6 +77,7 @@ export class EvidenceFileError extends AnalysisError {
readonly published?: PublishedEvidenceFile;
readonly userMessage?: string;
readonly contentIssues?: readonly AnalysisInputIssue[];
readonly overwrite?: Exclude<OverwritePolicy, "replace">;
},
) {
super(`Evidence bundle ${operation} failed: ${reason}`, options);
@@ -76,5 +85,6 @@ export class EvidenceFileError extends AnalysisError {
this.published = options?.published;
this.userMessage = options?.userMessage;
this.contentIssues = options?.contentIssues;
this.overwrite = options?.overwrite;
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ export class Il2cppGhidraSeedWriter implements Il2cppSeedWriterPort {
const written = await writeTextParts(
[seeds.text],
output.path,
false,
"never",
this.#resources,
options?.signal === undefined
? undefined
+1 -1
View File
@@ -53,7 +53,7 @@ const registerExportEvidenceTool = ({
const written = await writeEvidenceBundle(
bundle,
input.path,
input.overwrite,
input.overwrite ? "replace" : "refuse",
resources,
context.mcpReq.signal,
);
@@ -1,5 +1,5 @@
import { readEvidenceBundle } from "../../../src/application/EvidenceBundleFiles.js";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -95,7 +95,8 @@ cliTest(
const saved = await readEvidenceBundle(bundlePath);
if (!saved.ok) throw saved.error;
expect(saved.value.records).toEqual([evidence]);
const overwrite = await cli.run({
const published = await readFile(bundlePath);
const collision = await cli.run({
arguments: [
"inspect-desktop-application",
installation,
@@ -106,7 +107,18 @@ cliTest(
"--json",
],
});
expect(overwrite.exitCode).not.toBe(0);
expect(collision.exitCode).not.toBe(0);
// --evidence_output has no overwrite flag, so recovery must not offer one.
const neverOverwrites =
"Evidence file already exists, and this operation never overwrites it. Choose another path and try again.";
expect(collision.json).toMatchObject({
code: "invalid_request",
category: "invalid_input",
message: neverOverwrites,
remediation: { action: neverOverwrites },
details: { operation: "write", reason: "exists", path: bundlePath },
});
expect(await readFile(bundlePath)).toEqual(published);
},
);
@@ -405,7 +405,13 @@ const writeBundle = (
signal?: AbortSignal,
) =>
withArtifactResourceScope((resources) =>
writeEvidenceBundle(value, path, overwrite, resources, signal),
writeEvidenceBundle(
value,
path,
overwrite ? "replace" : "refuse",
resources,
signal,
),
);
const writeParts = (
@@ -418,7 +424,13 @@ const writeParts = (
},
) =>
withArtifactResourceScope((resources) =>
writeTextParts(parts, path, overwrite, resources, cancellation),
writeTextParts(
parts,
path,
overwrite ? "replace" : "refuse",
resources,
cancellation,
),
);
describe("evidence bundle publication", () => {
@@ -58,7 +58,7 @@ it("closes the descriptor and retains staged content when its final identity is
const result = await writeTextParts(
["owned bytes"],
destination,
false,
"refuse",
resources,
);
if (result.ok) throw new Error("Expected unverified publication failure");
@@ -107,7 +107,7 @@ it.each([false, true])(
const result = await writeTextParts(
["canonical ", "output"],
destination,
overwrite,
overwrite ? "replace" : "refuse",
resources,
);
if (!result.ok) throw result.error;
@@ -137,7 +137,7 @@ it("removes cancelled staging after the first write assigned its file ID", async
const result = await writeTextParts(
cancelledAfterFirstWrite(),
destination,
false,
"refuse",
resources,
{ signal: controller.signal, operation: "export_evidence_bundle" },
);
@@ -160,7 +160,7 @@ it("reports committed output when post-publication staging cleanup remains owned
const result = await writeTextParts(
stagingPollutingParts(root),
join(root, "bundle.json"),
false,
"refuse",
resources,
);
if (result.ok) throw new Error("Expected staged cleanup failure");
@@ -214,7 +214,7 @@ it("preserves cancellation when staging cleanup also fails", async () => {
const result = await writeTextParts(
cancelledParts(),
join(root, "cancelled.json"),
false,
"refuse",
resources,
{ signal: controller.signal, operation: "export_evidence_bundle" },
);
@@ -267,7 +267,7 @@ it("restores an unpublished write failure once its retained stage is released",
const result = await writeTextParts(
failingParts(),
destination,
false,
"refuse",
resources,
);
if (result.ok) throw new Error("Expected staged write failure");
@@ -317,7 +317,7 @@ it("clears an admission retry failure only after every retained owner releases",
const result = await writeTextParts(
["unused"],
destination,
false,
"refuse",
resources,
);
if (result.ok) throw new Error("Expected admission retry failure");
@@ -347,7 +347,7 @@ it("refuses to publish a staged path replaced after its descriptor opened", asyn
const result = await writeTextParts(
replacingStagedParts(root),
destination,
false,
"refuse",
resources,
);
if (result.ok) throw new Error("Expected staged identity rejection");
@@ -375,7 +375,7 @@ it("does not invent a staged owner when the supplied scope is closed", async ()
const result = await writeTextParts(
["unused"],
destination,
false,
"refuse",
resources,
);
if (result.ok) throw new Error("Expected closed-scope admission failure");