mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726a10dc6e | ||
|
|
ac30fbd658 | ||
|
|
5d21bddb18 | ||
|
|
2e2f981ccf | ||
|
|
180f121ba7 | ||
|
|
f8bb792f15 | ||
|
|
b65ff9bb79 | ||
|
|
5628b6ebd2 | ||
|
|
2f2665b5d2 | ||
|
|
7a76f676ab | ||
|
|
0e424cae8d |
No files matched your search
@@ -310,8 +310,8 @@ entry it does not read. The module reads the central directory itself, as REA
|
||||
does, and shows `zipfile` only the records it lists, reading each entry in
|
||||
bounded chunks no further than a byte past its declared size. A JSON file
|
||||
holding a number JavaScript cannot read exactly, such as an integer above 2^53,
|
||||
or nested more than 500 levels deep, gets an `error` instead of a view, as in
|
||||
REA, while text that is not one JSON
|
||||
or whose retained value nests more than 500 levels deep, gets an `error` instead of a view, as in
|
||||
REA. Duplicate keys keep their last value before the depth check. Text that is not one JSON
|
||||
document, such as JSON Lines, stays text. Format detection follows REA's
|
||||
rules. Files larger than 64 MiB get an `error` instead of a view
|
||||
and are not read. XML is read with Python's `expat` twice: with namespace
|
||||
|
||||
+14
-2
@@ -1012,8 +1012,20 @@ limitations. An unavailable group carries `remediation` when its readiness
|
||||
owner supplied a repair instruction; `tool_availability` uses that instruction
|
||||
rather than the failure description.
|
||||
|
||||
`binary_session.analysis_provider_candidates` is authoritative for deep-engine
|
||||
discovery. Each candidate's capability sets, returned with complete detail,
|
||||
`binary_session.analysis_provider_candidates` reports deep-engine discovery.
|
||||
`analysis_provider_candidates_observed` is `target_selection` when those facts
|
||||
were retained from the active target's selection, `status_request` when the
|
||||
status request sought candidate observations, and `not_applicable` for resource-only
|
||||
sessions. Selection-time readiness may have changed since the target was opened.
|
||||
Set `refresh_candidates: true` to inspect candidate readiness and target support
|
||||
again without replacing the active binding or engine. That does not resolve a
|
||||
new analysis profile or start a provider client. Later transitions and analysis
|
||||
calls wait for the inspection to finish, including cleanup. Current-route capabilities
|
||||
remain those of the bound session. Candidate statuses can still be unknown when
|
||||
an inspection cannot complete.
|
||||
`open_binary` with an explicit `provider_id` performs a new selection. Reopening
|
||||
the same target without `provider_id` reuses its current route. A changed route
|
||||
can retire the previous engine and its ephemeral edits. Each candidate's capability sets, returned with complete detail,
|
||||
describe that possible provider;
|
||||
they do not select it. The top-level `capabilities` describe the current route,
|
||||
while `analysis_provider_binding` identifies the concrete selected route. These
|
||||
|
||||
@@ -277,6 +277,11 @@ const openServer = async (provider, kunaSettings = {}) => {
|
||||
path: provider.path,
|
||||
provider_id: provider.id,
|
||||
});
|
||||
const session = (await call("binary_session")).value;
|
||||
assert.equal(
|
||||
session.analysis_provider_candidates_observed,
|
||||
"target_selection",
|
||||
);
|
||||
} catch (cause) {
|
||||
// A rejected connection/target still owns its transport and workspace.
|
||||
await close(cause);
|
||||
|
||||
@@ -1304,8 +1304,20 @@ export class BinarySession
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly detail?: BinarySessionDetail;
|
||||
readonly refreshCandidates?: boolean;
|
||||
} = {},
|
||||
): Promise<JsonValue> {
|
||||
return options.refreshCandidates === true
|
||||
? this.#serialize(() => this.#status(options), { abortPrompts: false })
|
||||
: this.#status(options);
|
||||
}
|
||||
|
||||
/** Keep an explicit candidate refresh ordered with target transitions. */
|
||||
#status(options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly detail?: BinarySessionDetail;
|
||||
readonly refreshCandidates?: boolean;
|
||||
}): Promise<JsonValue> {
|
||||
const resourceRun = this.#activeBundleResources;
|
||||
const run =
|
||||
this.#active ??
|
||||
@@ -1604,9 +1616,11 @@ export class BinarySession
|
||||
|
||||
#serialize<T>(
|
||||
operation: (admittedThrough: number) => Promise<T>,
|
||||
options: { readonly abortPrompts?: boolean } = {},
|
||||
): Promise<T> {
|
||||
for (const controller of this.#promptCompletionControllers)
|
||||
controller.abort();
|
||||
if (options.abortPrompts !== false)
|
||||
for (const controller of this.#promptCompletionControllers)
|
||||
controller.abort();
|
||||
this.#transitionPending += 1;
|
||||
// Later calls wait for this transition and must not be included in its drain.
|
||||
const admittedThrough = this.#transitionGeneration++;
|
||||
|
||||
@@ -167,6 +167,7 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
|
||||
expect(await session.status()).toMatchObject({
|
||||
open: true,
|
||||
session_kind: "bundle_resources",
|
||||
analysis_provider_candidates_observed: "not_applicable",
|
||||
resource_root: { canonical_path: opened.value.canonicalPath },
|
||||
});
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface BinarySessionPort
|
||||
status(options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly detail?: BinarySessionDetail;
|
||||
readonly refreshCandidates?: boolean;
|
||||
}): Promise<JsonValue>;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
/** Read a snapshot with failed descriptors retained for session cleanup retry. */
|
||||
|
||||
@@ -47,6 +47,7 @@ interface BinarySessionStatusInput {
|
||||
readonly cleanupRetry?: AnalysisCleanupRetry;
|
||||
readonly detail?: BinarySessionDetail;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly refreshCandidates?: boolean;
|
||||
}
|
||||
|
||||
/** Project internal provider routing state into the caller-visible session status. */
|
||||
@@ -66,6 +67,7 @@ export const binarySessionStatus = async ({
|
||||
cleanupRetry,
|
||||
detail = "complete",
|
||||
signal,
|
||||
refreshCandidates = false,
|
||||
}: BinarySessionStatusInput): Promise<JsonValue> => {
|
||||
const routeCapabilities = (
|
||||
bundleResourceRoot === undefined
|
||||
@@ -112,7 +114,13 @@ export const binarySessionStatus = async ({
|
||||
const analysisProviderCandidates =
|
||||
bundleResourceRoot !== undefined
|
||||
? []
|
||||
: (await router.candidateStatuses(route, signal)).map((candidate) =>
|
||||
: (
|
||||
await router.candidateStatuses(route, {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(target === undefined ? {} : { target }),
|
||||
refresh: refreshCandidates,
|
||||
})
|
||||
).map((candidate) =>
|
||||
detail === "summary"
|
||||
? {
|
||||
provider: providerSummary(candidate.provider),
|
||||
@@ -146,6 +154,12 @@ export const binarySessionStatus = async ({
|
||||
capabilities,
|
||||
analysis_provider_binding: analysisProviderBinding,
|
||||
analysis_provider_candidates: analysisProviderCandidates,
|
||||
analysis_provider_candidates_observed:
|
||||
bundleResourceRoot !== undefined
|
||||
? "not_applicable"
|
||||
: route.selection === undefined || refreshCandidates
|
||||
? "status_request"
|
||||
: "target_selection",
|
||||
analysis_run:
|
||||
runId === undefined
|
||||
? null
|
||||
|
||||
@@ -129,11 +129,26 @@ export class SessionProviderRouter {
|
||||
);
|
||||
}
|
||||
|
||||
/** Candidate status authoritative for the active or target-free route. */
|
||||
/** Preserve selection observations unless a caller requests a fresh inspection. */
|
||||
async candidateStatuses(
|
||||
route: SessionProviderRoute,
|
||||
signal?: AbortSignal,
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly target?: BinaryTarget;
|
||||
readonly refresh?: boolean;
|
||||
} = {},
|
||||
): Promise<readonly AnalysisProviderCandidateStatus[]> {
|
||||
const { signal, target, refresh = false } = options;
|
||||
if (refresh && route.selection !== undefined) {
|
||||
const current = await this.registry.candidates(
|
||||
target,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return current.map((candidate) => ({
|
||||
...candidate,
|
||||
selected: candidate.provider.id === route.binding?.identity.id,
|
||||
}));
|
||||
}
|
||||
return (
|
||||
route.selection?.candidates ??
|
||||
this.registry.candidates(
|
||||
|
||||
@@ -266,12 +266,10 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
// "0x10" or "1e2".
|
||||
blockSize: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.block_size,
|
||||
).describe(
|
||||
"Power-of-two block size from 256 to 65536 bytes (default 4096)",
|
||||
),
|
||||
).describe("Power-of-two block size from 256 to 65536 bytes"),
|
||||
offset: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.offset,
|
||||
).describe("Window start as a decimal file byte offset (default 0)"),
|
||||
).describe("Window start as a decimal file byte offset"),
|
||||
length: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.length,
|
||||
).describe("Window length in bytes; omit to extend through EOF"),
|
||||
@@ -429,16 +427,16 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
options: z.object({
|
||||
maxFileBytes: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_file_bytes,
|
||||
).describe("Maximum FDT file size in bytes (default 4194304)"),
|
||||
).describe("Maximum FDT file size in bytes"),
|
||||
maxNodes: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_nodes,
|
||||
).describe("Maximum number of device-tree nodes (default 8192)"),
|
||||
).describe("Maximum number of device-tree nodes"),
|
||||
maxProperties: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_properties,
|
||||
).describe("Maximum number of device-tree properties (default 32768)"),
|
||||
).describe("Maximum number of device-tree properties"),
|
||||
maxDepth: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_depth,
|
||||
).describe("Maximum device-tree nesting depth (default 64)"),
|
||||
).describe("Maximum device-tree nesting depth"),
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("CLI JSON path normalization", () => {
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
code: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
format: "regex",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@ export const binarySessionInputSchema = z.strictObject({
|
||||
.describe(
|
||||
"Start with summary (default): it keeps open state, the binding, current-route capabilities (including selected Ghidra checkpoint mode), each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. Request complete only when you need unavailable-candidate remediation, provider diagnostics, candidate capabilities or static tool surface, effect and annotation flags.",
|
||||
),
|
||||
refresh_candidates: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Inspect current provider candidates for the active target without replacing or restarting the selected provider. Later transitions and analysis calls wait for inspection finalization. Omitted or false reports the retained selection observations.",
|
||||
),
|
||||
expected_package_version: z.string().min(1).optional(),
|
||||
expected_catalog_digest: z
|
||||
.string()
|
||||
|
||||
@@ -88,7 +88,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"binary_session",
|
||||
'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.',
|
||||
'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. analysis_provider_candidates_observed distinguishes this status request from retained target-selection observations; selection-time readiness may have changed. Set refresh_candidates true to inspect current candidates without changing the active binding or engine. open_binary with an explicit provider_id reselects and may retire the previous engine and its ephemeral edits; reopening the same target without provider_id reuses its current route. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.',
|
||||
binarySessionInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -556,6 +556,11 @@ export const sessionProviderFacts = z
|
||||
analysis_run: analysisRun,
|
||||
analysis_activity: analysisActivity,
|
||||
provider_operation_health: providerOperationHealth,
|
||||
analysis_provider_candidates_observed: z
|
||||
.enum(["status_request", "target_selection", "not_applicable"])
|
||||
.describe(
|
||||
"When provider candidate observations were collected: this status request, the retained target selection, or not applicable to a resource-only session.",
|
||||
),
|
||||
analysis_provider_binding: z
|
||||
.object({
|
||||
provider: providerIdentity,
|
||||
|
||||
@@ -51,7 +51,7 @@ const captureInputShape = z.strictObject({
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Skip this many matching packets after filtering; default 0"),
|
||||
.describe("Skip this many matching packets after filtering"),
|
||||
match_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -5,9 +5,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
kind: z
|
||||
.enum(["protocols", "fields"])
|
||||
.default("protocols")
|
||||
.describe(
|
||||
"Return protocol registrations or field definitions; defaults to protocols",
|
||||
),
|
||||
.describe("Return protocol registrations or field definitions"),
|
||||
protocol: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -30,7 +28,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe("Number of matching records to skip; defaults to 0"),
|
||||
.describe("Number of matching records to skip"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PYTHON_JSON_READER } from "./appExportPythonJson.js";
|
||||
import { PYTHON_XML_READER } from "./appExportPythonXml.js";
|
||||
import { PYTHON_ZIP_HELPERS } from "./appExportPythonZip.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ export const PYTHON_READY_CHECK = `
|
||||
|
||||
export const PYTHON_IMPORTS = `from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
@@ -57,11 +59,6 @@ MAX_PARSED_BYTES = 4 * 1024 * 1024
|
||||
PARSED_TOO_LARGE = "larger than the 4 MiB limit for JSON and key file views"
|
||||
MAX_JSON_VIEW_DEPTH = 500
|
||||
JSON_TOO_DEEP = f"nested deeper than the {MAX_JSON_VIEW_DEPTH} levels a JSON view keeps"
|
||||
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
|
||||
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
|
||||
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
|
||||
r"|true|false|null))")
|
||||
JSON_SPACE = re.compile(r"[ \t\n\r]*")
|
||||
|
||||
|
||||
def _respelled(text: str, home: Path) -> str:
|
||||
@@ -254,143 +251,7 @@ def _keyfile(text: str) -> dict:
|
||||
|
||||
${PYTHON_XML_READER}
|
||||
|
||||
def _not_json(constant: str):
|
||||
raise ValueError(f"{constant} is not JSON")
|
||||
|
||||
|
||||
class _Inexact(Exception):
|
||||
"""A JSON number that REA's state view refuses, since reading it would change it."""
|
||||
|
||||
|
||||
def _digits(text: str) -> str:
|
||||
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
|
||||
|
||||
|
||||
def _js_string(value: float) -> str:
|
||||
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
|
||||
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
|
||||
|
||||
|
||||
def _safe_number(text: str) -> bool:
|
||||
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
|
||||
if re.fullmatch(r"-?[0-9]+", text):
|
||||
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
return False
|
||||
written = _js_string(value)
|
||||
return written == text or _digits(written) == _digits(text)
|
||||
|
||||
|
||||
def _exact_number(text: str):
|
||||
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
|
||||
written-back form both safe, and not -0."""
|
||||
value = float(text)
|
||||
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
|
||||
or (value == 0 and math.copysign(1, value) < 0)):
|
||||
raise _Inexact(text)
|
||||
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
|
||||
|
||||
|
||||
_INEXACT = object()
|
||||
_LEVEL_END = object()
|
||||
|
||||
|
||||
def _number_or_mark(spelling: str):
|
||||
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
|
||||
try:
|
||||
return _exact_number(spelling)
|
||||
except _Inexact:
|
||||
return _INEXACT
|
||||
|
||||
|
||||
def _holds_inexact(value) -> bool:
|
||||
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
|
||||
iterator per nesting level, so no child list is copied."""
|
||||
levels = [iter((value,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _INEXACT:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _deeper_than(root, limit: int) -> bool:
|
||||
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
|
||||
iterators open above it, one per nesting level, so no child list is copied."""
|
||||
levels = [iter((root,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif len(levels) - 1 > limit:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _is_json(text: str) -> bool:
|
||||
"""Whether text is one JSON value as REA's state view reads it, checked a token at a time without building it or
|
||||
recursing: it holds a byte per open container and one string at a time, so text too deep for Python's parser or
|
||||
too large to parse into a view is still told apart from other text."""
|
||||
containers, expect, empty, at = bytearray(), "value", False, 0
|
||||
while True:
|
||||
token = JSON_TOKEN.match(text, at)
|
||||
if token is None:
|
||||
break
|
||||
kind, at = token.lastindex, token.end()
|
||||
if kind == 5:
|
||||
try:
|
||||
at = json.decoder.scanstring(text, at)[1]
|
||||
except ValueError:
|
||||
return False
|
||||
if kind == 1 and expect == "value":
|
||||
containers.append(ord(text[at - 1]))
|
||||
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
|
||||
# A closing bracket's code is its opening's plus two.
|
||||
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
|
||||
containers.pop()
|
||||
expect, empty = "after", False
|
||||
elif kind == 3 and expect == "after" and containers:
|
||||
expect = "value" if containers[-1] == ord("[") else "key"
|
||||
elif kind == 4 and expect == "colon":
|
||||
expect = "value"
|
||||
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
|
||||
expect, empty = ("colon" if expect == "key" else "after"), False
|
||||
else:
|
||||
return False
|
||||
return expect == "after" and not containers and JSON_SPACE.fullmatch(text, at) is not None
|
||||
|
||||
|
||||
def _json_view(text: str, size: int):
|
||||
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
|
||||
parsed-view limit, or nested past Python's recursion limit, is told apart without building its value."""
|
||||
if size > MAX_PARSED_BYTES:
|
||||
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if _is_json(text) else None
|
||||
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
|
||||
try:
|
||||
view = json.loads(text, parse_constant=_not_json, parse_float=_number_or_mark, parse_int=_number_or_mark)
|
||||
except RecursionError:
|
||||
# Nesting too deep for Python to parse is far deeper than REA's view keeps, once the rest of it is JSON too.
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP} if _is_json(text) else None
|
||||
except ValueError:
|
||||
return None
|
||||
if _holds_inexact(view):
|
||||
return {"format": "json", "view": None,
|
||||
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
|
||||
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
|
||||
return {"format": "json", "view": view, "truncated": False}
|
||||
|
||||
${PYTHON_JSON_READER}
|
||||
|
||||
def _text_view(name: str, text: str, size: int) -> dict:
|
||||
"""The format REA's state view detects for decoded text of size bytes, and its view; a JSON or key file over the
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/** JSON parsing and bounded retained-view checks for exported desktop environments. */
|
||||
export const PYTHON_JSON_READER = String.raw`
|
||||
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
|
||||
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
|
||||
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
|
||||
r"|true|false|null))")
|
||||
JSON_SPACE = re.compile(r"[ \t\n\r]*")
|
||||
|
||||
|
||||
class _Inexact(Exception):
|
||||
"""A JSON number that REA's state view refuses, since reading it would change it."""
|
||||
|
||||
|
||||
def _digits(text: str) -> str:
|
||||
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
|
||||
|
||||
|
||||
def _js_string(value: float) -> str:
|
||||
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
|
||||
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
|
||||
|
||||
|
||||
def _safe_number(text: str) -> bool:
|
||||
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
|
||||
if re.fullmatch(r"-?[0-9]+", text):
|
||||
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
return False
|
||||
written = _js_string(value)
|
||||
return written == text or _digits(written) == _digits(text)
|
||||
|
||||
|
||||
def _exact_number(text: str):
|
||||
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
|
||||
written-back form both safe, and not -0."""
|
||||
value = float(text)
|
||||
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
|
||||
or (value == 0 and math.copysign(1, value) < 0)):
|
||||
raise _Inexact(text)
|
||||
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
|
||||
|
||||
|
||||
_INEXACT = object()
|
||||
_DEEP = object()
|
||||
_DEEP_INEXACT = object()
|
||||
_LEVEL_END = object()
|
||||
_INVALID_JSON = object()
|
||||
JSON_ATOMS = {"true": True, "false": False, "null": None}
|
||||
|
||||
|
||||
def _number_or_mark(spelling: str):
|
||||
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
|
||||
try:
|
||||
return _exact_number(spelling)
|
||||
except _Inexact:
|
||||
return _INEXACT
|
||||
|
||||
|
||||
def _holds_inexact(value) -> bool:
|
||||
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
|
||||
iterator per nesting level, so no child list is copied."""
|
||||
levels = [iter((value,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _INEXACT or item is _DEEP_INEXACT:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _deeper_than(root, limit: int) -> bool:
|
||||
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
|
||||
iterators open above it, one per nesting level, so no child list is copied."""
|
||||
levels = [iter((root,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _DEEP or item is _DEEP_INEXACT:
|
||||
return True
|
||||
elif len(levels) - 1 > limit:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _read_json(text: str, build: bool = True):
|
||||
"""One JSON value, or _INVALID_JSON, read without recursion so duplicate keys settle before depth is checked.
|
||||
Deep containers are summarized rather than built; exact-number marks still follow last-key-wins semantics.
|
||||
With build=False, only syntax is checked: one byte per open container and one decoded string at a time."""
|
||||
containers, expect, empty, at = bytearray(), "value", False, 0
|
||||
values, keys, root = [], [], _INVALID_JSON
|
||||
deep_bad, deep_key_at, bad_keys = bytearray(), array("I"), {}
|
||||
|
||||
def keep(value) -> None:
|
||||
nonlocal root
|
||||
if not values:
|
||||
root = value
|
||||
elif containers[-1] == ord("["):
|
||||
values[-1].append(value)
|
||||
else:
|
||||
values[-1][keys[-1]] = value
|
||||
|
||||
def record_deep(bad: bool) -> None:
|
||||
"""Summarize a finished child; keep only object keys with live inexact values."""
|
||||
depth = len(deep_bad) - 1
|
||||
if containers[-1] == ord("["):
|
||||
deep_bad[-1] |= 2 | bad
|
||||
return
|
||||
marked = bad_keys.get(depth)
|
||||
if bad or marked is not None:
|
||||
key, _ = json.decoder.scanstring(text, deep_key_at[-1])
|
||||
if bad:
|
||||
if marked is None:
|
||||
marked = key
|
||||
elif isinstance(marked, str):
|
||||
if marked != key:
|
||||
marked = {marked, key}
|
||||
else:
|
||||
marked.add(key)
|
||||
else:
|
||||
if isinstance(marked, str):
|
||||
if marked == key:
|
||||
marked = None
|
||||
else:
|
||||
marked.discard(key)
|
||||
if len(marked) == 1:
|
||||
marked = next(iter(marked))
|
||||
if marked is None:
|
||||
bad_keys.pop(depth, None)
|
||||
else:
|
||||
bad_keys[depth] = marked
|
||||
deep_bad[-1] = 2 | (marked is not None)
|
||||
else:
|
||||
deep_bad[-1] |= 2
|
||||
|
||||
while True:
|
||||
token = JSON_TOKEN.match(text, at)
|
||||
if token is None:
|
||||
break
|
||||
kind, at = token.lastindex, token.end()
|
||||
if kind == 5:
|
||||
string_at = at
|
||||
try:
|
||||
value, at = json.decoder.scanstring(text, at)
|
||||
except ValueError:
|
||||
return _INVALID_JSON
|
||||
if kind == 1 and expect == "value":
|
||||
if build:
|
||||
if deep_bad or len(containers) >= MAX_JSON_VIEW_DEPTH:
|
||||
deep_bad.append(0)
|
||||
deep_key_at.append(0)
|
||||
else:
|
||||
value = [] if text[at - 1] == "[" else {}
|
||||
keep(value)
|
||||
values.append(value)
|
||||
keys.append(None)
|
||||
containers.append(ord(text[at - 1]))
|
||||
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
|
||||
# A closing bracket's code is its opening's plus two.
|
||||
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
|
||||
opening = containers.pop()
|
||||
if build:
|
||||
if deep_bad:
|
||||
state = deep_bad.pop()
|
||||
bad = bool(state & 1)
|
||||
deep_key_at.pop()
|
||||
bad_keys.pop(len(deep_bad), None)
|
||||
if deep_bad:
|
||||
record_deep(bad)
|
||||
else:
|
||||
keep((_DEEP_INEXACT if bad else _DEEP) if state & 2 else ([] if opening == ord("[") else {}))
|
||||
else:
|
||||
values.pop()
|
||||
keys.pop()
|
||||
expect, empty = "after", False
|
||||
elif kind == 3 and expect == "after" and containers:
|
||||
expect = "value" if containers[-1] == ord("[") else "key"
|
||||
elif kind == 4 and expect == "colon":
|
||||
expect = "value"
|
||||
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
|
||||
if build:
|
||||
if expect == "key":
|
||||
if deep_bad:
|
||||
deep_key_at[-1] = string_at
|
||||
else:
|
||||
keys[-1] = value
|
||||
else:
|
||||
if kind == 6:
|
||||
value = JSON_ATOMS.get(token[6], _INVALID_JSON)
|
||||
if value is _INVALID_JSON:
|
||||
value = _number_or_mark(token[6])
|
||||
if deep_bad:
|
||||
record_deep(value is _INEXACT)
|
||||
else:
|
||||
keep(value)
|
||||
expect, empty = ("colon" if expect == "key" else "after"), False
|
||||
else:
|
||||
return _INVALID_JSON
|
||||
if expect != "after" or containers or JSON_SPACE.fullmatch(text, at) is None:
|
||||
return _INVALID_JSON
|
||||
return root if build else True
|
||||
|
||||
|
||||
def _json_view(text: str, size: int):
|
||||
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
|
||||
parsed-view limit is told apart without building its value."""
|
||||
if size > MAX_PARSED_BYTES:
|
||||
valid = _read_json(text, build=False) is not _INVALID_JSON
|
||||
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if valid else None
|
||||
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
|
||||
view = _read_json(text)
|
||||
if view is _INVALID_JSON:
|
||||
return None
|
||||
if _holds_inexact(view):
|
||||
return {"format": "json", "view": None,
|
||||
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
|
||||
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
|
||||
return {"format": "json", "view": view, "truncated": False}
|
||||
|
||||
`;
|
||||
@@ -21,11 +21,20 @@ const PATHS = [
|
||||
];
|
||||
|
||||
it("matches the host path module for absolute local paths", () => {
|
||||
const checked: string[] = [];
|
||||
for (const path of PATHS) {
|
||||
checked.push(path);
|
||||
expect(absoluteLocalPathStringSchema.safeParse(path).success).toBe(
|
||||
isAbsolute(path),
|
||||
);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"/absolute/path",
|
||||
"relative/path",
|
||||
"file:///absolute/path",
|
||||
]),
|
||||
);
|
||||
expect(absoluteLocalPathStringSchema.safeParse("").success).toBe(false);
|
||||
expect(
|
||||
absoluteLocalPathStringSchema.safeParse("/absolute/with\0nul").success,
|
||||
@@ -40,8 +49,18 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => {
|
||||
() => new RegExp(windowsAbsolutePathPattern.source, flags),
|
||||
flags,
|
||||
).not.toThrow();
|
||||
for (const path of PATHS)
|
||||
const checked: string[] = [];
|
||||
for (const path of PATHS) {
|
||||
checked.push(path);
|
||||
expect(windowsAbsolutePathPattern.test(path), path).toBe(
|
||||
win32.isAbsolute(path),
|
||||
);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"C:\\absolute\\path",
|
||||
"C:relative/path",
|
||||
"/absolute/path",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -24,9 +24,7 @@ export const inspectSignalDecodersInputSchema = z.strictObject({
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Number of matching decoders or help lines to skip; defaults to 0",
|
||||
),
|
||||
.describe("Number of matching decoders or help lines to skip"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -31,7 +31,7 @@ const signalInputShape = z.strictObject({
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Skip this many literal LF-separated output lines; default 0"),
|
||||
.describe("Skip this many literal LF-separated output lines"),
|
||||
line_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -103,6 +103,7 @@ export const registerSessionStatusTool = (
|
||||
status = await session.status({
|
||||
signal: context.mcpReq.signal,
|
||||
detail: input.detail,
|
||||
refreshCandidates: input.refresh_candidates,
|
||||
});
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof AnalysisError)) throw cause;
|
||||
|
||||
@@ -255,11 +255,11 @@ cliTest(
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
},
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
@@ -329,21 +330,84 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
|
||||
["start_gdb_session", { inferior_tty: nul }],
|
||||
];
|
||||
|
||||
const nulIssuePaths = (
|
||||
value: unknown,
|
||||
path: readonly (string | number)[] = [],
|
||||
): (string | number)[][] => {
|
||||
if (typeof value === "string") return value.includes("\0") ? [[...path]] : [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item, index) =>
|
||||
nulIssuePaths(item, [...path, index]),
|
||||
);
|
||||
if (typeof value === "object" && value !== null)
|
||||
return Object.entries(value).flatMap(([key, item]) =>
|
||||
nulIssuePaths(item, [...path, key]),
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
it("rejects OS-invalid local inputs through the real advertised MCP contracts", async () => {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const checked: string[] = [];
|
||||
for (const [name, arguments_] of pathRequests) {
|
||||
const response = await call(name, arguments_);
|
||||
expect(response.isError, name).toBe(true);
|
||||
expect(JSON.stringify(response.content), name).toContain("NUL");
|
||||
const paths = nulIssuePaths(arguments_);
|
||||
expect(paths.length, name).toBeGreaterThan(0);
|
||||
const error = parseMcpToolError(await call(name, arguments_)).error;
|
||||
expect(error, name).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: name },
|
||||
});
|
||||
const issues = z
|
||||
.object({
|
||||
issues: z.array(
|
||||
z.object({
|
||||
path: z.array(z.union([z.string(), z.number()])),
|
||||
reason: z.string(),
|
||||
message: z.string(),
|
||||
expected: z.unknown().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(error.details).issues;
|
||||
expect(issues, name).toEqual(
|
||||
expect.arrayContaining(
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: expect.stringMatching(/\bNUL\b|null byte|zero byte/i),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
checked.push(name);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"extract_firmware",
|
||||
"inspect_wasm_artifact",
|
||||
"demangle_swift",
|
||||
"capture_electron_scenario",
|
||||
]),
|
||||
);
|
||||
const relative = await call("extract_firmware", {
|
||||
path: "/tmp/firmware.bin",
|
||||
output_directory: "relative-output",
|
||||
});
|
||||
expect(relative.isError).toBe(true);
|
||||
expect(JSON.stringify(relative.content)).toContain(
|
||||
"absolute local filesystem path",
|
||||
);
|
||||
expect(parseMcpToolError(relative).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "extract_firmware",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["output_directory"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const ignoredOptions = [
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
|
||||
type CliInstance = ReturnType<typeof createCli>;
|
||||
|
||||
const serve = async (
|
||||
cli: CliInstance,
|
||||
argv: readonly string[],
|
||||
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
|
||||
let stdout = "";
|
||||
let exitCode = 0;
|
||||
await cli.serve([...argv], {
|
||||
env: {},
|
||||
exit: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
});
|
||||
return { stdout, exitCode };
|
||||
};
|
||||
|
||||
/**
|
||||
* Options whose owning field already declares a schema default. The help
|
||||
* renderer appends that default, so the description must not restate it.
|
||||
*/
|
||||
const singleDefaultOptions: readonly {
|
||||
readonly command: string;
|
||||
readonly options: readonly string[];
|
||||
}[] = [
|
||||
{ command: "inspect-byte-entropy", options: ["--block-size", "--offset"] },
|
||||
{
|
||||
command: "inspect-devicetree",
|
||||
options: [
|
||||
"--max-file-bytes",
|
||||
"--max-nodes",
|
||||
"--max-properties",
|
||||
"--max-depth",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "decode-signal-recording",
|
||||
options: ["--line_offset", "--line_limit"],
|
||||
},
|
||||
{
|
||||
command: "inspect-packet-capture",
|
||||
options: ["--match_offset", "--match_limit"],
|
||||
},
|
||||
{
|
||||
command: "inspect-packet-fields",
|
||||
options: ["--kind", "--offset"],
|
||||
},
|
||||
{ command: "inspect-signal-decoders", options: ["--offset"] },
|
||||
];
|
||||
|
||||
const optionLine = (help: string, option: string): string | undefined => {
|
||||
const line = help
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.trimStart().startsWith(`${option} `));
|
||||
return line?.trim();
|
||||
};
|
||||
|
||||
describe("CLI help default annotations", () => {
|
||||
it("states each schema-owned option default exactly once", async () => {
|
||||
const cli = createCli({});
|
||||
const repeated: string[] = [];
|
||||
for (const { command, options } of singleDefaultOptions) {
|
||||
const { stdout, exitCode } = await serve(cli, [command, "--help"]);
|
||||
expect(exitCode, command).toBe(0);
|
||||
for (const option of options) {
|
||||
const line = optionLine(stdout, option);
|
||||
if (line === undefined) {
|
||||
repeated.push(`${command} ${option}: option line missing`);
|
||||
continue;
|
||||
}
|
||||
const statements = line.match(/default/giu)?.length ?? 0;
|
||||
if (statements !== 1)
|
||||
repeated.push(
|
||||
`${command} ${option}: ${statements} default statements`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ interface PathInputCase {
|
||||
readonly field: string;
|
||||
readonly valid: Readonly<Record<string, unknown>>;
|
||||
readonly invalid: Readonly<Record<string, unknown>>;
|
||||
readonly issuePath?: readonly string[];
|
||||
readonly issuePath: readonly string[];
|
||||
}
|
||||
|
||||
const createMcp = async (
|
||||
@@ -104,7 +104,13 @@ const inputCases = (
|
||||
field: string,
|
||||
valid: Readonly<Record<string, unknown>>,
|
||||
invalid: Readonly<Record<string, unknown>>,
|
||||
): PathInputCase => ({ name, field, valid, invalid });
|
||||
): PathInputCase => ({
|
||||
name,
|
||||
field,
|
||||
valid,
|
||||
invalid,
|
||||
issuePath: field.split("."),
|
||||
});
|
||||
|
||||
return [
|
||||
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
|
||||
@@ -246,8 +252,7 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const absolute = resolve("rea-absolute-input");
|
||||
const relative = "relative-input";
|
||||
|
||||
for (const { name, field, valid, invalid } of [
|
||||
const schemaCases = [
|
||||
...inputCases(absolute, relative),
|
||||
{
|
||||
name: "inspect_go_binary",
|
||||
@@ -276,7 +281,9 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
right_bundle_path: absolute,
|
||||
},
|
||||
},
|
||||
]) {
|
||||
];
|
||||
const checked: string[] = [];
|
||||
for (const { name, field, valid, invalid } of schemaCases) {
|
||||
const schema = listed.tools.find(
|
||||
(tool) => tool.name === name,
|
||||
)?.inputSchema;
|
||||
@@ -288,7 +295,16 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
validate(valid),
|
||||
`${name}.${field}: ${JSON.stringify(validate.errors)}`,
|
||||
).toBe(true);
|
||||
checked.push(`${name}.${field}`);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"open_binary.path",
|
||||
"extract_firmware.output_directory",
|
||||
"inspect_go_binary.path NUL",
|
||||
"compare_bundles.left_bundle_path empty",
|
||||
]),
|
||||
);
|
||||
expect(firmwareCalls).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
@@ -299,19 +315,39 @@ it("rejects relative paths through in-memory MCP handlers", async () => {
|
||||
const firmwareCalls: string[] = [];
|
||||
const { client, close } = await createMcp(firmwareCalls);
|
||||
try {
|
||||
for (const { name, field, invalid, issuePath } of inputCases(
|
||||
resolve("rea-absolute-input"),
|
||||
"relative-input",
|
||||
)) {
|
||||
const cases = inputCases(resolve("rea-absolute-input"), "relative-input");
|
||||
const checked: string[] = [];
|
||||
for (const { name, field, invalid, issuePath } of cases) {
|
||||
const result = await client.callTool({ name, arguments: invalid });
|
||||
expect(result.isError, `${name}.${field}`).toBe(true);
|
||||
expect(JSON.stringify(result), `${name}.${field}`).toContain("absolute");
|
||||
if (issuePath !== undefined)
|
||||
expect(parseMcpToolError(result).error.details).toMatchObject({
|
||||
operation: name,
|
||||
issues: [{ path: issuePath, reason: "invalid_format" }],
|
||||
});
|
||||
expect(parseMcpToolError(result).error, `${name}.${field}`).toMatchObject(
|
||||
{
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: name,
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: issuePath,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
checked.push(`${name}.${field}`);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"open_binary.path",
|
||||
"open_binary.snapshot_path",
|
||||
"extract_firmware.path",
|
||||
"extract_firmware.output_directory",
|
||||
"inspect_analysis_view.source.path",
|
||||
"capture_browser_scenario.browser.executable_path",
|
||||
"capture_electron_scenario.application_root",
|
||||
]),
|
||||
);
|
||||
expect(firmwareCalls).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
|
||||
@@ -994,6 +994,439 @@ it("preserves a selected Ghidra project's read-only guidance when an edit is req
|
||||
expect(starts).toEqual([]);
|
||||
});
|
||||
|
||||
it("labels unbound provider observations as selection-time after a repair", async () => {
|
||||
const directory = await createTestTempDirectory("rea-candidate-observation-");
|
||||
const target = join(directory, "fixture.hop");
|
||||
await writeFile(target, "fixture");
|
||||
let available = false;
|
||||
const base = candidate("alpha", []);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: () =>
|
||||
candidate("alpha", [], available).inspectAvailability(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const mcp = new Client({ name: "candidate-scope", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server, { close: () => session.close() });
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: target } }))
|
||||
.isError,
|
||||
).toBeFalsy();
|
||||
available = true;
|
||||
const stale = toolContract("binary_session").outputSchema.parse(
|
||||
(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.structuredContent,
|
||||
).result;
|
||||
expect(stale.analysis_provider_binding).toBeNull();
|
||||
expect(stale.analysis_provider_candidates_observed).toBe("target_selection");
|
||||
expect(stale.analysis_provider_candidates[0]?.availability.status).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
const inspected = toolContract("binary_session").outputSchema.parse(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { refresh_candidates: true },
|
||||
})
|
||||
).structuredContent,
|
||||
).result;
|
||||
expect(inspected.analysis_provider_binding).toBeNull();
|
||||
expect(inspected.analysis_provider_candidates_observed).toBe(
|
||||
"status_request",
|
||||
);
|
||||
expect(inspected.analysis_provider_candidates[0]?.availability.status).toBe(
|
||||
"available",
|
||||
);
|
||||
const retained = toolContract("binary_session").outputSchema.parse(
|
||||
(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.structuredContent,
|
||||
).result;
|
||||
expect(retained.analysis_provider_candidates[0]?.availability.status).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: target } }))
|
||||
.isError,
|
||||
).toBeFalsy();
|
||||
const reused = toolContract("binary_session").outputSchema.parse(
|
||||
(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.structuredContent,
|
||||
).result;
|
||||
expect(reused.analysis_provider_binding).toBeNull();
|
||||
expect(reused.analysis_provider_candidates[0]?.availability.status).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "alpha" },
|
||||
})
|
||||
).isError,
|
||||
).toBeFalsy();
|
||||
const refreshed = toolContract("binary_session").outputSchema.parse(
|
||||
(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.structuredContent,
|
||||
).result;
|
||||
expect(refreshed.analysis_provider_candidates_observed).toBe(
|
||||
"target_selection",
|
||||
);
|
||||
expect(refreshed.analysis_provider_binding?.provider.id).toBe("alpha");
|
||||
});
|
||||
|
||||
it("preserves the bound client when a candidate refresh is cancelled during finalization", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-candidate-refresh-cancel-",
|
||||
);
|
||||
const target = join(directory, "fixture.hop");
|
||||
await writeFile(target, "fixture");
|
||||
const starts: string[] = [];
|
||||
const base = candidate("alpha", starts);
|
||||
let hold = false;
|
||||
let enter!: () => void;
|
||||
let finish!: () => void;
|
||||
let finalized!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const done = new Promise<void>((resolve) => {
|
||||
finalized = resolve;
|
||||
});
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: async ({
|
||||
signal,
|
||||
}: AnalysisProviderInspectionOptions = {}) => {
|
||||
const result = await base.inspectAvailability();
|
||||
if (!hold) return result;
|
||||
enter();
|
||||
await gate;
|
||||
finalized();
|
||||
if (signal?.aborted)
|
||||
throw new AnalysisCancelledError("binary_session", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const mcp = new Client({ name: "candidate-refresh-cancel", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server, { close: () => session.close() });
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
let refreshing: ReturnType<typeof mcp.callTool> | undefined;
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "alpha" },
|
||||
})
|
||||
).isError,
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
|
||||
).toBeFalsy();
|
||||
expect(starts).toEqual(["alpha"]);
|
||||
hold = true;
|
||||
const controller = new AbortController();
|
||||
refreshing = mcp.callTool(
|
||||
{ name: "binary_session", arguments: { refresh_candidates: true } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await awaitAcknowledgement(entered, refreshing, "candidate refresh");
|
||||
controller.abort();
|
||||
await expect(refreshing).rejects.toThrow();
|
||||
finish();
|
||||
await done;
|
||||
hold = false;
|
||||
expect(
|
||||
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.result,
|
||||
).toMatchObject({
|
||||
analysis_provider_binding: { provider: { id: "alpha" } },
|
||||
analysis_provider_candidates_observed: "target_selection",
|
||||
});
|
||||
expect(
|
||||
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
|
||||
).toBeFalsy();
|
||||
expect(starts).toEqual(["alpha"]);
|
||||
} finally {
|
||||
hold = false;
|
||||
finish();
|
||||
await Promise.allSettled(refreshing === undefined ? [] : [refreshing]);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders a provider switch after an in-flight candidate refresh", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-candidate-refresh-switch-",
|
||||
);
|
||||
const target = join(directory, "fixture.hop");
|
||||
await writeFile(target, "fixture");
|
||||
const starts: string[] = [];
|
||||
const alpha = candidate("alpha", starts);
|
||||
let hold = false;
|
||||
let enter!: () => void;
|
||||
let finish!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...alpha,
|
||||
inspectAvailability: async () => {
|
||||
if (hold) {
|
||||
hold = false;
|
||||
enter();
|
||||
await gate;
|
||||
}
|
||||
return alpha.inspectAvailability();
|
||||
},
|
||||
},
|
||||
candidate("beta", starts),
|
||||
]),
|
||||
);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const mcp = new Client({ name: "refresh-switch-race", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server, { close: () => session.close() });
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
let refreshing: ReturnType<typeof mcp.callTool> | undefined;
|
||||
let switching: ReturnType<typeof mcp.callTool> | undefined;
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "alpha" },
|
||||
})
|
||||
).isError,
|
||||
).toBeFalsy();
|
||||
hold = true;
|
||||
refreshing = mcp.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { refresh_candidates: true },
|
||||
});
|
||||
await awaitAcknowledgement(entered, refreshing, "candidate refresh");
|
||||
let switched = false;
|
||||
switching = mcp
|
||||
.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "beta" },
|
||||
})
|
||||
.then((result) => {
|
||||
switched = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(switched).toBe(false);
|
||||
finish();
|
||||
const result = await refreshing;
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(structured(result).result).toMatchObject({
|
||||
analysis_provider_binding: { provider: { id: "alpha" } },
|
||||
});
|
||||
expect((await switching).isError).toBeFalsy();
|
||||
expect(
|
||||
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
|
||||
.result,
|
||||
).toMatchObject({
|
||||
analysis_provider_binding: { provider: { id: "beta" } },
|
||||
});
|
||||
} finally {
|
||||
finish();
|
||||
await Promise.allSettled(
|
||||
[refreshing, switching].filter((operation) => operation !== undefined),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cancel an already admitted prompt lookup for a candidate refresh", async () => {
|
||||
const directory = await createTestTempDirectory("rea-refresh-prompt-");
|
||||
const target = join(directory, "fixture.hop");
|
||||
await writeFile(target, "fixture");
|
||||
const base = candidate("alpha", []);
|
||||
let enter!: () => void;
|
||||
let finish!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
createClient: (selected, profile, context) => {
|
||||
const client = base.createClient(selected, profile, context);
|
||||
return {
|
||||
...client,
|
||||
executeIfReady: async (operation, args, options) => {
|
||||
enter();
|
||||
await gate;
|
||||
if (options?.signal?.aborted)
|
||||
throw new Error("Prompt lookup was cancelled");
|
||||
return client.execute(operation, args, options);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const mcp = new Client({ name: "refresh-prompt", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server, { close: () => session.close() });
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
let lookup: ReturnType<typeof session.executeIfProviderReady> | undefined;
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "alpha" },
|
||||
})
|
||||
).isError,
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
|
||||
).toBeFalsy();
|
||||
lookup = session.executeIfProviderReady("address_name", {});
|
||||
await awaitAcknowledgement(entered, lookup, "prompt lookup");
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { refresh_candidates: true },
|
||||
})
|
||||
).isError,
|
||||
).toBeFalsy();
|
||||
finish();
|
||||
expect((await lookup)?.ok).toBe(true);
|
||||
} finally {
|
||||
finish();
|
||||
await Promise.allSettled(lookup === undefined ? [] : [lookup]);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains failed candidate-refresh cleanup while preserving the selected binding", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-candidate-refresh-cleanup-",
|
||||
);
|
||||
const target = join(directory, "fixture.hop");
|
||||
await writeFile(target, "fixture");
|
||||
const starts: string[] = [];
|
||||
const beta = candidate("beta", starts);
|
||||
let fail = false;
|
||||
let release = false;
|
||||
const failure = new ProviderCleanupError("beta", ["beta-refresh-owner"], {
|
||||
reason: "controlled cleanup failure",
|
||||
});
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([
|
||||
candidate("alpha", starts),
|
||||
{
|
||||
...beta,
|
||||
inspectAvailability: () => {
|
||||
if (fail) throw failure;
|
||||
return beta.inspectAvailability();
|
||||
},
|
||||
closeOwnedResources: async () => {
|
||||
if (!fail) return ok(null);
|
||||
if (!release) return err(failure);
|
||||
failure.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const mcp = new Client({ name: "candidate-refresh-cleanup", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server, { close: () => session.close() });
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "alpha" },
|
||||
})
|
||||
).isError,
|
||||
).toBeFalsy();
|
||||
fail = true;
|
||||
const failed = parseMcpToolError(
|
||||
await mcp.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { refresh_candidates: true },
|
||||
}),
|
||||
);
|
||||
expect(failed.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { resources: ["beta-refresh-owner"] },
|
||||
});
|
||||
const status = structured(
|
||||
await mcp.callTool({ name: "binary_session", arguments: {} }),
|
||||
);
|
||||
expect(status.result).toMatchObject({
|
||||
cleanup_pending: true,
|
||||
analysis_provider_binding: { provider: { id: "alpha" } },
|
||||
});
|
||||
release = true;
|
||||
expect(
|
||||
(await mcp.callTool({ name: "close_binary", arguments: {} })).isError,
|
||||
).toBeFalsy();
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
release = true;
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => {
|
||||
directory = await createTestTempDirectory("rea-profile-completion-mcp-");
|
||||
const path = join(directory, "fixture.hop");
|
||||
|
||||
@@ -709,8 +709,19 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: "relative-bundle.json" },
|
||||
});
|
||||
expect(exported.isError, JSON.stringify(exported.content)).toBe(true);
|
||||
expect(JSON.stringify(exported.content)).toContain("absolute");
|
||||
expect(parseMcpToolError(exported).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "export_evidence_bundle",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
@@ -721,8 +732,19 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: "relative-analysis.json" },
|
||||
});
|
||||
expect(closed.isError, JSON.stringify(closed.content)).toBe(true);
|
||||
expect(JSON.stringify(closed.content)).toContain("absolute");
|
||||
expect(parseMcpToolError(closed).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "close_binary",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["snapshot_path"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await mcp.callTool({ name: "close_binary", arguments: {} });
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
},
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
for (const [input, paths] of [
|
||||
const relativeCases = [
|
||||
[{ path: "relative.wasm" }, [["path"]]],
|
||||
[
|
||||
{ path: value.artifact.path, glue_paths: ["relative.js"] },
|
||||
@@ -412,7 +412,9 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
},
|
||||
[["path"], ["glue_paths", 0], ["candidate_paths", 0]],
|
||||
],
|
||||
] as const) {
|
||||
] as const;
|
||||
const checked: string[] = [];
|
||||
for (const [input, paths] of relativeCases) {
|
||||
const response = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: input,
|
||||
@@ -423,14 +425,19 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
.object({ issues: z.array(z.unknown()) })
|
||||
.parse(failure.details);
|
||||
expect(issues).toEqual(
|
||||
paths.map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: "must be an absolute local filesystem path",
|
||||
})),
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
),
|
||||
);
|
||||
checked.push(paths.map((path) => path.join(".")).join("+"));
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining(["path", "glue_paths.0", "candidate_paths.0"]),
|
||||
);
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
const valid = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
@@ -760,12 +767,13 @@ it("rejects malformed paths, refuses WASM summary delivery before provider work
|
||||
expect(
|
||||
z.object({ issues: z.array(z.unknown()) }).parse(rejected.details).issues,
|
||||
).toEqual(
|
||||
[["path"], ["glue_paths", 0]].map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: "must be an absolute local filesystem path",
|
||||
})),
|
||||
[["path"], ["glue_paths", 0]].map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
for (const input of [
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PYTHON_HELPERS,
|
||||
PYTHON_IMPORTS,
|
||||
} from "../../../src/domain/desktop/appExportPython.js";
|
||||
import {
|
||||
detectStateFormat,
|
||||
textualView,
|
||||
} from "../../../src/domain/desktop/stateFormats.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const arrays = "[".repeat(1500) + "1" + "]".repeat(1500);
|
||||
const objects = '{"a":'.repeat(1500) + "1" + "}".repeat(1500);
|
||||
const cases = [
|
||||
["discarded arrays", `{"value":${arrays},"value":"done"}`, true],
|
||||
["discarded objects", `{"value":${objects},"value":"done"}`, true],
|
||||
["escaped duplicate key", `{"val\\u0075e":${arrays},"value":"done"}`, true],
|
||||
["retained deep value", `{"value":${arrays}}`, false],
|
||||
[
|
||||
"retained deep inexact number",
|
||||
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep overwritten inexact number",
|
||||
`{"value":${"[".repeat(600)}{"x":9007199254740993,"x":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep empty-key overwrite",
|
||||
`{"value":${"[".repeat(600)}{"":9007199254740993,"":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep multiple inexact keys with one overwritten",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep multiple inexact keys all overwritten",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1,"b":2}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep inexact key replaced by inexact",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"a":9007199254740995}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep escaped duplicate overwritten",
|
||||
`{"value":${"[".repeat(600)}{"\\u0061":9007199254740993,"a":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"discarded deep inexact subtree",
|
||||
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)},"value":"done"}`,
|
||||
true,
|
||||
],
|
||||
["last admitted depth", "[".repeat(500) + "1" + "]".repeat(500), true],
|
||||
["empty container at limit", "[".repeat(501) + "]".repeat(501), true],
|
||||
["empty container over limit", "[".repeat(502) + "]".repeat(502), false],
|
||||
["trailing comma", `{"value":${arrays},"value":"done",}`, false],
|
||||
["missing value", `{"value":${arrays},"value":}`, false],
|
||||
["wrong closing bracket", `{"value":${arrays},"value":"done"]`, false],
|
||||
] as const;
|
||||
|
||||
it.each(cases)("reads JSON with %s", async (_name, text, readable) => {
|
||||
const root = await createTestTempDirectory("desktop-json-depth-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}${PYTHON_HELPERS}
|
||||
text = json.load(sys.stdin)
|
||||
print(json.dumps(_text_view("state.json", text, len(text.encode("utf-8")))))
|
||||
`,
|
||||
);
|
||||
const output = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{ input: JSON.stringify(text), encoding: "utf8" },
|
||||
),
|
||||
) as { format: string; view: unknown; error?: string };
|
||||
const bytes = Buffer.from(text);
|
||||
const format = detectStateFormat("state.json", bytes);
|
||||
if (format !== "json" && format !== "text")
|
||||
throw new Error(`Unexpected native format ${format}`);
|
||||
const native = textualView(format, bytes);
|
||||
expect(format === "json" && native.ok).toBe(readable);
|
||||
expect(output.format).toBe(format);
|
||||
if (native.ok) {
|
||||
expect(output.error).toBeUndefined();
|
||||
// Compare JSON values on their wire form, independent of object prototypes.
|
||||
expect(JSON.stringify(output.view)).toBe(JSON.stringify(native.value));
|
||||
if (
|
||||
readable &&
|
||||
(_name.startsWith("discarded") || _name === "escaped duplicate key")
|
||||
)
|
||||
expect(output.view).toEqual({ value: "done" });
|
||||
} else {
|
||||
expect(output.view).toBeNull();
|
||||
expect(output.error).toBe(native.error);
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds construction before refusing retained excessive JSON depth", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-construction-");
|
||||
const script = join(root, "bounded.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}${PYTHON_HELPERS}
|
||||
import tracemalloc
|
||||
text = "[" * 20_000 + "0" + "]" * 20_000
|
||||
recursion_limit = sys.getrecursionlimit()
|
||||
tracemalloc.start()
|
||||
entry = _json_view(text, len(text))
|
||||
peak = tracemalloc.get_traced_memory()[1]
|
||||
tracemalloc.stop()
|
||||
print(json.dumps({"entry": entry, "peak": peak, "recursion_unchanged": sys.getrecursionlimit() == recursion_limit}))
|
||||
`,
|
||||
);
|
||||
const output = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
) as { entry: unknown; peak: number; recursion_unchanged: boolean };
|
||||
expect(output.entry).toEqual({
|
||||
format: "json",
|
||||
view: null,
|
||||
error: "nested deeper than the 500 levels a JSON view keeps",
|
||||
});
|
||||
expect(output.recursion_unchanged).toBe(true);
|
||||
// Python heap only, excluding source construction and interpreter/OS memory.
|
||||
// A value retained only to refuse it must not allocate every input container.
|
||||
expect(output.peak).toBeLessThan(1024 * 1024);
|
||||
});
|
||||
|
||||
it("bounds allocations when a very deep value is overwritten", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-memory-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
|
||||
text = '{"value":' + '[' * 200000 + '1' + ']' * 200000 + ',"value":"done"}'
|
||||
tracemalloc.start()
|
||||
result = _json_view(text, len(text))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
print(json.dumps({"view": result["view"], "peak": peak}))
|
||||
`,
|
||||
);
|
||||
const measured = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", script],
|
||||
{ encoding: "utf8", timeout: 30000 },
|
||||
),
|
||||
) as { view: unknown; peak: number };
|
||||
expect(measured.view).toEqual({ value: "done" });
|
||||
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 40000);
|
||||
|
||||
it("bounds a wide container already beyond the retained view depth", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-wide-depth-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
|
||||
text = '[' * 501 + ','.join(['"abc"'] * 600000) + ']' * 501
|
||||
tracemalloc.start()
|
||||
result = _json_view(text, len(text))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
print(json.dumps({"error": result.get("error"), "peak": peak}))
|
||||
`,
|
||||
);
|
||||
const measured = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", script],
|
||||
{ encoding: "utf8", timeout: 30000 },
|
||||
),
|
||||
) as { error: string; peak: number };
|
||||
expect(measured.error).toBe(
|
||||
"nested deeper than the 500 levels a JSON view keeps",
|
||||
);
|
||||
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 40000);
|
||||
@@ -34,7 +34,7 @@ const execPython = (
|
||||
try {
|
||||
writeFileSync(script, args[codeIndex + 1]!, "utf8");
|
||||
return execFileSync(
|
||||
"python3",
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
[
|
||||
"-X",
|
||||
"utf8",
|
||||
@@ -375,15 +375,16 @@ it("checks text for JSON the way REA does, token by token, without parsing it",
|
||||
"-I",
|
||||
"-B",
|
||||
"-c",
|
||||
`${imports}${PYTHON_HELPERS}\nprint(json.dumps([_is_json(text) for text in json.load(sys.stdin)]))`,
|
||||
`${imports}${PYTHON_HELPERS}\ntexts = json.load(sys.stdin)\nprint(json.dumps({"syntax": [_read_json(text, build=False) is not _INVALID_JSON for text in texts], "built": [_read_json(text) is not _INVALID_JSON for text in texts]}))`,
|
||||
],
|
||||
{ input: JSON.stringify(texts), encoding: "utf8" },
|
||||
),
|
||||
) as boolean[];
|
||||
) as { syntax: boolean[]; built: boolean[] };
|
||||
|
||||
const parsed = texts.map((text) => safeParseJson(text).ok);
|
||||
expect(texts.map((text) => isJsonText(text))).toEqual(parsed);
|
||||
expect(python).toEqual(parsed);
|
||||
expect(python.syntax).toEqual(parsed);
|
||||
expect(python.built).toEqual(parsed);
|
||||
expect(parsed.filter(Boolean)).toHaveLength(10);
|
||||
});
|
||||
|
||||
@@ -445,9 +446,13 @@ print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["
|
||||
const root = await createTestTempDirectory("rea-keyfile-python-");
|
||||
const script = join(root, "fixture.py");
|
||||
await writeFile(script, program, "utf8");
|
||||
const output = execFileSync("python3", ["-I", "-B", "-X", "utf8", script], {
|
||||
input: JSON.stringify(inputs),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const output = execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{
|
||||
input: JSON.stringify(inputs),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output)).toEqual(inputs.map(keyfileView));
|
||||
});
|
||||
Reference in new issue
Block a user