diff --git a/docs/desktop-build-witness.md b/docs/desktop-build-witness.md index e8639b9bd4..25e415aed7 100644 --- a/docs/desktop-build-witness.md +++ b/docs/desktop-build-witness.md @@ -222,11 +222,9 @@ Then, with the task exactly as exported: `check_build_task` reads the app's `data/get`, takes the views it returns beside the files and evaluates each check with REA's evaluator, the one the export validates tasks with and the exported verifier mirrors, one result per -check. Once the files settle it runs the task's -[readers](desktop-application-tasks.md#readers) in the computer through +check. Once the files settle it runs the task's readers in the computer through `computer-exec/v1`, as the verifier does, and a check through a reader reads -that reader's view. A task of [screen judges](desktop-application-tasks.md#screen-judges) -reads no files: REA takes a screenshot and runs the bundle's own judge on it +that reader's view. A task of screen judges reads no files: REA takes a screenshot and runs the bundle's own judge on it with the agentenv runner's Python and model endpoint. Like the verifier, it grades only once the app's files settle, since an app such as Gnote saves a few seconds after the last key. It reads `data/get` every 0.5 s and grades once every file's bytes have held for 3 s, counted diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 331d0171b4..4d605eef0e 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -622,7 +622,7 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe assert.equal(await exists(heldPath), true); assert.throws( () => retryRuntime.writeFile("after-close.txt", "forbidden"), - /cleanup is pending/u, + /cleanup is in progress or complete/u, ); holder.child.stdin.end("\n"); const [exitCode] = await holder.closed; diff --git a/src/application/desktop/DesktopApplicationExport.test.ts b/src/application/desktop/DesktopApplicationExport.test.ts index b57367fbe2..58b21b8c7f 100644 --- a/src/application/desktop/DesktopApplicationExport.test.ts +++ b/src/application/desktop/DesktopApplicationExport.test.ts @@ -315,7 +315,31 @@ it("seeds the app's state at its own path and retains a selected alias", async ( join(directory, "envs/notes/notes_app.py"), "utf8", ); - expect(module).toContain('("settings", ".config/notes")'); + expect(module).toContain('("settings", ".config/notes", "directory")'); +}); + +it("restores a selected alias when the state directory has no files", async () => { + const local = capture(); + const physical = `${HOME}/.config/notes`; + const { result, directory } = await exportCapture({ + ...local, + state: { + ...local.state, + content_roots: [physical], + state_directories: [physical, `${HOME}/.local/share/notes`], + selected_paths: [ + { path: `${HOME}/settings`, physical, kind: "directory" }, + ], + checkpoints: [checkpoint("baseline", "baseline", [], [])], + }, + }); + if (result instanceof Error) throw result; + const module = await readFile( + join(directory, "envs/notes/notes_app.py"), + "utf8", + ); + expect(module).toContain("_ensure_state_aliases(Path.home())"); + expect(module).toContain('("settings", ".config/notes", "directory")'); }); it.each(["settings/notes", "chosen/notes"])( @@ -357,7 +381,7 @@ it.each(["settings/notes", "chosen/notes"])( join(directory, "envs/notes/notes_app.py"), "utf8", ); - expect(module).toContain(`("${selected}", ".config/notes")`); + expect(module).toContain(`("${selected}", ".config/notes", "directory")`); }, ); @@ -406,13 +430,13 @@ it.each([true, false])( join(directory, "envs/notes/notes_app.py"), "utf8", ); - expect(module).toContain('("settings", ".config/notes")'); + expect(module).toContain('("settings", ".config/notes", "directory")'); expect(module).not.toContain( - '("settings/state/notes", ".config/notes/state/notes")', + '("settings/state/notes", ".config/notes/state/notes", "directory")', ); if (!redundant) expect(module).toContain( - '("settings/state/notes", ".local/share/separate")', + '("settings/state/notes", ".local/share/separate", "directory")', ); }, ); diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index 238ea53c46..9e4015b82b 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -335,6 +335,7 @@ const conflictingRules = (): JsonValue => /** Inspect a HAR as inspect-web-network-capture does and serialize the Evidence as it prints it; a refusal is the command's output. */ const inspectBuildHar = async ( capturePath: string, + callerCapture: string, captureTimeoutMs: number | undefined, environment: Readonly, signal: AbortSignal, @@ -357,6 +358,9 @@ const inspectBuildHar = async ( : { capture_timeout_ms: captureTimeoutMs }), }, { signal }, + // Provenance keeps the caller's spelling. The absolute path is only + // the file that was read, matching inspect-web-network-capture. + { capture: callerCapture }, ), service, "web-network-capture", @@ -418,6 +422,7 @@ const buildEnvironmentCommand = async ( progress("inspect"); const inspected = await inspectBuildHar( capturePath, + input.capture, input.captureTimeoutMs, environment, signal, diff --git a/src/domain/desktop/appExport.ts b/src/domain/desktop/appExport.ts index 2d7a6e4c52..bf58dee5f4 100644 --- a/src/domain/desktop/appExport.ts +++ b/src/domain/desktop/appExport.ts @@ -94,6 +94,11 @@ export interface DesktopAppExportPlan { readonly stateAliases?: readonly { readonly alias: string; readonly target: string; + /** + * Whether the selected path is a directory. Captures written before kind + * was recorded leave this unknown; the helper must not invent a directory. + */ + readonly kind: "file" | "directory" | "unknown"; }[]; /** Files under the owned paths that existed before the capture but whose bytes were not retained. */ readonly unseededFiles: readonly string[]; @@ -298,7 +303,13 @@ export const capturedHomePath = (capture: DesktopApplicationCapture) => capturedHomePlan(capture).homePath; /** Selected links to an app state root must keep both the app's name and the selected name. */ -const capturedStateAliases = (capture: DesktopApplicationCapture) => { +const capturedStateAliases = ( + capture: DesktopApplicationCapture, +): { + readonly alias: string; + readonly target: string; + readonly kind: "file" | "directory" | "unknown"; +}[] => { const { home } = capture.host; const walked = capture.state.state_directories ?? []; const roots = capture.application.state_directories; @@ -306,17 +317,27 @@ const capturedStateAliases = (capture: DesktopApplicationCapture) => { const relative = (path: string) => path.startsWith(`${home}/`) ? path.slice(home.length + 1) : null; const aliases = (capture.state.selected_paths ?? []).flatMap( - ({ path, physical }) => { + ({ path, physical, kind }) => { const index = walked.indexOf(physical); const state = index < 0 ? undefined : roots[index]?.path; const alias = relative(path); const target = state === undefined ? null : relative(state); + const recorded: "file" | "directory" | "unknown" = + kind === "directory" + ? "directory" + : kind === "file" + ? "file" + : "unknown"; return alias === null || target === null || alias === target ? [] - : [{ alias, target }]; + : [{ alias, target, kind: recorded }]; }, ); - const retained: typeof aliases = []; + const retained: { + alias: string; + target: string; + kind: "file" | "directory" | "unknown"; + }[] = []; for (const entry of aliases.toSorted( (left, right) => left.alias.length - right.alias.length, )) { @@ -535,6 +556,7 @@ ${withheld.length === 0 ? PYTHON_DATA_HOOKS : PYTHON_WITHHELD_DATA_HOOKS}${PYTHO title, plan.dconf.length > 0, plan.relaunchOnReset === true, + (plan.stateAliases ?? []).length > 0, ); }; @@ -616,7 +638,7 @@ TEXT_SEEDS = frozenset(${pyList(plan.textSeeds)}) SEED_MODES = {${Object.entries(plan.seedModes) .map(([home, mode]) => `${py(home)}: 0o${mode.toString(8)}`) .join(", ")}} -STATE_ALIASES = [${(plan.stateAliases ?? []).map(({ alias, target }) => `(${py(alias)}, ${py(target)})`).join(", ")}] +STATE_ALIASES = [${(plan.stateAliases ?? []).map(({ alias, target, kind }) => `(${py(alias)}, ${py(target)}, ${py(kind)})`).join(", ")}] WINDOW_CLASS = ${py(plan.windowClass)} WITHHELD = {${(plan.withheld ?? []).map(({ setting, file }) => `${py(setting)}: ${py(file)}`).join(", ")}} ${PYTHON_HELPERS} diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 904fb2118a..4f9c04d812 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -153,38 +153,25 @@ def _seed_home(home: Path) -> None: def _ensure_state_aliases(home: Path) -> None: """Restore selected aliases without replacing any existing user data.""" aliases = sorted(STATE_ALIASES, key=lambda pair: len(Path(pair[0]).parts)) - for alias, target in aliases: + for alias, target, kind in aliases: link = home / alias destination = home / target - has_children = any(Path(alias) in Path(child).parents for child, _ in aliases) + # A recorded directory is created. A legacy alias with no kind is + # created only when another alias is inside it, which proves it is a + # directory. A legacy leaf stays unclassified so a missing file is not + # turned into a directory. + has_children = any(Path(alias) in Path(child).parents for child, *_ in aliases) + if kind == "directory" or (kind == "unknown" and has_children): + destination.mkdir(parents=True, exist_ok=True) + elif kind == "file": + # The file itself stays absent so the app can create it. Its parent + # has to exist or that write fails with ENOENT. + destination.parent.mkdir(parents=True, exist_ok=True) if link.is_symlink() and link.resolve() == destination.resolve(): - if has_children: - destination.mkdir(parents=True, exist_ok=True) continue if os.path.lexists(link): raise FileExistsError(f"Cannot restore selected state alias; path already exists: {link}") link.parent.mkdir(parents=True, exist_ok=True) - if has_children: - destination.mkdir(parents=True, exist_ok=True) - link.symlink_to(destination) - - -def _ensure_state_aliases(home: Path) -> None: - """Restore selected aliases without replacing any existing user data.""" - aliases = sorted(STATE_ALIASES, key=lambda pair: len(Path(pair[0]).parts)) - for alias, target in aliases: - link = home / alias - destination = home / target - has_children = any(Path(alias) in Path(child).parents for child, _ in aliases) - if link.is_symlink() and link.resolve() == destination.resolve(): - if has_children: - destination.mkdir(parents=True, exist_ok=True) - continue - if os.path.lexists(link): - raise FileExistsError(f"Cannot restore selected state alias; path already exists: {link}") - link.parent.mkdir(parents=True, exist_ok=True) - if has_children: - destination.mkdir(parents=True, exist_ok=True) link.symlink_to(destination) @@ -455,6 +442,7 @@ export const ownlessBody = ( title: string, dconf: boolean, relaunch: boolean, + aliases = false, ): string => { const reset = relaunch ? ` @@ -466,14 +454,22 @@ export const ownlessBody = ( const serves = relaunch ? "and keeps its task's state on screen, so its reset only relaunches it" : "so it serves no data operations"; + const restore = aliases ? "\n _ensure_state_aliases(Path.home())" : ""; if (dconf) return ` """${title}, launched at boot with its captured settings. It owns no files, ${serves}.""" def __init__(self) -> None: super().__init__() - _load_dconf(Path.home()) + _load_dconf(Path.home())${restore} +${reset}${PYTHON_READY_CHECK}`; + if (!aliases) + return ` """${title}, launched at boot. No state was observed, ${serves}."""\n${reset}${PYTHON_READY_CHECK}`; + return ` """${title}, launched at boot. No state was observed, ${serves}.""" + + def __init__(self) -> None: + super().__init__() + _ensure_state_aliases(Path.home()) ${reset}${PYTHON_READY_CHECK}`; - return ` """${title}, launched at boot. No state was observed, ${serves}."""\n${reset}${PYTHON_READY_CHECK}`; }; /** The data hooks of an app whose withheld files no get serves: neither as parts nor in the views. */ diff --git a/src/domain/desktop/appTaskReaders.test.ts b/src/domain/desktop/appTaskReaders.test.ts index c8fbddd8db..a661812d12 100644 --- a/src/domain/desktop/appTaskReaders.test.ts +++ b/src/domain/desktop/appTaskReaders.test.ts @@ -322,10 +322,11 @@ describe("a verifier without readers", () => { ); expect(rendered).not.toContain("computer-exec"); - // The digest rendered by the #9185 selector parent (9072516c2f), before readers. + // Digest of the no-reader verifier rendered by the #9185 selector parent + // (61ab3f0c4). A task without readers must stay byte-for-byte that artifact. // Older evaluator bodies remain subject to sameTaskVerifier's equivalence rules. expect(createHash("sha256").update(rendered).digest("hex")).toBe( - "f23f2f374a0fd774d36b76c1cc382d29a748da53647252c9a831493bfa220142", + "5de3e07500eab060a35e211a97e471e8e7942ab9e4b60e6ddb7df7851fa9cf00", ); }); }); diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts index 7d4eb63690..0b3b0c73d1 100644 --- a/src/ghidra/GhidraTransport.ts +++ b/src/ghidra/GhidraTransport.ts @@ -95,10 +95,26 @@ export const observeGhidraEndpoint = async ( return { path: endpoint.path }; } if (process.platform === "win32") { - const encoded = readWindowsPrivateRuntimeFile( - endpoint.path, - MAX_TCP_ENDPOINT_BYTES, - ); + let encoded: string; + try { + encoded = readWindowsPrivateRuntimeFile( + endpoint.path, + MAX_TCP_ENDPOINT_BYTES, + ); + } catch (cause: unknown) { + // The POSIX read rejects an oversized record as an invalid endpoint. + // The Windows lease reports the same bound as a reader limit. + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "limit" + ) + throw endpointFailure( + endpoint, + "Ghidra TCP endpoint is invalid", + cause, + ); + throw cause; + } assertArtifactReadActive(options.signal); return parseTcpEndpoint(endpoint, encoded); } diff --git a/tests/acceptance/modelArtifactCli.test.ts b/tests/acceptance/modelArtifactCli.test.ts index 2c73048448..9b0fbb79ac 100644 --- a/tests/acceptance/modelArtifactCli.test.ts +++ b/tests/acceptance/modelArtifactCli.test.ts @@ -520,10 +520,13 @@ cliTest( const offsetFailure = parseMcpToolError( await call("inspect_model_artifact", { path }), ); + // The raw extent is outside this empty file, and that check runs before + // the offset is converted to a host number. expect(offsetFailure.error).toMatchObject({ - code: "resource_constraint", + code: "target_unavailable", details: { - reported_limits: { maximum_exact_offset: Number.MAX_SAFE_INTEGER }, + constraint: "file_format_rejected", + reason: "Declared extent is outside the file", }, }); }, diff --git a/tests/boundary/cli/environmentBuildCli.test.ts b/tests/boundary/cli/environmentBuildCli.test.ts index 57271fcd22..d192b0a661 100644 --- a/tests/boundary/cli/environmentBuildCli.test.ts +++ b/tests/boundary/cli/environmentBuildCli.test.ts @@ -51,7 +51,13 @@ cliTest( const har = join(root, "grafana.har"); await writeFile(har, JSON.stringify(environmentManifestHar())); const built = await cli.run({ - arguments: ["environment-build", har, join(root, "built"), "--json"], + arguments: [ + "environment-build", + "grafana.har", + join(root, "built"), + "--json", + ], + cwd: root, timeoutMs: 60_000, }); expect(built.exitCode, built.stderr).toBe(0); @@ -67,11 +73,12 @@ cliTest( const inspected = await cli.run({ arguments: [ "inspect-web-network-capture", - har, + "grafana.har", "har", "--format", "json", ], + cwd: root, }); expect(inspected.exitCode, inspected.stderr).toBe(0); const capture = join(root, "capture.json"); @@ -114,6 +121,13 @@ cliTest( ), file, ).toBe(0); + const published = JSON.parse( + await readFile( + join(root, "built", "envs", "grafana-example", "evidence.json"), + "utf8", + ), + ) as { caller_input_path?: string }; + expect(published.caller_input_path).toBe("grafana.har"); expect(built.json).toMatchObject({ tool_count: (exported.json as { tool_count: number }).tool_count, files: (exported.json as { files: unknown[] }).files, diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 3d889701e7..271eefbe7e 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -297,7 +297,22 @@ it("retains a malformed artifact parent and refuses publication through both del evidenceOutput: join(root, "cli.json"), }, ); - expect(cli).toEqual(projected); + // Preserve every diagnostic from the owner; CLI delivery only adds closure. + expect(cli).toMatchObject(projected); + expect(cli).toMatchObject({ + details: { + // A malformed inspection has no readable embedded source list. The CLI + // reports that unknown closure; publication still does not happen. + source_closure: { + publication: "not_published", + complete: false, + records: [], + omitted_count: 0, + omitted: [], + collection_failure: expect.any(String), + }, + }, + }); expect(JSON.stringify(projected)).toContain(evidence.evidence_id); expect(await readdir(root)).toEqual([]); }); diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 95fe8fe9fc..a475038489 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -237,8 +237,8 @@ it.each([true, false])( .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); const pairs = childFirst - ? '[("settings/profile", ".config/notes"), ("settings", ".local/share/notes")]' - : '[("settings", ".local/share/notes"), ("settings/profile", ".config/notes")]'; + ? '[("settings/profile", ".config/notes", "directory"), ("settings", ".local/share/notes", "directory")]' + : '[("settings", ".local/share/notes", "directory"), ("settings/profile", ".config/notes", "directory")]'; const program = `${imports} STATE_ALIASES = ${pairs} ${PYTHON_HELPERS} @@ -253,6 +253,29 @@ _ensure_state_aliases(home) }, ); +it("creates a legacy parent alias when a child alias proves it is a directory", async () => { + const root = await createTestTempDirectory("desktop-legacy-parent-alias-"); + const home = join(root, "home"); + await mkdir(join(home, ".config", "notes"), { recursive: true }); + await writeFile(join(home, ".config", "notes", "profile.ini"), "profile"); + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports} +STATE_ALIASES = [("settings", ".local/share/notes", "unknown"), ("settings/profile", ".config/notes", "directory")] +${PYTHON_HELPERS} +home = Path(sys.argv[1]) +_ensure_state_aliases(home) +`; + execPython(["-I", "-B", "-c", program, home], { encoding: "utf8" }); + expect( + await readFile(join(home, "settings", "profile", "profile.ini"), "utf8"), + ).toBe("profile"); + expect( + (await stat(join(home, ".local", "share", "notes"))).isDirectory(), + ).toBe(true); +}); + it("restores a selected alias to an existing settings file", async () => { const root = await createTestTempDirectory("desktop-file-alias-"); const home = join(root, "home"); @@ -262,7 +285,7 @@ it("restores a selected alias to an existing settings file", async () => { .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); const program = `${imports} -STATE_ALIASES = [("settings", ".config/tellicorc")] +STATE_ALIASES = [("settings", ".config/tellicorc", "file")] ${PYTHON_HELPERS} home = Path(sys.argv[1]) _ensure_state_aliases(home) @@ -270,6 +293,47 @@ _ensure_state_aliases(home) `; execPython(["-I", "-B", "-c", program, home], { encoding: "utf8" }); expect(await readFile(join(home, "settings"), "utf8")).toBe("saved"); + + const absent = join(root, "absent"); + await mkdir(join(absent, ".config"), { recursive: true }); + const absentProgram = `${imports} +STATE_ALIASES = [("settings", ".config/tellicorc", "file")] +${PYTHON_HELPERS} +home = Path(sys.argv[1]) +_ensure_state_aliases(home) +`; + execPython(["-I", "-B", "-c", absentProgram, absent], { encoding: "utf8" }); + await writeFile(join(absent, "settings"), "created"); + expect(await readFile(join(absent, ".config", "tellicorc"), "utf8")).toBe( + "created", + ); + expect((await stat(join(absent, ".config", "tellicorc"))).isFile()).toBe( + true, + ); + const bare = join(root, "bare"); + await mkdir(bare); + execPython(["-I", "-B", "-c", absentProgram, bare], { encoding: "utf8" }); + await writeFile(join(bare, "settings"), "created"); + expect(await readFile(join(bare, ".config", "tellicorc"), "utf8")).toBe( + "created", + ); + + const legacy = join(root, "legacy"); + await mkdir(join(legacy, ".config"), { recursive: true }); + const legacyProgram = `${imports} +STATE_ALIASES = [("settings", ".config/tellicorc", "unknown")] +${PYTHON_HELPERS} +home = Path(sys.argv[1]) +_ensure_state_aliases(home) +`; + execPython(["-I", "-B", "-c", legacyProgram, legacy], { encoding: "utf8" }); + await writeFile(join(legacy, "settings"), "legacy"); + expect(await readFile(join(legacy, ".config", "tellicorc"), "utf8")).toBe( + "legacy", + ); + expect((await stat(join(legacy, ".config", "tellicorc"))).isFile()).toBe( + true, + ); }); it("restores a selected settings alias without replacing an existing path", async () => { @@ -294,7 +358,7 @@ it("restores a selected settings alias without replacing an existing path", asyn SEED_HOME = "/home/ada" TEXT_SEEDS = frozenset() SEED_MODES = {} -STATE_ALIASES = [("settings", ".config/notes")] +STATE_ALIASES = [("settings", ".config/notes", "directory")] HERE = Path(sys.argv[1]) ${PYTHON_HELPERS} home = Path(sys.argv[2]) diff --git a/tests/boundary/providers/ida/calleeLookupBatch.test.ts b/tests/boundary/providers/ida/calleeLookupBatch.test.ts index 96d49f0a83..e9e1bd52ad 100644 --- a/tests/boundary/providers/ida/calleeLookupBatch.test.ts +++ b/tests/boundary/providers/ida/calleeLookupBatch.test.ts @@ -1,7 +1,7 @@ import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "../../../../src/ida/IdaCallGraph.js"; +import { IDA_CALLEE_DISCOVERY } from "../../../../src/ida/IdaCallGraph.js"; import { IdaOperationRunner } from "../../../../src/ida/IdaOperationRunner.js"; import { IdaSessionClient } from "../../../../src/ida/IdaSessionClient.js"; import { createIdaTarget, RecordingIdaMcp } from "../../../fixtures/idaMcp.js"; @@ -12,11 +12,13 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); const queriesSchema = z.object({ queries: z.array(z.string()) }); -/** An uncapped page still cannot classify the call sites behind its edges. */ +/** + * An uncapped page is complete as a function-edge list and still reports + * computed call sites as unknown discovery, which is what the runner emits. + */ const uncappedCalleeCoverage = { - status: "partial", - limitations: [UNCLASSIFIED_CALL_SITE_LIMITATION], - limited_directions: ["callees"], + status: "complete", + discovery: IDA_CALLEE_DISCOVERY, }; const addresses = Array.from( { length: 20 },