Compare commits

...
Author SHA1 Message Date
morluto 6878a84796 fix(tests): restore worker fixture and allow setup completion 2026-11-12 20:57:43 -05:00
Kaoru0822 937d10df1c fix(pe): enforce DER extension critical encoding (#10212) 2026-11-12 19:33:31 -05:00
Kaoru0822 c19d319929 fix(desktop): preserve literal directories in ELF origin expansion (#10215)
fix(desktop): expand ELF origin paths as literal directory names
2026-11-12 19:33:16 -05:00
Kaoru0822 966bde3780 fix(android): enforce DEX array dimension limit (#10227) 2026-11-12 19:29:53 -05:00
Kaoru0822 4d15b7f509 fix(android): preserve SDK-gated manifest permissions (#10236) 2026-11-12 19:29:40 -05:00
Kaoru0822 50d4f7bea4 fix(desktop): validate guest file replies at their shared boundary (#10224)
* fix(desktop): reject malformed guest file base64

* fix(desktop): scope guest file metadata validation to its namespace
2026-11-12 19:29:18 -05:00
Kaoru0822 594d73985c fix(desktop): preserve Unicode separators in entry values (#10210) 2026-11-12 19:29:05 -05:00
Kaoru0822 418e3f5e91 fix(release): honor selected npm in update verification (#10240) 2026-11-12 19:28:29 -05:00
Kaoru0822 0f5ac9d5f3 fix(desktop): preserve multiline apt-config locations (#10241) 2026-11-12 19:28:11 -05:00
2SinceJulyand2SinceJuly 7559e06740 fix(contracts): drop nested defaults from a root union sibling (#10254)
* fix(contracts): drop nested defaults from a root union sibling

Flattening a union of strict object groups lifts each group's properties
into one object. A default the scenario group carried inside
normalization.rules survived that lift even though the passive group
declares no normalization, so a client that fills nested defaults sent
{normalization: {rules: []}} and the runtime parser refused the call
with "Unrecognized key: normalization".

Remove defaults below a property that any group omits, not just the one
on the property itself. A property every group owns keeps its own
nested guidance.

* fix(contracts): keep a nested default a reference still targets

Dropping the nested default of a property a sibling group omits also removed defaults that references point into. A resource whose validation $ref targets its own default was advertised with that reference intact but its target gone, so the advertised schema no longer compiled in AJV or client projection.

Collect the paths into a default that references inside the branch target and keep those entries while stripping the rest. Other nested defaults are still removed, so the strict sibling group keeps refusing the object.

* test(contracts): type the root union default regression fixture

The fixture spread the canonical branch through a widened cast, so the repository typecheck rejected the spread and indexed access. Narrow the branch shape and guard the optional schema before collecting its referenced defaults.

---------

Co-authored-by: 2SinceJuly <[email protected]>
2026-11-12 19:27:58 -05:00
ShuaixinHuangandShuaixinHuang 9d32836d37 fix(apple): expand loaded siblings along the first dyld chain (#10248)
* fix(apple): expand loaded siblings along the first dyld chain

* fix(apple): retain conditional incoming dyld expansion edges

* fix(apple): qualify findings by their expansion chain

---------

Co-authored-by: ShuaixinHuang <[email protected]>
2026-11-12 19:27:40 -05:00
morluto 02ebd3e02d refactor: prune test-only seams and obsolete helpers (#10275) 2026-11-12 19:24:54 -05:00
morluto a8527d43b8 fix(setup): supply registration profile in catalog test 2026-11-12 11:47:18 -05:00
323 changed files with 3116 additions and 1498 deletions

No files matched your search

+3 -1
View File
@@ -464,7 +464,9 @@ class ReaJadxBridge {
if (element.getTagName().equals("uses-sdk")) {
minSdk = element.getAttributeNS(android, "minSdkVersion");
targetSdk = element.getAttributeNS(android, "targetSdkVersion");
} else if (element.getTagName().equals("uses-permission")) {
} else if (element.getTagName().equals("uses-permission")
|| element.getTagName().equals("uses-permission-sdk-23")
|| element.getTagName().equals("uses-permission-sdk-m")) {
String permission = element.getAttributeNS(android, "name");
if (!permission.isEmpty()) permissions.add(permission);
}
-7
View File
@@ -154,13 +154,6 @@ class HopperApiFacade:
)
def _configure_hopper_api(document_provider):
"""Inject Hopper's document provider or a standalone test facade."""
global _hopper_api, _rea_session_document
_hopper_api = HopperApiFacade(document_provider)
_rea_session_document = None
def _api():
"""Resolve Hopper globals lazily so importing this module needs no Hopper."""
global _hopper_api
@@ -215,7 +215,10 @@ Resolution follows dyld:
image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image
loads once per process. A later @rpath request whose install name matches an
already loaded image reuses it.
already loaded image reuses it. Loading an image and expanding its dependencies
are separate: an already loaded sibling can first be expanded through another
dependency, inheriting that traversal's rpath stack. Cycles do not expand an
image twice, and lazy edges do not establish a launch-time traversal chain.
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`.
- Dyld path components are POSIX names. On Windows hosts, link text uses `\`
@@ -252,8 +255,12 @@ one of:
A `conditional` image is still traced, because it is an in-root candidate
that may load. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. An in-root dylib
with no platform load command remains unknown unless the root is an executable
request that reuses it by install name is `conditional` too. A definitely loaded
image can also first expand through a conditional chain: its dependency edges
then have `loader_conditional: true`, while later reuse of the image retains its
definite first-load result.
An in-root dylib with no platform load command remains unknown unless the root is an executable
with observed macOS platform and the candidate's CPU type is x86_64, i386, or arm64. In that macOS context,
[Apple's dyld](https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/mach_o/UnsafeHeader.cpp#L1137-L1145) supplies an implicit macOS platform to legacy dylibs on those CPU
types. The candidate records `platform_basis: "macos-dyld-implicit"`; the
+3 -8
View File
@@ -222,7 +222,7 @@ cannot answer, such as an address without one exact file offset, omits this
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
the operation and the REA server/provider deadline. Inspect partial Evidence
and reported cleanup state, then follow the returned remediation. Change a
request field or REA_* setting only when the returned remediation explicitly
request field or REA\_\* setting only when the returned remediation explicitly
recommends that change. Restart REA when that text says the value is read at
startup. A mention that a control does not raise the deadline is not a
recommendation to change it. When it names no setting, reduce supported work;
@@ -1911,13 +1911,10 @@ support for omitted historical values.
known and differ. If only one value is known, storage comparison is unknown;
when both are omitted, storage is not compared.
<!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->
`compare_artifacts` also compares the declarations in the `format_metadata`
of UnityFS members (`unityfs-entry`), PyInstaller entries
`compare_artifacts` also compares the declarations in the `format_metadata` of <!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->UnityFS members (`unityfs-entry`), PyInstaller entries
(`pyinstaller-entry`), Node SEA members (`node-sea-entry`),
ESP image segments (`esp-image-segment`), DfuSe elements (`dfuse-element`),
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`);
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`)<!-- END:compare-artifacts:compared-format-metadata-kinds -->;
each format's guide names the compared fields. A changed declaration is a
metadata change, including when content is unavailable. A declaration that
only one side records, or that the other side records as another kind, is
@@ -1928,8 +1925,6 @@ container or from caller options that identify the target, or repeat
occurrence fields, except Godot PCK source paths, which
[Godot PCK archives](../godot-pck.md) explains.
<!-- END:compare-artifacts:compared-format-metadata-kinds -->
Every occurrence records `entry_kind`, `executable` and `encrypted`. A
different entry kind or `encrypted` value is a `metadata` difference, and a
different `executable` value is an `executable` difference. Occurrence and node
@@ -10,8 +10,8 @@
4. Verifier: `appTaskPython.ts` renders `READER_RUNNER`, `READERS`,
`_reader_views` and `evaluate_with_readers` only for a task with readers,
so a task without renders as before.
5. Application: `readTaskReaderViews` and `readerViewsOfBytes` in
`DesktopTaskReaders.ts`; `runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
5. Application: `readerViewsOfBytes` in `DesktopTaskReaders.ts`;
`runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
the witness runs readers through `AgentEnvComputer.exec`; the export runs
them on `shippedSeedBytes` and the end's retained content.
6. Docs: the tasks reference, the witness page, the build skill's recipes for
+5 -2
View File
@@ -10,7 +10,6 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { GHIDRA_ANNOTATIONS_RETAINED } from "../../dist/ghidra/GhidraProviderClient.js";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { OFFICIAL_TOOL_CONTRACTS } from "../../dist/contracts/officialToolContracts.js";
@@ -571,7 +570,11 @@ export async function verifyGhidraBoundaries(
// The producer reason describes the failure; continuing-session guidance
// is projected separately and the following live calls verify it.
assert.match(regexError.details.reason, /stack while matching pattern/u);
assert.ok(regexError.message.includes(GHIDRA_ANNOTATIONS_RETAINED));
assert.ok(
regexError.message.includes(
"The active analysis session and annotations remain available.",
),
);
rejectedCalls++;
const nestedPattern = "(".repeat(12000) + "a" + ")".repeat(12000);
const compileFailure = await client.callTool(
+3 -5
View File
@@ -99,8 +99,7 @@ async function verifyPublishedUpdate({
environment,
);
await commands.exec(
"npm",
await commands.execNpm(
[
"install",
"--global",
@@ -118,7 +117,7 @@ async function verifyPublishedUpdate({
);
const npmRoot = (
await commands.exec("npm", ["root", "--global"], {
await commands.execNpm(["root", "--global"], {
cwd: canaryRoot,
env: environment,
timeout: 30000,
@@ -186,8 +185,7 @@ if (result.status === "failed") process.exitCode = 1;
}
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
const { stdout } = await commands.exec(
"npm",
const { stdout } = await commands.execNpm(
["view", "rea-agents", "versions", "--json"],
{ cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 },
);
+12 -16
View File
@@ -58,7 +58,7 @@ export const androidSnapshotCopy = (
: undefined;
/** Adopt a snapshot descriptor whose close rejected, keyed by its exact path. */
export type AndroidSnapshotCloseRetention = (
type AndroidSnapshotCloseRetention = (
path: string,
owner: OwnedFileHandle,
) => void;
@@ -69,8 +69,8 @@ export const snapshotAndroidTarget = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
): Promise<string> => {
const snapshot = join(root, "target.apk");
try {
@@ -105,8 +105,8 @@ export const snapshotAndroidEngine = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
try {
@@ -159,17 +159,14 @@ const copyAndroidSnapshot = async (
snapshotPath: string,
expectedSha256: string,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
): Promise<AndroidSnapshotCopy> => {
assertRegularFileActive(signal);
let removeSnapshot = false;
const retain = (path: string) =>
retainFailedClose === undefined
? {}
: {
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
};
const retain = (path: string) => ({
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
});
try {
// Close each descriptor through its owner. A rejected close keeps the
// copy outcome and stays retained until that owner is retried; the
@@ -287,7 +284,7 @@ const openSnapshotSource = async (
path: string,
signal: AbortSignal | undefined,
retention: {
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
},
): Promise<StableRegularFileDescriptor> => {
try {
@@ -305,10 +302,9 @@ const openSnapshotSource = async (
};
const retainAdmissionOwner = (
retain: ((owner: OwnedFileHandle) => void) | undefined,
retain: (owner: OwnedFileHandle) => void,
owner: OwnedFileHandle,
): RegularFileCleanupFailure["retention"] => {
if (retain === undefined) return { kind: "not_requested" };
try {
retain(owner);
return { kind: "callback_returned" };
-9
View File
@@ -6,7 +6,6 @@ import {
resolveWindowsChangeTimeSettle,
} from "../config/windowsChangeTimeSettle.js";
import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js";
import { resolveBridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import { resolveCaptureDecoderHeap } from "../config/captureDecoderHeap.js";
import { resolveRegexMatchingDeadline } from "../config/regexMatchingDeadline.js";
import { resolveRegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js";
@@ -366,7 +365,6 @@ it("reads an accepted override before an invalid launch seed", () => {
{
revision: 1,
overrides: {
"bridge.max_request_bytes": 8192,
"capture.decoder_old_generation_mib": 64,
"regex.matching_deadline_ms": 250,
"regex.worker_heap_request_mib": 128,
@@ -375,9 +373,6 @@ it("reads an accepted override before an invalid launch seed", () => {
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
@@ -393,10 +388,6 @@ it("reads an accepted override before an invalid launch seed", () => {
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
+5 -9
View File
@@ -86,17 +86,13 @@ describe("app bundle Info.plist magic", () => {
const plist = Buffer.from("bplist00");
plist[0] = (plist[0] ?? 0) | 0x80;
await writeFile(plistPath, plist);
let decoded = false;
const result = await resolveAppBundleExecutable(bundle, {
readFile: async () => plist,
realpath: async (path) => path,
decodeBinaryPlist: async () => {
decoded = true;
return "Fixture";
const result = await resolveAppBundleExecutable(bundle);
expect(result).toMatchObject({
ok: false,
error: {
cause: { name: "XmlPlistTextDecodeError", reason: "invalid-bytes" },
},
});
expect(decoded).toBe(false);
expect(result.ok).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
+2 -20
View File
@@ -91,7 +91,6 @@ export interface ResolvedAppBundleResourceRoot {
export interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
readonly realpath: (path: string) => Promise<string>;
readonly decodeBinaryPlist?: (path: string) => Promise<string>;
}
const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
@@ -396,15 +395,6 @@ const codeDescription = (cause: unknown): string => {
: "";
};
const isSystemErrorCode = (cause: unknown): boolean => {
const code = errorCode(cause);
return (
typeof code === "string" &&
/^[A-Z][A-Z0-9_]+$/u.test(code) &&
!code.startsWith("ERR_")
);
};
const isPermissionDenied = (cause: unknown): boolean =>
errorCode(cause) === "EACCES" || errorCode(cause) === "EPERM";
@@ -461,20 +451,12 @@ const resolveLayoutExecutable = async (
let name: string;
try {
name =
hasBinaryPlistSignature(plist) &&
fileSystem.decodeBinaryPlist !== undefined
? await fileSystem.decodeBinaryPlist(plistPath)
: executableName(decodeInfoPlistValue(plist));
name = executableName(decodeInfoPlistValue(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
plistPath,
isPermissionDenied(cause)
? `permission denied decoding ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: isSystemErrorCode(cause)
? `could not decode ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: `${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
`${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
{
cause,
// A bundle without code, such as a resource bundle, declares no
+2 -2
View File
@@ -747,8 +747,8 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
const detectArtifactFormat = async (
path: string,
source: StableRegularFileDescriptor,
signal?: AbortSignal,
sourcePath = path,
signal: AbortSignal | undefined,
sourcePath: string,
): Promise<
| Exclude<
BinaryTarget["format"],
+3 -3
View File
@@ -23,14 +23,14 @@ export interface ClientConfigurationSource extends ClientConfigurationDocument {
const invalidEnvironment = (
path: string,
error: ConfigurationError,
overrides: Readonly<Record<string, string>> = {},
platform: NodeJS.Platform = process.platform,
overrides: Readonly<Record<string, string>> | undefined,
platform: NodeJS.Platform,
): ClientConfigurationFileError => {
const constraints = error.settings
.map(({ setting, constraint }) => `${setting}: ${constraint}`)
.join("; ");
const overrideNames = new Set(
Object.keys(overrides).map((name) =>
Object.keys(overrides ?? {}).map((name) =>
platform === "win32" ? name.toUpperCase() : name,
),
);
@@ -34,7 +34,7 @@ export interface ParsedReferenceSourceEntries {
}
/** Preserve the observed reader failure and add entry-specific recovery guidance. */
export const projectReferenceSourceEntryFailure = (
const projectReferenceSourceEntryFailure = (
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
): string => `${entry.message} ${entryRecoveryGuidance(entry)}`;
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
projectReferenceSourceImportError,
referenceSourceImportAnalysisError,
type ReferenceSourceImportError,
} from "./ReferenceSourceImportTypes.js";
@@ -20,11 +19,13 @@ describe("reference-source import error projection", () => {
parse: "execution_failure",
} as const;
for (const [code, category] of Object.entries(expectedCategories)) {
const projected = projectReferenceSourceImportError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
});
const projected = projectAnalysisError(
referenceSourceImportAnalysisError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
}),
);
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
@@ -5,10 +5,6 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import type {
AnalysisError,
AnalysisErrorOptions,
@@ -125,12 +121,6 @@ export const referenceSourceImportAnalysisError = (
);
};
/** Canonical CLI/MCP projection, retaining source facts under diagnostic details. */
export const projectReferenceSourceImportError = (
error: ReferenceSourceImportError,
): AnalysisErrorProjection =>
projectAnalysisError(referenceSourceImportAnalysisError(error));
/** Import a caller-selected local source tree as historical reference. */
export interface ReferenceSourceImportOptions {
readonly root: string;
+7 -1
View File
@@ -877,7 +877,13 @@ it("discloses the selected catalog profile before applying it with the family sc
const host = new FakeSetupHost();
host.doctorHealthy = true;
host.skill = "unchanged";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
const selection = {
clientIds: ["cursor"],
toolCatalogProfile: "hot-cold" as const,
@@ -11,7 +11,7 @@ import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "./AnalysisViewService.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import type { Evidence } from "../../domain/evidence.js";
const trace = async (
@@ -58,7 +58,7 @@ interface PrepareSessionExecutionInput {
}
/** Recovery for an operation absent from the selected binary route. */
export const undeclaredOperationGuidance = (
const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable = false,
): string => {
@@ -31,9 +31,7 @@ type RoutedOperation = Pick<
>;
/** Reason recorded for an operation's own refusal or provider-wide failure. */
export const runtimeFailureReason = (
failure: AnalysisError,
): string | undefined => {
const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
if (failure._tag === "AnalysisCapabilityUnavailableError")
return failure.userMessage ?? failure.message;
return PROVIDER_WIDE_FAILURES.has(failure._tag)
@@ -42,7 +40,7 @@ export const runtimeFailureReason = (
};
/** Explain unavailability inferred for an operation no request failed for. */
export const inferredUnavailabilityReason = (
const inferredUnavailabilityReason = (
providerId: string,
failure: Pick<ProviderFailure, "operation" | "reason">,
): string =>
@@ -29,16 +29,7 @@ import type {
} from "../../domain/desktop/desktopCapture.js";
import { renderBuildBundle } from "../../domain/desktop/appBuild.js";
import { readDownloadInstall } from "./DesktopExportInstall.js";
import {
NO_LAUNCH_DOCUMENT,
launchDocumentNote,
seedWording,
} from "../../domain/desktop/appExport.js";
import { exportDesktopApplication } from "./DesktopApplicationExport.js";
import {
seedLimitation,
trashedFilesLimitation,
} from "./DesktopExportLimitations.js";
import {
EDITED_NOTE,
HOME,
@@ -529,13 +520,9 @@ describe("exportDesktopApplication and the desktop Trash", () => {
"Documents/today.txt",
],
});
const trashPaths = [
".local/share/Trash/files/today.txt",
".local/share/Trash/info/today.txt.trashinfo",
];
const notice = trashedFilesLimitation(trashPaths);
expect(result.limitations).toContain(notice);
for (const path of trashPaths) expect(notice).toContain(`~/${path}`);
expect(result.limitations).toContain(
"The desktop Trash held 2 file(s) at the seed checkpoint, which are not seeded: ~/.local/share/Trash/files/today.txt, ~/.local/share/Trash/info/today.txt.trashinfo. Restore one the task needs and capture again.",
);
});
});
@@ -1936,7 +1923,9 @@ const exportSeeded = async (
/** The limitations on what the launch opens, found by the lead their formatter gives them. */
const launchLimitations = (limitations: readonly string[]) =>
limitations.filter((line) => line.includes(NO_LAUNCH_DOCUMENT));
limitations.filter((line) =>
line.includes("The app opens no document at launch"),
);
/**
* The seed-then-end capture of an app whose titles name no file, given the
@@ -2037,9 +2026,7 @@ describe("exportDesktopApplication launch document", () => {
`command=["sh", "-c", "cd \\"$HOME\\" 2>/dev/null; exec 'notes' \\"$HOME\\"/'Documents/today.txt'"]`,
);
expect(readme).toContain(
launchDocumentNote({
opens: { kind: "seed", document: "Documents/today.txt" },
}),
"At launch the app opens `~/Documents/today.txt`, the seed's one document.",
);
expect(readme).toContain("`~/Documents/today.txt`");
expect(launchLimitations(result.limitations)).toEqual([]);
@@ -2151,8 +2138,13 @@ describe("exportDesktopApplication launch document", () => {
const made = await exportSeeded("notes %U", { syntheticSeed: true });
const captured = await exportSeeded("notes %U");
const notice = (syntheticSeed: boolean) =>
seedWording({ name: "notes", seedCheckpoint: "seed", syntheticSeed })
.notice;
syntheticSeed
? "The seed was made for this task, not taken from a user's own data; read `envs/notes/seed/` before publishing the bundle."
: "The seed is the user's real data. Keep this bundle local; do not publish it.";
const limitation = (syntheticSeed: boolean) =>
syntheticSeed
? "The seed was made for the task, not taken from a user's own data; read it before publishing the bundle."
: "The seed is the user's real state at the seed checkpoint; keep the bundle local.";
expect(made.result.parameters).toMatchObject({ synthetic_seed: true });
expect(captured.result.parameters).toMatchObject({ synthetic_seed: false });
@@ -2160,10 +2152,8 @@ describe("exportDesktopApplication launch document", () => {
[made, true],
[captured, false],
] as const) {
expect(exported.result.limitations).toContain(seedLimitation(synthetic));
expect(exported.result.limitations).not.toContain(
seedLimitation(!synthetic),
);
expect(exported.result.limitations).toContain(limitation(synthetic));
expect(exported.result.limitations).not.toContain(limitation(!synthetic));
expect(exported.readme).toContain(notice(synthetic));
expect(exported.readme).not.toContain(notice(!synthetic));
}
@@ -328,7 +328,7 @@ const liveComputer = (
* message.
*/
/** The start failure's notice that the bundle seed still holds lock files. */
export const seededLockStartNotice = (locks: readonly string[]): string =>
const seededLockStartNotice = (locks: readonly string[]): string =>
`The bundle's seed holds lock files, which can keep the app from starting when it finds its profile or document in use: ${counted(locks)}. Delete them from the bundle, or export it again, which leaves them out, and start again.`;
const withSeededLocks = async (
@@ -15,7 +15,7 @@ import type { HostIdentity } from "../../domain/desktop/hostIdentity.js";
import { recoveryListLimitation } from "../../domain/desktop/seedRecoveryList.js";
/** The limitation naming files the desktop Trash held at the seed; none when it held none. */
export const trashedFilesLimitation = (
const trashedFilesLimitation = (
files: readonly string[],
): string | undefined =>
files.length === 0
@@ -23,7 +23,7 @@ export const trashedFilesLimitation = (
: `The desktop Trash held ${String(files.length)} file(s) at the seed checkpoint, which are not seeded: ${files.map((home) => `~/${home}`).join(", ")}. Restore one the task needs and capture again.`;
/** The limitation on where the seed came from: made for the task, or the user's real state, kept local. */
export const seedLimitation = (syntheticSeed: boolean): string =>
const seedLimitation = (syntheticSeed: boolean): string =>
syntheticSeed
? "The seed was made for the task, not taken from a user's own data; read it before publishing the bundle."
: "The seed is the user's real state at the seed checkpoint; keep the bundle local.";
+11 -30
View File
@@ -30,33 +30,6 @@ export type ReaderFileBytes =
| { readonly kind: "error"; readonly error: string }
| { readonly kind: "bytes"; readonly bytes: Buffer };
/**
* Each reader's view of its file by reader id, read by REA's reader runner;
* an absent file has none. `pathOf` names bytes to read in place of the
* home's file. A runner that does not answer throws.
*/
export const readTaskReaderViews = async (
readers: readonly DesktopTaskReader[],
run: ReaderCommandRun,
pathOf: (reader: DesktopTaskReader) => string | undefined = () => undefined,
work?: string,
): Promise<ReadonlyMap<string, TaskFileView>> => {
const views = new Map<string, TaskFileView>();
for (const reader of readers) {
const result = await run(
readerCommand(reader, pathOf(reader), work),
readerTimeout(reader) + READER_RUNNER_SLACK_S,
);
if (result.exit_code !== 0 || result.timed_out)
throw new Error(
`REA's reader runner for ${reader.id} ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`}: ${result.stderr.slice(-1024) || result.stdout.slice(-1024)}`,
);
const view = readerAnswerView(result.stdout);
if (view !== undefined) views.set(reader.id, view);
}
return views;
};
/** Recover only a complete, valid answer emitted before later finalization failed. */
const completedReaderAnswer = (
cause: unknown,
@@ -112,9 +85,17 @@ export const readerViewsOfBytes = async (
const path = join(folder, reader.file.split("/").at(-1) ?? "file");
await writeFile(path, file.bytes);
// The runner works inside this folder, so removing it cleans up after a runner that was killed.
const read = await readTaskReaderViews([reader], run, () => path, folder);
for (const [id, view] of read) views.set(id, view);
if (!read.has(reader.id)) absent.push(reader.id);
const result = await run(
readerCommand(reader, path, folder),
readerTimeout(reader) + READER_RUNNER_SLACK_S,
);
if (result.exit_code !== 0 || result.timed_out)
throw new Error(
`REA's reader runner for ${reader.id} ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`}: ${result.stderr.slice(-1024) || result.stdout.slice(-1024)}`,
);
const view = readerAnswerView(result.stdout);
if (view === undefined) absent.push(reader.id);
else views.set(reader.id, view);
} catch (cause: unknown) {
failure = { cause };
const completed = completedReaderAnswer(cause);
@@ -26,7 +26,7 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { writeJsonOutput } from "../../cli/jsonOutput.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { summarizeEnvironmentCoverage } from "../../domain/analysisView/environmentCoverageView.js";
import type { Evidence } from "../../domain/evidence.js";
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
@@ -26,11 +26,14 @@ const withTools = (tools: readonly EnvironmentManifestTool[]) => ({
manifest: { ...source.manifest, tools: [...tools] },
});
const environmentToolInputSchema = (tool: EnvironmentManifestTool) => {
const [definition] = environmentToolDefinitions(withTools([tool]));
const [definition] = environmentToolDefinitions(
withTools([tool]),
source.manifest.name,
);
if (definition === undefined) throw new Error("Missing tool definition");
return definition.inputSchema;
};
const definitions = environmentToolDefinitions(source);
const definitions = environmentToolDefinitions(source, source.manifest.name);
const tool = (name: string): EnvironmentManifestTool => {
const found = source.manifest.tools.find((entry) => entry.name === name);
@@ -47,9 +50,9 @@ describe("environmentToolDefinitions", () => {
expect(definition.description).toContain(
"state.source, not replay.exact_match, identifies state vs recording",
);
expect(environmentToolDefinitions(source)[0]?.description).not.toContain(
"state.source",
);
expect(
environmentToolDefinitions(source, source.manifest.name)[0]?.description,
).not.toContain("state.source");
});
it("maps every manifest tool in order with a replay description naming its records and evidence", () => {
@@ -154,10 +157,13 @@ const usersAnswering = (
record_ordinals: [...exchanges.keys()],
statuses: [...statuses],
};
const [definition] = environmentToolDefinitions({
...withTools([users]),
exchanges,
});
const [definition] = environmentToolDefinitions(
{
...withTools([users]),
exchanges,
},
source.manifest.name,
);
return definition?.description;
};
const returnsOf = (bodyText: string): string | undefined =>
@@ -314,28 +320,31 @@ describe("environment tool response summaries", () => {
const users = tool("get_users_by_id");
const ordinal = users.record_ordinals[0] ?? 0;
const catalog = (contentType: string) =>
prepareDefinitions({
...withTools(
Array.from({ length: 200 }, (_, index) => ({
...users,
name: `get_users_${String(index)}`,
record_ordinals: [ordinal],
})),
),
exchanges: new Map([
[
ordinal,
{
prepareDefinitions(
{
...withTools(
Array.from({ length: 200 }, (_, index) => ({
...users,
name: `get_users_${String(index)}`,
record_ordinals: [ordinal],
})),
),
exchanges: new Map([
[
ordinal,
path: "/api/users/2",
query: null,
status: 200,
contentType,
bodyText: JSON.stringify(fields),
},
],
]),
});
{
ordinal,
path: "/api/users/2",
query: null,
status: 200,
contentType,
bodyText: JSON.stringify(fields),
},
],
]),
},
source.manifest.name,
);
expect(catalog("text/html").ok).toBe(true);
const bounded = catalog("application/json");
expect(bounded).toMatchObject({
@@ -346,18 +355,21 @@ describe("environment tool response summaries", () => {
reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES },
},
});
const [single] = environmentToolDefinitions({
...withTools([users]),
exchanges: new Map([
[
ordinal,
{
...source.exchanges.get(ordinal)!,
bodyText: JSON.stringify(fields),
},
],
]),
});
const [single] = environmentToolDefinitions(
{
...withTools([users]),
exchanges: new Map([
[
ordinal,
{
...source.exchanges.get(ordinal)!,
bodyText: JSON.stringify(fields),
},
],
]),
},
source.manifest.name,
);
expect(Buffer.byteLength(single?.description ?? "")).toBeLessThan(1700);
expect(single?.description).toContain(
"/300 shown; full list in capture Evidence)",
@@ -374,7 +386,10 @@ describe("environment catalog description budgets", () => {
path_template: `/${"😀".repeat(1000)}`,
query_parameter_names: [query],
};
const [definition] = environmentToolDefinitions(withTools([expanded]));
const [definition] = environmentToolDefinitions(
withTools([expanded]),
source.manifest.name,
);
expect(definition?.tool).toBe(expanded);
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(3500);
expect(definition?.description).toContain(
@@ -406,7 +421,10 @@ describe("environment catalog description budgets", () => {
})),
record_ordinals: ordinals,
};
const [definition] = environmentToolDefinitions(withTools([expanded]));
const [definition] = environmentToolDefinitions(
withTools([expanded]),
source.manifest.name,
);
expect(definition?.tool).toBe(expanded);
expect(expanded.params[0]?.observed_values).toBe(observations);
expect(expanded.record_ordinals).toBe(ordinals);
@@ -431,7 +449,10 @@ describe("environment catalog description budgets", () => {
...users,
path_template: `/api/${"segment/".repeat(200_000)}{id}`,
};
const [definition] = environmentToolDefinitions(withTools([long]));
const [definition] = environmentToolDefinitions(
withTools([long]),
source.manifest.name,
);
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(2048);
expect(definition?.description).toContain(
"path display truncated; full value in manifest Evidence",
@@ -595,6 +616,7 @@ describe("environment tool annotations", () => {
method,
})),
),
source.manifest.name,
).map((definition) => definition.annotations);
expect(hints).toEqual([
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
@@ -432,7 +432,7 @@ const catalogBody = (
/** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */
export const environmentToolDefinitions = (
source: EnvironmentSource,
name: string = source.manifest.name,
name: string,
{
operation = ENVIRONMENT_SERVE_OPERATION,
dataPlane = "none",
@@ -5,7 +5,7 @@ import {
} from "./JavaScriptArtifactAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { err, ok } from "../../domain/result.js";
@@ -1,7 +1,7 @@
import { expect, it } from "vitest";
import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import type {
JavaScriptArtifactFile,
JavaScriptUnanalyzedArtifactFile,
@@ -5,7 +5,7 @@ import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { err, ok } from "../../domain/result.js";
@@ -8,7 +8,7 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "../../domain/javascript/javascriptStaticAnalysis.js";
} from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
import type {
AnalyzedJavaScriptArtifactFile,
@@ -8,7 +8,7 @@ import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
const completeInventoryCoverage = {
scope: "container-entries",
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { projectJavaScriptSemanticFileSteps } from "./JavaScriptSemanticGraphBuilder.js";
@@ -8,6 +8,12 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok } from "../../domain/result.js";
/** Expected user-facing unknown reasons, independent of the trace producer. */
export const EXPECTED_CLEANUP_STOPPED_TRACE =
"Trace stopped because the provider did not confirm cleanup of resources it owns";
export const EXPECTED_UNRECORDED_VALUE_FLOW =
"Native def-use was not recorded for this procedure.";
/** Simulated provider seam for value traces; not a real decompiler observation. */
export const valueTraceSubject = {
path: "/fixture",
@@ -1,9 +1,6 @@
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import { describe, expect, it } from "vitest";
import {
traceNativeValues as traceNativeValuesCore,
UNRECORDED_NATIVE_VALUE_FLOW,
} from "./NativeValueTrace.js";
import { traceNativeValues as traceNativeValuesCore } from "./NativeValueTrace.js";
import { executeComposedWorkflow } from "../WorkflowEvidence.js";
import {
createAnalysisExecution,
@@ -22,6 +19,7 @@ import {
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
import { ok, err } from "../../domain/result.js";
import {
EXPECTED_UNRECORDED_VALUE_FLOW,
nativeFlowDossier,
pcodeOperation as node,
pcodeScalar as scalar,
@@ -196,7 +194,7 @@ describe("native value trace seed admission", () => {
{
procedure: base.procedure.address,
address: null,
reason: UNRECORDED_NATIVE_VALUE_FLOW,
reason: EXPECTED_UNRECORDED_VALUE_FLOW,
},
],
});
+2 -2
View File
@@ -29,11 +29,11 @@ import {
import { createValueTraceBudget } from "./NativeValueTraceBudget.js";
/** The located unknown where unconfirmed provider cleanup stopped a trace. */
export const CLEANUP_STOPPED_VALUE_TRACE =
const CLEANUP_STOPPED_VALUE_TRACE =
"Trace stopped because the provider did not confirm cleanup of resources it owns";
/** Fallback when a dossier has no native value-flow facet for this procedure. */
export const UNRECORDED_NATIVE_VALUE_FLOW =
const UNRECORDED_NATIVE_VALUE_FLOW =
"Native def-use was not recorded for this procedure.";
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
@@ -6,11 +6,9 @@ import {
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js";
import { traceNativeValues } from "./NativeValueTrace.js";
import {
CLEANUP_STOPPED_VALUE_TRACE,
traceNativeValues,
} from "./NativeValueTrace.js";
import {
EXPECTED_CLEANUP_STOPPED_TRACE,
failingValueTraceReads,
valueTraceDiamond,
valueTraceProvider as provider,
@@ -149,7 +147,7 @@ describe("native value trace child stops", () => {
{
procedure: "0x1000",
address: "0x1008",
reason: CLEANUP_STOPPED_VALUE_TRACE,
reason: EXPECTED_CLEANUP_STOPPED_TRACE,
},
],
);
@@ -34,7 +34,6 @@ export const compareProcessEvidence = (
lookup?: EvidenceLookup,
options: {
readonly signal?: AbortSignal;
readonly now?: () => number;
} = {},
): Result<ProcessComparisonExecution, AnalysisError> => {
const selected = resolveEvidenceRequest(
@@ -57,7 +56,6 @@ export const compareProcessEvidence = (
...(request.trace_spec === undefined
? {}
: { traceSpecification: request.trace_spec }),
...(options.now === undefined ? {} : { now: options.now }),
});
};
+3 -3
View File
@@ -22,9 +22,9 @@ import type { ArtifactEntry } from "./ArtifactReader.js";
import { artifactFormatDescriptor } from "./artifactFormats.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
import { NODE_SEA_MAGIC } from "./nodeSea/NodeSeaBlob.js";
import {
NODE_SEA_FUSE_HASH,
NODE_SEA_FIXTURE_MAGIC,
buildNodeSeaBlob,
nodeSeaElf,
nodeSeaMachO,
@@ -206,7 +206,7 @@ it.each([
expect(root).toMatchObject({ artifact_kind: "container" });
expect(root?.format_metadata).toMatchObject({
kind: "node-sea",
magic: NODE_SEA_MAGIC,
magic: NODE_SEA_FIXTURE_MAGIC,
carrier,
code_path: "app/main.js",
disable_experimental_sea_warning: true,
@@ -503,7 +503,7 @@ it("fails closed when the located blob is truncated, has a bad magic, or unknown
message: expect.stringMatching(/magic.*not.*0x143da20/iu),
});
const unknown = Buffer.alloc(9);
unknown.writeUInt32LE(NODE_SEA_MAGIC, 0);
unknown.writeUInt32LE(NODE_SEA_FIXTURE_MAGIC, 0);
unknown.writeUInt32LE(1 << 5, 4);
unknown[8] = 1;
await expect(
+1 -1
View File
@@ -2551,7 +2551,7 @@ const embeddedPackageFormat = async (
nativeFormat: NativeFormat,
state: ScanState,
context: ScanContext,
location = filePath,
location: string,
expectedSha256?: string,
): Promise<EmbeddedPackageProbe> => {
try {
@@ -1014,8 +1014,8 @@ const isInterfaceBuilderArchive = (path: string): boolean => {
const readEntry = async (
reader: DirectoryArtifactReader,
entry: ArtifactEntry,
signal?: AbortSignal,
maxInputBytes = MAX_AGGREGATE_INPUT_BYTES,
signal: AbortSignal | undefined,
maxInputBytes: number,
): Promise<Buffer> => {
if (entry.declaredSize !== null && entry.declaredSize > maxInputBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
+49 -14
View File
@@ -1,6 +1,34 @@
import { describe, expect, it } from "vitest";
import { projectNibViewHierarchy } from "./NibViewHierarchy.js";
import { InterfaceBuilderDecodeBudget } from "./InterfaceBuilderDecodeBudget.js";
// Use the same budget and diagnostic accounting as Interface Builder's NIB owner.
const projectFixture = (
objects: Parameters<typeof projectNibViewHierarchy>[0],
retainedIds: ReadonlySet<number>,
expectedDiagnostics: readonly string[] = [],
maximumBytes = 4_000_000,
) => {
const budget = new InterfaceBuilderDecodeBudget(maximumBytes);
const diagnostics: string[] = [];
const projection = projectNibViewHierarchy(
objects,
retainedIds,
() => budget.tryReserve(128),
20_000,
(description) => {
budget.reserve(
256,
"NIB registry hierarchy diagnostics exceed the aggregate Interface Builder decode budget",
);
budget.reserveJsonStringOccurrence(description);
diagnostics.push(description);
},
);
expect(diagnostics).toEqual(expectedDiagnostics);
return projection;
};
const object = (id: number, parent?: number) => ({
id,
@@ -11,7 +39,7 @@ const object = (id: number, parent?: number) => ({
describe("serialized NIB view hierarchy bounds", () => {
it("does not invent hierarchy for objects without serialized relationships", () => {
expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({
expect(projectFixture([object(0)], new Set([0]))).toEqual({
hierarchy: null,
omitted: 0,
});
@@ -19,7 +47,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("uses the serialized parent direction and retains all sibling links", () => {
expect(
projectNibViewHierarchy(
projectFixture(
[object(0), object(1, 0), object(2, 0)],
new Set([0, 1, 2]),
),
@@ -38,7 +66,7 @@ describe("serialized NIB view hierarchy bounds", () => {
});
it("reports missing parents without inventing parentage", () => {
expect(projectNibViewHierarchy([object(0, 99)], new Set([0]))).toEqual({
expect(projectFixture([object(0, 99)], new Set([0]))).toEqual({
hierarchy: [],
omitted: 1,
});
@@ -46,7 +74,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("reports a missing encoded window content view as incomplete", () => {
expect(
projectNibViewHierarchy(
projectFixture(
[
{
id: 0,
@@ -61,7 +89,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("marks unsupported serialized parent values as incomplete", () => {
expect(
projectNibViewHierarchy(
projectFixture(
[{ id: 0, class_name: "NSView", values: { NSSuperview: "unknown" } }],
new Set([0]),
),
@@ -70,7 +98,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("reports cyclic and self-referential parentage as incomplete", () => {
expect(
projectNibViewHierarchy(
projectFixture(
[object(0, 1), object(1, 0), object(2, 2)],
new Set([0, 1, 2]),
),
@@ -81,9 +109,7 @@ describe("serialized NIB view hierarchy bounds", () => {
});
it("does not project children whose parent was not retained", () => {
expect(
projectNibViewHierarchy([object(0), object(1, 0)], new Set([1])),
).toEqual({
expect(projectFixture([object(0), object(1, 0)], new Set([1]))).toEqual({
hierarchy: [],
omitted: 1,
});
@@ -94,8 +120,7 @@ describe("serialized NIB view hierarchy bounds", () => {
object(id, id === 0 ? undefined : id - 1),
);
expect(
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
.omitted,
projectFixture(objects, new Set(objects.map(({ id }) => id))).omitted,
).toBe(3);
});
@@ -104,14 +129,21 @@ describe("serialized NIB view hierarchy bounds", () => {
object(id, id === 0 ? undefined : 0),
);
expect(
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
.omitted,
projectFixture(objects, new Set(objects.map(({ id }) => id))).omitted,
).toBe(2);
});
it("reports nodes the shared representation budget cannot reserve", () => {
const objects = [object(0), object(1, 0)];
expect(projectFixture(objects, new Set([0, 1]), [], 128)).toEqual({
hierarchy: [{ objectID: "0", children: [] }],
omitted: 1,
});
});
});
it("does not treat a dangling registry owner as top-level authority", () => {
const result = projectNibViewHierarchy(
const result = projectFixture(
[
{
id: 0,
@@ -135,6 +167,9 @@ it("does not treat a dangling registry owner as top-level authority", () => {
},
],
new Set([1]),
[
"NSIBObjectData object 0: NSObjectsKeys/NSObjectsValues pair child 1 with registry value 99; NSRoot is 99 (unresolved). This decoded registry association does not establish visual parentage.",
],
);
expect(result.hierarchy).toEqual([]);
expect(result.omitted).toBeGreaterThan(0);
+3 -3
View File
@@ -56,9 +56,9 @@ const isNonViewCell = (
export const projectNibViewHierarchy = (
objects: NibArchiveDocument["objects"],
retainedIds: ReadonlySet<number>,
reserveNode: () => boolean = () => true,
maxNodes = 20_000,
reportUnknownRegistry: (description: string) => void = () => {},
reserveNode: () => boolean,
maxNodes: number,
reportUnknownRegistry: (description: string) => void,
) => {
const byId = new Map(objects.map((object) => [object.id, object]));
const parents = new Map<number, number | null>();
@@ -74,13 +74,10 @@ const XML_REFERENCE_BYTES = 64;
const estimateXmlPlistExpansion = (
bytes: Buffer,
maxBytes: number,
xmlText?: string,
numberProjection = false,
budget = "the property list decode budget",
fail: (
kind: BinaryPlistExpansionFailure,
message: string,
) => Error = propertyListDecodeBudgetFailure,
xmlText: string | undefined,
numberProjection: boolean,
budget: string,
fail: (kind: BinaryPlistExpansionFailure, message: string) => Error,
): number => {
const xml = xmlText ?? decodeXmlPlistText(bytes);
// The decoded text and the DOM's copies of its character data.
+10 -5
View File
@@ -5,10 +5,7 @@ import {
ZIP_PACKAGE_FORMATS,
type ZipPackageFormat,
} from "../domain/zipPackageFormat.js";
import {
ARTIFACT_FORMATS,
ZIP_FAMILY_CAPABILITIES,
} from "./artifactFormats.js";
import { ARTIFACT_FORMATS } from "./artifactFormats.js";
import {
createArtifactReader,
type ArtifactReaderFormat,
@@ -76,7 +73,15 @@ describe("artifact format descriptors", () => {
it("shares one capability row across zip package families", () => {
for (const id of ZIP_PACKAGE_FORMATS)
expect(ARTIFACT_FORMATS[id]).toMatchObject(ZIP_FAMILY_CAPABILITIES);
expect(ARTIFACT_FORMATS[id]).toMatchObject({
zipFamily: true,
occurrenceContainer: true,
roleContainer: false,
retainClassifiedSource: true,
extractable: true,
requiresDarwin: false,
absentInventory: "unavailable",
});
const zipIds = new Set<ZipPackageFormat>(ZIP_PACKAGE_FORMATS);
for (const id of ARTIFACT_FORMAT_IDS)
if (!zipIds.has(id as ZipPackageFormat))
+1 -1
View File
@@ -134,7 +134,7 @@ function admittedAs(
}
/** One capability row for every ZIP-backed package family. */
export const ZIP_FAMILY_CAPABILITIES = {
const ZIP_FAMILY_CAPABILITIES = {
zipFamily: true,
occurrenceContainer: true,
roleContainer: false,
+7 -5
View File
@@ -6,13 +6,11 @@ import { open, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
probeBunStandaloneGraph,
BUN_GRAPH_SUFFIX_BYTES,
} from "./BunModuleGraphLayout.js";
import { probeBunStandaloneGraph } from "./BunModuleGraphLayout.js";
import { decodeBunModuleGraph } from "./BunModuleGraphMembers.js";
import {
locateBunGraphFixture,
BUN_FIXTURE_GRAPH_SUFFIX_BYTES,
bunModuleGraphPayload,
bunPrefixedPayload,
machoBunSection,
@@ -167,7 +165,11 @@ it.each(["trailer", "byte_count"])(
modules: [{ name: "/$bunfs/root/main.js" }],
});
if (field === "trailer") payload[payload.length - 1] = 0;
else payload.writeBigUInt64LE(0n, payload.length - BUN_GRAPH_SUFFIX_BYTES);
else
payload.writeBigUInt64LE(
0n,
payload.length - BUN_FIXTURE_GRAPH_SUFFIX_BYTES,
);
const prefix = Buffer.alloc(4);
prefix.writeUInt32LE(payload.length);
expect(
+4 -4
View File
@@ -17,10 +17,10 @@ import { abortIfNeeded } from "../ArtifactHash.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
/** Exact trailer written by Bun's standalone module graph serializer. */
export const BUN_TRAILER = Buffer.from("\n---- Bun! ----\n");
export const BUN_TRAILER_BYTES = BUN_TRAILER.length;
export const BUN_OFFSETS_BYTES = 32;
export const BUN_GRAPH_SUFFIX_BYTES = BUN_OFFSETS_BYTES + BUN_TRAILER_BYTES;
const BUN_TRAILER = Buffer.from("\n---- Bun! ----\n");
const BUN_TRAILER_BYTES = BUN_TRAILER.length;
const BUN_OFFSETS_BYTES = 32;
const BUN_GRAPH_SUFFIX_BYTES = BUN_OFFSETS_BYTES + BUN_TRAILER_BYTES;
/** Current compilers prefix a section with this little-endian payload length. */
export const BUN_LENGTH_PREFIX_BYTES = 8;
/** Bun 1.3.3 through 1.3.8 module records. */
+14 -9
View File
@@ -1,9 +1,5 @@
import type { FileHandle } from "node:fs/promises";
import {
BUN_OFFSETS_BYTES,
BUN_RECORD_BYTES_1_3_3,
BUN_RECORD_BYTES_1_3_9,
BUN_TRAILER,
loadBunStandaloneGraph,
probeBunStandaloneGraph,
type BunLayoutRevision,
@@ -11,6 +7,15 @@ import {
type BunStringPointer,
} from "./BunModuleGraphLayout.js";
// https://github.com/oven-sh/bun/blob/bun-v1.3.9/src/StandaloneModuleGraph.zig
// and the 1.3.3 serializer define the two supported layouts.
const OFFSETS_BYTES = 32;
const RECORD_BYTES_1_3_3 = 36;
const RECORD_BYTES_1_3_9 = 52;
export const BUN_FIXTURE_TRAILER = Buffer.from("\n---- Bun! ----\n");
export const BUN_FIXTURE_GRAPH_SUFFIX_BYTES =
OFFSETS_BYTES + BUN_FIXTURE_TRAILER.length;
/** Exercise the same two-stage probe and payload read as the artifact reader. */
export const locateBunGraphFixture = async (
handle: FileHandle,
@@ -46,7 +51,7 @@ export const bunModuleGraphPayload = (input: {
}): Buffer => {
const revision = input.revision ?? "bun-1.3.9";
const recordBytes =
revision === "bun-1.3.3" ? BUN_RECORD_BYTES_1_3_3 : BUN_RECORD_BYTES_1_3_9;
revision === "bun-1.3.3" ? RECORD_BYTES_1_3_3 : RECORD_BYTES_1_3_9;
const modules = input.modules ?? [];
const chunks: Buffer[] = [];
let cursor = 0;
@@ -87,8 +92,8 @@ export const bunModuleGraphPayload = (input: {
writePointer(record, 8, contents);
writePointer(record, 16, row.sourcemap);
writePointer(record, 24, row.bytecode);
const metadataAt = recordBytes === BUN_RECORD_BYTES_1_3_9 ? 48 : 32;
if (recordBytes === BUN_RECORD_BYTES_1_3_9) {
const metadataAt = recordBytes === RECORD_BYTES_1_3_9 ? 48 : 32;
if (recordBytes === RECORD_BYTES_1_3_9) {
writePointer(record, 32, row.moduleInfo);
writePointer(record, 40, row.origin);
}
@@ -100,13 +105,13 @@ export const bunModuleGraphPayload = (input: {
chunks.push(records.subarray(0, tableLength));
cursor += tableLength;
const data = Buffer.concat(chunks);
const offsets = Buffer.alloc(BUN_OFFSETS_BYTES);
const offsets = Buffer.alloc(OFFSETS_BYTES);
offsets.writeBigUInt64LE(BigInt(data.length), 0);
writePointer(offsets, 8, { offset: tableOffset, length: tableLength });
offsets.writeUInt32LE(input.entryPointId ?? 0, 16);
writePointer(offsets, 20, argv);
offsets.writeUInt32LE(input.flags ?? 0, 28);
return Buffer.concat([data, offsets, BUN_TRAILER]);
return Buffer.concat([data, offsets, BUN_FIXTURE_TRAILER]);
};
/** Length prefix used by current Mach-O, PE, and ELF section writers. */
+13 -5
View File
@@ -1,18 +1,26 @@
import { expect, it } from "vitest";
import {
DENO_SECTION_MAGIC,
MAX_DENO_METADATA_JSON_BYTES,
MAX_DENO_TABLE_COUNT,
parseDenoSectionBytes,
parseDenoSection,
} from "./DenoSectionLayout.js";
import {
buildDenoSection,
defaultDenoMetadata,
DENO_FIXTURE_SECTION_MAGIC,
} from "./DenoStandalone.fixture.js";
const secret = "super-secret-value";
const parseDenoSectionBytes = (bytes: Buffer, signal?: AbortSignal) =>
parseDenoSection(
bytes.length,
(offset, length) =>
Promise.resolve(bytes.subarray(offset, offset + length)),
signal,
);
it("parses metadata, tables, remote regions, and embedded configuration", async () => {
const original = Buffer.from("export const value = 1;\n");
const transpiled = Buffer.from("transpiled\n");
@@ -123,9 +131,9 @@ it("treats a missing opening marker as an unsupported layout", async () => {
it("refuses metadata JSON above the named allowance before reading it as JSON", async () => {
const claimed = MAX_DENO_METADATA_JSON_BYTES + 1;
const bytes = Buffer.alloc(DENO_SECTION_MAGIC.length + 8 + claimed);
DENO_SECTION_MAGIC.copy(bytes, 0);
bytes.writeBigUInt64LE(BigInt(claimed), DENO_SECTION_MAGIC.length);
const bytes = Buffer.alloc(DENO_FIXTURE_SECTION_MAGIC.length + 8 + claimed);
DENO_FIXTURE_SECTION_MAGIC.copy(bytes, 0);
bytes.writeBigUInt64LE(BigInt(claimed), DENO_FIXTURE_SECTION_MAGIC.length);
await expect(parseDenoSectionBytes(bytes)).rejects.toMatchObject({
reason: "limit",
message: expect.stringContaining("metadata JSON"),
-12
View File
@@ -366,18 +366,6 @@ export const parseDenoSection = async (
};
};
/** Parse a section that is already in memory. Tests and small fixtures use this. */
export const parseDenoSectionBytes = (
bytes: Buffer,
signal?: AbortSignal,
): Promise<DenoParsedSection> =>
parseDenoSection(
bytes.length,
(offset, length) =>
Promise.resolve(bytes.subarray(offset, offset + length)),
signal,
);
const addRemoteBody = (total: bigint, length: number): bigint => {
const next = total + BigInt(length);
if (next > BigInt(MAX_DENO_REMOTE_BODY_BYTES))
+5 -3
View File
@@ -1,7 +1,9 @@
import { elfNote, elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js";
import { peResourceFixture } from "../../native/pe/PeResources.fixture.js";
import { DENO_SECTION_MAGIC } from "./DenoSectionLayout.js";
// https://github.com/denoland/deno/blob/v2.9.8/cli/lib/standalone/binary.rs
// declares this section marker.
export const DENO_FIXTURE_SECTION_MAGIC = Buffer.from("d3n0l4nd", "latin1");
export interface DenoSpecifierFixture {
readonly text?: string;
@@ -93,7 +95,7 @@ export const buildDenoSection = (options: DenoSectionFixture = {}): Buffer => {
const vfsBytes = Buffer.from(JSON.stringify(vfs), "utf8");
const parts: Buffer[] = [];
if (options.openingMagic !== null)
parts.push(options.openingMagic ?? DENO_SECTION_MAGIC);
parts.push(options.openingMagic ?? DENO_FIXTURE_SECTION_MAGIC);
parts.push(
u64(options.metadataLength ?? metadataBytes.length),
metadataBytes,
@@ -138,7 +140,7 @@ export const buildDenoSection = (options: DenoSectionFixture = {}): Buffer => {
}
parts.push(u64(vfsBytes.length), vfsBytes, u64(contents.length), contents);
if (options.closingMagic !== null)
parts.push(options.closingMagic ?? DENO_SECTION_MAGIC);
parts.push(options.closingMagic ?? DENO_FIXTURE_SECTION_MAGIC);
if (options.trailing !== undefined) parts.push(options.trailing);
return Buffer.concat(parts);
};
@@ -19,13 +19,13 @@ import { artifactFormatDescriptor } from "../artifactFormats.js";
import { ArtifactPathRegistry } from "../ArtifactPaths.js";
import { DenoStandaloneArtifactReader } from "../DenoStandaloneArtifactReader.js";
import { cArchiveExecutable } from "../pyinstaller/CArchive.fixture.js";
import { DENO_SECTION_MAGIC } from "./DenoSectionLayout.js";
import {
locateDenoStandalone,
MAX_DENO_INTEL_TRAILER_SENTINELS,
} from "./DenoStandaloneLocation.js";
import {
buildDenoSection,
DENO_FIXTURE_SECTION_MAGIC,
defaultDenoMetadata,
denoElf,
denoIntelTrailer,
@@ -249,7 +249,7 @@ it("fails closed when a named section is truncated or lacks the opening marker",
await expect(
inventoryOf(
"truncated",
denoMachO(Buffer.concat([DENO_SECTION_MAGIC, Buffer.alloc(4)])),
denoMachO(Buffer.concat([DENO_FIXTURE_SECTION_MAGIC, Buffer.alloc(4)])),
),
).rejects.toMatchObject({ reason: "format" });
await expect(
@@ -560,7 +560,7 @@ export const classifyArtifactPath = (
export const classifyArtifactContent = (
path: string,
evidence: ArtifactClassificationEvidence,
fileSize = evidence.prefix.length,
fileSize: number,
): {
readonly kind: ArtifactOccurrence["artifact_kind"];
readonly format: ArtifactOccurrence["artifact_format"];
@@ -620,7 +620,7 @@ const artifactRoleForFormat = (
/** Classify storage bytes independently of path names, roles, and permissions. */
export const classifyArtifactBytes = (
evidence: ArtifactClassificationEvidence,
fileSize = evidence.prefix.length,
fileSize: number,
): ArtifactNode["format"] => {
const { prefix, suffix, mzWindowsHeaderSignature } = evidence;
// A header-only region has the signature but not the declared cartridge
+3 -2
View File
@@ -1,7 +1,8 @@
import { elfNote, elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js";
import { peResourceFixture } from "../../native/pe/PeResources.fixture.js";
import { NODE_SEA_MAGIC } from "./NodeSeaBlob.js";
/** Node 24 SEA blob magic: https://github.com/nodejs/node/blob/v24.12.0/src/node_sea.h */
export const NODE_SEA_FIXTURE_MAGIC = 0x143da20;
/** Official Node `NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2` hash. */
export const NODE_SEA_FUSE_HASH = "fce680ab2cc467b6e072b8b5df1996b2";
@@ -62,7 +63,7 @@ export const buildNodeSeaBlob = (
if (assets.length > 0) flags |= 1 << 3;
if (execArgv.length > 0) flags |= 1 << 4;
const header = Buffer.alloc(9);
header.writeUInt32LE(NODE_SEA_MAGIC, 0);
header.writeUInt32LE(NODE_SEA_FIXTURE_MAGIC, 0);
header.writeUInt32LE(flags, 4);
header[8] = options.execArgvExtension ?? 1;
const parts: Buffer[] = [header, sized(codePath), sized(body)];
+3 -7
View File
@@ -3,16 +3,12 @@ import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { DEFAULT_NODE_SEA_READER_LIMITS } from "../../domain/artifactReaderLimits.js";
import {
NODE_SEA_MAGIC,
decodeNodeSeaBlob,
observeNodeSeaFuseMatches,
} from "./NodeSeaBlob.js";
import { buildNodeSeaBlob } from "./NodeSea.fixture.js";
import { decodeNodeSeaBlob, observeNodeSeaFuseMatches } from "./NodeSeaBlob.js";
import { buildNodeSeaBlob, NODE_SEA_FIXTURE_MAGIC } from "./NodeSea.fixture.js";
const header = (flags: number, extension = 1): Buffer => {
const bytes = Buffer.alloc(9);
bytes.writeUInt32LE(NODE_SEA_MAGIC, 0);
bytes.writeUInt32LE(NODE_SEA_FIXTURE_MAGIC, 0);
bytes.writeUInt32LE(flags, 4);
bytes[8] = extension;
return bytes;
+1 -1
View File
@@ -9,7 +9,7 @@ import {
import { artifactReaderLimitFailure } from "../ArtifactReaderLimits.js";
/** Node `SeaBlob::kMagic` from `src/node_sea.h` (v24). */
export const NODE_SEA_MAGIC = 0x143da20;
const NODE_SEA_MAGIC = 0x143da20;
const FLAG_DISABLE_EXPERIMENTAL_SEA_WARNING = 1 << 0;
const FLAG_USE_SNAPSHOT = 1 << 1;
@@ -6,7 +6,7 @@ import {
} from "../../python/PythonPyc.fixture.js";
// PyInstaller CArchiveWriter's on-disk cookie signature, independent of REA's reader.
const CARCHIVE_COOKIE_MAGIC = Buffer.from("4d45490c0b0a0b0e", "hex");
export const CARCHIVE_COOKIE_MAGIC = Buffer.from("4d45490c0b0a0b0e", "hex");
/** Final CPython 3.9 bytecode magic, little-endian with the CR LF trailer. */
export const PYTHON_39_MAGIC = Buffer.from("610d0d0a", "hex");
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "./PyInstallerFailure.js";
/** Magic at the start of a CArchive cookie. */
export const PYINSTALLER_COOKIE_MAGIC = Buffer.from("MEI\x0c\x0b\x0a\x0b\x0e");
const PYINSTALLER_COOKIE_MAGIC = Buffer.from("MEI\x0c\x0b\x0a\x0b\x0e");
/** PyInstaller 2.1+ cookie size, including the 64-byte library name. */
export const PYINSTALLER_COOKIE_V21_BYTES = 88;
@@ -9,7 +9,10 @@ import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { inventoryArtifact } from "../inventory/ArtifactInventory.js";
import { classifyAndHashRootForInventory } from "../inventory/classify.js";
import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
import { cArchiveExecutable } from "./CArchive.fixture.js";
import {
CARCHIVE_COOKIE_MAGIC,
cArchiveExecutable,
} from "./CArchive.fixture.js";
import * as layout from "./CArchiveLayout.js";
const fixture = () =>
@@ -248,7 +251,7 @@ it("retains rejected magic candidates without classifying their native carrier a
path,
Buffer.concat([
fixture().subarray(0, 64),
layout.PYINSTALLER_COOKIE_MAGIC,
CARCHIVE_COOKIE_MAGIC,
Buffer.alloc(88),
]),
);
@@ -288,11 +291,7 @@ it("continues past an invalid trailing magic copy to inventory the valid cookie"
const path = join(root, "app");
await writeFile(
path,
Buffer.concat([
fixture(),
layout.PYINSTALLER_COOKIE_MAGIC,
Buffer.alloc(88),
]),
Buffer.concat([fixture(), CARCHIVE_COOKIE_MAGIC, Buffer.alloc(88)]),
);
const resourceScope = new ArtifactResourceScope();
try {
+3 -3
View File
@@ -614,8 +614,8 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal,
commandTimeoutMs: number = DEFAULT_CDP_COMMAND_TIMEOUT_MS,
signal: AbortSignal | undefined,
handshakeTimeoutMs: number,
): Promise<void> =>
await new Promise((resolve, reject) => {
const onOpen = (): void => {
@@ -651,7 +651,7 @@ const waitForOpen = async (
};
const boundedTimeoutMs = Math.min(
MAX_BROWSER_DEADLINE_MS,
commandTimeoutMs,
handshakeTimeoutMs,
);
const timer = setTimeout(() => {
terminateSocket(socket);
+1 -1
View File
@@ -43,7 +43,7 @@ export interface CdpResponseFacts {
readonly encodedDataLength: number | undefined;
readonly headers: unknown;
}
export const readCdpResponseFacts = (
const readCdpResponseFacts = (
reported: unknown,
): CdpResponseFacts | undefined => {
const source = recordValue(reported);
+2 -2
View File
@@ -2,7 +2,7 @@ import { MIMEType } from "node:util";
import { describe, expect, it } from "vitest";
import { safeResponseMetadata as projectResponseMetadata } from "./CdpSafeMetadata.js";
import { readCdpResponseFacts } from "./CdpNetworkEventFacts.js";
import { readCdpResponseReceived } from "./CdpNetworkEventFacts.js";
import type { UnknownRecord } from "./CdpCaptureValues.js";
/** Admit each raw response fixture through the production field owner. */
@@ -12,7 +12,7 @@ const safeResponseMetadata = (
response: UnknownRecord,
allowedOrigins: ReadonlySet<string>,
) => {
const facts = readCdpResponseFacts(response);
const facts = readCdpResponseReceived({ requestId, response }).response;
if (facts === undefined)
throw new Error("Expected a response fixture record");
return projectResponseMetadata(requestId, url, facts, allowedOrigins);
+4 -3
View File
@@ -15,7 +15,6 @@ export interface InspectArgumentsPreflightOptions {
readonly signal?: AbortSignal;
readonly resources?: ArtifactResourceScope;
readonly readFuseImage?: typeof readElectronFuseImage;
readonly resolveFuseTarget?: typeof resolveElectronFuseTarget;
}
const appBundleDirectoryOf = (executablePath: string): string | null => {
@@ -47,13 +46,15 @@ export const assertNodeCliInspectArgumentsSupported = async (
): Promise<void> => {
assertArtifactReadActive(options?.signal);
const reader = options?.readFuseImage ?? readElectronFuseImage;
const resolver = options?.resolveFuseTarget ?? resolveElectronFuseTarget;
try {
let targetPath = executablePath;
const bundleDir = appBundleDirectoryOf(executablePath);
if (bundleDir !== null) {
try {
const resolved = await resolver(bundleDir, options?.signal);
const resolved = await resolveElectronFuseTarget(
bundleDir,
options?.signal,
);
targetPath = resolved.file.path;
} catch (cause: unknown) {
if (options?.signal?.aborted) throw cause;
+36 -14
View File
@@ -191,9 +191,13 @@ it("retains a fetch host that ignores an item deadline until it settles", async
});
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_fetch_timeout_ms: 50,
},
undefined,
{ fetch: async () => fetching, timeoutMs: 50 },
{ fetch: async () => fetching },
);
const result = await pending;
if (result.ok) throw new Error("Expected pending-fetch cleanup");
@@ -365,9 +369,10 @@ it.each([
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_limits: { fetch_bytes: 1 },
},
controller.signal,
{ fetch: async () => new Response(body), maxResponseBytes: 1 },
{ fetch: async () => new Response(body) },
);
try {
await reading;
@@ -534,7 +539,11 @@ it.each([null, false])(
try {
const pending = fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{ ...input, allowed_origins: [new URL(url).origin] },
{
...input,
allowed_origins: [new URL(url).origin],
source_map_fetch_timeout_ms: 10_000,
},
controller.signal,
{
fetch: async (...args) => {
@@ -542,7 +551,6 @@ it.each([null, false])(
delivered();
return response;
},
timeoutMs: 10_000,
},
);
await responseReceived;
@@ -604,9 +612,13 @@ it("keeps an actual stalled HTTP header deadline local to its map", async () =>
try {
const result = await fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{ ...input, allowed_origins: [new URL(url).origin] },
{
...input,
allowed_origins: [new URL(url).origin],
source_map_fetch_timeout_ms: 1000,
},
undefined,
{ fetch, timeoutMs: 1000 },
{ fetch },
);
expect(result).toMatchObject({
ok: true,
@@ -641,9 +653,13 @@ it("keeps an actual stalled HTTP response deadline local to its map", async () =
try {
const result = await fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{ ...input, allowed_origins: [new URL(url).origin] },
{
...input,
allowed_origins: [new URL(url).origin],
source_map_fetch_timeout_ms: 1000,
},
undefined,
{ fetch, timeoutMs: 1000 },
{ fetch },
);
expect(result).toMatchObject({
ok: true,
@@ -674,7 +690,11 @@ it.each([302, 503])(
}).tee();
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_fetch_timeout_ms: 100,
},
undefined,
{
fetch: async () =>
@@ -682,7 +702,6 @@ it.each([302, 503])(
status,
...(status === 302 ? { headers: { location: "/next" } } : {}),
}),
timeoutMs: 100,
},
);
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -719,7 +738,11 @@ it("retains an admitted response when the deadline expires before body reading",
}).tee();
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_fetch_timeout_ms: 50,
},
undefined,
{
fetch: async () => {
@@ -727,7 +750,6 @@ it("retains an admitted response when the deadline expires before body reading",
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150);
return new Response(body);
},
timeoutMs: 50,
},
);
try {
@@ -757,6 +779,7 @@ it("retains an oversized redirect identity's pending response cleanup", async ()
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_limits: { output_bytes: 4000 },
source_map_fetch_timeout_ms: 100,
},
undefined,
{
@@ -764,7 +787,6 @@ it("retains an oversized redirect identity's pending response cleanup", async ()
String(url) === request.fetchUrl
? new Response(null, { status: 302, headers: { location: finalUrl } })
: new Response(body),
timeoutMs: 100,
},
);
try {
+5 -11
View File
@@ -60,10 +60,6 @@ export interface WebSourceMapRequest {
interface SourceMapFetchHost {
readonly fetch: typeof fetch;
/** Fetch-operation deadline; injectable so boundary regressions stay fast. */
readonly timeoutMs?: number;
/** Maximum response bytes retained across this operation. */
readonly maxResponseBytes?: number;
}
// Source maps are normally fetched as a small part of a larger inspection.
@@ -125,12 +121,10 @@ export const fetchWebSourceMaps = async (
const limits = selectedSourceMapLimits(input.source_map_limits);
const items: WebSourceMapItem[] = [];
const operationController = new AbortController();
const fetchTimeoutMs =
host.timeoutMs ??
selectedBrowserDeadlineMs(
input.source_map_fetch_timeout_ms,
SOURCE_MAP_FETCH_TIMEOUT_MS,
);
const fetchTimeoutMs = selectedBrowserDeadlineMs(
input.source_map_fetch_timeout_ms,
SOURCE_MAP_FETCH_TIMEOUT_MS,
);
const fetchDeadlineLimitation =
sourceMapFetchDeadlineLimitation(fetchTimeoutMs);
const timeout = setTimeout(
@@ -695,7 +689,7 @@ const fetchOne = async (
const body = await readBoundedText(
response,
budget,
host.maxResponseBytes ?? budget.decodedRecords.limits.fetchBytes,
budget.decodedRecords.limits.fetchBytes,
signal,
fetchedUrl,
);
@@ -125,7 +125,6 @@ const harness = (
selection: selected,
limits: scenario.capture.limits,
secrets,
readTimeoutMs: 50,
...(signal === undefined ? {} : { signal }),
emit: (event) => {
const sequence = events.length + 1;
@@ -733,7 +732,7 @@ it("starts each queued body's timeout when its read begins", async () => {
const response = new ResponseFixture(request, "unused");
response.read = () =>
new Promise((resolve) => {
setTimeout(() => resolve(Buffer.from("captured")), 40);
setTimeout(() => resolve(Buffer.from("captured")), 4_000);
});
collector.request(request);
collector.response(response);
@@ -741,8 +740,8 @@ it("starts each queued body's timeout when its read begins", async () => {
}
const finishing = collector.finish();
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(4_000);
await vi.advanceTimersByTimeAsync(4_000);
await finishing;
const content = events.filter(
@@ -817,7 +816,7 @@ it("bounds hung completed-body reads and rejects late mutation after finalizatio
collector.response(response);
collector.finished(request);
const finishing = collector.finish();
await vi.advanceTimersByTimeAsync(51);
await vi.advanceTimersByTimeAsync(5_001);
await finishing;
const frozen = JSON.stringify(events);
expect(events).toContainEqual(
@@ -125,8 +125,6 @@ interface NetworkCaptureOptions {
reason: BrowserScenarioEventBudgetReason,
) => void;
readonly signal?: AbortSignal;
/** Production uses 5s; a fixture can supply its own bounded-read clock. */
readonly readTimeoutMs?: number;
}
/** Captures correlated network phases and drains selected content before browser cleanup. */
@@ -156,7 +154,7 @@ export class PlaywrightScenarioNetwork {
constructor(private readonly options: NetworkCaptureOptions) {
this.limits = options.limits;
this.readOptions = {
timeoutMs: options.readTimeoutMs ?? PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS,
timeoutMs: PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS,
maxBodyBytes: this.limits.max_network_body_bytes,
maxBodyAllocationBytes: this.limits.max_network_body_allocation_bytes,
maxHeaderAllocationBytes: this.limits.max_network_header_allocation_bytes,
@@ -0,0 +1,15 @@
import { expect, it } from "vitest";
import { ANALYSIS_SETTINGS } from "../domain/analysisSettingDefinitions.js";
import { parseConfig } from "./parseConfig.js";
import { parseEnvironment } from "./environment.js";
it("admits invalid runtime-owned launch seeds at startup", () => {
for (const definition of Object.values(ANALYSIS_SETTINGS)) {
if (definition.updateMode !== "runtime" || !("environment" in definition))
continue;
const environment = { [definition.environment]: "not-a-number" };
expect(parseEnvironment(environment).ok, definition.environment).toBe(true);
expect(parseConfig(environment).ok, definition.environment).toBe(true);
}
});
-19
View File
@@ -1,7 +1,6 @@
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -95,24 +94,6 @@ export const parseBridgeMaxRequestBytes = (
);
};
/**
* Resolve the ceiling from a composition root's selected environment. An
* invalid setting is refused, not ignored. Callers that already resolved it
* at startup pass that value through instead of reading the environment again.
*/
export const resolveBridgeMaxRequestBytes = (
environment: Readonly<Record<string, string | undefined>>,
): BridgeMaxRequestBytes => {
const override = currentAnalysisSettingOverride("bridge.max_request_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
const parsed = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
/** How to read and change the effective bridge request ceiling. */
export const bridgeRequestLimit = (limit: BridgeMaxRequestBytes) =>
analysisSettingLimit(
+8 -6
View File
@@ -23,7 +23,6 @@ import {
DEBUGGER_MAX_PENDING_COMMANDS_SETTING,
DEBUGGER_MAX_SESSIONS_SETTING,
parseDebuggerSessionLimits,
resolveDebuggerSessionLimits,
} from "./debuggerSessionLimits.js";
it("uses the shared defaults when the settings are unset", () => {
@@ -302,9 +301,12 @@ it("defers an invalid GDB deadline until a command reads it", () => {
code: "configuration_invalid",
details: { settings: parsed.error.settings },
});
expect(
resolveDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500",
}).commandDeadline,
).toMatchObject({ value: 1500, source: "setting" });
const valid = parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500",
});
if (!valid.ok) throw valid.error;
expect(valid.value.commandDeadline).toMatchObject({
value: 1500,
source: "setting",
});
});
-9
View File
@@ -270,15 +270,6 @@ export const debuggerConstructionLimits = (
return again.value;
};
/** Refuse an invalid launch cap instead of falling back to the default. */
export const resolveDebuggerSessionLimits = (
environment: Readonly<Record<string, string | undefined>>,
): DebuggerSessionLimits => {
const parsed = parseDebuggerSessionLimits(environment);
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
/**
* Fixed budget for Rizin cleanup readback and GDB startup or detach MI.
* This is not `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`. A caller cap below
+13 -10
View File
@@ -16,7 +16,6 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
HOPPER_CALLER_SCAN_BUDGET_SETTING,
hopperCallerScanLimit,
hopperCallerScanRecovery,
} from "./hopperCallerScanBudget.js";
@@ -79,15 +78,19 @@ it("explains the time budget and that fixed byte allowances have no override", (
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
if (!parsed.ok) throw parsed.error;
const budget = parsed.value.hopperCallerScanBudget;
expect(hopperCallerScanLimit(budget)).toMatchObject({
setting: "hopper.caller_scan_budget_ms",
effective_value: 250,
source: "setting",
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
maximum: MAX_NODE_TIMER_DELAY_MS,
update_mode: "runtime",
pending_restart: false,
});
const deadline = hopperCallerScanRecovery(
facts({ stop: { code: "work_budget", budget_ms: 250 } }),
budget,
);
expect(deadline).toContain(
"hopper.caller_scan_budget_ms is 250 ms (source setting",
);
expect(deadline).toContain(
`floor ${String(MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS)} ms`,
);
expect(deadline).toContain(`maximum ${String(MAX_NODE_TIMER_DELAY_MS)} ms`);
expect(deadline).toContain("update_analysis_settings");
expect(deadline).toContain(HOPPER_CALLER_SCAN_BUDGET_SETTING);
expect(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS).toBe(2000);
const page = hopperCallerScanRecovery(
facts({
+1 -1
View File
@@ -56,7 +56,7 @@ export const hopperCallerScanBudgetForMilliseconds = (
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. hopper.caller_scan_budget_ms does not raise them.`;
/** Structured facts for the scan time budget. The sentence is display text. */
export const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
analysisSettingLimit(
"hopper.caller_scan_budget_ms",
budget.milliseconds,
+1 -1
View File
@@ -6,8 +6,8 @@ import {
binaryLayoutSummaryViewResultSchema,
javascriptApplicationSummaryViewResultSchema,
projectAnalysisView,
analysisViewRequestSchema,
} from "../domain/analysisView/analysisView.js";
import { analysisViewRequestSchema } from "../domain/analysisView/analysisViewRequest.js";
import {
analysisViewCaptureEvidence,
analysisViewJavaScriptEvidence,
+13
View File
@@ -754,6 +754,19 @@ it("includes restored root identity in the compact byte budget", () => {
expect(result.schema.properties).toEqual({ value: { type: "number" } });
});
it("bounds an oversized root description while retaining an object input", () => {
const budget = 13 * 1024;
const result = compactAdvertisedInputSchema(
{ type: "object", description: "x".repeat(budget + 1), properties: {} },
budget,
);
expect(result.presentation).toBe("reduced");
expect(serializedBytes(result.schema)).toBeLessThanOrEqual(budget);
const validate = new Ajv2020({ strict: false }).compile(result.schema);
expect(validate({ value: "allowed" })).toBe(true);
expect(validate("not an object")).toBe(false);
});
// An untyped primitive enum is gateway-incompatible, so these schemas take
// the reduced projection independently of the budget.
const reducedPresentation = (
+1 -7
View File
@@ -87,13 +87,7 @@ export const compactAdvertisedInputSchema = (
// an honest, budget-fitting shape instead of an oversized one.
return {
schema: reduceToShallowProperties(
{
type: "object",
description:
(typeof schema.description === "string"
? `${schema.description} `
: "") + INPUT_REDUCED_ANNOTATION,
},
{ type: "object" },
INPUT_REDUCED_ANNOTATION,
),
presentation: "reduced",
+95 -12
View File
@@ -180,6 +180,79 @@ const isBooleanSchema = (schema: unknown): boolean =>
isObject(schema) &&
(schema.type === "boolean" || isBooleanLiteralSchema(schema));
const isObjectKeyword = (key: string) => schemaMaps.has(key);
const isArrayKeyword = (key: string) => schemaArrays.has(key);
const isChildKeyword = (key: string) => schemaChildren.has(key);
/**
* Remove every instance default below a schema, not only the one this keyword
* carries, keeping the entries at `kept`. A nested default belongs to the group
* that declares it: outside that group a client that fills it would send an
* object a strict sibling refuses. A default a reference still targets stays
* caller data; dropping it would leave that reference dangling.
*/
const omitNestedDefaults = (
schema: unknown,
kept: ReadonlySet<string> = new Set(),
path: readonly string[] = [],
): unknown => {
if (Array.isArray(schema))
return schema.map((child, index) =>
omitNestedDefaults(child, kept, [...path, String(index)]),
);
if (!isObject(schema)) return schema;
return Object.fromEntries(
Object.entries(schema).flatMap(([key, value]) => {
const childPath = [...path, key];
if (kept.has(JSON.stringify(childPath))) return [[key, value]];
if (key === "default") return [];
return [
[
key,
isObjectKeyword(key) && isObject(value)
? Object.fromEntries(
Object.entries(value).map(([name, child]) => [
name,
omitNestedDefaults(child, kept, [...childPath, name]),
]),
)
: isArrayKeyword(key) && Array.isArray(value)
? value.map((child, index) =>
omitNestedDefaults(child, kept, [
...childPath,
String(index),
]),
)
: isChildKeyword(key)
? Array.isArray(value)
? value.map((child, index) =>
omitNestedDefaults(child, kept, [
...childPath,
String(index),
]),
)
: omitNestedDefaults(value, kept, childPath)
: value,
],
];
}),
);
};
/** Every path into a `default` that a reference inside `schema` targets. */
const referencedDefaultPaths = (
schema: Readonly<Record<string, unknown>>,
): ReadonlySet<string> => {
const kept = new Set<string>();
for (const { target } of new JsonSchemaReferences(schema).references()) {
if (target.kind !== "target") continue;
const defaultAt = target.path.indexOf("default");
if (defaultAt < 0) continue;
kept.add(JSON.stringify(target.path.slice(0, defaultAt + 1)));
}
return kept;
};
const preserveSharedDefault = (
schema: Record<string, unknown>,
branches: readonly unknown[],
@@ -599,14 +672,19 @@ const flattenRootObjectUnion = (
const required = sharedRequiredProperties(branches);
// A default lifted out of an input group can make a client send a field
// rejected by another strict group. Keep it only when every group owns the
// property and supplies the same default.
// property and supplies the same default. A property some group omits takes
// its nested defaults too: filling only the inner property would otherwise
// still send an object that sibling group refuses. A default the nested
// property carries inside a group that does accept the parent stays, so
// callers keep that guidance.
for (const [name, schemas] of variants) {
const defaults = branches.map((branch) => {
const property = isObject(branch.properties)
? branch.properties[name]
: undefined;
return isObject(property) ? property.default : undefined;
});
const properties = branches.map((branch) =>
isObject(branch.properties) ? branch.properties[name] : undefined,
);
const ownedByEveryGroup = properties.every(isObject);
const defaults = properties.map((property) =>
isObject(property) ? property.default : undefined,
);
if (
defaults.every(
(value) => value !== undefined && isDeepStrictEqual(value, defaults[0]),
@@ -615,11 +693,16 @@ const flattenRootObjectUnion = (
continue;
const withoutDefaults: unknown[] = [];
for (const schema of schemas) {
let withoutDefault = schema;
if (isObject(schema) && Object.hasOwn(schema, "default")) {
const { default: _default, ...rest } = schema;
withoutDefault = rest;
}
const withoutDefault = ownedByEveryGroup
? isObject(schema) && Object.hasOwn(schema, "default")
? Object.fromEntries(
Object.entries(schema).filter(([key]) => key !== "default"),
)
: schema
: omitNestedDefaults(
schema,
isObject(schema) ? referencedDefaultPaths(schema) : new Set(),
);
if (
!withoutDefaults.some((known) =>
isDeepStrictEqual(known, withoutDefault),
+113
View File
@@ -831,3 +831,116 @@ it.each(["$ref", "$dynamicRef"])(
expect(advertised(value)).toBe(canonical(value));
},
);
describe("root union default presentation", () => {
it.each(["draft-2020-12", "draft-07"] as const)(
"omits a nested default the other root group would refuse in %s",
(target) => {
const normalization = z
.strictObject({ rules: z.array(z.string()).default([]) })
.default({ rules: [] });
const schema = z.union([
z.strictObject({ before: z.string(), after: z.string() }),
z.strictObject({
before_scenario: z.string(),
after_scenario: z.string(),
normalization,
}),
]);
const canonical = z.toJSONSchema(schema, { io: "input", target });
const advertised = presentInputJsonSchema(
canonical,
(property) => property,
);
const defaults: string[] = [];
const collect = (value: unknown, path: readonly string[]): void => {
if (Array.isArray(value)) {
value.forEach((child, index) =>
collect(child, [...path, String(index)]),
);
return;
}
if (typeof value !== "object" || value === null) return;
if (Object.hasOwn(value, "default")) defaults.push(path.join("."));
for (const [key, child] of Object.entries(value))
collect(child, [...path, key]);
};
collect(advertised, []);
// `normalization` belongs to the scenario group only. Leaving its
// `rules` default behind made a client that fills nested defaults send
// an object the strict passive group rejects.
expect(defaults).toEqual([]);
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
expect(
ajv.compile(advertised)({ before: "first", after: "second" }),
).toBe(true);
expect(
schema.safeParse({
before: "first",
after: "second",
normalization: { rules: [] },
}).success,
).toBe(false);
},
);
it.each(["draft-2020-12", "draft-07"] as const)(
"keeps a nested default a reference inside its resource targets in %s",
(target) => {
const schema = z.union([
z.strictObject({ before: z.string(), after: z.string() }),
z.strictObject({
before_scenario: z.string(),
after_scenario: z.string(),
normalization: z
.object({ rules: z.array(z.string()).optional() })
.default({ rules: [] }),
}),
]);
const canonical = z.toJSONSchema(schema, { io: "input", target }) as {
anyOf: {
properties: Record<string, Record<string, unknown>>;
required: string[];
additionalProperties: boolean;
}[];
};
// A nested resource keeps the group, so the union still flattens. Its
// sibling reference points into the default the flattening would drop.
const scenario = canonical.anyOf[1] as NonNullable<
(typeof canonical.anyOf)[1]
>;
const normalization = scenario.properties.normalization as Record<
string,
unknown
>;
canonical.anyOf[1] = {
...scenario,
properties: {
...scenario.properties,
normalization: {
$id: "https://example.test/normalization",
...normalization,
properties: {
...(normalization.properties as Record<string, unknown>),
alias: { $ref: "#/default/rules" },
},
},
},
};
const advertised = presentInputJsonSchema(
canonical,
(property) => property,
);
expect(advertised).not.toHaveProperty("anyOf");
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
const validate = ajv.compile(advertised);
expect(validate({ before: "first", after: "second" })).toBe(true);
},
);
});
+1 -3
View File
@@ -256,9 +256,7 @@ const hasRelativeSchemaReference = (
const withAdvertisedJsonSchema = <Schema extends z.ZodType>(
canonical: Schema,
io: "input" | "output",
advertise: (project: JsonSchemaProjection) => JsonSchemaProjection = (
project,
) => project,
advertise: (project: JsonSchemaProjection) => JsonSchemaProjection,
): Schema => {
// Zod's input projection drops root metadata when a descendant transforms.
// Preserve the parser and let the SDK own conversion of everything else.
+20 -7
View File
@@ -66,10 +66,16 @@ it.each(["http", "invalid_json", "readiness"] as const)(
}
},
);
it.each(["canonical", "missing", "rpc_error"] as const)(
it.each([
"canonical",
"missing",
"rpc_error",
"trailing_base64",
"scalar_metadata",
] as const)(
"validates recording replies and redacts their errors (%s)",
async (answer) => {
const present = answer === "canonical";
const present = answer !== "missing" && answer !== "rpc_error";
const token = "fake-recording-control-token-123456789";
const path = "rec-0123/0000.mp4";
const homePath = `.agentenv-recordings/${path}`;
@@ -95,11 +101,16 @@ it.each(["canonical", "missing", "rpc_error"] as const)(
parts: [
{
kind: "file",
file: { bytes: bytes.toString("base64") },
file: {
bytes:
bytes.toString("base64") +
(answer === "trailing_base64" ? "!ignored" : ""),
},
metadata: {
"urn:unrelated": {
path: present ? "wrong-path" : homePath,
},
"urn:unrelated":
answer === "scalar_metadata"
? "recording label"
: { path: present ? "wrong-path" : homePath },
...(present
? {
"urn:agentenv:data-objects/v1": {
@@ -138,7 +149,9 @@ it.each(["canonical", "missing", "rpc_error"] as const)(
},
],
});
if (present) await expect(read).resolves.toEqual([{ path, bytes }]);
if (answer === "trailing_base64")
await expect(read).rejects.toThrow("Expected canonical padded base64");
else if (present) await expect(read).resolves.toEqual([{ path, bytes }]);
else if (answer === "rpc_error") {
const failure = await read.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(Error);
@@ -94,6 +94,30 @@ it.each(["list", "sources"] as const)(
describe.skipIf(
process.platform !== "linux" || !existsSync("/usr/bin/apt-config"),
)("resolveAptLocations with apt-config", () => {
it("preserves newline paths printed by the real apt-config command", async () => {
const root = await createTestTempDirectory("apt-config-newline-");
const selected = {
lists: `${root}/user's\nlists/`,
trustedParts: `${root}/trusted\nparts/`,
trusted: `${root}/trusted\nkeys.gpg`,
};
const configured: QueryRunner = (command, argv, signal) =>
runHostQuery(
command,
[
"-o",
`Dir::State::Lists=${selected.lists}`,
"-o",
`Dir::Etc::TrustedParts=${selected.trustedParts}`,
"-o",
`Dir::Etc::Trusted=${selected.trusted}`,
...argv,
],
signal,
);
expect(await resolveAptLocations(configured, undefined)).toEqual(selected);
});
it("reads the lists folder an APT_CONFIG file names, resolved under Dir::State", async () => {
const root = await createTestTempDirectory("apt-config-");
const config = join(root, "apt.conf");
+10 -2
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { canonicalBase64Schema } from "../domain/stringPatterns.js";
/** Where a data-plane file part names its path in the home. */
export const DATA_OBJECTS = "urn:agentenv:data-objects/v1";
/** A `data/get` answer: file parts, each naming its home path, or an error. */
@@ -8,9 +10,15 @@ export const dataGetSchema = z.union([
result: z.object({
parts: z.array(
z.looseObject({
file: z.looseObject({ bytes: z.string().optional() }).optional(),
file: z
.looseObject({ bytes: canonicalBase64Schema.optional() })
.optional(),
metadata: z
.record(z.string(), z.looseObject({ path: z.string().optional() }))
.looseObject({
[DATA_OBJECTS]: z
.looseObject({ path: z.string().optional() })
.optional(),
})
.optional(),
}),
),
+29 -26
View File
@@ -876,34 +876,37 @@ describe.skipIf(process.platform === "win32")("desktop entry package", () => {
expect(installed).not.toHaveProperty("executable_package_reason");
});
it("finds apt's package lists in the folder apt-config names, or the default when it cannot say", async () => {
const { root, runQuery, inspect } = await writerFixture();
// A user-space apt-get update under APT_CONFIG keeps its lists elsewhere.
const userLists = join(root, "user's lists");
await rename(join(root, "lists"), userLists);
await mkdir(join(root, "lists"));
const answering =
(outcome: QueryOutcome): QueryRunner =>
async (command, argv, signal) =>
command === "apt-config" ? outcome : runQuery(command, argv, signal);
const quoted = `LISTS='${userLists.replaceAll("'", "'\\''")}/'\n`;
it.each(["user's lists", "user's\nlists"])(
"finds apt's package lists in %j, or the default when it cannot say",
async (name) => {
const { root, runQuery, inspect } = await writerFixture();
// A user-space apt-get update under APT_CONFIG keeps its lists elsewhere.
const userLists = join(root, name);
await rename(join(root, "lists"), userLists);
await mkdir(join(root, "lists"));
const answering =
(outcome: QueryOutcome): QueryRunner =>
async (command, argv, signal) =>
command === "apt-config" ? outcome : runQuery(command, argv, signal);
const quoted = `LISTS='${userLists.replaceAll("'", "'\\''")}/'\n`;
const present = await Promise.all(
[
{ stdout: quoted, exitCode: 0 },
{ stdout: "", exitCode: 100 },
"missing" as const,
].map(async (outcome) => {
const { installed } = await inspect(
{ desktopEntry: "libreoffice-writer" },
{ run: answering(outcome) },
);
return installed.package_lists_present;
}),
);
const present = await Promise.all(
[
{ stdout: quoted, exitCode: 0 },
{ stdout: "", exitCode: 100 },
"missing" as const,
].map(async (outcome) => {
const { installed } = await inspect(
{ desktopEntry: "libreoffice-writer" },
{ run: answering(outcome) },
);
return installed.package_lists_present;
}),
);
expect(present).toEqual([true, false, false]);
});
expect(present).toEqual([true, false, false]);
},
);
it("records why the computer cannot install the program's package when no source it can use offers it", async () => {
const { runQuery, inspect } = await writerFixture();
+73 -4
View File
@@ -12,10 +12,79 @@ import {
retainGuestFiles,
} from "./AgentEnvComputer.js";
describe("guest file encoding", () => {
const read = (encoded: string) =>
readHomeFiles(
async () => ({
result: {
parts: [
{
file: { bytes: encoded },
metadata: {
"urn:agentenv:data-objects/v1": { path: "selected.bin" },
},
},
],
},
}),
(text) => text,
["selected.bin"],
);
it.each(["!!!", "aGVsbG8=garbage", "aGVsbG8", "Zh==", "AA-_"])(
"rejects malformed base64 %j before retaining file bytes",
async (encoded) => {
await expect(read(encoded)).rejects.toThrow(
"Expected canonical padded base64",
);
},
);
it.each([
Buffer.alloc(0),
Buffer.from("hello"),
Buffer.from([0, 255, 128, 0]),
])("preserves valid empty, text and binary files: %j", async (bytes) => {
await expect(read(bytes.toString("base64"))).resolves.toEqual([
{ path: "selected.bin", bytes },
]);
});
});
describe("guest data-object metadata", () => {
it.each([null, 42, { path: 42 }])(
"refuses malformed known-namespace facts: %j",
async (metadata) => {
await expect(
readHomeFiles(
async () => ({
result: {
parts: [
{
file: { bytes: "aGVsbG8=" },
metadata: { "urn:agentenv:data-objects/v1": metadata },
},
],
},
}),
(text) => text,
["selected.bin"],
),
).rejects.toThrow();
},
);
});
describe("guest file answers", () => {
it.each([true, false])(
"uses only the named data-object metadata for file identity (present: %s)",
async (present) => {
it.each(
[true, false].flatMap((present) =>
[{ path: "other.txt" }, "download", 42, false, null, ["trace"]].map(
(unrelated) => ({ present, unrelated }),
),
),
)(
"uses only named data-object metadata (present: $present, unrelated: $unrelated)",
async ({ present, unrelated }) => {
const files = await readHomeFiles(
async () => ({
result: {
@@ -24,7 +93,7 @@ describe("guest file answers", () => {
kind: "file",
file: { bytes: Buffer.from("source bytes").toString("base64") },
metadata: {
"urn:unrelated": { path: "other.txt" },
"urn:unrelated": unrelated,
...(present
? {
"urn:agentenv:data-objects/v1": {
@@ -10,6 +10,50 @@ import { inspectLibraryPackages } from "./LibraryPackageInventory.js";
import { elfImage } from "../domain/desktop/elfImage.fixture.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe.skipIf(process.platform === "win32")(
"literal ELF origin paths",
() => {
it.each(["rpath", "runpath"] as const)(
"resolves %s libraries when the executable's directory contains dollars",
async (tag) => {
const root = await createTestTempDirectory("rea-library-origin-");
const bin = join(root, "$build-$&", "bin");
const lib = join(root, "$build-$&", "lib");
await Promise.all([
mkdir(bin, { recursive: true }),
mkdir(lib, { recursive: true }),
]);
const executable = join(bin, "app");
const library = join(lib, "liborigin.so");
await Promise.all([
writeFile(
executable,
elfImage({ needed: ["liborigin.so"], [tag]: "${ORIGIN}/../lib" }),
),
writeFile(library, elfImage({})),
]);
const queried: string[] = [];
const result = await inspectLibraryPackages(
executable,
"app",
async (command, args) => {
if (command === "ldconfig") return { stdout: "", exitCode: 0 };
queried.push(...args.slice(1));
return { stdout: `origin-owner: ${library}`, exitCode: 0 };
},
undefined,
);
expect(result).toEqual({
library_packages: ["origin-owner"],
missing_libraries: [],
unpackaged_libraries: [],
});
expect(queried).toContain(library);
},
);
},
);
it.skipIf(process.platform === "win32")(
"retains a compatible candidate's package when its declared header size is invalid",
async () => {
@@ -99,6 +99,41 @@ describe.skipIf(process.platform === "win32")(
},
);
describe.skipIf(process.platform === "win32")(
"desktop entry Unicode values",
() => {
it.each(["\u2028", "\u2029"])(
"retains Unicode separator %j in desktop launch arguments",
async (separator) => {
const { home, bin, environment } = await fixture();
const desktopEntry = join(home, "unicode.desktop");
await writeFile(
desktopEntry,
`[Desktop Entry]\nType=Application\nName=Notes${separator}Editor\nExec=notes-app --title "A${separator}B" %F\n`,
);
const inspected = await inspectLinuxApplication({
desktopEntry,
executable: undefined,
arguments: ["today.md"],
environment,
home,
callerDirectory: home,
});
expect(inspected).toMatchObject({
ok: true,
value: {
inventory: {
argv: ["notes-app", "--title", `A${separator}B`, "today.md"],
desktop_entry: { name: `Notes${separator}Editor` },
executable: { path: join(bin, "notes-app") },
},
},
});
},
);
},
);
describe.skipIf(process.platform === "win32")("inspectLinuxApplication", () => {
it("resolves a desktop entry id into a launch command and state directories", async () => {
const { home, bin, environment } = await fixture();
+1 -20
View File
@@ -1,24 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
analysisSettingDefinition,
runtimeAnalysisEnvironmentNames,
} from "./analysisSettingDefinitions.js";
const source = (path: string) =>
readFileSync(join(import.meta.dirname, path), "utf8");
it("does not validate runtime-owned launch seeds at startup", () => {
const startup = [
source("../config/environment.ts"),
source("../config/debuggerSessionLimits.ts"),
].join("\n");
const mentioned = [...runtimeAnalysisEnvironmentNames()].filter(
(name) => startup.includes(`${name}:`) || startup.includes(`[${name}]`),
);
expect(mentioned).toEqual([]);
});
import { analysisSettingDefinition } from "./analysisSettingDefinitions.js";
it("advertises the operations that consume each migrated setting", () => {
const operations = (id: Parameters<typeof analysisSettingDefinition>[0]) =>
-10
View File
@@ -559,16 +559,6 @@ export const ANALYSIS_SETTINGS = {
operations: windowsChangeTimeOperations,
},
} as const satisfies Record<AnalysisSettingId, SettingDefinition>;
/** Launch seeds `update_analysis_settings` can replace. Startup must not reject them. */
export const runtimeAnalysisEnvironmentNames = (): ReadonlySet<string> =>
new Set(
Object.values(ANALYSIS_SETTINGS).flatMap((definition) =>
definition.updateMode === "runtime" && "environment" in definition
? [definition.environment]
: [],
),
);
export const analysisSettingIds = Object.keys(
ANALYSIS_SETTINGS,
) as AnalysisSettingId[];
@@ -27,7 +27,6 @@ import {
} from "../javascript/javascriptSemanticGraph.js";
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
import { createArtifactInspection } from "../artifactInspection.js";
import { javascriptModulePaths } from "./javascriptApplicationView.js";
import {
analysisViewResultSchema,
inspectAnalysisViewInputSchema,
@@ -793,9 +792,6 @@ it("projects JavaScript summary and preserves selected module observations", ()
selector: { path: "renderer.js" },
});
if (!item.ok) throw item.error;
const moduleNode = analysis.graph.nodes[0];
if (moduleNode === undefined) throw new Error("missing module");
expect(javascriptModulePaths(moduleNode)).toEqual(["renderer.js"]);
if (item.value.kind !== "item") throw new Error("expected item view");
expect(item.value.item).toMatchObject({
path: "renderer.js",
-2
View File
@@ -101,7 +101,6 @@ import { projectIl2cppMetadataView } from "./il2cppMetadataView.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import type { GroupingViewKind } from "./analysisViewShape.js";
import {
analysisViewRequestSchema,
inspectAnalysisViewInputSchema,
type AnalysisViewRequest,
type InspectAnalysisViewInput,
@@ -180,7 +179,6 @@ import {
type UnsignedAnalysisView,
} from "./analysisViewResult.js";
export {
analysisViewRequestSchema,
inspectAnalysisViewInputSchema,
type AnalysisViewRequest,
type InspectAnalysisViewInput,
@@ -3,12 +3,12 @@ import { createEvidence } from "../evidence.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import type { JsonValue } from "../jsonValue.js";
import {
analysisViewRequestSchema,
androidComponentsViewResultSchema,
androidComponentSummaryViewResultSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const component = (name: string, extra: Record<string, JsonValue> = {}) => ({
kind: "activity",
@@ -243,9 +243,7 @@ const summarizeBatch = (items: readonly BatchDecompileItem[]) => {
};
};
export const batchUnknowns = (
items: readonly BatchDecompileItem[],
): string[] => {
const batchUnknowns = (items: readonly BatchDecompileItem[]): string[] => {
const unknownIdentity = items.filter(
({ procedure }) => procedure.status === "unknown",
).length;
@@ -11,11 +11,11 @@ import {
} from "../../../tests/fixtures/browserScenarioCapture.js";
import type { Evidence } from "../evidence.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
type AnalysisViewResult,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
import {
WITHHELD_WRITE_HINTS,
summarizeEnvironmentCoverage,
@@ -5,10 +5,10 @@ import {
environmentManifestCaptureFixture,
} from "../../../tests/fixtures/analysisView.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewRequest,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
type ManifestView = Extract<
AnalysisViewRequest,
@@ -54,9 +54,7 @@ const jsIncompatible = (detail: string): AnalysisError =>
]);
/** Every exact module path or original source-map reference retained by a node. */
export const javascriptModulePaths = (
node: ApplicationNode,
): readonly string[] => {
const javascriptModulePaths = (node: ApplicationNode): readonly string[] => {
if (node.identity.strategy === "canonical-path") return [node.identity.path];
if (node.identity.strategy === "source-map-original")
return [node.identity.original_source];
@@ -1,14 +1,14 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { completeSemanticSteps } from "../javascript/javascriptSemanticTraversal.js";
import { buildJavaScriptRouteInventorySteps } from "../javascript/javascriptRouteInventory.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import {
projectAnalysisView,
analysisViewRequestSchema,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const inventory = (
text = `createBrowserRouter([{path:'/reports',children:[{path:':id'},{path:'settings'}]}]);router.push('/login');`,
@@ -2,10 +2,10 @@ import { afterEach, expect, it, vi } from "vitest";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { nativeInventoryRowsSchemas } from "../native/nativeInventory.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
afterEach(() => vi.restoreAllMocks());
@@ -3,10 +3,8 @@ import { createEvidence } from "../evidence.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
} from "./analysisView.js";
import { projectAnalysisView } from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const nested = (levels: number): JsonValue => {
let value: JsonValue = "leaf";
@@ -3,10 +3,10 @@ import type { WebBundleAnalysis } from "../webBundleAnalysis.js";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const sampleAnalysis = (): WebBundleAnalysis => ({
capture: {
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff. Show more