Compare commits

..
Author SHA1 Message Date
morluto 243e4015cb docs(ci): remove dated shard measurements from the testing guides (#10414)
* docs(ci): move dated shard measurements into a verification record

The testing guide and testing-lanes guide mixed the October 2026 shard
budget and stale-history measurements into current rules. Move them to
docs/verification/ci-shard-scheduling-2026-10-20.md and link it from
the rules they justify.

* docs(ci): drop dated shard measurements instead of keeping a record
2026-11-14 11:47:46 +08:00
RRFZ 4a4e959513 fix(javascript): retain global escapes across repeated calls 2026-11-14 04:46:27 +01:00
RRFZ 4e1e9b62a7 fix(javascript): preserve global escapes across callback boundaries 2026-11-14 04:46:27 +01:00
RRFZ 54677ec1d6 fix(javascript): preserve timer delays after global escapes 2026-11-14 04:46:27 +01:00
RRFZ 1abd327913 fix(javascript): retain timer delays after opaque global effects 2026-11-14 04:46:27 +01:00
RRFZ 186b2c352c fix(javascript): omit Node timers with rejected delay values 2026-11-14 04:46:27 +01:00
RRFZ 827b31c128 fix(javascript): honor child-process callback overloads 2026-11-14 04:46:27 +01:00
gensokyo-devandClaude Opus 5.5 ee339e5db5 fix(debuggers): keep stopped Rizin and GDB sessions explainable
After REA stopped a Rizin debug session (frame or output limit, deadline,
cancellation, protocol failure) or the debugger exited, later commands
returned a generic invalid_request and status gave no reason. GDB
collapsed the same states the same way.

Both managers now keep a published session addressable until
close_*_session releases it, record the first reason it stopped, report
it as closed_reason in session status, and refuse later commands with
capability_unavailable naming that reason and the close-then-restart
recovery. An ID the server never returned, or that close released, still
fails as invalid_request with its own recovery. Retained stopped
sessions keep their slot, which the capacity recovery now says. A GDB
command deadline now records its reason like cancellation already did.

Fixes #8024

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:42:35 +01:00
gensokyo-devandClaude Opus 5.5 6ce708cfde fix(rizin): report shell refusals instead of complete empty results
Rizin exits 0 when its shell refuses a statement, so execute_rizin_command
reported completion_status complete with exit code 0 and an empty stdout
that read as a genuine empty result.

Rizin 0.6 through 0.9.1 log exactly four refusal lines from
librz/core/cmd/cmd.c: an unknown command, a wrong argument count, an
invalid statement (RZ_CMD_STATUS_INVALID, which also stops the remaining
statements) and a parse error. The Rizin command profile now classifies a
completed process's stderr against those lines; the shared one-shot
service reports completion_status "rejected" and a limitation that names
the refusal and says stdout holds only earlier statements' output. Other
ERROR lines come from commands that ran and stay diagnostics.

Fixes #10364

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:42:35 +01:00
gensokyo-devandClaude Opus 5.5 884b683a94 fix(cli): advertise the decimal token grammar on integer arguments
`cliIntegerArgument` piped a regex-guarded string into a number but never
published Zod's input view, so `--schema` advertised a bare integer for 15
fields while the parser accepted only decimal argv text. `environment-serve
--port` had the same gap through the transforming MCP_PORT owner.

Remove `cliIntegerArgument` and route every call site through
`cliDecimalField`, binding each field to its domain or contract owner where
one exists so bounds, defaults and optionality come from that owner. Export
the input-view publisher as `publishArgvInput` and apply it to `--port`,
which keeps the MCP_PORT grammar shared with `rea mcp --http`.

The decimal catalog test now requires every numeric field's advertisement
to admit its decimal argv token, covers repeated options and
string-advertised token grammars, and checks `--schema` output directly.

The string branch's missing numeric range (#9348) is unchanged.

Fixes #9344

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:27:54 +01:00
159 changed files with 1725 additions and 526 deletions

No files matched your search

+5 -29
View File
@@ -307,10 +307,7 @@ assertion. `vitest.config.ts` applies the deadlines in
and 120 seconds for built projects, with hooks sharing their project's
deadline. Coverage doubles every deadline, and so does hosted CI, where
CPU-bound cases share every core with other workers and their subprocesses.
Do not pass a per-test timeout to fit a slow host. A child started through
the `processTest` registry, including `cli.run`, gets the test's remaining
deadline less a reporting reserve, so a hung child fails with its captured
output. Pass `timeoutMs` only when the child's deadline is itself under test. Tag a bounded workload that needs longer with
Do not pass a per-test timeout to fit a slow host. Tag a bounded workload that needs longer with
`{ tags: ["long-running"] }`, or a whole file with
`// @module-tag long-running`. Assert elapsed time explicitly when speed is the
behavior under test. Derive an explicit deadline only when a test must outlive
@@ -1094,32 +1091,11 @@ existing four-minute timing collection plus the bounded diagnostic stages.
Fast built shards target under ten minutes of uncontended shard-job execution.
That figure is the `test-shard` job. The job waits for classification and the
build, and the portable-test merge runs after the slowest shard. On 2026-10-20,
run `39451548945` measured those surrounding jobs with runner queue excluded:
classification 15 seconds, build 49 seconds, and the portable-test merge 29
seconds. Shard 1/5 of that run spent 22 seconds before built Vitest and 3
seconds after it, including `npm ci` at 9 seconds. `node_modules` is about 674
MiB, so publishing it as a shared artifact would transfer more data than that
install saves. The npm download cache stays the setup cache.
File timings from that run put the built suite at 7944 seconds of module time.
The sequencer keeps each file's full duration on one worker. Extra workers
overlap other files in the same sequence group, and `process-boundary` runs
after that group. Scheduling the unsplit inventory onto five shards peaks at
820 seconds of tests. That run's slowest built shard job lasted 925 seconds
(shard 4/5), and shard 1/5 spent 874 seconds in built Vitest, so hosted job
time sits above the file schedule. Six shards of the same inventory peak at
684 seconds of tests. Eight shards, after the 578-second loader file is split
by case count (54 cases stay in that file and 46 move into each of three
files), peak at 513 seconds of tests. With the measured 25-second shard setup,
that shard job is about 538 seconds. Adding the measured classification, build,
and merge makes an uncontended portable critical path of about 631 seconds,
still before the gap between this file schedule and hosted Vitest. Runner queue
is separate and grows when more shard jobs compete for runners.
build, and the portable-test merge runs after the slowest shard. Runner queue is
separate and grows when more shard jobs compete for runners. The npm download
cache stays the setup cache rather than a shared `node_modules` artifact.
`process-boundary` stays serial because process-tree sampling shares the host.
Raising the worker cap from two to four on this four-core host cut a six-file
acceptance sample from 96–101 seconds to 71 seconds and did not remove the
single-file floor. Hosted CI jobs use four workers; other hosts stay at two
Hosted CI jobs use four workers; other hosts stay at two
because they are shared with other checks. Full qualification keeps six covered
shards; its longer instrumentation is outside this fast budget.
+6
View File
@@ -96,6 +96,10 @@ Persistent command Evidence retains the caller-selected and resolved startup pat
Session status retains up to 64 recent protocol frames. Each history entry keeps a complete-character UTF-8 prefix of at most 16 KiB, plus a truncation marker when needed; `recent_output_truncated` reports omitted history. This history budget also accounts for replacement characters from invalid UTF-8. It does not shorten command Evidence, whose output budget defaults to 16 MiB. Rizin debug startup waits 30000 ms with no runtime override. Each command frame defaults to 120000 ms; its deadline, command-output budget, session capacity and pending-command queue use the [shared debugger launch settings](#debugger-launch-settings). The 16777216-byte frame cap and 4096-frame command cap remain fixed. Evidence names the cap that stopped the command. The one-shot `command_timeout_ms` setting does not change a debug session.
### Stopped debugger sessions
A Rizin or GDB session stops accepting commands when REA stops it, for example after an output or frame limit, a command deadline, cancellation or a protocol failure, or when the debugger process exits. REA keeps the stopped session and the first reason it observed until `close_rizin_debug_session` or `close_gdb_session` releases it, including after a retried cleanup succeeds. `rizin_debug_session_status` and `gdb_session_status` then report `state: "closed"` with that reason in `closed_reason`. A later `rizin_debug_command` or `gdb_console` on the session fails with `capability_unavailable`; `details.reason` carries the same reason, and the remediation is to close the session and start a new one. A `session_id` that this server never returned, or that close already released, fails with `invalid_request` instead.
### Debugger launch settings
GDB and Rizin share these settings in the environment that launches REA:
@@ -136,6 +140,8 @@ rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr
rea execute-rizin-command ./program iI
```
Rizin exits with status 0 even when its shell refuses a statement, so the exit code does not show whether the command ran. Rizin 0.6 through 0.9.1 log one of four stderr lines for such a refusal: `ERROR: Command '…' does not exist.`, ``ERROR: Wrong number of arguments passed to `…`…``, `ERROR: core: Error while executing command: …` (an invalid statement), or `ERROR: core: Error while parsing command: …`. Rizin then skips the refused statement and every later one in the command. When the completed process's stderr contains one of these lines, REA reports `completion_status: "rejected"` and adds a limitation naming the refusal. stdout then holds only the output of earlier statements, so empty stdout is not an empty result. Other `ERROR:` lines, such as `ERROR: Cannot find function at …`, come from commands that ran; REA keeps them in stderr and reports `complete`. A command that lowers `log.level` or redirects stderr hides refusals from REA.
Captured stdout and stderr use the same `max_output_bytes` (CLI: `--max-output-bytes`) admission and recovery described above, default 16 MiB. One-shot commands independently use `command_timeout_ms`. Raising output admission cannot recover an earlier capture; command effects and cleanup must be checked before another invocation.
### Decoder and map inputs
+2 -8
View File
@@ -842,14 +842,8 @@ branches are not producers. A tier with no qualifying artifact leaves history
unset, and Vitest uses its own assignment. A later schedule that reuses
qualification without uploading timings leaves the previous artifact in place.
On 2026-10-21 the newest full history was run
`39545047294`, recorded before compiled-CLI session reuse. Fast run
`39552964873` packed its eight built shards from that history, and those shard
jobs executed for 319–591 seconds. Replaying that run's file durations through
the sequencer's two-worker schedule balances every shard at about 404 seconds
of file time. The same replay with the stale history weights leaves the slowest
shard near 547 seconds and the fastest near 293. That spread is file-schedule
time, not runner queue, which is why each tier keeps its own history.
Each tier keeps its own history because stale weights from another tier, or
from before a large test change, unbalance the shards.
CI assigns discovered files by historical duration rather than dividing only
by file count. A file with no recorded duration, such as a newly added or
renamed test, is weighted at the median recorded duration until a published
@@ -296,6 +296,7 @@ it(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", CATALOG_PROGRAM],
{
timeoutMs: 50_000,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
@@ -102,6 +102,7 @@ it("repairs equal-launcher Copilot JSONC syntax while strict JSON and standard J
process.execPath,
["--import", "tsx", entrypoint],
{
timeoutMs: 20_000,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
@@ -11,6 +11,11 @@ export interface ReverseEngineeringProviderCommand {
readonly identity: EvidenceProvider;
/** What the provider cannot attest about the arguments it built. */
readonly limitations?: readonly string[];
/**
* Explain a provider refusal of the command that a completed process
* reported only in its diagnostics, or return undefined.
*/
readonly rejection?: (stderr: string) => string | undefined;
readonly environmentOverrides?: Readonly<NodeJS.ProcessEnv>;
}
@@ -599,7 +599,7 @@ export class ReverseEngineeringService {
let stderrInvalidUtf8 = false;
let cleanupIncomplete = false;
let cleanupOnlyFailure = false;
let completionStatus: "complete" | "unknown" = "complete";
let completionStatus: "complete" | "rejected" | "unknown" = "complete";
let outputLimitReached = false;
let failure: AnalysisError | undefined;
let cleanupOwner: ProviderProcessSupervisor | undefined;
@@ -1076,6 +1076,15 @@ export class ReverseEngineeringService {
: digestAfter !== digest;
const outputBudgetSource =
parameters.max_output_bytes === undefined ? "default" : "request";
// Only a completed process's diagnostics can establish a refusal; partial
// or failed captures keep their unknown completion.
const rejection =
completionStatus === "complete" &&
(failure === undefined ||
(failure instanceof AnalysisOutputError && failure.cleanupOnly))
? profile.rejection?.(stderr)
: undefined;
if (rejection !== undefined) completionStatus = "rejected";
const result = {
stdout,
stderr,
@@ -1116,6 +1125,7 @@ export class ReverseEngineeringService {
]
: []),
...(profile.limitations ?? []),
...(rejection === undefined ? [] : [rejection]),
...(stdoutInvalidUtf8 || stderrInvalidUtf8
? [
`Captured ${[
+13 -9
View File
@@ -21,15 +21,19 @@ it("retains escape-heavy strings within a heap near their decoded size", async (
if (!Array.isArray(parsed) || parsed.length !== 463 || parsed.some((value) => value !== body)) process.exit(3);
`;
const child = await processes.run(process.execPath, [
"--max-old-space-size=48",
"--max-semi-space-size=8",
"--import",
"tsx",
"--input-type=module",
"--eval",
program,
]);
const child = await processes.run(
process.execPath,
[
"--max-old-space-size=48",
"--max-semi-space-size=8",
"--import",
"tsx",
"--input-type=module",
"--eval",
program,
],
{ timeoutMs: 30_000 },
);
expect(child.exitCode, child.stderr).toBe(0);
});
+7 -7
View File
@@ -22,7 +22,7 @@ import { parseAnalysisInput } from "../application/AnalysisInput.js";
import { androidJniSelectionSchema } from "../domain/android/androidJniBindings.js";
import { androidJniDeliverySchema } from "../domain/android/androidJniQuery.js";
import { androidInputSchemas } from "../domain/android/androidAnalysis.js";
import { cliIntegerArgument } from "./options.js";
import { cliDecimalField } from "./options.js";
/** Expose the same explicit APK requests through one-shot CLI commands. */
export const registerAndroidCommands = (
@@ -223,11 +223,11 @@ export const registerAndroidCommands = (
),
}),
options: z.object({
overloadIndex: cliIntegerArgument(0)
.optional()
.describe(
overloadIndex: cliDecimalField(
androidInputSchemas.inspect_android_method.shape.overload_index.describe(
"Explicit zero-based index among same-name methods; required when ambiguous",
),
),
mappingPath,
...analysisEvidenceDeliveryOptions,
}),
@@ -255,11 +255,11 @@ export const registerAndroidCommands = (
options: z.object({
methodName:
androidInputSchemas.trace_android_references.shape.method_name,
overloadIndex: cliIntegerArgument(0)
.optional()
.describe(
overloadIndex: cliDecimalField(
androidInputSchemas.trace_android_references.shape.overload_index.describe(
"Index among defined methods or observed external prototypes listed by an ambiguous query",
),
),
}),
run: ({ args, options }) =>
execute(
+6 -3
View File
@@ -8,7 +8,8 @@ import { logCliCommand } from "../cliLogging.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { cliDecimalField } from "./options.js";
import { cutterCommandInputSchema } from "../contracts/cutterToolContracts.js";
/** Register commands that control an already-running Cutter instance. */
export const registerCutterCommands = (
@@ -43,8 +44,10 @@ export const registerCutterCommands = (
.string()
.uuid()
.describe("Session ID copied from list-cutter-sessions."),
expectedGeneration: cliIntegerArgument(0).describe(
"Session generation returned by list-cutter-sessions.",
expectedGeneration: cliDecimalField(
cutterCommandInputSchema.shape.expected_generation.describe(
"Session generation returned by list-cutter-sessions.",
),
),
command: z
.string()
+8 -6
View File
@@ -38,7 +38,7 @@ import { inspectWebNetworkCaptureInputSchema } from "../domain/webNetworkCapture
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { CliInstance } from "./types.js";
import { cliDecimalField } from "./options.js";
import { cliDecimalField, publishArgvInput } from "./options.js";
/** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */
const cliError = (error: AnalysisError): JsonValue => ({
@@ -689,11 +689,13 @@ export const registerEnvironmentCommands = (
host: mcpHttpHostSchema
.optional()
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
port: mcpHttpPortSchema
.optional()
.describe(
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
),
port: publishArgvInput(
mcpHttpPortSchema
.optional()
.describe(
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
),
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
+6 -11
View File
@@ -24,13 +24,6 @@ type DecimalWrapper =
readonly kind: "optional" | "exactOptional" | "nullable" | "readonly";
};
/** Parse decimal integer tokens before Number coercion can erase their syntax. */
export const cliIntegerArgument = (min: number) =>
z
.string()
.regex(DECIMAL_INTEGER_TOKEN, "Expected a decimal integer")
.pipe(z.coerce.number<string>().safe().min(min));
const decimalToken = (domain: z.ZodNumber) =>
z
.string()
@@ -42,10 +35,12 @@ const unwrapSchema = (field: { unwrap: () => unknown }): z.ZodType =>
field.unwrap() as z.ZodType;
/**
* Incur advertises Zod's output view. Publish the input view so the decimal
* token guard survives `--schema` and other SDK conversions.
* Incur advertises Zod's output view. Publish the input view so an argv token
* grammar that transforms text survives `--schema` and other SDK conversions.
*/
const publishDecimalInput = (field: z.ZodType): z.ZodType => {
export const publishArgvInput = <Field extends z.ZodType>(
field: Field,
): Field => {
const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(field, {
io: "input",
});
@@ -149,7 +144,7 @@ export const cliDecimalField = <Field extends z.ZodType>(
numeric.description === undefined
? admitted
: admitted.describe(numeric.description);
return publishDecimalInput(described) as z.ZodType<z.output<Field>>;
return publishArgvInput(described) as z.ZodType<z.output<Field>>;
};
/** Adapt each numeric property of a shared shape; other properties stay unchanged. */
+1 -1
View File
@@ -80,7 +80,7 @@ const cleanupOnlyCompleteEvidence = (
return typeof normalized === "object" &&
normalized !== null &&
!Array.isArray(normalized) &&
normalized.completion_status === "complete"
normalized.completion_status !== "unknown"
? parsed.data
: undefined;
};
+28 -14
View File
@@ -1,6 +1,6 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { cliDecimalField, cliIntegerArgument } from "./options.js";
import { cliDecimalField } from "./options.js";
import type { DirectAnalysis } from "../composition/directAnalysis.js";
import { createHistoricalSourceSummary } from "../domain/referenceSourceSummary.js";
@@ -21,7 +21,10 @@ import {
} from "../domain/analysisErrorCore.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import { nativeUiScenarioInputSchema } from "../domain/native/nativeUiObservation.js";
import {
nativeUiObservationInputSchema,
nativeUiScenarioInputSchema,
} from "../domain/native/nativeUiObservation.js";
import {
nativeCommandTimeoutField,
nativeUiDeadlineField,
@@ -49,11 +52,16 @@ export const registerUtilityCommands = (
registerReferenceSourceCommand(cli, logger, environment);
};
// Decimal tokens only; shared fields own their bounds and help.
// Decimal tokens only; shared fields own their bounds and defaults.
const nativeUiScopeFields = nativeUiObservationInputSchema.shape;
const nativeUiScopeOptions = {
pid: cliIntegerArgument(1).describe("Existing application PID"),
windowId: cliIntegerArgument(1).describe(
"Exact selected application window ID",
pid: cliDecimalField(
nativeUiScopeFields.pid.describe("Existing application PID"),
),
windowId: cliDecimalField(
nativeUiScopeFields.window_id.describe(
"Exact selected application window ID",
),
),
screenshot: z
.boolean()
@@ -63,9 +71,11 @@ const nativeUiScopeOptions = {
.boolean()
.default(true)
.describe("Read the selected window accessibility tree"),
maxNodes: cliIntegerArgument(1)
.default(500)
.describe("Maximum accessibility nodes per capture"),
maxNodes: cliDecimalField(
nativeUiScopeFields.max_nodes.describe(
"Maximum accessibility nodes per capture",
),
),
nativeUiDeadlineMs: cliDecimalField(nativeUiDeadlineField),
};
@@ -364,11 +374,15 @@ const registerReferenceSourceCommand = (
.describe(
"Complete committed graph or compact historical-source summary",
),
outlineDepth: cliIntegerArgument(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
outlineDepth: cliDecimalField(
z
.int()
.min(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
),
})
.superRefine((options, context) => {
if (options.outlineDepth !== undefined && options.detail !== "summary")
+5 -3
View File
@@ -6,7 +6,7 @@ import { logCliCommand } from "../cliLogging.js";
import { webModuleTraceInputSchema } from "../domain/webModuleTrace.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { cliDecimalField } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
@@ -25,8 +25,10 @@ export const registerWebModuleCommands = (
"Trace one captured script's native imports to URLs and captured source candidates",
args: z.object({
manifestPath: webModuleTraceInputSchema.shape.manifest_path,
scriptIndex: cliIntegerArgument(0).describe(
"Zero-based manifest script index",
scriptIndex: cliDecimalField(
webModuleTraceInputSchema.shape.script_index.describe(
"Zero-based manifest script index",
),
),
}),
options: z.object({
+5 -2
View File
@@ -8,7 +8,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliDecimalField, cliIntegerArgument } from "./options.js";
import { cliDecimalField } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import {
analysisEvidenceDeliveryOptions,
@@ -18,6 +18,9 @@ import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { explicitSensitiveValueOptions } from "./explicitSensitiveArguments.js";
const recordOrdinal =
inspectWebNetworkCaptureInputSchema.shape.record_ordinals.unwrap().element;
/** Expose offline historical capture inspection with the same selection contract as MCP. */
export const registerWebNetworkCaptureCommands = (
cli: CliInstance,
@@ -51,7 +54,7 @@ export const registerWebNetworkCaptureCommands = (
.optional()
.describe("Fully qualified message type in the descriptor set"),
record: z
.array(cliIntegerArgument(0))
.array(cliDecimalField(recordOrdinal))
.optional()
.describe("Zero-based producer ordinals; repeat to select several"),
...explicitSensitiveValueOptions,
+13 -6
View File
@@ -16,10 +16,11 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliDecimalField, cliIntegerArgument } from "./options.js";
import { cliDecimalField } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
const sourceMapSelection = webSourceLocationInputSchema.shape.source_map;
const generatedPosition = webSourceLocationInputSchema.shape.generated_position;
/** Expose the same explicit selected map/point contract through the CLI. */
export const registerWebSourceLocationCommands = (
@@ -33,14 +34,20 @@ export const registerWebSourceLocationCommands = (
"Trace one retained script position through an explicitly selected local source map",
args: z.object({
manifestPath: webSourceLocationInputSchema.shape.manifest_path,
scriptIndex: cliIntegerArgument(0).describe(
"Zero-based manifest script index",
scriptIndex: cliDecimalField(
webSourceLocationInputSchema.shape.script_index.describe(
"Zero-based manifest script index",
),
),
sourceMapPath: sourceMapSelection.shape.path,
sourceMapUrl: sourceMapSelection.shape.url,
line: cliIntegerArgument(1).describe("One-based generated line"),
column: cliIntegerArgument(0).describe(
"Zero-based UTF-16 generated column",
line: cliDecimalField(
generatedPosition.shape.line.describe("One-based generated line"),
),
column: cliDecimalField(
generatedPosition.shape.column.describe(
"Zero-based UTF-16 generated column",
),
),
}),
options: z.object({
+2
View File
@@ -6,6 +6,7 @@ import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js
import {
rizinCommand,
rizinCommandLimitations,
rizinCommandRejection,
} from "../rizin/RizinCommand.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js";
@@ -32,6 +33,7 @@ export const createReverseEngineeringCommandPort = (
),
arguments: rizinCommand(input),
limitations: rizinCommandLimitations(input),
rejection: rizinCommandRejection,
identity: RIZIN_PROVIDER_IDENTITY,
}),
});
+7 -1
View File
@@ -63,7 +63,7 @@ export const GDB_TOOL_CONTRACTS = [
...toolContractMetadata("gdb_session_status"),
kind: "native-provider",
description:
"Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; truncation and invalid-UTF-8 fields report discarded or lossy data.",
"Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; closed_reason says why a closed session stopped, and truncation and invalid-UTF-8 fields report discarded or lossy data.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: z.strictObject({
session_id: sessionId,
@@ -79,6 +79,12 @@ export const GDB_TOOL_CONTRACTS = [
diagnostics_truncated: z.boolean(),
inferior_tty: z.string().nullable(),
inferior_tty_verified: z.boolean(),
closed_reason: z
.string()
.exactOptional()
.describe(
"Present when state is closed: why the session stopped accepting commands. Close it and start a new session to continue.",
),
}),
examples: [
{
@@ -39,7 +39,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
...toolContractMetadata("execute_rizin_command"),
kind: "native-provider",
description:
"Run one caller-selected Rizin command on a local artifact. Commands/plugins can execute code, mutate files, access network and read outside the artifact; those external inputs are not Evidence-bound. Suppresses user settings/scripts but retains plugins. completion_status reports process completion; Rizin can reject a command and exit with code 0. Inspect stdout/stderr before treating empty output as absence. Raw stdout/stderr is authoritative, JSON is command-specific, and UTF-8 loss/truncation is explicit; output-limit termination leaves completion unknown. For raw images, asm_arch/asm_bits/asm_cpu select decoding and base_address maps offset 0; base_address also passes -n to skip loaders even on ELF, without REA confirming either. An admitted command retires shared binary analysis: close Ghidra explicitly first, then open_binary after effects settle. command_timeout_ms is per-phase and max_output_bytes bounds combined output (16 MiB default); both apply next call without restart and do not raise client receive limits or persistent debugger deadlines.",
"Run one caller-selected Rizin command on a local artifact. Commands/plugins can execute code, mutate files, access network and read outside the artifact; those external inputs are not Evidence-bound. Suppresses user settings/scripts but retains plugins. Raw stdout/stderr is authoritative, JSON is command-specific, and UTF-8 loss/truncation is explicit; output-limit termination leaves completion unknown. Rizin exits 0 even when its shell refuses a statement; completion_status rejected marks that refusal, and later statements did not run. Other command failures can still exit 0, so inspect stderr before treating empty output as absence. For raw images, asm_arch/asm_bits/asm_cpu select decoding and base_address maps offset 0; base_address also passes -n to skip loaders even on ELF, without REA confirming either. An admitted command retires shared binary analysis: close Ghidra explicitly first, then open_binary after effects settle. command_timeout_ms is per-phase and max_output_bytes bounds combined output (16 MiB default); both apply next call without restart and do not raise client receive limits or persistent debugger deadlines.",
inputSchema: rizinInputSchema,
outputSchema: evidenceResultOf(commandObservationSchema),
examples: [
+7 -1
View File
@@ -72,7 +72,7 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [
...toolContractMetadata("rizin_debug_session_status"),
kind: "native-provider",
description:
"Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.",
"Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; closed_reason says why a closed session stopped, truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: z.strictObject({
session_id: sessionId,
@@ -82,6 +82,12 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [
recent_output_truncated: z.boolean(),
diagnostics_truncated: z.boolean(),
output_invalid_utf8: z.boolean(),
closed_reason: z
.string()
.exactOptional()
.describe(
"Present when state is closed: why the session stopped accepting commands. Close it and start a new session to continue.",
),
}),
examples: [
{
+5 -1
View File
@@ -9,7 +9,11 @@ export const commandObservationSchema = z.strictObject({
output_budget_bytes: z.int().positive().exactOptional(),
output_budget_source: z.enum(["default", "request"]).exactOptional(),
output_truncated: z.boolean(),
completion_status: z.enum(["complete", "unknown"]),
completion_status: z
.enum(["complete", "rejected", "unknown"])
.describe(
"complete: the provider process completed. rejected: it completed, but the provider reported that it refused the command; limitations say what ran. unknown: the capture or process ended early.",
),
artifact_sha256_after: z
.string()
.regex(/^[a-f0-9]{64}$/u)
+58
View File
@@ -0,0 +1,58 @@
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "./analysisErrorCore.js";
/** Tool names and wording for one persistent debugger's session errors. */
export interface DebuggerSessionTools {
readonly providerId: string;
readonly name: string;
readonly start: string;
readonly close: string;
}
/** A session_id this server does not hold, unlike a retained stopped session. */
export const unknownDebuggerSessionError = (
tools: DebuggerSessionTools,
operation: string,
): AnalysisInputError =>
new AnalysisInputError(
operation,
{
conformsToInputContract: true,
userMessage: `No ${tools.name} debug session has this session_id in this REA server.`,
remediationAction: `Use a session_id that ${tools.start} returned in this server process; ${tools.close} releases a session's ID. Call ${tools.start} to begin a new session.`,
},
[
{
path: ["session_id"],
reason: "invalid_value",
message: `No ${tools.name} debug session has this session_id in this REA server.`,
},
],
);
/** Refuse a command on a retained session that stopped, naming why it stopped. */
export const stoppedDebuggerSessionError = (
tools: DebuggerSessionTools,
operation: string,
reason: string,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(tools.providerId, operation, reason, {
userMessage: `The ${tools.name} debug session stopped and accepts no more commands. ${reason}`,
remediationAction: `Call ${tools.close} with this session_id to release the stopped session, then call ${tools.start} to begin a new one. Earlier commands may have changed the target, so confirm their effects before repeating them.`,
});
export const RIZIN_DEBUG_SESSION_TOOLS = {
providerId: "rizin",
name: "Rizin",
start: "start_rizin_debug_session",
close: "close_rizin_debug_session",
} as const satisfies DebuggerSessionTools;
export const GDB_SESSION_TOOLS = {
providerId: "gdb",
name: "GDB",
start: "start_gdb_session",
close: "close_gdb_session",
} as const satisfies DebuggerSessionTools;
@@ -961,22 +961,15 @@ const createGlobalPathRead = (
};
byNamespace.set(namespace, effects);
}
// The call that escapes a receiver contains the callee being identified.
// That escape cannot have replaced the callee before the call selects it.
const escapePrecedesRead = effects.escapes.some((effect) => {
const escape = effect.node;
const escapePrecedesRead = effects.escapes.some(({ node }) => {
// Selecting this callee precedes exposing its own receiver. Other reads,
// including arguments, need activation-aware ordering across repetitions.
if (
escape !== undefined &&
escape !== site &&
typeof escape.start === "number" &&
typeof escape.end === "number" &&
typeof site.start === "number" &&
typeof site.end === "number" &&
site.start >= escape.start &&
site.end <= escape.end
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
node.callee === site
)
return false;
return semanticEffectTiming(site, escape, state) !== "after";
return semanticEffectTiming(site, node, state) !== "after";
});
if (
(escapesCovered && effects.escapes.length === 0) ||
@@ -0,0 +1,67 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
const spawns = (source: string) =>
analyzeJavaScriptSemantics(source).childProcessSpawns;
it.each(["spawn", "fork"])("rejects function-valued %s options", (method) => {
expect(
spawns(`
import { ${method} } from "node:child_process";
${method}("worker.js", () => {});
${method}("worker.js", [], function options() {});
${method}("worker.js", [], class Options {});
`),
).toEqual([]);
});
it("rejects a literal exec callback after explicit options", () => {
expect(
spawns(`
import { exec } from "node:child_process";
exec("node --version", {}, false);
exec("node --version", null, {});
`),
).toEqual([]);
});
it("selects execFile callbacks with and without an argv array", () => {
expect(
spawns(`
import { execFile } from "node:child_process";
execFile("node", {}, { cwd: "." });
execFile("node", [], {}, false);
execFile("node", null, {}, "callback");
`),
).toEqual([]);
});
it("keeps callback overloads and nullish callbacks", () => {
expect(
spawns(`
import { exec, execFile } from "node:child_process";
exec("node --version", () => {}, false);
exec("node --version", {}, null);
execFile("node", () => {}, { cwd: false });
execFile("node", [], () => {}, false);
execFile("node", {}, null);
execFile("node", [], {}, undefined);
`),
).toHaveLength(6);
});
it("leaves uncertain callback slots and spread positions undecided", () => {
expect(
spawns(`
import { exec, execFile, spawn } from "node:child_process";
function run(options, callback, args) {
exec("node --version", options, false);
exec("node --version", {}, callback);
execFile("node", options, false);
execFile("node", [], options, false);
execFile("node", ...args, {}, false);
spawn("node", ...args, () => {});
}
`),
).toHaveLength(6);
});
@@ -0,0 +1,189 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
const operations = (source: string) =>
analyzeJavaScriptSemantics(source).timerOperations;
it.each(["setTimeout", "setInterval"])(
"does not create a %s handle when delay conversion must throw",
(method) => {
const result = operations(`
import { ${method}, clearTimeout } from "node:timers";
const positive = ${method}(() => {}, 1n);
const negative = ${method}(() => {}, -1n);
const zero = ${method}(() => {}, 0n);
const symbol = ${method}(() => {}, Symbol("delay"));
const converted = ${method}(() => {}, BigInt("1"));
clearTimeout(positive);
`);
expect(result.filter(({ kind }) => kind === "schedule")).toEqual([]);
expect(result).toMatchObject([
{ kind: "cancel", resolution: "unresolved", linkedTimerId: null },
]);
},
);
it("keeps supported delays and the immediate callback's BigInt argument", () => {
const result = operations(`
import { setTimeout, setInterval, setImmediate } from "node:timers";
setTimeout(() => {}, "2");
setInterval(() => {}, null);
setTimeout(() => {}, !1n);
setTimeout(() => {}, void 1n);
setImmediate(() => {}, 1n);
`);
expect(result.map(({ method }) => method)).toEqual([
"setTimeout",
"setInterval",
"setTimeout",
"setTimeout",
"setImmediate",
]);
});
it("retains uncertain delays, shifted arguments and shadowed constructors", () => {
const result = operations(`
import { setTimeout } from "node:timers";
function schedule(delay, args, Symbol, BigInt) {
setTimeout(() => {}, delay);
setTimeout(...args, 1n);
setTimeout(() => {}, ...args);
setTimeout(() => {}, Symbol("delay"));
setTimeout(() => {}, BigInt("1"));
}
`);
expect(result).toHaveLength(5);
});
it.each([
'eval("Symbol = () => 1; BigInt = () => 1");',
'(0, eval)("Symbol = () => 1; BigInt = () => 1");',
'Function("Symbol = () => 1; BigInt = () => 1")();',
])(
"retains delays after global code may replace constructors: %s",
(effect) => {
expect(
operations(`
import { setTimeout, setInterval } from "node:timers";
${effect}
setTimeout(() => {}, Symbol("delay"));
setInterval(() => {}, BigInt("1"));
`).map(({ method }) => method),
).toEqual(["setTimeout", "setInterval"]);
},
);
it("keeps literal rejection independent of global code effects", () => {
expect(
operations(`
import { setTimeout } from "node:timers";
eval(code);
setTimeout(() => {}, 1n);
`),
).toEqual([]);
});
it("ignores later global code and shadowed eval for constructor identity", () => {
expect(
operations(`
import { setTimeout } from "node:timers";
setTimeout(() => {}, Symbol("delay"));
eval("Symbol = () => 1");
`),
).toEqual([]);
expect(
operations(`
import { setTimeout } from "node:timers";
function run(eval) {
eval("Symbol = () => 1");
setTimeout(() => {}, Symbol("delay"));
}
`),
).toEqual([]);
});
it.each([
["Symbol", "configure(globalThis);"],
["BigInt", "configure(globalThis);"],
["Symbol", "const host = globalThis; configure(host);"],
["BigInt", "configure({ host: globalThis });"],
])("retains %s delays and cancellation after %s", (constructor, effect) => {
const result = operations(`
import configure from "./configure.js";
import { setTimeout, clearTimeout } from "node:timers";
${effect}
const handle = setTimeout(() => {}, ${constructor}("1"));
clearTimeout(handle);
`);
expect(result).toMatchObject([
{ kind: "schedule", resolution: "complete" },
{ kind: "cancel", resolution: "complete" },
]);
expect(result[1]?.linkedTimerId).toBe(result[0]?.timerId);
});
it("still rejects literal BigInt delays after the global object escapes", () => {
expect(
operations(`
import configure from "./configure.js";
import { setTimeout, setInterval } from "node:timers";
configure(globalThis);
setTimeout(() => {}, 1n);
setInterval(() => {}, -1n);
`),
).toEqual([]);
});
it("does not let later or unrelated escapes change constructor identity", () => {
expect(
operations(`
import configure from "./configure.js";
import { setTimeout, setInterval } from "node:timers";
configure({});
setTimeout(() => {}, Symbol("1"));
setInterval(() => {}, BigInt("1"));
configure(globalThis);
`),
).toEqual([]);
});
it("selects a constructor before an argument can escape the global object", () => {
expect(
operations(`
import configure from "./configure.js";
import { setTimeout } from "node:timers";
setTimeout(() => {}, Symbol(configure(globalThis)));
`),
).toEqual([]);
});
it.each(["Symbol", "BigInt"])(
"retains %s identity uncertainty inside an escaped callback",
(constructor) => {
const result = operations(`
import configure from "./configure.js";
import { setTimeout, clearTimeout } from "node:timers";
configure(globalThis, () => {
const handle = setTimeout(() => {}, ${constructor}("1"));
clearTimeout(handle);
});
`);
expect(result).toMatchObject([
{ kind: "schedule", resolution: "complete" },
{ kind: "cancel", resolution: "complete" },
]);
expect(result[1]?.linkedTimerId).toBe(result[0]?.timerId);
},
);
it.each([
'for (let i=0;i<2;i++) configure(globalThis, i ? setTimeout(()=>{}, Symbol("1")) : null);',
'function run(first) { configure(globalThis, first ? null : setTimeout(()=>{}, BigInt("1"))); } run(true); run(false);',
'let first=true; while (next()) { configure(globalThis, first ? null : setTimeout(()=>{}, Symbol("1"))); first=false; }',
])("retains constructor uncertainty across repeated escapes: %s", (source) => {
expect(
operations(
`import configure from "./configure.js"; import {setTimeout} from "node:timers"; ${source}`,
),
).toMatchObject([{ kind: "schedule" }]);
});
@@ -19,8 +19,14 @@ import {
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js";
import {
createGlobalReferenceReader,
isUnmodifiedGlobalReference,
} from "./javascriptSemanticAmbientWrites.js";
import {
semanticOpaqueGlobalMayHaveChanged,
semanticOpaqueNameMayHaveChanged,
} from "./javascriptSemanticOpaqueEffects.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
builtinDataEffectIdentity,
@@ -109,6 +115,10 @@ interface TimerCollectionInput {
readonly ownerCallableId: string | null;
readonly state: JavaScriptSemanticAnalysisState;
readonly output: TimerCandidate[];
readonly delayConstructors: ReadonlyMap<
string,
ReturnType<typeof createGlobalReferenceReader>
>;
readonly ancestors: readonly t.Node[];
}
@@ -120,6 +130,10 @@ export const collectJavaScriptSemanticAsyncEffects = (
): AsyncEffectAnalysis => {
const events: JavaScriptSemanticEventOperation[] = [];
const timers: TimerCandidate[] = [];
const delayConstructors = new Map([
["Symbol", createGlobalReferenceReader(state, ["Symbol"])],
["BigInt", createGlobalReferenceReader(state, ["BigInt"])],
]);
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack = createSemanticCallableIdStack(callableIds);
const ancestors: t.Node[] = [];
@@ -134,6 +148,7 @@ export const collectJavaScriptSemanticAsyncEffects = (
ownerCallableId,
state,
output: timers,
delayConstructors,
ancestors,
});
}
@@ -376,6 +391,38 @@ const schedulesWithNonFunctionCallback = (
return t.isUnaryExpression(value);
};
// Node converts timeout and interval delays to numbers before creating a handle.
// BigInt and Symbol values throw there. A spread can shift the delay slot, and
// shadowed or replaced constructors cannot establish the argument's type.
const schedulesWithInvalidDelay = (
node: t.CallExpression | t.OptionalCallExpression,
state: JavaScriptSemanticAnalysisState,
delayConstructors: TimerCollectionInput["delayConstructors"],
): boolean => {
if (
node.arguments.slice(0, 2).some((argument) => t.isSpreadElement(argument))
)
return false;
const argument = node.arguments[1];
if (argument === undefined) return false;
const value = unwrapJavaScriptExpression(argument).node;
if (t.isBigIntLiteral(value)) return true;
if (
t.isUnaryExpression(value) &&
["+", "-", "~"].includes(value.operator) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)
)
return true;
return (
t.isCallExpression(value) &&
t.isIdentifier(value.callee) &&
delayConstructors.get(value.callee.name)?.(value.callee).status ===
"unmodified" &&
!semanticOpaqueNameMayHaveChanged(value.callee, state) &&
!semanticOpaqueGlobalMayHaveChanged(value.callee, state)
);
};
const collectTimer = (input: TimerCollectionInput): void => {
const { node, ownerCallableId, state, output, ancestors } = input;
const identity = timerMethod(node.callee, state);
@@ -384,7 +431,9 @@ const collectTimer = (input: TimerCollectionInput): void => {
if (
TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) &&
nodeBuiltinTimerMethod(node.callee, state) !== null &&
schedulesWithNonFunctionCallback(node, input.state)
(schedulesWithNonFunctionCallback(node, state) ||
((method === "setTimeout" || method === "setInterval") &&
schedulesWithInvalidDelay(node, state, input.delayConstructors)))
)
return;
const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method)
@@ -79,7 +79,8 @@ export const collectJavaScriptSemanticChildProcesses = (
!forkRejectsStdioArray(candidate, context) &&
!rejectsStdioLiteral(candidate, context) &&
!rejectsProcessOptionLiteral(candidate, context) &&
!rejectsOptionsShape(candidate, context, state),
!rejectsOptionsShape(candidate, context, state) &&
!rejectsCallback(candidate, context),
);
const childProcessSpawns = candidates.map((candidate) =>
immutableSpawn(candidate, context, state),
@@ -647,6 +648,13 @@ const rejectsOptionsShape = (
if (options === undefined) return false;
const value = unwrapJavaScriptExpression(options).node;
if (context.isArray(value)) return true;
if (
candidate.method !== "execFile" &&
(t.isFunctionExpression(value) ||
t.isArrowFunctionExpression(value) ||
t.isClassExpression(value))
)
return true;
const literal = readExactJavaScriptLiteral(value);
if (literal.found)
return !(candidate.method !== "spawn" && literal.value === null);
@@ -662,6 +670,52 @@ const rejectsOptionsShape = (
return isUnpricedPrimitiveLiteral(value);
};
/** Select only callback slots whose overload is established by source syntax. */
const rejectsCallback = (
candidate: SpawnCandidate,
context: ChildSpawnContext,
): boolean => {
if (candidate.method !== "exec" && candidate.method !== "execFile")
return false;
const args = candidate.node.arguments;
if (args.slice(0, 4).some((argument) => t.isSpreadElement(argument)))
return false;
const second = args[1] && unwrapJavaScriptExpression(args[1]).node;
const third = args[2] && unwrapJavaScriptExpression(args[2]).node;
let callback: t.Node | undefined;
if (candidate.method === "exec") {
// A function in slot 1 replaces slot 2, even when slot 2 is not callable.
if (
second !== undefined &&
!context.isNullish(second) &&
!rejectsSemanticEventListener(second)
)
return false;
callback = third;
} else if (t.isObjectExpression(second) || t.isRegExpLiteral(second)) {
callback = third;
} else if (
second === undefined ||
context.isArray(second) ||
context.isNullish(second)
) {
// With argv present, a third-slot function replaces the fourth callback.
if (
third !== undefined &&
!context.isNullish(third) &&
!rejectsSemanticEventListener(third)
)
return false;
callback = args[3];
} else {
return false;
}
return (
callback !== undefined &&
!context.isNullish(callback) &&
rejectsSemanticEventListener(callback)
);
};
/**
* The exact-value reader deliberately refuses to price a BigInt, a non-finite
* numeric literal such as `1e400`, and a negated numeric literal such as `-0`.
+52 -36
View File
@@ -13,6 +13,11 @@ import {
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
GDB_SESSION_TOOLS,
stoppedDebuggerSessionError,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import {
AnalysisError,
type AnalysisErrorOptions,
@@ -542,6 +547,8 @@ interface GdbSession {
noInferiorVerified: boolean;
/** Sticky admission seal once destructive cleanup starts, including failed stops. */
cleanupStarted: boolean;
/** close_gdb_session started cleanup, so a successful retry releases the ID. */
closeRequested: boolean;
stopAttempt: ReturnType<typeof stopOwnedDebuggerProcess> | undefined;
readonly pending: Map<number, PendingCommand>;
readonly cleanupFailures: Set<AnalysisError>;
@@ -566,17 +573,8 @@ interface GdbSession {
recentRecordsTruncated: boolean;
}
const gdbConsoleUnavailable = (): AnalysisInputError =>
new AnalysisInputError("gdb_console", undefined, [
{
path: ["session_id"],
reason: "invalid_value",
message: "GDB session is unavailable or closed.",
},
]);
const gdbCloseUnavailable = (): AnalysisInputError =>
new AnalysisInputError("close_gdb_session");
unknownDebuggerSessionError(GDB_SESSION_TOOLS, "close_gdb_session");
/** Owns persistent GDB/MI sessions independently from the active binary target. */
export class GdbSessionManager {
@@ -769,7 +767,7 @@ export class GdbSessionManager {
"start_gdb_session",
"memory",
`GDB session capacity reached (${String(this.#maxSessions.value)} sessions).`,
"Close a GDB session before starting another.",
"Close a GDB session before starting another; a stopped session that gdb_session_status reports closed still holds its slot until close_gdb_session releases it.",
this.#maxSessions,
{
max_sessions: this.#maxSessions.value,
@@ -903,6 +901,7 @@ export class GdbSessionManager {
// during startup, so no inferior can exist until execute() is called.
noInferiorVerified: true,
cleanupStarted: false,
closeRequested: false,
stopAttempt: undefined,
pending: new Map(),
cleanupFailures: new Set(),
@@ -1256,15 +1255,13 @@ export class GdbSessionManager {
>
> {
const session = this.#sessions.get(sessionId);
if (
this.#closing ||
session === undefined ||
!session.ready ||
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined
)
return err(gdbConsoleUnavailable());
if (session === undefined)
return err(unknownDebuggerSessionError(GDB_SESSION_TOOLS, "gdb_console"));
const stopped = this.#stopReason(session);
if (stopped !== undefined)
return err(
stoppedDebuggerSessionError(GDB_SESSION_TOOLS, "gdb_console", stopped),
);
if (signal?.aborted)
return err(cancelledWithCallerReason("gdb_console", signal));
try {
@@ -1273,14 +1270,16 @@ export class GdbSessionManager {
async () => {
// Retained cleanup ownership and a delayed exit event do not imply
// command readiness. Recheck admission when this queued turn starts.
if (
this.#closing ||
this.#sessions.get(sessionId) !== session ||
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined
)
return err(gdbConsoleUnavailable());
// Every path that releases a session first records why it stopped.
const stoppedWhileQueued = this.#stopReason(session);
if (stoppedWhileQueued !== undefined)
return err(
stoppedDebuggerSessionError(
GDB_SESSION_TOOLS,
"gdb_console",
stoppedWhileQueued,
),
);
session.noInferiorVerified = false;
session.knownInferiorIdentities.clear();
if (commandMayChangeInferiorOutput(command))
@@ -1455,21 +1454,17 @@ export class GdbSessionManager {
readonly diagnostics_truncated: boolean;
readonly inferior_tty: string | null;
readonly inferior_tty_verified: boolean;
readonly closed_reason?: string;
}
| undefined {
const session = this.#sessions.get(sessionId);
if (session === undefined) return undefined;
const diagnostics = session.supervisor.snapshot();
const stopped = this.#stopReason(session);
return {
session_id: session.id,
mi_version: session.miVersion,
state:
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined ||
!session.ready
? "closed"
: "ready",
state: stopped === undefined ? "ready" : "closed",
recent_mi_records: [...session.lines],
recent_mi_records_truncated: session.recentRecordsTruncated,
mi_invalid_utf8: session.invalidUtf8Observed,
@@ -1481,9 +1476,24 @@ export class GdbSessionManager {
diagnostics_truncated: diagnostics.diagnosticTruncated === true,
inferior_tty: session.inferiorTty ?? null,
inferior_tty_verified: session.inferiorTtyVerified,
...(stopped === undefined ? {} : { closed_reason: stopped }),
};
}
/** Why a retained session accepts no commands, or undefined while it is ready. */
#stopReason(session: GdbSession): string | undefined {
if (session.protocolError !== undefined) return session.protocolError;
if (this.#closing)
return "REA is shutting down and closing every GDB session.";
if (session.closeRequested)
return "close_gdb_session started releasing this session, and its owned cleanup has not completed.";
if (session.exited)
return `The GDB process exited (exit code ${String(session.exitCode)}, signal ${String(session.exitSignal)}); a command such as quit, or a crash, can end it.`;
if (session.cleanupStarted || !session.ready)
return "REA stopped this GDB session.";
return undefined;
}
async close(
sessionId: string,
): Promise<
@@ -1549,6 +1559,7 @@ export class GdbSessionManager {
// Refusing inferior disposition above leaves console remediation
// available; from here onward only owned cleanup may be retried.
session.cleanupStarted = true;
session.closeRequested = true;
if (groupCount === 0) {
session.noInferiorVerified = true;
targetState = "none_observed";
@@ -1588,6 +1599,7 @@ export class GdbSessionManager {
async #finishSessionCleanup(
session: GdbSession,
): Promise<Result<{ readonly target_state: "unknown" }, AnalysisError>> {
session.closeRequested = true;
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
return err(
@@ -1614,7 +1626,10 @@ export class GdbSessionManager {
stopped.reason,
),
);
this.#sessions.delete(session.id);
// The caller still holds a published session's ID; keep it addressable
// so commands and status report why it stopped until it is closed.
if (!session.ready || session.closeRequested)
this.#sessions.delete(session.id);
}
return ok(null);
}
@@ -2209,6 +2224,7 @@ export class GdbSessionManager {
"The unrestricted GDB console can access local shell, filesystem, network, and target controls.",
],
});
session.protocolError ??= `A GDB command exceeded its ${String(acceptedDeadline.value)} ms deadline; owned-session shutdown was requested because output correlation can no longer be guaranteed.`;
void this.#stopDebuggerOnly(session).then((stopped) => {
if (
stopped.status === "incomplete" &&
+41 -1
View File
@@ -25,7 +25,7 @@ export const rizinCommand = (input: RizinInput): readonly string[] => [
export const rizinCommandLimitations = (
input: RizinInput,
): readonly string[] => [
"completion_status reports process completion. Command validity remains unverified: Rizin can reject a command while returning exit_code 0. Inspect stdout and stderr before treating an empty response as absence.",
"completion_status covers process completion and Rizin shell refusals only. A statement that ran can still fail while Rizin returns exit_code 0, so inspect stderr before treating an empty response as absence.",
...(input.asm_arch === undefined &&
input.asm_bits === undefined &&
input.asm_cpu === undefined
@@ -39,3 +39,43 @@ export const rizinCommandLimitations = (
"With base_address, REA passed -n and -m so that Rizin maps file offset 0 at that address as a raw image; REA does not confirm the map, which oml shows. -n skips Rizin's format loaders, so expect no sections, symbols or entry point, and arch, bits and byte order stay at Rizin's defaults unless the call selects them.",
]),
];
/**
* Rizin's shell logs exactly these lines when it refuses a statement
* (librz/core/cmd/cmd.c, unchanged from 0.6 through 0.9.1). Rizin then skips
* the remaining statements and still exits 0. Other ERROR lines come from
* commands that ran, so they are diagnostics rather than refusals.
*/
const RIZIN_SHELL_REFUSALS: readonly {
readonly line: RegExp;
readonly refused: (match: RegExpExecArray) => string;
}[] = [
{
line: /^ERROR: Command '(.*)' does not exist\.$/u,
refused: (match) => `the unknown command ${JSON.stringify(match[1])}`,
},
{
line: /^ERROR: Wrong number of arguments passed to `(.*)`, see its help with `.*\?`$/u,
refused: (match) =>
`a wrong argument count for ${JSON.stringify(match[1])}`,
},
{
line: /^ERROR: core: Error while executing command: /u,
refused: () => "an invalid statement",
},
{
line: /^ERROR: core: Error while parsing command: /u,
refused: () => "input that failed to parse",
},
];
/** Explain the first Rizin shell refusal in a completed process's stderr. */
export const rizinCommandRejection = (stderr: string): string | undefined => {
for (const text of stderr.split(/\r?\n/u))
for (const { line, refused } of RIZIN_SHELL_REFUSALS) {
const match = line.exec(text);
if (match !== null)
return `Rizin refused ${refused(match)} (ERROR line on stderr). It skips a refused statement and every later one, so stdout holds at most the output of earlier statements; empty stdout is not an empty result. Correct the statement, using <command>? for Rizin's help, and run it again.`;
}
return undefined;
};
+87 -38
View File
@@ -22,6 +22,11 @@ import {
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
RIZIN_DEBUG_SESSION_TOOLS,
stoppedDebuggerSessionError,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import type {
AnalysisError,
AnalysisErrorOptions,
@@ -224,9 +229,18 @@ interface RizinDebugSession {
frameChunks: Buffer[];
bufferedFrameBytes: number;
ready: boolean;
/** start returned this session's ID, so the caller must close it. */
published: boolean;
closeRequested: boolean;
exited: boolean;
commandTail: Promise<void>;
protocolError: string | undefined;
/**
* Why the session stopped accepting commands. Cleanup synchronization
* clears protocolError once the transport is correlated again, but the
* session stays stopped, so the diagnosis is retained here.
*/
stopReason: string | undefined;
cleanupSynchronization:
| {
readonly marker: string;
@@ -364,7 +378,7 @@ export class RizinDebugSessionManager {
"start_rizin_debug_session",
"memory",
`Rizin session capacity reached (${String(this.#maxSessions.value)} sessions).`,
"Close a Rizin debug session before starting another.",
"Close a Rizin debug session before starting another; a stopped session that rizin_debug_session_status reports closed still holds its slot until close_rizin_debug_session releases it.",
this.#maxSessions,
{
max_sessions: this.#maxSessions.value,
@@ -609,9 +623,12 @@ export class RizinDebugSessionManager {
frameChunks: [],
bufferedFrameBytes: 0,
ready: false,
published: false,
closeRequested: false,
exited: false,
commandTail: Promise.resolve(),
protocolError: undefined,
stopReason: undefined,
cleanupSynchronization: undefined,
commandCaptureActive: false,
commandOutputDropped: false,
@@ -651,6 +668,7 @@ export class RizinDebugSessionManager {
if (session.protocolError !== undefined)
throw new Error(session.protocolError);
session.ready = true;
session.published = true;
return ok({ session_id: id, backend: input.backend ?? null });
} catch (caught) {
const protocolFailure = session.protocolError;
@@ -719,15 +737,22 @@ export class RizinDebugSessionManager {
>
> {
const session = this.#sessions.get(sessionId);
if (
session === undefined ||
!session.ready ||
session.exited ||
session.protocolError !== undefined ||
session.closing ||
this.#closingAll
)
return err(new AnalysisInputError("rizin_debug_command"));
if (session === undefined)
return err(
unknownDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"rizin_debug_command",
),
);
const stopped = this.#stopReason(session);
if (stopped !== undefined)
return err(
stoppedDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"rizin_debug_command",
stopped,
),
);
if (signal?.aborted)
return err(rizinCancellation("rizin_debug_command", signal));
if (session.pendingCommandCount >= this.#maxPendingCommands.value)
@@ -782,14 +807,15 @@ export class RizinDebugSessionManager {
acquired = true;
if (signal?.aborted)
return err(rizinCancellation("rizin_debug_command", signal));
if (
!session.ready ||
session.exited ||
session.closing ||
this.#closingAll ||
session.protocolError !== undefined
)
return err(new AnalysisInputError("rizin_debug_command"));
const stoppedWhileQueued = this.#stopReason(session);
if (stoppedWhileQueued !== undefined)
return err(
stoppedDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"rizin_debug_command",
stoppedWhileQueued,
),
);
if (hasUnclaimedRizinFrames(session)) {
const message =
"Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated.";
@@ -1068,27 +1094,40 @@ export class RizinDebugSessionManager {
readonly recent_output_truncated: boolean;
readonly diagnostics_truncated: boolean;
readonly output_invalid_utf8: boolean;
readonly closed_reason?: string;
}
| undefined {
const session = this.#sessions.get(sessionId);
return session === undefined
? undefined
: {
session_id: session.id,
state:
session.exited ||
session.protocolError !== undefined ||
session.closing ||
!session.ready
? "closed"
: "ready",
inferior_tty: session.inferiorTty,
recent_output: [...session.history],
recent_output_truncated: session.historyTruncated,
diagnostics_truncated:
session.supervisor.snapshot().diagnosticTruncated === true,
output_invalid_utf8: session.invalidUtf8Observed,
};
if (session === undefined) return undefined;
const stopped = this.#stopReason(session);
return {
session_id: session.id,
state: stopped === undefined ? "ready" : "closed",
inferior_tty: session.inferiorTty,
recent_output: [...session.history],
recent_output_truncated: session.historyTruncated,
diagnostics_truncated:
session.supervisor.snapshot().diagnosticTruncated === true,
output_invalid_utf8: session.invalidUtf8Observed,
...(stopped === undefined ? {} : { closed_reason: stopped }),
};
}
/** Why a retained session accepts no commands, or undefined while it is ready. */
#stopReason(session: RizinDebugSession): string | undefined {
const diagnosed = session.stopReason ?? session.protocolError;
if (diagnosed !== undefined) return diagnosed;
if (this.#closingAll)
return "REA is shutting down and closing every Rizin debug session.";
if (session.closeRequested)
return "close_rizin_debug_session started releasing this session, and its owned cleanup has not completed.";
if (session.exited) {
const { exitCode, signalCode } = session.launched.process;
return `The Rizin process exited (exit code ${String(exitCode)}, signal ${String(signalCode)}); a command such as q, or a crash, can end it.`;
}
if (session.closing || !session.ready)
return "REA stopped this Rizin debug session.";
return undefined;
}
async close(
@@ -1098,7 +1137,13 @@ export class RizinDebugSessionManager {
return err(new AnalysisInputError("close_rizin_debug_session"));
const session = this.#sessions.get(sessionId);
if (session === undefined)
return err(new AnalysisInputError("close_rizin_debug_session"));
return err(
unknownDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"close_rizin_debug_session",
),
);
session.closeRequested = true;
session.closing = true;
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
@@ -1141,7 +1186,10 @@ export class RizinDebugSessionManager {
stopped.reason,
),
);
this.#sessions.delete(session.id);
// The caller still holds a published session's ID; keep it addressable
// so commands and status report why it stopped until it is closed.
if (!session.published || session.closeRequested)
this.#sessions.delete(session.id);
}
return ok(null);
}
@@ -1516,6 +1564,7 @@ export class RizinDebugSessionManager {
clearTimeout(timer);
session.cleanupSynchronization = undefined;
if (cause === undefined) {
session.stopReason ??= session.protocolError;
session.protocolError = undefined;
session.commandOutputDropped = false;
resolve();
+10 -5
View File
@@ -2,6 +2,10 @@ import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { Logger } from "pino";
import type { GdbSessionManager } from "../gdb/GdbSessionManager.js";
import {
GDB_SESSION_TOOLS,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
@@ -10,7 +14,6 @@ import {
} from "../application/analysisView/AnalysisViewService.js";
import { toolContract } from "../contracts/toolContracts.js";
import { err, ok } from "../domain/result.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import {
runOwnedCleanupWithExternalMutationAdmission,
runWithExternalMutationAdmission,
@@ -117,7 +120,12 @@ export const registerGdbTools = (
const result = manager.status(input.session_id);
if (result === undefined)
return delivery.toCallToolResult(
err(invalidSession("gdb_session_status")),
err(
unknownDebuggerSessionError(
GDB_SESSION_TOOLS,
"gdb_session_status",
),
),
status,
);
return delivery.toCallToolResult(
@@ -150,6 +158,3 @@ export const registerGdbTools = (
},
);
};
const invalidSession = (operation: string): AnalysisInputError =>
new AnalysisInputError(operation);
+11 -2
View File
@@ -3,7 +3,10 @@ import type { Logger } from "pino";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { toolContract } from "../contracts/toolContracts.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import {
RIZIN_DEBUG_SESSION_TOOLS,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import type { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
import {
runOwnedCleanupWithExternalMutationAdmission,
@@ -99,7 +102,13 @@ export const registerRizinDebugTools = (
const result = manager.status(input.session_id);
if (result === undefined)
return delivery.toCallToolResult(
{ ok: false, error: new AnalysisInputError(status.name) },
{
ok: false,
error: unknownDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
status.name,
),
},
status,
);
return delivery.toCallToolResult(
+1
View File
@@ -29,6 +29,7 @@ cliTest(
"--json",
],
environment: { NODE_OPTIONS: "--max-old-space-size=256" },
timeoutMs: 30000,
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_apple_ips").outputSchema.parse(
@@ -16,6 +16,7 @@ cliTest.for(["", " \t "])(
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
timeoutMs: 15000,
});
expect(response.exitCode).toBe(1);
expect(response.json).toMatchObject({
@@ -30,6 +30,7 @@ cliTest(
bundlePath,
"--json",
],
timeoutMs: 60_000,
});
expect(response.exitCode).toBe(0);
const bundle = evidenceBundleSchema.parse(
@@ -9,12 +9,16 @@ processTest(
"returns a nonzero exit for a streamed JSON analysis failure",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-cli-js-missing-");
const result = await processes.run(process.execPath, [
"scripts/rea.mjs",
"analyze-javascript-application",
join(root, "missing"),
"--json",
]);
const result = await processes.run(
process.execPath,
[
"scripts/rea.mjs",
"analyze-javascript-application",
join(root, "missing"),
"--json",
],
{ timeoutMs: 20_000 },
);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "invalid_request",
@@ -84,6 +84,7 @@ cliTest.skipIf(process.versions.bun !== undefined).for([
environment: {
NODE_OPTIONS: `--max-old-space-size=${String(heapMb)} --max-semi-space-size=8`,
},
timeoutMs: 60_000,
});
expect(response.exitCode).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
@@ -200,6 +201,7 @@ cliTest.skipIf(process.versions.bun !== undefined)(
environment: {
NODE_OPTIONS: "--max-old-space-size=640 --max-semi-space-size=8",
},
timeoutMs: 75_000,
});
expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu);
expect(
@@ -32,10 +32,11 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
["a", "b"],
[],
]) {
const control = await processes.run(process.execPath, [
join(root, "child.cjs"),
...values,
]);
const control = await processes.run(
process.execPath,
[join(root, "child.cjs"), ...values],
{ timeoutMs: 5_000 },
);
expect(control.exitCode, control.stderr).toBe(0);
counts.push(Number(control.stdout));
}
@@ -27,7 +27,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
const control = await processes.run(
process.execPath,
[join(root, "entry.mjs"), "abc", " mode "],
{ env: { REA_ENDPOINT: "endpoint" } },
{ env: { REA_ENDPOINT: "endpoint" }, timeoutMs: 15_000 },
);
expect(control.exitCode, control.stderr).toBe(0);
expect(JSON.parse(control.stdout)).toEqual([
@@ -46,6 +46,7 @@ cliTest(
"directory",
"--json",
],
timeoutMs: 60_000,
});
expect(cliResponse.exitCode).toBe(0);
const cliEvidence = toolContract(
@@ -36,6 +36,7 @@ cliTest(
"directory",
"--json",
],
timeoutMs: 60_000,
});
expect(cliResponse.exitCode).toBe(0);
const cliEvidence = toolContract(
@@ -177,6 +178,7 @@ cliTest(
"directory",
"--json",
],
timeoutMs: 60_000,
});
expect(analyzed.exitCode, analyzed.stderr).toBe(0);
const application = toolContract(
@@ -209,6 +211,7 @@ cliTest(
);
const traced = await cli.run({
arguments: ["trace-javascript-semantics", request, "--json"],
timeoutMs: 60_000,
});
expect(traced.exitCode, traced.stdout).toBe(0);
const trace = javaScriptSemanticTraceResultSchema.parse(
@@ -19,6 +19,7 @@ cliTest(
const cliResult = await driver.run({
arguments: ["analyze-javascript-application", root, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
@@ -591,7 +591,7 @@ cliTest(
const result = await analyzeJavaScriptDirectory(
"CLI",
{ run: (invocation) => cli.run({ ...invocation }) },
{ run: (invocation) => cli.run({ ...invocation, timeoutMs: 20_000 }) },
root,
);
expect(result.statistics.parse_failures).toBe(0);
@@ -39,9 +39,11 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
"console.log(JSON.stringify([a, b, c, d, first.length, rest.length, mixed.length]));",
].join("\n"),
});
const control = await processes.run(process.execPath, [
join(root, "entry.mts"),
]);
const control = await processes.run(
process.execPath,
[join(root, "entry.mts")],
{ timeoutMs: 15_000 },
);
expect(control.exitCode, control.stderr).toBe(0);
expect(JSON.parse(control.stdout)).toEqual([
7,
@@ -25,6 +25,7 @@ cliTest(
const cliResult = await driver.run({
arguments: ["inspect-artifact", leftPath, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const cliEvidence = parseEvidence(JSON.parse(cliResult.stdout));
@@ -23,6 +23,7 @@ cliTest(
const result = await driver.run({
arguments: ["inspect-artifact", path, "--detail", "complete", "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -16,19 +16,23 @@ cliTest.skipIf(process.platform !== "darwin")(
await mkdir(source);
await writeFile(join(source, "fixture.txt"), "caller-owned image\n");
const image = join(root, "fixture.dmg");
const created = await processes.run("/usr/bin/hdiutil", [
"create",
"-quiet",
"-srcfolder",
source,
"-volname",
"ReaRecoveryFixture",
"-size",
"20m",
"-format",
"UDZO",
image,
]);
const created = await processes.run(
"/usr/bin/hdiutil",
[
"create",
"-quiet",
"-srcfolder",
source,
"-volname",
"ReaRecoveryFixture",
"-size",
"20m",
"-format",
"UDZO",
image,
],
{ timeoutMs: 30_000 },
);
expect(created.exitCode, created.stderr).toBe(0);
const digest = createHash("sha256")
.update(await readFile(image))
@@ -37,7 +41,9 @@ cliTest.skipIf(process.platform !== "darwin")(
const owner = new NativeDmgArtifactReader(image, process.env);
let mountRoot: string | undefined;
const attachedImages = async () => {
const info = await processes.run("/usr/bin/hdiutil", ["info", "-plist"]);
const info = await processes.run("/usr/bin/hdiutil", ["info", "-plist"], {
timeoutMs: 10_000,
});
expect(info.exitCode, info.stderr).toBe(0);
return parseNativeDmgInfo(info.stdout).images;
};
@@ -75,6 +81,7 @@ cliTest.skipIf(process.platform !== "darwin")(
expect(await attachedImagePaths()).toContain(resolvedImage);
const busy = await cli.run({
arguments: ["inspect-artifact", image, "--json"],
timeoutMs: 30_000,
});
expect(busy.exitCode, busy.stdout).toBe(1);
expect(busy.json).toMatchObject({
@@ -113,6 +120,7 @@ cliTest.skipIf(process.platform !== "darwin")(
expect(await mountedDevices()).toEqual([]);
const control = await cli.run({
arguments: ["inspect-artifact", image, "--json"],
timeoutMs: 30_000,
});
expect(control.exitCode, control.stdout).toBe(0);
expect(control.json).toMatchObject({
@@ -39,6 +39,7 @@ cliTest.skipIf(process.platform === "win32")(
"--json",
],
environment: { BROWSER_FIXTURE_SECRET: secret },
timeoutMs: 20_000,
});
expect(response.exitCode, response.stdout + response.stderr).toBe(1);
expect(response.stdout).not.toContain(secret);
@@ -47,6 +47,7 @@ cliTest(
"--total-analysis-timeout-ms",
"1",
],
timeoutMs: 30_000,
});
expect(
interrupted.exitCode,
@@ -73,6 +74,7 @@ cliTest(
const recovered = await cli.run({
arguments: baseArguments,
timeoutMs: 30_000,
});
expect(recovered.exitCode, recovered.stdout + recovered.stderr).toBe(0);
expect(recovered.json).toMatchObject({
@@ -46,6 +46,7 @@ cliTest.for([
const result = await cli.run({
arguments: ["capture-browser-scenario", path, "--json"],
environment: { HOME: home, REA_LOG_LEVEL: "silent" },
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject(expected);
@@ -30,6 +30,7 @@ cliTest(
"30000",
"--json",
],
timeoutMs: 55_000,
});
// Analysis retains the interrupted file rather than failing the command.
expect(response.exitCode, `${response.stdout}\n${response.stderr}`).toBe(0);
@@ -29,6 +29,7 @@ cliTest(
}),
"--json",
],
timeoutMs: 60_000,
});
expect(unpublished.exitCode, unpublished.stderr).toBe(1);
expect(unpublished.json).toMatchObject({
@@ -59,6 +60,7 @@ cliTest(
bundle,
"--json",
],
timeoutMs: 60_000,
});
expect(analyzed.exitCode, analyzed.stderr).toBe(0);
const request = (fields?: readonly string[]) =>
@@ -66,6 +66,7 @@ for (const { jarConfigured, setting } of [
const cliResult = await cli.run({
arguments: ["inspect-android-package", apk, "--json"],
environment,
timeoutMs: 10_000,
});
expect(cliResult.exitCode).toBe(1);
expect(cliResult.json).toMatchObject({ code: "capability_unavailable" });
@@ -90,6 +91,7 @@ cliTest.skipIf(process.platform === "win32")(
const cliResult = await cli.run({
arguments: ["inspect-android-package", apk, "--json"],
environment: { REA_LOG_LEVEL: "silent", ...environment },
timeoutMs: 10_000,
});
expect(cliResult.exitCode).toBe(1);
const client = await connect({ REA_LOG_LEVEL: "silent", ...environment });
@@ -144,6 +146,7 @@ cliTest.skipIf(process.platform !== "linux")(
const cliResult = await cli.run({
arguments: ["inspect-firmware-regions", path, "--json"],
environment,
timeoutMs: 10_000,
});
expect(cliResult.exitCode).toBe(1);
expect(cliResult.json).toMatchObject({ code: "capability_unavailable" });
@@ -169,6 +172,7 @@ cliTest.skipIf(process.platform !== "linux")(
arguments: ["inspect-firmware-regions", "input.bin", "--json"],
cwd: root,
environment: { REA_LOG_LEVEL: "silent" },
timeoutMs: 10_000,
});
// A relative path must pass input validation and reach the provider,
// which reports the missing firmware engine — not a validation error.
@@ -195,6 +199,7 @@ cliTest(
"--json",
],
environment,
timeoutMs: 10000,
});
expect(cliResult.exitCode).toBe(1);
expect(cliResult.json).toMatchObject({ code: "capability_unavailable" });
@@ -234,6 +239,7 @@ cliTest(
"--json",
],
environment,
timeoutMs: 10_000,
});
const response = await client.callTool({
name: "recover_javascript_sources",
@@ -60,6 +60,7 @@ cliTest.skipIf(missingNative)(
const success = await cli.run({
arguments: ["inspect-pcap-capture", single, "--json"],
environment: environmentFor(root),
timeoutMs: 60_000,
});
expect(success.exitCode, success.stderr).toBe(0);
expect(success.stdout.includes(credential)).toBe(false);
@@ -77,6 +78,7 @@ cliTest.skipIf(missingNative)(
const failed = await cli.run({
arguments: ["inspect-pcap-capture", many, "--json"],
environment: environmentFor(root),
timeoutMs: 60_000,
});
expect(failed.exitCode, failed.stderr).toBe(1);
expect(failed.stdout.includes(credential)).toBe(false);
@@ -132,6 +132,7 @@ test.for(["target", "loader"] as const)(
const result = await cli.run({
arguments: ["analyze", target, "--provider", "hopper", "--json"],
environment,
timeoutMs: 15_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -222,6 +223,7 @@ test("recovers a denied Hopper launch through the same stdio MCP session and CLI
const deniedCli = await cli.run({
arguments: ["analyze", target, "--provider", "hopper", "--json"],
environment,
timeoutMs: 15_000,
});
expect(deniedCli.exitCode).toBe(1);
expect(deniedCli.json).toMatchObject({
@@ -160,6 +160,7 @@ cliTest(
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -327,6 +328,7 @@ cliTest(
const result = await driver.run({
arguments: ["extract-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const extraction = artifactExtractionResultSchema.parse(
@@ -390,6 +392,7 @@ cliTest(
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -442,6 +445,7 @@ cliTest(
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -469,6 +473,7 @@ cliTest(
const result = await driver.run({
arguments: ["extract-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const extracted = artifactExtractionResultSchema.parse(
@@ -531,6 +536,7 @@ cliTest(
const inspectResult = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(inspectResult.exitCode, inspectResult.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -604,6 +610,7 @@ cliTest(
const extractResult = await driver.run({
arguments: ["extract-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(extractResult.exitCode, extractResult.stderr).toBe(0);
const extracted = artifactExtractionResultSchema.parse(
@@ -638,6 +645,7 @@ cliTest(
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -680,6 +688,7 @@ cliTest.for([
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -743,6 +752,7 @@ cliTest.for([
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode, result.stderr).toBe(0);
const inspection = artifactInspectionResultSchema.parse(
@@ -790,6 +800,7 @@ cliTest(
const result = await driver.run({
arguments: ["inspect-artifact", path, "--json"],
environment: process.env,
timeoutMs: 15_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -13,12 +13,16 @@ const outcomes = [
command: "axffj",
stdout: "",
stderr: "ERROR: Command 'axffj' does not exist.\n",
// The refusal line on stderr is classified; process facts stay as observed.
completion: "rejected",
},
{ command: "q", stdout: "", stderr: "" },
{ command: "q", stdout: "", stderr: "", completion: "complete" },
{
command: "echo diagnostic",
stdout: "ERROR: Command 'axffj' does not exist.\n",
stderr: "WARNING: fixture warning\n",
// Refusal text on stdout is command output, not a Rizin diagnostic.
completion: "complete",
},
] as const;
@@ -71,7 +75,7 @@ cliTest.skipIf(process.platform === "win32").for(outcomes)(
stdout: outcome.stdout,
stderr: outcome.stderr,
exit_code: 0,
completion_status: "complete",
completion_status: outcome.completion,
output_truncated: false,
artifact_changed: false,
});
@@ -0,0 +1,179 @@
import { execFileSync } from "node:child_process";
import { chmod, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
const contract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find(
({ name }) => name === "execute_rizin_command",
);
if (contract === undefined)
throw new Error("execute_rizin_command contract is missing");
interface Outcome {
readonly command: string;
readonly stdout: string;
readonly stderr: string;
/** Expected completion and the refusal the limitation must name. */
readonly refused?: RegExp;
}
// Captured from the official Rizin 0.9.1 static Linux build on /usr/bin/true.
const outcomes: readonly Outcome[] = [
{
command: "axffj",
stdout: "",
stderr:
"ERROR: Command 'axffj' does not exist.\nERROR: Displaying the help of command 'axf'.\n\nERROR: Usage: axf[jq] # List xrefs from current seek\n",
refused: /unknown command "axffj"/u,
},
{
// Rizin runs the statements before a refusal and skips the rest.
command: "%v 1; zz; %v 2",
stdout: "0x1\n",
stderr: "ERROR: Command 'zz' does not exist.\n",
refused: /unknown command "zz"/u,
},
{
command: "%v 1; afl 1 2; %v 2",
stdout: "0x1\n",
stderr:
"ERROR: Wrong number of arguments passed to `afl`, see its help with `afl?`\n\nERROR: Usage: afl[?] # List functions\n",
refused: /wrong argument count for "afl"/u,
},
{
command: "pd 1 @ nosuch",
stdout: "",
stderr:
"ERROR: core: Invalid address (nosuch)\nERROR: core: Error while executing command: pd 1 @ nosuch\n",
refused: /an invalid statement/u,
},
{
command: "?e",
stdout: "",
stderr: "ERROR: core: Error while parsing command: `?e`\n",
refused: /input that failed to parse/u,
},
// A command that ran and reported its own failure is not a shell refusal.
{
command: "afxj",
stdout: "",
stderr: "ERROR: No function found in 0x000023d0.\n",
},
// Refusal text on stdout is command output, not a Rizin diagnostic.
{
command: "echo refusal",
stdout: "ERROR: Command 'axffj' does not exist.\n",
stderr: "",
},
];
const expectClassified = (raw: unknown, outcome: Outcome): void => {
const evidence = contract.outputSchema.parse(raw);
expect(evidence.normalized_result).toMatchObject({
stdout: outcome.stdout,
stderr: outcome.stderr,
exit_code: 0,
completion_status: outcome.refused === undefined ? "complete" : "rejected",
});
expect(evidence.raw_result).toEqual(evidence.normalized_result);
const refusals = evidence.limitations.filter((limitation) =>
limitation.startsWith("Rizin refused "),
);
if (outcome.refused === undefined) expect(refusals).toEqual([]);
else {
expect(refusals).toHaveLength(1);
expect(refusals[0]).toMatch(outcome.refused);
expect(refusals[0]).toMatch(/empty stdout is not an empty result/u);
}
};
cliTest.skipIf(process.platform === "win32")(
"reports Rizin shell refusals as rejected through CLI and MCP (simulated Rizin)",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-rizin-rejection-");
const provider = join(root, "rizin-fixture");
const artifact = join(root, "artifact.bin");
await writeFile(artifact, "caller-selected inert artifact");
// A Node script replays recorded Rizin output; it is not Rizin.
await writeFile(
provider,
`#!${process.execPath}\n` +
`const cases=${JSON.stringify(outcomes)};\n` +
"const selected=cases.find(item=>item.command===process.argv[process.argv.indexOf('-c')+1]);\n" +
"if(!selected)process.exit(99);\n" +
"process.stdout.write(selected.stdout);process.stderr.write(selected.stderr);\n",
);
await chmod(provider, 0o755);
const environment = { REA_RIZIN_COMMAND: provider };
const { call } = await connectLocalToolsMcp(environment);
// Both adapters share the service's classification; the CLI checks one
// refusal and one command diagnostic.
for (const outcome of [outcomes[0]!, outcomes[5]!]) {
const response = await cli.sharedCliRun!({
arguments: [
"execute-rizin-command",
artifact,
outcome.command,
"--json",
],
environment,
});
expect(response.exitCode, `${response.stdout}\n${response.stderr}`).toBe(
0,
);
expectClassified(response.json, outcome);
}
for (const outcome of outcomes) {
const mcp = await call("execute_rizin_command", {
path: artifact,
command: outcome.command,
});
expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true);
expectClassified(mcp.structuredContent, outcome);
}
},
);
// Real-provider gate: runs the Rizin found on PATH and skips without one.
const rizin = (() => {
try {
const path = execFileSync("which", ["rizin"], { encoding: "utf8" }).trim();
return path === "" ? undefined : path;
} catch {
return undefined;
}
})();
cliTest.skipIf(rizin === undefined)(
"classifies real Rizin refusals and command diagnostics",
async () => {
const root = await createTestTempDirectory("rea-real-rizin-rejection-");
const artifact = join(root, "artifact.bin");
await writeFile(artifact, Buffer.from([0x90, 0xc3]));
const { call } = await connectLocalToolsMcp({ REA_RIZIN_COMMAND: rizin! });
const expected = [
["axffj", "rejected"],
["%v 1; zz; %v 2", "rejected"],
["%v 1; afl 1 2; %v 2", "rejected"],
["pd 1 @ nosuch", "rejected"],
["s nosuch", "complete"],
["%v 1; %v 2", "complete"],
] as const;
for (const [command, status] of expected) {
const response = await call("execute_rizin_command", {
path: artifact,
command,
});
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const evidence = contract.outputSchema.parse(response.structuredContent);
expect(evidence.normalized_result, command).toMatchObject({
exit_code: 0,
completion_status: status,
});
}
},
);
@@ -200,6 +200,7 @@ cliTest(
arguments: [...args, "--json"],
cwd: home,
environment,
timeoutMs: 20_000,
});
const plan = await run(["setup", "--client", "dsh", "--dry-run"]);
expect(plan.exitCode).toBe(0);
@@ -50,6 +50,7 @@ for (const version of ["1.1", "1.2"] as const) {
HERMES_HOME: profile,
HOPPER_LAUNCHER_PATH: process.execPath,
},
timeoutMs: 20_000,
});
expect(removed.exitCode).toBe(0);
expect(await readFile(config, "utf8")).toBe(original);
@@ -83,6 +84,7 @@ for (const version of ["1.1", "1.2"] as const) {
GHIDRA_INSTALL_DIR: ghidra,
JAVA_HOME: java,
},
timeoutMs: 20_000,
});
const setup = [
"setup",
@@ -164,6 +166,7 @@ cliTest("Hermes still registers into an explicit table", async ({ cli }) => {
HERMES_HOME: profile,
HOPPER_LAUNCHER_PATH: process.execPath,
},
timeoutMs: 20_000,
});
expect(applied.exitCode).toBe(0);
expect(applied.json).toMatchObject({ status: "ready" });
@@ -206,6 +209,7 @@ for (const version of ["1.1", "1.2"] as const) {
HERMES_HOME: profile,
HOPPER_LAUNCHER_PATH: process.execPath,
},
timeoutMs: 20_000,
});
expect(applied.exitCode).toBe(0);
const configured = parse(await readFile(config, "utf8"), { version });
@@ -233,6 +237,7 @@ cliTest(
HERMES_HOME: profile,
HOPPER_LAUNCHER_PATH: process.execPath,
},
timeoutMs: 20_000,
});
const [command, ...args] = currentClientRegistrationCommand({
entrypointPath: resolve("scripts/rea.mjs"),
@@ -311,6 +316,7 @@ for (const original of [
HERMES_HOME: profile,
HOPPER_LAUNCHER_PATH: process.execPath,
},
timeoutMs: 20_000,
});
expect(rejected.exitCode).not.toBe(0);
expect(rejected.json).toMatchObject({
@@ -23,6 +23,7 @@ const cliLifecycle = (cli: TestCli, home: string, profile: string) => {
arguments: arguments_,
cwd: home,
environment: { HOME: home, USERPROFILE: home, HERMES_HOME: profile },
timeoutMs: 20_000,
});
const setup = [
"setup",
@@ -61,6 +61,7 @@ for (const [version, keyStyle] of [
arguments: arguments_,
cwd: home,
environment: { USERPROFILE: home, HERMES_HOME: profile },
timeoutMs: 20_000,
});
const setup = [
"setup",
@@ -140,6 +141,7 @@ cliTest(
arguments: arguments_,
cwd: home,
environment: { USERPROFILE: home, HERMES_HOME: profile },
timeoutMs: 20_000,
});
const setup = ["setup", "--client", "hermes", "--skill=false", "--json"];
const preview = await run([...setup, "--dry-run"]);
@@ -221,6 +223,7 @@ cliTest(
REA_CLIENT_PROFILE_HOME: home,
HERMES_HOME: " ${REA_CLIENT_PROFILE_HOME}/profile ",
},
timeoutMs: 20_000,
});
const planned = await run([
"setup",
@@ -330,6 +333,7 @@ for (const selection of ["default", "custom", "suffix", "explicit"] as const) {
? root
: undefined,
},
timeoutMs: 20_000,
});
const plan = await run([
"setup",
@@ -464,6 +468,7 @@ for (const selection of [
LOCALAPPDATA: join(home, "AppData", "Local"),
HERMES_HOME: root,
},
timeoutMs: 20_000,
});
if (selection === "missing") {
expect(plan.exitCode).toBe(1);
@@ -568,6 +573,7 @@ for (const layout of ["symlinked-default", "escaping-prefix"] as const) {
LOCALAPPDATA: join(home, "AppData", "Local"),
HERMES_HOME: root,
},
timeoutMs: 20_000,
});
expect(plan.exitCode, plan.stdout).toBe(0);
expect(plan.json).toMatchObject({
@@ -644,6 +650,7 @@ for (const name of ["missing", "deleted", "ghost", "../escape", "root"]) {
arguments: args,
cwd: home,
environment: { USERPROFILE: home, HERMES_HOME: root },
timeoutMs: 20_000,
});
const rejected = await run([
"setup",
@@ -713,6 +720,7 @@ cliTest.skipIf(process.platform === "win32")(
HERMES_HOME: "$REA_HERMES_PROFILE",
REA_HERMES_PROFILE: override,
},
timeoutMs: 20_000,
});
const planned = await run([
"setup",
@@ -775,6 +783,7 @@ cliTest(
arguments: arguments_,
cwd: home,
environment: { USERPROFILE: home, HERMES_HOME: override },
timeoutMs: 20_000,
});
const setup = await run(["setup", "--client", "hermes", "--yes", "--json"]);
expect(setup.exitCode).not.toBe(0);
@@ -161,6 +161,7 @@ cliTest.skipIf(process.platform === "win32").for([
XDG_CONFIG_HOME: join(home, ".config"),
PATH: process.env.PATH ?? "",
},
timeoutMs: 30_000,
});
const setup = await run(registered, [
@@ -295,6 +296,7 @@ cliTest.skipIf(process.platform === "win32")(
XDG_CONFIG_HOME: join(home, ".config"),
PATH: process.env.PATH ?? "",
},
timeoutMs: 30_000,
});
const uninstall = await run(["uninstall", "--json"]);
expect(uninstall.exitCode, uninstall.stderr).toBe(0);
@@ -34,6 +34,7 @@ for (const [variable, profile] of [
PI_CODING_AGENT_DIR: agent,
[variable]: profile,
},
timeoutMs: 20_000,
});
for (const option of ["--dry-run", "--yes"]) {
const rejected = await run(
@@ -100,6 +101,7 @@ cliTest(
OMP_PROFILE: " ",
PI_PROFILE: "Bad Name",
},
timeoutMs: 20_000,
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({ status: "ready" });
@@ -138,6 +138,7 @@ cliTest(
arguments: arguments_,
cwd: home,
environment,
timeoutMs: 20_000,
});
expect(applied.exitCode).toBe(0);
expect(applied.json).toMatchObject({
@@ -192,6 +193,7 @@ cliTest(
arguments: arguments_,
cwd: home,
environment,
timeoutMs: 20_000,
});
expect(repeated.exitCode).toBe(0);
expect(repeated.json).toMatchObject({
@@ -230,6 +232,7 @@ cliTest(
PI_PROFILE: "legacy",
PI_CONFIG_DIR: ".omp-other",
},
timeoutMs: 20_000,
});
const planned = await run([
@@ -342,6 +345,7 @@ cliTest(
HOPPER_LAUNCHER_PATH: join(home, "unconfigured-hopper"),
PI_CODING_AGENT_DIR: "custom-agent",
},
timeoutMs: 20_000,
});
const run = runIn(created);
const setup = await run(["setup", "--client", "pi", "--yes", "--json"]);
@@ -452,6 +456,7 @@ cliTest(
PI_CODING_AGENT_DIR: "file:///agent%2fdir",
REA_DARWIN_MODULE_CACHE_PATH: moduleCache,
},
timeoutMs: 20_000,
});
const plan = await run([
"setup",
@@ -46,6 +46,7 @@ cliTest(
cli.run({
arguments: arguments_,
cwd: home,
timeoutMs: 20_000,
environment: {
USERPROFILE: home,
PATH: [
@@ -36,6 +36,7 @@ cliTest(
"--json",
],
environment,
timeoutMs: 30_000,
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(result.json).toMatchObject({
+1
View File
@@ -43,6 +43,7 @@ cliTest(
// This single CLI invocation cannot reuse a newly written cache.
NODE_DISABLE_COMPILE_CACHE: "1",
},
timeoutMs: 15000,
});
expect(cliResponse.exitCode, cliResponse.stdout + cliResponse.stderr).toBe(
0,
@@ -72,6 +72,7 @@ const expectCliSourceLocationSummary = async (
const { bundlePath, evidenceId, sha256 } = expected;
const missingSink = await cli.run({
arguments: [...arguments_, "--detail", "summary"],
timeoutMs: 45000,
});
expect(missingSink.exitCode).toBe(1);
expect(missingSink.json).toMatchObject({
@@ -86,6 +87,7 @@ const expectCliSourceLocationSummary = async (
"--evidence_output",
bundlePath,
],
timeoutMs: 45000,
});
expect(summary.exitCode, summary.stderr).toBe(0);
expect(summary.stdout.length).toBeLessThan(30000);
@@ -215,6 +217,7 @@ cliTest(
REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser",
CLANG_MODULE_CACHE_PATH: map,
},
timeoutMs: 45000,
});
expect(response.exitCode, response.stdout + response.stderr).toBe(0);
expect(response.json).toMatchObject({
@@ -312,6 +315,7 @@ cliTest(
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
timeoutMs: 45000,
});
expect(invalid.exitCode).toBe(1);
expectInvalidSourceLocation(
@@ -368,6 +372,7 @@ cliTest(
REA_LOG_LEVEL: "silent",
REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser",
},
timeoutMs: 45_000,
});
const response = await call("trace_web_source_location", {
manifest_path: manifest,
@@ -486,6 +491,7 @@ cliTest(
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
timeoutMs: 45_000,
});
const response = await call("trace_web_source_location", {
manifest_path: manifest,
@@ -572,6 +578,7 @@ cliTest(
REA_LOG_LEVEL: "silent",
REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser",
},
timeoutMs: 45_000,
});
expect(result.exitCode, result.stdout + result.stderr).toBe(0);
expect(result.json).toMatchObject({
@@ -642,6 +649,7 @@ cliTest(
];
const refused = await cli.run({
arguments: [...args, "--source-map-bytes", "128"],
timeoutMs: 45000,
});
expect(refused.exitCode).toBe(1);
expect((refused.json as { code?: unknown }).code).toBe("truncated");
@@ -657,6 +665,7 @@ cliTest(
"--source-map-bytes",
String(Buffer.byteLength(fixture.sourceMap.text)),
],
timeoutMs: 45000,
});
expect(recovered.exitCode, recovered.stderr).toBe(0);
expect(recovered.json).toMatchObject({
@@ -712,6 +721,7 @@ cliTest(
];
const refused = await cli.run({
arguments: [...arguments_, "--source-map-bytes", "4194304"],
timeoutMs: 45000,
});
expect(refused.exitCode).toBe(1);
expect(JSON.stringify(refused.json)).toContain(
@@ -719,6 +729,7 @@ cliTest(
);
const completed = await cli.run({
arguments: arguments_,
timeoutMs: 45000,
});
expect(completed.exitCode, completed.stderr).toBe(0);
const evidence = completed.json as {
@@ -7,15 +7,19 @@ for (const kind of ["proxy", "workers", "storage"] as const) {
processTest(
`${kind} HTTP fixture preserves targets and survives invalid requests`,
async ({ processes }) => {
const result = await processes.run(process.execPath, [
fileURLToPath(
new URL(
"../../fixtures/browserFixtureHttpTargets.mjs",
import.meta.url,
const result = await processes.run(
process.execPath,
[
fileURLToPath(
new URL(
"../../fixtures/browserFixtureHttpTargets.mjs",
import.meta.url,
),
),
),
kind,
]);
kind,
],
{ timeoutMs: 15_000 },
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toBe("");
@@ -11,16 +11,20 @@ const hasNonLoopbackIpv4 = Object.values(networkInterfaces())
processTest.skipIf(!hasNonLoopbackIpv4)(
"storage fixture serves localhost and refuses this host's non-loopback interface",
async ({ processes }) => {
const result = await processes.run(process.execPath, [
fileURLToPath(
new URL(
"../../fixtures/browserFixtureHttpTargets.mjs",
import.meta.url,
const result = await processes.run(
process.execPath,
[
fileURLToPath(
new URL(
"../../fixtures/browserFixtureHttpTargets.mjs",
import.meta.url,
),
),
),
"storage",
"listener-scope",
]);
"storage",
"listener-scope",
],
{ timeoutMs: 15_000 },
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toBe("");
@@ -73,6 +73,7 @@ processTest(
["--input-type=module", "--eval", source],
{
env: { ...process.env, REA_BROWSER_EXECUTABLE: "fixture-chrome" },
timeoutMs: 15_000,
},
);
expect(result.exitCode, result.stderr).toBe(0);
@@ -3774,7 +3774,7 @@ const runNode = async (
const result = await processes.run(
process.execPath,
[...nodeArguments, ...(preloadHook ? ["-r", hook] : []), script],
{ cwd: root, env: process.env },
{ cwd: root, env: process.env, timeoutMs: 10_000 },
);
return {
code: result.exitCode,
@@ -90,6 +90,7 @@ cliTest(
const fromCli = await cli.run({
arguments: ["inspect-web-network-capture", path, "har", "--json"],
timeoutMs: 120_000,
});
expect(fromCli.exitCode).toBe(0);
const cliSummary = summarize(fromCli.json);
@@ -29,6 +29,7 @@ cliTest(
arguments: ["mcp", "add", "--agent=amp", ...options, "--json"],
cwd: root,
environment,
timeoutMs: 20_000,
});
const settingsPath = ampSettings(home);
const original = JSON.stringify(
@@ -132,6 +133,7 @@ cliTest(
arguments: ["mcp", "add", "--agent", "amp", "--json"],
cwd: root,
environment,
timeoutMs: 20_000,
});
expect(rejected.exitCode, rejected.stderr).toBe(1);
expect(mcpRegistrationReportSchema.parse(rejected.json)).toMatchObject({
+95 -18
View File
@@ -68,8 +68,16 @@ const REQUIRED_NUMERIC_FIELDS = [
"inspect-web-page options.observationMs",
"observe-web-execution options.observationMs",
"extract-firmware options.maxDepth",
"trace-web-source-location args.line",
"cutter-command args.expectedGeneration",
"observe-native-ui options.maxNodes",
"inspect-web-network-capture options.record",
"environment-serve options.port",
] as const;
/** Decimal argv tokens the parser admits as numbers, for fields advertised as strings. */
const PROBE_TOKENS = ["0", "1"] as const;
describe("decimal integer CLI admission", () => {
it("rejects 0x10 and 1e3 for every numeric input and keeps an in-range decimal", () => {
const cli = createCli({});
@@ -83,7 +91,6 @@ describe("decimal integer CLI admission", () => {
const advertised = Schema.toJsonSchema(parser);
if (!isRecord(advertised) || !isRecord(advertised.properties)) continue;
for (const [name, property] of Object.entries(advertised.properties)) {
if (!numericBranches(property)) continue;
const field = parser.shape[name];
if (field === undefined) {
failures.push(
@@ -92,11 +99,31 @@ describe("decimal integer CLI admission", () => {
continue;
}
const label = `${path} ${group}.${name}`;
// A repeated option parses each argv token as one array item.
const repeated = isRecord(property) && property.type === "array";
const item = repeated ? property.items : property;
const isolated = z.object({ [name]: field });
const read = (token: string): unknown => {
const parsed =
group === "options"
? Parser.parse([flagName(name), token], { options: isolated })
.options[name]
: Parser.parse([token], { args: isolated }).args[name];
return repeated && Array.isArray(parsed) ? parsed[0] : parsed;
};
const readsNumber = (token: string): boolean => {
try {
return typeof read(token) === "number";
} catch {
return false;
}
};
// String-advertised token grammars (for example --port) still count.
const probed = PROBE_TOKENS.find(readsNumber);
if (!numericBranches(item) && probed === undefined) continue;
let validate: ((value: unknown) => boolean) | undefined;
try {
validate = ajv.compile(
property as Parameters<Ajv2020["compile"]>[0],
);
validate = ajv.compile(item as Parameters<Ajv2020["compile"]>[0]);
} catch (error) {
failures.push(
`${label}: Ajv could not compile the advertised schema (${error instanceof Error ? error.message : String(error)})`,
@@ -107,15 +134,6 @@ describe("decimal integer CLI admission", () => {
if (validate(token))
failures.push(`${label}: advertised schema accepts ${token}`);
}
const isolated = z.object({ [name]: field });
const read = (token: string): unknown => {
if (group === "options") {
return Parser.parse([flagName(name), token], {
options: isolated,
}).options[name];
}
return Parser.parse([token], { args: isolated }).args[name];
};
for (const token of NON_DECIMAL) {
try {
read(token);
@@ -124,7 +142,9 @@ describe("decimal integer CLI admission", () => {
// Decimal tokens are the only numeric spelling.
}
}
const candidate = bounds(property)[0]?.min;
const candidate =
bounds(item)[0]?.min ??
(probed === undefined ? undefined : Number(probed));
if (candidate === undefined) {
failures.push(`${label}: no in-range decimal candidate`);
continue;
@@ -138,8 +158,7 @@ describe("decimal integer CLI admission", () => {
);
continue;
}
const admitsLeadingZeros =
JSON.stringify(property).includes("^[0-9]+$");
const admitsLeadingZeros = JSON.stringify(item).includes("^[0-9]+$");
try {
const parsed = read(`0${decimal}`);
if (parsed !== candidate)
@@ -152,9 +171,10 @@ describe("decimal integer CLI admission", () => {
`${label}: leading-zero decimal was rejected (${error instanceof Error ? error.message : String(error)})`,
);
}
if (!(validate(decimal) || validate(candidate)))
// Argv supplies text, so the advertisement must admit the token itself.
if (!validate(decimal))
failures.push(
`${label}: advertised schema rejects in-range ${decimal}`,
`${label}: advertised schema rejects in-range token "${decimal}"`,
);
checked.push(label);
}
@@ -168,3 +188,60 @@ describe("decimal integer CLI admission", () => {
expect(failures).toEqual([]);
});
});
describe("decimal integer CLI advertisement", () => {
const schemaOf = async (command: string): Promise<unknown> => {
let stdout = "";
let exitCode = 0;
await createCli({}).serve([command, "--schema", "--json"], {
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
});
expect(exitCode, command).toBe(0);
return JSON.parse(stdout) as unknown;
};
it("publishes the token grammar in --schema for arguments and options", async () => {
const location = await schemaOf("trace-web-source-location");
expect(location).toMatchObject({
args: {
properties: {
line: {
description: "One-based generated line",
anyOf: [
{ type: "integer", minimum: 1 },
{ type: "string", pattern: "^[0-9]+$" },
],
},
},
},
});
expect(await schemaOf("inspect-web-network-capture")).toMatchObject({
options: {
properties: {
record: {
type: "array",
items: {
anyOf: [
{ type: "integer", minimum: 0 },
{ type: "string", pattern: "^[0-9]+$" },
],
},
},
},
},
});
expect(await schemaOf("environment-serve")).toMatchObject({
options: {
properties: {
port: { type: "string", pattern: "^(?:0|[1-9]\\d*)$" },
},
},
});
});
});
@@ -739,7 +739,12 @@ describe("reference-source outline-depth input schema", () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "import-reference-source");
const outlineDepth = advertised.options?.properties?.outlineDepth;
expect(outlineDepth).toMatchObject({ type: "integer", minimum: 0 });
expect(outlineDepth).toMatchObject({
anyOf: [
{ type: "integer", minimum: 0 },
{ type: "string", pattern: "^[0-9]+$" },
],
});
const parser = parserSchema(cli, "import-reference-source", "options");
expect(parser?.safeParse({}).success).toBe(true);
@@ -785,7 +790,10 @@ describe("reference-source outline-depth input schema", () => {
if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`);
expect(validate(parsed.data)).toBe(true);
expect(validate({ outlineDepth: token, detail: "summary" })).toBe(true);
}
for (const token of ["0x2", "2e0", "-1"])
expect(validate({ outlineDepth: token, detail: "summary" })).toBe(false);
expect(validate({ outlineDepth: -1 })).toBe(false);
expect(validate({ outlineDepth: 9007199254740992 })).toBe(false);
expect(validate({ outlineDepth: 2 })).toBe(false);
+4
View File
@@ -293,6 +293,7 @@ describe("CLI output flag value forms", () => {
]) {
const result = await cli.run({
arguments: [...filter, "providers", "--json"],
timeoutMs: 10_000,
});
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({});
@@ -392,6 +393,7 @@ describe("unclosed CLI output filter slices", () => {
"--filter-output",
"result[0,1].name[",
],
timeoutMs: 3000,
});
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain("INVALID_OUTPUT_FILTER");
@@ -400,6 +402,7 @@ describe("unclosed CLI output filter slices", () => {
}
const splitPath = await cli.run({
arguments: ["providers", "--json", "--filter-output", "][,]"],
timeoutMs: 3000,
});
expect(splitPath.exitCode).toBe(1);
expect(splitPath.json).toMatchObject({
@@ -419,6 +422,7 @@ describe("unclosed CLI output filter slices", () => {
"--filter-output",
"capabilities[0,1].provider.id,open",
],
timeoutMs: 3000,
});
expect(result.exitCode).toBe(0);
expect(result.json).toEqual({
+1
View File
@@ -365,6 +365,7 @@ cliTest.skipIf(process.platform === "win32")(
XDG_DATA_HOME: join(home, ".local", "share"),
XDG_CACHE_HOME: join(home, ".cache"),
},
timeoutMs: 20_000,
});
const planned = await setup("--dry-run");
@@ -306,6 +306,7 @@ cliTest(
XDG_CONFIG_HOME: join(root, "xdg-config"),
XDG_CACHE_HOME: join(root, "xdg-cache"),
},
timeoutMs: 20_000,
});
expect(result.exitCode, result.stderr).toBe(0);
@@ -351,6 +352,7 @@ for (const group of groups) {
arguments: arguments_,
cwd: home,
environment,
timeoutMs: 20_000,
});
const planned = await run([
"setup",
@@ -468,6 +470,7 @@ cliTest("uninstall names each retained skill directory", async ({ cli }) => {
USERPROFILE: home,
CLAUDE_CONFIG_DIR: join(home, ".claude"),
},
timeoutMs: 20_000,
});
expect(result.exitCode, result.stderr).toBe(0);
@@ -33,6 +33,7 @@ cliTest(
USERPROFILE: home,
CLAUDE_CONFIG_DIR: "",
},
timeoutMs: 20_000,
});
const setup = (flags: readonly string[] = []) =>
run(["setup", "--client", "claude_code", ...flags, "--json"]);
@@ -150,6 +151,7 @@ for (const policy of [{ excluded: ["rea"] }, { allowed: ["other"] }]) {
arguments: arguments_,
cwd: home,
environment: { HOME: home, USERPROFILE: home },
timeoutMs: 20_000,
});
const setup = await run([
"setup",
@@ -219,6 +221,7 @@ cliTest(
arguments: arguments_,
cwd: home,
environment: { HOME: home, USERPROFILE: home },
timeoutMs: 20_000,
});
const before = await run(["doctor", "--client", "opencode", "--json"]);
expect(before.exitCode).toBe(1);
@@ -49,6 +49,7 @@ cliTest(
],
cwd: f.home,
environment: f.environment,
timeoutMs: 20_000,
});
const first = await run();
expect(first.exitCode, first.stderr).toBe(0);
@@ -106,6 +107,7 @@ cliTest(
arguments: ["mcp", "add", ...(json ? ["--json"] : [])],
cwd: f.home,
environment: f.environment,
timeoutMs: 20_000,
});
expect(result.exitCode).toBe(1);
const document = report(result.stdout, json);
@@ -140,6 +142,7 @@ cliTest(
arguments: arguments_,
cwd: f.home,
environment: f.environment,
timeoutMs: 20_000,
});
expect(result.exitCode, result.stdout).toBe(0);
expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n");
@@ -164,6 +167,7 @@ cliTest(
arguments: arguments_,
cwd: f.home,
environment: f.environment,
timeoutMs: 20_000,
});
const registered = await run();
expect(registered.exitCode, registered.stdout).toBe(0);
@@ -231,6 +235,7 @@ cliTest(
],
cwd: f.home,
environment: f.environment,
timeoutMs: 20_000,
});
expect(result.exitCode, result.stdout).toBe(0);
expect(report(result.stdout, true)).toMatchObject({
@@ -260,6 +265,7 @@ cliTest(
arguments: ["mcp", "add", ...options, "--json"],
cwd: f.home,
environment: f.environment,
timeoutMs: 20_000,
});
expect(result.exitCode, result.stderr).toBe(1);
expect(result.json).toMatchObject({
@@ -91,6 +91,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"--json",
],
environment,
timeoutMs: 45_000,
});
const report = z
.object({ scope_checks: z.array(checkSchema) })
@@ -151,6 +152,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"--json",
],
environment,
timeoutMs: 45_000,
});
const report = z
.object({ scope_checks: z.array(checkSchema) })
@@ -181,6 +183,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
...environment,
REA_DOCKER_COMMAND: join(environment.HOME, "..", "bin", "docker"),
},
timeoutMs: 45_000,
});
expect(result.exitCode, result.stderr).toBe(1);
@@ -239,6 +242,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"--json",
],
environment,
timeoutMs: 45_000,
});
const plan = z
@@ -178,6 +178,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
REA_HOPPER_TEST_MARKER: launchMarker,
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
},
timeoutMs: 20_000,
});
const hopperReady = process.platform === "darwin";
@@ -75,6 +75,7 @@ const run = async (
const result = await cli.run({
arguments: [...arguments_, "--json"],
environment,
timeoutMs: 30_000,
});
return { result, report: reportSchema.safeParse(result.json) };
};
@@ -44,6 +44,7 @@ cliTest.for([
REA_FUSE_PROBE_TRACE: tracePath,
REA_FUSE_PROBE_ALLOW: join(root, "allow"),
},
timeoutMs: 30_000,
},
);
const trace = (await readFile(tracePath, "utf8"))
@@ -59,6 +59,7 @@ cliTest(
"--json",
],
cwd: root,
timeoutMs: 60_000,
});
expect(built.exitCode, built.stderr).toBe(0);
expect(built.json).toMatchObject({
@@ -110,6 +111,7 @@ cliTest(
join(root, "three-step"),
"--json",
],
timeoutMs: 60_000,
});
expect(exported.exitCode, exported.stderr).toBe(0);
@@ -176,6 +178,7 @@ cliTest(
"--capture-timeout-ms=030000",
"--json",
],
timeoutMs: 60_000,
});
expect(planned.exitCode, planned.stderr).toBe(0);
expect(planned.json).toMatchObject({
@@ -228,6 +231,7 @@ cliTest(
const noDirectory = await cli.run({
arguments: ["environment-build", har, "--json"],
timeoutMs: 60_000,
});
expect(noDirectory.exitCode).toBe(1);
expect(noDirectory.json).toMatchObject({
@@ -261,6 +265,7 @@ cliTest.for(["--dry-run=abc", "--dry-run="] as const)(
await writeFile(har, JSON.stringify(environmentManifestHar()));
const result = await cli.run({
arguments: ["environment-build", har, output, dryRun, "--json"],
timeoutMs: 60_000,
});
expect(result.exitCode).not.toBe(0);
expect(`${result.stdout}\n${result.stderr}`).toContain("--dry-run");
@@ -279,6 +284,7 @@ cliTest.for(["--dry-run=true", "--dry-run=false"] as const)(
await writeFile(har, JSON.stringify(environmentManifestHar()));
const result = await cli.run({
arguments: ["environment-build", har, output, dryRun, "--json"],
timeoutMs: 60_000,
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
@@ -709,6 +709,7 @@ describe("rea environment-compare replay and chronology", () => {
files.probe,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
@@ -736,6 +737,7 @@ describe("rea environment-compare replay and chronology", () => {
files.manifest,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -46,6 +46,7 @@ cliTest(
NODE_OPTIONS: `--import=${preload} --max-old-space-size=768`,
NODE_DISABLE_COMPILE_CACHE: "1",
},
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -92,6 +93,7 @@ cliTest(
name,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -121,6 +123,7 @@ cliTest(
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
@@ -208,6 +211,7 @@ cliTest(
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -249,6 +253,7 @@ cliTest(
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -285,6 +290,7 @@ cliTest.skipIf(process.platform === "win32")(
NODE_OPTIONS: `--import=${preload} --max-old-space-size=768`,
NODE_DISABLE_COMPILE_CACHE: "1",
},
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(0);
for (const relative of BUNDLE_FILES) {
@@ -326,6 +332,7 @@ cliTest.skipIf(process.platform === "win32")(
packagePath,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
@@ -380,6 +387,7 @@ cliTest.skipIf(HOST_PATH_MAX[process.platform] === undefined)(
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -46,6 +46,7 @@ for (const role of ["manifest", "evidence"] as const) {
REA_TEST_EXPORT_READ_MARKER: marker,
REA_TEST_EXPORT_READ_ACTION: "append",
},
timeoutMs: 30_000,
});
expect(await readFile(marker, "utf8")).toBe("read");
expect(
@@ -121,6 +122,7 @@ cliTest(
REA_TEST_EXPORT_READ_MARKER: marker,
REA_TEST_EXPORT_READ_ACTION: "record",
},
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(1);
const limits = heapRefusalSchema.parse(result.json).details
@@ -176,6 +178,7 @@ cliTest(
REA_TEST_EXPORT_READ_MARKER: marker,
REA_TEST_EXPORT_READ_ACTION: "fail-copy",
},
timeoutMs: 30_000,
});
expect(await readFile(marker, "utf8")).toBe("copy");
expect(await readFile(tarball, "utf8")).toBe("admitted package");
@@ -41,6 +41,7 @@ describe("rea environment-serve refusals", () => {
"0",
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -65,6 +66,7 @@ describe("rea environment-serve refusals", () => {
const files = await writeEnvironmentServeFiles(root);
const result = await cli.run({
arguments: ["environment-serve", files.manifestPath, "--json"],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -95,6 +97,7 @@ describe("rea environment-serve refusals", () => {
flag,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -118,6 +121,7 @@ describe("rea environment-serve refusals", () => {
files.capturePath,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -191,6 +195,7 @@ cliTest(
"0",
"--json",
],
timeoutMs: 30000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
@@ -37,6 +37,7 @@ const serve = async (
String(port),
"--json",
],
timeoutMs: 30_000,
});
return {
exitCode: result.exitCode,
@@ -23,22 +23,24 @@ cliTest.skipIf(process.platform !== "darwin")(
const directory = await createTestTempDirectory("rea-foreign-fat-cli-");
const thin = join(directory, "thin");
const universal = join(directory, "universal");
const compiled = await processes.run("/usr/bin/xcrun", [
"clang",
"-arch",
foreign.name,
resolve("tests/conformance/c/fixture.c"),
"-o",
thin,
]);
const compiled = await processes.run(
"/usr/bin/xcrun",
[
"clang",
"-arch",
foreign.name,
resolve("tests/conformance/c/fixture.c"),
"-o",
thin,
],
{ timeoutMs: 30_000 },
);
expect(compiled.exitCode, compiled.stderr).toBe(0);
const created = await processes.run("/usr/bin/xcrun", [
"lipo",
"-create",
thin,
"-output",
universal,
]);
const created = await processes.run(
"/usr/bin/xcrun",
["lipo", "-create", thin, "-output", universal],
{ timeoutMs: 30_000 },
);
expect(created.exitCode, created.stderr).toBe(0);
const result = await cli.run({
@@ -31,6 +31,7 @@ cliTest.for(["pipe", "capacity"] as const)(
JAVA_HOME: join(root, "missing-jdk"),
REA_LOG_LEVEL: "silent",
},
timeoutMs: 5000,
});
expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1);
expect(result.signal).toBeNull();
@@ -156,6 +156,7 @@ setTimeout(() => process.exit(92), 12000);
GHIDRA_INSTALL_DIR: install,
REA_LOG_LEVEL: "silent",
},
timeoutMs: 15000,
});
// Capture the actual group while the selected process is alive; the PID
// alone is never used as cleanup authority. Release is a private protocol.
@@ -147,6 +147,7 @@ describe.skipIf(process.platform === "win32")(
{
cwd: workspace,
env,
timeoutMs: 30_000,
},
);
const checkoutReports = join(workspace, "reports", "rea");
@@ -79,6 +79,7 @@ cliTest(
});
const built = await cli.run({
arguments: ["environment-build", har, "--dry-run", "--json"],
timeoutMs: 60_000,
});
expect(built.exitCode, built.stdout + built.stderr).toBe(0);
expect(built.json).toMatchObject({
@@ -54,6 +54,7 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
HOPPER_LOADER_ARGS_JSON: JSON.stringify(loaderArgs),
REA_LOG_LEVEL: "silent",
},
timeoutMs: 45_000,
});
const retained = RetainedRuntime.safeParse(result.json);
if (retained.success)
@@ -15,6 +15,7 @@ cliTest.skipIf(process.platform === "win32")(
const result = await cli.run({
arguments: ["doctor", "--provider", "ida", "--json"],
environment: { HOME: root, REA_IDA_MCP_CONFIG: registration },
timeoutMs: 10_000,
});
expect(result.exitCode, result.stderr).toBe(1);
expect(result.json).toMatchObject({
@@ -63,6 +64,7 @@ cliTest.skipIf(process.platform !== "darwin").for([true, false])(
const result = await cli.run({
arguments: ["doctor", "--provider", "ida", "--json"],
environment: { HOME: root, REA_IDA_MCP_CONFIG: registration },
timeoutMs: 10_000,
});
expect(result.exitCode, result.stderr).toBe(usable ? 0 : 1);
expect(result.json).toMatchObject({
@@ -142,6 +144,7 @@ cliTest.for([
const result = await cli.run({
arguments: ["doctor", "--provider", "ida", "--json"],
environment: { HOME: root, REA_IDA_MCP_CONFIG: path },
timeoutMs: 10_000,
});
expect(result.exitCode, result.stderr).toBe(
constraint === undefined ? 0 : 1,
@@ -168,6 +171,7 @@ cliTest.for([
const providers = await cli.run({
arguments: ["providers", "--json"],
environment: { HOME: root, REA_IDA_MCP_CONFIG: path },
timeoutMs: 10_000,
});
expect(providers.exitCode, providers.stderr).toBe(0);
expect(providers.json).toMatchObject({
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more