fix(native): recover macOS command deadlines (#6059)
* fix(native): recover macOS command deadlines Promote native_command_timeout_ms (default 60000), native_ui_deadline_ms (default 180000), and pwntools_timeout_ms (default 30000) as validated per-call settings. Keep cpuSeconds, byte allowances, LLDB grace, and plist probes fixed, and say those caps have no runtime override. Co-authored-by: morluto <[email protected]> * fix(native): report the deadline that actually expired Pass the selected native command timeout through xcrun resolution, thread the pwntools deadline into pwndbg failures, and state the selected UI deadline apart from the fixed wait-step cap. SIGXFSZ and EFBIG recovery names the file-size allowance. Co-authored-by: morluto <[email protected]> * fix(native): honor the selected deadline on helper stages The UI helper compile and capture subprocesses used fixed 60s and 30s exec timeouts, so a longer native_ui_deadline_ms still failed as a generic provider error. Preparation timeouts reported the 60000 ms default instead of the selected native_command_timeout_ms. Memory resource-constraint recovery names memory again, and the Darwin plist CLI expectation matches the byte-counted input allowance. Co-authored-by: morluto <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: morluto <[email protected]>
This commit is contained in:
55 files changed
+1365
-169
No files matched your search
@@ -539,8 +539,10 @@ cleanup. A later abort does not replace an earlier helper or output failure.
|
||||
Cleanup failures retain their runtime owner until a confirmed cleanup retry.
|
||||
|
||||
Scenarios accept caller-selected action lists and accessibility node counts.
|
||||
Individual waits cannot exceed the operation's 180-second deadline, and the
|
||||
complete scenario result has a 64 MiB output budget. Screenshots are scaled to
|
||||
One wait step stays capped at 180000 ms. That cap has no runtime override.
|
||||
The operation deadline is `native_ui_deadline_ms` (omitted means 180000 ms)
|
||||
and does not raise the wait-step cap. The complete scenario result has a
|
||||
64 MiB output budget. Screenshots are scaled to
|
||||
at most 2,048 pixels and captured only for the selected window. REA compiles one
|
||||
owned helper per scenario, removes its temporary compiler cache and stops its
|
||||
helper on cancellation. It does not launch or own the selected application. UI
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { plistNestingLimitMessage } from "../domain/apple/plistValue.js";
|
||||
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -33,7 +34,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => {
|
||||
error: {
|
||||
path: join(app, "Contents", "Info.plist"),
|
||||
cause: expect.objectContaining({
|
||||
message: "Plist exceeds 128 nesting levels",
|
||||
message: plistNestingLimitMessage(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,7 +101,12 @@ export class BinaryLayoutService {
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
parameters: {
|
||||
path: input.data.path,
|
||||
...(input.data.pwntools_timeout_ms === undefined
|
||||
? {}
|
||||
: { pwntools_timeout_ms: input.data.pwntools_timeout_ms }),
|
||||
},
|
||||
result: value,
|
||||
// The decoded report is the only representation; do not repeat it.
|
||||
rawResult: null,
|
||||
|
||||
@@ -109,7 +109,13 @@ export class RecordedCrashService {
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
parameters: {
|
||||
path: input.data.path,
|
||||
include_debugger_context: input.data.include_debugger_context,
|
||||
...(input.data.pwntools_timeout_ms === undefined
|
||||
? {}
|
||||
: { pwntools_timeout_ms: input.data.pwntools_timeout_ms }),
|
||||
},
|
||||
result: value,
|
||||
// The decoded report is the only representation; do not repeat it.
|
||||
rawResult: null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js";
|
||||
|
||||
const NATIVE_MATCHING_REPRESENTATION_BYTES = 96 * 1024 * 1024;
|
||||
|
||||
@@ -18,8 +19,13 @@ export class NativeMatchingAdmission {
|
||||
throw new AnalysisResourceConstraintError(
|
||||
this.operation,
|
||||
"capacity",
|
||||
"Native function matching exceeds representation capacity",
|
||||
`Native function matching exceeds its ${String(NATIVE_MATCHING_REPRESENTATION_BYTES)}-byte (96 MiB) representation allowance. This release has no runtime override.`,
|
||||
{ maximum_bytes: NATIVE_MATCHING_REPRESENTATION_BYTES },
|
||||
fixedNativeAllowance(
|
||||
"The native function-matching representation allowance",
|
||||
"bytes",
|
||||
`${String(NATIVE_MATCHING_REPRESENTATION_BYTES)} bytes`,
|
||||
),
|
||||
);
|
||||
this.signal?.throwIfAborted();
|
||||
if (++this.#work % 256 === 0)
|
||||
|
||||
@@ -435,7 +435,7 @@ export const traceNativeValues = async (
|
||||
limitations: [
|
||||
"Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.",
|
||||
"LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.",
|
||||
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8 MiB). Complete serialized output is bounded to 32 MiB. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
|
||||
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8388608 bytes, 8 MiB). This release has no runtime override for the 8388608-byte node-payload allowance. Complete serialized output is bounded to 33554432 bytes (32 MiB) and that output allowance has no runtime override. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
|
||||
...renderSourceLimitations(sourceLimitations),
|
||||
],
|
||||
});
|
||||
@@ -443,7 +443,7 @@ export const traceNativeValues = async (
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"trace_native_values",
|
||||
"Graph output exceeds 32 MiB; lower max_nodes, max_edges or page limit",
|
||||
"Graph output exceeds 33554432 bytes (32 MiB). This release has no runtime override. Lower max_nodes, max_edges, or the page limit.",
|
||||
),
|
||||
);
|
||||
return ok(result);
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
decodeKeyedArchiveBytes,
|
||||
inspectBundleKeyedArchive,
|
||||
} from "./KeyedArchiveReader.js";
|
||||
import { unknownDateLimitation } from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
plistNestingLimitMessage,
|
||||
unknownDateLimitation,
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { scanBinaryPlistScalars } from "./PlistScalars.js";
|
||||
@@ -791,7 +794,7 @@ describe("XML plist nesting preflight", () => {
|
||||
Buffer.from(deeplyNestedXmlPlist(depth)),
|
||||
selection,
|
||||
),
|
||||
).toThrow(new RangeError("Plist exceeds 128 nesting levels"));
|
||||
).toThrow(new RangeError(plistNestingLimitMessage()));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -808,6 +811,6 @@ describe("XML plist nesting preflight", () => {
|
||||
it("rejects one nesting level past the limit before decoding", () => {
|
||||
expect(() =>
|
||||
decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection),
|
||||
).toThrow(new RangeError("Plist exceeds 128 nesting levels"));
|
||||
).toThrow(new RangeError(plistNestingLimitMessage()));
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { logCliCommand } from "../cliLogging.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js";
|
||||
import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js";
|
||||
import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
@@ -60,11 +62,20 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
args: z.object({
|
||||
path: z.string().describe("Local ELF binary or relocatable object"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object({
|
||||
pwntoolsTimeoutMs:
|
||||
inspectBinaryLayoutInputSchema.shape.pwntools_timeout_ms,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => {
|
||||
const result = await service.inspectAndClose(
|
||||
{ path: absoluteCallerPath(args.path) },
|
||||
{
|
||||
path: absoluteCallerPath(args.path),
|
||||
...(options.pwntoolsTimeoutMs === undefined
|
||||
? {}
|
||||
: { pwntools_timeout_ms: options.pwntoolsTimeoutMs }),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
@@ -85,6 +96,8 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Add BYO core-only GDB/pwndbg mapping candidates"),
|
||||
pwntoolsTimeoutMs:
|
||||
inspectRecordedCrashInputSchema.shape.pwntools_timeout_ms,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
@@ -93,6 +106,9 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
{
|
||||
path: absoluteCallerPath(args.path),
|
||||
include_debugger_context: options.debuggerContext,
|
||||
...(options.pwntoolsTimeoutMs === undefined
|
||||
? {}
|
||||
: { pwntools_timeout_ms: options.pwntoolsTimeoutMs }),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
@@ -14,6 +14,10 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { nativeUiScenarioInputSchema } from "../domain/native/nativeUiObservation.js";
|
||||
import {
|
||||
nativeCommandTimeoutField,
|
||||
nativeUiDeadlineField,
|
||||
} from "../domain/native/nativeDeadlines.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
@@ -59,6 +63,7 @@ const nativeUiScopeOptions = {
|
||||
.safe()
|
||||
.default(500)
|
||||
.describe("Maximum accessibility nodes per capture"),
|
||||
nativeUiDeadlineMs: nativeUiDeadlineField,
|
||||
};
|
||||
|
||||
const nativeUiScope = (options: {
|
||||
@@ -67,12 +72,16 @@ const nativeUiScope = (options: {
|
||||
readonly screenshot: boolean;
|
||||
readonly accessibility: boolean;
|
||||
readonly maxNodes: number;
|
||||
readonly nativeUiDeadlineMs?: number | undefined;
|
||||
}) => ({
|
||||
pid: options.pid,
|
||||
window_id: options.windowId,
|
||||
screenshot: options.screenshot,
|
||||
accessibility: options.accessibility,
|
||||
max_nodes: options.maxNodes,
|
||||
...(options.nativeUiDeadlineMs === undefined
|
||||
? {}
|
||||
: { native_ui_deadline_ms: options.nativeUiDeadlineMs }),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -138,7 +147,15 @@ const registerNativeUiCommands = (
|
||||
...(options.steps === undefined ? {} : { steps: decoded.value }),
|
||||
}),
|
||||
);
|
||||
return runProviderAnalysis(args.path, operation, parsed.data, logger);
|
||||
const { native_ui_deadline_ms: deadline, ...scenario } = parsed.data;
|
||||
return runProviderAnalysis(
|
||||
args.path,
|
||||
operation,
|
||||
deadline === undefined
|
||||
? scenario
|
||||
: { ...scenario, native_ui_deadline_ms: deadline },
|
||||
logger,
|
||||
);
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -183,9 +200,19 @@ const registerNativeCommands = (
|
||||
cli.command(command, {
|
||||
description: `Run ${tool} without launching Hopper`,
|
||||
args: z.object({ path: z.string().describe("Mach-O or app path") }),
|
||||
run: ({ args }) =>
|
||||
options: z.object({
|
||||
nativeCommandTimeoutMs: nativeCommandTimeoutField,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, command, () =>
|
||||
runProviderAnalysis(args.path, tool, {}, logger),
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
tool,
|
||||
options.nativeCommandTimeoutMs === undefined
|
||||
? {}
|
||||
: { native_command_timeout_ms: options.nativeCommandTimeoutMs },
|
||||
logger,
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -199,15 +226,21 @@ const registerNativeCommands = (
|
||||
.describe(
|
||||
"Plist path relative to the app root, or an absolute path (default: the bundle's Info.plist)",
|
||||
),
|
||||
nativeCommandTimeoutMs: nativeCommandTimeoutField,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-plist", () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"inspect_plist",
|
||||
options.relativePath === undefined
|
||||
? {}
|
||||
: { path: options.relativePath },
|
||||
{
|
||||
...(options.relativePath === undefined
|
||||
? {}
|
||||
: { path: options.relativePath }),
|
||||
...(options.nativeCommandTimeoutMs === undefined
|
||||
? {}
|
||||
: { native_command_timeout_ms: options.nativeCommandTimeoutMs }),
|
||||
},
|
||||
logger,
|
||||
),
|
||||
),
|
||||
@@ -218,12 +251,20 @@ const registerNativeCommands = (
|
||||
path: z.string().describe("Artifact path used for evidence identity"),
|
||||
symbols: swiftSymbolsSchema.describe("Swift mangled symbols to demangle"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object({
|
||||
nativeCommandTimeoutMs: nativeCommandTimeoutField,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "demangle-swift", () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"demangle_swift",
|
||||
{ symbols: args.symbols },
|
||||
{
|
||||
symbols: args.symbols,
|
||||
...(options.nativeCommandTimeoutMs === undefined
|
||||
? {}
|
||||
: { native_command_timeout_ms: options.nativeCommandTimeoutMs }),
|
||||
},
|
||||
logger,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -45,7 +45,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_binary_layout"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Initial profile: ELF64 x86-64 little-endian EXEC/DYN/REL on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view.",
|
||||
"Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Initial profile: ELF64 x86-64 little-endian EXEC/DYN/REL on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.",
|
||||
inputSchema: inspectBinaryLayoutRequestSchema,
|
||||
outputSchema: analysisDetailOutputSchemaOf(
|
||||
binaryLayoutSchema,
|
||||
@@ -67,7 +67,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_recorded_crash"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Inspect a supplied Linux ELF64 amd64 or AArch64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Linux x64 host profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
|
||||
"Inspect a supplied Linux ELF64 amd64 or AArch64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Linux x64 host profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.",
|
||||
inputSchema: inspectRecordedCrashInputSchema,
|
||||
outputSchema: evidenceResultOf(recordedCrashSchema),
|
||||
examples: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { localPathStringSchema } from "../../domain/localPath.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
||||
import { analysisDetailSchema } from "../analysisDetail.js";
|
||||
import { nativeCommandTimeoutField } from "../../domain/native/nativeDeadlines.js";
|
||||
|
||||
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
inspect_macho: {},
|
||||
@@ -78,12 +79,12 @@ export const swiftSymbolsSchema = z.array(
|
||||
export const NATIVE_TOOL_CONTRACTS = [
|
||||
native(
|
||||
"observe_native_ui",
|
||||
"Observe an already-running native app by PID and window ID, bound to the active Mach-O target. Captures a bounded accessibility tree and selected-window screenshot; OS permission denial fails without broad capture or target launch.",
|
||||
"Observe an already-running native app by PID and window ID, bound to the active Mach-O target. Captures a bounded accessibility tree and selected-window screenshot; OS permission denial fails without broad capture or target launch. native_ui_deadline_ms is the observation deadline (omitted means 180000 ms, range 1..2147483647, next call). It does not raise the fixed 67108864-byte output allowance or the 180000 ms wait-step cap.",
|
||||
nativeUiObservationInputSchema,
|
||||
),
|
||||
native(
|
||||
"capture_native_ui_scenario",
|
||||
"Run selected-element press, increment/decrement scroll, AXValue text entry and bounded wait steps in one exact native app window. Returns ordered before/after captures and action/capture failures. Actions can change app data, network activity and persistent state; application state is left as-is and restoration is not attempted.",
|
||||
"Run selected-element press, increment/decrement scroll, AXValue text entry and bounded wait steps in one exact native app window. Returns ordered before/after captures and action/capture failures. Actions can change app data, network activity and persistent state; application state is left as-is and restoration is not attempted. native_ui_deadline_ms is the scenario deadline (omitted means 180000 ms, range 1..2147483647, next call). It does not raise the fixed 67108864-byte output allowance or the 180000 ms wait-step cap.",
|
||||
nativeUiScenarioInputSchema,
|
||||
),
|
||||
native(
|
||||
@@ -93,7 +94,7 @@ export const NATIVE_TOOL_CONTRACTS = [
|
||||
),
|
||||
native(
|
||||
"inspect_macho",
|
||||
"Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper. Large frameworks return megabytes of symbols; detail summary retains the complete Evidence and returns slice identity with per-collection counts, then page rows with inspect_analysis_view kind macho.",
|
||||
"Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper. Large frameworks return megabytes of symbols; detail summary retains the complete Evidence and returns slice identity with per-collection counts, then page rows with inspect_analysis_view kind macho. native_command_timeout_ms is each Xcode command's wall clock (omitted means 60000 ms, range 1..2147483647, next call) and is distinct from objdump/rizin command_timeout_ms. It does not raise the fixed 67108864-byte output allowance.",
|
||||
z.object({
|
||||
detail: analysisDetailSchema
|
||||
.unwrap()
|
||||
@@ -101,30 +102,37 @@ export const NATIVE_TOOL_CONTRACTS = [
|
||||
.describe(
|
||||
"Omitted or complete returns the complete inspection Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for macho pages.",
|
||||
),
|
||||
native_command_timeout_ms: nativeCommandTimeoutField,
|
||||
}),
|
||||
),
|
||||
native(
|
||||
"inspect_signature",
|
||||
"Inspect the active Mach-O executable's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.",
|
||||
z.object({}),
|
||||
"Inspect the active Mach-O executable's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance. native_command_timeout_ms is each codesign command's wall clock (omitted means 60000 ms). It does not raise the fixed 67108864-byte output allowance.",
|
||||
z.object({
|
||||
native_command_timeout_ms: nativeCommandTimeoutField,
|
||||
}),
|
||||
),
|
||||
native(
|
||||
"inspect_plist",
|
||||
"Parse Info.plist from the active artifact by default, or pass any local plist path. Returns normalized JSON rather than plutil text.",
|
||||
"Parse Info.plist from the active artifact by default, or pass any local plist path. Returns normalized JSON rather than plutil text. native_command_timeout_ms is each file or plutil command's wall clock (omitted means 60000 ms). The 67108864-byte plist input and decode allowances and the 128-level nesting allowance have no runtime override. The 4096-byte markup probe is a probe window, not a document cap, and has no runtime override.",
|
||||
z.object({
|
||||
path: localPathStringSchema.optional(),
|
||||
native_command_timeout_ms: nativeCommandTimeoutField,
|
||||
}),
|
||||
),
|
||||
native(
|
||||
"list_architectures",
|
||||
"List thin or universal Mach-O slices with offsets, sizes, alignment, explicit coverage, and native-tool provenance.",
|
||||
z.object({}),
|
||||
"List thin or universal Mach-O slices with offsets, sizes, alignment, explicit coverage, and native-tool provenance. native_command_timeout_ms is the lipo wall clock (omitted means 60000 ms). It does not raise the fixed 67108864-byte output allowance.",
|
||||
z.object({
|
||||
native_command_timeout_ms: nativeCommandTimeoutField,
|
||||
}),
|
||||
),
|
||||
native(
|
||||
"demangle_swift",
|
||||
"Demangle an ordered list of single-line Swift symbols without requiring Hopper. Each input returns demangled, unchanged, or invalid status.",
|
||||
"Demangle an ordered list of single-line Swift symbols without requiring Hopper. Each input returns demangled, unchanged, or invalid status. native_command_timeout_ms is the swift-demangle wall clock (omitted means 60000 ms) and does not raise the fixed 67108864-byte output allowance.",
|
||||
z.object({
|
||||
symbols: swiftSymbolsSchema,
|
||||
native_command_timeout_ms: nativeCommandTimeoutField,
|
||||
}),
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_PLIST_DEPTH } from "./plistValue.js";
|
||||
import { MAX_PLIST_DEPTH, plistNestingLimitMessage } from "./plistValue.js";
|
||||
import { projectKeyedArchive } from "./keyedArchive.js";
|
||||
import { UnknownPlistScalar } from "./unknownPlistScalar.js";
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("projectKeyedArchive input bound", () => {
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
).toThrow(`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`);
|
||||
).toThrow(plistNestingLimitMessage());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import { UnknownPlistScalar } from "./unknownPlistScalar.js";
|
||||
*/
|
||||
export const MAX_PLIST_DEPTH = 128;
|
||||
|
||||
/** Agent-visible refusal for nesting past the JSON projection allowance. */
|
||||
export const plistNestingLimitMessage = (): string =>
|
||||
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels. This release has no runtime override for this ${String(MAX_PLIST_DEPTH)}-level nesting allowance.`;
|
||||
|
||||
/** A decoded plist projected into JSON, with values JSON cannot express. */
|
||||
export interface ProjectedPlistValue {
|
||||
readonly value: JsonValue;
|
||||
@@ -71,9 +75,7 @@ export const projectPlistValue = (
|
||||
};
|
||||
const project = (item: unknown, depth: number): JsonValue => {
|
||||
if (depth > MAX_PLIST_DEPTH)
|
||||
throw new RangeError(
|
||||
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
|
||||
);
|
||||
throw new RangeError(plistNestingLimitMessage());
|
||||
if (item !== null && typeof item === "object" && types.isProxy(item))
|
||||
throw new TypeError("Plist values cannot contain proxies");
|
||||
if (item instanceof Uint8Array)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
|
||||
|
||||
import { pwntoolsTimeoutField } from "./nativeDeadlines.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
@@ -21,6 +22,7 @@ export const inspectBinaryLayoutInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute filesystem path to the selected binary",
|
||||
),
|
||||
pwntools_timeout_ms: pwntoolsTimeoutField,
|
||||
});
|
||||
|
||||
/** File and linked-image evidence; numeric ELF values never pass through unsafe JSON numbers. */
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
|
||||
DEFAULT_NATIVE_UI_DEADLINE_MS,
|
||||
DEFAULT_PWNTOOLS_TIMEOUT_MS,
|
||||
MAX_NATIVE_TIMER_MS,
|
||||
nativeCommandTimeoutField,
|
||||
nativeUiDeadlineField,
|
||||
pwntoolsTimeoutField,
|
||||
readNativeDeadline,
|
||||
} from "./nativeDeadlines.js";
|
||||
|
||||
it("keeps today's omitted native deadlines and the shared timer range", () => {
|
||||
expect(DEFAULT_NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000);
|
||||
expect(DEFAULT_NATIVE_UI_DEADLINE_MS).toBe(180_000);
|
||||
expect(DEFAULT_PWNTOOLS_TIMEOUT_MS).toBe(30_000);
|
||||
expect(MAX_NATIVE_TIMER_MS).toBe(2_147_483_647);
|
||||
for (const field of [
|
||||
nativeCommandTimeoutField,
|
||||
nativeUiDeadlineField,
|
||||
pwntoolsTimeoutField,
|
||||
]) {
|
||||
expect(field.safeParse(undefined).success).toBe(true);
|
||||
expect(field.safeParse(1).success).toBe(true);
|
||||
expect(field.safeParse(MAX_NATIVE_TIMER_MS).success).toBe(true);
|
||||
expect(field.safeParse(0).success).toBe(false);
|
||||
expect(field.safeParse(MAX_NATIVE_TIMER_MS + 1).success).toBe(false);
|
||||
expect(field.safeParse(1.5).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads an omitted deadline as the caller-supplied default", () => {
|
||||
expect(readNativeDeadline(undefined, 60_000)).toEqual({
|
||||
ok: true,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(readNativeDeadline(2_500, 60_000)).toEqual({
|
||||
ok: true,
|
||||
timeoutMs: 2_500,
|
||||
});
|
||||
expect(readNativeDeadline(0, 60_000)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "out_of_range",
|
||||
});
|
||||
expect(readNativeDeadline("60000", 60_000)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "invalid_type",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
fixedAllowanceRemediation,
|
||||
type FixedAllowanceKind,
|
||||
} from "../fixedAllowanceRemediation.js";
|
||||
|
||||
/** Largest delay Node honors as a timer rather than clamping to 1 ms. */
|
||||
export const MAX_NATIVE_TIMER_MS = 2_147_483_647;
|
||||
|
||||
/**
|
||||
* Omitted `native_command_timeout_ms`. This is the macOS Xcode-tool wall
|
||||
* clock, not objdump/rizin `command_timeout_ms` (default 120000).
|
||||
*/
|
||||
export const DEFAULT_NATIVE_COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Omitted `native_ui_deadline_ms` for one UI observation or scenario. */
|
||||
export const DEFAULT_NATIVE_UI_DEADLINE_MS = 180_000;
|
||||
|
||||
/** Omitted `pwntools_timeout_ms` for one owned pwntools command. */
|
||||
export const DEFAULT_PWNTOOLS_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* One scenario wait step. The operation deadline is separate and adjustable;
|
||||
* this step cap stays fixed.
|
||||
*/
|
||||
export const NATIVE_UI_WAIT_STEP_MAX_MS = 180_000;
|
||||
|
||||
/** Integer millisecond deadline shared by the native command, UI, and pwntools settings. */
|
||||
export const nativeDeadlineMsSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_NATIVE_TIMER_MS);
|
||||
|
||||
const deadlineField = (description: string) =>
|
||||
nativeDeadlineMsSchema.optional().describe(description);
|
||||
|
||||
/** Per-call macOS native command deadline. Omitted means 60000 ms. */
|
||||
export const nativeCommandTimeoutField = deadlineField(
|
||||
"Wall-clock budget in milliseconds for each macOS native command in this call (otool, lipo, codesign, plutil, swift-demangle, and the other allowlisted Xcode tools). Omitted means 60000. Integer from 1 to 2147483647. Applies on the next call. Does not raise the fixed 67108864-byte output allowance or the 65536-byte failure-diagnostic excerpt. Named native_command_timeout_ms rather than command_timeout_ms because command_timeout_ms is the separate one-shot objdump and rizin deadline (default 120000).",
|
||||
);
|
||||
|
||||
/** Per-call native UI observation deadline. Omitted means 180000 ms. */
|
||||
export const nativeUiDeadlineField = deadlineField(
|
||||
"Deadline in milliseconds for this UI observation or scenario, including helper preparation and captures. Omitted means 180000. Integer from 1 to 2147483647. Applies on the next call. Does not raise the fixed 67108864-byte scenario output allowance or the 180000 ms cap on one wait step. A partial observation may already be retained when this deadline expires.",
|
||||
);
|
||||
|
||||
/** Per-call pwntools command deadline. Omitted means 30000 ms. */
|
||||
export const pwntoolsTimeoutField = deadlineField(
|
||||
"Deadline in milliseconds for each owned pwntools command in this call. Omitted means 30000. Integer from 1 to 2147483647. Applies on the next call. Does not raise cpuSeconds 30 or the output (67108864 bytes), diagnostic (1048576 bytes), or address-space (3221225472 bytes) allowances. Artifact admission stays REA_PWNTOOLS_MAX_INPUT_BYTES (default 33554432 bytes).",
|
||||
);
|
||||
|
||||
export type NativeDeadlineRead =
|
||||
| { readonly ok: true; readonly timeoutMs: number }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason: "invalid_type" | "out_of_range";
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
/** Accept an omitted deadline as the shipped default. */
|
||||
export const readNativeDeadline = (
|
||||
value: unknown,
|
||||
defaultMs: number,
|
||||
): NativeDeadlineRead => {
|
||||
if (value === undefined) return { ok: true, timeoutMs: defaultMs };
|
||||
const parsed = nativeDeadlineMsSchema.safeParse(value);
|
||||
if (parsed.success) return { ok: true, timeoutMs: parsed.data };
|
||||
const invalidType = parsed.error.issues.some(
|
||||
(issue) => issue.code === "invalid_type",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: invalidType ? "invalid_type" : "out_of_range",
|
||||
message: `Expected an integer from 1 to ${String(MAX_NATIVE_TIMER_MS)} milliseconds; received ${JSON.stringify(value)}. Omitted means ${String(defaultMs)}.`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Agent recovery for a caller-selected native deadline at today's default. */
|
||||
export const nativeDeadlineRecovery = (input: {
|
||||
readonly timeoutMs: number;
|
||||
readonly setting: string;
|
||||
readonly cliFlag: string;
|
||||
readonly defaultMs: number;
|
||||
readonly phase: string;
|
||||
readonly unchanged: string;
|
||||
readonly detail?: string;
|
||||
}): { readonly userMessage: string; readonly remediationAction: string } => {
|
||||
const atMaximum = input.timeoutMs >= MAX_NATIVE_TIMER_MS;
|
||||
const adjustment = atMaximum
|
||||
? `${input.setting} (${input.cliFlag} on the CLI) is already at the maximum ${String(MAX_NATIVE_TIMER_MS)} ms; do not increase it.`
|
||||
: `Increase ${input.setting} above ${String(input.timeoutMs)} (integer milliseconds from 1 to ${String(MAX_NATIVE_TIMER_MS)}; omitted means ${String(input.defaultMs)}), or pass ${input.cliFlag} on the CLI. A change applies on the next call.`;
|
||||
return {
|
||||
userMessage: `This request reached REA's ${input.phase} of ${String(input.timeoutMs)} ms (${input.setting}). This is a server/provider deadline, not the MCP client deadline.${input.detail ?? ""}`,
|
||||
remediationAction: `${adjustment} ${input.unchanged} Allow execution, cleanup, and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Fixed native allowance the agent can see, with the value and no override. */
|
||||
export const fixedNativeAllowance = (
|
||||
allowance: string,
|
||||
kind: FixedAllowanceKind,
|
||||
valueWithUnits: string,
|
||||
): { readonly retryable: false; readonly remediationAction: string } => ({
|
||||
retryable: false,
|
||||
remediationAction: `${fixedAllowanceRemediation(allowance, kind)} This release has no runtime override. The allowance is ${valueWithUnits}.`,
|
||||
});
|
||||
@@ -2,6 +2,11 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
NATIVE_UI_WAIT_STEP_MAX_MS,
|
||||
nativeUiDeadlineField,
|
||||
} from "./nativeDeadlines.js";
|
||||
|
||||
import { decodeCanonicalBase64 } from "../webScreenshot.js";
|
||||
import { readPngHeaderDimensions } from "../pngHeader.js";
|
||||
|
||||
@@ -19,6 +24,7 @@ const scope = {
|
||||
.describe(
|
||||
"Maximum accessibility nodes per capture; increase for large windows.",
|
||||
),
|
||||
native_ui_deadline_ms: nativeUiDeadlineField,
|
||||
};
|
||||
/** Opt-in passive observation binds one already-running process and window. */
|
||||
export const nativeUiObservationInputSchema = z.strictObject(scope);
|
||||
@@ -48,9 +54,9 @@ export const nativeUiScenarioInputSchema = z.strictObject({
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(180_000)
|
||||
.max(NATIVE_UI_WAIT_STEP_MAX_MS)
|
||||
.describe(
|
||||
"Wait duration in milliseconds, within the 180-second operation deadline.",
|
||||
"Wait duration in milliseconds. One wait step stays capped at 180000 ms. This release has no runtime override for that step cap. The operation deadline is native_ui_deadline_ms (omitted means 180000).",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
|
||||
import { pwntoolsTimeoutField } from "./nativeDeadlines.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
|
||||
@@ -149,6 +150,7 @@ export const inspectRecordedCrashInputSchema = z.strictObject({
|
||||
.describe(
|
||||
"Add core-only mapping candidates through caller-supplied GDB/pwndbg",
|
||||
),
|
||||
pwntools_timeout_ms: pwntoolsTimeoutField,
|
||||
});
|
||||
|
||||
/** Debugger map candidates preserve reported flags; zero does not establish absent permissions. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "plist";
|
||||
|
||||
import { plistNestingLimitMessage } from "./apple/plistValue.js";
|
||||
import {
|
||||
omitPrototypeKeys,
|
||||
parseXmlPropertyList as parseWithNumbers,
|
||||
@@ -239,7 +240,7 @@ it("rejects XML nesting beyond the projection limit with the limit diagnostic",
|
||||
// `plist` and the prototype-key traversal recurse once per level, so the
|
||||
// depth check must report the same bounded-resource error as the projection
|
||||
// instead of letting those recursive passes exhaust the call stack.
|
||||
const limit = new RangeError("Plist exceeds 128 nesting levels");
|
||||
const limit = new RangeError(plistNestingLimitMessage());
|
||||
expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit);
|
||||
expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit);
|
||||
expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit);
|
||||
@@ -280,7 +281,7 @@ it.each(['"', "'"])(
|
||||
opening.repeat(3000) + "<string>x</string>" + "</dict>".repeat(3000),
|
||||
);
|
||||
expect(() => parseXmlPropertyList(text)).toThrow(
|
||||
new RangeError("Plist exceeds 128 nesting levels"),
|
||||
new RangeError(plistNestingLimitMessage()),
|
||||
);
|
||||
expect(
|
||||
parseXmlPropertyList(
|
||||
@@ -308,7 +309,7 @@ it("bounds nested plist wrappers before recursive decoding", () => {
|
||||
const text =
|
||||
"<plist>".repeat(3000) + "<string>x</string>" + "</plist>".repeat(3000);
|
||||
expect(() => parseXmlPropertyList(text)).toThrow(
|
||||
new RangeError("Plist exceeds 128 nesting levels"),
|
||||
new RangeError(plistNestingLimitMessage()),
|
||||
);
|
||||
expect(
|
||||
parseXmlPropertyList(plist("<plist><string>x</string></plist>")).value,
|
||||
@@ -347,7 +348,7 @@ describe("OpenStep plist nesting preflight", () => {
|
||||
"rejects %s nested OpenStep values with the shared depth limit",
|
||||
(depth) => {
|
||||
expect(() => parseXmlPropertyList(arrays(depth))).toThrow(
|
||||
"Plist exceeds 128 nesting levels",
|
||||
plistNestingLimitMessage(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "./apple/plistNumbers.js";
|
||||
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
|
||||
|
||||
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
|
||||
import {
|
||||
MAX_PLIST_DEPTH,
|
||||
plistNestingLimitMessage,
|
||||
} from "./apple/plistValue.js";
|
||||
|
||||
/** The dictionary key REA results cannot carry: validation strips it. */
|
||||
const PROTOTYPE_KEY = "__proto__";
|
||||
@@ -132,9 +135,7 @@ const scanOpenStep = (text: string): number => {
|
||||
};
|
||||
const assertDepth = (): void => {
|
||||
if (containers.length > MAX_PLIST_DEPTH)
|
||||
throw new RangeError(
|
||||
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
|
||||
);
|
||||
throw new RangeError(plistNestingLimitMessage());
|
||||
};
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Document, Node } from "@xmldom/xmldom";
|
||||
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
|
||||
import {
|
||||
MAX_PLIST_DEPTH,
|
||||
plistNestingLimitMessage,
|
||||
} from "./apple/plistValue.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { parseXmlDocument, XML_LINE_ENDING } from "./xmlDocument.js";
|
||||
import {
|
||||
@@ -176,9 +179,7 @@ const collectXmlPlistScalarSpans = (
|
||||
pending.push({ node: node.nextSibling, depth });
|
||||
if (node.nodeType !== node.ELEMENT_NODE) continue;
|
||||
if (node.nodeName !== "key" && depth > MAX_PLIST_DEPTH)
|
||||
throw new RangeError(
|
||||
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
|
||||
);
|
||||
throw new RangeError(plistNestingLimitMessage());
|
||||
if (PLIST_LEXICAL_SCALARS.has(node.nodeName))
|
||||
spans.set(node, readSpan(node));
|
||||
if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import { fixedNativeAllowance } from "../domain/native/nativeDeadlines.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
@@ -273,11 +273,12 @@ export const inspectAppleDispatchMetadata = async (
|
||||
new AnalysisResourceConstraintError(
|
||||
"inspect_native_dispatch_metadata",
|
||||
"memory",
|
||||
"Apple static metadata input exceeds its 64 MiB snapshot budget",
|
||||
`Apple static metadata input exceeds its ${String(maximumBytes)}-byte (64 MiB) snapshot budget. This release has no runtime override.`,
|
||||
{ input_bytes: maximumBytes },
|
||||
fixedAllowanceOptions(
|
||||
fixedNativeAllowance(
|
||||
"The Apple static metadata snapshot allowance",
|
||||
"parser",
|
||||
`${String(maximumBytes)} bytes`,
|
||||
),
|
||||
);
|
||||
const cancelled = () =>
|
||||
|
||||
@@ -17,9 +17,13 @@ import type {
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js";
|
||||
|
||||
/** Maximum wall-clock duration for one native command. */
|
||||
export const NATIVE_COMMAND_TIMEOUT_MS = 60_000;
|
||||
/**
|
||||
* Omitted `native_command_timeout_ms`. Distinct from objdump/rizin
|
||||
* `command_timeout_ms`, which is a separate 120000 ms one-shot budget.
|
||||
*/
|
||||
export const NATIVE_COMMAND_TIMEOUT_MS = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS;
|
||||
/** Retain complete command output up to a bounded aggregate size. */
|
||||
export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024;
|
||||
/**
|
||||
@@ -127,6 +131,8 @@ export interface NativeCommandFailureCapture {
|
||||
export interface NativeCommandOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly acceptNonZero?: boolean;
|
||||
/** Selected wall clock; omitted uses `NATIVE_COMMAND_TIMEOUT_MS`. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface NativeCommandRunner {
|
||||
@@ -149,6 +155,8 @@ export type NativeToolResolver = (
|
||||
tool: string,
|
||||
signal?: AbortSignal,
|
||||
launcher?: NativeCommandLauncher,
|
||||
/** Selected command deadline. Omitted uses `NATIVE_COMMAND_TIMEOUT_MS`. */
|
||||
timeoutMs?: number,
|
||||
) => Promise<Result<ResolvedTool, NativeCommandFailure>>;
|
||||
|
||||
/** Run allowlisted Xcode tools directly without a shell. */
|
||||
@@ -161,8 +169,12 @@ export class XcrunCommandRunner implements NativeCommandRunner {
|
||||
#cleanupAttempt: Promise<readonly NativeCommandCleanupFailure[]> | undefined;
|
||||
constructor(
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
private readonly resolveTool: NativeToolResolver = (tool, signal) =>
|
||||
resolveXcrunTool(tool, this.environment, signal, this.launcher),
|
||||
private readonly resolveTool: NativeToolResolver = (
|
||||
tool,
|
||||
signal,
|
||||
launcher,
|
||||
timeoutMs,
|
||||
) => resolveXcrunTool(tool, this.environment, signal, launcher, timeoutMs),
|
||||
private readonly launcher?: NativeCommandLauncher,
|
||||
) {
|
||||
this.environment = snapshotEnvironment(environment);
|
||||
@@ -202,6 +214,7 @@ export class XcrunCommandRunner implements NativeCommandRunner {
|
||||
tool,
|
||||
options.signal,
|
||||
this.launcher,
|
||||
options.timeoutMs,
|
||||
);
|
||||
if (!resolved.ok) {
|
||||
return err(this.#retainCleanupOwner(resolved.error));
|
||||
@@ -363,12 +376,16 @@ export const resolveXcrunTool = async (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
signal?: AbortSignal,
|
||||
launcher?: NativeCommandLauncher,
|
||||
timeoutMs?: number,
|
||||
): Promise<Result<ResolvedTool, NativeCommandFailure>> => {
|
||||
const found = await captureProcess(
|
||||
"/usr/bin/xcrun",
|
||||
["--find", tool],
|
||||
"xcrun",
|
||||
signal === undefined ? {} : { signal },
|
||||
{
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
},
|
||||
environment,
|
||||
launcher,
|
||||
);
|
||||
@@ -496,6 +513,7 @@ const captureProcess = async (
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly acceptNonZero?: boolean;
|
||||
readonly timeoutMs?: number;
|
||||
},
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
launcher?: NativeCommandLauncher,
|
||||
@@ -515,7 +533,7 @@ const captureProcess = async (
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
{
|
||||
timeoutMs: NATIVE_COMMAND_TIMEOUT_MS,
|
||||
timeoutMs: options.timeoutMs ?? NATIVE_COMMAND_TIMEOUT_MS,
|
||||
diagnosticBytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
acceptNonZeroExit: options.acceptNonZero === true,
|
||||
},
|
||||
|
||||
@@ -59,10 +59,23 @@ const LLDB_REQUIREMENT =
|
||||
"LLDB from Xcode or the Command Line Tools is not available";
|
||||
/** Captured target output kept per stream; the rest is counted, not stored. */
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
/** LLDB startup, symbol loading and teardown beyond the observation window. */
|
||||
/**
|
||||
* LLDB startup, symbol loading and teardown beyond the observation window.
|
||||
* This grace has no runtime override; duration_ms is the caller-selected window.
|
||||
*/
|
||||
const DEADLINE_GRACE_MS = 60_000;
|
||||
const DIAGNOSTIC_BYTES = 16 * 1024;
|
||||
|
||||
const lldbGraceRecovery = (
|
||||
durationMs: number,
|
||||
): { readonly userMessage: string; readonly remediationAction: string } => {
|
||||
const total = durationMs + DEADLINE_GRACE_MS;
|
||||
return {
|
||||
userMessage: `observe_native_calls reached its ${String(total)} ms server/provider deadline: duration_ms ${String(durationMs)} plus a fixed ${String(DEADLINE_GRACE_MS)} ms LLDB startup, symbol loading, and teardown grace. This is not the MCP client deadline.`,
|
||||
remediationAction: `This release has no runtime override for the ${String(DEADLINE_GRACE_MS)} ms grace. Increase duration_ms on the next call when the observation window was too short; the grace stays added to that window. Captured output beyond ${String(MAX_OUTPUT_BYTES)} bytes per stream is counted and not kept, and that byte allowance has no runtime override. Failure diagnostics keep at most ${String(DIAGNOSTIC_BYTES)} bytes and that excerpt has no runtime override. Allow execution, cleanup, and delivery headroom in the outer MCP client deadline. Confirm the owned process was killed and cleanup finished before retrying.`,
|
||||
};
|
||||
};
|
||||
|
||||
const BRIDGE = fileURLToPath(
|
||||
new URL("../../bridge/native/rea_lldb_tracer.py", import.meta.url),
|
||||
);
|
||||
@@ -601,6 +614,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
? {}
|
||||
: { cleanup: lifecycleCleanup }),
|
||||
partialObservation,
|
||||
...lldbGraceRecovery(input.duration_ms),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -753,6 +767,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
trace,
|
||||
"timeout",
|
||||
),
|
||||
...lldbGraceRecovery(input.duration_ms),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -220,7 +220,7 @@ export const readLldbObservationJournal = (
|
||||
limitations: [
|
||||
...(size > LLDB_OBSERVATION_JOURNAL_MAX_BYTES
|
||||
? [
|
||||
"The observation journal exceeded 8 MiB; only its bounded prefix was read.",
|
||||
"The observation journal exceeded 8388608 bytes (8 MiB); only its bounded prefix was read. This release has no runtime override.",
|
||||
]
|
||||
: []),
|
||||
...(read.status === "eof"
|
||||
|
||||
@@ -188,7 +188,7 @@ const nativeCallFacts = (
|
||||
: []),
|
||||
...(resourceLimit
|
||||
? [
|
||||
"Observation stopped before retaining the next complete event because the estimated 8 MiB trace-payload or 65,536-frame aggregate budget was reached; the process was killed.",
|
||||
"Observation stopped before retaining the next complete event because the estimated 8388608-byte (8 MiB) trace-payload or 65536-frame aggregate budget was reached; the process was killed. This release has no runtime override for those budgets.",
|
||||
]
|
||||
: []),
|
||||
...(run.outcome === "duration-elapsed"
|
||||
@@ -220,14 +220,16 @@ const nativeCallFacts = (
|
||||
: []),
|
||||
...(breakpointLocationLimit
|
||||
? [
|
||||
"Resolved breakpoint locations exceeded the aggregate 8 MiB metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome.",
|
||||
"Resolved breakpoint locations exceeded the aggregate 8388608-byte (8 MiB) metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome. This release has no runtime override.",
|
||||
]
|
||||
: []),
|
||||
...(trace.stdout === null || trace.stderr === null
|
||||
? ["One or more target output captures were unavailable."]
|
||||
: []),
|
||||
...(trace.stdout?.truncated === true || trace.stderr?.truncated === true
|
||||
? ["Target output beyond 1 MiB per stream is counted but not kept."]
|
||||
? [
|
||||
"Target output beyond 1048576 bytes (1 MiB) per stream is counted but not kept. This release has no runtime override.",
|
||||
]
|
||||
: []),
|
||||
...(trace.stdout?.complete === false || trace.stderr?.complete === false
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
nativeMachoTarget,
|
||||
} from "../../tests/fixtures/nativeCommands.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import type {
|
||||
NativeCommandOptions,
|
||||
NativeCommandRunner,
|
||||
} from "./CommandRunner.js";
|
||||
import {
|
||||
NATIVE_COMMAND_TIMEOUT_MS,
|
||||
XcrunCommandRunner,
|
||||
} from "./CommandRunner.js";
|
||||
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
|
||||
|
||||
vi.mock("../process/OwnedCommand.js", async (original) => ({
|
||||
...(await original<typeof import("../process/OwnedCommand.js")>()),
|
||||
runOwnedCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(runOwnedCommand).mockReset();
|
||||
});
|
||||
|
||||
class RecordingRunner implements NativeCommandRunner {
|
||||
readonly timeouts: number[] = [];
|
||||
readonly #inner = new NativeFixtureRunner();
|
||||
|
||||
run(
|
||||
tool: string,
|
||||
arguments_: readonly string[],
|
||||
options: NativeCommandOptions,
|
||||
) {
|
||||
if (options.timeoutMs !== undefined) this.timeouts.push(options.timeoutMs);
|
||||
return this.#inner.run(tool, arguments_, options);
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (
|
||||
runner: NativeCommandRunner,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => {
|
||||
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
|
||||
nativeMachoTarget("/fixture/app"),
|
||||
);
|
||||
return client.execute("list_architectures", parameters);
|
||||
};
|
||||
|
||||
describe("native_command_timeout_ms", () => {
|
||||
it("uses 60000 ms when the caller omits the setting", async () => {
|
||||
const runner = new RecordingRunner();
|
||||
const result = await execute(runner, {});
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
expect(runner.timeouts.every((timeout) => timeout === 60_000)).toBe(true);
|
||||
expect(NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000);
|
||||
expect(runner.timeouts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes a selected deadline through to each native command", async () => {
|
||||
const runner = new RecordingRunner();
|
||||
const result = await execute(runner, { native_command_timeout_ms: 1_500 });
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
expect(runner.timeouts.every((timeout) => timeout === 1_500)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the selected deadline through production xcrun resolution", async () => {
|
||||
const calls: {
|
||||
command: string;
|
||||
arguments: readonly string[];
|
||||
timeoutMs: number;
|
||||
}[] = [];
|
||||
vi.mocked(runOwnedCommand).mockImplementation(async (spawn, limits) => {
|
||||
calls.push({
|
||||
command: spawn.command,
|
||||
arguments: spawn.arguments,
|
||||
timeoutMs: limits.timeoutMs,
|
||||
});
|
||||
throw new OwnedCommandFailure("timeout", "Command deadline elapsed.");
|
||||
});
|
||||
const runner = new XcrunCommandRunner({});
|
||||
const result = await runner.run("lipo", ["-info", "/fixture/app"], {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected resolution timeout");
|
||||
expect(result.error.reason).toBe("timeout");
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "/usr/bin/xcrun",
|
||||
arguments: ["--find", "lipo"],
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses 60000 ms for xcrun resolution when the caller omits the deadline", async () => {
|
||||
const timeouts: number[] = [];
|
||||
vi.mocked(runOwnedCommand).mockImplementation(async (_spawn, limits) => {
|
||||
timeouts.push(limits.timeoutMs);
|
||||
throw new OwnedCommandFailure("timeout", "Command deadline elapsed.");
|
||||
});
|
||||
const runner = new XcrunCommandRunner({});
|
||||
const result = await runner.run("file", ["--brief", "/fixture/app"], {});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(timeouts).toEqual([60_000]);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range deadline before launching a command", async () => {
|
||||
const runner = new RecordingRunner();
|
||||
const result = await execute(runner, { native_command_timeout_ms: 0 });
|
||||
if (result.ok) throw new Error("expected an invalid deadline");
|
||||
expect(runner.timeouts).toEqual([]);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["native_command_timeout_ms"],
|
||||
reason: "out_of_range",
|
||||
minimum: 1,
|
||||
maximum: 2_147_483_647,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,11 +53,18 @@ import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import {
|
||||
DEFAULT_NATIVE_UI_DEADLINE_MS,
|
||||
MAX_NATIVE_TIMER_MS,
|
||||
fixedNativeAllowance,
|
||||
nativeDeadlineRecovery,
|
||||
readNativeDeadline,
|
||||
} from "../domain/native/nativeDeadlines.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
@@ -90,6 +97,7 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
NATIVE_COMMAND_TIMEOUT_MS,
|
||||
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
|
||||
NativeCommandFailure,
|
||||
XcrunCommandRunner,
|
||||
failureCapture,
|
||||
@@ -136,6 +144,7 @@ interface NativePlistReadContext {
|
||||
readonly selection: NativePlistSelection;
|
||||
readonly resources: ArtifactResourceScope;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
/** Read-only semantic provider composed from Xcode command-line utilities. */
|
||||
@@ -420,10 +429,30 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
return err(
|
||||
this.#nativeUiCleanupFailure(earlierCleanupFailures, operation),
|
||||
);
|
||||
const runtime = createNativeUiHelperRuntime(this.environment);
|
||||
const deadline = readNativeDeadline(
|
||||
parameters.native_ui_deadline_ms,
|
||||
DEFAULT_NATIVE_UI_DEADLINE_MS,
|
||||
);
|
||||
if (!deadline.ok)
|
||||
return err(
|
||||
new AnalysisInputError(operation, { userMessage: deadline.message }, [
|
||||
{
|
||||
path: ["native_ui_deadline_ms"],
|
||||
reason: deadline.reason,
|
||||
message: deadline.message,
|
||||
minimum: 1,
|
||||
maximum: MAX_NATIVE_TIMER_MS,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const runtime = createNativeUiHelperRuntime(
|
||||
this.environment,
|
||||
deadline.timeoutMs,
|
||||
);
|
||||
const interruption = new NativeUiInterruptionControl(
|
||||
signal,
|
||||
runtime.signal,
|
||||
deadline.timeoutMs,
|
||||
);
|
||||
try {
|
||||
this.#pendingNativeUiRuntimes.add(runtime);
|
||||
@@ -554,20 +583,60 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
),
|
||||
);
|
||||
case "inspect_macho":
|
||||
return this.#inspectMacho(signal);
|
||||
case "inspect_signature":
|
||||
return this.#inspectSignature(signal);
|
||||
case "inspect_plist":
|
||||
return this.#inspectPlist(parameters, signal);
|
||||
case "list_architectures":
|
||||
return this.#listArchitectures(signal);
|
||||
case "demangle_swift":
|
||||
return this.#demangle(parameters, signal);
|
||||
return this.#dispatchCommand(operation, parameters, signal);
|
||||
}
|
||||
}
|
||||
|
||||
#dispatchCommand(
|
||||
operation:
|
||||
| "inspect_macho"
|
||||
| "inspect_signature"
|
||||
| "inspect_plist"
|
||||
| "list_architectures"
|
||||
| "demangle_swift",
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
const timeout = readNativeDeadline(
|
||||
parameters.native_command_timeout_ms,
|
||||
NATIVE_COMMAND_TIMEOUT_MS,
|
||||
);
|
||||
if (!timeout.ok)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisInputError(operation, { userMessage: timeout.message }, [
|
||||
{
|
||||
path: ["native_command_timeout_ms"],
|
||||
reason: timeout.reason,
|
||||
message: timeout.message,
|
||||
minimum: 1,
|
||||
maximum: MAX_NATIVE_TIMER_MS,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const timeoutMs = timeout.timeoutMs;
|
||||
switch (operation) {
|
||||
case "inspect_macho":
|
||||
return this.#inspectMacho(signal, timeoutMs);
|
||||
case "inspect_signature":
|
||||
return this.#inspectSignature(signal, timeoutMs);
|
||||
case "inspect_plist":
|
||||
return this.#inspectPlist(parameters, signal, timeoutMs);
|
||||
case "list_architectures":
|
||||
return this.#listArchitectures(signal, timeoutMs);
|
||||
case "demangle_swift":
|
||||
return this.#demangle(parameters, signal, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async #listArchitectures(
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
// lipo reads only Mach-O; its refusal of a PE, ELF, or plist target is not
|
||||
// a tool failure that a retry or `rea doctor` could repair.
|
||||
@@ -583,7 +652,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
"list_architectures",
|
||||
"lipo",
|
||||
["-detailed_info", this.target.path],
|
||||
{ signal },
|
||||
{ signal, timeoutMs },
|
||||
);
|
||||
if (!capture.ok) return capture;
|
||||
const provenance = [invocation(capture.value, this.target.path)];
|
||||
@@ -610,7 +679,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
|
||||
async #demangle(
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
const symbols = z.array(z.string()).safeParse(parameters.symbols);
|
||||
if (!symbols.success)
|
||||
@@ -625,7 +695,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
"swift-demangle",
|
||||
// `--` keeps symbols that begin with `-` from selecting demangler options.
|
||||
["--compact", "--", ...symbols.data],
|
||||
{ signal },
|
||||
{ signal, timeoutMs },
|
||||
);
|
||||
if (!capture.ok) return capture;
|
||||
const provenance = [invocation(capture.value, this.target.path)];
|
||||
@@ -642,7 +712,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
}
|
||||
|
||||
async #inspectSignature(
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
// This provider's signature inspection covers Mach-O code signatures
|
||||
// only. On another container, codesign's "not signed" verdict cannot
|
||||
@@ -660,7 +731,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
const inspected = await withSignatureTarget(
|
||||
this.target,
|
||||
this.#signatureResources,
|
||||
(binding) => this.#inspectBoundSignature(binding, signal),
|
||||
(binding) => this.#inspectBoundSignature(binding, signal, timeoutMs),
|
||||
signal,
|
||||
this.privateRuntimeRootFactory,
|
||||
);
|
||||
@@ -675,7 +746,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
|
||||
async #inspectBoundSignature(
|
||||
binding: SignatureTargetBinding,
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<InspectSignature, AnalysisError>> {
|
||||
const executablePaths = [
|
||||
binding.snapshotPath,
|
||||
@@ -685,6 +757,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
binding,
|
||||
["-d", "--verbose=4"],
|
||||
signal,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!display.ok) return display;
|
||||
const unsigned = isNonzeroUnsignedObservation(
|
||||
@@ -702,6 +775,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
binding,
|
||||
["-d", "-r-"],
|
||||
signal,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!requirements.ok) return requirements;
|
||||
const requirementsFailure = codeSignCaptureFailure(
|
||||
@@ -714,6 +788,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
binding,
|
||||
["-d", "--entitlements", ":-"],
|
||||
signal,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
const entitlementsFailure = codeSignCaptureFailure(
|
||||
@@ -780,6 +855,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
entitlements: entitlements.value.exitCode,
|
||||
},
|
||||
signal,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!slices.ok) {
|
||||
limitations.push(
|
||||
@@ -803,7 +879,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
requirements: number | null;
|
||||
entitlements: number | null;
|
||||
}>,
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<void, AnalysisError>> {
|
||||
const { captures, limitations } = observation;
|
||||
const unsigned: string[] = [];
|
||||
@@ -824,6 +901,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
binding,
|
||||
["-d", "-a", architecture, "--verbose=4"],
|
||||
signal,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!slice.ok) return slice;
|
||||
captures.push(slice.value);
|
||||
@@ -844,7 +922,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
async #captureSignature(
|
||||
binding: SignatureTargetBinding,
|
||||
args: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<NativeCommandCapture, AnalysisError>> {
|
||||
const before = await verifySignatureTarget(this.target, binding, signal);
|
||||
if (!before.ok) return before;
|
||||
@@ -857,6 +936,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
{
|
||||
signal,
|
||||
acceptNonZero: true,
|
||||
timeoutMs,
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -879,7 +959,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
|
||||
async #inspectPlist(
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
const requested = parameters.path;
|
||||
if (requested !== undefined && typeof requested !== "string")
|
||||
@@ -901,6 +982,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
selection: plist.value,
|
||||
resources: this.#plistResources,
|
||||
signal,
|
||||
timeoutMs,
|
||||
};
|
||||
const selected = await requireRegularPlist(fileInput);
|
||||
if (!selected.ok) return selected;
|
||||
@@ -908,7 +990,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
"inspect_plist",
|
||||
"file",
|
||||
["-b", plist.value.path],
|
||||
{ signal },
|
||||
{ signal, timeoutMs },
|
||||
);
|
||||
if (!classified.ok) return classified;
|
||||
const binary = await this.#inspectBinaryPlist(
|
||||
@@ -964,12 +1046,12 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
classified: NativeCommandCapture,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
const { path } = fileInput.selection;
|
||||
const { signal } = fileInput;
|
||||
const { signal, timeoutMs } = fileInput;
|
||||
const json = await this.#run(
|
||||
"inspect_plist",
|
||||
"plutil",
|
||||
["-convert", "json", "-o", "-", "--", path],
|
||||
{ signal, acceptNonZero: true },
|
||||
{ signal, acceptNonZero: true, timeoutMs },
|
||||
);
|
||||
if (!json.ok) return json;
|
||||
// JSON cannot express data, dates, or non-finite reals; plutil rejects
|
||||
@@ -983,7 +1065,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
"inspect_plist",
|
||||
"plutil",
|
||||
["-convert", "xml1", "-o", "-", "--", path],
|
||||
{ signal, acceptNonZero: !jsonDecoded },
|
||||
{ signal, acceptNonZero: !jsonDecoded, timeoutMs },
|
||||
);
|
||||
if (xml !== undefined && !xml.ok) return xml;
|
||||
if (xml !== undefined && xml.value.exitCode !== 0) {
|
||||
@@ -1044,7 +1126,8 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
}
|
||||
|
||||
async #inspectMacho(
|
||||
signal?: AbortSignal,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
if (!isMachOFormat(this.target.format))
|
||||
return err(
|
||||
@@ -1060,6 +1143,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
run: (tool, arguments_, commandSignal, acceptNonZeroIf) =>
|
||||
this.#run("inspect_macho", tool, arguments_, {
|
||||
signal: commandSignal,
|
||||
timeoutMs,
|
||||
...(acceptNonZeroIf === undefined ? {} : { acceptNonZeroIf }),
|
||||
}),
|
||||
invocation: (capture) => invocation(capture, this.target.path),
|
||||
@@ -1072,11 +1156,12 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
arguments_: readonly string[],
|
||||
options: {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly timeoutMs: number;
|
||||
readonly acceptNonZero?: boolean;
|
||||
readonly acceptNonZeroIf?:
|
||||
| ((capture: NativeCommandCapture) => boolean)
|
||||
| undefined;
|
||||
} = {},
|
||||
},
|
||||
): Promise<Result<NativeCommandCapture, AnalysisError>> {
|
||||
if (this.#closed)
|
||||
return err(
|
||||
@@ -1088,6 +1173,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
);
|
||||
const command = this.runner.run(tool, arguments_, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
timeoutMs: options.timeoutMs,
|
||||
acceptNonZero:
|
||||
options.acceptNonZero ?? options.acceptNonZeroIf !== undefined,
|
||||
});
|
||||
@@ -1132,7 +1218,9 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
return err(translateCommandFailure(operation, captured.error));
|
||||
return err(
|
||||
translateCommandFailure(operation, captured.error, options.timeoutMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,6 +1295,7 @@ const isNativeOperation = (
|
||||
const translateCommandFailure = (
|
||||
operation: NativeToolName,
|
||||
failure: NativeCommandFailure,
|
||||
timeoutMs = NATIVE_COMMAND_TIMEOUT_MS,
|
||||
): AnalysisError => {
|
||||
const cleanupOwners = [
|
||||
...failure.cleanupFailures,
|
||||
@@ -1271,9 +1360,17 @@ const translateCommandFailure = (
|
||||
preparation.cancellation === undefined
|
||||
? new ProviderAdapterError(IDENTITY.id, operation, options)
|
||||
: failure.reason === "timeout"
|
||||
? new AnalysisTimeoutError(operation, NATIVE_COMMAND_TIMEOUT_MS, {
|
||||
? new AnalysisTimeoutError(operation, timeoutMs, {
|
||||
...options,
|
||||
cause: preparation.cancellation.reason,
|
||||
...nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "native_command_timeout_ms",
|
||||
cliFlag: "--native-command-timeout-ms",
|
||||
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
|
||||
phase: "macOS native command deadline",
|
||||
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
|
||||
}),
|
||||
})
|
||||
: new AnalysisCancelledError(operation, {
|
||||
...options,
|
||||
@@ -1326,24 +1423,31 @@ const translateCommandFailure = (
|
||||
);
|
||||
if (failure.reason === "timeout")
|
||||
return bindCleanup(
|
||||
new AnalysisTimeoutError(
|
||||
operation,
|
||||
NATIVE_COMMAND_TIMEOUT_MS,
|
||||
errorOptions,
|
||||
),
|
||||
new AnalysisTimeoutError(operation, timeoutMs, {
|
||||
...errorOptions,
|
||||
...nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "native_command_timeout_ms",
|
||||
cliFlag: "--native-command-timeout-ms",
|
||||
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
|
||||
phase: "macOS native command deadline",
|
||||
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (failure.reason === "output-limit")
|
||||
return bindCleanup(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
`Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes; complete output is required for analysis.`,
|
||||
`Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes. Complete output is required for analysis. This release has no runtime override. native_command_timeout_ms does not raise this allowance.`,
|
||||
{ max_output_bytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES },
|
||||
{
|
||||
...errorOptions,
|
||||
...fixedAllowanceOptions(
|
||||
...fixedNativeAllowance(
|
||||
"The native command output capture allowance",
|
||||
"capture",
|
||||
`${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes`,
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
MAX_NATIVE_PLIST_BYTES,
|
||||
NATIVE_PLIST_INPUT_ALLOWANCE,
|
||||
} from "./parsers/plist.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
|
||||
/** Resolved lookup identity and selection intent, independent of file readiness. */
|
||||
export interface NativePlistSelection {
|
||||
@@ -313,7 +313,7 @@ export const readNativePlistBytes = (
|
||||
if (before.size > BigInt(MAX_NATIVE_PLIST_BYTES))
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`Selected plist exceeds ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}`,
|
||||
`Selected plist exceeds ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}. This release has no runtime override for this ${String(MAX_NATIVE_PLIST_BYTES)}-byte allowance.`,
|
||||
{ boundary: "file-size" },
|
||||
);
|
||||
const bytes = Buffer.allocUnsafe(Number(before.size));
|
||||
@@ -329,7 +329,7 @@ export const readNativePlistBytes = (
|
||||
if (after.size > BigInt(MAX_NATIVE_PLIST_BYTES))
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`Selected plist grew beyond ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}`,
|
||||
`Selected plist grew beyond ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}. This release has no runtime override for this ${String(MAX_NATIVE_PLIST_BYTES)}-byte allowance.`,
|
||||
{ boundary: "file-size" },
|
||||
);
|
||||
if (
|
||||
@@ -349,7 +349,10 @@ export const readNativePlistBytes = (
|
||||
return bytes;
|
||||
});
|
||||
|
||||
/** Bytes examined for leading markup when `file` reports only text. */
|
||||
/**
|
||||
* Bytes examined for leading markup when `file` reports only text.
|
||||
* This is a probe window, not a document cap, and has no runtime override.
|
||||
*/
|
||||
const PLIST_MARKUP_PROBE_BYTES = 4096;
|
||||
|
||||
/** Source classification remains independent of plutil's converted value format. */
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import { createNativeUiHelperRuntime } from "./NativeUiHelperRuntime.js";
|
||||
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
|
||||
|
||||
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
|
||||
execFileOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
const emptyCapture = {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
};
|
||||
const helperCapture = {
|
||||
stdout: JSON.stringify({ ok: true }),
|
||||
stderr: "",
|
||||
stdoutBytes: 11,
|
||||
stderrBytes: 0,
|
||||
};
|
||||
|
||||
it.each([250_000, undefined])(
|
||||
"uses the selected UI deadline for both helper subprocesses: %s",
|
||||
async (deadlineMs) => {
|
||||
vi.mocked(execFileOutput).mockReset();
|
||||
vi.mocked(execFileOutput)
|
||||
.mockResolvedValueOnce(emptyCapture)
|
||||
.mockResolvedValueOnce(helperCapture);
|
||||
const runtime =
|
||||
deadlineMs === undefined
|
||||
? createNativeUiHelperRuntime({})
|
||||
: createNativeUiHelperRuntime({}, deadlineMs);
|
||||
try {
|
||||
await runtime.invoke({ pid: 1 });
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
const expected = deadlineMs ?? NATIVE_UI_DEADLINE_MS;
|
||||
const calls = vi.mocked(execFileOutput).mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.[0]).toBe("/usr/bin/xcrun");
|
||||
expect(calls[0]?.[2]).toMatchObject({
|
||||
timeout: expected,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
expect(calls[1]?.[2]).toMatchObject({
|
||||
timeout: expected,
|
||||
stopSignal: "SIGTERM",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -16,6 +16,8 @@ export interface NativeUiHelperRuntime {
|
||||
readonly invoke: NativeUiHelper;
|
||||
readonly cleanupLease: AnalysisCleanupLease;
|
||||
readonly signal: AbortSignal;
|
||||
/** Omitted by test doubles; production runtimes always set the selected deadline. */
|
||||
readonly deadlineMs?: number;
|
||||
close(): Promise<void>;
|
||||
resources(): readonly string[];
|
||||
}
|
||||
@@ -23,10 +25,13 @@ export interface NativeUiHelperRuntime {
|
||||
/** Lazily compile one owned helper per observation/scenario and remove its compiler cache. */
|
||||
export const createNativeUiHelperRuntime = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
deadlineMs = NATIVE_UI_DEADLINE_MS,
|
||||
): NativeUiHelperRuntime => {
|
||||
const selectedEnvironment = snapshotEnvironment(environment);
|
||||
const deadlineSignal = AbortSignal.timeout(NATIVE_UI_DEADLINE_MS);
|
||||
const deadlineSignal = AbortSignal.timeout(deadlineMs);
|
||||
const operations = new ArtifactResourceScope();
|
||||
// Both helper stages share the selected deadline. A shorter fixed exec
|
||||
// timeout would fail the subprocess before that deadline and hide it.
|
||||
let root: string | undefined;
|
||||
let preparation: Promise<string> | undefined;
|
||||
const cleanupLease = new AnalysisCleanupLease(async () => {
|
||||
@@ -67,7 +72,7 @@ export const createNativeUiHelperRuntime = (
|
||||
output,
|
||||
],
|
||||
{
|
||||
timeout: 60_000,
|
||||
timeout: deadlineMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
stopSignal: SWIFTC_INTERRUPT,
|
||||
env: { ...selectedEnvironment, TMPDIR: compilerTemporary },
|
||||
@@ -88,7 +93,7 @@ export const createNativeUiHelperRuntime = (
|
||||
executable,
|
||||
[JSON.stringify(parameters)],
|
||||
{
|
||||
timeout: 30_000,
|
||||
timeout: deadlineMs,
|
||||
maxBuffer: NATIVE_UI_HELPER_MAX_BUFFER,
|
||||
stopSignal: "SIGTERM",
|
||||
env: selectedEnvironment,
|
||||
@@ -109,6 +114,7 @@ export const createNativeUiHelperRuntime = (
|
||||
invoke,
|
||||
cleanupLease,
|
||||
signal: deadlineSignal,
|
||||
deadlineMs,
|
||||
close: () => cleanupLease.release(),
|
||||
resources: () => (root === undefined ? [] : [root]),
|
||||
};
|
||||
|
||||
@@ -2,9 +2,25 @@ import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
|
||||
|
||||
it("names the selected native UI deadline and the fixed sibling caps", () => {
|
||||
const deadline = AbortSignal.abort();
|
||||
const control = new NativeUiInterruptionControl(undefined, deadline, 2_500);
|
||||
const error = control.error("observe_native_ui");
|
||||
expect(error).toBeInstanceOf(AnalysisTimeoutError);
|
||||
if (!(error instanceof AnalysisTimeoutError)) return;
|
||||
const projected = projectAnalysisError(error);
|
||||
expect(projected.message).toContain("2500 ms");
|
||||
expect(projected.message).toContain("native_ui_deadline_ms");
|
||||
expect(projected.remediation.action).toContain("--native-ui-deadline-ms");
|
||||
expect(projected.remediation.action).toContain("no runtime override");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
control.dispose();
|
||||
});
|
||||
|
||||
it("gives a pre-aborted caller priority over a pre-aborted deadline", () => {
|
||||
const reason = { phase: "shared" };
|
||||
const caller = new AbortController();
|
||||
@@ -39,6 +55,39 @@ it("keeps the first observed source when caller and deadline share a reason", ()
|
||||
control.dispose();
|
||||
});
|
||||
|
||||
it("records a helper subprocess timeout as the selected UI deadline", () => {
|
||||
const deadline = new AbortController();
|
||||
const control = new NativeUiInterruptionControl(
|
||||
undefined,
|
||||
deadline.signal,
|
||||
2_500,
|
||||
);
|
||||
const error = control.deadlineFailure("observe_native_ui");
|
||||
expect(error).toBeInstanceOf(AnalysisTimeoutError);
|
||||
if (!(error instanceof AnalysisTimeoutError)) return;
|
||||
const projected = projectAnalysisError(error);
|
||||
expect(projected.message).toContain("2500 ms");
|
||||
expect(projected.message).toContain("native_ui_deadline_ms");
|
||||
expect(projected.message).not.toContain("30000 ms");
|
||||
expect(projected.message).not.toContain("60000 ms");
|
||||
control.dispose();
|
||||
});
|
||||
|
||||
it("keeps caller cancellation ahead of a later helper subprocess timeout", () => {
|
||||
const reason = { phase: "caller" };
|
||||
const caller = new AbortController();
|
||||
caller.abort(reason);
|
||||
const control = new NativeUiInterruptionControl(
|
||||
caller.signal,
|
||||
new AbortController().signal,
|
||||
2_500,
|
||||
);
|
||||
const error = control.deadlineFailure("observe_native_ui");
|
||||
expect(error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(error.cause).toBe(reason);
|
||||
control.dispose();
|
||||
});
|
||||
|
||||
it("records a caller interruption before a later deadline", () => {
|
||||
const reason = { phase: "shared" };
|
||||
const caller = new AbortController();
|
||||
|
||||
@@ -3,8 +3,13 @@ import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
DEFAULT_NATIVE_UI_DEADLINE_MS,
|
||||
nativeDeadlineRecovery,
|
||||
} from "../domain/native/nativeDeadlines.js";
|
||||
|
||||
export const NATIVE_UI_DEADLINE_MS = 180_000;
|
||||
/** Omitted `native_ui_deadline_ms`. */
|
||||
export const NATIVE_UI_DEADLINE_MS = DEFAULT_NATIVE_UI_DEADLINE_MS;
|
||||
|
||||
type Interruption =
|
||||
| { readonly kind: "caller"; readonly reason: unknown }
|
||||
@@ -17,6 +22,7 @@ export class NativeUiInterruptionControl {
|
||||
#interruption: Interruption | undefined;
|
||||
#caller: AbortSignal | undefined;
|
||||
#deadline: AbortSignal;
|
||||
#deadlineMs: number;
|
||||
#onCallerAbort = (): void => {
|
||||
this.#interrupt("caller", this.#caller?.reason);
|
||||
};
|
||||
@@ -24,9 +30,19 @@ export class NativeUiInterruptionControl {
|
||||
this.#interrupt("deadline", this.#deadline.reason);
|
||||
};
|
||||
|
||||
constructor(caller: AbortSignal | undefined, deadline: AbortSignal) {
|
||||
/** Selected `native_ui_deadline_ms`, including the omitted default. */
|
||||
get deadlineMs(): number {
|
||||
return this.#deadlineMs;
|
||||
}
|
||||
|
||||
constructor(
|
||||
caller: AbortSignal | undefined,
|
||||
deadline: AbortSignal,
|
||||
deadlineMs = NATIVE_UI_DEADLINE_MS,
|
||||
) {
|
||||
this.#caller = caller;
|
||||
this.#deadline = deadline;
|
||||
this.#deadlineMs = deadlineMs;
|
||||
this.signal = this.#controller.signal;
|
||||
|
||||
// Match AbortSignal.any's iterable priority when both are already aborted.
|
||||
@@ -40,6 +56,22 @@ export class NativeUiInterruptionControl {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper subprocess reached the selected deadline before the abort signal
|
||||
* settled. Caller cancellation already recorded on this control stays primary.
|
||||
*/
|
||||
deadlineFailure(
|
||||
operation: string,
|
||||
partialObservation?: AnalysisPartialObservation,
|
||||
): AnalysisTimeoutError | AnalysisCancelledError {
|
||||
if (this.#interruption === undefined)
|
||||
this.#interrupt("deadline", this.#deadline.reason);
|
||||
const error = this.error(operation, partialObservation);
|
||||
if (error === undefined)
|
||||
throw new Error("UI deadline failure requires an interruption.");
|
||||
return error;
|
||||
}
|
||||
|
||||
error(
|
||||
operation: string,
|
||||
partialObservation?: AnalysisPartialObservation,
|
||||
@@ -47,9 +79,19 @@ export class NativeUiInterruptionControl {
|
||||
const interruption = this.#interruption;
|
||||
if (interruption === undefined) return undefined;
|
||||
return interruption.kind === "deadline"
|
||||
? new AnalysisTimeoutError(operation, NATIVE_UI_DEADLINE_MS, {
|
||||
? new AnalysisTimeoutError(operation, this.#deadlineMs, {
|
||||
cause: interruption.reason,
|
||||
partialObservation,
|
||||
...nativeDeadlineRecovery({
|
||||
timeoutMs: this.#deadlineMs,
|
||||
setting: "native_ui_deadline_ms",
|
||||
cliFlag: "--native-ui-deadline-ms",
|
||||
defaultMs: NATIVE_UI_DEADLINE_MS,
|
||||
phase: "native UI observation deadline",
|
||||
detail: " A partial observation may already be retained.",
|
||||
unchanged:
|
||||
"The 67108864-byte scenario output allowance and the 180000 ms cap on one wait step have no runtime override.",
|
||||
}),
|
||||
})
|
||||
: new AnalysisCancelledError(operation, {
|
||||
cause: interruption.reason,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { observeNativeUi } from "./NativeUiObservation.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
@@ -275,6 +276,53 @@ describe("native UI capture selection and budgets", () => {
|
||||
});
|
||||
|
||||
describe("native UI deadline execution", () => {
|
||||
it("states the selected deadline and the fixed wait-step cap", async () => {
|
||||
const result = await observeNativeUi(
|
||||
target,
|
||||
"capture_native_ui_scenario",
|
||||
{
|
||||
...scope,
|
||||
native_ui_deadline_ms: 2_500,
|
||||
steps: [{ kind: "wait", milliseconds: 0 }],
|
||||
},
|
||||
{
|
||||
environment: {},
|
||||
runtime: {
|
||||
signal: new AbortController().signal,
|
||||
deadlineMs: 2_500,
|
||||
invoke: async () => ({ ok: true, result: snapshot }),
|
||||
cleanupLease: new AnalysisCleanupLease(async () => undefined),
|
||||
close: async () => undefined,
|
||||
resources: () => [],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected a completed scenario");
|
||||
const limitation = result.value.limitations.join("\n");
|
||||
expect(limitation).toContain("selected native_ui_deadline_ms of 2500 ms");
|
||||
expect(limitation).toContain("omitted means 180000");
|
||||
expect(limitation).toContain(
|
||||
"One wait step stays capped at 180000 ms and that cap has no runtime override.",
|
||||
);
|
||||
expect(limitation).not.toContain("180-second");
|
||||
});
|
||||
|
||||
it("states the omitted 180000 ms deadline separately from the wait-step cap", async () => {
|
||||
const result = await observeNativeUi(target, "observe_native_ui", scope, {
|
||||
environment: {},
|
||||
invoke: async () => ({ ok: true, result: snapshot }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected a completed observation");
|
||||
const limitation = result.value.limitations.join("\n");
|
||||
expect(limitation).toContain("selected native_ui_deadline_ms of 180000 ms");
|
||||
expect(limitation).toContain(
|
||||
"One wait step stays capped at 180000 ms and that cap has no runtime override.",
|
||||
);
|
||||
expect(limitation).not.toContain("180-second");
|
||||
});
|
||||
|
||||
it("reports the owned deadline as a timeout during helper execution", async () => {
|
||||
const deadline = new AbortController();
|
||||
const reason = new DOMException("deadline elapsed", "TimeoutError");
|
||||
@@ -358,6 +406,40 @@ describe("native UI deadline execution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a helper subprocess timeout as the selected UI deadline", async () => {
|
||||
const result = await observeNativeUi(
|
||||
target,
|
||||
"observe_native_ui",
|
||||
{ ...scope, native_ui_deadline_ms: 2_500 },
|
||||
{
|
||||
environment: {},
|
||||
runtime: {
|
||||
signal: new AbortController().signal,
|
||||
deadlineMs: 2_500,
|
||||
invoke: async () => ({ ok: true, result: snapshot }),
|
||||
cleanupLease: new AnalysisCleanupLease(async () => undefined),
|
||||
close: async () => undefined,
|
||||
resources: () => [],
|
||||
},
|
||||
invoke: async () => {
|
||||
throw Object.assign(new Error("helper subprocess timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected helper deadline timeout");
|
||||
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.code).toBe("provider_timeout");
|
||||
expect(projected.message).toContain("of 2500 ms");
|
||||
expect(projected.message).toContain("native_ui_deadline_ms");
|
||||
expect(projected.remediation.action).toContain("above 2500");
|
||||
expect(projected.message).not.toContain("of 30000 ms");
|
||||
expect(projected.message).not.toContain("of 60000 ms");
|
||||
});
|
||||
|
||||
describe("native UI interruption provenance", () => {
|
||||
it("reports an owned deadline during a scenario wait as a failed step", async () => {
|
||||
const deadline = new AbortController();
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
isMachOFormat,
|
||||
type BinaryTarget,
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
DEFAULT_NATIVE_UI_DEADLINE_MS,
|
||||
NATIVE_UI_WAIT_STEP_MAX_MS,
|
||||
} from "../domain/native/nativeDeadlines.js";
|
||||
import {
|
||||
nativeUiObservationInputSchema,
|
||||
nativeUiScenarioInputSchema,
|
||||
@@ -30,7 +34,10 @@ import {
|
||||
NATIVE_UI_OUTPUT_BUDGET_BYTES,
|
||||
NATIVE_UI_OUTPUT_WEIGHT,
|
||||
} from "./NativeUiOutputBudget.js";
|
||||
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
|
||||
import {
|
||||
NATIVE_UI_DEADLINE_MS,
|
||||
NativeUiInterruptionControl,
|
||||
} from "./NativeUiInterruptionControl.js";
|
||||
|
||||
const helper = fileURLToPath(
|
||||
new URL("../../bridge/native/ReaNativeUI.swift", import.meta.url),
|
||||
@@ -69,7 +76,11 @@ export const observeNativeUi = async (
|
||||
options.runtime ?? createNativeUiHelperRuntime(options.environment);
|
||||
const interruption =
|
||||
options.interruption ??
|
||||
new NativeUiInterruptionControl(options.signal, runtime.signal);
|
||||
new NativeUiInterruptionControl(
|
||||
options.signal,
|
||||
runtime.signal,
|
||||
runtime.deadlineMs ?? NATIVE_UI_DEADLINE_MS,
|
||||
);
|
||||
const ownsInterruption = options.interruption === undefined;
|
||||
let result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>;
|
||||
try {
|
||||
@@ -202,12 +213,22 @@ const observeWithHelper = async (
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"native-macos",
|
||||
operation,
|
||||
"Scenario captures exceeded the 64 MiB output budget",
|
||||
`Scenario captures exceeded the fixed 64 MiB output budget (${String(NATIVE_UI_OUTPUT_BUDGET_BYTES)} bytes). This release has no runtime override. native_ui_deadline_ms does not raise it.`,
|
||||
{
|
||||
userMessage: `Scenario captures exceeded the fixed 64 MiB output budget (${String(NATIVE_UI_OUTPUT_BUDGET_BYTES)} bytes). This release has no runtime override.`,
|
||||
remediationAction: `The scenario output allowance is ${String(NATIVE_UI_OUTPUT_BUDGET_BYTES)} bytes. This release has no runtime override. Waiting, additional host memory, and native_ui_deadline_ms do not raise it. Repeating the unchanged request fails the same way.`,
|
||||
},
|
||||
),
|
||||
);
|
||||
return ok(response.result);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ETIMEDOUT"
|
||||
)
|
||||
return err(interruption.deadlineFailure(operation));
|
||||
return err(
|
||||
cause instanceof ExecFileOutputCancelledError
|
||||
? ((signal.aborted && cause.cause === signal.reason
|
||||
@@ -270,7 +291,7 @@ const observeWithHelper = async (
|
||||
"Click uses AXPress; scroll uses AXIncrement/AXDecrement; key-entry sets the selected element's AXValue. Unsupported elements fail without global event fallback.",
|
||||
"Each completed action is followed immediately by capture; delayed UI changes require explicit wait steps. A failed after-capture can follow a completed action, so failure does not prove absence of application effects.",
|
||||
"Scenarios can change app data, cause app network activity and persist changes. The caller explicitly chooses leave-as-is; automatic restoration is unsupported.",
|
||||
"Window matching uses exact PID and window ID for screenshots and unique accessibility geometry. Accessibility paths can change as the UI changes; stale or ambiguous paths fail. Screenshots are scaled to at most 2048 pixels; scenario output is budgeted at 64 MiB and execution has a 180-second deadline.",
|
||||
`Window matching uses exact PID and window ID for screenshots and unique accessibility geometry. Accessibility paths can change as the UI changes; stale or ambiguous paths fail. Screenshots are scaled to at most 2048 pixels; scenario output is budgeted at 64 MiB. Execution uses the selected native_ui_deadline_ms of ${String(interruption.deadlineMs)} ms (omitted means ${String(DEFAULT_NATIVE_UI_DEADLINE_MS)}). One wait step stays capped at ${String(NATIVE_UI_WAIT_STEP_MAX_MS)} ms and that cap has no runtime override.`,
|
||||
],
|
||||
});
|
||||
return terminalFailure === undefined
|
||||
|
||||
@@ -63,7 +63,7 @@ const request = (
|
||||
throw new BinaryResourceFailure(
|
||||
"structure",
|
||||
STRUCTURE_BYTES,
|
||||
"Native directory exceeds the 16 MiB structural decoding budget",
|
||||
"Native directory exceeds the 16777216-byte (16 MiB) structural decoding budget. This release has no runtime override.",
|
||||
);
|
||||
return { offset: exact(offset), size: exact(size) };
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
binaryDatePrecisionOverlapLimitation,
|
||||
@@ -37,18 +38,21 @@ import {
|
||||
MAX_BINARY_PLIST_NESTING_DEPTH,
|
||||
PropertyListDecodeBudgetFailure,
|
||||
} from "../../artifacts/apple/PropertyListDecodeBudget.js";
|
||||
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
|
||||
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
|
||||
|
||||
/** Input ceiling matches native command output; projection is bounded separately. */
|
||||
/**
|
||||
* Input ceiling matches native command output; projection is bounded separately.
|
||||
* Provisional byte allowance. This release has no runtime override.
|
||||
*/
|
||||
export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024;
|
||||
/** Provisional decode allowance. This release has no runtime override. */
|
||||
const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const mebibytes = (bytes: number): string =>
|
||||
`${String(bytes / (1024 * 1024))} MiB`;
|
||||
|
||||
/** Recovery name for the fixed source-size ceiling. */
|
||||
export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} native plist input allowance`;
|
||||
export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} (${String(MAX_NATIVE_PLIST_BYTES)}-byte) native plist input allowance (no runtime override)`;
|
||||
|
||||
const plistObject = z.record(z.string(), z.unknown());
|
||||
|
||||
@@ -302,8 +306,8 @@ export const parsePlistBinary = (
|
||||
cause,
|
||||
...fixedAllowanceOptions(
|
||||
cause.kind === "depth"
|
||||
? `The ${String(MAX_BINARY_PLIST_NESTING_DEPTH)}-level native binary plist nesting allowance`
|
||||
: `The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} native binary plist decode allowance`,
|
||||
? `The ${String(MAX_BINARY_PLIST_NESTING_DEPTH)}-level native binary plist nesting allowance (no runtime override)`
|
||||
: `The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} (${String(MAX_NATIVE_PLIST_DECODE_BYTES)}-byte) native binary plist decode allowance (no runtime override)`,
|
||||
"parser",
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
|
||||
import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
@@ -142,9 +142,10 @@ const peResourcesFailurePrimary = (
|
||||
...(cause.boundary === undefined
|
||||
? {}
|
||||
: metadata
|
||||
? fixedAllowanceOptions(
|
||||
? fixedNativeAllowance(
|
||||
"The PE resource metadata allowance",
|
||||
"parser",
|
||||
`${String(PE_RESOURCE_METADATA_BYTES)} bytes (8 MiB)`,
|
||||
)
|
||||
: {
|
||||
retryable: false as const,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const inspectPwndbgCore = async ({
|
||||
diagnostics,
|
||||
options,
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
probePath,
|
||||
}: {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
@@ -66,6 +67,8 @@ export const inspectPwndbgCore = async ({
|
||||
readonly diagnostics: AnalysisCapturedOutput;
|
||||
readonly options: ExecutionOptions | undefined;
|
||||
readonly runCommand: PwntoolsCommand;
|
||||
/** Effective `pwntools_timeout_ms` for this debugger command. */
|
||||
readonly timeoutMs: number;
|
||||
readonly probePath?: (
|
||||
path: string,
|
||||
kind: "file" | "directory",
|
||||
@@ -142,6 +145,7 @@ export const inspectPwndbgCore = async ({
|
||||
operation: OPERATION,
|
||||
providerId: "pwndbg-core",
|
||||
precedingOutput: diagnostics,
|
||||
timeoutMs,
|
||||
};
|
||||
const failure = (cause: unknown) =>
|
||||
pwntoolsDecoderFailure(
|
||||
|
||||
@@ -79,7 +79,10 @@ type DecoderReport = AnalysisPartialObservation & {
|
||||
};
|
||||
/** Adapter-selected semantics around a shared owned pwntools process boundary. */
|
||||
export interface PwntoolsDecoderProfile<
|
||||
Input extends { readonly path: string },
|
||||
Input extends {
|
||||
readonly path: string;
|
||||
readonly pwntools_timeout_ms?: number | undefined;
|
||||
},
|
||||
Payload,
|
||||
Report extends DecoderReport,
|
||||
> {
|
||||
@@ -96,12 +99,17 @@ export interface PwntoolsDecoderProfile<
|
||||
readonly diagnostics: AnalysisCapturedOutput;
|
||||
readonly options: ExecutionOptions | undefined;
|
||||
readonly runCommand: PwntoolsCommand;
|
||||
/** Effective `pwntools_timeout_ms`, including the omitted default. */
|
||||
readonly timeoutMs: number;
|
||||
}) => Report | Promise<Report>;
|
||||
}
|
||||
|
||||
/** Thin owned-process adapter; all ELF parsing and heuristics come from unchanged upstream code. */
|
||||
export class PwntoolsDecoder<
|
||||
Input extends { readonly path: string },
|
||||
Input extends {
|
||||
readonly path: string;
|
||||
readonly pwntools_timeout_ms?: number | undefined;
|
||||
},
|
||||
Payload,
|
||||
Report extends DecoderReport,
|
||||
> {
|
||||
@@ -209,7 +217,12 @@ export class PwntoolsDecoder<
|
||||
const pending = await this.#retire();
|
||||
if (pending !== undefined) return err(pending);
|
||||
const OPERATION = this.profile.operation;
|
||||
const context = { operation: OPERATION, providerId: this.identity.id };
|
||||
const timeoutMs = input.pwntools_timeout_ms ?? PWNTOOLS_LIMITS.timeoutMs;
|
||||
const context = {
|
||||
operation: OPERATION,
|
||||
providerId: this.identity.id,
|
||||
timeoutMs,
|
||||
};
|
||||
const replySchema = z.discriminatedUnion("ok", [
|
||||
z.strictObject({
|
||||
ok: z.literal(true),
|
||||
@@ -335,6 +348,7 @@ export class PwntoolsDecoder<
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
const execution = await this.#runCommand(
|
||||
timeoutMs,
|
||||
{
|
||||
command: this.environment.REA_PWNTOOLS_PYTHON ?? "",
|
||||
arguments: [
|
||||
@@ -408,9 +422,12 @@ export class PwntoolsDecoder<
|
||||
throw new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
reply.message,
|
||||
`${reply.message} cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. Output is ${String(PWNTOOLS_LIMITS.outputBytes)} bytes, diagnostics are ${String(PWNTOOLS_LIMITS.diagnosticBytes)} bytes, and address space is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override.`,
|
||||
reply.reported_limits ?? null,
|
||||
{ capturedOutput },
|
||||
{
|
||||
capturedOutput,
|
||||
remediationAction: `Review the reported worker memory limits and available host memory. cpuSeconds ${String(PWNTOOLS_LIMITS.cpuSeconds)}, the ${String(PWNTOOLS_LIMITS.outputBytes)}-byte output allowance, the ${String(PWNTOOLS_LIMITS.diagnosticBytes)}-byte diagnostic allowance, and the ${String(PWNTOOLS_LIMITS.addressSpaceBytes)}-byte address-space allowance have no runtime override. pwntools_timeout_ms does not raise them. Repeating the unchanged request fails the same way.`,
|
||||
},
|
||||
);
|
||||
if (reply.reason === "format")
|
||||
throw new AnalysisInputError(OPERATION, { capturedOutput }, [
|
||||
@@ -460,7 +477,8 @@ export class PwntoolsDecoder<
|
||||
diagnostics: capturedOutput,
|
||||
options,
|
||||
runCommand: (spawn, classifyFailure) =>
|
||||
this.#runCommand(spawn, options, classifyFailure),
|
||||
this.#runCommand(timeoutMs, spawn, options, classifyFailure),
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -522,6 +540,7 @@ export class PwntoolsDecoder<
|
||||
}
|
||||
|
||||
async #runCommand(
|
||||
timeoutMs: number,
|
||||
spawn: OwnedProviderProcessSpawnOptions,
|
||||
options: ExecutionOptions | undefined,
|
||||
failure: (cause: unknown) => AnalysisError,
|
||||
@@ -542,7 +561,7 @@ export class PwntoolsDecoder<
|
||||
expectedCommand: spawn.expectedCommand ?? null,
|
||||
},
|
||||
{
|
||||
timeoutMs: PWNTOOLS_LIMITS.timeoutMs,
|
||||
timeoutMs,
|
||||
diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
|
||||
import { pwntoolsDecoderFailure } from "./PwntoolsFailures.js";
|
||||
|
||||
@@ -24,6 +25,113 @@ it.each([undefined, null, false, 0, "stop", { stop: true }, new Error("stop")])(
|
||||
},
|
||||
);
|
||||
|
||||
it("names pwntools_timeout_ms and leaves cpuSeconds and byte allowances fixed", () => {
|
||||
const projected = projectAnalysisError(
|
||||
pwntoolsDecoderFailure(
|
||||
new OwnedCommandFailure("timeout", "deadline expired"),
|
||||
"decoder",
|
||||
"/selected",
|
||||
"/python",
|
||||
{},
|
||||
{
|
||||
operation: "inspect_binary_layout",
|
||||
providerId: "pwntools",
|
||||
timeoutMs: 4_500,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(projected.code).toBe("provider_timeout");
|
||||
expect(projected.message).toContain("4500 ms");
|
||||
expect(projected.message).toContain("pwntools_timeout_ms");
|
||||
expect(projected.remediation.action).toContain("--pwntools-timeout-ms");
|
||||
expect(projected.remediation.action).toContain("cpuSeconds is 30");
|
||||
expect(projected.remediation.action).toContain("no runtime override");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
});
|
||||
|
||||
it("names the reported file-size allowance instead of address space", () => {
|
||||
const projected = projectAnalysisError(
|
||||
pwntoolsDecoderFailure(
|
||||
new OwnedCommandFailure("process", "sigxfsz", {
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: { text: "", bytes: 0, observedBytes: 0 },
|
||||
exitCode: null,
|
||||
signal: "SIGXFSZ",
|
||||
}),
|
||||
"decoder",
|
||||
"/selected",
|
||||
"/python",
|
||||
{
|
||||
limits: {
|
||||
limits: {
|
||||
address_space_bytes: 1_111,
|
||||
cpu_seconds: 2,
|
||||
file_size_bytes: 4_096,
|
||||
},
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(projected.code).toBe("resource_constraint");
|
||||
expect(projected.message).toContain("file_size_bytes");
|
||||
expect(projected.message).toContain("4096");
|
||||
expect(projected.message).toContain("outputBytes");
|
||||
expect(projected.message).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("4096");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("tighter inherited limit");
|
||||
expect(projected.remediation.action).not.toContain("address-space");
|
||||
expect(projected.remediation.action).not.toContain("1111");
|
||||
expect(projected.remediation.action).not.toContain("3221225472");
|
||||
});
|
||||
|
||||
it("names the file-size cap when SIGXFSZ has no reported limit", () => {
|
||||
const projected = projectAnalysisError(
|
||||
pwntoolsDecoderFailure(
|
||||
new OwnedCommandFailure("process", "efbig", {
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: { text: "", bytes: 0, observedBytes: 0 },
|
||||
exitCode: 76,
|
||||
signal: null,
|
||||
}),
|
||||
"decoder",
|
||||
"/selected",
|
||||
"/python",
|
||||
{ marker: { resource: "file-size", failure: null } },
|
||||
),
|
||||
);
|
||||
expect(projected.remediation.action).toContain("outputBytes");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("file_size_bytes");
|
||||
expect(projected.remediation.action).toContain("not reported");
|
||||
expect(projected.remediation.action).toContain("tighter inherited limit");
|
||||
expect(projected.remediation.action).not.toContain("address-space");
|
||||
expect(projected.remediation.action).not.toContain("3221225472");
|
||||
});
|
||||
|
||||
it("names memory in the marker recovery for an allocation failure", () => {
|
||||
const projected = projectAnalysisError(
|
||||
pwntoolsDecoderFailure(
|
||||
new OwnedCommandFailure("process", "allocation", {
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: { text: "", bytes: 0, observedBytes: 0 },
|
||||
exitCode: 75,
|
||||
signal: null,
|
||||
}),
|
||||
"decoder",
|
||||
"/selected",
|
||||
"/python",
|
||||
{ marker: { resource: "memory", failure: null } },
|
||||
),
|
||||
);
|
||||
expect(projected.code).toBe("resource_constraint");
|
||||
expect(projected.details).toMatchObject({ resource: "memory" });
|
||||
expect(projected.remediation.action).toContain("memory");
|
||||
expect(projected.remediation.action).toContain("address-space");
|
||||
expect(projected.remediation.action).toContain("3221225472");
|
||||
});
|
||||
|
||||
it("retains legacy owner causes without guessing cancellation from error text", () => {
|
||||
const reason = { stop: "legacy" };
|
||||
const result = pwntoolsDecoderFailure(
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { ProviderSelectionError } from "../../domain/providerSelectionError.js";
|
||||
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
|
||||
import type { PwntoolsFailureEvidence } from "./PwntoolsResourceLimits.js";
|
||||
import { nativeDeadlineRecovery } from "../../domain/native/nativeDeadlines.js";
|
||||
import {
|
||||
PWNTOOLS_PROVIDER_IDENTITY,
|
||||
PWNTOOLS_LIMITS,
|
||||
@@ -35,7 +36,21 @@ export interface PwntoolsFailureContext {
|
||||
readonly providerId: string;
|
||||
readonly precedingOutput?: AnalysisCapturedOutput;
|
||||
readonly configurationKey?: string;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
const fixedPwntoolsBudgets = `cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. The output allowance is ${String(PWNTOOLS_LIMITS.outputBytes)} bytes, the diagnostic allowance is ${String(PWNTOOLS_LIMITS.diagnosticBytes)} bytes, and the address-space allowance is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override for those budgets. pwntools_timeout_ms does not raise them. Artifact admission stays REA_PWNTOOLS_MAX_INPUT_BYTES (default ${String(PWNTOOLS_LIMITS.inputBytes)} bytes).`;
|
||||
|
||||
/** Name the file-size cap and any reported effective limit, not address space. */
|
||||
const fileSizeAllowance = (reportedBytes: number | undefined): string => {
|
||||
const cap = String(PWNTOOLS_LIMITS.outputBytes);
|
||||
const inherited =
|
||||
"A tighter inherited limit is retained. This release has no runtime override. pwntools_timeout_ms does not raise the file-size allowance.";
|
||||
return reportedBytes === undefined
|
||||
? `The file-size cap is ${cap} bytes (outputBytes). The effective file_size_bytes limit was not reported. ${inherited}`
|
||||
: `The effective file-size allowance is ${String(reportedBytes)} bytes (file_size_bytes). The file-size cap is ${cap} bytes (outputBytes). ${inherited}`;
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT = {
|
||||
operation: "inspect_binary_layout",
|
||||
providerId: PWNTOOLS_PROVIDER_IDENTITY.id,
|
||||
@@ -102,25 +117,34 @@ export const pwntoolsDecoderFailure = (
|
||||
? cause.cause
|
||||
: cause.cancellation.reason,
|
||||
});
|
||||
if (cause.reason === "timeout")
|
||||
return new AnalysisTimeoutError(
|
||||
context.operation,
|
||||
PWNTOOLS_LIMITS.timeoutMs,
|
||||
outputOptions,
|
||||
);
|
||||
if (cause.reason === "timeout") {
|
||||
const timeoutMs = context.timeoutMs ?? PWNTOOLS_LIMITS.timeoutMs;
|
||||
return new AnalysisTimeoutError(context.operation, timeoutMs, {
|
||||
...outputOptions,
|
||||
...nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "pwntools_timeout_ms",
|
||||
cliFlag: "--pwntools-timeout-ms",
|
||||
defaultMs: PWNTOOLS_LIMITS.timeoutMs,
|
||||
phase: "pwntools command deadline",
|
||||
unchanged: fixedPwntoolsBudgets,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (cause.reason === "output-limit")
|
||||
return new AnalysisOutputError(
|
||||
context.operation,
|
||||
cause.message,
|
||||
outputOptions,
|
||||
);
|
||||
return new AnalysisOutputError(context.operation, cause.message, {
|
||||
...outputOptions,
|
||||
userMessage: `${cause.message} ${fixedPwntoolsBudgets}`,
|
||||
remediationAction: fixedPwntoolsBudgets,
|
||||
});
|
||||
if (
|
||||
cause.reason === "process" &&
|
||||
(cause.snapshot?.signal === "SIGXFSZ" ||
|
||||
(failureEvidence.marker?.resource === "file-size" &&
|
||||
cause.snapshot?.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT &&
|
||||
cause.snapshot.signal === null))
|
||||
)
|
||||
) {
|
||||
const allowance = fileSizeAllowance(limitReport?.limits?.file_size_bytes);
|
||||
return new AnalysisResourceConstraintError(
|
||||
context.operation,
|
||||
"file-size",
|
||||
@@ -129,10 +153,15 @@ export const pwntoolsDecoderFailure = (
|
||||
: "The owned Python bridge reported EFBIG while writing a file; the exact write failure cause is unknown.") +
|
||||
(limitReport?.failure === null || limitReport === undefined
|
||||
? ""
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`),
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`) +
|
||||
` ${allowance}`,
|
||||
limitReport?.limits ?? null,
|
||||
outputOptions,
|
||||
{
|
||||
...outputOptions,
|
||||
remediationAction: `Review the reported worker file-size limits and write failure. ${allowance} Repeating the unchanged request fails the same way.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (cause.reason === "process" && cause.snapshot?.signal === "SIGXCPU")
|
||||
return new AnalysisResourceConstraintError(
|
||||
context.operation,
|
||||
@@ -140,9 +169,13 @@ export const pwntoolsDecoderFailure = (
|
||||
"Owned Python terminated with SIGXCPU; the exact signal cause is unknown." +
|
||||
(limitReport?.failure === null || limitReport === undefined
|
||||
? ""
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`),
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`) +
|
||||
` cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. This release has no runtime override.`,
|
||||
limitReport?.limits ?? null,
|
||||
outputOptions,
|
||||
{
|
||||
...outputOptions,
|
||||
remediationAction: `Review the reported worker CPU limits and observed signal. cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. This release has no runtime override. pwntools_timeout_ms does not raise it. Repeating the unchanged request fails the same way.`,
|
||||
},
|
||||
);
|
||||
if (
|
||||
cause.reason === "process" &&
|
||||
@@ -156,9 +189,13 @@ export const pwntoolsDecoderFailure = (
|
||||
"The owned Python bridge reported a memory allocation failure without a structured reply; the exact allocation cause is unknown." +
|
||||
(limitReport?.failure === null || limitReport === undefined
|
||||
? ""
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`),
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`) +
|
||||
` The address-space allowance is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override.`,
|
||||
limitReport?.limits ?? null,
|
||||
outputOptions,
|
||||
{
|
||||
...outputOptions,
|
||||
remediationAction: `Review the reported worker memory limits and available host memory. ${fixedPwntoolsBudgets}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -200,13 +237,28 @@ export const pwntoolsDecoderFailure = (
|
||||
cause.reason === "path" ||
|
||||
cause.reason === "format"
|
||||
)
|
||||
return new AnalysisInputError(context.operation, { cause }, [
|
||||
return new AnalysisInputError(
|
||||
context.operation,
|
||||
{
|
||||
path: ["path"],
|
||||
reason: cause.reason === "limit" ? "out_of_range" : "invalid_format",
|
||||
message: cause.message,
|
||||
cause,
|
||||
...(cause.reason === "limit"
|
||||
? {
|
||||
userMessage: `${cause.message} Admitted artifact bytes use REA_PWNTOOLS_MAX_INPUT_BYTES (default ${String(PWNTOOLS_LIMITS.inputBytes)} bytes, maximum 1073741824). The 1073741824-byte maximum has no runtime override. Restart REA after changing the environment value. pwntools_timeout_ms does not raise this allowance. ${fixedPwntoolsBudgets}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]);
|
||||
[
|
||||
{
|
||||
path: ["path"],
|
||||
reason:
|
||||
cause.reason === "limit" ? "out_of_range" : "invalid_format",
|
||||
message:
|
||||
cause.reason === "limit"
|
||||
? `${cause.message} REA_PWNTOOLS_MAX_INPUT_BYTES defaults to ${String(PWNTOOLS_LIMITS.inputBytes)} bytes.`
|
||||
: cause.message,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
if (cause instanceof Error && "code" in cause) {
|
||||
if (phase === "configuration")
|
||||
|
||||
@@ -56,6 +56,7 @@ export class PwntoolsRecordedCrashProvider
|
||||
diagnostics,
|
||||
options,
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
}) => {
|
||||
const validate = (debuggerContext: RecordedCrash["debugger"]) =>
|
||||
recordedCrashSchema.safeParse({
|
||||
@@ -140,6 +141,7 @@ export class PwntoolsRecordedCrashProvider
|
||||
diagnostics,
|
||||
options,
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
});
|
||||
const enriched = validate(context);
|
||||
if (!enriched.success)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DEFAULT_PWNTOOLS_TIMEOUT_MS } from "../../domain/native/nativeDeadlines.js";
|
||||
|
||||
/** Verified unchanged upstream profile for the Linux ELF adapter. */
|
||||
export const PWNTOOLS_PROVIDER_IDENTITY = {
|
||||
id: "pwntools-elf",
|
||||
@@ -8,12 +10,17 @@ export const PWNTOOLS_PROVIDER_IDENTITY = {
|
||||
export const PWNTOOLS_MEMORY_FAILURE_EXIT = 75;
|
||||
/** Bridge-reserved status for an observed EFBIG failure without a complete reply. */
|
||||
export const PWNTOOLS_FILE_SIZE_FAILURE_EXIT = 76;
|
||||
/** Complete evidence budgets; address-space is separate from resident memory. */
|
||||
|
||||
/**
|
||||
* Complete evidence budgets; address-space is separate from resident memory.
|
||||
* `timeoutMs` is the omitted `pwntools_timeout_ms`. cpuSeconds and the byte
|
||||
* allowances stay fixed.
|
||||
*/
|
||||
export const PWNTOOLS_LIMITS = {
|
||||
inputBytes: 32 * 1024 * 1024,
|
||||
outputBytes: 64 * 1024 * 1024,
|
||||
diagnosticBytes: 1024 * 1024,
|
||||
timeoutMs: 30_000,
|
||||
timeoutMs: DEFAULT_PWNTOOLS_TIMEOUT_MS,
|
||||
addressSpaceBytes: 3 * 1024 * 1024 * 1024,
|
||||
cpuSeconds: 30,
|
||||
} as const;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "./cli.js";
|
||||
|
||||
const help = async (command: string): Promise<string> => {
|
||||
const cli = createCli({ REA_LOG_LEVEL: "silent" });
|
||||
let text = "";
|
||||
await cli.serve([command, "--help"], {
|
||||
env: {},
|
||||
exit: () => undefined,
|
||||
stdout: (chunk: string) => {
|
||||
text += chunk;
|
||||
},
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
it("advertises the native deadline flags separately from objdump and rizin", async () => {
|
||||
expect(await help("inspect-macho")).toContain("--native-command-timeout-ms");
|
||||
expect(await help("observe-native-ui")).toContain("--native-ui-deadline-ms");
|
||||
expect(await help("inspect-binary-layout")).toContain(
|
||||
"--pwntools-timeout-ms",
|
||||
);
|
||||
expect(await help("inspect-recorded-crash")).toContain(
|
||||
"--pwntools-timeout-ms",
|
||||
);
|
||||
});
|
||||
@@ -86,6 +86,15 @@ it.runIf(!unsupportedHost).each([
|
||||
expect(projected.remediation.action).toContain(
|
||||
resource === "cpu" ? "CPU" : "file-size",
|
||||
);
|
||||
if (resource === "file-size") {
|
||||
expect(projected.remediation.action).toContain("outputBytes");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("file_size_bytes");
|
||||
expect(projected.remediation.action).not.toContain("address-space");
|
||||
expect(projected.remediation.action).not.toContain("3221225472");
|
||||
if (scenario === "reported")
|
||||
expect(projected.remediation.action).toContain("1024");
|
||||
}
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
@@ -313,7 +322,8 @@ it.runIf(!unsupportedHost).each([
|
||||
);
|
||||
const result = await provider.inspect({ path });
|
||||
if (result.ok) throw new Error("Expected resource failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
category: "resource_constraint",
|
||||
details: {
|
||||
@@ -325,6 +335,8 @@ it.runIf(!unsupportedHost).each([
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(projected.remediation.action).toContain("memory");
|
||||
expect(projected.remediation.action).toContain("address-space");
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(path, "utf8")).toBe("source-owned-seam-bytes");
|
||||
},
|
||||
|
||||
@@ -473,6 +473,82 @@ const expectOptionalContextFailure = (
|
||||
});
|
||||
};
|
||||
|
||||
it.runIf(host)(
|
||||
"reports a non-default debugger deadline when pwndbg times out",
|
||||
async () => {
|
||||
const { path, value, bytes, root } = await fixture();
|
||||
const venv = join(root, "venv");
|
||||
const plugin = join(root, "gdbinit.py");
|
||||
await mkdir(venv);
|
||||
await writeFile(plugin, "");
|
||||
let stage = 0;
|
||||
const provider = new PwntoolsRecordedCrashProvider(
|
||||
{
|
||||
REA_PWNTOOLS_PYTHON: process.execPath,
|
||||
REA_PWNDBG_GDB: process.execPath,
|
||||
REA_PWNDBG_GDBINIT: plugin,
|
||||
REA_PWNDBG_VENV_PATH: venv,
|
||||
},
|
||||
async (spawn) => {
|
||||
stage += 1;
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")),
|
||||
);
|
||||
if (stage === 1) {
|
||||
const {
|
||||
artifact: _artifact,
|
||||
diagnostics: _diagnostics,
|
||||
debugger: _debugger,
|
||||
decoder_diagnostics: _decoderDiagnostics,
|
||||
...raw
|
||||
} = value;
|
||||
await writeFile(
|
||||
request.reply_path,
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
profile: PWNTOOLS_PROVIDER_IDENTITY.version,
|
||||
value: raw,
|
||||
}),
|
||||
);
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", "process.stdout.write('basic stage diagnostic')"],
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
if (spawn.signal?.aborted === true) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
spawn.signal?.addEventListener("abort", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", "process.exit(0)"],
|
||||
});
|
||||
},
|
||||
);
|
||||
onTestFinished(() => provider.close());
|
||||
const result = await provider.inspect({
|
||||
path,
|
||||
include_debugger_context: true,
|
||||
pwntools_timeout_ms: 1_500,
|
||||
});
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
if (result.ok) throw new Error("expected debugger timeout");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.code).toBe("provider_timeout");
|
||||
expect(projected.message).toContain("of 1500 ms");
|
||||
expect(projected.remediation.action).toContain("above 1500");
|
||||
expect(projected.remediation.action).not.toContain("above 30000");
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
const fixture = async (
|
||||
createReport: (path: string) => RecordedCrash = recordedCrashFixture,
|
||||
) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { build } from "plist";
|
||||
|
||||
import { plistNestingLimitMessage } from "../../../src/domain/apple/plistValue.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js";
|
||||
@@ -37,8 +38,7 @@ for (const [name, xml] of [
|
||||
details: {
|
||||
reason: "limit",
|
||||
truncated: true,
|
||||
detail:
|
||||
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
|
||||
detail: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action:
|
||||
"The 64 MiB native binary plist decode allowance is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.",
|
||||
"The 64 MiB (67108864-byte) native binary plist decode allowance (no runtime override) is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.",
|
||||
},
|
||||
details: {
|
||||
operation: "inspect_plist",
|
||||
@@ -310,7 +310,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action: expect.stringContaining(
|
||||
"Select a suitable complete artifact within the fixed 64 MiB native plist input allowance.",
|
||||
"Select a suitable complete artifact within the fixed 64 MiB (67108864-byte) native plist input allowance (no runtime override).",
|
||||
),
|
||||
},
|
||||
details: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { expect, it } from "vitest";
|
||||
import { parse } from "plist";
|
||||
|
||||
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
|
||||
import { plistNestingLimitMessage } from "../../../src/domain/apple/plistValue.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js";
|
||||
|
||||
@@ -23,8 +24,7 @@ it.each([129, 3000])(
|
||||
// can report it, so the limit diagnostic must come from the preflight.
|
||||
expect(rejected).toMatchObject({
|
||||
reason: "limit",
|
||||
message:
|
||||
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
|
||||
message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -72,8 +72,7 @@ it("retains the limit classification for syntactically valid deep OpenStep value
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(rejected).toMatchObject({
|
||||
reason: "limit",
|
||||
message:
|
||||
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
|
||||
message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,8 +95,7 @@ it.each([
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(rejected).toMatchObject({
|
||||
reason: "limit",
|
||||
message:
|
||||
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
|
||||
message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -4,7 +4,11 @@ import { promisify } from "node:util";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js";
|
||||
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
createTestWorkspace,
|
||||
@@ -49,6 +53,7 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)(
|
||||
diagnostics,
|
||||
options: { signal: controller.signal },
|
||||
probePath,
|
||||
timeoutMs: 30_000,
|
||||
runCommand: () => {
|
||||
throw new Error("No process should exist before cancellation");
|
||||
},
|
||||
@@ -100,6 +105,7 @@ it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)(
|
||||
diagnostics,
|
||||
options: undefined,
|
||||
probePath: async () => {},
|
||||
timeoutMs: 30_000,
|
||||
runCommand: async () => {
|
||||
const request: unknown = JSON.parse(
|
||||
await readFile(
|
||||
@@ -147,6 +153,7 @@ it.each(["cancelled", "physical-name", "physical-identity"])(
|
||||
diagnostics: { stdout: "basic evidence", stderr: "", truncated: false },
|
||||
options: { signal: controller.signal },
|
||||
probePath: async () => {},
|
||||
timeoutMs: 30_000,
|
||||
runCommand: async () => {
|
||||
controller.abort(outcome === "physical-identity" ? failure : reason);
|
||||
throw failure;
|
||||
@@ -165,6 +172,35 @@ it.each(["cancelled", "physical-name", "physical-identity"])(
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a non-default debugger deadline instead of 30000 ms", async () => {
|
||||
const workspace = await createTestWorkspace("rea-pwndbg-deadline-");
|
||||
onTestFinished(() => removeTestWorkspace(workspace.root));
|
||||
const failure = await inspectPwndbgCore({
|
||||
environment: {
|
||||
REA_PWNDBG_GDB: workspace.path("gdb"),
|
||||
REA_PWNDBG_GDBINIT: workspace.path("gdbinit.py"),
|
||||
REA_PWNDBG_VENV_PATH: workspace.path("venv"),
|
||||
},
|
||||
architecture: "x86_64-little-endian",
|
||||
rootPath: workspace.root,
|
||||
resources: new ArtifactResourceScope(),
|
||||
diagnostics,
|
||||
options: undefined,
|
||||
probePath: async () => {},
|
||||
timeoutMs: 60_000,
|
||||
runCommand: () => {
|
||||
throw new OwnedCommandFailure("timeout", "deadline expired");
|
||||
},
|
||||
}).catch((cause: unknown) => cause);
|
||||
if (!(failure instanceof AnalysisTimeoutError))
|
||||
throw new Error("Expected a typed debugger timeout");
|
||||
const projected = projectAnalysisError(failure);
|
||||
expect(projected.code).toBe("provider_timeout");
|
||||
expect(projected.message).toContain("of 60000 ms");
|
||||
expect(projected.remediation.action).toContain("above 60000");
|
||||
expect(projected.remediation.action).not.toContain("above 30000");
|
||||
});
|
||||
|
||||
it("retains a physical debugger path failure when cancellation arrives in the probe", async () => {
|
||||
const workspace = await createTestWorkspace("rea-pwndbg-probe-failure-");
|
||||
onTestFinished(() => removeTestWorkspace(workspace.root));
|
||||
@@ -181,6 +217,7 @@ it("retains a physical debugger path failure when cancellation arrives in the pr
|
||||
resources: new ArtifactResourceScope(),
|
||||
diagnostics,
|
||||
options: { signal: controller.signal },
|
||||
timeoutMs: 30_000,
|
||||
probePath: async () => {
|
||||
controller.abort({ late: true });
|
||||
throw physical;
|
||||
|
||||
@@ -259,7 +259,7 @@ describe("observe_native_calls projection: reports partial observation coverage"
|
||||
unresolved_breakpoints: [],
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"Resolved breakpoint locations exceeded the aggregate 8 MiB metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome.",
|
||||
"Resolved breakpoint locations exceeded the aggregate 8388608-byte (8 MiB) metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome. This release has no runtime override.",
|
||||
);
|
||||
expect(result.limitations.join(" ")).not.toContain("process was killed");
|
||||
});
|
||||
@@ -314,7 +314,7 @@ describe("observe_native_calls projection: reports partial observation coverage"
|
||||
expect.arrayContaining([
|
||||
"Observation stopped at max_events (1); later calls were not recorded and the process was killed.",
|
||||
"Breakpoints 1 matched no code in any image loaded while observing; their calls could not be observed.",
|
||||
"Target output beyond 1 MiB per stream is counted but not kept.",
|
||||
"Target output beyond 1048576 bytes (1 MiB) per stream is counted but not kept. This release has no runtime override.",
|
||||
"REA could not confirm that process 4242 exited; check for it before relying on host state.",
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ it("reports a binary source-size refusal as a typed resource limit", async () =>
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action: expect.stringContaining(
|
||||
"within the fixed 64 MiB native plist input allowance",
|
||||
"native plist input allowance (no runtime override)",
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -91,14 +91,16 @@ it.each([
|
||||
value: { data: Buffer.alloc(22 * 1024 * 1024, 0x78) },
|
||||
reason: "binary plist object table exceeds the native plist decode budget",
|
||||
limits: { maximum_decoded_bytes: 64 * 1024 * 1024 },
|
||||
allowance: "The 64 MiB native binary plist decode allowance",
|
||||
allowance:
|
||||
"The 64 MiB (67108864-byte) native binary plist decode allowance (no runtime override)",
|
||||
},
|
||||
{
|
||||
budget: "nesting",
|
||||
value: nested(MAX_BINARY_PLIST_NESTING_DEPTH + 1),
|
||||
reason: "binary plist nesting exceeds the native plist decode budget",
|
||||
limits: { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH },
|
||||
allowance: "The 128-level native binary plist nesting allowance",
|
||||
allowance:
|
||||
"The 128-level native binary plist nesting allowance (no runtime override)",
|
||||
},
|
||||
])(
|
||||
"names the fixed binary plist $budget allowance instead of host memory",
|
||||
|
||||
@@ -1829,3 +1829,54 @@ it("retains an owned preparation cleanup marker when the command deadline expire
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the selected command deadline when preparation times out", async () => {
|
||||
const marker = new ProviderCleanupError(
|
||||
"process-ownership",
|
||||
["owned helper root"],
|
||||
{ reason: "controlled cleanup failure" },
|
||||
);
|
||||
const reason = new Error("command deadline");
|
||||
const inspection = new DarwinProcessOwnershipInspectionError(
|
||||
"cancelled preparation",
|
||||
{ cancellation: { reason }, cleanupFailure: marker },
|
||||
);
|
||||
const owned = new OwnedCommandFailure(
|
||||
"timeout",
|
||||
"command preparation timed out",
|
||||
null,
|
||||
null,
|
||||
{ cause: inspection, preparationFailure: inspection },
|
||||
);
|
||||
const failure = new NativeCommandFailure("swift-demangle", "timeout", null, {
|
||||
cause: owned,
|
||||
});
|
||||
const runner: NativeCommandRunner = { run: async () => err(failure) };
|
||||
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
|
||||
machoTarget("/fixture"),
|
||||
);
|
||||
try {
|
||||
const result = await client.execute("demangle_swift", {
|
||||
symbols: ["fixture"],
|
||||
native_command_timeout_ms: 1_500,
|
||||
});
|
||||
if (result.ok) throw new Error("Expected preparation timeout");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisTimeoutError",
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(result.error.cause).toBe(reason);
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.message).toContain("of 1500 ms");
|
||||
expect(projected.message).toContain("native_command_timeout_ms");
|
||||
expect(projected.remediation.action).toContain("above 1500");
|
||||
expect(projected.remediation.action).not.toContain("above 60000");
|
||||
expect(projected.remediation.action).toContain(
|
||||
`${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance`,
|
||||
);
|
||||
marker.resolveCleanup();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
Vendored
+6
-1
@@ -5,6 +5,7 @@ import type { InspectSignature } from "../../src/domain/native/nativeInspection.
|
||||
import { ok } from "../../src/domain/result.js";
|
||||
import type {
|
||||
NativeCommandCapture,
|
||||
NativeCommandOptions,
|
||||
NativeCommandRunner,
|
||||
} from "../../src/native/CommandRunner.js";
|
||||
|
||||
@@ -15,7 +16,11 @@ export class NativeFixtureRunner implements NativeCommandRunner {
|
||||
private readonly exitCode = 0,
|
||||
) {}
|
||||
|
||||
async run(tool: string, arguments_: readonly string[]) {
|
||||
async run(
|
||||
tool: string,
|
||||
arguments_: readonly string[],
|
||||
_options?: NativeCommandOptions,
|
||||
) {
|
||||
const output =
|
||||
this.overrides[`${tool}:${arguments_[0] ?? ""}`] ??
|
||||
this.overrides[tool] ??
|
||||
|
||||
Reference in new issue
Block a user