diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 3eaa239b8..dd4527664 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -31,7 +31,7 @@ When tools are absent or registration is stale: `npx -y rea-agents@latest doctor --client codex --json`. Substitute the current supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, - `commandcode`, `vscode`, `grok_build`, `omp`, or `grok_bot`. `grok_bot` has no local + `commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, `hermes`, or `grok_bot`. `grok_bot` has no local file registration: doctor reports the Grok Bot chat step, and setup does not write `mcp.json`. If the client is unknown, use `doctor --json` and inspect its registration results before choosing a setup scope. diff --git a/docs/installation.md b/docs/installation.md index 71b7ffb7c..da726182b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -175,9 +175,26 @@ Setup and doctor follow Qwen Code's tilde and working-directory-relative home overrides. Qwen Code also discovers personal skills in `~/.agents/skills`, where setup installs the bundled workflow. +Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override +is nonempty, including its shared personal skills at +`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude +policies from system defaults, user settings, trusted workspace settings, and +system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`, +`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting, +and the selected `trustedFolders.json` (including +`GEMINI_CLI_TRUSTED_FOLDERS_PATH`). Settings in an untrusted workspace do not +participate. A workspace or system `mcpServers.rea` replaces the entire user +entry; setup stops if the winning definition conflicts with its planned entry. +Review those files manually: setup never edits administrator/workspace settings +or grants workspace trust. Trust supplied only by a running IDE is outside this +standalone CLI check. + +Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Devin honors +`XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems. + For OMP, setup writes a `type: "stdio"` entry to the user-level -`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute -`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or +`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR` and +`PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or `PI_PROFILE` (`~/.omp/profiles//agent/mcp.json`). Setup also removes `rea` from that file's `disabledServers` list, which would otherwise hide the registration. Doctor treats an `enabled: false` entry as active when @@ -191,17 +208,65 @@ Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Herme connects an entry unless `enabled` is false, so setup states `enabled: true`. Hermes personal skills install under the active Hermes home's `skills` directory. +Setup, doctor and uninstall follow the root's `active_profile` selection, as +plain Hermes CLI commands do. An explicit `HERMES_HOME` pointing to +`profiles/` selects that named profile directly. An invalid, missing or +deleted sticky profile is reported instead of writing into the default home; +repair the profile or run `hermes profile use default` before retrying. REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, and home expansion before resolving that profile. Setup previews the resolved configuration and skill destinations; doctor and uninstall use those same paths. +### DeepSeek Harness + +DeepSeek Harness uses a Cordis YAML patch sequence rather than a `mcpServers` +object. REA's `setup --client` does not edit this format. For Harness +`0.2.0-rc.2`, append the following entry to `$DSH_HOME/cordis.patch.yml` +(`~/.dsh/cordis.patch.yml` when `DSH_HOME` is unset). Preserve existing patches +and replace the executable paths with absolute paths from your installation: + +```yaml +- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: /absolute/path/to/node + args: ["/absolute/path/to/rea/scripts/rea.mjs", mcp] + cwd: !!js process.cwd() +``` + +For a global npm installation, the launcher is under +`$(npm root -g)/rea-agents/scripts/rea.mjs`. Check the Node executable with +`command -v node`. Register `serverName: rea` only once in each active profile, +then restart Harness. It exposes tools as `mcp__rea__`. +Run `rea setup --skill --dry-run`, review the destination, then +`rea setup --skill --yes` to install the workflow in `~/.agents/skills`. +Harness discovers that directory, or `$DSH_AGENTS_HOME/skills` when configured; +its native `skill` tool can load `reverse-engineer-anything`. + +REA's optional [native client verification](testing.md#end-to-end-integration-and-golden-evidence) +checks headless chat, skill loading, all tool input schemas and actual analysis +using the installed Harness and a local model fixture. This establishes the +Harness integration, with live DeepSeek API compatibility remaining unverified. +See the [Harness MCP guide](https://github.com/deepseek-ai/deepseek-harness/blob/639ed015397290b3745d163aafe02ffee4aa3f84/packages/mcp/mcp-client/README.md) +for its native configuration contract. + +### Other client settings + For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2 both load. If the configuration already uses OpenCode V2's native `mcp.servers` table, setup registers REA there instead and replaces any earlier -`mcp.rea` entry from REA. +`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and +`OPENCODE_CONFIG`, preferring the later-loaded JSONC file when both JSON and +JSONC exist. The ordinary XDG global configuration still participates when a +custom directory is selected. Doctor checks the merged user registration; uninstall removes +REA-owned entries from every applicable user file so an older entry cannot +become active again. Malformed applicable files block setup before changes. Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from -`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server +`~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name in that list. The shared skill installed under `~/.agents/skills` is already @@ -241,9 +306,10 @@ save verified paths for an existing Ghidra installation. The bundled skill is installed where each selected client discovers personal skills: Claude Code uses `~/.claude/skills` (or -`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients, -including Qwen Code, use the shared `~/.agents/skills` directory. A mixed selection -plans the relevant paths. Selecting the skill without a client uses the shared +`$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills` +under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`. +Other integrations, including Qwen Code, use `~/.agents/skills`. +A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared directory. Setup leaves existing skill copies in other locations untouched. `doctor --skill --json` verifies the selected copies against the bundled diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index b52ac6c33..38161e1bd 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -36,6 +36,49 @@ after SDK conversion and check advertised validation and actual calls. Individual model APIs can impose additional nesting limits; complete producer captures can exceed ten structural levels. +### Compact input schema profile + +Some function-calling providers reject a request while any single tool's +`parameters` schema exceeds a literal serialized size cap; Moonshot clients +such as kimi-cli fail every request while the default catalog is attached +([#1484](https://github.com/morluto/rea/issues/1484)). Setting the server's +`REA_MCP_INPUT_SCHEMA_PROFILE` environment variable to `compact` renders every +advertised input schema within a 13,312-byte budget, measured to sit below +Moonshot's observed cap (community measurements: a 13.7 KB schema is accepted +while a ~15 KB one is rejected). The setting is read at startup; restart the +server to apply changes, as with `REA_MCP_MAX_RESPONSE_BYTES`. The default +value `full` keeps the complete advertisement unchanged. + +The compact profile renders the same tool set, output schemas, annotations, +and canonical server-side validation. It changes only the advertised input +JSON Schema: + +- Annotation prose leaves the advertised form: nested property descriptions, + literal examples, defaults, and titles are dropped. The root keeps its + description because it states the accepted input groups. +- Schema-local references are inlined within the budget. Kimi Code expands + references before sending provider requests, so sharing definitions cannot + establish the provider-facing size. Expansion stops at the budget before + allocating an oversized presentation. Unconstrained JSON fields advertise + all JSON value types explicitly so Kimi cannot infer string for Evidence + objects or arrays. +- The few schemas whose validation structure alone exceeds the budget + (`compare_web_captures`, `build_reconstruction_obligation_ledger`, + `evaluate_reconstruction_coverage`, `project_managed_application_graph`, + `compare_application_versions`, `capture_browser_scenario`) are advertised + in a reduced form: property names, reference-resolved types (including union + alternatives), first-level guidance, and the root's required-field constraints, + with nested validation detail elided. The + canonical schema still rejects malformed calls server-side, so callers + following the reduced advertisement receive the canonical typed error + instead of silent acceptance. + +Boundary tests pin every advertised compact schema to the budget, keep them +valid JSON Schema that still accepts every canonical example, and hold the +set of six reduced presentations as contracts evolve. Client verification +also checks provider-facing sizes and canonical examples after conversion by +the actual Kimi Code CLI; local captures do not establish Moonshot acceptance. + Self-contained output schemas advertise a content-bound `$id`, including their declared dialect. SDK validators can reuse compiled schemas across complete catalog refreshes and equivalent tool outputs; changing the schema changes its @@ -101,6 +144,12 @@ if retention is unavailable or fails, the diagnostic reports that reason. REA accepts ordinary `tools/call` progress tokens. Updates are monotonic, rate-bounded to at most one intermediate update per 100 ms, and always allow a terminal update. Unknown totals are omitted; REA does not fabricate percentages. +`capture_process_scenario` uses that boundary only when the client supplies a +progress token. Notifications set `progress` to the count of collected terminal +frames, process samples, and interaction events, omit `total`, and put the +lifecycle phase, elapsed time, and final cleanup status in `message`. A client +that does not send a progress token receives no progress notifications. The +tool result is unchanged either way. Provider calls receive the request cancellation signal. Artifact traversal, hashing, version comparisons, Hopper requests, and process capture check the same signal. Cancellation is distinct from timeout. A cleanup failure diff --git a/docs/process-capture.md b/docs/process-capture.md index 5cc246ec2..723a53561 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -51,6 +51,34 @@ format is TOON. The file contains the complete capture Evidence record. Choose an output file distinct from the scenario input: shell redirection opens and truncates the output before REA reads the scenario. +While the capture runs, REA writes live status to stderr as one JSON object per +line. Stdout stays the final Evidence document: + +```json +{ + "rea_progress": { + "phase": "running", + "completed": 3, + "total": null, + "message": "elapsed_ms=1200 frames=1 samples=2 interactions=0", + "sequence": 4 + } +} +``` + +`phase` is the observed lifecycle stage: `prepare`, `running`, `settling`, or +`cleanup`. `completed` counts collected terminal frames, process samples, and +interaction events. `total` stays null; REA does not invent a unit total or a +percentage. `message` reports elapsed time and those counts. The final line uses +`phase` `cleanup`, sets `terminal` to true, and adds `disposition` (`exited`, +`timeout`, `idle_timeout`, `cancelled`, or `failed`) plus the owned-process, +renderer, and temporary-root cleanup states. Updates use the shared progress +boundary and are limited to one intermediate line per 100 ms; the terminal line +is always emitted. If a receiver is slow, intermediate observations are merged +into the latest status, and stale live observations are discarded before final +cleanup status. Progress never copies child output. Child output remains in the +capture Evidence on stdout. + For example: ```json @@ -119,6 +147,14 @@ selected normalization. Original admitted PTY chunks are available through comparison contract, so a capture whose lines were padded to full width does not compare as the same contract. +Custom `normalization.patterns` replace literal text with the caller's literal +replacement, including dollar sequences such as `$&` and `$$`. An empty pattern +also matches the first and last string positions. Captures with custom patterns +commit `pattern_normalization_version: "literal-replacements-v1"`. Legacy captures +remain readable. Cross-version comparison is rejected; capture both sides with +the same replacement semantics. Captures without custom patterns keep their +existing comparison contract. + Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and `listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`; diff --git a/docs/testing.md b/docs/testing.md index 9730f027b..e90436112 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -88,6 +88,51 @@ entrypoint and include its verifier/fixture files in their cache inputs. ## End-to-end, integration and golden evidence +The optional `verify:qwen-client`, `verify:pi-client`, and `verify:hermes-client` +lanes require an installed native client. Select its executable with +`REA_VERIFY_QWEN_COMMAND`, `REA_VERIFY_PI_COMMAND`, or `REA_VERIFY_HERMES_COMMAND`. +They configure disposable profiles through the public REA CLI, preserve caller +settings and backups, and exercise real stdio MCP calls and skill loading. The +`call` mode checks complete catalog discovery and actual JavaScript Evidence; +pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full +result that its client offloads, and Pi exercises default codemode execution. +Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package. +Receipts retain client versions, result digests, host coverage and owned-process +cleanup. These POSIX lanes use a deterministic loopback model, so they do not +prove live model-provider or native Windows compatibility. Qwen and Pi use +caller-configured skill directories to isolate the fixture account from the OS +home; this does not prove their default home-directory discovery. +Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of +a named sticky profile with `HERMES_HOME` still pointing to its root. + +`verify:gemini-client` requires an installed Gemini CLI (verified with +`@google/gemini-cli@0.63.0`); select it with `REA_VERIFY_GEMINI_COMMAND`. +The optional POSIX lane uses native `GEMINI_CLI_HOME` discovery in an isolated +Git project, checks setup plans, backups and idempotence, activates the installed +personal skill, validates all forwarded input JSON Schemas against their declared +dialect, forwards the complete REA catalog and checks full JavaScript +Evidence for a Unicode path. Use `-- chat` for ordinary chat with REA enabled, +or `REA_VERIFY_RUNTIME_ROOT` for a production-only installed REA package. +The local fixture exercises the real Gemini API adapter, including its +`untrusted_context` tool-result envelope. Token counts are synthetic; live +Google API and native Windows compatibility remain unverified. The lane disables +the client's memory-based relaunch to preserve the caller's Node heap budget. + +`verify:deepseek-client` requires an installed DeepSeek Harness (`dsh`; +verified with `@deepseek-ai/dsh@0.2.0-rc.2`) and Git. Set +`REA_VERIFY_DEEPSEEK_COMMAND` to select its executable. This optional POSIX lane +installs the skill through `rea setup --skill`, writes the native Cordis MCP +patch into an isolated `DSH_HOME`, and uses `DSH_AGENTS_HOME` for native personal +skill discovery. A separate Git root prevents inherited project skills from +masking a missing installation. The default `call` mode checks skill loading, +complete catalog discovery, the actual forwarded input schemas and regexes, +and full JavaScript Evidence for a Unicode path. Use `-- chat` for ordinary +chat with REA enabled, or `REA_VERIFY_RUNTIME_ROOT` for an installed package. +The fixture uses Harness's custom OpenAI adapter and the one-million-token +context capacity declared by its default DeepSeek Flash model; it does not +establish live DeepSeek API or native Windows coverage. Receipts include +request artifacts, result digests and owned-process lineage. + Full E2E tests invoke the production command dispatcher and real providers, without fake launchers, runners or responses. `verify:keyed-archive` writes an actual Foundation binary archive, runs the CLI and a separate stdio MCP diff --git a/package.json b/package.json index 439746c70..cf60ba460 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,11 @@ "verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs", "verify:package": "npm run build:cached && node scripts/verify-package.mjs", "verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs", + "verify:qwen-client": "npm run build:cached && node scripts/verify-qwen-client.mjs", + "verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs", + "verify:pi-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs pi", + "verify:hermes-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs hermes", + "verify:deepseek-client": "npm run build:cached && node scripts/verify-deepseek-client.mjs", "probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs", "verify:published": "node scripts/verify-published-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index 0a59c5fae..849815b84 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -40,7 +40,11 @@ export async function verifyGhidraBoundaries( ajv.validateSchema(tool[key]), `${tool.name} ${key}: ${JSON.stringify(ajv.errors)}`, ); - validators.set(tool.name, ajv.compile(tool.outputSchema)); + validators.set( + tool.name, + (tool.outputSchema?.$id && ajv.getSchema(tool.outputSchema.$id)) || + ajv.compile(tool.outputSchema), + ); } let successfulCalls = 0; let rejectedCalls = 0; diff --git a/scripts/lib/real-hopper-boundaries.mjs b/scripts/lib/real-hopper-boundaries.mjs index ffcc037ff..69884012e 100644 --- a/scripts/lib/real-hopper-boundaries.mjs +++ b/scripts/lib/real-hopper-boundaries.mjs @@ -42,7 +42,11 @@ export async function verifyHopperBoundaryContracts( ajv.validateSchema(tool.outputSchema), `${tool.name} output schema`, ); - validators.set(tool.name, ajv.compile(tool.outputSchema)); + validators.set( + tool.name, + (tool.outputSchema?.$id && ajv.getSchema(tool.outputSchema.$id)) || + ajv.compile(tool.outputSchema), + ); } let successfulCalls = 0; let rejectedCalls = 0; diff --git a/scripts/verify-deepseek-client.mjs b/scripts/verify-deepseek-client.mjs new file mode 100644 index 000000000..d53131a88 --- /dev/null +++ b/scripts/verify-deepseek-client.mjs @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../dist/contracts/toolContracts.js"; +import { exec } from "./lib/verify-package-core.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; + +const mode = process.argv[2] ?? "call"; +assert(["chat", "call"].includes(mode), "Select chat or call."); +assert(process.platform !== "win32", "Native Windows coverage is unverified."); +const repo = fileURLToPath(new URL("..", import.meta.url)); +const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; +const command = process.env.REA_VERIFY_DEEPSEEK_COMMAND ?? "dsh"; +const lab = await mkdtemp(join(tmpdir(), "rea-deepseek-client-")); +const account = join(lab, "account"); +const profile = join(lab, "selected profile"); +const workspace = join(lab, "workspace"); +const target = join(workspace, "fixture α测试"); +const skillPath = join( + account, + ".agents", + "skills", + "reverse-engineer-anything", + "SKILL.md", +); +const cli = join(runtimeRoot, "scripts", "rea.mjs"); +const verifier = createVerifierRun(); +await Promise.all([ + mkdir(account), + mkdir(profile), + mkdir(target, { recursive: true }), +]); +const environment = { + PATH: process.env.PATH ?? "", + USERPROFILE: account, + DSH_HOME: profile, + DSH_AGENTS_HOME: join(account, ".agents"), + OPENAI_API_KEY: "local-fixture-key", + REA_PROCESS_RUN_ID: verifier.run_id, +}; +// A temporary directory under the checkout must not inherit its project skills or AGENTS.md. +await exec( + "git", + ["-c", "init.defaultBranch=main", "init", "--quiet", workspace], + { + env: environment, + timeout: 30_000, + }, +); +await writeFile( + join(target, "app.js"), + 'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n', +); +const clientVersion = ( + await exec(command, ["--version"], { + env: environment, + cwd: workspace, + timeout: 30_000, + }) +).stdout.trim(); +for (const args of [ + ["setup", "--skill", "--dry-run"], + ["setup", "--skill", "--yes"], +]) { + const result = await exec(process.execPath, [cli, ...args, "--json"], { + env: environment, + cwd: workspace, + timeout: 60_000, + }); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.status, args.includes("--dry-run") ? "planned" : "ready"); + if (args.includes("--dry-run")) { + assert.equal(parsed.plannedActions.length, 1); + assert.equal(parsed.plannedActions[0].kind, "install_skill"); + assert.equal(parsed.plannedActions[0].target, dirname(skillPath)); + } + await writeFile( + join(lab, args.includes("--dry-run") ? "setup-plan.json" : "setup.json"), + result.stdout, + ); +} +assert( + (await readFile(skillPath, "utf8")).includes( + "name: reverse-engineer-anything", + ), +); + +let catalog; +let patternCount = 0; +let skillRequested = false; +let skillLoaded = false; +let evidenceSeen = false; +let finalSeen = false; +let artifactDigest; +let mainRequests = 0; +const ajv = new Ajv2020({ strict: false, validateFormats: false }); +const patterns = new Set(); +const validatePatterns = (value) => { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (key === "pattern" && typeof child === "string") { + // Check the representation the native client actually forwards to its model adapter. + for (const flags of ["", "u", "v"]) new RegExp(child, flags); + patterns.add(child); + patternCount++; + } else validatePatterns(child); + } +}; +const extractEvidence = (value) => { + if (typeof value === "string") { + try { + return extractEvidence(JSON.parse(value)); + } catch { + return undefined; + } + } + if (value && typeof value === "object") { + if ( + typeof value.evidence_id === "string" && + value.normalized_result?.graph?.nodes + ) + return value; + for (const child of Object.values(value)) { + const found = extractEvidence(child); + if (found) return found; + } + } +}; +const fixture = await createOpenAiModelFixture({ + directory: lab, + prefix: `deepseek-${mode}`, + onRequest: async ({ body, tools, requestIndex }) => { + // Harness generates a session title in a separate, tool-free model request. + if (tools.length === 0) + return { role: "assistant", content: "REA validation" }; + assert( + ++mainRequests <= 4, + "Harness did not complete the bounded fixture workflow.", + ); + const rea = tools.filter((tool) => tool.name.startsWith("mcp__rea__")); + const observedCatalog = rea + .map((tool) => tool.name.slice("mcp__rea__".length)) + .sort(); + assert.deepEqual( + observedCatalog, + TOOL_CONTRACTS.map((tool) => tool.name).sort(), + ); + if (catalog === undefined) { + catalog = observedCatalog; + for (const tool of rea) { + assert( + ajv.validateSchema(tool.parameters), + `${tool.name}: ${ajv.errorsText(ajv.errors)}`, + ); + validatePatterns(tool.parameters); + } + assert( + patternCount > 0, + "The advertised regex constraints must reach the model.", + ); + } + const toolMessages = (body.messages ?? []).filter( + (message) => message.role === "tool", + ); + const serialized = JSON.stringify(toolMessages); + skillLoaded ||= + serialized.includes( + '', + ) && serialized.includes(dirname(skillPath)); + for (const message of toolMessages) { + const found = extractEvidence(message.content); + if (found === undefined) continue; + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(found); + assert.equal(evidence.subject.local_path, target); + const shape = evidence.normalized_result.graph.nodes + .flatMap((node) => node.observations) + .map((observation) => observation.properties) + .find( + (properties) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === "result", + ); + assert(shape?.return_shape_coverage?.projection_complete); + const fields = shape.static_return_shapes.flatMap( + (entry) => entry.fields, + ); + assert( + fields.some( + (field) => + field.path === "/clientCompatibility" && + field.state === "literal" && + field.value === "REA_CLIENT_FIXTURE", + ), + ); + assert( + fields.some( + (field) => + field.path === "/count" && + field.state === "literal" && + field.value === 7, + ), + ); + const text = JSON.stringify(evidence); + await writeFile(join(lab, "evidence.json"), text); + artifactDigest = createHash("sha256").update(text).digest("hex"); + evidenceSeen = true; + } + if (mode === "chat" || evidenceSeen) { + finalSeen = true; + return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" }; + } + const call = (name, args) => ({ + role: "assistant", + content: null, + tool_calls: [ + { + id: `call_rea_${requestIndex}`, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }); + if (!skillRequested) { + assert(tools.some((tool) => tool.name === "skill")); + skillRequested = true; + return call("skill", { name: "reverse-engineer-anything" }); + } + assert( + skillLoaded, + "Harness must load the installed skill before analysis.", + ); + return call("mcp__rea__analyze_javascript_application", { + input_path: target, + format: "directory", + }); + }, +}); +let failure; +try { + // Use the client's native Cordis patch DSL; generic `rea setup --client` does not edit it. + await writeFile( + join(profile, "cordis.patch.yml"), + ` +- id: llm-pi-ai + config: + providers: + rea-fixture: + apiKeyEnv: OPENAI_API_KEY + api: openai-completions + baseURL: ${fixture.baseUrl} + defaultMaxTokens: 4096 + retryPolicy: + mode: normal + maxRetries: 0 + models: + - id: rea-client-fixture + contextWindow: 1000000 +- id: agent-default-model + config: + provider: rea-fixture + model: rea-client-fixture +- insert: + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: rea + transport: stdio + command: ${JSON.stringify(process.execPath)} + args: ${JSON.stringify([cli, "mcp"])} + cwd: !!js process.cwd() + env: + REA_PROCESS_RUN_ID: ${JSON.stringify(verifier.run_id)} +`, + ); + const execution = exec( + command, + [ + "--profile", + "headless", + "--json", + mode === "chat" ? "Say hello briefly." : `Use REA to analyze ${target}.`, + ], + { + env: environment, + cwd: workspace, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + execution.child.stdin.end(); + const result = await execution; + await writeFile(join(lab, "client.stdout"), result.stdout); + await writeFile(join(lab, "client.stderr"), result.stderr); + assert.equal(fixture.failure, undefined); + assert(finalSeen && result.stdout.includes("REA_CLIENT_COMPATIBILITY_OK")); + if (mode === "call") assert(evidenceSeen && skillLoaded); +} catch (cause) { + failure = cause; + if (typeof cause?.stdout === "string") + await writeFile(join(lab, "client.stdout"), cause.stdout); + if (typeof cause?.stderr === "string") + await writeFile(join(lab, "client.stderr"), cause.stderr); +} finally { + await fixture.close(); + const receipt = { + client: "deepseek-harness", + clientVersion, + mode, + passed: failure === undefined, + artifacts: lab, + runtimeRoot, + host: { + platform: process.platform, + architecture: process.arch, + node: process.versions.node, + }, + modelFixture: + "loopback custom OpenAI-compatible adapter; no live DeepSeek provider claim", + modelContextWindow: 1000000, + skillDiscovery: + "native DSH_AGENTS_HOME personal directory; isolated project root", + catalogSize: catalog?.length, + patternCount, + uniquePatterns: patterns.size, + skillLoaded, + evidenceSeen, + artifactDigest, + finalSeen, + requests: fixture.requests, + fixtureFailure: fixture.failure, + verifier: await completeVerifierRun(verifier), + }; + await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} +if (failure !== undefined) throw failure; diff --git a/scripts/verify-gemini-client.mjs b/scripts/verify-gemini-client.mjs new file mode 100644 index 000000000..c2d940077 --- /dev/null +++ b/scripts/verify-gemini-client.mjs @@ -0,0 +1,378 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../dist/contracts/toolContracts.js"; +import { exec } from "./lib/verify-package-core.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { createGeminiModelFixture } from "./verify/clients/gemini-model-fixture.mjs"; + +assert( + process.platform !== "win32", + "Native Gemini verifier requires POSIX; Windows is unverified", +); +const repo = fileURLToPath(new URL("..", import.meta.url)); +const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; +const command = process.env.REA_VERIFY_GEMINI_COMMAND ?? "gemini"; +const mode = process.argv[2] ?? "call"; +assert( + ["chat", "call"].includes(mode), + "Usage: verify-gemini-client.mjs [chat|call]", +); +const verifier = createVerifierRun(); +const lab = await mkdtemp(join(tmpdir(), "rea-gemini-client-")); +const account = join(lab, "account"); +const profile = join(lab, "selected profile"); +const workspace = join(lab, "workspace"); +const configPath = join(profile, ".gemini", "settings.json"); +const skillPath = join( + profile, + ".agents", + "skills", + "reverse-engineer-anything", + "SKILL.md", +); +await Promise.all([ + mkdir(account), + mkdir(workspace), + mkdir(join(profile, ".gemini"), { recursive: true }), +]); +await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 }); +await writeFile(join(lab, "system-settings.json"), "{}\n"); +await writeFile(join(lab, "system-defaults.json"), "{}\n"); +const environment = { + PATH: process.env.PATH ?? "", + USERPROFILE: account, + GEMINI_CLI_HOME: profile, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(lab, "system-settings.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(lab, "system-defaults.json"), + // Preserve the caller's bounded Node heap instead of the launcher's host-memory auto-sizing. + GEMINI_CLI_NO_RELAUNCH: "1", + GEMINI_CLI_TRUST_WORKSPACE: "true", + REA_PROCESS_RUN_ID: verifier.run_id, +}; +let clientVersion; +try { + clientVersion = ( + await exec(command, ["--version"], { env: environment, timeout: 30_000 }) + ).stdout.trim(); +} catch (cause) { + throw new Error( + `Gemini preflight failed for ${command}; install Gemini CLI or set REA_VERIFY_GEMINI_COMMAND. ${String(cause)}`, + ); +} +const original = JSON.stringify( + { + security: { + auth: { selectedType: "gemini-api-key" }, + folderTrust: { enabled: false }, + }, + telemetry: { enabled: false }, + advanced: { autoConfigureMemory: false }, + model: { name: "gemini-2.5-flash", maxSessionTurns: 8 }, + mcpServers: { other: { command: "unrelated-server" } }, + }, + null, + 2, +); +await writeFile(configPath, original); +for (const args of [ + ["setup", "--client", "gemini_cli", "--dry-run"], + ["setup", "--client", "gemini_cli", "--yes"], + ["doctor", "--client", "gemini_cli", "--skill"], +]) { + const result = await exec( + process.execPath, + [join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"], + { env: environment, cwd: workspace, timeout: 60_000 }, + ); + const parsed = JSON.parse(result.stdout); + if (args[0] === "doctor") assert.equal(parsed.healthy, true); + else { + assert.equal( + parsed.status, + args.includes("--dry-run") ? "planned" : "ready", + ); + if (args.includes("--dry-run")) { + assert(parsed.plannedActions.some((a) => a.target === configPath)); + assert( + parsed.plannedActions.some((a) => + a.target.startsWith(join(profile, ".agents", "skills") + "/"), + ), + ); + assert( + parsed.plannedActions.every((a) => a.target.startsWith(profile + "/")), + ); + } + } + await writeFile( + join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`), + result.stdout, + ); +} +assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); +const configured = await readFile(configPath, "utf8"); +assert.deepEqual(JSON.parse(configured).mcpServers.other, { + command: "unrelated-server", +}); +const repeated = await exec( + process.execPath, + [ + join(runtimeRoot, "scripts/rea.mjs"), + "setup", + "--client", + "gemini_cli", + "--yes", + "--json", + ], + { env: environment, cwd: workspace, timeout: 60_000 }, +); +assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []); +assert.equal(await readFile(configPath, "utf8"), configured); +assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); +assert( + (await readFile(skillPath, "utf8")).includes( + "name: reverse-engineer-anything", + ), +); +const target = join(lab, "fixture α测试"); +await mkdir(target); +await writeFile( + join(target, "app.js"), + 'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n', +); +let evidenceSeen = false; +let skillLoaded = false; +let skillRequested = false; +let finalSeen = false; +let catalogSize = 0; +let schemasChecked = 0; +let artifactDigest; +const schemaValidator = new Ajv2020({ strict: false, validateFormats: false }); +const extractEvidence = (value) => { + if (typeof value === "string") { + if ( + value.startsWith("\n") && + value.endsWith("\n") + ) { + return extractEvidence( + value.slice( + "\n".length, + -"\n".length, + ), + ); + } + try { + return extractEvidence(JSON.parse(value)); + } catch { + return undefined; + } + } + if (value && typeof value === "object") { + if ( + typeof value.evidence_id === "string" && + value.normalized_result?.graph?.nodes?.length > 0 + ) + return value; + for (const nested of Object.values(value)) { + const evidence = extractEvidence(nested); + if (evidence) return evidence; + } + } + return undefined; +}; +const fixture = await createGeminiModelFixture({ + directory: lab, + onRequest: async ({ body, tools }) => { + const analysis = tools.find((t) => + t.name.endsWith("analyze_javascript_application"), + ); + assert(analysis, "Gemini must expose the REA analysis tool"); + const prefix = analysis.name.slice( + 0, + -"analyze_javascript_application".length, + ); + const expected = TOOL_CONTRACTS.map((t) => prefix + t.name).sort(); + assert.deepEqual( + tools + .filter((t) => expected.includes(t.name)) + .map((t) => t.name) + .sort(), + expected, + "Gemini must forward the complete REA tool catalog", + ); + catalogSize = expected.length; + if (schemasChecked === 0) { + for (const tool of tools.filter((t) => expected.includes(t.name))) { + assert( + tool.parametersJsonSchema, + `${tool.name}: missing advertised JSON Schema`, + ); + assert( + schemaValidator.validateSchema(tool.parametersJsonSchema), + `${tool.name}: invalid advertised JSON Schema: ${JSON.stringify(schemaValidator.errors)}`, + ); + schemasChecked++; + } + } + const responses = body.contents + .flatMap((c) => c.parts ?? []) + .filter((p) => p.functionResponse) + .map((p) => p.functionResponse); + skillLoaded ||= responses.some( + (r) => + r.name === "activate_skill" && + r.response?.output?.includes( + ``, + ) && + r.response.output.includes( + join(profile, ".agents", "skills", "reverse-engineer-anything"), + ), + ); + const observed = extractEvidence(responses); + if (observed) { + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(observed); + assert.equal(evidence.subject.local_path, target); + const shape = evidence.normalized_result.graph.nodes + .flatMap((n) => n.observations) + .map((o) => o.properties) + .find( + (p) => + p.semantic_role === "export-return-shapes" && + p.exported_name === "result", + ); + assert(shape?.return_shape_coverage?.projection_complete); + const fields = shape.static_return_shapes.flatMap((s) => s.fields); + assert( + fields.some( + (f) => + f.path === "/clientCompatibility" && + f.state === "literal" && + f.value === "REA_CLIENT_FIXTURE", + ), + ); + assert( + fields.some( + (f) => f.path === "/count" && f.state === "literal" && f.value === 7, + ), + ); + const bytes = JSON.stringify(evidence); + artifactDigest = createHash("sha256").update(bytes).digest("hex"); + await writeFile(join(lab, "gemini-evidence.json"), bytes); + evidenceSeen = true; + } + if (mode === "chat" || evidenceSeen) { + finalSeen = true; + return [{ text: "REA_CLIENT_COMPATIBILITY_OK" }]; + } + if (!skillRequested) { + const skill = tools.find((t) => t.name === "activate_skill"); + assert(skill, "Gemini must expose its native skill activation tool"); + skillRequested = true; + return [ + { + functionCall: { + name: skill.name, + args: { name: "reverse-engineer-anything" }, + }, + }, + ]; + } + assert(skillLoaded, "Gemini must load the installed personal REA skill"); + return [ + { + functionCall: { + name: analysis.name, + args: { input_path: target, format: "directory" }, + }, + }, + ]; + }, +}); +let failure; +try { + const response = await exec( + command, + [ + "--model", + "gemini-2.5-flash", + "--approval-mode", + mode === "chat" ? "default" : "yolo", + "--allowed-mcp-server-names", + "rea", + "--output-format", + "stream-json", + "-p", + mode === "chat" + ? "Say hello briefly." + : `Use REA to analyze the JavaScript directory ${target}. Report the result.`, + ], + { + env: { + ...environment, + GEMINI_API_KEY: "local-fixture-key", + GOOGLE_GEMINI_BASE_URL: fixture.baseUrl, + }, + cwd: workspace, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + await writeFile(join(lab, `${mode}.stdout`), response.stdout); + await writeFile(join(lab, `${mode}.stderr`), response.stderr); + const events = response.stdout + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + assert.equal(events.findLast((e) => e.type === "result")?.status, "success"); + assert.equal(fixture.failure, undefined); + assert(finalSeen && response.stdout.includes("REA_CLIENT_COMPATIBILITY_OK")); + if (mode === "call") assert(evidenceSeen && skillLoaded); +} catch (cause) { + failure = cause; + if (typeof cause?.stdout === "string") + await writeFile(join(lab, `${mode}.stdout`), cause.stdout); + if (typeof cause?.stderr === "string") + await writeFile(join(lab, `${mode}.stderr`), cause.stderr); +} finally { + await fixture.close(); + const lineage = await completeVerifierRun(verifier); + const receipt = { + client: "Gemini CLI", + clientVersion, + mode, + passed: failure === undefined, + artifacts: lab, + runtimeRoot, + host: { + platform: process.platform, + architecture: process.arch, + node: process.versions.node, + }, + modelFixture: + "loopback native Gemini API adapter; token counts synthetic; no live Google API claim", + skillDiscovery: + "native GEMINI_CLI_HOME personal shared directory; isolated Git project", + catalogSize, + schemasChecked, + evidenceSeen, + skillLoaded, + artifactDigest, + finalSeen, + requests: fixture.requests, + probes: fixture.probes, + fixtureFailure: fixture.failure, + verifier: lineage, + }; + await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} +if (failure !== undefined) throw failure; diff --git a/scripts/verify-native-agent-client.mjs b/scripts/verify-native-agent-client.mjs new file mode 100644 index 000000000..5b6600479 --- /dev/null +++ b/scripts/verify-native-agent-client.mjs @@ -0,0 +1,467 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../dist/contracts/toolContracts.js"; +import { exec } from "./lib/verify-package-core.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; + +const client = process.argv[2]; +const mode = process.argv[3] ?? "call"; +assert(["pi", "hermes"].includes(client), "Select pi or hermes."); +assert(["chat", "call"].includes(mode), "Select chat or call."); +assert( + process.platform !== "win32", + "This real-client lane requires POSIX; native Windows is unverified.", +); +const repo = fileURLToPath(new URL("..", import.meta.url)); +const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; +const command = + process.env[`REA_VERIFY_${client.toUpperCase()}_COMMAND`] ?? client; +const lab = await mkdtemp(join(tmpdir(), `rea-${client}-client-`)); +const account = join(lab, "account"); +const profile = join(lab, "selected profile"); +const stickyHermes = + client === "hermes" && process.env.REA_VERIFY_HERMES_STICKY_PROFILE === "1"; +const selectedProfile = stickyHermes + ? join(profile, "profiles", "coder") + : profile; +const workspace = join(lab, "workspace"); +const target = join(workspace, "fixture α测试"); +const artifactPath = join(workspace, "evidence.json"); +const skillPath = join( + client === "pi" ? join(account, ".agents") : selectedProfile, + "skills", + "reverse-engineer-anything", + "SKILL.md", +); +const verifier = createVerifierRun(); +await Promise.all([ + mkdir(account), + mkdir(profile, { recursive: true }), + ...(stickyHermes ? [mkdir(selectedProfile, { recursive: true })] : []), + mkdir(target, { recursive: true }), +]); +await writeFile( + join(target, "app.js"), + 'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n', +); +const environment = { + PATH: process.env.PATH ?? "", + USERPROFILE: account, + [client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile, + PI_OFFLINE: "1", + REA_PROCESS_RUN_ID: verifier.run_id, +}; +const configPath = join( + selectedProfile, + client === "pi" ? "mcp.json" : "config.yaml", +); +if (stickyHermes) await writeFile(join(profile, "active_profile"), "coder\n"); +const original = + client === "pi" + ? JSON.stringify({ + mcpServers: { other: { command: "unrelated-server", enabled: false } }, + }) + : "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n"; +await writeFile(configPath, original); +if (client === "pi") { + // The CLI fixture account and native os.homedir() differ; use Pi's caller-owned skill setting. + await writeFile( + join(profile, "settings.json"), + JSON.stringify({ skills: [join(account, ".agents", "skills")] }), + ); +} +const clientVersion = ( + await exec(command, ["--version"], { env: environment, timeout: 30_000 }) +).stdout.trim(); +for (const args of [ + ["setup", "--client", client, "--dry-run"], + ["setup", "--client", client, "--yes"], + ["doctor", "--client", client, "--skill"], +]) { + const result = await exec( + process.execPath, + [join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"], + { env: environment, cwd: workspace, timeout: 60_000 }, + ); + const parsed = JSON.parse(result.stdout); + if (args[0] === "doctor") assert.equal(parsed.healthy, true); + else + assert.equal( + parsed.status, + args.includes("--dry-run") ? "planned" : "ready", + ); + await writeFile( + join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`), + result.stdout, + ); +} +assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); +const installedConfiguration = await readFile(configPath, "utf8"); +assert(installedConfiguration.includes("unrelated-server")); +if (client === "hermes") + assert(installedConfiguration.includes("# Keep caller settings.")); +const repeated = await exec( + process.execPath, + [ + join(runtimeRoot, "scripts/rea.mjs"), + "setup", + "--client", + client, + "--yes", + "--json", + ], + { env: environment, cwd: workspace, timeout: 60_000 }, +); +assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []); +assert.equal(await readFile(configPath, "utf8"), installedConfiguration); +assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); +assert( + (await readFile(skillPath, "utf8")).includes( + "name: reverse-engineer-anything", + ), +); +let evidenceSeen = false; +let skillLoaded = false; +let skillRequested = false; +let callRequested = false; +let searchRequested = false; +let describeRequested = false; +let finalSeen = false; +let catalog; +let artifactDigest; +const extractEvidence = (value) => { + if (typeof value === "string") { + if ( + value.startsWith("") + ) { + // Hermes wraps external MCP content with its own instruction banner. + // Decode only that observed envelope, retaining the original request artifact. + const start = value.indexOf("\n\n"); + if (start >= 0) + return extractEvidence( + value + .slice(start + 2, value.lastIndexOf("")) + .trim(), + ); + } + try { + return extractEvidence(JSON.parse(value)); + } catch { + return undefined; + } + } + if (value && typeof value === "object") { + if ( + typeof value.evidence_id === "string" && + value.normalized_result?.graph?.nodes + ) + return value; + for (const child of Object.values(value)) { + const found = extractEvidence(child); + if (found) return found; + } + } +}; +const validateEvidence = async (value) => { + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(value); + assert.equal(evidence.subject.local_path, target); + const shape = evidence.normalized_result.graph.nodes + .flatMap((n) => n.observations) + .map((o) => o.properties) + .find( + (p) => + p.semantic_role === "export-return-shapes" && + p.exported_name === "result", + ); + assert(shape?.return_shape_coverage?.projection_complete); + const fields = shape.static_return_shapes.flatMap((s) => s.fields); + assert( + fields.some( + (f) => + f.path === "/clientCompatibility" && + f.state === "literal" && + f.value === "REA_CLIENT_FIXTURE", + ), + ); + assert( + fields.some( + (f) => f.path === "/count" && f.state === "literal" && f.value === 7, + ), + ); + const serialized = JSON.stringify(evidence); + await writeFile(join(lab, "evidence.json"), serialized); + artifactDigest = createHash("sha256").update(serialized).digest("hex"); + evidenceSeen = true; +}; +const fixture = await createOpenAiModelFixture({ + directory: lab, + prefix: `${client}-${mode}`, + onRequest: async ({ body, tools, requestIndex }) => { + assert( + requestIndex <= 6, + "Client did not complete the bounded fixture workflow.", + ); + const messages = body.messages ?? []; + const toolMessages = messages.filter((m) => m.role === "tool"); + const serializedTools = JSON.stringify(toolMessages); + const call = (name, args) => ({ + role: "assistant", + content: null, + tool_calls: [ + { + id: `call_rea_${requestIndex}`, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }); + if ( + client === "pi" && + callRequested && + serializedTools.includes("REA_CLIENT_FIXTURE") + ) { + const fact = toolMessages + .flatMap((m) => { + const text = + typeof m.content === "string" + ? m.content + : JSON.stringify(m.content); + const marker = text.indexOf('{"REA_CLIENT_FIXTURE":true'); + if (marker < 0) return []; + const end = text.indexOf("}\n", marker); + try { + return [ + JSON.parse(text.slice(marker, end < 0 ? undefined : end + 1)), + ]; + } catch { + return []; + } + }) + .find((v) => Array.isArray(v.catalog)); + assert( + fact, + "Pi codemode must return its actual catalog and skill-read receipt.", + ); + catalog = fact.catalog; + skillLoaded = fact.skill_loaded === true; + await validateEvidence(JSON.parse(await readFile(artifactPath, "utf8"))); + } else if (client === "hermes") { + const search = tools.find((t) => t.name === "tool_search"); + const exposed = tools + .filter((t) => t.name.startsWith("mcp__rea__")) + .map((t) => t.name.slice(10)); + const deferred = [ + ...(search?.description ?? "").matchAll(/^- mcp__rea__(\w+):/gmu), + ].map((m) => m[1]); + const native = [...new Set([...exposed, ...deferred])]; + // Hermes advertises MCP prompt utilities beside server tools. + if (native.length) + catalog = native.filter( + (name) => !["get_prompt", "list_prompts"].includes(name), + ); + skillLoaded ||= + serializedTools.includes(skillPath) && + serializedTools.includes("reverse-engineer-anything"); + for (const message of toolMessages) { + const evidence = extractEvidence(message.content); + if (evidence) await validateEvidence(evidence); + } + } + if (mode === "chat" || evidenceSeen) { + finalSeen = true; + return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" }; + } + if (client === "pi") { + const tool = tools.find((t) => t.name === "codemode"); + assert( + tool, + `Native Pi codemode unavailable: ${tools.map((t) => t.name).join(", ")}`, + ); + assert( + JSON.stringify(messages).includes(skillPath), + "Pi must advertise the installed REA skill.", + ); + callRequested = true; + return call(tool.name, { + code: `await describeNamespace("rea"); const skill = await tools.read({path:${JSON.stringify(skillPath)}}); const result = await tools.mcp__rea__analyze_javascript_application({input_path:${JSON.stringify(target)},format:"directory"}); if(result.isError) throw new Error(JSON.stringify(result)); const evidence = result.structuredContent ?? JSON.parse(result.content.find(c=>c.type==="text").text); await tools.write({path:${JSON.stringify(artifactPath)},content:JSON.stringify(evidence)}); text({REA_CLIENT_FIXTURE:true,skill_loaded:typeof skill==="string"&&skill.includes("name: reverse-engineer-anything"),catalog:ALL_TOOLS.filter(t=>t.name.startsWith("mcp__rea__")).map(t=>t.name.slice(10))});`, + }); + } + if (!skillRequested) { + const tool = tools.find((t) => t.name === "skill_view"); + assert(tool, "Hermes skill_view must be available."); + skillRequested = true; + return call(tool.name, { name: "reverse-engineer-anything" }); + } + if (!searchRequested) { + const tool = tools.find((t) => t.name === "tool_search"); + assert(tool, "Hermes native deferred tool_search must be available."); + searchRequested = true; + return call(tool.name, { queries: ["analyze_javascript_application"] }); + } + assert( + serializedTools.includes("mcp__rea__analyze_javascript_application"), + "Hermes must discover the analysis tool.", + ); + if (!describeRequested) { + const tool = tools.find((t) => t.name === "tool_describe"); + assert(tool, "Hermes must load the actual parameter schema."); + describeRequested = true; + return call(tool.name, { + names: ["mcp__rea__analyze_javascript_application"], + }); + } + const tool = tools.find((t) => t.name === "tool_call"); + assert(tool, "Hermes native deferred tool_call must be available."); + callRequested = true; + return call(tool.name, { + calls: [ + { + name: "mcp__rea__analyze_javascript_application", + arguments: { input_path: target, format: "directory" }, + }, + ], + }); + }, +}); +if (client === "pi") { + await writeFile( + join(profile, "models.json"), + JSON.stringify({ + providers: { + "rea-fixture": { + baseUrl: fixture.baseUrl, + api: "openai-completions", + apiKey: "local-fixture-key", + models: [ + { + id: "rea-client-fixture", + reasoning: false, + contextWindow: 200000, + maxTokens: 4096, + }, + ], + }, + }, + }), + ); +} else { + const configured = await readFile(configPath, "utf8"); + await writeFile( + configPath, + configured + + `\nmodel:\n default: rea-client-fixture\n provider: custom\n base_url: ${fixture.baseUrl}\nagent:\n max_turns: 6\n`, + ); +} +const args = + client === "pi" + ? [ + "--offline", + "--provider", + "rea-fixture", + "--model", + "rea-client-fixture", + "--no-session", + "--no-context-files", + "--mode", + "json", + "-p", + mode === "chat" + ? "Say hello briefly." + : `Use REA to analyze ${target}.`, + ] + : [ + "--oneshot", + mode === "chat" + ? "Say hello briefly." + : `Use REA to analyze ${target}.`, + "--provider", + "custom", + "--model", + "rea-client-fixture", + "--reasoning", + "none", + ]; +let failure; +try { + const execution = exec(command, args, { + env: { + ...environment, + OPENAI_API_KEY: "local-fixture-key", + OPENAI_BASE_URL: fixture.baseUrl, + }, + cwd: workspace, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }); + // Pi consumes piped input before its first prompt; an unused open pipe is not EOF. + execution.child.stdin.end(); + const result = await execution; + await writeFile(join(lab, "client.stdout"), result.stdout); + await writeFile(join(lab, "client.stderr"), result.stderr); + assert.equal(fixture.failure, undefined); + assert(finalSeen); + assert(result.stdout.includes("REA_CLIENT_COMPATIBILITY_OK")); + if (mode === "call") { + assert(evidenceSeen); + assert( + skillLoaded, + "Native client must actually read the installed skill.", + ); + assert.deepEqual( + catalog?.sort(), + TOOL_CONTRACTS.map((t) => t.name).sort(), + "The complete REA catalog must survive native client discovery.", + ); + } +} catch (cause) { + failure = cause; + if (typeof cause?.stdout === "string") + await writeFile(join(lab, "client.stdout"), cause.stdout); + if (typeof cause?.stderr === "string") + await writeFile(join(lab, "client.stderr"), cause.stderr); +} finally { + await fixture.close(); + const receipt = { + client, + clientVersion, + mode, + passed: failure === undefined, + artifacts: lab, + runtimeRoot, + host: { + platform: process.platform, + architecture: process.arch, + node: process.versions.node, + }, + modelFixture: + "loopback deterministic OpenAI-compatible endpoint; no live provider claim", + skillDiscovery: + client === "pi" + ? "caller-configured isolated shared directory; default OS home discovery unverified" + : "native HERMES_HOME personal directory", + stickyHermes, + catalogSize: catalog?.length, + evidenceSeen, + skillLoaded, + artifactDigest, + finalSeen, + requests: fixture.requests, + probes: fixture.probes, + fixtureFailure: fixture.failure, + verifier: await completeVerifierRun(verifier), + }; + await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} +if (failure !== undefined) throw failure; diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index 1a97cfbab..cd84655b2 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -87,6 +87,10 @@ export async function verifyPackageEnvironment({ PI_PROFILE: undefined, QWEN_HOME: undefined, OPENCODE_CONFIG: undefined, + OPENCODE_CONFIG_DIR: undefined, + GEMINI_CLI_HOME: undefined, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: undefined, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: undefined, XDG_CONFIG_HOME: join(home, ".config"), PATH: `${fakeBin}:${process.env.PATH ?? ""}`, REA_ANALYSIS_PROVIDER: "auto", diff --git a/scripts/verify-qwen-client.mjs b/scripts/verify-qwen-client.mjs new file mode 100644 index 000000000..4b85470d6 --- /dev/null +++ b/scripts/verify-qwen-client.mjs @@ -0,0 +1,403 @@ +import assert from "node:assert/strict"; +import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; +import { + mkdir, + writeFile, + readFile, + mkdtemp, + realpath, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { exec } from "./lib/verify-package-core.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../dist/contracts/toolContracts.js"; + +assert( + process.platform !== "win32", + "The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.", +); +const repo = fileURLToPath(new URL("..", import.meta.url)); +const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; +const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-")); +const client = "qwen"; +const mode = process.argv[2] ?? "call"; +assert( + ["chat", "call"].includes(mode), + "Usage: node scripts/verify-qwen-client.mjs [chat|call]", +); +const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen"; +const verifier = createVerifierRun(); +const profile = join(lab, "selected profile"); +const account = join(lab, "account"); +const runtime = join(profile, "runtime"); +const workspace = join(lab, "workspace"); +const sharedSkills = join(account, ".agents", "skills"); +await Promise.all([ + mkdir(profile, { recursive: true }), + mkdir(account), + mkdir(workspace), +]); +const baseEnvironment = { + PATH: process.env.PATH ?? "", + USERPROFILE: account, + QWEN_HOME: profile, + QWEN_RUNTIME_DIR: runtime, + REA_PROCESS_RUN_ID: verifier.run_id, +}; +let clientVersion; +try { + clientVersion = ( + await exec(command, ["--version"], { + env: baseEnvironment, + timeout: 30_000, + }) + ).stdout.trim(); +} catch (cause) { + throw new Error( + `Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`, + ); +} +const original = JSON.stringify( + { + security: { + auth: { selectedType: "openai" }, + folderTrust: { enabled: false }, + }, + telemetry: { enabled: false }, + model: { name: "rea-client-fixture" }, + // Qwen uses os.homedir(), while REA's isolated CLI account uses USERPROFILE. + // This caller-owned setting lets the real client load the REA-installed bundle. + skills: { directories: [sharedSkills] }, + mcpServers: { other: { command: "unrelated-server", disabled: true } }, + }, + null, + 2, +); +await writeFile(join(profile, "settings.json"), original); +for (const step of [ + ["setup", "--client", "qwen_code", "--dry-run"], + ["setup", "--client", "qwen_code", "--yes"], + ["doctor", "--client", "qwen_code", "--skill"], +]) { + const response = await exec( + process.execPath, + [join(runtimeRoot, "scripts/rea.mjs"), ...step, "--json"], + { env: baseEnvironment, cwd: repo, timeout: 60_000 }, + ); + const result = JSON.parse(response.stdout); + if (step.includes("--dry-run")) + assert( + result.plannedActions.every( + (a) => + a.target.startsWith(profile + "/") || + a.target.startsWith(sharedSkills + "/"), + ), + ); + await writeFile( + join(lab, `${step[0]}${step.includes("--dry-run") ? "-plan" : ""}.json`), + response.stdout, + ); +} +assert.equal( + await readFile(join(profile, "settings.json.rea.backup"), "utf8"), + original, +); +const configured = JSON.parse( + await readFile(join(profile, "settings.json"), "utf8"), +); +assert.deepEqual(configured.model, { name: "rea-client-fixture" }); +assert.deepEqual(configured.skills, { directories: [sharedSkills] }); +assert.deepEqual(configured.mcpServers.other, { + command: "unrelated-server", + disabled: true, +}); +const target = join(lab, "fixture α测试"); +await mkdir(target, { recursive: true }); +await writeFile( + `${target}/app.js`, + 'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n', +); +let evidenceSeen = false; +let skillSeen = false; +let finalSeen = false; +let skillRequested = false; +let searchRequested = false; +let artifactReadByAgent = false; +let artifactPath; +let selectedSkillLoaded = false; +let artifactDigest; +const extractEvidence = (value) => { + if (typeof value === "string") { + try { + return extractEvidence(JSON.parse(value)); + } catch { + return null; + } + } + if (Array.isArray(value)) { + for (const item of value) { + const result = extractEvidence(item); + if (result) return result; + } + } else if (value && typeof value === "object") { + if ( + typeof value.evidence_id === "string" && + value.normalized_result?.graph?.nodes?.length > 0 + ) + return value; + for (const nested of Object.values(value)) { + const result = extractEvidence(nested); + if (result) return result; + } + } + return null; +}; +const fixture = await createOpenAiModelFixture({ + directory: lab, + prefix: `${client}-${mode}`, + onRequest: async ({ body, tools, requestIndex }) => { + skillSeen ||= JSON.stringify(body.messages ?? []).includes( + "reverse-engineer-anything", + ); + selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes( + join(sharedSkills, "reverse-engineer-anything"), + ); + const toolText = JSON.stringify( + (body.messages ?? []).filter((m) => m.role === "tool"), + ); + if ( + toolText.includes("REA_CLIENT_FIXTURE") && + /(?:\\?"REA_CLIENT_FIXTURE\\?"\s*:\s*true)/u.test(toolText) + ) { + artifactReadByAgent = true; + assert(artifactPath); + const artifact = await readFile(artifactPath); + artifactDigest = createHash("sha256").update(artifact).digest("hex"); + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(JSON.parse(artifact.toString("utf8"))); + assert(extractEvidence(evidence)); + assert.equal(evidence.subject.local_path, target); + const shape = evidence.normalized_result.graph.nodes + .flatMap((n) => n.observations) + .map((o) => o.properties) + .find( + (p) => + p.semantic_role === "export-return-shapes" && + p.exported_name === "result", + ); + assert(shape?.return_shape_coverage?.projection_complete); + const fields = shape.static_return_shapes.flatMap((s) => s.fields); + assert( + fields.some( + (f) => + f.path === "/clientCompatibility" && + f.state === "literal" && + f.value === "REA_CLIENT_FIXTURE", + ), + ); + assert( + fields.some( + (f) => f.path === "/count" && f.state === "literal" && f.value === 7, + ), + ); + evidenceSeen = true; + await writeFile( + `${lab}/${client}-${mode}-evidence.json`, + JSON.stringify(evidence), + ); + } + for (const message of body.messages ?? []) { + if (message.role !== "tool") continue; + const evidence = extractEvidence(message.content); + if (evidence) { + assert( + JSON.stringify(evidence.normalized_result).includes( + "REA_CLIENT_FIXTURE", + ), + ); + evidenceSeen = true; + await writeFile( + `${lab}/${client}-${mode}-evidence.json`, + JSON.stringify(evidence), + ); + } + } + const selected = tools.find((t) => + t.name.includes("analyze_javascript_application"), + ); + const reviewedNames = [ + ...JSON.stringify( + (body.messages ?? []).filter((m) => m.role === "tool"), + ).matchAll( + /(?:\\?"name\\?"\s*:\s*\\?")([^"\\]*analyze_javascript_application)(?:\\?")/g, + ), + ] + .map((m) => m[1]) + .sort((a, b) => b.length - a.length); + const search = tools.find((t) => t.name === "tool_search"); + const invoke = tools.find((t) => t.name === "tool_call"); + const skill = tools.find((t) => t.name === "skill"); + const shell = tools.find((t) => t.name === "run_shell_command"); + const persisted = toolText.match( + /Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u, + ); + const call = (name, args) => ({ + role: "assistant", + content: null, + tool_calls: [ + { + id: `call_rea_fixture_${requestIndex}`, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }); + let message; + if (mode === "chat" || evidenceSeen) { + message = { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" }; + finalSeen = true; + } else if (skill && !skillRequested) { + skillRequested = true; + message = call(skill.name, { skill: "reverse-engineer-anything" }); + } else if (persisted && shell) { + artifactPath = persisted[1]; + assert( + (await realpath(artifactPath)).startsWith( + (await realpath(runtime)) + "/", + ), + ); + const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`; + const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; + message = call(shell.name, { + command: `node -e ${quote(script)}`, + description: + "Read the complete saved REA Evidence and verify the fixture facts.", + }); + } else if (selected) + message = call(selected.name, { + input_path: target, + format: "directory", + }); + else if (reviewedNames.length && invoke) + message = call(invoke.name, { + name: reviewedNames[0], + arguments: { input_path: target, format: "directory" }, + }); + else if (search && !searchRequested) { + searchRequested = true; + message = call(search.name, { query: "analyze_javascript_application" }); + } else + throw new Error( + `REA analysis tool not exposed; tools: ${tools.map((t) => t.name).join(", ")}`, + ); + return message; + }, +}); +const endpoint = fixture.baseUrl; +const prompt = + mode === "chat" + ? "Say hello briefly." + : `Use REA to analyze the JavaScript directory ${target}. Report the result.`; +const env = { + ...baseEnvironment, + OPENAI_API_KEY: "local-fixture-key", + OPENAI_BASE_URL: endpoint, +}; +const args = [ + "--auth-type", + "openai", + "--openai-base-url", + endpoint, + "--model", + "rea-client-fixture", + "--approval-mode", + mode === "chat" ? "default" : "yolo", + "--allowed-mcp-server-names", + "rea", + "--output-format", + "stream-json", + "--max-session-turns", + "8", + "-p", + prompt, +]; +let response; +let failure; +try { + response = await exec(command, args, { + env, + cwd: workspace, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }); + await writeFile(join(lab, `${mode}.stdout`), response.stdout); + await writeFile(join(lab, `${mode}.stderr`), response.stderr); + const initialized = response.stdout + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + .find((v) => v.type === "system" && v.subtype === "init"); + assert(initialized); + const observed = initialized.tools + .filter((n) => n.startsWith("mcp__rea__")) + .sort(); + assert.deepEqual( + observed, + TOOL_CONTRACTS.map((c) => `mcp__rea__${c.name}`).sort(), + "Qwen must discover the complete REA catalog", + ); + assert.equal(fixture.failure, undefined); + assert(finalSeen); + if (mode === "call") { + assert(evidenceSeen); + assert(selectedSkillLoaded); + assert(artifactReadByAgent); + } +} catch (cause) { + failure = cause; + if (typeof cause?.stdout === "string") + await writeFile(join(lab, `${mode}.stdout`), cause.stdout); + if (typeof cause?.stderr === "string") + await writeFile(join(lab, `${mode}.stderr`), cause.stderr); +} finally { + await fixture.close(); + const lineage = await completeVerifierRun(verifier); + const receipt = { + client: "Qwen Code", + clientVersion, + mode, + passed: failure === undefined, + artifacts: lab, + runtimeRoot, + host: { + platform: process.platform, + architecture: process.arch, + node: process.versions.node, + }, + modelFixture: + "loopback deterministic OpenAI-compatible endpoint; no live model-provider claim", + skillDiscovery: + "caller-configured isolated shared directory; default OS home discovery unverified", + catalogSize: TOOL_CONTRACTS.length, + requests: fixture.requests, + probes: fixture.probes, + evidenceSeen, + selectedSkillLoaded, + artifactReadByAgent, + artifactPath, + artifactDigest, + finalSeen, + fixtureFailure: fixture.failure, + verifier: lineage, + }; + await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} +if (failure !== undefined) throw failure; diff --git a/scripts/verify/apple/dispatch.mjs b/scripts/verify/apple/dispatch.mjs index 411e3e32e..4ac861b29 100644 --- a/scripts/verify/apple/dispatch.mjs +++ b/scripts/verify/apple/dispatch.mjs @@ -29,7 +29,7 @@ const LINK_MODES = [ { mode: "dyld-info", flags: ["-Wl,-no_fixup_chains"], - fixups: "LC_DYLD_INFO bind opcodes", + fixups: "LC_DYLD_INFO rebase opcodes were not decoded", swift: true, }, { mode: "chained", flags: [], fixups: "chained fixups:", swift: true }, @@ -135,7 +135,12 @@ const checkObjc = (metadata, mode) => { item.selector === "reaFixtureLength", ) || objcCoverage?.status !== "complete" || - fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true + fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true || + (mode.mode === "dyld-info") !== + (fixupCoverage(metadata)?.reason?.includes( + "LC_DYLD_INFO bind opcodes", + ) === + true) ) throw new Error( `Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`, @@ -165,7 +170,12 @@ const checkSwift = (metadata, mode) => { item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" && item.implementation_address !== null, ) || - fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true + fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true || + (mode.mode === "dyld-info") !== + (fixupCoverage(metadata)?.reason?.includes( + "LC_DYLD_INFO bind opcodes", + ) === + true) ) throw new Error( `Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`, diff --git a/scripts/verify/clients/gemini-model-fixture.mjs b/scripts/verify/clients/gemini-model-fixture.mjs new file mode 100644 index 000000000..58b21302d --- /dev/null +++ b/scripts/verify/clients/gemini-model-fixture.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** Exercise the native Gemini API adapter against a local model, without cloud claims. */ +export const createGeminiModelFixture = async ({ directory, onRequest }) => { + const requests = []; + const probes = []; + let incoming = 0; + let failure; + const server = createServer(async (request, response) => { + try { + const chunks = []; + let bytes = 0; + for await (const chunk of request) { + bytes += chunk.length; + assert( + bytes <= 10 * 1024 * 1024, + "Model fixture request exceeds 10 MiB budget", + ); + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString("utf8"); + const body = raw.length === 0 ? null : JSON.parse(raw); + await writeFile( + join(directory, `gemini-incoming-${++incoming}.json`), + JSON.stringify({ method: request.method, path: request.url, body }), + ); + const path = new URL(request.url, "http://localhost").pathname; + if (path.endsWith(":countTokens")) { + probes.push({ path, kind: "synthetic-token-count" }); + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ totalTokens: 100 })); + return; + } + if ( + !/\/models\/[^/]+:(?:streamGenerateContent|generateContent)$/u.test( + path, + ) + ) { + probes.push({ path, status: 404 }); + response.statusCode = 404; + response.end( + JSON.stringify({ + error: { message: "Unsupported fixture endpoint" }, + }), + ); + return; + } + assert.equal(request.method, "POST"); + assert(body && typeof body === "object" && !Array.isArray(body)); + assert(Array.isArray(body.contents)); + const tools = (body.tools ?? []).flatMap( + (t) => t.functionDeclarations ?? [], + ); + requests.push({ + path, + tools: tools.map((t) => t.name), + contentRoles: body.contents.map((c) => c.role), + }); + const parts = await onRequest({ + body, + tools, + requestIndex: requests.length, + }); + const result = { + candidates: [ + { index: 0, content: { role: "model", parts }, finishReason: "STOP" }, + ], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 20, + totalTokenCount: 120, + }, + modelVersion: "local-rea-fixture", + }; + if (path.endsWith(":streamGenerateContent")) { + response.setHeader("Content-Type", "text/event-stream"); + response.end(`data: ${JSON.stringify(result)}\n\n`); + } else { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(result)); + } + } catch (cause) { + failure = String(cause); + response.statusCode = 400; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ error: { message: failure, code: 400 } })); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address !== null && typeof address !== "string"); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + probes, + get failure() { + return failure; + }, + close: () => new Promise((resolve) => server.close(resolve)), + }; +}; diff --git a/scripts/verify/clients/model-fixture.mjs b/scripts/verify/clients/model-fixture.mjs new file mode 100644 index 000000000..cdc99a31c --- /dev/null +++ b/scripts/verify/clients/model-fixture.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** A loopback model fixture exercises real clients without claiming live-provider compatibility. */ +export const createOpenAiModelFixture = async ({ + directory, + prefix, + onRequest, +}) => { + const requests = []; + const probes = []; + let incoming = 0; + let failure; + const server = createServer(async (request, response) => { + try { + if (request.method === "GET" && request.url?.endsWith("/models")) { + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + object: "list", + data: [ + { + id: "rea-client-fixture", + object: "model", + owned_by: "local-fixture", + }, + ], + }), + ); + return; + } + const chunks = []; + let bytes = 0; + for await (const chunk of request) { + bytes += chunk.length; + assert( + bytes <= 10 * 1024 * 1024, + "Local model fixture exceeded its 10 MiB request resource budget", + ); + chunks.push(chunk); + } + const rawBody = Buffer.concat(chunks).toString("utf8"); + const body = rawBody.length === 0 ? null : JSON.parse(rawBody); + await writeFile( + join(directory, `${prefix}-incoming-${++incoming}.json`), + JSON.stringify({ method: request.method, path: request.url, body }), + ); + if (request.url !== "/v1/chat/completions") { + // Hermes probes custom endpoints for Ollama/llama.cpp metadata. + // Unsupported HTTP endpoints return 404, as on an OpenAI-only server. + probes.push({ + method: request.method, + path: request.url, + body, + status: 404, + }); + response.statusCode = 404; + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + error: + "This OpenAI fixture exposes models and chat completions only.", + }), + ); + return; + } + assert.equal(request.method, "POST"); + assert(body !== null && typeof body === "object" && !Array.isArray(body)); + assert(Array.isArray(body.messages)); + const tools = (body.tools ?? []) + .filter((t) => t.type === "function") + .map((t) => t.function ?? t); + requests.push({ + path: request.url, + tools: tools.map((t) => t.name), + stream: body.stream, + messageRoles: body.messages.map((m) => m.role), + }); + await writeFile( + join(directory, `${prefix}-request-${requests.length}.json`), + JSON.stringify(body), + ); + const message = await onRequest({ + body, + tools, + requestIndex: requests.length, + }); + const base = { + id: `chatcmpl-rea-${requests.length}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: body.model, + }; + const finish = message.tool_calls ? "tool_calls" : "stop"; + const usage = { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + }; + if (body.stream) { + response.setHeader("Content-Type", "text/event-stream"); + const emit = (delta, finishReason = null) => + response.write( + `data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`, + ); + if (message.tool_calls) { + const fragments = message.tool_calls.map((call, index) => { + const split = Math.floor(call.function.arguments.length / 2); + return { + first: { + index, + ...call, + function: { + name: call.function.name, + arguments: call.function.arguments.slice(0, split), + }, + }, + second: { + index, + function: { arguments: call.function.arguments.slice(split) }, + }, + }; + }); + emit({ + role: "assistant", + content: null, + tool_calls: fragments.map((f) => f.first), + }); + emit({ tool_calls: fragments.map((f) => f.second) }); + } else emit(message); + emit({}, finish); + response.write( + `data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [], usage })}\n\n`, + ); + response.end("data: [DONE]\n\n"); + } else { + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + ...base, + choices: [{ index: 0, message, finish_reason: finish }], + usage, + }), + ); + } + } catch (cause) { + failure = String(cause); + response.statusCode = 400; + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + error: { message: failure, type: "invalid_request_error" }, + }), + ); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address !== null && typeof address !== "string"); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + requests, + probes, + get failure() { + return failure; + }, + close: () => new Promise((resolve) => server.close(resolve)), + }; +}; diff --git a/scripts/verify/electron/tool-contracts.mjs b/scripts/verify/electron/tool-contracts.mjs index c5afa9f1c..0de31cb7b 100644 --- a/scripts/verify/electron/tool-contracts.mjs +++ b/scripts/verify/electron/tool-contracts.mjs @@ -88,7 +88,10 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi ajv.compile(tool.inputSchema); return [ tool.name, - tool.outputSchema ? ajv.compile(tool.outputSchema) : undefined, + tool.outputSchema + ? (tool.outputSchema.$id && ajv.getSchema(tool.outputSchema.$id)) || + ajv.compile(tool.outputSchema) + : undefined, ]; }), ); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index a0b7c9c1c..c7f13ba66 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -1413,3 +1413,45 @@ export const clientRegistrationEntry = ( }; } }; + +/** Parse one Gemini policy at its source boundary before consolidating scopes. */ +export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) => + z + .object({ + allowed: z.array(z.string()).optional(), + excluded: z.array(z.string()).optional(), + }) + .passthrough() + .parse(parsed.document.mcp ?? {}); + +/** Gemini consolidates excludes by union and allowlists by normalized intersection. */ +export const geminiServerPolicyBlock = ( + policies: readonly ReturnType[], + serverKey: string, +): string | undefined => { + if (policies.some(({ excluded }) => excluded?.includes(serverKey))) + return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`; + const allowlists = policies.flatMap(({ allowed }) => + allowed === undefined ? [] : [allowed], + ); + const allowed = allowlists.reduce( + (previous, current) => { + if (previous === undefined) return current; + const normalized = new Set( + current.map((name) => name.toLowerCase().trim()), + ); + return previous.filter((name) => + normalized.has(name.toLowerCase().trim()), + ); + }, + undefined, + ); + // Gemini's manager applies the consolidated allowlist only when nonempty. + if ( + allowed !== undefined && + allowed.length > 0 && + !allowed.includes(serverKey) + ) + return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`; + return undefined; +}; diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts new file mode 100644 index 000000000..5982f53a9 --- /dev/null +++ b/src/application/ClientConfigurationFiles.ts @@ -0,0 +1,108 @@ +import { err, ok, type Result } from "../domain/result.js"; +import { readFile } from "node:fs/promises"; +import { + effectiveClientServer, + parseClientConfiguration, + type ClientConfigurationDocument, +} from "./ClientConfigurationDocument.js"; +import type { SetupClient } from "./SupportedClients.js"; + +/** A named source failure that setup can explain without losing the bad path. */ +export interface ClientConfigurationFileError { + readonly kind: "unreadable" | "malformed"; + readonly path: string; + readonly detail: string; +} + +/** Preserve named read failures and distinguish a missing source from an empty file. */ +export const readConfigurationText = async ( + path: string, +): Promise> => { + try { + return ok(await readFile(path, "utf8")); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return ok(undefined); + return err({ + kind: "unreadable", + path, + detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } +}; + +/** Read and parse one configuration at its source boundary. */ +export const readConfigurationFile = async ( + path: string, + format: SetupClient["format"], +): Promise< + Result +> => { + const source = await readConfigurationText(path); + if (!source.ok) return source; + const content = source.value; + if (content === undefined) return ok(undefined); + try { + return ok(parseClientConfiguration(content, format)); + } catch (cause: unknown) { + return err({ + kind: "malformed", + path, + detail: `Configuration ${path} is malformed: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } +}; + +/** Read every applicable user file; malformed lower-priority files also stop setup. */ +export const readClientConfigurationFiles = async ( + client: SetupClient, +): Promise< + Result +> => { + const documents: ClientConfigurationDocument[] = []; + for (const path of client.configPaths ?? [client.configPath]) { + const document = await readConfigurationFile(path, client.format); + if (!document.ok) return document; + if (document.value !== undefined) documents.push(document.value); + } + return ok(documents); +}; + +/** OpenCode merges server objects in file order, replacing arrays and scalar values. */ +const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => { + if (!isObject(previous) || !isObject(next)) return next; + return Object.fromEntries( + [...new Set([...Object.keys(previous), ...Object.keys(next)])].map( + (key) => [ + key, + Object.hasOwn(next, key) + ? mergeConfigurationValue(previous[key], next[key]) + : previous[key], + ], + ), + ); +}; +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Project a client's effective user registration without modifying its source files. */ +export const effectiveClientConfiguration = ( + documents: readonly ClientConfigurationDocument[], +): ClientConfigurationDocument | undefined => { + const last = documents.at(-1); + if (last === undefined) return undefined; + if (documents.length === 1) return last; + const servers: Record = {}; + for (const document of documents) { + const names = new Set([ + ...Object.keys(document.legacyServers), + ...Object.keys(document.servers), + ]); + for (const name of names) + servers[name] = mergeConfigurationValue( + servers[name], + effectiveClientServer(document, name), + ); + } + return { ...last, servers, legacyServers: {} }; +}; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 73cab0a1c..1b1bb6d6e 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -1,10 +1,14 @@ +import { readClientPolicyBlock } from "./GeminiClientSettings.js"; +import { + effectiveClientConfiguration, + readClientConfigurationFiles, +} from "./ClientConfigurationFiles.js"; import { clientServerForcedEnabled, clientServerListedDisabled, effectiveClientServer, - parseClientConfiguration, } from "./ClientConfigurationDocument.js"; -import { access, readFile } from "node:fs/promises"; +import { access } from "node:fs/promises"; import { resolve } from "node:path"; import { z } from "zod"; @@ -16,6 +20,7 @@ import { npxRegistrationCommand, } from "./ClientRegistrationIdentity.js"; import { + clientEvidencePaths, manualRegistrationRemediation, supportedClients, } from "./SupportedClients.js"; @@ -90,11 +95,7 @@ export const readClientRegistrationStatuses = async ( }); continue; } - if ( - !(await exists(client.markerPath)) && - !(await exists(client.configPath)) - ) - continue; + if (!(await clientEvidencePresent(client))) continue; const manualRemediation = manualRegistrationRemediation(client.name); if (client.format === "unsupported") { if (manualRemediation !== undefined) @@ -108,8 +109,36 @@ export const readClientRegistrationStatuses = async ( continue; } try { - const content = await readFile(client.configPath, "utf8"); - const parsed = parseClientConfiguration(content, client.format); + const files = await readClientConfigurationFiles(client); + if (!files.ok) { + statuses.push({ + client: client.name, + config_path: client.configPath, + command: [], + state: "invalid", + remediation: files.error.detail, + }); + continue; + } + const parsed = effectiveClientConfiguration(files.value); + if (parsed === undefined) { + statuses.push( + unavailableStatus(client.name, client.configPath, "missing"), + ); + continue; + } + const policy = await readClientPolicyBlock(client, parsed); + if (!policy.ok) { + statuses.push({ + client: client.name, + config_path: client.configPath, + command: [], + state: "invalid", + remediation: policy.error.detail, + }); + continue; + } + const policyBlock = policy.value; const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey); if (raw === undefined) { statuses.push( @@ -134,9 +163,14 @@ export const readClientRegistrationStatuses = async ( options.platform ?? process.platform, clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey), ) && - !clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey) + !clientServerListedDisabled( + parsed, + PRODUCT_IDENTITY.mcpServerKey, + ) && + policyBlock === undefined ? "aligned" : "stale", + policyBlock, ), ); } catch (cause: unknown) { @@ -163,7 +197,7 @@ const registrationAligned = ( ): boolean => { const command = [registration.command, ...registration.args]; if ( - registration.disabled === true || + (client.format !== "grok" && registration.disabled === true) || (registration.enabled === false && !forcedEnabled) ) return false; @@ -256,10 +290,17 @@ const configuredStatus = ( configPath: string, command: RegistrationCommand, state: "aligned" | "stale", + policyRemediation?: string, ): ClientRegistrationStatus => state === "aligned" ? { client, config_path: configPath, command, state, remediation: null } - : { client, config_path: configPath, command, state, remediation }; + : { + client, + config_path: configPath, + command, + state, + remediation: policyRemediation ?? remediation, + }; const unavailableStatus = ( client: string, @@ -273,6 +314,12 @@ const unavailableStatus = ( remediation, }); +const clientEvidencePresent = async (client: SetupClient): Promise => { + for (const path of clientEvidencePaths(client)) + if (await exists(path)) return true; + return false; +}; + const exists = async (path: string | undefined): Promise => { if (path === undefined) return false; try { diff --git a/src/application/GeminiClientSettings.ts b/src/application/GeminiClientSettings.ts new file mode 100644 index 000000000..eecb33b53 --- /dev/null +++ b/src/application/GeminiClientSettings.ts @@ -0,0 +1,202 @@ +import { realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { stripComments } from "jsonc-parser"; +import { z } from "zod"; +import { err, ok, type Result } from "../domain/result.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { + clientConfigurationValuesEqual, + clientRegistrationEntry, + geminiServerPolicyBlock, + parseGeminiMcpPolicy, + type ClientConfigurationDocument, +} from "./ClientConfigurationDocument.js"; +import { + readConfigurationFile, + readConfigurationText, + type ClientConfigurationFileError, +} from "./ClientConfigurationFiles.js"; +import type { SetupClient } from "./SupportedClients.js"; + +type SettingsSource = { path: string; document: ClientConfigurationDocument }; +const folderTrustSchema = z + .object({ + security: z + .object({ + folderTrust: z + .object({ enabled: z.boolean().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +const trustRulesSchema = z.record( + z.string(), + z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]), +); +const sourceFailure = ( + path: string, + cause: unknown, +): ClientConfigurationFileError => ({ + kind: "malformed", + path, + detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`, +}); + +// Gemini resolves symlinks when possible and compares macOS/Windows paths without case. +const normalizedPath = async ( + path: string, + platform: NodeJS.Platform, +): Promise => { + const physical = await realpath(path).catch(() => path); + const absolute = resolve(physical).replaceAll("\\", "/"); + return platform === "win32" || platform === "darwin" + ? absolute.toLowerCase() + : absolute; +}; + +const workspaceTrusted = async ( + settings: NonNullable, + sources: readonly SettingsSource[], +): Promise> => { + let enabled = true; + for (const { path, document } of sources) { + const parsed = folderTrustSchema.safeParse(document.document); + if (!parsed.success) return err(sourceFailure(path, parsed.error)); + enabled = parsed.data.security?.folderTrust?.enabled ?? enabled; + } + if (settings.trustOverride !== undefined) return ok(settings.trustOverride); + if (!enabled) return ok(true); + const text = await readConfigurationText(settings.trustedFoldersPath); + if (!text.ok) return text; + if (text.value === undefined) return ok(false); + let rules: z.output; + try { + // Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels. + rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value))); + } catch (cause: unknown) { + return err(sourceFailure(settings.trustedFoldersPath, cause)); + } + const workspace = await normalizedPath( + settings.workspaceDirectory, + settings.platform, + ); + let longest = -1; + let trusted = false; + for (const [path, level] of Object.entries(rules)) { + const effective = await normalizedPath( + level === "TRUST_PARENT" ? dirname(path) : path, + settings.platform, + ); + if ( + (workspace === effective || + workspace.startsWith( + effective.endsWith("/") ? effective : `${effective}/`, + )) && + path.length > longest + ) { + longest = path.length; + trusted = level !== "DO_NOT_TRUST"; + } + } + return ok(trusted); +}; + +/** Check effective Gemini scopes while preserving their authority and source identity. */ +export const readClientPolicyBlock = async ( + client: SetupClient, + userDocument?: ClientConfigurationDocument, + desiredRegistration?: unknown, +): Promise> => { + if (client.name !== "gemini_cli" || client.geminiSettings === undefined) + return ok(undefined); + const settings = client.geminiSettings; + const sources: SettingsSource[] = []; + const higher: SettingsSource[] = []; + for (const path of [ + settings.defaultsPath, + client.configPath, + settings.systemPath, + ]) { + const parsed = + path === client.configPath && userDocument !== undefined + ? ok(userDocument) + : await readConfigurationFile(path, "json"); + if (!parsed.ok) return parsed; + if (parsed.value !== undefined) + sources.push({ path, document: parsed.value }); + } + const workspace = await normalizedPath( + settings.workspaceDirectory, + settings.platform, + ); + const home = await normalizedPath( + dirname(dirname(client.configPath)), + settings.platform, + ); + if (workspace !== home) { + const path = resolve( + settings.workspaceDirectory, + ".gemini", + "settings.json", + ); + const document = await readConfigurationFile(path, "json"); + if (!document.ok) return document; + if (document.value !== undefined) { + const trusted = await workspaceTrusted(settings, sources); + if (!trusted.ok) return trusted; + if (trusted.value) { + const source = { path, document: document.value }; + const systemIndex = sources.findIndex( + (item) => item.path === settings.systemPath, + ); + sources.splice( + systemIndex < 0 ? sources.length : systemIndex, + 0, + source, + ); + higher.push(source); + } + } + } + const system = sources.find(({ path }) => path === settings.systemPath); + if (system !== undefined) higher.push(system); + const policies: ReturnType[] = []; + for (const { path, document } of sources) { + try { + policies.push(parseGeminiMcpPolicy(document)); + } catch (cause: unknown) { + return err(sourceFailure(path, cause)); + } + } + const policy = geminiServerPolicyBlock( + policies, + PRODUCT_IDENTITY.mcpServerKey, + ); + if (policy !== undefined) + return ok( + `${policy} Settings sources: ${sources.map(({ path }) => path).join(", ")}.`, + ); + // mcpServers uses shallow merge: the entire same-name definition is replaced. + const override = higher.findLast(({ document }) => + Object.hasOwn(document.servers, PRODUCT_IDENTITY.mcpServerKey), + ); + const desired = + desiredRegistration ?? + userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ?? + clientRegistrationEntry("json", npxRegistrationCommand(), {}); + if ( + override !== undefined && + !clientConfigurationValuesEqual( + override.document.servers[PRODUCT_IDENTITY.mcpServerKey], + desired, + ) + ) + return ok( + `Gemini mcpServers.${PRODUCT_IDENTITY.mcpServerKey} in ${override.path} overrides the user registration. Review that settings file before rerunning setup; REA does not modify system or workspace settings.`, + ); + return ok(undefined); +}; diff --git a/src/application/HermesClientHome.ts b/src/application/HermesClientHome.ts new file mode 100644 index 000000000..c3f148d96 --- /dev/null +++ b/src/application/HermesClientHome.ts @@ -0,0 +1,150 @@ +import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; + +/** An unresolved Hermes profile must not be configured as the default home. */ +export class HermesClientPathError extends Error { + constructor( + readonly path: string, + message: string, + ) { + super(message); + this.name = "HermesClientPathError"; + } +} + +const defaultDirectory = ( + home: string, + platform: NodeJS.Platform, + env: Readonly, +): string => { + const suffix = env.HERMES_DATA_DIR_SUFFIX ?? ""; + return platform === "win32" + ? join( + env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"), + `hermes${suffix}`, + ) + : join(home, `.hermes${suffix}`); +}; +const expandedDirectory = ( + home: string, + platform: NodeJS.Platform, + env: Readonly, +): string | undefined => { + const override = env.HERMES_HOME?.trim(); + if (!override) return undefined; + const variables = override.replace( + /\$(\w+|\{[^}]*\})/gu, + (match, variable: string) => { + const key = variable.startsWith("{") ? variable.slice(1, -1) : variable; + return env[key] ?? match; + }, + ); + const expanded = + platform === "win32" + ? variables.replace( + /%([^%]+)%/gu, + (match, variable: string) => env[variable] ?? match, + ) + : variables; + if (expanded === "~") return home; + if ( + expanded.startsWith("~/") || + (platform === "win32" && expanded.startsWith("~\\")) + ) + return join(home, expanded.slice(2)); + return resolve(expanded); +}; +const physicalPath = (path: string): string => { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +}; +const within = (root: string, path: string): boolean => { + const difference = relative(physicalPath(root), physicalPath(path)); + return ( + difference === "" || + (!isAbsolute(difference) && + difference !== ".." && + !difference.startsWith(`..${sep}`)) + ); +}; +const PROFILE_MARKERS = [ + "config.yaml", + ".env", + "SOUL.md", + "profile.yaml", + "auth.json", + "state.db", +]; +const RESERVED_PROFILES = new Set(["hermes", "test", "tmp", "root", "sudo"]); +const liveProfile = (path: string): boolean => { + try { + if (!statSync(path).isDirectory()) return false; + try { + statSync(join(dirname(path), ".deleted", basename(path))); + return false; + } catch { + /* No tombstone. */ + } + return PROFILE_MARKERS.some((marker) => { + try { + const status = lstatSync(join(path, marker)); + return status.isFile() || status.isSymbolicLink(); + } catch { + return false; + } + }); + } catch { + return false; + } +}; + +/** Match Hermes CLI sticky-profile selection while retaining the configured path spelling. */ +export const resolveHermesClientHome = ( + home: string, + platform: NodeJS.Platform, + env: Readonly, +): string => { + const native = defaultDirectory(home, platform, env); + const root = expandedDirectory(home, platform, env) ?? native; + // An explicit named profile bypasses the root's sticky selection in Hermes CLI. + if (basename(dirname(root)) === "profiles") return root; + const stickyRoot = within(native, root) ? native : root; + let name: string; + try { + // Python uses utf-8-sig and ignores unreadable / invalid-UTF8 active_profile files. + name = new TextDecoder("utf-8", { fatal: true }) + .decode(readFileSync(join(stickyRoot, "active_profile"))) + .trim(); + } catch { + return root; + } + if (name === "" || name === "default") return root; + const canonical = name.toLowerCase(); + if (canonical === "default") return root; + if ( + !/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(canonical) || + RESERVED_PROFILES.has(canonical) + ) + throw new HermesClientPathError( + join(stickyRoot, "active_profile"), + `Invalid saved Hermes profile ${JSON.stringify(name)}. Run 'hermes profile use default' or repair active_profile.`, + ); + const selected = join(root, "profiles", canonical); + if (!liveProfile(selected)) + throw new HermesClientPathError( + selected, + `Saved Hermes profile ${JSON.stringify(name)} is missing, deleted, or has no profile identity. Run 'hermes profile use default' or restore that profile before configuring it.`, + ); + return selected; +}; diff --git a/src/application/ProgressReporter.test.ts b/src/application/ProgressReporter.test.ts new file mode 100644 index 000000000..0cd44659f --- /dev/null +++ b/src/application/ProgressReporter.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; + +import { createProgressReporter } from "./ProgressReporter.js"; + +it("limits intermediate sends to 100 ms and always delivers terminal status", async () => { + let now = 0; + const sent: number[] = []; + const reporter = createProgressReporter( + async () => { + sent.push(now); + }, + { now: () => now }, + ); + for (const [completed, elapsed] of [0, 25, 50, 99, 100, 199, 200].entries()) { + now = elapsed; + await reporter.report({ + phase: "running", + completed, + total: null, + message: "Observed capture status", + }); + } + expect(sent).toEqual([0, 100, 200]); + now = 201; + await reporter.report({ + phase: "cleanup", + completed: 7, + total: null, + message: "Owned cleanup complete", + terminal: true, + }); + expect(sent).toEqual([0, 100, 200, 201]); +}); diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 6a3e70451..4b3f2689f 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -1,6 +1,10 @@ import { createHash } from "node:crypto"; import { posix } from "node:path"; +import { + logicalPathEscapesRoot, + normalizeJoinedLogicalPath, +} from "../domain/artifactIdentity.js"; import { classifyReferenceSourcePath, detectReferenceSourceLanguage, @@ -83,10 +87,10 @@ const resolveInternalSpecifier = ( ): { to: string; resolution: "internal" | "unresolved" } => { if (posix.isAbsolute(specifier)) return { to: specifier, resolution: "unresolved" }; - const normalized = posix.normalize( + const normalized = normalizeJoinedLogicalPath( posix.join(posix.dirname(fromPath), specifier), ); - if (normalized.startsWith("../") || normalized === "..") + if (logicalPathEscapesRoot(normalized)) return { to: normalized, resolution: "unresolved" }; const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; const candidates = suffixes.flatMap((suffix) => [ diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index eb5c01571..5cbf95206 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -1,3 +1,5 @@ +import { readClientPolicyBlock } from "./GeminiClientSettings.js"; +import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js"; import { clientRegistrationEntry, clientConfigurationValuesEqual, @@ -46,6 +48,15 @@ const configureClientDocument = async ( command: readonly string[], format: NonNullable, ): Promise => { + const files = await readClientConfigurationFiles(client); + if (!files.ok) return { status: "failed", reason: "readback" }; + const policy = await readClientPolicyBlock( + client, + undefined, + clientConfigurationDesired(client, environment, command, client.format), + ); + if (!policy.ok || policy.value !== undefined) + return { status: "failed", reason: "readback" }; const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); @@ -63,6 +74,13 @@ const configureClientDocument = async ( original ?? (format === "toml" || format === "grok" ? "" : "{}"), format, ); + const policy = await readClientPolicyBlock( + client, + parsed, + clientConfigurationDesired(client, environment, command, parsed.dialect), + ); + if (!policy.ok || policy.value !== undefined) + return { status: "failed", reason: "readback" }; } catch (cause: unknown) { // Malformed existing configuration fails the readback gate. void cause; @@ -158,9 +176,12 @@ export const clientConfigurationAligned = async ( ): Promise => { if (client.configPathError !== undefined) return false; try { + const files = await readClientConfigurationFiles(client); + if (!files.ok) return false; const original = await readFile(client.configPath, "utf8"); const parsed = parseClientConfiguration(original, client.format); - return registrationCurrent( + const policy = await readClientPolicyBlock( + client, parsed, clientConfigurationDesired( client, @@ -169,6 +190,19 @@ export const clientConfigurationAligned = async ( parsed.dialect, ), ); + return ( + policy.ok && + policy.value === undefined && + registrationCurrent( + parsed, + clientConfigurationDesired( + client, + providerEnvironment, + command, + parsed.dialect, + ), + ) + ); } catch (cause: unknown) { // Unreadable configuration is treated as not aligned so setup repairs it. void cause; @@ -185,6 +219,22 @@ export const inspectClientConfiguration = async ( if (client.configPathError !== undefined) return { status: "invalid", remediation: client.configPathError }; if (client.format === "unsupported") return { status: "already_current" }; + const files = await readClientConfigurationFiles(client); + if (!files.ok) return { status: "invalid", remediation: files.error.detail }; + const policy = await readClientPolicyBlock( + client, + undefined, + clientConfigurationDesired( + client, + providerEnvironment, + command, + client.format, + ), + ); + if (!policy.ok) + return { status: "invalid", remediation: policy.error.detail }; + if (policy.value !== undefined) + return { status: "invalid", remediation: policy.value }; const transactionPath = await resolveClientConfigTransactionPath( client.configPath, ); @@ -207,6 +257,20 @@ export const inspectClientConfiguration = async ( } try { const parsed = parseClientConfiguration(original, client.format); + const policy = await readClientPolicyBlock( + client, + parsed, + clientConfigurationDesired( + client, + providerEnvironment, + command, + parsed.dialect, + ), + ); + if (!policy.ok) + return { status: "invalid", remediation: policy.error.detail }; + if (policy.value !== undefined) + return { status: "invalid", remediation: policy.value }; const desired = clientConfigurationDesired( client, providerEnvironment, diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 87bf63aa0..ed789fec0 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -14,7 +14,11 @@ import { systemLinuxHopperInstallHost, } from "./LinuxHopper.js"; import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; -import { supportedClients, type SetupClient } from "./SupportedClients.js"; +import { + clientEvidencePaths, + supportedClients, + type SetupClient, +} from "./SupportedClients.js"; import { skillDestinations, canonicalSkillNeedsInstall, @@ -168,17 +172,19 @@ export const detectClients = async ( const detected: SetupClient[] = []; for (const candidate of supportedClients(home, platform, environment)) { if (candidate.configPathError !== undefined) continue; - const [hasConfig, hasMarker] = await Promise.all([ - exists(candidate.configPath), - candidate.markerPath === undefined ? false : exists(candidate.markerPath), - ]); - if (hasConfig || hasMarker) detected.push(candidate); + if (await clientEvidencePresent(candidate)) detected.push(candidate); } return detected; }; const major = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); +const clientEvidencePresent = async (client: SetupClient): Promise => { + for (const path of clientEvidencePaths(client)) + if (await exists(path)) return true; + return false; +}; + const exists = async (path: string): Promise => { try { await access(path); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 90cd52d0f..c87925f98 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,12 +1,27 @@ -import { isAbsolute, join, resolve, win32 } from "node:path"; +import { dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { lstatSync } from "node:fs"; +import { + HermesClientPathError, + resolveHermesClientHome, +} from "./HermesClientHome.js"; /** One supported client configuration location. */ export interface SetupClient { readonly name: string; readonly displayName?: string; readonly configPath: string; + /** User configuration files in increasing precedence, including the write target. */ + readonly configPaths?: readonly string[]; + /** Read-only Gemini scopes and trust inputs; never setup write targets. */ + readonly geminiSettings?: { + readonly systemPath: string; + readonly defaultsPath: string; + readonly workspaceDirectory: string; + readonly trustedFoldersPath: string; + readonly trustOverride?: boolean; + readonly platform: NodeJS.Platform; + }; /** Unresolved path evidence only; consumers must not perform I/O on it. */ readonly configPathError?: string; readonly markerPath?: string; @@ -39,6 +54,10 @@ interface ClientDefinition { readonly markerPath: ClientPath; readonly format: NonNullable; readonly skillPath?: ClientPath; + readonly configPaths?: (context: ClientPathContext) => readonly string[]; + readonly geminiSettings?: ( + context: ClientPathContext, + ) => NonNullable; } const vscodeUserDirectory = ({ @@ -76,7 +95,7 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"); const codexDirectory = ({ home, env }: ClientPathContext): string => - env.CODEX_HOME ?? join(home, ".codex"); + env.CODEX_HOME || join(home, ".codex"); /** Match Qwen Code's home override, including tilde and cwd-relative paths. */ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { @@ -98,51 +117,10 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { }; const grokDirectory = ({ home, env }: ClientPathContext): string => - env.GROK_HOME ?? join(home, ".grok"); + env.GROK_HOME || join(home, ".grok"); -/** - * Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors - * Hermes's own resolver (`%LOCALAPPDATA%\\hermes` on Windows, `~/.hermes` - * elsewhere), including the optional `HERMES_DATA_DIR_SUFFIX` that is appended - * to the literal `hermes`/`.hermes` directory name. - */ -const hermesDirectory = ({ - home, - platform, - env, -}: ClientPathContext): string => { - const override = env.HERMES_HOME?.trim(); - if (override) { - const expandedVariables = override.replace( - /\$(\w+|\{[^}]*\})/gu, - (match, variable: string) => { - const key = variable.startsWith("{") ? variable.slice(1, -1) : variable; - return env[key] ?? match; - }, - ); - const expanded = - platform === "win32" - ? expandedVariables.replace( - /%([^%]+)%/gu, - (match, variable: string) => env[variable] ?? match, - ) - : expandedVariables; - if (expanded === "~") return home; - if ( - expanded.startsWith("~/") || - (platform === "win32" && expanded.startsWith("~\\")) - ) - return join(home, expanded.slice(2)); - return resolve(expanded); - } - const suffix = env.HERMES_DATA_DIR_SUFFIX ?? ""; - return platform === "win32" - ? join( - env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"), - `hermes${suffix}`, - ) - : join(home, `.hermes${suffix}`); -}; +const hermesDirectory = ({ home, platform, env }: ClientPathContext): string => + resolveHermesClientHome(home, platform, env); /** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */ const grokBotDirectory = ({ home, env }: ClientPathContext): string => { @@ -188,17 +166,14 @@ const ompProfile = ({ env }: ClientPathContext): string | undefined => { /** * The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP * joins PI_CONFIG_DIR to the home directory, and a named profile ignores - * PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each - * process's working directory, so only an absolute override names one file. + * PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory. */ const ompAgentDirectory = (context: ClientPathContext): string => { const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp"); const profile = ompProfile(context); if (profile !== undefined) return join(root, "profiles", profile, "agent"); const override = context.env.PI_CODING_AGENT_DIR; - return override !== undefined && override !== "" && isAbsolute(override) - ? override - : join(root, "agent"); + return override ? resolve(override) : join(root, "agent"); }; /** @@ -242,21 +217,66 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string => const commandCodeDirectory = ({ home }: ClientPathContext): string => join(home, ".commandcode"); -const opencodeDirectory = ({ home, env }: ClientPathContext): string => - join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode"); +/** + * OpenCode appends `OPENCODE_CONFIG_DIR` only when the value is non-empty. + * An empty variable is the same as unset and leaves the XDG directory in place. + */ +const openCodeOverrideDirectory = ( + env: ClientPathContext["env"], +): string | undefined => { + const directory = env.OPENCODE_CONFIG_DIR; + return directory === undefined || directory === "" ? undefined : directory; +}; + +const opencodeDirectory = (context: ClientPathContext): string => + openCodeOverrideDirectory(context.env) ?? + join( + context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"), + "opencode", + ); + +/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */ +const openCodeConfigurationPaths = ( + context: ClientPathContext, +): readonly string[] => { + const directory = join( + context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"), + "opencode", + ); + const overrideDirectory = openCodeOverrideDirectory(context.env); + const paths = [ + join(directory, "config.json"), + join(directory, "opencode.json"), + join(directory, "opencode.jsonc"), + ...(context.env.OPENCODE_CONFIG ? [context.env.OPENCODE_CONFIG] : []), + ...(overrideDirectory !== undefined + ? [ + join(overrideDirectory, "opencode.json"), + join(overrideDirectory, "opencode.jsonc"), + ] + : []), + ]; + const resolved = paths.map((path) => resolve(path)); + return resolved.filter((path, index) => resolved.lastIndexOf(path) === index); +}; const existingOpenCodeConfigPath = (context: ClientPathContext): string => { - if (context.env.OPENCODE_CONFIG !== undefined) - return context.env.OPENCODE_CONFIG; - const directory = opencodeDirectory(context); - const candidates = [ - "opencode.json", - "opencode.jsonc", - ".opencode.json", - ".opencode.jsonc", - ].map((filename) => join(directory, filename)); + // The selected directory is loaded after an explicit file by OpenCode V1. + const overrideDirectory = openCodeOverrideDirectory(context.env); + const candidates = + overrideDirectory !== undefined + ? [ + join(overrideDirectory, "opencode.json"), + join(overrideDirectory, "opencode.jsonc"), + ].map((path) => resolve(path)) + : context.env.OPENCODE_CONFIG + ? [resolve(context.env.OPENCODE_CONFIG)] + : [ + join(opencodeDirectory(context), "opencode.json"), + join(opencodeDirectory(context), "opencode.jsonc"), + ].map((path) => resolve(path)); return ( - candidates.find((path) => { + [...candidates].reverse().find((path) => { try { lstatSync(path); return true; @@ -268,15 +288,52 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => { ); } }) ?? - candidates[0] ?? - join(directory, "opencode.json") + resolve( + overrideDirectory !== undefined + ? join(overrideDirectory, "opencode.json") + : context.env.OPENCODE_CONFIG || + join(opencodeDirectory(context), "opencode.json"), + ) ); }; +const geminiSettings = ({ + home, + platform, + env, +}: ClientPathContext): NonNullable => { + const system = + env.GEMINI_CLI_SYSTEM_SETTINGS_PATH || + (platform === "darwin" + ? "/Library/Application Support/GeminiCli/settings.json" + : platform === "win32" + ? "C:\\ProgramData\\gemini-cli\\settings.json" + : "/etc/gemini-cli/settings.json"); + const trustOverride = + env.GEMINI_RESTRICTED_MODE === "true" || + env.GEMINI_CLI_TRUST_WORKSPACE === "false" + ? false + : env.GEMINI_CLI_TRUST_WORKSPACE === "true" + ? true + : undefined; + return { + systemPath: system, + defaultsPath: + env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH || + join(dirname(system), "system-defaults.json"), + workspaceDirectory: process.cwd(), + trustedFoldersPath: + env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || + join(env.GEMINI_CLI_HOME || home, ".gemini", "trustedFolders.json"), + ...(trustOverride === undefined ? {} : { trustOverride }), + platform, + }; +}; + const devinDirectory = ({ home, platform, env }: ClientPathContext): string => platform === "win32" ? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin") - : join(home, ".config", "devin"); + : join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin"); /** Stable product metadata used to derive setup discovery and documentation. */ export const SUPPORTED_CLIENT_DEFINITIONS = [ @@ -316,8 +373,13 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ { name: "gemini_cli", displayName: "Gemini CLI", - configPath: [".gemini", "settings.json"], - markerPath: [".gemini"], + geminiSettings, + configPath: ({ home, env }: ClientPathContext) => + join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"), + markerPath: ({ home, env }: ClientPathContext) => + join(env.GEMINI_CLI_HOME || home, ".gemini"), + skillPath: ({ home, env }: ClientPathContext) => + join(env.GEMINI_CLI_HOME || home, ".agents", "skills"), format: "json", }, { @@ -339,12 +401,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ name: "opencode", displayName: "OpenCode", configPath: existingOpenCodeConfigPath, + configPaths: openCodeConfigurationPaths, markerPath: opencodeDirectory, format: "opencode", }, { name: "antigravity", displayName: "Antigravity", + skillPath: [".gemini", "config", "skills"], configPath: [".gemini", "config", "mcp_config.json"], markerPath: [".gemini", "config"], format: "json", @@ -427,6 +491,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }, ] as const satisfies readonly ClientDefinition[]; +/** + * Paths whose presence means doctor and setup should inspect the client. + * Layered sources count: OpenCode still loads its XDG files when a custom + * directory override does not exist. + */ +export const clientEvidencePaths = ( + client: Pick, +): readonly string[] => [ + ...new Set( + [ + client.configPath, + ...(client.configPaths ?? []), + client.markerPath, + ].filter((path): path is string => path !== undefined && path.length > 0), + ), +]; + const resolvePath = (path: ClientPath, context: ClientPathContext): string => typeof path === "function" ? path(context) : join(context.home, ...path); @@ -437,16 +518,36 @@ export const supportedClients = ( env: ClientPathContext["env"] = process.env, ): readonly SetupClient[] => { const context = { home, platform, env }; - return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => { + const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS; + return definitions.map((definition) => { try { + const configPath = resolvePath(definition.configPath, context); return { name: definition.name, displayName: definition.displayName, - configPath: resolvePath(definition.configPath, context), + configPath, + ...(definition.geminiSettings === undefined + ? {} + : { geminiSettings: definition.geminiSettings(context) }), + ...(definition.configPaths === undefined + ? {} + : { + configPaths: [ + ...new Set([...definition.configPaths(context), configPath]), + ], + }), markerPath: resolvePath(definition.markerPath, context), format: definition.format, }; } catch (cause: unknown) { + if (cause instanceof HermesClientPathError) + return { + name: definition.name, + displayName: definition.displayName, + format: definition.format, + configPath: cause.path, + configPathError: cause.message, + }; if (definition.name !== "pi") throw cause; return { name: definition.name, @@ -481,14 +582,21 @@ export const clientSkillDirectories = ( const destinations = new Map(); for (const client of selected) { const definition = definitions.find(({ name }) => name === client); - const directory = - definition?.skillPath === undefined - ? join(home, ".agents", "skills") - : resolvePath(definition.skillPath, { - home, - platform, - env: environment, - }); + let directory: string; + try { + directory = + definition?.skillPath === undefined + ? join(home, ".agents", "skills") + : resolvePath(definition.skillPath, { + home, + platform, + env: environment, + }); + } catch (cause: unknown) { + // Registration diagnostics own an unresolved profile; never fall back to another skill root. + if (cause instanceof HermesClientPathError) continue; + throw cause; + } if (!destinations.has(directory)) destinations.set(directory, { client: diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index c30accb38..bc8b180dc 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -124,11 +124,12 @@ export const runUninstall = async ( status: "retained", detail: "Hopper is not owned by REA uninstall.", }); + const reported = collapseUninstallItems(items); return { - status: items.some(({ status }) => status === "failed") + status: reported.some(({ status }) => status === "failed") ? "failed" : "complete", - items, + items: reported, }; }; @@ -171,7 +172,16 @@ export const systemUninstallHost = ( selectedHome ?? homeDirectoryFromEnvironment(environment, platform); return { clients: () => - Promise.resolve(supportedClients(home, platform, environment)), + Promise.resolve( + supportedClients(home, platform, environment).flatMap((client) => + client.configPaths === undefined + ? [client] + : client.configPaths.map((configPath) => ({ + ...client, + configPath, + })), + ), + ), inspectClient: async (client) => { const read = await readClientConfiguration(client, fileSystem); return read.kind === "item" && read.item.status === "failed" @@ -452,6 +462,68 @@ const item = ( status: UninstallItem["status"], detail: string, ): UninstallItem => ({ name, status, detail }); + +/** + * Layered clients are removed file by file, but callers look up one status by + * client name. A missing earlier file must not hide a later removal. + */ +const collapseUninstallItems = ( + items: readonly UninstallItem[], +): UninstallItem[] => { + const collapsed: UninstallItem[] = []; + for (const next of items) { + const index = collapsed.findIndex(({ name }) => name === next.name); + const current = collapsed[index]; + if (current === undefined) { + collapsed.push(next); + continue; + } + collapsed[index] = mergeUninstallItems(current, next); + } + return collapsed; +}; + +const mergeUninstallItems = ( + current: UninstallItem, + next: UninstallItem, +): UninstallItem => { + const status = preferredUninstallStatus(current.status, next.status); + const details = [current, next] + .filter( + (entry) => + entry.status === status || + (status === "removed" && entry.status === "retained"), + ) + .map(({ detail }) => detail); + return item(current.name, status, [...new Set(details)].join(" ")); +}; + +const preferredUninstallStatus = ( + current: UninstallItem["status"], + next: UninstallItem["status"], +): UninstallItem["status"] => + uninstallStatusPriority(next) > uninstallStatusPriority(current) + ? next + : current; + +const uninstallStatusPriority = (status: UninstallItem["status"]): number => { + switch (status) { + case "failed": + return 3; + case "removed": + return 2; + case "retained": + return 1; + case "skipped": + return 0; + default: { + const exhaustive: never = status; + throw new TypeError( + `Unhandled uninstall item status: ${String(exhaustive)}`, + ); + } + } +}; const registrationSchema = z .object({ command: z.string(), args: z.array(z.string()) }) .passthrough(); diff --git a/src/application/javascript/JavaScriptApplicationCancellation.test.ts b/src/application/javascript/JavaScriptApplicationCancellation.test.ts index 1457046ef..5c2b2f005 100644 --- a/src/application/javascript/JavaScriptApplicationCancellation.test.ts +++ b/src/application/javascript/JavaScriptApplicationCancellation.test.ts @@ -42,6 +42,46 @@ describe("JavaScript analysis cancellation before publication", () => { expect(phases).not.toContain("validate_javascript_application_result"); }); + it("handles control turns and cancellation inside one large source file (#1462)", async () => { + const root = await createTestTempDirectory("rea-js-cancel-in-file-"); + await writeFile( + join(root, "index.js"), + Array.from( + { length: 1200 }, + (_, index) => + `export function f${index}(){return {name:'f${index}',value:${index}};}`, + ).join("\n"), + ); + const controller = new AbortController(); + const phases: string[] = []; + let turns = 0; + // Count event-loop turns once the file starts; parsing, analysis and + // projection of this one file must leave room for them. + const tick = (): void => { + turns += 1; + if (turns === 3) controller.abort(); + else if (!controller.signal.aborted) setImmediate(tick); + }; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + phases.push(event.phase); + if (event.phase === "parse_javascript_source") setImmediate(tick); + }, + }, + }, + ); + if (result.ok) throw new Error("Cancelled analysis must not publish"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + }); + expect(phases).toContain("parse_javascript_source"); + expect(phases).not.toContain("build_javascript_application_graph"); + }); + it.each([ "parse_javascript_source", "build_javascript_application_graph", diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 84390b873..72fb67edd 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -4,7 +4,7 @@ import { setImmediate } from "node:timers/promises"; import { resolveJavaScriptSourceMapReference } from "../../domain/javascript/javascriptSourceMapPaths.js"; import { analyzeParsedJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; import { - analyzeParsedJavaScriptSemantics, + analyzeParsedJavaScriptSemanticsSteps, classifyParsedJavaScriptOpenReceivers, } from "../../domain/javascript/javascriptSemanticAnalysis.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; @@ -33,6 +33,7 @@ import type { JavaScriptSourceMapOriginal, } from "./JavaScriptArtifactAnalysisTypes.js"; import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js"; +import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactReferences.js"; interface MutableArtifactAnalysis< @@ -56,34 +57,44 @@ interface ArtifactAnalysisContext< readonly projectSemantics: ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, - ) => SemanticIr; + ) => Generator; } /** Analyze every text source from the artifact without a result quota. */ export const analyzeJavaScriptArtifactFiles = ( fileSet: JavaScriptArtifactFileSet, ): JavaScriptArtifactAnalysis => - analyzeArtifactFiles(fileSet, (_file, ir) => ir); + analyzeArtifactFiles(fileSet, function* (_file, ir) { + // The full IR needs no projection; one step keeps the shared protocol. + yield; + return ir; + }); -/** Project each file's full IR immediately, retaining only later module facts. */ +/** + * Project each file's full IR immediately, retaining only later module facts. + * Parsing, analysis and each projection pass run as separate steps, so control + * messages and cancellation are handled within one large file as well. + */ export const analyzeAndProjectJavaScriptArtifactFiles = async ( fileSet: JavaScriptArtifactFileSet, projectSemantics: ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, - ) => void, + ) => Iterator | void, beforeFile?: ( file: JavaScriptArtifactFile, completed: number, total: number, ) => Promise, + signal?: AbortSignal, ): Promise => { const state = emptyArtifactAnalysis(); - const projectFileSemantics = ( + const projectFileSemantics = function* ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, - ): JavaScriptModuleSemanticIr => { - projectSemantics(file, ir); + ): Generator { + const projection = projectSemantics(file, ir); + if (projection !== undefined) while (projection.next().done !== true) yield; const programScopes = ir.scopes.filter(({ kind }) => kind === "program"); const programScopeId = programScopes[0]?.scopeId; const localNames = new Set( @@ -111,7 +122,10 @@ export const analyzeAndProjectJavaScriptArtifactFiles = async ( // Let progress, cancellation and garbage collection run between files. await setImmediate(); await beforeFile?.(file, index, fileSet.files.length); - analyzeArtifactFile(file, context); + await completeJavaScriptAnalysisSteps( + analyzeArtifactFileSteps(file, context), + signal, + ); } return finalizeArtifactAnalysis(state); }; @@ -122,7 +136,10 @@ const analyzeArtifactFiles = ( ): JavaScriptArtifactAnalysis => { const state = emptyArtifactAnalysis(); const context = { state, projectSemantics }; - for (const file of fileSet.files) analyzeArtifactFile(file, context); + for (const file of fileSet.files) { + const steps = analyzeArtifactFileSteps(file, context); + while (steps.next().done !== true); + } return finalizeArtifactAnalysis(state); }; @@ -148,10 +165,12 @@ const finalizeArtifactAnalysis = < }; }; -const analyzeArtifactFile = ( +function* analyzeArtifactFileSteps< + SemanticIr extends JavaScriptModuleSemanticIr, +>( file: JavaScriptArtifactFile, context: ArtifactAnalysisContext, -): void => { +): Generator { const { state } = context; addStructuredObservations(file, state); if (file.kind === "html" && file.text.included) @@ -162,6 +181,7 @@ const analyzeArtifactFile = ( return; } const parsed = parseJavaScriptSource(file.text.value, file.path); + yield; if (parsed === null) { const analysis = failedJavaScriptStaticAnalysis(); state.files.push({ file, javascript: analysis, semantic: null }); @@ -169,23 +189,26 @@ const analyzeArtifactFile = ( return; } const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(parsed); + yield; const analysis = analyzeParsedJavaScriptStaticSource( file.text.value, parsed, openReceiverFacts, ); const staticFindings = findingCount(analysis); + yield; const semantics = analysis.parse_status === "complete" || analysis.parse_status === "partial" - ? analyzeParsedJavaScriptSemantics(parsed) + ? yield* analyzeParsedJavaScriptSemanticsSteps(parsed) : null; + const projected = + semantics === null + ? null + : yield* context.projectSemantics(file, semantics); state.files.push({ file, javascript: analysis, - semantic: - semantics === null - ? null - : { ir: context.projectSemantics(file, semantics) }, + semantic: projected === null ? null : { ir: projected }, }); state.visitedNodes += analysis.visited_ast_nodes; state.findings += staticFindings + (semantics?.moduleLinks.length ?? 0); @@ -194,7 +217,7 @@ const analyzeArtifactFile = ( 0, ); if (analysis.parse_status === "failed") state.parseFailures += 1; -}; +} const addSourceMap = ( file: JavaScriptArtifactFile, diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index bf46a0557..023802a79 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,7 +1,7 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { createJavaScriptApplicationGraph, - createImmutableJavaScriptApplicationGraphSteps, + sealTransferredJavaScriptApplicationGraphSteps, type JavaScriptApplicationGraph, } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; @@ -15,9 +15,9 @@ import { addJavaScriptHtmlRoles, addJavaScriptSourceMapOriginals, } from "./JavaScriptArtifactGraphDocuments.js"; -import { addJavaScriptStaticFindings } from "./JavaScriptArtifactGraphFindings.js"; +import { addJavaScriptStaticFindingsSteps } from "./JavaScriptArtifactGraphFindings.js"; import { - addJavaScriptModuleRelationships, + addJavaScriptModuleRelationshipsSteps, type JavaScriptModuleRelationshipOmissions, addJavaScriptSourceModules, } from "./JavaScriptModuleRelationships.js"; @@ -51,24 +51,42 @@ export const buildJavaScriptArtifactGraph = ( analysis: JavaScriptModuleArtifactAnalysis, ): JavaScriptApplicationGraph => createJavaScriptApplicationGraph( - buildJavaScriptArtifactGraphInput(snapshot, fileSet, analysis), + completeSynchronously( + buildJavaScriptArtifactGraphInputSteps(snapshot, fileSet, analysis), + ), ); -/** Build a validated application graph with cooperative immutable ownership. */ -export const buildImmutableJavaScriptArtifactGraphSteps = ( +/** + * Build a validated application graph with cooperative immutable ownership. + * Constructing the graph input also runs in steps: projecting every module + * relationship and finding at once held the event loop for seconds. + */ +export function* buildImmutableJavaScriptArtifactGraphSteps( snapshot: ArtifactInventorySnapshot, fileSet: JavaScriptArtifactFileSet, analysis: JavaScriptModuleArtifactAnalysis, -): Generator => - createImmutableJavaScriptApplicationGraphSteps( - buildJavaScriptArtifactGraphInput(snapshot, fileSet, analysis), +): Generator { + const input = yield* buildJavaScriptArtifactGraphInputSteps( + snapshot, + fileSet, + analysis, ); + // The input was built here and is not retained, so it can be transferred. + return yield* sealTransferredJavaScriptApplicationGraphSteps(input); +} -const buildJavaScriptArtifactGraphInput = ( +const completeSynchronously = (steps: Iterator): Value => { + for (;;) { + const step = steps.next(); + if (step.done === true) return step.value; + } +}; + +function* buildJavaScriptArtifactGraphInputSteps( snapshot: ArtifactInventorySnapshot, fileSet: JavaScriptArtifactFileSet, analysis: JavaScriptModuleArtifactAnalysis, -): unknown => { +): Generator { const accumulator = new JavaScriptArtifactGraphAccumulator(); const root = createJavaScriptArtifactRootNode(accumulator, snapshot); const context: JavaScriptArtifactGraphContext = { @@ -86,15 +104,23 @@ const buildJavaScriptArtifactGraphInput = ( containerNodes: new Map([[snapshot.manifest.root_sha256, root]]), }; addJavaScriptArtifactContainers(context); + yield; addJavaScriptArtifactFiles(context); + yield; const packageRoots = addJavaScriptPackageNodes(context); + yield; addJavaScriptSourceModules(context); + yield; const bundlerLimitations = addJavaScriptBundlerNodes(context); - const relationshipOmissions = addJavaScriptModuleRelationships(context); - const findingLimitations = addJavaScriptStaticFindings(context); + const relationshipOmissions = + yield* addJavaScriptModuleRelationshipsSteps(context); + const findingLimitations = yield* addJavaScriptStaticFindingsSteps(context); addElectronBoundaries(context); + yield; addJavaScriptHtmlRoles(context); + yield; addJavaScriptSourceMapOriginals(context); + yield; const coverage = graphCoverage(context); return { schema: "JavaScriptApplicationGraph", @@ -111,7 +137,7 @@ const buildJavaScriptArtifactGraphInput = ( ...graphLimitations(context, coverage.status, relationshipOmissions), ], }; -}; +} const graphCoverage = (context: JavaScriptArtifactGraphContext) => { const resourceLimits = semanticResourceLimits(context); diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 41f480daf..e7a002a6a 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -44,11 +44,12 @@ interface ResolvedReference { } /** Project imports, workers, roles, endpoints, storage, and source-map links. */ -export const addJavaScriptStaticFindings = ( +export function* addJavaScriptStaticFindingsSteps( context: JavaScriptArtifactGraphContext, -): readonly string[] => { +): Generator { const omissions = { selfReferences: 0 }; for (const analyzed of context.analysis.files) { + yield; const { file, javascript } = analyzed; const asset = context.assetNodes.get(file.path); if (javascript === null || asset === undefined) continue; @@ -69,7 +70,7 @@ export const addJavaScriptStaticFindings = ( ["static reference", "static references"], "the referencing module", ); -}; +} const addReference = ( context: JavaScriptArtifactGraphContext, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 1b5bd33c4..97715243d 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -67,6 +67,36 @@ describe("artifact-local graph identity", () => { }); }); +describe("logical path identity", () => { + it("resolves an NFD relative import to the NFC inventory path and rejects a root escape", () => { + const nfc = "caf\u00e9".normalize("NFC"); + const nfd = nfc.normalize("NFD"); + const files = fileMap([ + file("dir/index.js", "root"), + file(`dir/${nfc}.js`, "root"), + ]); + expect( + resolve({ + declaredPath: `./${nfd}.js`, + sourcePath: "dir/index.js", + context: "module-specifier", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: `dir/${nfc}.js`, + }); + expect( + resolve({ + declaredPath: "../escape.js", + sourcePath: "index.js", + context: "module-specifier", + files, + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }); +}); + describe("contextual JavaScript artifact path resolution", () => { it.each([ ["main.js", "app/main.js"], diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index f87cc2455..418b9ac86 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1,6 +1,10 @@ import { isBuiltin } from "node:module"; import { posix } from "node:path"; +import { + normalizeJoinedLogicalPath, + logicalPathEscapesRoot, +} from "../../domain/artifactIdentity.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { admitsCanonicalPathSyntax, @@ -326,12 +330,8 @@ const confineCandidate = ( input: ResolveArtifactPathInput, candidate: string, ): string | ArtifactPathResolution => { - const normalized = posix.normalize(candidate); - if ( - normalized === ".." || - normalized.startsWith("../") || - normalized.startsWith("/") - ) + const normalized = normalizeJoinedLogicalPath(candidate); + if (logicalPathEscapesRoot(normalized)) return unresolvedOutcome(input, "rejected", [ "The resolved candidate escapes the canonical artifact root.", ]); diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index ab986608a..25919923c 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -90,7 +90,7 @@ export const reconstructJavaScriptArtifact = async ( const semanticProjection = createJavaScriptSemanticGraphProjection(); const analysis = await analyzeAndProjectJavaScriptArtifactFiles( files, - semanticProjection.projectFile, + semanticProjection.projectFileSteps, async (file, completed, total) => { abortIfNeeded(signal); await progress?.report({ @@ -101,6 +101,7 @@ export const reconstructJavaScriptArtifact = async ( }); abortIfNeeded(signal); }, + signal, ); await checkpointJavaScriptAnalysis(signal); abortIfNeeded(signal); diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 8e12f3beb..3784141a8 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -136,15 +136,17 @@ export interface JavaScriptModuleRelationshipOmissions { type RelationshipOmissionCounter = { selfImports: number }; /** Compose bounded CommonJS and ESM binding relationships across artifact files. */ -export const addJavaScriptModuleRelationships = ( +export function* addJavaScriptModuleRelationshipsSteps( context: JavaScriptArtifactGraphContext, -): JavaScriptModuleRelationshipOmissions => { +): Generator { const omissions = { selfImports: 0 }; for (const analyzed of context.analysis.files) { const { file, semantic } = analyzed; const source = context.sourceModuleNodes.get(file.path); if (semantic === null || source === undefined) continue; for (const link of semantic.ir.moduleLinks) { + // One module can export thousands of bindings, each with return shapes. + yield; const input = { context, file, semantic, source, link }; if (isExportLink(link)) addExportRelationship(input, omissions); else if (link.specifier !== null) @@ -155,7 +157,7 @@ export const addJavaScriptModuleRelationships = ( } } return omissions; -}; +} const addExportRelationship = ( input: RelationshipInput, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 45ded5051..a0cc8ccf8 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -1,6 +1,6 @@ import { createJavaScriptSemanticGraph, - createImmutableJavaScriptSemanticGraphSteps, + sealTransferredJavaScriptSemanticGraphSteps, type JavaScriptSemanticGraph, } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { @@ -130,6 +130,11 @@ export interface JavaScriptSemanticGraphProjection { file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, ) => void; + /** Project one file in steps between its projection passes. */ + readonly projectFileSteps: ( + file: JavaScriptArtifactFile, + ir: JavaScriptSemanticIr, + ) => Generator; readonly finish: ( rootArtifactSha256: string, applicationGraph: BuilderInput["applicationGraph"], @@ -146,10 +151,10 @@ export const createJavaScriptSemanticGraphProjection = const state = emptyState({ nodes: [] }); const fingerprints: JavaScriptSemanticFingerprint[] = []; let truncatedFiles = 0; - const projectSource = ( + const projectSourceSteps = function* ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, - ): void => { + ): Generator { if (state.nodes.size >= SEMANTIC_GRAPH_NODE_CEILING) { truncatedFiles += 1; return; @@ -161,7 +166,7 @@ export const createJavaScriptSemanticGraphProjection = remainingTreeBudget, ); state.fileNodesDropped = false; - fingerprints.push(...projectFile(file, ir, state)); + fingerprints.push(...(yield* projectFileSteps(file, ir, state))); // Nodes were dropped only if the budget actually blocked creation. A file // that exactly fills its share ends with a zero budget without dropping // anything, so the remaining budget alone cannot decide truncation. @@ -198,7 +203,8 @@ export const createJavaScriptSemanticGraphProjection = evidence_contexts: state.evidenceContexts.contexts, nodes: [...state.nodes.values()], relations, - fingerprints, + // The input is transferred to the factory; `fingerprints` is cleared below. + fingerprints: [...fingerprints], unknowns, coverage: { status: truncatedFiles > 0 ? "partial" : "unknown", @@ -246,7 +252,11 @@ export const createJavaScriptSemanticGraphProjection = return graph; }; return { - projectFile: projectSource, + projectFile: (file, ir) => { + const steps = projectSourceSteps(file, ir); + while (steps.next().done !== true); + }, + projectFileSteps: projectSourceSteps, finish: (rootArtifactSha256, applicationGraph) => finishWith( createJavaScriptSemanticGraph, @@ -255,18 +265,19 @@ export const createJavaScriptSemanticGraphProjection = ), finishImmutableSteps: (rootArtifactSha256, applicationGraph) => finishWith( - createImmutableJavaScriptSemanticGraphSteps, + sealTransferredJavaScriptSemanticGraphSteps, rootArtifactSha256, applicationGraph, ), }; }; -const projectFile = ( +function* projectFileSteps( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, state: BuilderState, -): JavaScriptSemanticFingerprint[] => { +): Generator { + yield; const moduleNode = retainNode(state, file, { kind: "module", roleKey: "module", @@ -276,38 +287,50 @@ const projectFile = ( }); if (moduleNode === null) return []; state.roots.add(moduleNode.node_id); - const callableNodes = new Map( - ir.callables.flatMap((callable) => { - const node = retainNode(state, file, { - kind: "function", - roleKey: `callable:${callable.callableId}`, - location: callable.location, - label: callable.name, - functionNodeId: null, - // The label is display text; keep the exact name, which may be "". - properties: { name: callable.name }, - }); - return node === null ? [] : [[callable.callableId, node] as const]; - }), - ); + // Node creation keeps source order, which the per-file node budget relies + // on; yields between nodes do not change it. + const callableNodes = new Map(); + for (const [index, callable] of ir.callables.entries()) { + if (index % 64 === 0) yield; + const node = retainNode(state, file, { + kind: "function", + roleKey: `callable:${callable.callableId}`, + location: callable.location, + label: callable.name, + functionNodeId: null, + // The label is display text; keep the exact name, which may be "". + properties: { name: callable.name }, + }); + if (node !== null) callableNodes.set(callable.callableId, node); + } const callableOwnerAt = createSemanticCallableOwnerLookup(ir, callableNodes); - const bindingNodes = new Map( - ir.bindings.flatMap((binding) => { - const location = binding.definitions[0]?.location ?? null; - const kind = binding.kind === "parameter" ? "parameter" : "binding"; - const owner = callableOwnerAt(location); - const node = retainNode(state, file, { - kind, - roleKey: `binding:${binding.bindingId}`, - location, - label: binding.name, - functionNodeId: owner?.node_id ?? null, - }); - return node === null ? [] : [[binding.bindingId, node] as const]; - }), + const bindingNodes = new Map(); + for (const [index, binding] of ir.bindings.entries()) { + if (index % 64 === 0) yield; + const location = binding.definitions[0]?.location ?? null; + const kind = binding.kind === "parameter" ? "parameter" : "binding"; + const owner = callableOwnerAt(location); + const node = retainNode(state, file, { + kind, + roleKey: `binding:${binding.bindingId}`, + location, + label: binding.name, + functionNodeId: owner?.node_id ?? null, + }); + if (node !== null) bindingNodes.set(binding.bindingId, node); + } + const returnSiteNodes = yield* createReturnSiteNodes( + file, + ir, + callableNodes, + state, + ); + const callSiteNodes = yield* createCallSiteNodes( + file, + ir, + callableNodes, + state, ); - const returnSiteNodes = createReturnSiteNodes(file, ir, callableNodes, state); - const callSiteNodes = createCallSiteNodes(file, ir, callableNodes, state); const definitions: Omit = { file, ir, @@ -328,42 +351,38 @@ const projectFile = ( callableOwnerAt, callSiteAt: createSemanticCallSiteLookup(ir, callSiteNodes), }; - projectDefinitionsAndReferences(definitions); + yield* projectDefinitionsAndReferences(definitions); const context: FileContext = { ...definitions, referenceNodesWithin: createSemanticNodeRangeLookup( definitions.referenceNodes, ), }; - projectSemanticValues(context); - projectSemanticObjects(context); - projectCalls(context); - projectSemanticPromises(context); - projectSemanticEvents(context); - projectSemanticTimers(context); - projectSemanticChildProcesses(context); - projectSemanticConfiguration(context); - projectSemanticRequests(context); - projectSemanticBoundaries(context); - projectSemanticResources(context); - projectSemanticClosureCaptures(context); - projectSemanticFrontiers(context); + // Each pass completes before the next starts, so yielding between passes + // keeps projection order and results unchanged. + for (const project of FILE_PROJECTION_PASSES) { + yield; + const steps = project(context); + if (steps !== undefined) while (steps.next().done !== true) yield; + } + yield; return projectSemanticFunctionFingerprints( file, ir, callableNodes, state.evidenceContexts, ); -}; +} -const createReturnSiteNodes = ( +function* createReturnSiteNodes( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, callables: ReadonlyMap, state: BuilderState, -): Map => { +): Generator> { const result = new Map(); - for (const callable of ir.callables) { + for (const [index, callable] of ir.callables.entries()) { + if (index % 64 === 0) yield; const owner = callables.get(callable.callableId); if (owner === undefined) continue; for (const site of callable.returnSites) { @@ -379,16 +398,17 @@ const createReturnSiteNodes = ( } } return result; -}; +} -const createCallSiteNodes = ( +function* createCallSiteNodes( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, callables: ReadonlyMap, state: BuilderState, -): Map => { +): Generator> { const result = new Map(); - for (const call of ir.callSites) { + for (const [index, call] of ir.callSites.entries()) { + if (index % 64 === 0) yield; const owner = call.callerCallableId === null ? null @@ -403,12 +423,13 @@ const createCallSiteNodes = ( if (node !== null) result.set(call.callSiteId, node); } return result; -}; +} -const projectDefinitionsAndReferences = ( +function* projectDefinitionsAndReferences( context: Omit, -): void => { - for (const binding of context.ir.bindings) { +): Generator { + for (const [bindingIndex, binding] of context.ir.bindings.entries()) { + if (bindingIndex % 64 === 0) yield; const bindingNode = context.bindingNodes.get(binding.bindingId); if (bindingNode === undefined) continue; for (const [index, definition] of binding.definitions.entries()) { @@ -428,6 +449,7 @@ const projectDefinitionsAndReferences = ( } } for (const [index, reference] of context.ir.references.entries()) { + if (index % 64 === 0) yield; const expression = retainNode(context.state, context.file, { kind: "expression", roleKey: `reference:${String(index)}:${reference.role}:${reference.name}`, @@ -458,7 +480,7 @@ const projectDefinitionsAndReferences = ( resolution: "resolved", }); } -}; +} const projectCalls = (context: FileContext): void => { for (const call of context.ir.callSites) { @@ -543,3 +565,22 @@ const createArgumentNode = ( : `argument ${String(argument.index)}`, functionNodeId: callNode.function_node_id, }); + +/** Per-file projection passes, run in this order with a yield between each. */ +const FILE_PROJECTION_PASSES: readonly (( + context: FileContext, +) => Iterator | void)[] = [ + projectSemanticValues, + projectSemanticObjects, + projectCalls, + projectSemanticPromises, + projectSemanticEvents, + projectSemanticTimers, + projectSemanticChildProcesses, + projectSemanticConfiguration, + projectSemanticRequests, + projectSemanticBoundaries, + projectSemanticResources, + projectSemanticClosureCaptures, + projectSemanticFrontiers, +]; diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index be801ab86..e98526160 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -240,10 +240,11 @@ export const projectSemanticClosureCaptures = ( }; /** Retain bounded unresolved dynamic-call, dynamic-property and dynamic-scope frontiers. */ -export const projectSemanticFrontiers = ( +export function* projectSemanticFrontiers( context: SemanticFlowProjectionContext, -): void => { - for (const frontier of context.ir.frontiers) { +): Generator { + for (const [index, frontier] of context.ir.frontiers.entries()) { + if (index % 64 === 0) yield; // Dynamic environments leave identifier reads and writes unresolved. const family = frontier.kind === "dynamic-call" @@ -275,4 +276,4 @@ export const projectSemanticFrontiers = ( ); addSemanticGraphUnknown(context.state, unknown); } -}; +} diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index 1b71b2784..337899f83 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -20,13 +20,14 @@ import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlo import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js"; /** Project static object reads, writes, spreads, and destructuring. */ -export const projectSemanticObjects = ( +export function* projectSemanticObjects( context: SemanticFlowProjectionContext, -): void => { +): Generator { const values = new Map( context.ir.bindings.map((binding) => [binding.bindingId, binding.value]), ); - for (const operation of context.ir.objectOperations) { + for (const [index, operation] of context.ir.objectOperations.entries()) { + if (index % 64 === 0) yield; const occurrence = addObjectOccurrence(context, operation); const target = objectTarget(context, values, operation); const ends = objectRelationEnds(context, operation, occurrence, target); @@ -43,7 +44,7 @@ export const projectSemanticObjects = ( if (!resolved) addObjectUnknown(context, operation, target.slot ?? occurrence, target); } -}; +} const OBJECT_RELATIONS = { read: "reads-property", diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 125a11567..36e7ec67b 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -73,15 +73,27 @@ export const traceNativeUiAction = async ( ), ); graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result); + const archiveDecode = decoded.data.graph.coverage.find( + (facet) => facet.facet === "archive_decode", + ); + const documentsFound = decoded.data.documents.length > 0; coverage.push({ facet: "interface_builder_archives", - status: decoded.data.documents.length > 0 ? "complete" : "unsupported", + status: + archiveDecode?.status === "partial" || + archiveDecode?.status === "complete" || + archiveDecode?.status === "unsupported" + ? archiveDecode.status + : documentsFound + ? "complete" + : "unsupported", reason: - decoded.data.documents.length > 0 + archiveDecode?.reason ?? + (documentsFound ? null - : "no_compiled_interface_builder_archives_found", - examined: decoded.data.documents.length, - omitted: 0, + : "no_compiled_interface_builder_archives_found"), + examined: archiveDecode?.examined ?? decoded.data.documents.length, + omitted: archiveDecode?.omitted ?? 0, }); const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges); graph = bounded.graph; diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index c7c4d479b..bbc4667ee 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -15,6 +15,7 @@ import { processTraceSpecificationSchema } from "../../domain/process/processTra import { processScenarioSchema } from "../../domain/process/processScenario.js"; import { compareProcessCaptures } from "../../domain/process/processComparison.js"; import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js"; +import { createProgressReporter } from "../ProgressReporter.js"; import { captureProcessScenario } from "../../process/capture/ProcessHarness.js"; import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js"; import { createProcessCaptureEvidence } from "./ProcessEvidence.js"; @@ -56,11 +57,20 @@ export const captureProcessScenarioFile = async ( input, { cause: parsed.error }, ); + const progress = createProgressReporter( + async (update) => { + process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`); + }, + { minimumIntervalMs: 100 }, + ); const captured = await captureProcessScenario( parsed.data, signal, process.platform, environment, + undefined, + undefined, + progress, ); if (!captured.ok) return cliAnalysisError(captured.error); return createProcessCaptureEvidence(parsed.data, captured.value); diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index cabcb60cf..3950b4c6e 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -1,5 +1,6 @@ import { posix } from "node:path"; +import { logicalPathNfc } from "../domain/artifactIdentity.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; @@ -31,7 +32,7 @@ export const normalizeArtifactPath = (input: string): string => { "path", `Artifact path is absolute or unsafe: ${JSON.stringify(input)}`, ); - const normalized = input.normalize("NFC").replace(/\/+$/u, ""); + const normalized = logicalPathNfc(input).replace(/\/+$/u, ""); const parts = normalized.split("/"); if ( normalized.length === 0 || diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 18c675921..d0c8dfef8 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -6,7 +6,10 @@ import { import type { FileHandle } from "node:fs/promises"; import type { Stats } from "node:fs"; -import { classifyArtifactContent } from "./ArtifactGraphConstruction.js"; +import { + classifyArtifactBytes, + classifyArtifactContent, +} from "./ArtifactGraphConstruction.js"; import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; import { @@ -17,17 +20,6 @@ import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { HashResult } from "../ArtifactHash.js"; import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js"; -const classifyContainerExtension = ( - path: string, -): ArtifactOccurrence["artifact_format"] | undefined => { - const lower = path.toLowerCase(); - const zipPackage = zipPackageFormatForPath(lower); - if (zipPackage !== undefined) return zipPackage; - for (const format of ["asar", "dmg", "pkg"] as const) - if (lower.endsWith(`.${format}`)) return format; - return undefined; -}; - /** Classify and hash one file root through the same stable open descriptor. */ export const classifyAndHashRoot = async ( path: string, @@ -47,9 +39,7 @@ export const classifyAndHashRoot = async ( "integrity", `Root artifact changed before inventory: ${path}`, ); - const format = - classifyContainerExtension(path) ?? - (await classifyOpenedRoot(path, handle)); + const format = await classifyRootFormat(path, handle); const digest = await hashStableRootArtifactHandle( path, handle, @@ -79,14 +69,50 @@ const openRootFile = async ( } }; -const classifyOpenedRoot = async ( +const classifyRootFormat = async ( path: string, handle: FileHandle, ): Promise => { const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES); const observed = await handle.read(magic, 0, magic.length, 0); const prefix = magic.subarray(0, observed.bytesRead); - if (hasZipSignature(prefix)) return "zip"; - return classifyArtifactContent(path, prefix, (await handle.stat()).size) - .format; + const lower = path.toLowerCase(); + if (hasZipSignature(prefix)) return zipPackageFormatForPath(lower) ?? "zip"; + if (lower.endsWith(".asar") && hasAsarHeader(prefix)) return "asar"; + if ( + lower.endsWith(".pkg") && + prefix.subarray(0, 4).toString("ascii") === "xar!" + ) + return "pkg"; + if (lower.endsWith(".dmg") && (await hasKolyTrailer(handle))) return "dmg"; + const size = (await handle.stat()).size; + const format = classifyArtifactContent(path, prefix, size).format; + // Path suffixes stay role hints for nested members. A root whose container + // magic did not match is classified from the bytes that were read. + if (format === "asar" || format === "pkg" || format === "dmg") + return classifyArtifactBytes(prefix, size); + return format; +}; + +/** + * Electron ASAR starts with a size pickle whose payload is one uint32, then + * a header pickle whose payload begins with a JSON object. + */ +const hasAsarHeader = (prefix: Buffer): boolean => { + if (prefix.length < 16 || prefix.readUInt32LE(0) !== 4) return false; + const headerSize = prefix.readUInt32LE(4); + const payloadSize = prefix.readUInt32LE(8); + if (headerSize < 16 || payloadSize !== headerSize - 4) return false; + const stringLength = prefix.readUInt32LE(12); + return ( + stringLength >= 2 && stringLength <= payloadSize - 4 && prefix[16] === 0x7b + ); +}; + +const hasKolyTrailer = async (handle: FileHandle): Promise => { + const size = (await handle.stat()).size; + if (size < 512) return false; + const trailer = Buffer.alloc(4); + const read = await handle.read(trailer, 0, trailer.length, size - 512); + return read.bytesRead === 4 && trailer.toString("ascii") === "koly"; }; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index cc0a3593b..692431a00 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -5,6 +5,7 @@ import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js"; import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js"; import { ZipArtifactReader } from "../ZipArtifactReader.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; +import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "../../domain/zipPackageFormat.js"; export const createReader = async ( path: string, @@ -39,7 +40,15 @@ export const inventoryLimitations = ( format: ArtifactOccurrence["artifact_format"], reader: ArtifactReader | undefined, ): string[] => { - if (reader !== undefined) return []; + if (reader !== undefined) + return reader.format === "zip" || + reader.format === "ipa" || + reader.format === "apk" || + reader.format === "msix" || + reader.format === "appx" + ? [ZIP_NON_ENTRY_TAIL_LIMITATION] + : []; + // dmg and pkg reach here only after classifyRootFormat matched their bytes. if (format === "dmg" || format === "pkg") return [ `${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`, diff --git a/src/browser/CdpCaptureContextRetention.test.ts b/src/browser/CdpCaptureContextRetention.test.ts index 81e372297..a75a63bf5 100644 --- a/src/browser/CdpCaptureContextRetention.test.ts +++ b/src/browser/CdpCaptureContextRetention.test.ts @@ -56,4 +56,39 @@ describe("CDP execution-context retention", () => { }); expect(electronFrames.size).toBe(0); }); + + it("keeps a script on the frame stored when it was parsed", () => { + const request = inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: ["https://app.example.test"], + target_id: "page-1", + }); + const browser = new CdpCaptureEvents( + request, + new Set(["https://app.example.test"]), + ); + browser.beginAuthorizedFrame("frame-old"); + browser.beginAuthorizedFrame("frame-new"); + browser.ingest({ + method: "Runtime.executionContextCreated", + params: { context: { id: 7, auxData: { frameId: "frame-old" } } }, + }); + browser.ingest({ + method: "Debugger.scriptParsed", + params: { + scriptId: "script-1", + url: "https://app.example.test/app.js", + executionContextId: 7, + }, + }); + browser.ingest({ + method: "Runtime.executionContextCreated", + params: { context: { id: 7, auxData: { frameId: "frame-new" } } }, + }); + const script = browser.scripts.get("script-1"); + if (script === undefined) throw new TypeError("Expected captured script"); + expect( + browser.frameForScript(script, new Set(["frame-old", "frame-new"])), + ).toBe("frame-old"); + }); }); diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index 94b04dc65..29492706e 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -198,6 +198,23 @@ describe("empty form destinations with a document base URL", () => { }, ); + it("keeps the first value when an attribute name repeats", () => { + const value = snapshot(); + const later = `${origin}/later`; + value.strings.push(later); + const href = value.strings.indexOf("href"); + const guide = value.strings.indexOf("guide"); + const nodes = value.documents[0]?.nodes; + if (nodes === undefined) throw new TypeError("Expected a DOM snapshot"); + nodes.attributes[2] = [href, guide, href, value.strings.length - 1]; + const result = capture(value); + expect(result.nodes[2]?.attribute_names).toEqual(["href", "href"]); + expect(result.urls.map(({ url }) => url)).toContain( + `${origin}/screens/guide`, + ); + expect(result.urls.map(({ url }) => url)).not.toContain(later); + }); + it("does not apply form semantics to an action attribute on another element", () => { const baseUrl = `${origin}/assets/`; const value = snapshot(baseUrl); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 4fd86caf8..38276d480 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -387,7 +387,8 @@ const domMetadata = ( for (let index = 0; index + 1 < attributes.length; index += 2) { const name = indexedString(strings, attributes[index]).toLowerCase(); const value = indexedString(strings, attributes[index + 1]); - pairs.set(name, value); + // HTML keeps the first declaration. A later repeat must not move dom_urls. + if (!pairs.has(name)) pairs.set(name, value); } const urls: WebPageInspection["metadata"]["dom_urls"] = []; for (const attribute of domUrlAttributes) { diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index 2b6d0ef4b..3e4c1290f 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -47,6 +47,7 @@ export const handleExecutionContextCreated = ( ) return; const key = String(identifier); + // New contexts may reuse an id. Scripts already parsed keep the frame stored on them. state.executionContextFrames.set(key, frameId); }; @@ -80,6 +81,7 @@ export const handleScriptParsed = ( state.completeness.exclude("scripts", exclusionReasonForUrl(rawUrl)); return; } + const executionContext = executionContextKey(params.executionContextId); const baseScript = { scriptId, rawUrl, @@ -87,7 +89,11 @@ export const handleScriptParsed = ( origin: sanitized.origin, ...scriptMetadataValues(params), language: cdpStringValue(params.scriptLanguage) ?? null, - executionContextKey: executionContextKey(params.executionContextId), + executionContextKey: executionContext, + frameId: + executionContext === null + ? null + : (state.executionContextFrames.get(executionContext) ?? null), }; const baseBytes = scriptMetadataStringBytes(baseScript); const sourceMapDeclared = @@ -131,6 +137,7 @@ const scriptMetadataStringBytes = (script: { readonly hash: string | null; readonly language: string | null; readonly executionContextKey: string | null; + readonly frameId: string | null; }): number => stringByteLength(script.scriptId) + stringByteLength(script.rawUrl) + @@ -138,7 +145,8 @@ const scriptMetadataStringBytes = (script: { stringByteLength(script.origin) + stringByteLength(script.hash) + stringByteLength(script.language) + - stringByteLength(script.executionContextKey); + stringByteLength(script.executionContextKey) + + stringByteLength(script.frameId); const stringByteLength = (value: string | null): number => value === null ? 0 : Buffer.byteLength(value, "utf8"); diff --git a/src/browser/CdpCaptureEventTypes.ts b/src/browser/CdpCaptureEventTypes.ts index 6095e571b..dbd16b5d5 100644 --- a/src/browser/CdpCaptureEventTypes.ts +++ b/src/browser/CdpCaptureEventTypes.ts @@ -12,6 +12,8 @@ export interface CapturedScript { readonly sourceMapUrl: string | null; readonly sourceMapRawUrl: string | null; readonly executionContextKey: string | null; + /** Frame observed when this script was parsed. Later context reuse does not move it. */ + readonly frameId: string | null; } export type NetworkState = WebPageInspection["network"]["requests"][number]; diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 3312a9749..b5dcc7218 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -159,10 +159,8 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { script: CapturedScript, allowedFrameIds: ReadonlySet, ): string | null { - if (script.executionContextKey === null) return null; - const frameId = this.executionContextFrames.get(script.executionContextKey); - return frameId !== undefined && allowedFrameIds.has(frameId) - ? frameId + return script.frameId !== null && allowedFrameIds.has(script.frameId) + ? script.frameId : null; } diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 4ee0a3f1f..cb3c0e862 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -3,12 +3,22 @@ import type { Browser } from "playwright-core"; import { browserScenarioSchema } from "../domain/browserScenario.js"; import { + attachedScenarioLeaseKey, openPlaywrightScenarioBrowser, PlaywrightScenarioBrowserCleanupOwner, } from "./PlaywrightScenarioBrowser.js"; afterEach(() => vi.unstubAllEnvs()); +it("locks one attached browser for spellings that discover the same endpoint", () => { + expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe( + attachedScenarioLeaseKey("http://127.0.0.1:9222/", "page-1"), + ); + expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).not.toBe( + attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-2"), + ); +}); + const deferred = () => { let resolve: (value: Value | PromiseLike) => void = () => undefined; const promise = new Promise((resolvePromise) => { diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index cd2d5a65a..b82b56864 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -182,6 +182,13 @@ const createAttachedScenarioRelease = ( }; }; +/** One browser admission. The key is the discovery URL this endpoint already parses. */ +export const attachedScenarioLeaseKey = ( + endpoint: string, + targetId: string, +): string => + JSON.stringify([new URL("/json/version", endpoint).href, targetId]); + const acquireAttachedScenario = ( scenario: Extract, signal: AbortSignal | undefined, @@ -189,7 +196,10 @@ const acquireAttachedScenario = ( if (signal?.aborted === true) return Promise.reject(new BrowserObservationError(OPERATION, "cancelled")); - const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]); + const key = attachedScenarioLeaseKey( + scenario.cdp_endpoint, + scenario.target_id, + ); const admission = attachedScenarioAdmissions.get(key) ?? ({ diff --git a/src/browser/assets/PublishWebScripts.ts b/src/browser/assets/PublishWebScripts.ts index 04bf10893..f3af5b8b6 100644 --- a/src/browser/assets/PublishWebScripts.ts +++ b/src/browser/assets/PublishWebScripts.ts @@ -2,20 +2,26 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { Readable } from "node:stream"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { webScriptExportManifestSchema, webScriptExportResultSchema, type ExportWebScriptsInput, + type ExportedWebScript, type WebScriptExportResult, } from "../../domain/webScriptExport.js"; -import { planWebScriptExport } from "../../domain/webScriptExportPlan.js"; +import { + planWebScriptExport, + type PlannedWebScript, +} from "../../domain/webScriptExportPlan.js"; import { WebScriptExportError } from "../../domain/webScriptExportError.js"; import type { SelectedScriptCapture } from "./ScriptCaptureAdapters.js"; const LIMITATIONS = [ "Only script bytes retained in this capture are exported. Sources are not refetched or executed, and a response or Debugger source does not prove execution.", - "Ordinary unambiguous URL paths preserve relative module layouts by origin. Query variants, collisions, inline sources, and unrepresentable paths are isolated; their browser module relationships remain unknown.", + "Ordinary unambiguous URL paths preserve relative module layouts by origin, including case-distinct spellings. Query variants, exact path collisions, file/directory prefixes, inline sources, and unrepresentable paths are isolated. A case-distinct pair is isolated only when the destination filesystem cannot store both spellings; the record names that pair. Browser module relationships of isolated paths remain unknown.", "Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.", "Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.", "Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.", @@ -32,7 +38,11 @@ export const publishWebScripts = async ( const tree = await SafeOutputTree.create(input.output_directory); try { signal?.throwIfAborted(); - const records = planned.map(({ record }) => record); + const records: ExportedWebScript[] = []; + for (const [index, item] of planned.entries()) { + signal?.throwIfAborted(); + records.push(await publishPlannedScript(tree, item, index, signal)); + } const manifest = webScriptExportManifestSchema.parse({ capture_path: input.capture_path, capture_sha256: captureSha256, @@ -66,19 +76,6 @@ export const publishWebScripts = async ( bytes: bytes.length, }, }); - for (const { script, record } of planned) { - signal?.throwIfAborted(); - if ( - script.content.state === "captured" && - record.content.state === "exported" - ) - await tree.write( - `files/${record.content.relative_path}`, - Readable.from([script.content.bytes]), - script.content.sha256, - signal, - ); - } await tree.write("manifest.json", Readable.from([bytes]), digest, signal); signal?.throwIfAborted(); await tree.commit(); @@ -98,5 +95,56 @@ export const publishWebScripts = async ( } }; +const publishPlannedScript = async ( + tree: SafeOutputTree, + item: PlannedWebScript, + index: number, + signal: AbortSignal | undefined, +): Promise => { + const { script, record } = item; + const content = script.content; + if (content.state !== "captured" || record.content.state !== "exported") + return record; + const write = (relativePath: string): Promise => + tree.write( + `files/${relativePath}`, + Readable.from([content.bytes]), + content.sha256, + signal, + ); + try { + await write(record.content.relative_path); + return record; + } catch (cause: unknown) { + const collision = destinationCollisionReason(cause); + if (collision === undefined || record.content.layout !== "url-path") + throw cause; + const relativePath = isolatedScriptPath(script, index); + await write(relativePath); + return { + ...record, + content: { + ...record.content, + relative_path: relativePath, + layout: "isolated", + layout_reason: collision, + }, + }; + } +}; + +const isolatedScriptPath = ( + script: PlannedWebScript["script"], + index: number, +): string => + `isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`; + +const destinationCollisionReason = (cause: unknown): string | undefined => + cause instanceof ArtifactReaderFailure && + cause.reason === "path" && + cause.message.startsWith(DESTINATION_CASE_COLLISION_PREFIX) + ? cause.message + : undefined; + const message = (cause: unknown): string => cause instanceof Error ? cause.message : "Unknown publication failure"; diff --git a/src/config/environment.ts b/src/config/environment.ts index f743de576..3e02f68a2 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -5,11 +5,13 @@ import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js"; import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js"; +import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js"; import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js"; const environmentSchema = z.object({ REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"), REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(), + REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(), REA_IDA_MCP_CONFIG: z .string() .min(1) diff --git a/src/config/mcpInputSchemaProfile.ts b/src/config/mcpInputSchemaProfile.ts new file mode 100644 index 000000000..321803b5b --- /dev/null +++ b/src/config/mcpInputSchemaProfile.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +// Moonshot's function-calling API rejects requests while any single tool +// parameters schema exceeds its literal serialized size cap. Community +// measurements bound the cap between ~13.7 KB (accepted) and ~15 KB +// (rejected); 13 KiB leaves margin under the lower bound. See +// https://github.com/morluto/rea/issues/1484 for the measured evidence. +export const COMPACT_INPUT_SCHEMA_BUDGET_BYTES = 13 * 1024; + +export const mcpInputSchemaProfileSchema = z.enum(["full", "compact"]); + +const profileConstraint = + 'REA_MCP_INPUT_SCHEMA_PROFILE must be "full" (default) or "compact". The compact profile advertises input schemas without nested annotation prose for providers that cap each tool schema\'s serialized size.'; + +export type McpInputSchemaProfile = z.infer; + +/** Parse the advertised input schema presentation selection. */ +export const parseMcpInputSchemaProfile = ( + value: string | undefined, +): Result => { + if (value === undefined) return ok(undefined); + const parsed = mcpInputSchemaProfileSchema.safeParse(value); + return parsed.success + ? ok(parsed.data) + : err( + new ConfigurationError(profileConstraint, { + settings: [ + { + setting: "REA_MCP_INPUT_SCHEMA_PROFILE", + constraint: profileConstraint, + }, + ], + }), + ); +}; diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 074ed991a..da6de5be2 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -50,6 +50,9 @@ export const parseConfig = ( ...(env.REA_MCP_MAX_RESPONSE_BYTES === undefined ? {} : { mcpMaxResponseBytes: env.REA_MCP_MAX_RESPONSE_BYTES }), + ...(env.REA_MCP_INPUT_SCHEMA_PROFILE === undefined + ? {} + : { mcpInputSchemaProfile: env.REA_MCP_INPUT_SCHEMA_PROFILE }), analysisProvider: env.REA_ANALYSIS_PROVIDER, ...(env.REA_IDA_MCP_CONFIG === undefined ? {} diff --git a/src/config/types.ts b/src/config/types.ts index 37696ff4c..706db8e5c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,8 +2,11 @@ import type { LogLevel } from "../logger.js"; import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; +import type { McpInputSchemaProfile } from "./mcpInputSchemaProfile.js"; + export interface AppConfig { readonly mcpMaxResponseBytes?: number; + readonly mcpInputSchemaProfile?: McpInputSchemaProfile; readonly analysisProvider: AnalysisProviderSelector; readonly idaMcpConfigPath?: string; readonly ghidraInstallDir: string | undefined; diff --git a/src/contracts/compactInputSchema.test.ts b/src/contracts/compactInputSchema.test.ts new file mode 100644 index 000000000..3fcfc4265 --- /dev/null +++ b/src/contracts/compactInputSchema.test.ts @@ -0,0 +1,314 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { compactAdvertisedInputSchema } from "./compactInputSchema.js"; + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const repeatedObservation = { + type: "object", + properties: { + captured_url: { type: "string", minLength: 1 }, + captured_at: { type: "string", minLength: 1 }, + status_code: { type: "integer", minimum: 100, maximum: 599 }, + headers: { + type: "object", + additionalProperties: { type: "string" }, + minProperties: 1, + }, + body_digest: { type: "string", minLength: 64, maxLength: 64 }, + }, + required: ["captured_url", "captured_at", "status_code"], + additionalProperties: false, +} as const; + +const annotatedSchema = { + type: "object", + description: "Root group guidance stays.", + properties: { + query: { + type: "string", + description: "Field guidance leaves.", + examples: ["seed"], + default: "seed", + title: "Query", + minLength: 1, + }, + limits: { + type: "object", + description: "Nested guidance leaves.", + properties: { + max_results: { + type: "integer", + description: "Leaves.", + minimum: 1, + maximum: 500, + default: 50, + }, + }, + required: ["max_results"], + additionalProperties: false, + }, + }, + required: ["query"], + dependentRequired: { limits: ["query"] }, + additionalProperties: false, +} as const; + +const oversizedPairSchema = (): Record => { + const deepStructure = { + type: "object", + description: "Nested guidance that leaves.", + properties: Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [ + `field_${index}`, + { type: "string", minLength: 1, description: `Field ${index}` }, + ]), + ), + required: Array.from({ length: 40 }, (_, index) => `field_${index}`), + additionalProperties: false, + }; + return { + type: "object", + description: "Provide a complete capture pair.", + properties: { + before: structuredClone(deepStructure), + after: structuredClone(deepStructure), + }, + required: ["before", "after"], + additionalProperties: false, + }; +}; + +describe("compact advertised input schema", () => { + it("drops nested annotation prose while keeping validation keywords", () => { + const { schema, presentation } = compactAdvertisedInputSchema( + structuredClone(annotatedSchema), + 13 * 1024, + ); + expect(presentation).toBe("inline"); + expect(schema.description).toBe("Root group guidance stays."); + const query = (schema.properties as Record) + .query as Record; + expect(query).toEqual({ type: "string", minLength: 1 }); + const limits = (schema.properties as Record) + .limits as Record; + expect(limits.description).toBeUndefined(); + expect(limits.required).toEqual(["max_results"]); + expect(schema.dependentRequired).toEqual({ limits: ["query"] }); + expect(schema.required).toEqual(["query"]); + }); + + it("keeps annotation-shaped keys inside literal const and enum data", () => { + const literalMode = { + description: "saved data", + default: { keep: "me" }, + }; + const { schema } = compactAdvertisedInputSchema( + { + type: "object", + properties: { + mode: { enum: [literalMode] }, + pinned: { const: { description: "literal" } }, + }, + }, + 13 * 1024, + ); + const properties = schema.properties as Record; + expect((properties.mode as Record).enum).toEqual([ + literalMode, + ]); + expect((properties.pinned as Record).const).toEqual({ + description: "literal", + }); + }); + + it("inlines local references without changing validation or literal data", () => { + const original = { + type: "object", + $defs: { "observation/~": structuredClone(repeatedObservation) }, + properties: { + before: { $ref: "#/$defs/observation~1~0" }, + after: { $ref: "#/$defs/observation~1~0" }, + literal: { const: { $ref: "saved data", $defs: { keep: true } } }, + }, + required: ["before", "after"], + additionalProperties: false, + }; + const { schema, presentation } = compactAdvertisedInputSchema( + original, + 13312, + ); + expect(presentation).toBe("inline"); + expect(schema.$defs).toBeUndefined(); + const ajv = new Ajv2020({ strict: false }); + const canonical = ajv.compile(original); + const compact = ajv.compile(schema); + const observation = { + captured_url: "https://example.com", + captured_at: "now", + status_code: 200, + }; + for (const input of [ + { before: observation, after: observation }, + { before: observation, after: { ...observation, status_code: 99 } }, + { + before: observation, + after: observation, + literal: original.properties.literal.const, + }, + ]) + expect(compact(input)).toBe(canonical(input)); + }); + + it("keeps unconstrained JSON values explicit for client type inference", () => { + const { schema } = compactAdvertisedInputSchema( + { + type: "object", + properties: { normalized_result: {}, literal: { const: {} } }, + }, + 13312, + ); + const properties = schema.properties as Record; + expect(properties.normalized_result).toEqual({ + type: ["null", "boolean", "object", "array", "number", "string"], + }); + expect(properties.literal).toEqual({ const: {} }); + const validate = new Ajv2020({ strict: false }).compile(schema); + for (const normalized_result of [null, true, {}, [], 42, "result"]) + expect(validate({ normalized_result })).toBe(true); + }); + + it("preserves independent validation constraints on reference siblings", () => { + const original = { + type: "object", + $defs: { text: { type: "string", minLength: 3 } }, + properties: { + query: { $ref: "#/$defs/text", minLength: 1, maxLength: 5 }, + }, + }; + const { schema } = compactAdvertisedInputSchema(original, 13312); + const validate = new Ajv2020({ strict: false }).compile(schema); + expect(validate({ query: "a" })).toBe(false); + expect(validate({ query: "abc" })).toBe(true); + expect(validate({ query: "abcdef" })).toBe(false); + }); +}); + +describe("compact input schema budget and reduction", () => { + it("budgets repeated-reference expansion before materializing it", () => { + const definitions: Record = { + leaf: structuredClone(repeatedObservation), + }; + for (let index = 0; index < 30; index++) { + const $ref = `#/$defs/${index === 0 ? "leaf" : `level${index - 1}`}`; + definitions[`level${index}`] = { + type: "object", + properties: { left: { $ref }, right: { $ref } }, + }; + } + const { schema, presentation } = compactAdvertisedInputSchema( + { + type: "object", + $defs: definitions, + properties: { tree: { $ref: "#/$defs/level29" } }, + }, + 1024, + ); + expect(presentation).toBe("reduced"); + expect(serializedBytes(schema)).toBeLessThanOrEqual(1024); + expect(schema.properties).toEqual({ tree: { type: "object" } }); + }); + + it("retains referenced object, array and nullable types in reduced inputs", () => { + const original = { + type: "object", + $defs: { + pair: oversizedPairSchema(), + observations: { + type: "array", + items: structuredClone(repeatedObservation), + }, + nullable: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + properties: { + capture: { $ref: "#/$defs/pair", description: "Complete capture." }, + observations: { $ref: "#/$defs/observations" }, + label: { $ref: "#/$defs/nullable" }, + }, + }; + const { schema, presentation } = compactAdvertisedInputSchema( + original, + 1024, + ); + expect(presentation).toBe("reduced"); + expect(schema.properties).toEqual({ + capture: { type: "object", description: "Complete capture." }, + observations: { type: "array" }, + label: { type: ["string", "null"] }, + }); + const validate = new Ajv2020({ strict: false }).compile(schema); + expect(validate({ capture: {}, observations: [], label: null })).toBe(true); + expect(validate({ capture: {}, observations: [], label: "capture" })).toBe( + true, + ); + expect(validate({ capture: "{}", observations: [], label: null })).toBe( + false, + ); + }); + + it("reduces structurally oversized schemas to the shallow property presentation", () => { + const compacted = compactAdvertisedInputSchema(oversizedPairSchema(), 1024); + expect(compacted.presentation).toBe("reduced"); + expect(serializedBytes(compacted.schema)).toBeLessThanOrEqual(1024); + expect(compacted.schema.type).toBe("object"); + expect(compacted.schema.description).toContain( + "Provide a complete capture pair.", + ); + expect(compacted.schema.description).toContain( + "compact input schema profile", + ); + const properties = compacted.schema.properties as Record; + expect(properties.before).toEqual({ + type: "object", + description: "Nested guidance that leaves.", + }); + expect(properties.after).toEqual(properties.before); + expect(compacted.schema.required).toEqual(["before", "after"]); + }); + + it("produces deterministic output for equal input", () => { + const schema = { + type: "object", + properties: { + before: structuredClone(repeatedObservation), + after: structuredClone(repeatedObservation), + }, + }; + expect(compactAdvertisedInputSchema(schema, 13 * 1024)).toEqual( + compactAdvertisedInputSchema(structuredClone(schema), 13 * 1024), + ); + }); + + it("keeps inline presentations under the measured provider budget", () => { + const { schema, presentation } = compactAdvertisedInputSchema( + { + type: "object", + description: "Group guidance.", + properties: { + before: structuredClone(repeatedObservation), + after: structuredClone(repeatedObservation), + normalization: { + type: "object", + description: "Rules.", + properties: { rules: { type: "array", items: { type: "string" } } }, + }, + }, + }, + 13312, + ); + expect(presentation).toBe("inline"); + expect(serializedBytes(schema)).toBeLessThanOrEqual(13312); + }); +}); diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts new file mode 100644 index 000000000..321ea8323 --- /dev/null +++ b/src/contracts/compactInputSchema.ts @@ -0,0 +1,311 @@ +import { + schemaArrays, + schemaChildren, + schemaMaps, +} from "./inputSchemaPresentation.js"; + +// Kimi expands schema-local references before sending tools to Moonshot. +// Compact advertisements must fit after expansion, independently of client +// reference handling. Canonical server-side validation remains unchanged. + +/** Annotation keys that carry guidance but no validation semantics. */ +const annotationKeys = new Set(["description", "examples", "default", "title"]); + +/** Keys whose values are literal data, never nested schemas to rewrite. */ +const dataKeys = new Set(["const", "enum", "examples", "default"]); +const definitionKeys = new Set(["$defs", "definitions"]); +// An empty schema accepts every JSON value. Kimi otherwise infers string. +const jsonValueTypes = [ + "null", + "boolean", + "object", + "array", + "number", + "string", +]; + +export type AdvertisedInputSchemaPresentation = "inline" | "reduced"; + +export interface CompactedInputSchema { + readonly schema: Record; + readonly presentation: AdvertisedInputSchemaPresentation; +} + +const isSchemaObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +/** Rewrite every schema-valued position; literal data stays opaque. */ +const rebuildSchemaPositions = ( + node: unknown, + rebuild: (child: unknown) => unknown, +): unknown => { + if (Array.isArray(node)) return node.map((child) => rebuild(child)); + if (!isSchemaObject(node)) return node; + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (dataKeys.has(key)) { + out[key] = value; + continue; + } + if (schemaMaps.has(key) && isSchemaObject(value)) { + out[key] = Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, rebuild(child)]), + ); + continue; + } + if (schemaArrays.has(key) && Array.isArray(value)) { + out[key] = value.map((child) => rebuild(child)); + continue; + } + if (schemaChildren.has(key)) { + out[key] = Array.isArray(value) + ? value.map((child) => rebuild(child)) + : rebuild(value); + continue; + } + out[key] = value; + } + return out; +}; + +/** + * Drop annotation prose from every subschema while preserving all validation + * keywords. The root keeps its own description because the root-union + * projection encodes accepted input groups there; the root's echoed literal + * examples leave with the rest of the annotation prose. + */ +const stripAnnotations = ( + schema: Record, +): Record => { + const rebuilt = rebuildSchemaPositions( + schema, + stripChildAnnotations, + ) as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(rebuilt)) { + if (annotationKeys.has(key) && key !== "description") continue; + out[key] = value; + } + return out; +}; + +const stripChildAnnotations = (child: unknown): unknown => { + const rebuilt = rebuildSchemaPositions(child, stripChildAnnotations); + if (!isSchemaObject(rebuilt)) return rebuilt; + const kept: Record = {}; + for (const [key, value] of Object.entries(rebuilt)) + if (!annotationKeys.has(key)) kept[key] = value; + return kept; +}; + +const resolveLocalReference = ( + root: Record, + reference: string, +): unknown => { + if (reference === "#") return root; + if (!reference.startsWith("#/")) return undefined; + let value: unknown = root; + for (const token of reference.slice(2).split("/")) { + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!isSchemaObject(value) || !Object.hasOwn(value, key)) return undefined; + value = value[key]; + } + return value; +}; + +/** Stop serialization at the budget before allocating an expanded schema. */ +const inlineWithinBudget = ( + root: Record, + budgetBytes: number, +): Record | undefined => { + let resolved = true; + function* serialize( + value: unknown, + position: "schema" | "map" | "data", + active: ReadonlySet, + ): Generator { + if (Array.isArray(value)) { + yield "["; + for (const [index, child] of value.entries()) { + if (index > 0) yield ","; + yield* serialize(child, position, active); + } + yield "]"; + } else if (isSchemaObject(value)) { + if (position === "schema" && Object.keys(value).length === 0) { + yield JSON.stringify({ type: jsonValueTypes }); + return; + } + if (position === "schema" && typeof value.$ref === "string") { + const target = resolveLocalReference(root, value.$ref); + if (target === undefined || active.has(value.$ref)) { + resolved = false; + return; + } + const { $ref, ...siblings } = value; + // Reference siblings are independent constraints in draft 2020-12. + // Merging their keys into the target would overwrite validation rules. + yield* serialize( + Object.keys(siblings).length === 0 + ? target + : { allOf: [target, siblings] }, + "schema", + new Set([...active, $ref]), + ); + return; + } + yield "{"; + let first = true; + for (const [key, child] of Object.entries(value)) { + if (position === "schema" && definitionKeys.has(key)) continue; + if (!first) yield ","; + first = false; + yield JSON.stringify(key) + ":"; + const childPosition = + position === "map" + ? "schema" + : position === "schema" && schemaMaps.has(key) + ? "map" + : position === "schema" && + (schemaArrays.has(key) || schemaChildren.has(key)) + ? "schema" + : "data"; + yield* serialize(child, childPosition, active); + } + yield "}"; + } else { + yield JSON.stringify(value); + } + } + const chunks: string[] = []; + let bytes = 0; + for (const chunk of serialize(root, "schema", new Set())) { + bytes += Buffer.byteLength(chunk, "utf8"); + if (bytes > budgetBytes || !resolved) return undefined; + chunks.push(chunk); + } + return resolved + ? (JSON.parse(chunks.join("")) as Record) + : undefined; +}; + +/** Preserve every possible type, including referenced and nullable unions. */ +const detectedTypes = ( + schema: unknown, + root: Record, + active: ReadonlySet = new Set(), +): readonly string[] | undefined => { + if (!isSchemaObject(schema)) return undefined; + if (typeof schema.$ref === "string") { + if (active.has(schema.$ref)) return undefined; + return detectedTypes( + resolveLocalReference(root, schema.$ref), + root, + new Set([...active, schema.$ref]), + ); + } + if (typeof schema.type === "string") return [schema.type]; + if ( + Array.isArray(schema.type) && + schema.type.every((type) => typeof type === "string") + ) + return schema.type; + const alternatives = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : []; + if (alternatives.length === 0) return undefined; + const types = alternatives.map((branch) => + detectedTypes(branch, root, active), + ); + if (types.some((type) => type === undefined)) return undefined; + return [...new Set(types.flatMap((type) => type ?? []))]; +}; + +const REDUCED_ANNOTATION = + "Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs."; + +/** + * Present only the root property shape with per-property guidance. Used for + * schemas whose validation structure alone exceeds the compact budget; the + * canonical schema still rejects malformed calls server-side. + */ +const reduceToShallowProperties = ( + schema: Record, +): Record => { + const properties = isSchemaObject(schema.properties) ? schema.properties : {}; + const description = [ + typeof schema.description === "string" ? schema.description : undefined, + REDUCED_ANNOTATION, + ] + .filter((text) => text !== undefined) + .join(" "); + const $schema = + typeof schema.$schema === "string" ? { $schema: schema.$schema } : {}; + const shallow = Object.fromEntries( + Object.entries(properties).map(([name, value]) => { + const types = detectedTypes(value, schema); + const type = types?.length === 1 ? types[0] : types; + const description = + isSchemaObject(value) && typeof value.description === "string" + ? { description: value.description } + : {}; + return [ + name, + { ...(type === undefined ? {} : { type }), ...description }, + ]; + }), + ); + return { + ...$schema, + type: "object", + description, + properties: shallow, + ...(Array.isArray(schema.required) + ? { required: [...schema.required] } + : {}), + ...(typeof schema.minProperties === "number" + ? { minProperties: schema.minProperties } + : {}), + ...(isSchemaObject(schema.dependentRequired) + ? { dependentRequired: { ...schema.dependentRequired } } + : {}), + ...(schema.additionalProperties === false + ? { additionalProperties: false } + : {}), + }; +}; + +/** + * Render an advertised input schema within a per-schema byte budget for + * providers that validate the literal serialized schema size. Validation + * keywords and the canonical contract are preserved; only + * annotation prose and, for structurally oversized schemas, nested + * validation detail leave the advertised form. + */ +export const compactAdvertisedInputSchema = ( + schema: Record, + budgetBytes: number, +): CompactedInputSchema => { + const inline = inlineWithinBudget(stripAnnotations(schema), budgetBytes); + if (inline !== undefined) return { schema: inline, presentation: "inline" }; + const reduced = reduceToShallowProperties(schema); + if (serializedBytes(reduced) <= budgetBytes) + return { schema: reduced, presentation: "reduced" }; + // A future contract with an extreme root-property count still advertises + // an honest, budget-fitting shape instead of an oversized one. + return { + schema: reduceToShallowProperties({ + type: "object", + description: + (typeof schema.description === "string" + ? `${schema.description} ` + : "") + REDUCED_ANNOTATION, + }), + presentation: "reduced", + }; +}; diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 3b83dc4a7..9663dd832 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -1,6 +1,6 @@ import { isDeepStrictEqual } from "node:util"; -const schemaMaps = new Set([ +export const schemaMaps = new Set([ "$defs", "definitions", "properties", @@ -8,8 +8,8 @@ const schemaMaps = new Set([ "dependentSchemas", "dependencies", ]); -const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); -const schemaChildren = new Set([ +export const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); +export const schemaChildren = new Set([ "items", "additionalItems", "contains", diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index e2a6e50af..34089a929 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -95,6 +95,46 @@ export const toolOutputSchemaWithMetadata = ( }, ); +/** + * Apply a wire-presentation transform to one schema's advertised input + * projection. The wrapper preserves the schema's own parser and standard + * metadata, so both Zod registrations and plain standard schemas keep their + * canonical validation while only the advertised JSON changes. + */ +export const transformAdvertisedInputJsonSchema = ( + schema: Schema, + transform: (projected: Record) => Record, +): Schema => { + const standard = Reflect.get(schema as object, "~standard"); + if (!isSchemaRecord(standard)) return schema; + const jsonSchema = Reflect.get(standard, "jsonSchema"); + if (!isSchemaRecord(jsonSchema)) return schema; + const project = Reflect.get(jsonSchema, "input"); + if (typeof project !== "function") return schema; + const byTarget = new Map>(); + const transformedInput = (options: unknown): unknown => { + const target = Reflect.get(options as object, "target"); + if (typeof target === "string") { + const cached = byTarget.get(target); + if (cached !== undefined) return cached; + } + const projected = (project as (options: unknown) => unknown)(options); + const transformed = isSchemaRecord(projected) + ? transform(projected) + : projected; + if (typeof target === "string" && isSchemaRecord(transformed)) + byTarget.set(target, transformed); + return transformed; + }; + return { + ...(schema as object), + "~standard": { + ...standard, + jsonSchema: { ...jsonSchema, input: transformedInput }, + }, + } as Schema; +}; + const hasRelativeSchemaReference = ( schema: Record, ): boolean => { @@ -164,6 +204,9 @@ const memoizeByTarget = ( }; }; +const isSchemaRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + const fallbackPropertyDescription = (property: string): string | undefined => { const explicit = Object.hasOwn(PROPERTY_DESCRIPTIONS, property) ? PROPERTY_DESCRIPTIONS[property] diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index a8449146e..90056c3b2 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -98,6 +98,7 @@ import type { ArtifactInventoryPartialObservation } from "./artifactPartialObser import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js"; import type { Evidence } from "./evidence.js"; import type { AndroidPartialObservation } from "./android/androidPartialObservation.js"; +import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = @@ -109,4 +110,5 @@ export type AnalysisPartialObservation = | JavaScriptRuntimeObservation | ArtifactInventoryPartialObservation | Evidence - | AndroidPartialObservation; + | AndroidPartialObservation + | FirmwarePartialObservation; diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 674425749..67b58499f 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -11,6 +11,7 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; +import { nativeRuntimeConvention } from "../nativeConvention.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const pathSchema = z.string().min(1); @@ -176,7 +177,6 @@ const classify = (all: readonly Component[]) => ({ }); const runtimeFamilies = (all: readonly Component[]) => { - const paths = all.map(({ path }) => path.toLowerCase()); const families = new Set< AndroidApplicationProjectionResult["runtime_families"][number] >(); @@ -187,15 +187,10 @@ const runtimeFamilies = (all: readonly Component[]) => { if (all.some(({ format }) => format === "elf")) families.add("native"); if (all.some(({ format }) => format === "javascript-bundle")) families.add("javascript"); - if ( - paths.some( - (path) => path.includes("reactnative") || path.includes("hermes"), - ) - ) - families.add("react-native"); - if (paths.some((path) => path.includes("libflutter.so"))) - families.add("flutter"); - if (paths.some((path) => path.includes("libunity.so"))) families.add("unity"); + for (const { path } of all) { + const convention = nativeRuntimeConvention(path); + if (convention !== null) families.add(convention); + } return [...families].sort(compare); }; @@ -216,12 +211,12 @@ const bridgeCandidates = ( const bridgeBasis = ( path: string, ): AndroidApplicationProjectionResult["bridge_candidates"][number]["basis"] => { - const lower = path.toLowerCase(); - if (lower.includes("react") || lower.includes("hermes")) - return "react-native-convention"; - if (lower.includes("flutter")) return "flutter-convention"; - if (lower.includes("unity")) return "unity-convention"; - if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(lower)) return "jni-library-convention"; + const convention = nativeRuntimeConvention(path); + if (convention === "react-native") return "react-native-convention"; + if (convention === "flutter") return "flutter-convention"; + if (convention === "unity") return "unity-convention"; + if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(path.toLowerCase())) + return "jni-library-convention"; return "managed-and-native-content"; }; diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 5c846611a..173334f2d 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -572,3 +572,80 @@ describe("Apple application bridge candidates", () => { }); }); }); + +describe("Apple path conventions", () => { + it("recognizes anatomy names by English case and reports the archive spelling", () => { + const result = project( + inventoryEvidence("directory", "Fixture.app", [ + { path: "contents/info.plist" }, + macho("contents/macos/Fixture"), + { path: "contents/_codesignature/coderesources" }, + ]), + ); + expect(result.bundles[0]).toMatchObject({ + layout: "macos-deep", + info_plist_path: "contents/info.plist", + executable_candidates: ["contents/macos/Fixture"], + signing_paths: ["contents/_codesignature/coderesources"], + }); + expect(result.components.bundle_metadata.map(({ path }) => path)).toEqual([ + "contents/info.plist", + ]); + expect(result.components.signing.map(({ path }) => path)).toEqual([ + "contents/_codesignature/coderesources", + ]); + }); + + it("limits runtime families and bridge bases to path segments", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + macho("Payload/Fixture.app/Fixture"), + macho("Payload/Fixture.app/Frameworks/libfoo.dylib"), + macho("SwiftSupport/libswiftCore.dylib"), + macho("Payload/Fixture.app/Frameworks/MyApp.framework/MyApp"), + macho("Payload/Fixture.app/community/libcommunity.so"), + { + path: "Payload/Fixture.app/main.js", + format: "javascript-bundle", + }, + ]), + ); + expect(result.runtime_families).toEqual( + expect.arrayContaining(["javascript", "native", "swift-objective-c"]), + ); + expect(result.runtime_families).not.toContain("flutter"); + expect(result.runtime_families).not.toContain("unity"); + expect(result.runtime_families).not.toContain("react-native"); + expect( + result.bridge_candidates.every( + ({ basis }) => basis === "javascript-and-native-content", + ), + ).toBe(true); + expect(result.limitations).toEqual( + expect.arrayContaining([ + "Runtime families are inferred from inventory formats and paths.", + "A bridge basis is inferred from the native path and is repeated for every managed component.", + ]), + ); + }); + + it("recognizes framework segments and does not treat a bare dylib as Swift", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + macho("Payload/Fixture.app/Fixture"), + macho("Payload/Fixture.app/Frameworks/Flutter.framework/Flutter"), + macho( + "Payload/Fixture.app/Frameworks/UnityFramework.framework/UnityFramework", + ), + macho("Payload/Fixture.app/Frameworks/hermes.framework/hermes"), + macho("Payload/Fixture.app/Frameworks/libfoo.dylib"), + ]), + ); + expect(result.runtime_families).toEqual( + expect.arrayContaining(["flutter", "unity", "react-native", "native"]), + ); + expect(result.runtime_families).not.toContain("swift-objective-c"); + }); +}); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 232ce2a36..79dff1c63 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -4,6 +4,8 @@ import canonicalize from "canonicalize"; import { z } from "zod"; import { + anatomyNameIs, + anatomyTailMatches, appleBundleSchema, appleComponentSchema, applicationRoots, @@ -14,6 +16,10 @@ import { platformsOf, type AppleInventoryEntry, } from "./appleBundleAnatomy.js"; +import { + isSwiftRuntimeLibrary, + nativeRuntimeConvention, +} from "../nativeConvention.js"; import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; import { evidenceSchema } from "../evidence.js"; import { digestSchema } from "../digests.js"; @@ -239,19 +245,20 @@ const classifyComponents = ( ) => { const withinApp = (path: string): boolean => roots.some((root) => isWithin(path, root)); - const anatomy = (pattern: RegExp): Component[] => + const matching = (predicate: (path: string) => boolean): Component[] => entries.flatMap(({ path, kind, component }) => kind === "file" && component !== undefined && !isSidecar(path) && withinApp(path) && - pattern.test(path) + predicate(path) ? [component] : [], ); + const lastSegment = (path: string): string => path.split("/").at(-1) ?? ""; return { bundle_metadata: all.filter(({ path }) => - /(?:^|\/)Info\.plist$/u.test(path), + anatomyNameIs(lastSegment(path), "Info.plist"), ), executables: all.filter( ({ path, format }) => withinApp(path) && isMachOFormat(format), @@ -259,23 +266,41 @@ const classifyComponents = ( frameworks: all.filter(({ path }) => /\.framework\//u.test(path)), native_libraries: all.filter(({ path }) => /\.(?:dylib|so)$/iu.test(path)), javascript: all.filter(({ format }) => format === "javascript-bundle"), - signing: all.filter(({ path }) => - /(?:^|\/)(?:embedded\.(?:mobileprovision|provisionprofile)|_CodeSignature\/CodeResources)$/u.test( - path, - ), + signing: all.filter( + ({ path }) => + anatomyTailMatches(path, ["_CodeSignature", "CodeResources"]) || + anatomyNameIs(lastSegment(path), "embedded.mobileprovision") || + anatomyNameIs(lastSegment(path), "embedded.provisionprofile"), ), - privileged_helpers: anatomy( - /(?:^|\/)Contents\/Library\/LaunchServices\/[^/]+$/u, + privileged_helpers: matching((path) => + anatomyTailMatches(path, ["Contents", "Library", "LaunchServices", "*"]), ), - launchd_plists: anatomy( - /(?:^|\/)Contents\/Library\/Launch(?:Agents|Daemons)\/[^/]+\.plist$/u, + launchd_plists: matching( + (path) => + path.endsWith(".plist") && + (anatomyTailMatches(path, [ + "Contents", + "Library", + "LaunchAgents", + "*", + ]) || + anatomyTailMatches(path, [ + "Contents", + "Library", + "LaunchDaemons", + "*", + ])), ).map((component) => ({ ...component, - domain: component.path.includes("/LaunchDaemons/") + domain: component.path + .split("/") + .some((segment) => anatomyNameIs(segment, "LaunchDaemons")) ? ("daemon" as const) : ("agent" as const), })), - helpers: anatomy(/(?:^|\/)Contents\/Helpers\/[^/]+$/u), + helpers: matching((path) => + anatomyTailMatches(path, ["Contents", "Helpers", "*"]), + ), }; }; @@ -285,7 +310,6 @@ const deduplicateComponents = (values: readonly Component[]): Component[] => ); const identifyRuntimeFamilies = (all: readonly Component[]) => { - const paths = all.map(({ path }) => path.toLowerCase()); const families = new Set< z.infer< typeof appleApplicationProjectionResultSchema @@ -293,28 +317,14 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => { >(); if (all.some(({ format }) => ["mach-o", "mach-o-universal"].includes(format))) families.add("native"); - if (paths.some((path) => /\.(?:dylib|framework\/[^/]+)$/u.test(path))) + if (all.some(({ path }) => isSwiftRuntimeLibrary(path))) families.add("swift-objective-c"); if (all.some(({ format }) => format === "javascript-bundle")) families.add("javascript"); - if ( - paths.some( - (path) => - path.includes("reactnative") || - path.includes("react.framework") || - path.includes("hermes"), - ) - ) - families.add("react-native"); - if ( - paths.some( - (path) => - path.includes("flutter.framework") || path.includes("app.framework"), - ) - ) - families.add("flutter"); - if (paths.some((path) => path.includes("unityframework.framework"))) - families.add("unity"); + for (const { path } of all) { + const convention = nativeRuntimeConvention(path); + if (convention !== null) families.add(convention); + } return [...families].sort(compare); }; @@ -361,11 +371,10 @@ const bridgeBasis = ( | "react-native-convention" | "flutter-convention" | "unity-convention" => { - const lower = path.toLowerCase(); - if (lower.includes("react")) return "react-native-convention"; - if (lower.includes("flutter") || lower.includes("app.framework")) - return "flutter-convention"; - if (lower.includes("unity")) return "unity-convention"; + const convention = nativeRuntimeConvention(path); + if (convention === "react-native") return "react-native-convention"; + if (convention === "flutter") return "flutter-convention"; + if (convention === "unity") return "unity-convention"; return "javascript-and-native-content"; }; @@ -422,7 +431,9 @@ const projectionLimitations = (facts: { : []), "Bundle roles follow path conventions. Read each info_plist_path with inspect_plist for CFBundleExecutable, identifiers, and declared services.", "Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.", + "Runtime families are inferred from inventory formats and paths.", "Bridge candidates are path-based hypotheses, not observed runtime calls.", + "A bridge basis is inferred from the native path and is repeated for every managed component.", ]; const compare = (left: string, right: string): number => diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index 224c5ff49..05eb9d454 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -67,6 +67,50 @@ export const isSidecar = (path: string): boolean => { export const isWithin = (path: string, root: string): boolean => root === "." || path === root || path.startsWith(`${root}/`); +/** + * English (en-US) case fold used only for Apple's fixed anatomy vocabulary. + * Reported paths keep the archive spelling. Inventory identity stays case-sensitive. + */ +export const anatomyNameIs = (actual: string, expected: string): boolean => + actual.toLocaleLowerCase("en-US") === expected.toLocaleLowerCase("en-US"); + +/** Directory entry that folds to one vocabulary word. The exact spelling wins. */ +export const anatomyChild = ( + names: ReadonlySet | undefined, + expected: string, +): string | null => { + if (names === undefined) return null; + let exact: string | undefined; + let folded: string | undefined; + for (const name of names) { + if (name === expected) exact = name; + else if ( + anatomyNameIs(name, expected) && + (folded === undefined || compare(name, folded) < 0) + ) + folded = name; + } + return exact ?? folded ?? null; +}; + +/** + * Whether `path`'s tail matches anatomy vocabulary. `*` matches one segment + * that is not itself a vocabulary comparison. + */ +export const anatomyTailMatches = ( + path: string, + expected: readonly string[], +): boolean => { + const actual = path.split("/"); + if (actual.length < expected.length) return false; + const tail = actual.slice(-expected.length); + return expected.every((name, index) => { + const segment = tail[index]; + if (segment === undefined || segment.length === 0) return false; + return name === "*" || anatomyNameIs(segment, name); + }); +}; + /** * Outermost iOS (`Payload/X.app`) and macOS (`X.app/Contents/…`) applications. * An inventoried `.app` directory is its own root, `.`. @@ -82,15 +126,20 @@ export const applicationRoots = ( const found = new Set(); for (const { path } of entries) { if (isSidecar(path)) continue; - const ios = /^(Payload\/[^/]+\.app)(?:\/|$)/u.exec(path); - if (ios?.[1] !== undefined) { - found.add(ios[1]); + const segments = path.split("/"); + const app = segments[1]; + if ( + anatomyNameIs(segments[0] ?? "", "Payload") && + app !== undefined && + /\.app$/iu.test(app) + ) { + found.add(`${segments[0]}/${app}`); continue; } - const segments = path.split("/"); const index = segments.findIndex( (segment, position) => - /\.app$/iu.test(segment) && segments[position + 1] === "Contents", + /\.app$/iu.test(segment) && + anatomyNameIs(segments[position + 1] ?? "", "Contents"), ); if (index >= 0) found.add(segments.slice(0, index + 1).join("/")); } @@ -186,8 +235,8 @@ const enclosingBundle = ( const bundleLayout = (bundle: string, tree: Tree): Bundle["layout"] => { const names = tree.children.get(directoryOf(bundle)); - if (names?.has("Contents") === true) return "macos-deep"; - if (names?.has("Versions") === true) return "versioned-framework"; + if (anatomyChild(names, "Contents") !== null) return "macos-deep"; + if (anatomyChild(names, "Versions") !== null) return "versioned-framework"; return "shallow"; }; @@ -201,13 +250,18 @@ const bundleRole = (bundle: string): Bundle["role"] => { if (name.endsWith(".systemextension")) return "system-extension"; if (name.endsWith(".dext")) return "driver-extension"; if (name.endsWith(".bundle")) { - if (container === "PlugIns") return "plug-in"; - return container === "Resources" ? "resource-bundle" : "bundle"; + if (anatomyNameIs(container ?? "", "PlugIns")) return "plug-in"; + return anatomyNameIs(container ?? "", "Resources") + ? "resource-bundle" + : "bundle"; } if (!name.endsWith(".app")) return "plug-in"; - if (container === "LoginItems" && segments.at(-3) === "Library") + if ( + anatomyNameIs(container ?? "", "LoginItems") && + anatomyNameIs(segments.at(-3) ?? "", "Library") + ) return "login-item"; - if (container === "Helpers") return "helper-application"; + if (anatomyNameIs(container ?? "", "Helpers")) return "helper-application"; return "nested-application"; }; @@ -225,18 +279,32 @@ const contentDirectories = ( tree: Tree, ): string[] => { const directory = directoryOf(bundle); - if (layout === "macos-deep") return [joinPath(directory, "Contents")]; + if (layout === "macos-deep") { + const contents = namedChild(tree, directory, "Contents"); + return contents === null ? [] : [contents]; + } if (layout === "shallow") return [directory]; - const versions = joinPath(directory, "Versions"); + const versions = namedChild(tree, directory, "Versions"); + if (versions === null) return []; // Only real version directories count: Current is a symlink, and files // such as .DS_Store are not versions. return [...(tree.children.get(versions) ?? [])] - .filter((version) => version !== "Current") + .filter((version) => !anatomyNameIs(version, "Current")) .map((version) => joinPath(versions, version)) .filter((version) => tree.directories.has(version)) .sort(compare); }; +/** Child path using the archive's spelling of one anatomy name. */ +const namedChild = ( + tree: Tree, + directory: string, + expected: string, +): string | null => { + const name = anatomyChild(tree.children.get(directory), expected); + return name === null ? null : joinPath(directory, name); +}; + const infoPlistPath = ( bundle: string, layout: Bundle["layout"], @@ -246,17 +314,15 @@ const infoPlistPath = ( // With several real versions, Versions/Current (a symlink outside the // inventory) decides which plist applies, so the plist stays unknown. if (directories.length !== 1) return null; - const candidates = directories - .map((directory) => - joinPath( - directory, - layout === "versioned-framework" - ? "Resources/Info.plist" - : "Info.plist", - ), - ) - .filter((path) => tree.files.has(path)); - return candidates.length === 1 ? (candidates[0] ?? null) : null; + const directory = directories[0]; + if (directory === undefined) return null; + const parent = + layout === "versioned-framework" + ? namedChild(tree, directory, "Resources") + : directory; + if (parent === null) return null; + const info = namedChild(tree, parent, "Info.plist"); + return info !== null && tree.files.has(info) ? info : null; }; const executableCandidates = ( @@ -265,12 +331,15 @@ const executableCandidates = ( tree: Tree, ): string[] => contentDirectories(bundle, layout, tree) - .flatMap((directory) => - directFiles( - layout === "macos-deep" ? joinPath(directory, "MacOS") : directory, - tree, - ), - ) + .flatMap((directory) => { + const executableDirectory = + layout === "macos-deep" + ? namedChild(tree, directory, "MacOS") + : directory; + return executableDirectory === null + ? [] + : directFiles(executableDirectory, tree); + }) .filter((path) => MACH_O_FORMATS.includes(tree.files.get(path)?.format ?? ""), ); @@ -281,12 +350,17 @@ const signingPaths = ( tree: Tree, ): string[] => contentDirectories(bundle, layout, tree) - .flatMap((directory) => [ - joinPath(directory, "_CodeSignature/CodeResources"), - joinPath(directory, "embedded.provisionprofile"), - joinPath(directory, "embedded.mobileprovision"), - ]) - .filter((path) => tree.files.has(path)) + .flatMap((directory) => { + const signature = namedChild(tree, directory, "_CodeSignature"); + return [ + signature === null + ? null + : namedChild(tree, signature, "CodeResources"), + namedChild(tree, directory, "embedded.provisionprofile"), + namedChild(tree, directory, "embedded.mobileprovision"), + ]; + }) + .filter((path): path is string => path !== null && tree.files.has(path)) .sort(compare); /** Platforms of application roots whose layout was observed, not assumed. */ diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 04bc33ec9..7cdd419d2 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -13,6 +13,7 @@ import { } from "./artifactInventoryEvidence.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJson } from "./comparisonSemantics.js"; +import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const comparisonStatusSchema = z.enum([ @@ -122,6 +123,11 @@ export const compareArtifacts = ( ], leftCovered && rightCovered, ); + const rootChange = rootHashChange(left.inventory, right.inventory, changes, [ + ...left.evidence.map(({ evidence_id: id }) => id), + ...right.evidence.map(({ evidence_id: id }) => id), + ]); + if (rootChange !== null) changes.push(rootChange); const summary = summarize( left.inventory, right.inventory, @@ -431,5 +437,38 @@ const summarize = ( const hasCoverageLimitation = (limitations: readonly string[]): boolean => limitations.some( (limitation) => + limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION && !/integrity contradiction\(s\) were recorded/u.test(limitation), ); + +const rootHashChange = ( + left: InventorySet, + right: InventorySet, + changes: readonly ArtifactChange[], + evidenceLinks: readonly string[], +): ArtifactChange | null => { + if (left.manifest.root_sha256 === right.manifest.root_sha256) return null; + if ( + changes.some( + (change) => + change.logical_path === "." && change.dimensions.includes("content"), + ) + ) + return null; + const leftRoot = left.occurrences.find( + (occurrence) => occurrence.logical_path === ".", + ); + const rightRoot = right.occurrences.find( + (occurrence) => occurrence.logical_path === ".", + ); + return { + classification: "changed", + logical_path: ".", + dimensions: ["content"], + left_occurrence_id: leftRoot?.occurrence_id ?? null, + right_occurrence_id: rightRoot?.occurrence_id ?? null, + left_artifact_id: leftRoot?.artifact_id ?? null, + right_artifact_id: rightRoot?.artifact_id ?? null, + evidence_links: [...evidenceLinks], + }; +}; diff --git a/src/domain/artifactIdentity.ts b/src/domain/artifactIdentity.ts index 3c46a3b97..3107d9af0 100644 --- a/src/domain/artifactIdentity.ts +++ b/src/domain/artifactIdentity.ts @@ -1,3 +1,5 @@ +import { posix } from "node:path"; + import { digestCanonicalValue } from "./canonicalDigest.js"; import type { ArtifactEdge, @@ -6,6 +8,29 @@ import type { IntegrityContradiction, } from "./artifactGraph.js"; +/** NFC spelling of a logical archive path. Inventory and path joins share it. */ +export const logicalPathNfc = (value: string): string => value.normalize("NFC"); + +/** + * Lexical join against an inventoried logical path. The result is NFC. + * `"."` is the archive root. A path that escapes the root, or that contains + * NUL, is left in a form the caller can report; `logicalPathEscapesRoot` + * is then true. + */ +export const normalizeJoinedLogicalPath = (value: string): string => { + if (value.includes("\0")) return value; + return logicalPathNfc( + posix.normalize(value.replaceAll("\\", "/")).replace(/^\.\//u, ""), + ); +}; + +/** Whether a joined logical path leaves the archive root. */ +export const logicalPathEscapesRoot = (path: string): boolean => + path.includes("\0") || + path === ".." || + path.startsWith("../") || + path.startsWith("/"); + /** Content-address an artifact independently of where it was observed. */ export const artifactIdForContent = (sha256: string): string => `art_${digestCanonicalValue({ sha256 }, "Artifact")}`; diff --git a/src/domain/artifactPathSyntax.ts b/src/domain/artifactPathSyntax.ts index 5641da1b7..4884bd646 100644 --- a/src/domain/artifactPathSyntax.ts +++ b/src/domain/artifactPathSyntax.ts @@ -6,7 +6,7 @@ * Policy divergence (deliberate, not yet unified): * - Local artifact paths resolve against an inventoried container root and * report rejected | external | not-found. Confinement is lexical - * (posix.normalize must stay inside the root). + * (normalizeJoinedLogicalPath must stay inside the root). * - Live DOM destinations resolve against the document base URL and report * approved | outside_policy | unsupported against allowedOrigins * (see CdpCaptureDocuments.domDestination). diff --git a/src/domain/firmware/firmwareAnalysis.ts b/src/domain/firmware/firmwareAnalysis.ts index 00f6675d0..a7cdb3550 100644 --- a/src/domain/firmware/firmwareAnalysis.ts +++ b/src/domain/firmware/firmwareAnalysis.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { isAbsolute } from "node:path"; import { digestSchema } from "../digests.js"; -import { jsonValueSchema } from "../jsonValue.js"; +import { jsonValueSchema, type JsonValue } from "../jsonValue.js"; import { localPathStringSchema } from "../localPath.js"; const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); @@ -95,6 +95,12 @@ const chunk = z.strictObject({ root_file_range: range.nullable(), }); +/** Completed firmware result retained when private-runtime cleanup cannot be confirmed. */ +export interface FirmwarePartialObservation { + readonly kind: "firmware"; + readonly result: JsonValue; +} + /** Portable results retain reported boundaries, unknowns and extraction lineage. */ export const firmwareResultSchemas = { inspect_firmware_regions: z.strictObject({ diff --git a/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts b/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts index c96ccece7..a460de077 100644 --- a/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts @@ -11,11 +11,13 @@ import { createJavaScriptApplicationGraph, createImmutableJavaScriptApplicationGraphSteps, isValidatedImmutableJavaScriptApplicationGraph, + sealTransferredJavaScriptApplicationGraphSteps, } from "./javascriptApplicationGraph.js"; import { createJavaScriptSemanticGraph, createImmutableJavaScriptSemanticGraphSteps, isValidatedImmutableJavaScriptSemanticGraph, + sealTransferredJavaScriptSemanticGraphSteps, } from "./javascriptSemanticGraph.js"; const complete = (steps: Iterator): Value => { @@ -265,3 +267,60 @@ describe("owned JavaScript result bindings", () => { ); }); }); + +describe("graphs transferred to their sealing steps", () => { + it("seal the same validated graphs as the copying factories", () => { + const input = example(); + const { graph_id: _applicationId, ...application } = input.graph; + const { graph_id: _semanticId, ...semantic } = input.semantic_graph; + const expectedApplication = createJavaScriptApplicationGraph(application); + const expectedSemantic = createJavaScriptSemanticGraph(semantic); + const sealedApplication = complete( + sealTransferredJavaScriptApplicationGraphSteps( + structuredClone(application), + ), + ); + const sealedSemantic = complete( + sealTransferredJavaScriptSemanticGraphSteps(structuredClone(semantic)), + ); + expect(sealedApplication).toEqual(expectedApplication); + expect(sealedSemantic).toEqual(expectedSemantic); + expect( + isValidatedImmutableJavaScriptApplicationGraph(sealedApplication), + ).toBe(true); + expect(isValidatedImmutableJavaScriptSemanticGraph(sealedSemantic)).toBe( + true, + ); + }); + + it("report the issues a whole-graph parse reports", () => { + const input = example(); + const { graph_id: _applicationId, ...application } = input.graph; + const { graph_id: _semanticId, ...semantic } = input.semantic_graph; + const node = semantic.nodes[0]; + const applicationNode = application.nodes[0]; + if (node === undefined || applicationNode === undefined) + throw new Error("Expected semantic and application nodes"); + const malformed = { + ...semantic, + nodes: [{ ...node, label: 7 }, ...semantic.nodes.slice(1)], + }; + expect( + issuesFrom(() => + complete(sealTransferredJavaScriptSemanticGraphSteps(malformed)), + ), + ).toEqual(issuesFrom(() => createJavaScriptSemanticGraph(malformed))); + const stale = { + ...application, + nodes: [ + { ...applicationNode, node_id: `jag_node_${"f".repeat(64)}` }, + ...application.nodes.slice(1), + ], + }; + expect( + issuesFrom(() => + complete(sealTransferredJavaScriptApplicationGraphSteps(stale)), + ), + ).toEqual(issuesFrom(() => createJavaScriptApplicationGraph(stale))); + }); +}); diff --git a/src/domain/javascript/javascriptApplicationGraph.ts b/src/domain/javascript/javascriptApplicationGraph.ts index e16aded75..ea84585f1 100644 --- a/src/domain/javascript/javascriptApplicationGraph.ts +++ b/src/domain/javascript/javascriptApplicationGraph.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + arrayContainerSchema, + parseElementSteps, +} from "../zodCollectionSteps.js"; +import { canonicalJsonDigestSteps } from "../canonicalJsonDigestSteps.js"; import { canonicalJson } from "../comparisonSemantics.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { uniqueSorted } from "../canonicalOrdering.js"; @@ -93,11 +98,14 @@ type EdgeSemantic = Omit; const edgeId = (edge: EdgeSemantic): string => `jag_edge_${digestCanonicalValue(edge, "JavaScript Application Graph")}`; +/** The issue sink shared by schema refinement and stepwise checking. */ +type GraphIssueContext = Pick; + const sortedUniqueIssue = ( values: readonly string[], path: PropertyKey[], label: string, - context: z.RefinementCtx, + context: GraphIssueContext, ): void => { for (let index = 1; index < values.length; index += 1) { if ( @@ -116,7 +124,7 @@ const sortedUniqueIssue = ( const checkEvidenceNormalization = ( evidence: ApplicationGraphEvidence, path: PropertyKey[], - context: z.RefinementCtx, + context: GraphIssueContext, ): void => { sortedUniqueIssue( evidence.limitations, @@ -226,7 +234,7 @@ const observationMatchesIdentity = ( const checkNode = ( node: ApplicationNode, index: number, - context: z.RefinementCtx, + context: GraphIssueContext, ): void => { if (node.node_id !== nodeId(node)) context.addIssue({ @@ -306,8 +314,16 @@ type GraphRecord = z.infer; const checkGraphContent = ( graph: JavaScriptApplicationGraphInput, - context: z.RefinementCtx, + context: GraphIssueContext, ): void => { + const steps = checkGraphContentSteps(graph, context); + while (steps.next().done !== true); +}; + +function* checkGraphContentSteps( + graph: JavaScriptApplicationGraphInput, + context: GraphIssueContext, +): Generator { sortedUniqueIssue( graph.root_node_ids, ["root_node_ids"], @@ -353,9 +369,12 @@ const checkGraphContent = ( message: "Root identifier must name a graph node", path: ["root_node_ids"], }); - for (const [index, node] of graph.nodes.entries()) + for (const [index, node] of graph.nodes.entries()) { + if (index % 64 === 0) yield; checkNode(node, index, context); + } for (const [index, edge] of graph.edges.entries()) { + if (index % 64 === 0) yield; const { edge_id: identifier, ...semantic } = edge; if (identifier !== edgeId(semantic)) context.addIssue({ @@ -394,11 +413,11 @@ const checkGraphContent = ( context, ); } -}; +} const checkGraphInvariants = ( graph: GraphRecord, - context: z.RefinementCtx, + context: GraphIssueContext, ): void => { checkGraphContent(graph, context); const { graph_id: identifier, ...semantic } = graph; @@ -475,22 +494,9 @@ export const createJavaScriptApplicationEdge = ( export const createJavaScriptApplicationGraph = ( input: unknown, ): JavaScriptApplicationGraph => { - const parsed = javascriptApplicationGraphInputSchema.parse(input); - const semantic: JavaScriptApplicationGraphInput = { - ...parsed, - root_node_ids: uniqueSorted(parsed.root_node_ids), - nodes: [...parsed.nodes].sort((left, right) => - compareUnicodeCodePoints(left.node_id, right.node_id), - ), - edges: [...parsed.edges].sort((left, right) => - compareUnicodeCodePoints(left.edge_id, right.edge_id), - ), - coverage: { - ...parsed.coverage, - limits: normalizeLimits(parsed.coverage.limits), - }, - limitations: uniqueSorted(parsed.limitations), - }; + const semantic = normalizeGraphInput( + javascriptApplicationGraphInputSchema.parse(input), + ); const content = javascriptApplicationGraphContentSchema.parse(semantic); return { ...content, @@ -498,6 +504,24 @@ export const createJavaScriptApplicationGraph = ( }; }; +const normalizeGraphInput = ( + parsed: JavaScriptApplicationGraphInput, +): JavaScriptApplicationGraphInput => ({ + ...parsed, + root_node_ids: uniqueSorted(parsed.root_node_ids), + nodes: [...parsed.nodes].sort((left, right) => + compareUnicodeCodePoints(left.node_id, right.node_id), + ), + edges: [...parsed.edges].sort((left, right) => + compareUnicodeCodePoints(left.edge_id, right.edge_id), + ), + coverage: { + ...parsed.coverage, + limits: normalizeLimits(parsed.coverage.limits), + }, + limitations: uniqueSorted(parsed.limitations), +}); + const validatedImmutableApplicationGraphs = new WeakSet(); /** Clone and validate synchronously, then return steps to seal the owned graph. */ @@ -506,6 +530,65 @@ export const createImmutableJavaScriptApplicationGraphSteps = ( ): Generator => sealValidatedApplicationGraphSteps(createJavaScriptApplicationGraph(input)); +const applicationGraphShape = javascriptApplicationGraphInputSchema.shape; +const applicationGraphContainerSchema = + javascriptApplicationGraphInputSchema.extend({ + nodes: arrayContainerSchema(applicationGraphShape.nodes), + edges: arrayContainerSchema(applicationGraphShape.edges), + }); + +/** + * Seal a graph whose input the caller transfers: the caller neither retains nor + * mutates any part of it afterwards. Only the graph's own fields are validated + * synchronously; nodes and edges are validated, checked, hashed and frozen in + * steps, because doing so at once held the event loop for large applications. + */ +export const sealTransferredJavaScriptApplicationGraphSteps = ( + input: unknown, +): Generator => + parseAndSealApplicationGraphSteps( + applicationGraphContainerSchema.parse(input), + ); + +function* parseAndSealApplicationGraphSteps( + container: z.output, +): Generator { + const issues: z.core.$ZodIssue[] = []; + const parsed: JavaScriptApplicationGraphInput = { + ...container, + nodes: yield* parseElementSteps( + applicationGraphShape.nodes.element, + container.nodes, + ["nodes"], + issues, + ), + edges: yield* parseElementSteps( + applicationGraphShape.edges.element, + container.edges, + ["edges"], + issues, + ), + }; + if (issues.length > 0) throw new z.ZodError(issues); + const content = normalizeGraphInput(parsed); + let invalid = false; + yield* checkGraphContentSteps(content, { + addIssue: () => { + invalid = true; + }, + }); + // An invalid owned graph is a construction defect; report it with the exact + // issues the content schema produces. + if (invalid) { + javascriptApplicationGraphContentSchema.parse(content); + throw new TypeError("Application graph checks disagree with its schema"); + } + return yield* sealValidatedApplicationGraphSteps({ + ...content, + graph_id: `jag_${yield* canonicalJsonDigestSteps(content)}`, + }); +} + function* sealValidatedApplicationGraphSteps( graph: JavaScriptApplicationGraph, ): Generator { diff --git a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts index 64a47d050..1f02c820d 100644 --- a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts +++ b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts @@ -2,6 +2,10 @@ import { posix, relative, win32 } from "node:path"; import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; +import { + logicalPathEscapesRoot, + normalizeJoinedLogicalPath, +} from "../artifactIdentity.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import type { ParsedStaticLayer } from "./javascriptRuntimeReconciliationParsing.js"; @@ -174,7 +178,7 @@ const nodePaths = (node: ApplicationNode): string[] => { paths.push(observation.evidence.location.value.path); } return paths - .map(normalizeArtifactPath) + .map(relativeLogicalPath) .filter( (path): path is string => path !== null && path !== "artifact-root", ); @@ -221,7 +225,7 @@ const pathBelowRoot = (root: string, value: string): string | null => { portableRemainder.startsWith("../") ) return null; - return normalizeArtifactPath(portableRemainder); + return relativeLogicalPath(portableRemainder); }; const absoluteFilePathStyle = (path: string): "posix" | "windows" | null => { @@ -250,7 +254,7 @@ const pathBelowUrlPrefix = (prefix: string, value: string): string | null => { ) return null; try { - return normalizeArtifactPath( + return relativeLogicalPath( decodeURIComponent(candidate.pathname.slice(base.pathname.length)), ); } catch (cause: unknown) { @@ -292,17 +296,13 @@ const pathWithinPrefix = (path: string, prefix: string): boolean => prefix === "" || path === prefix || path.startsWith(`${prefix}/`); const safeArtifactPath = (prefix: string, suffix: string): string | null => - normalizeArtifactPath(prefix === "" ? suffix : posix.join(prefix, suffix)); + relativeLogicalPath(prefix === "" ? suffix : posix.join(prefix, suffix)); -const normalizeArtifactPath = (value: string): string | null => { - const normalized = posix - .normalize(value.replaceAll("\\", "/")) - .replace(/^\.\//u, ""); +const relativeLogicalPath = (value: string): string | null => { + const normalized = normalizeJoinedLogicalPath(value); return normalized !== "" && normalized !== "." && - normalized !== ".." && - !normalized.startsWith("/") && - !normalized.startsWith("../") + !logicalPathEscapesRoot(normalized) ? normalized : null; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index 0538dffe8..1104d9fa7 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -597,11 +597,9 @@ describe("property mutation collection on minified bundles", () => { { length: 2000 }, (_, index) => `source.p${index} = ${index};`, ).join(" "); - const start = performance.now(); const value = resultValue( `const source = { token: "TOKEN" }; ${writes} return [source.token, source.p1999];`, ); - expect(performance.now() - start).toBeLessThan(2000); expect(value).toMatchObject({ status: "array", items: [ @@ -662,3 +660,39 @@ describe("results of methods on an escaped receiver", () => { }); }, 30000); }); + +describe("results of methods on an escaped receiver", () => { + it("makes every method's distinct result uncertain, not only the called one", () => { + expect( + resultValue( + 'const first = { t: "A" }; const second = { t: "B" }; const kept = { t: "C" }; const box = { a() { return first; }, b() { return second; }, c() { return first; } }; box.a(); return [first.t, second.t, kept.t];', + ), + ).toMatchObject({ + status: "array", + items: [ + { value: { status: "unknown" } }, + { value: { status: "unknown" } }, + { value: { status: "literal", value: "C" } }, + ], + }); + }); + + it("keeps a shared result uncertain across a wide escaped receiver (#1495)", () => { + const count = 2000; + const methods = Array.from( + { length: count }, + (_, index) => `m${index}() { return shared; },`, + ).join(" "); + const calls = Array.from( + { length: count }, + (_, index) => `box.m${index}().p${index} = ${index};`, + ).join(" "); + const value = resultValue( + `const shared = { token: "TOKEN" }; const box = { ${methods} }; ${calls} return [shared.token];`, + ); + expect(value).toMatchObject({ + status: "array", + items: [{ value: { status: "unknown" } }], + }); + }, 30000); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 4b5ad0f72..50af0f2cb 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -38,7 +38,7 @@ import { collectSemanticReturns, resolveSemanticModuleCallables, } from "./javascriptSemanticReturns.js"; -import { collectJavaScriptDerivedSemantics } from "./javascriptSemanticDerivedAnalysis.js"; +import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName } from "./javascriptAstValues.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; @@ -107,18 +107,40 @@ export const analyzeParsedJavaScriptReferences = ( export const analyzeParsedJavaScriptSemantics = ( file: ParsedJavaScriptSource, ): JavaScriptSemanticIr => { + const steps = analyzeParsedJavaScriptSemanticsSteps(file); + for (;;) { + const step = steps.next(); + if (step.done === true) return step.value; + } +}; + +/** + * Recover semantics from a parsed artifact with a yield between analysis + * phases, so a caller can serve control messages within one large file. + * Each phase completes before the next starts; results equal the synchronous + * form. + */ +export function* analyzeParsedJavaScriptSemanticsSteps( + file: ParsedJavaScriptSource, +): Generator { const state = createState(file.program); collectDefinitions(file.program, state); + yield; collectSemanticMemberMutations(file.program, state); + yield; traverseJavaScriptAst(file.program, { enter: (node) => collectSemanticModuleLink(node, state), }); + yield; const references = collectSemanticReferences(file.program, state); + yield; const parserPartial = file.errors.length > 0; const bindings = immutableSemanticBindings(state); + yield; const callables = collectSemanticReturns(file.program, state, parserPartial); + yield; const moduleLinks = resolveSemanticModuleCallables(state, callables); - const derived = collectJavaScriptDerivedSemantics( + const derived = yield* collectJavaScriptDerivedSemanticsSteps( file.program, state, callables, @@ -154,7 +176,7 @@ export const analyzeParsedJavaScriptSemantics = ( "Cross-function mutation and dynamic property resolution remain unknown.", ], }; -}; +} /** Receiver identity facts for the overloaded `.open` syntax only. */ export type JavaScriptOpenReceiverFact = diff --git a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts new file mode 100644 index 000000000..9aaa71d93 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; + +import { + analyzeParsedJavaScriptSemantics, + analyzeParsedJavaScriptSemanticsSteps, +} from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const source = ` +import { readFile } from "node:fs"; +const shared = { token: "TOKEN" }; +export function load(path) { + return new Promise((resolve) => setTimeout(() => resolve(readFile(path)), 1)); +} +export const box = { get() { return shared; } }; +box.get().value = process.env.MODE ?? "default"; +`; + +it("yields between semantic phases and returns the synchronous result (#1462)", () => { + const parsed = parseJavaScriptSource(source, "main.js"); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = analyzeParsedJavaScriptSemanticsSteps(parsed); + let yields = 0; + let step = steps.next(); + while (step.done !== true) { + yields += 1; + step = steps.next(); + } + // Every top-level phase and derived collector is a separate step. + expect(yields).toBeGreaterThanOrEqual(13); + const reparsed = parseJavaScriptSource(source, "main.js"); + if (reparsed === null) throw new Error("Expected parsed source"); + expect(step.value).toEqual(analyzeParsedJavaScriptSemantics(reparsed)); +}); diff --git a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts index d3e630ac9..545652a74 100644 --- a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts @@ -36,43 +36,54 @@ type DerivedSemanticAnalysis = Pick< >; /** Compose bounded semantic passes after lexical definitions are available. */ -export const collectJavaScriptDerivedSemantics = ( +/** + * Collect derived semantics with a yield between collectors. Each collector + * completes before the next starts, so results equal the synchronous form. + */ +export function* collectJavaScriptDerivedSemanticsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], parserPartial: boolean, -): DerivedSemanticAnalysis => { +): Generator { const calls = collectJavaScriptSemanticCalls(program, state, callables); + yield; const promiseOperations = collectJavaScriptSemanticPromises( program, state, callables, ); + yield; const asyncEffects = collectJavaScriptSemanticAsyncEffects( program, state, callables, ); + yield; const childProcesses = collectJavaScriptSemanticChildProcesses( program, state, callables, ); + yield; const dataEffects = collectJavaScriptSemanticDataEffects( program, state, callables, ); + yield; const resourceOperations = collectJavaScriptSemanticResources( program, state, callables, ); + yield; const objectOperations = collectJavaScriptSemanticObjects( program, state, callables, ); + yield; const functionFingerprints = collectJavaScriptSemanticFingerprints({ state, callables, @@ -96,4 +107,4 @@ export const collectJavaScriptDerivedSemantics = ( objectOperations, functionFingerprints, }; -}; +} diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index 9ab2dd485..6908af2a1 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -6,6 +6,10 @@ import { uniqueSorted } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { canonicalJsonDigestSteps } from "../canonicalJsonDigestSteps.js"; import { freezeOwnedJsonSnapshotSteps } from "../immutableJson.js"; +import { + arrayContainerSchema, + parseElementSteps, +} from "../zodCollectionSteps.js"; import type { ApplicationGraphEvidence, ApplicationGraphEvidenceContext, @@ -765,6 +769,15 @@ export const createJavaScriptSemanticGraph = ( const validatedImmutableSemanticGraphs = new WeakSet(); +const semanticGraphShape = javaScriptSemanticGraphInputSchema.shape; +const semanticGraphContainerSchema = javaScriptSemanticGraphInputSchema.extend({ + evidence_contexts: arrayContainerSchema(semanticGraphShape.evidence_contexts), + nodes: arrayContainerSchema(semanticGraphShape.nodes), + relations: arrayContainerSchema(semanticGraphShape.relations), + fingerprints: arrayContainerSchema(semanticGraphShape.fingerprints), + unknowns: arrayContainerSchema(semanticGraphShape.unknowns), +}); + /** Clone input synchronously; commit, check and seal the owned graph in steps. */ export const createImmutableJavaScriptSemanticGraphSteps = ( input: unknown, @@ -773,6 +786,58 @@ export const createImmutableJavaScriptSemanticGraphSteps = ( normalizeGraphInput(javaScriptSemanticGraphInputSchema.parse(input)), ); +/** + * Seal a graph whose input the caller transfers: the caller neither retains nor + * mutates any part of it afterwards. Only the graph's own fields are validated + * synchronously; collection elements are validated and cloned in steps, because + * a whole-graph parse of a large application held the event loop for seconds. + */ +export const sealTransferredJavaScriptSemanticGraphSteps = ( + input: unknown, +): Generator => + parseAndSealSemanticGraphSteps(semanticGraphContainerSchema.parse(input)); + +function* parseAndSealSemanticGraphSteps( + container: z.output, +): Generator { + const issues: z.core.$ZodIssue[] = []; + const parsed: JavaScriptSemanticGraphInput = { + ...container, + evidence_contexts: yield* parseElementSteps( + semanticGraphShape.evidence_contexts.element, + container.evidence_contexts, + ["evidence_contexts"], + issues, + ), + nodes: yield* parseElementSteps( + semanticGraphShape.nodes.element, + container.nodes, + ["nodes"], + issues, + ), + relations: yield* parseElementSteps( + semanticGraphShape.relations.element, + container.relations, + ["relations"], + issues, + ), + fingerprints: yield* parseElementSteps( + semanticGraphShape.fingerprints.element, + container.fingerprints, + ["fingerprints"], + issues, + ), + unknowns: yield* parseElementSteps( + semanticGraphShape.unknowns.element, + container.unknowns, + ["unknowns"], + issues, + ), + }; + if (issues.length > 0) throw new z.ZodError(issues); + return yield* validateAndSealSemanticGraphSteps(normalizeGraphInput(parsed)); +} + function* validateAndSealSemanticGraphSteps( semantic: JavaScriptSemanticGraphInput, ): Generator { diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 4c0dfc91d..06a335bca 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -251,19 +251,9 @@ export const collectSemanticMemberMutations = ( originAt: current.originAt, }); } else { - if ( - t.isArrayExpression(expression) && - ((current.path[0] === "__proto__" && - (effect === "escape" || current.path.length > 1)) || - (current.path[0] === "constructor" && - current.path[1] === "prototype")) - ) - arrayIterationUnknown = true; - for (const value of referencedValues( - expression, - current.path, - effect, - )) { + for (const value of effect === "escape" + ? escapedReferenceLeaves(expression, current.path) + : expandedValues(expression, current.path, effect)) { if (value.iterableFallbackPath !== undefined) deferIterable({ node: value.node, @@ -290,6 +280,78 @@ export const collectSemanticMemberMutations = ( mutation: t.Node, path: PropertyPath = [], ): void => markValue(node, path, new Set(), "escape", mutation); + function expandedValues( + expression: t.Node, + path: PropertyPath, + effect: ValueEffect, + ): readonly ReferencedValue[] { + if ( + t.isArrayExpression(expression) && + ((path[0] === "__proto__" && (effect === "escape" || path.length > 1)) || + (path[0] === "constructor" && path[1] === "prototype")) + ) + arrayIterationUnknown = true; + return referencedValues(expression, path, effect); + } + // Expanding a value below its references depends only on syntax, not on the + // escape site. Escaping an object reaches every method's results, so + // re-expanding it at each call through that object made N calls over N + // methods quadratic. Identifiers that resolve to the same binding and path + // are processed identically, so one stands for all of them. + const escapedLeaves = new WeakMap< + t.Node, + Map + >(); + function escapedReferenceLeaves( + root: t.Node, + rootPath: PropertyPath, + ): readonly ReferencedValue[] { + // Parser recovery can leave an argument hole, which expands to nothing. + if (!t.isNode(root)) return []; + const rootKey = JSON.stringify(rootPath); + const cached = escapedLeaves.get(root)?.get(rootKey); + if (cached !== undefined) return cached; + const leaves: ReferencedValue[] = []; + const leafIdentities = new Set(); + const expanded = new WeakMap>(); + const pending = [...expandedValues(root, rootPath, "escape")]; + for (let cursor = 0; cursor < pending.length; cursor++) { + const value = pending[cursor]; + if (value === undefined) break; + const node = unwrapJavaScriptExpression(value.node).node; + if (!t.isNode(node)) continue; + const pathKey = JSON.stringify(value.path); + if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) { + const binding = resolveSemanticBindingState(state, node, node.name); + const identity = JSON.stringify([ + binding === undefined ? null : binding.bindingId, + binding === undefined ? node.name : null, + pathKey, + ]); + if (leafIdentities.has(identity)) continue; + leafIdentities.add(identity); + leaves.push(value); + } else if ( + value.iterableFallbackPath !== undefined || + t.isIdentifier(node) || + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) + leaves.push(value); + else { + const paths = expanded.get(node) ?? new Set(); + if (paths.has(pathKey)) continue; + paths.add(pathKey); + expanded.set(node, paths); + for (const reference of expandedValues(node, value.path, "escape")) + pending.push(reference); + } + } + const byPath = escapedLeaves.get(root) ?? new Map(); + byPath.set(rootKey, leaves); + escapedLeaves.set(root, byPath); + return leaves; + } const markReceiver = (callee: t.Node, mutation: t.Node): void => { const expression = unwrapJavaScriptExpression(callee).node; if ( diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts new file mode 100644 index 000000000..448dcef00 --- /dev/null +++ b/src/domain/nativeConvention.ts @@ -0,0 +1,38 @@ +/** Path-segment conventions. These are inferences, not observations of runtime behavior. */ + +export type NativeRuntimeConvention = "react-native" | "flutter" | "unity"; + +const foldSegment = (segment: string): string => + segment.toLocaleLowerCase("en-US"); + +const REACT_NATIVE_SEGMENTS = new Set([ + "react.framework", + "libhermes.so", + "hermes.framework", +]); +const FLUTTER_SEGMENTS = new Set(["libflutter.so", "flutter.framework"]); +const UNITY_SEGMENTS = new Set(["libunity.so", "unityframework.framework"]); + +/** + * A runtime family named by one filename or directory segment. + * `reactnative` may occur inside a segment (`libreactnativejni.so`). + * The other names match the whole segment. + */ +export const nativeRuntimeConvention = ( + path: string, +): NativeRuntimeConvention | null => { + for (const segment of path.split("/")) { + const folded = foldSegment(segment); + if (folded.includes("reactnative") || REACT_NATIVE_SEGMENTS.has(folded)) + return "react-native"; + if (FLUTTER_SEGMENTS.has(folded)) return "flutter"; + if (UNITY_SEGMENTS.has(folded)) return "unity"; + } + return null; +}; + +/** Swift runtime libraries are `libswift*.dylib` segments, not every dynamic library. */ +export const isSwiftRuntimeLibrary = (path: string): boolean => + path + .split("/") + .some((segment) => /^libswift.+\.dylib$/u.test(foldSegment(segment))); diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 0ecf2c259..ff9197d15 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -108,6 +108,9 @@ export const processComparisonContract = ( ...(scenario.normalization.ports ? { port_normalization_version: "contextual-endpoints-v2" } : {}), + ...(scenario.normalization.patterns.length > 0 + ? { pattern_normalization_version: "literal-replacements-v1" } + : {}), rendered_line_format: "trailing-spaces-trimmed-v1", }); diff --git a/src/domain/webScriptExportPlan.test.ts b/src/domain/webScriptExportPlan.test.ts index edfb2b0fc..f0af5cddd 100644 --- a/src/domain/webScriptExportPlan.test.ts +++ b/src/domain/webScriptExportPlan.test.ts @@ -46,7 +46,6 @@ describe("captured script path projection", () => { it.each([ ["https://a.test/main.js", "https://a.test/main.js"], ["https://a.test/main.js", "https://a.test/main.js?v=2"], - ["https://a.test/A/main.js", "https://a.test/a/other.js"], ["https://a.test/main.js", "https://a.test/main.js/child.js"], ])( "isolates every version in a conflicting layout (%s, %s)", @@ -99,6 +98,25 @@ describe("captured script path projection", () => { }, ); + it("keeps case-distinct URL paths until the destination collides", () => { + const planned = planWebScriptExport([ + source("https://a.test/A/main.js"), + source("https://a.test/a/other.js", 2), + ]); + expect( + planned.every( + ({ record }) => + record.content.state === "exported" && + record.content.layout === "url-path", + ), + ).toBe(true); + const paths = planned.map(({ record }) => + record.content.state === "exported" ? record.content.relative_path : "", + ); + expect(paths[0]).toMatch(/\/A\/main\.js$/u); + expect(paths[1]).toMatch(/\/a\/other\.js$/u); + }); + it("does not choose a canonical version when a competing source is unavailable", () => { const missing: CapturedWebScript = { ...source("https://a.test/main.js?v=2", 2), diff --git a/src/domain/webScriptExportPlan.ts b/src/domain/webScriptExportPlan.ts index 72ba2ebb8..e4c0aed4b 100644 --- a/src/domain/webScriptExportPlan.ts +++ b/src/domain/webScriptExportPlan.ts @@ -34,7 +34,7 @@ export const planWebScriptExport = ( }; const candidate = candidates[index]; const reason = conflicts.has(index) - ? "Captured paths collide by content version, case, or file/directory prefix." + ? "Captured paths collide by content version or file/directory prefix." : (candidate?.reason ?? null); const path = reason === null ? candidate?.path : undefined; return { @@ -103,40 +103,27 @@ const candidatePath = (script: CapturedWebScript): CandidatePath => { }; }; -// Register every prefix so case collisions in parent directories and a file -// conflicting with a directory isolate all affected sources, not just the last. +// Register every exact prefix so a repeated path, or a file that is also a +// directory prefix, isolates every affected source. Case-distinct spellings +// stay distinct until the destination filesystem reports a collision. const conflictingPaths = ( candidates: readonly CandidatePath[], ): Set => { - const prefixes = new Map< - string, - { - spellings: Set; - indices: number[]; - files: number[]; - } - >(); + const prefixes = new Map(); candidates.forEach(({ path }, index) => { if (path === undefined) return; const parts = path.split("/"); parts.forEach((_, position) => { const spelling = parts.slice(0, position + 1).join("/"); - const key = spelling.toLowerCase(); - const entry = prefixes.get(key) ?? { - spellings: new Set(), - indices: [], - files: [], - }; - entry.spellings.add(spelling); + const entry = prefixes.get(spelling) ?? { indices: [], files: [] }; entry.indices.push(index); if (position === parts.length - 1) entry.files.push(index); - prefixes.set(key, entry); + prefixes.set(spelling, entry); }); }); const conflicts = new Set(); for (const entry of prefixes.values()) if ( - entry.spellings.size > 1 || entry.files.length > 1 || (entry.files.length > 0 && entry.indices.length > entry.files.length) ) diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 39a8f86a5..1544084e9 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -28,3 +28,7 @@ export const zipPackageFormatForPath = ( if (lower.endsWith(".appx") || lower.endsWith(".appxbundle")) return "appx"; return undefined; }; + +/** Inventory cannot see bytes that are not central-directory entries. */ +export const ZIP_NON_ENTRY_TAIL_LIMITATION = + "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; diff --git a/src/domain/zodCollectionSteps.ts b/src/domain/zodCollectionSteps.ts new file mode 100644 index 000000000..a32e8c804 --- /dev/null +++ b/src/domain/zodCollectionSteps.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +/** + * An array schema's own constraints, such as a minimum length, without its + * element schema. Pair it with {@link parseElementSteps} to validate a large + * collection in cooperative steps. + */ +export const arrayContainerSchema = ( + array: z.ZodArray, +): z.ZodType => array.clone({ ...array.def, element: z.unknown() }); + +/** Elements validated between yields, in the issues and paths a whole-array parse reports. */ +export function* parseElementSteps( + element: Element, + values: readonly unknown[], + path: readonly PropertyKey[], + issues: z.core.$ZodIssue[], +): Generator[]> { + const parsed: z.output[] = []; + for (const [index, value] of values.entries()) { + if (index % 64 === 0) yield; + const result = element.safeParse(value); + if (result.success) parsed.push(result.data); + else + issues.push( + ...result.error.issues.map((issue) => ({ + ...issue, + path: [...path, index, ...issue.path], + })), + ); + } + return parsed; +} diff --git a/src/firmware/FirmwareProvider.test.ts b/src/firmware/FirmwareProvider.test.ts new file mode 100644 index 000000000..0f3bb0b61 --- /dev/null +++ b/src/firmware/FirmwareProvider.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok } from "../domain/result.js"; +import { firmwareRequestSchema } from "../domain/firmware/firmwareAnalysis.js"; +import { finishFirmwareWorkspace } from "./FirmwareProvider.js"; + +const request = firmwareRequestSchema.parse({ + operation: "extract_firmware", + input: { + path: "/tmp/firmware.bin", + output_directory: "/tmp/extracted", + }, +}); + +const execution = ok({ + result: { files: ["kept"] }, + rawResult: null, + provider: { id: "unblob", name: "unblob", version: "1" }, + limitations: [], + locations: [], + subject: null, +}); + +describe("firmware workspace cleanup", () => { + it("returns the completed extraction beside a cleanup failure and does not delete the leaked root", async () => { + const close = vi.fn(async () => { + throw new Error("cleanup failed"); + }); + const finished = await finishFirmwareWorkspace( + { path: "/tmp/runtime", close }, + execution, + "unblob", + request, + ); + expect(close).toHaveBeenCalledOnce(); + if (finished.ok || !(finished.error instanceof ProviderAdapterError)) + throw new Error("Expected cleanup failure"); + expect(finished.error.cleanupResources).toEqual([ + "/tmp/runtime", + "/tmp/extracted", + ]); + expect(finished.error.partialObservation).toEqual({ + kind: "firmware", + result: { files: ["kept"] }, + }); + expect(finished.error.diagnostics).toEqual({ reason: "cleanup failed" }); + expect(projectAnalysisError(finished.error).details).toMatchObject({ + partial_observation: { + kind: "firmware", + result: { files: ["kept"] }, + }, + }); + }); + + it("does not replay a cleanup failure for a later workspace", async () => { + const failed = await finishFirmwareWorkspace( + { + path: "/tmp/runtime", + close: async () => { + throw new Error("cleanup failed"); + }, + }, + execution, + "unblob", + request, + ); + expect(failed.ok).toBe(false); + const next = await finishFirmwareWorkspace( + { path: "/tmp/next", close: async () => undefined }, + execution, + "unblob", + request, + ); + expect(next).toBe(execution); + }); + + it("leaves an uncertain workspace recorded and does not remove it", async () => { + const close = vi.fn(async () => undefined); + const cleanup = new ProviderCleanupError( + "unblob", + ["/tmp/runtime"], + { reason: "ownership uncertain" }, + { operation: "extract_firmware" }, + ); + const outcome = err(cleanup); + const finished = await finishFirmwareWorkspace( + { path: "/tmp/runtime", close }, + outcome, + "unblob", + request, + ); + expect(close).not.toHaveBeenCalled(); + expect(finished).toBe(outcome); + }); +}); diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index a0d7dfa50..a8b3bf560 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -65,7 +65,6 @@ const limitations = [ /** Queued bring-your-own firmware adapters with private snapshots and owned cleanup. */ export class FirmwareProvider implements FirmwareAnalysisPort { #tail: Promise = Promise.resolve(); - #cleanupFailure: AnalysisError | undefined; constructor( readonly environment: Readonly< Record @@ -115,7 +114,6 @@ export class FirmwareProvider implements FirmwareAnalysisPort { : AbortSignal.any([options.signal, deadline]); let outcome: Outcome; try { - if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure); if (signal.aborted) throw new AnalysisCancelledError(request.operation); const command = await resolveFirmwareCommand( this.environment, @@ -316,29 +314,49 @@ export class FirmwareProvider implements FirmwareAnalysisPort { }, }), ); - if (!outcome.ok && outcome.error.cleanupIncomplete) { - this.#cleanupFailure = - root === undefined || - outcome.error.cleanupResources.includes(root.path) - ? outcome.error - : new ProviderCleanupError( - engineName, - [...outcome.error.cleanupResources, root.path], - outcome.error instanceof ProviderAdapterError - ? (outcome.error.diagnostics ?? { - reason: outcome.error.message, - }) - : { reason: outcome.error.message }, - { cause: outcome.error }, - ); - } } // Never remove a workspace while process ownership remains uncertain. - if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure); - try { - await root?.close(); - } catch (cause: unknown) { - this.#cleanupFailure = new ProviderCleanupError( + // A leaked root stays recorded on this result and is not deleted. The next + // operation may create a new root; this failure is not replayed for it. + return finishFirmwareWorkspace(root, outcome, engineName, request); + } +} + +/** Keep a completed extraction beside cleanup failure, or skip removal when ownership is uncertain. */ +export const finishFirmwareWorkspace = async ( + root: { readonly path: string; close(): Promise } | undefined, + outcome: Outcome, + engineName: string, + request: FirmwareRequest, +): Promise => { + if (!outcome.ok && outcome.error.cleanupIncomplete) { + if ( + root === undefined || + outcome.error.cleanupResources.includes(root.path) + ) + return outcome; + return err( + new ProviderCleanupError( + engineName, + [...outcome.error.cleanupResources, root.path], + outcome.error instanceof ProviderAdapterError + ? (outcome.error.diagnostics ?? { reason: outcome.error.message }) + : { reason: outcome.error.message }, + { + cause: outcome.error, + operation: request.operation, + ...(outcome.error.partialObservation === undefined + ? {} + : { partialObservation: outcome.error.partialObservation }), + }, + ), + ); + } + try { + await root?.close(); + } catch (cause: unknown) { + return err( + new ProviderCleanupError( engineName, [ root?.path ?? "unknown", @@ -346,13 +364,23 @@ export class FirmwareProvider implements FirmwareAnalysisPort { ? [request.input.output_directory] : []), ], + { reason: cause instanceof Error ? cause.message : String(cause) }, { - reason: cause instanceof Error ? cause.message : String(cause), - previous_result: outcome.ok ? outcome.value.result : null, + cause, + operation: request.operation, + ...(outcome.ok + ? { + partialObservation: { + kind: "firmware", + result: outcome.value.result, + }, + } + : outcome.error.partialObservation === undefined + ? {} + : { partialObservation: outcome.error.partialObservation }), }, - ); - return err(this.#cleanupFailure); - } - return outcome; + ), + ); } -} + return outcome; +}; diff --git a/src/native/AppleDispatchCoverage.ts b/src/native/AppleDispatchCoverage.ts index 746e099be..c49e35acc 100644 --- a/src/native/AppleDispatchCoverage.ts +++ b/src/native/AppleDispatchCoverage.ts @@ -4,11 +4,19 @@ import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; import type { FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; const describeFixups = (fixups: PointerFixups): string => { - if (fixups.kind === "chained") - return `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}`; - return fixups.kind === "dyld-info" - ? "LC_DYLD_INFO bind opcodes" - : "no fixup load commands"; + const unread = + fixups.unreadRebaseBytes > 0 + ? `LC_DYLD_INFO rebase opcodes were not decoded (${fixups.unreadRebaseBytes} bytes)` + : undefined; + const kind = + fixups.kind === "chained" + ? `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}` + : fixups.kind === "dyld-info" + ? "LC_DYLD_INFO bind opcodes" + : unread === undefined + ? "no fixup load commands" + : undefined; + return [unread, kind].filter((line) => line !== undefined).join("; "); }; /** Append the per-facet coverage of one Apple dispatch metadata decode. */ @@ -83,18 +91,26 @@ export const pushDispatchCoverage = (input: { ({ decode }) => decode.status === "decoded", ).length, }); + const fixupReason = [ + describeFixups(fixups), + ...fixups.issues.map( + ({ code, location, message }) => + `${code}${location === undefined ? "" : ` at ${location}`}: ${message}`, + ), + ] + .filter((line) => line.length > 0) + .join("; "); result.coverage.push({ facet: "pointer_fixups", - status: fixups.issues.length > 0 ? "partial" : "complete", - reason: [ - describeFixups(fixups), - ...fixups.issues.map( - ({ code, location, message }) => - `${code}${location === undefined ? "" : ` at ${location}`}: ${message}`, - ), - ].join("; "), - examined: 0, - decoded: 0, + status: + fixups.issues.length > 0 || fixups.unreadRebaseBytes > 0 + ? "partial" + : fixups.examined === 0 + ? "unsupported" + : "complete", + reason: fixupReason.length > 0 ? fixupReason : null, + examined: fixups.examined, + decoded: fixups.issues.length > 0 ? 0 : fixups.examined, }); result.coverage.push({ facet: "swift_generic_resilient_witnesses_overrides_async_coroutines", diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index b0758748e..927d98495 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -354,7 +354,7 @@ describe("byte-swapped universal Apple dispatch metadata", () => { const bytes = wrapped(false); bytes.writeUInt32BE(0xfeedface, 256); expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow( - "Only little-endian 64-bit Mach-O metadata is supported", + "Unsupported 32-bit Mach-O metadata for requested CPU type arm64 (FAT header byte order: little-endian)", ); }); diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index d6c56e7b9..0260d9eab 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -149,13 +149,37 @@ describe("pointer fixup tables", () => { data.writeUInt16LE(99, 46); const fixups = parsePointerFixups( data, - { chained: { offset: 0, size: data.length }, binds: [], dylibs: [] }, + { + chained: { offset: 0, size: data.length }, + binds: [], + rebases: [], + dylibs: [], + }, SEGMENTS, BASE, ); expect(fixups.failures).toContain("Unsupported chained pointer format 99"); }); + it("keeps unread LC_DYLD_INFO rebase bytes when chained fixups are present", () => { + const data = chainedData(1); + const fixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: data.length }, + binds: [], + rebases: [{ offset: 8, size: 12 }], + dylibs: [], + }, + SEGMENTS, + BASE, + ); + expect(fixups).toMatchObject({ + kind: "chained", + unreadRebaseBytes: 12, + }); + }); + it("does not decode unreferenced imports while constructing fixup facts", () => { const data = chainedData(1); // The second import is outside the table bytes; only the first is needed @@ -164,7 +188,12 @@ describe("pointer fixup tables", () => { expect(() => parsePointerFixups( data, - { chained: { offset: 0, size: data.length }, binds: [], dylibs: [] }, + { + chained: { offset: 0, size: data.length }, + binds: [], + rebases: [], + dylibs: [], + }, SEGMENTS, BASE, ), @@ -179,6 +208,7 @@ describe("pointer fixup tables", () => { { chained: { offset: 0, size: data.length }, binds: [], + rebases: [], dylibs: ["/usr/lib/libobjc.A.dylib", "/usr/lib/libSystem.B.dylib"], }, SEGMENTS, @@ -241,6 +271,7 @@ describe("pointer fixup tables", () => { { offset: 0, size: opcodes.length, stream: "bind" }, { offset: opcodes.length, size: lazy.length, stream: "lazy" }, ], + rebases: [], dylibs: ["/usr/lib/libobjc.A.dylib", "/usr/lib/libSystem.B.dylib"], }, SEGMENTS, diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index 4c7686790..1422ff8c7 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -6,6 +6,11 @@ import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; export interface FixupCommands { /** Absolute file range of `LC_DYLD_CHAINED_FIXUPS` data. */ readonly chained: { readonly offset: number; readonly size: number } | null; + /** Absolute file ranges of `LC_DYLD_INFO(_ONLY)` rebase streams. */ + readonly rebases: readonly { + readonly offset: number; + readonly size: number; + }[]; /** Absolute file ranges of `LC_DYLD_INFO(_ONLY)` bind streams. */ readonly binds: readonly { readonly offset: number; @@ -35,6 +40,10 @@ export interface PointerFixups { readonly formats: readonly string[]; readonly failures: readonly string[]; readonly issues: readonly DecodeIssue[]; + /** Fixup segments or bind streams this reader walked. */ + readonly examined: number; + /** Rebase opcode bytes present in the load command and not decoded. */ + readonly unreadRebaseBytes: number; /** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */ decode(address: bigint, raw: bigint): DecodedPointer; } @@ -250,6 +259,8 @@ const chainedFixups = ( formats, failures: issues.map(({ message }) => message), issues, + examined: Math.max(ranges.length, 1), + unreadRebaseBytes: 0, decode(address, raw) { const segment = ranges.find( ({ from, to }) => address >= from && address < to, @@ -420,13 +431,24 @@ export const parsePointerFixups = ( baseAddress: bigint, ): PointerFixups => { const image = { bytes, segments, dylibs: commands.dylibs, baseAddress }; - if (commands.chained !== null) return chainedFixups(image, commands.chained); + const unreadRebaseBytes = commands.rebases.reduce( + (total, rebase) => total + rebase.size, + 0, + ); + if (commands.chained !== null) { + const chained = chainedFixups(image, commands.chained); + return unreadRebaseBytes === 0 + ? chained + : { ...chained, unreadRebaseBytes }; + } if (commands.binds.length === 0) return { kind: "none", formats: [], failures: [], issues: [], + examined: 0, + unreadRebaseBytes, decode: (_address, raw) => ({ kind: "rebase", target: raw }), }; const binds = new Map(); @@ -446,6 +468,8 @@ export const parsePointerFixups = ( issues: failures.map((message) => issue("pointer_fixups", "bind_stream_decode_failed", message), ), + examined: commands.binds.length, + unreadRebaseBytes, decode(address, raw) { const bound = binds.get(address); return bound === undefined diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 5c4da8d00..7344b1b6a 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -16,6 +16,27 @@ describe("native Mach-O slice selection", () => { expect(parseMachoLayout(Buffer.from(fat), "arm64").segments).toEqual([]); }); + it("selects only the admitted CPU type and rejects a 32-bit slice", () => { + const sixtyFour = fatImage([ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + ]); + expect(() => parseMachoLayout(Buffer.from(sixtyFour), "x86")).toThrow( + /Requested FAT architecture is absent/u, + ); + expect(() => parseMachoLayout(Buffer.from(sixtyFour), "arm")).toThrow( + /Requested FAT architecture is absent/u, + ); + const thirtyTwo = fatImage([ + { + cpu: CPU.i386, + bytes: machoImage({ cpu: CPU.i386, wide: false }), + }, + ]); + expect(() => parseMachoLayout(Buffer.from(thirtyTwo), "x86")).toThrow( + /Unsupported 32-bit Mach-O metadata for requested CPU type x86/u, + ); + }); + it("rejects FAT subtype disagreement and unaligned slice offsets", () => { const original = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]); const mismatched = original.slice(); diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index ced43f164..bfc7a37d6 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -90,7 +90,8 @@ export const selectMachoSlice = ( const headerEnd = 8 + count * stride; if (count > 128 || headerEnd > bytes.length) throw malformed("Malformed FAT architecture table"); - const cpu = architecture === "arm64" ? 0x0100000c : 0x01000007; + const cpu = MACHO_CPU_TYPE[architecture]; + if (cpu === undefined) throw invalid("Requested FAT architecture is absent"); const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), { count, wide: fat64, @@ -115,9 +116,26 @@ export const selectMachoSlice = ( } if (selected === undefined) throw invalid("Requested FAT architecture is absent"); + if (selected.slice + 4 > bytes.length) + throw malformed("Truncated Mach-O header"); + const sliceMagic = bytes.readUInt32LE(selected.slice); + if (sliceMagic === MH_MAGIC || sliceMagic === MH_CIGAM) + throw invalid( + `Unsupported 32-bit Mach-O metadata for requested CPU type ${architecture}`, + ); return selected; }; +/** CPU types admitted by `machArchitecture` in the domain binary target parser. */ +const MACHO_CPU_TYPE: Readonly> = { + x86: 7, + x86_64: 0x01000007, + arm: 12, + arm64: 0x0100000c, +}; +const MH_MAGIC = 0xfeedface; +const MH_CIGAM = 0xcefaedfe; + /** Parse segments/sections for a little-endian 64-bit Mach-O architecture slice. */ export const parseMachoLayout = ( bytes: Buffer, @@ -151,6 +169,7 @@ export const parseMachoLayout = ( sections: [], dylibs: [], binds: [], + rebases: [], chained: null, baseAddress: undefined, }; @@ -168,7 +187,7 @@ export const parseMachoLayout = ( else collectFixupCommand(bytes, command, state); cursor += command.size; } - const { segments, sections, chained, binds, dylibs } = state; + const { segments, sections, chained, binds, rebases, dylibs } = state; const offset = (address: bigint, size = 1): number => { const matches = segments.filter( (segment) => @@ -187,7 +206,7 @@ export const parseMachoLayout = ( segments, sections, offset, - fixups: { chained, binds, dylibs }, + fixups: { chained, binds, rebases, dylibs }, baseAddress: state.baseAddress ?? 0n, }; }; @@ -200,6 +219,7 @@ interface LayoutState { readonly sections: Section[]; readonly dylibs: string[]; readonly binds: FixupCommands["binds"][number][]; + readonly rebases: FixupCommands["rebases"][number][]; chained: FixupCommands["chained"]; baseAddress: bigint | undefined; } @@ -273,6 +293,8 @@ const collectFixupCommand = ( state.chained = range(cursor + 8); } else if (kind === LC_DYLD_INFO || kind === LC_DYLD_INFO_ONLY) { if (size < 48) throw new RangeError("Truncated dyld-info command"); + const rebase = range(cursor + 8); + if (rebase.size > 0) state.rebases.push(rebase); for (const [at, stream] of [ [cursor + 16, "bind"], [cursor + 24, "weak"], diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index 782e09349..dbb1c12b5 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -66,6 +66,8 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => { failures: [], issues: [], decode: (_address, raw) => ({ kind: "rebase", target: raw }), + examined: 0, + unreadRebaseBytes: 0, }, swift: { facet: "swift_conformances_static_witness_slots", @@ -178,6 +180,8 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () = kind: "unsupported", reason: "Unsupported chained pointer format 99", }), + examined: 0, + unreadRebaseBytes: 0, }, swift: { facet: "swift_conformances_static_witness_slots", diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts new file mode 100644 index 000000000..939f61099 --- /dev/null +++ b/src/native/NativeMachoInspection.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { err, ok } from "../domain/result.js"; +import type { NativeCommandCapture } from "./CommandRunner.js"; +import { inspectNativeMacho } from "./NativeMachoInspection.js"; + +const capture = (tool: string, stdout: string): NativeCommandCapture => ({ + tool, + executable: tool, + executableSha256: "a".repeat(64), + toolVersion: null, + versionReason: null, + arguments: [], + stdout, + stderr: "", + stdoutBytes: stdout.length, + stderrBytes: 0, + exitCode: 0, + signal: null, +}); + +const inspect = async (lipo: string, architecture: "x86" | "arm64") => { + const commands: { + readonly tool: string; + readonly args: readonly string[]; + }[] = []; + const result = await inspectNativeMacho({ + target: { + kind: "executable", + format: "mach-o", + path: "/fixture/app", + sha256: "b".repeat(64), + architecture, + availableArchitectures: ["x86", "arm64"], + }, + run: (tool, args) => { + commands.push({ tool, args }); + if (tool === "lipo") return Promise.resolve(ok(capture(tool, lipo))); + if (tool === "file") return Promise.resolve(ok(capture(tool, "Mach-O"))); + return Promise.resolve(err(new AnalysisCancelledError("inspect_native"))); + }, + invocation: () => { + throw new Error("invocation is not reached"); + }, + }); + return { result, commands }; +}; + +describe("Mach-O tool architecture selection", () => { + it("rejects a CPU lipo did not list instead of reading another slice", async () => { + const { result, commands } = await inspect("architecture arm64\n", "x86"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisOutputError", + reason: "lipo did not list i386; refusing to read another slice", + }); + expect(commands.map(({ tool }) => tool)).toEqual(["file", "lipo"]); + }); + + it("passes the arm64e slice lipo listed for an arm64 target", async () => { + const { commands } = await inspect("architecture arm64e\n", "arm64"); + const otool = commands.find(({ tool }) => tool === "otool"); + expect(otool?.args).toEqual([ + "-h", + "-l", + "-arch", + "arm64e", + "/fixture/app", + ]); + }); +}); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 2ef2c95ae..d8fee7572 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -1,6 +1,7 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { inspectMachoSchema, type NativeCommandInvocation, @@ -65,10 +66,19 @@ export const inspectNativeMacho = async ( } const lipo = captures.find(({ tool }) => tool === "lipo"); if (lipo === undefined) throw new TypeError("Missing lipo capture"); - const selectedArchitecture = selectArchitecture( - context.target, - parseLipoArchitectures(lipo.stdout).map(({ name }) => name), + const availableArchitectures = parseLipoArchitectures(lipo.stdout).map( + ({ name }) => name, ); + const selection = selectArchitecture(context.target, availableArchitectures); + if (selection.status === "absent") + return err( + new AnalysisOutputError( + "inspect_macho", + `lipo did not list ${selection.architecture}; refusing to read another slice`, + ), + ); + const selectedArchitecture = + selection.status === "selected" ? selection.architecture : null; for (const [tool, prefix] of REQUIRED_SLICE_COMMANDS) { const captured = await context.run( tool, @@ -339,24 +349,32 @@ const selectedArchitectureArguments = ( : ["-arch", toolArchitecture]; }; +type ArchitectureSelection = + | { readonly status: "unfiltered" } + | { readonly status: "selected"; readonly architecture: string } + | { readonly status: "absent"; readonly architecture: string }; + const selectArchitecture = ( target: BinaryTarget, available: readonly string[], -): string | null => { +): ArchitectureSelection => { if (target.kind !== "executable" || target.availableArchitectures.length < 2) - return null; + return { status: "unfiltered" }; const normalized = target.architecture === "x86" ? "i386" : target.architecture; - if (available.includes(normalized)) return normalized; + if (available.includes(normalized)) + return { status: "selected", architecture: normalized }; // BinaryTarget intentionally normalizes ARM64 CPU subtypes. Recover the // concrete arm64e slice name from lipo before invoking slice-aware tools. if (normalized === "arm64") { const arm64e = available.find((architecture) => architecture === "arm64e"); - if (arm64e !== undefined) return arm64e; + if (arm64e !== undefined) + return { status: "selected", architecture: arm64e }; const variant = available.find((architecture) => /^arm64e\.[A-Za-z0-9_]+$/u.test(architecture), ); - if (variant !== undefined) return variant; + if (variant !== undefined) + return { status: "selected", architecture: variant }; } - return normalized; + return { status: "absent", architecture: normalized }; }; diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 17d262fa7..38d40e0bf 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -75,6 +75,7 @@ interface TerminalExitOptions { readonly interactions: InteractionEvent[]; readonly dispatchedEventIndexes: ReadonlySet; readonly recordEvent: RecordProcessCaptureEvent; + readonly onLiveProgress?: () => void; } interface CaptureResultOptions { @@ -601,8 +602,10 @@ export const observeSettlement = async ( recordEvent: RecordProcessCaptureEvent = () => undefined, platform: NodeJS.Platform = process.platform, signal?: AbortSignal, + onLiveProgress?: () => void, ): Promise => { assertNotCancelled(signal); + onLiveProgress?.(); if (platform === "win32") { recordEvent("lifecycle", 1); return { state: "unverifiable", elapsed_ms: 0 }; @@ -611,6 +614,7 @@ export const observeSettlement = async ( let consecutiveEmpty = 0; let deadlineReached = false; while (!deadlineReached) { + onLiveProgress?.(); assertNotCancelled(signal); const observations = await Promise.all( [...new Set(processGroupIds)].map((processGroupId) => @@ -651,6 +655,7 @@ export const awaitTerminalExit = async ({ interactions, dispatchedEventIndexes, recordEvent, + onLiveProgress, }: TerminalExitOptions): Promise<{ exitCode: number; signal?: number; @@ -661,6 +666,7 @@ export const awaitTerminalExit = async ({ // Keep the initiating lifecycle reason so comparisons distinguish a target // exit from harness-owned timeout, idle-timeout, and cancellation cleanup. let reason: "exited" | "timeout" | "idle_timeout" | "cancelled" = "exited"; + onLiveProgress?.(); terminal.onExit((exit) => { recordEvent("lifecycle", 0); for (const [eventIndex, event] of scenario.events.entries()) { @@ -688,6 +694,7 @@ export const awaitTerminalExit = async ({ resolveExit({ ...exit, reason }); }); const timeout = scheduleProcessInterval(() => { + onLiveProgress?.(); if (signal?.aborted === true) { reason = "cancelled"; terminal.kill("SIGKILL"); diff --git a/src/process/capture/ProcessCaptureProgress.test.ts b/src/process/capture/ProcessCaptureProgress.test.ts new file mode 100644 index 000000000..69349bef9 --- /dev/null +++ b/src/process/capture/ProcessCaptureProgress.test.ts @@ -0,0 +1,113 @@ +import { expect, it } from "vitest"; + +import { + createProcessCaptureProgressTracker, + type ProcessCaptureProgress, +} from "./ProcessCaptureProgress.js"; + +const blockedProgress = () => { + let release: () => void = () => undefined; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const delivered: Parameters[0][] = []; + const progress: ProcessCaptureProgress = { + async report(update) { + delivered.push(update); + if (delivered.length === 1) await blocked; + }, + }; + return { progress, delivered, release: () => release() }; +}; + +it("coalesces intermediate observations while the progress receiver is blocked", async () => { + const receiver = blockedProgress(); + const tracker = createProcessCaptureProgressTracker(receiver.progress); + void tracker.report({ + phase: "running", + counts: { frames: 1, samples: 0, interactions: 0 }, + }); + await Promise.resolve(); + let latest: Promise | undefined; + for (let frames = 2; frames <= 1_000; frames += 1) { + latest = tracker.report({ + phase: "running", + counts: { frames, samples: 0, interactions: 0 }, + }); + } + expect(receiver.delivered).toHaveLength(1); + receiver.release(); + await latest; + expect(receiver.delivered.map((update) => update.completed)).toEqual([ + 1, 1_000, + ]); + expect(receiver.delivered[1]?.message).toContain("frames=1000"); +}); + +it("discards stale live observations before delivering final cleanup", async () => { + const receiver = blockedProgress(); + const tracker = createProcessCaptureProgressTracker(receiver.progress); + void tracker.report({ + phase: "running", + counts: { frames: 1, samples: 0, interactions: 0 }, + }); + await Promise.resolve(); + void tracker.report({ + phase: "running", + counts: { frames: 2, samples: 0, interactions: 0 }, + }); + void tracker.report({ + phase: "settling", + counts: { frames: 2, samples: 1, interactions: 0 }, + }); + tracker.closeLive(); + await tracker.report({ + phase: "running", + counts: { frames: 10, samples: 10, interactions: 10 }, + }); + const terminal = tracker.report({ + phase: "cleanup", + counts: { frames: 2, samples: 2, interactions: 0 }, + disposition: "exited", + terminal: true, + }); + receiver.release(); + await terminal; + expect(receiver.delivered.map((update) => update.phase)).toEqual([ + "running", + "cleanup", + ]); + expect(receiver.delivered.at(-1)).toMatchObject({ + completed: 4, + total: null, + terminal: true, + }); +}); + +it("still delivers terminal status after an observer rejects an update", async () => { + const delivered: Parameters[0][] = []; + const tracker = createProcessCaptureProgressTracker({ + async report(update) { + delivered.push(update); + if (delivered.length === 1) throw new Error("Observer disconnected"); + }, + }); + await tracker.report({ + phase: "prepare", + counts: { frames: 0, samples: 0, interactions: 0 }, + }); + tracker.closeLive(); + await expect( + tracker.report({ + phase: "cleanup", + counts: { frames: 1, samples: 1, interactions: 0 }, + disposition: "failed", + terminal: true, + }), + ).resolves.toBeUndefined(); + expect(delivered.at(-1)).toMatchObject({ + phase: "cleanup", + completed: 2, + terminal: true, + }); +}); diff --git a/src/process/capture/ProcessCaptureProgress.ts b/src/process/capture/ProcessCaptureProgress.ts new file mode 100644 index 000000000..83d5a51f5 --- /dev/null +++ b/src/process/capture/ProcessCaptureProgress.ts @@ -0,0 +1,121 @@ +import type { ProcessCaptureCleanupReport } from "../../domain/process/processCapture.js"; + +/** + * Progress port accepted by process capture. + * Structurally matches the shared application `ProgressReporter`. + */ +export interface ProcessCaptureProgress { + report(update: { + readonly phase: string; + readonly completed: number; + readonly total: null; + readonly message: string; + readonly terminal?: boolean; + }): Promise; +} + +/** Observed collection counts. These are not a percentage of an unknown total. */ +export interface ProcessCaptureProgressCounts { + readonly frames: number; + readonly samples: number; + readonly interactions: number; +} + +export type ProcessCaptureProgressPhase = + | "prepare" + | "running" + | "settling" + | "cleanup"; + +export type ProcessCaptureProgressDisposition = + | "exited" + | "timeout" + | "idle_timeout" + | "cancelled" + | "failed"; + +export interface ProcessCaptureProgressReport { + readonly phase: ProcessCaptureProgressPhase; + readonly counts: ProcessCaptureProgressCounts; + readonly terminal?: boolean; + readonly disposition?: ProcessCaptureProgressDisposition; + readonly cleanup?: ProcessCaptureCleanupReport; +} + +const formatProgressMessage = ( + elapsedMs: number, + counts: ProcessCaptureProgressCounts, + disposition: ProcessCaptureProgressDisposition | undefined, + cleanup: ProcessCaptureCleanupReport | undefined, +): string => { + const status = `elapsed_ms=${String(elapsedMs)} frames=${String(counts.frames)} samples=${String(counts.samples)} interactions=${String(counts.interactions)}`; + const dispositionText = + disposition === undefined ? "" : ` disposition=${disposition}`; + const cleanupText = + cleanup === undefined + ? "" + : ` owned_process_group=${cleanup.owned_process_group.state} terminal_renderer=${cleanup.terminal_renderer.state} temporary_root=${cleanup.temporary_root.state}`; + return `${status}${dispositionText}${cleanupText}`; +}; + +/** Serialize observed capture status through one rate-limited reporter. */ +export const createProcessCaptureProgressTracker = ( + progress: ProcessCaptureProgress | undefined, + now: () => number = Date.now, +) => { + const started = now(); + let completed = 0; + let live = true; + let next: Parameters[0] | undefined; + let pending: Promise | undefined; + const flush = async (): Promise => { + try { + while (next !== undefined) { + const update = next; + next = undefined; + try { + await progress?.report(update); + } catch { + // Progress delivery is observational; capture and cleanup still finish. + continue; + } + } + } finally { + pending = undefined; + } + }; + return { + closeLive(): void { + live = false; + if (next?.terminal !== true) next = undefined; + }, + report(update: ProcessCaptureProgressReport): Promise { + if (progress === undefined) return Promise.resolve(); + if (update.terminal !== true && !live) return Promise.resolve(); + const units = + update.counts.frames + + update.counts.samples + + update.counts.interactions; + if (units > completed) completed = units; + // A slow observer needs the latest observation, not a queue of old states. + next = { + phase: update.phase, + completed, + total: null as null, + message: formatProgressMessage( + Math.max(0, now() - started), + update.counts, + update.disposition, + update.cleanup, + ), + ...(update.terminal === true ? { terminal: true as const } : {}), + }; + pending ??= Promise.resolve().then(flush); + return pending; + }, + }; +}; + +export type ProcessCaptureProgressTracker = ReturnType< + typeof createProcessCaptureProgressTracker +>; diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index f15210b15..19b3d10bf 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -55,6 +55,13 @@ import { import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js"; import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js"; import { processCaptureOwnershipUnavailableReason } from "./ProcessCaptureCapability.js"; +import { + createProcessCaptureProgressTracker, + type ProcessCaptureProgress, + type ProcessCaptureProgressCounts, + type ProcessCaptureProgressDisposition, + type ProcessCaptureProgressTracker, +} from "./ProcessCaptureProgress.js"; import type { ProcessOwnershipBaseline } from "../ProcessOwnership.js"; interface StartedCaptureRuntime { @@ -262,7 +269,10 @@ const finishProcessRun = async (options: { readonly executionFailure: unknown; readonly observations?: ProcessCaptureObservationBuffer; readonly rootPid?: number; + readonly progress: ProcessCaptureProgressTracker; + readonly progressCounts: () => ProcessCaptureProgressCounts; }): Promise => { + options.progress.closeLive(); await options.stopSampler(); const sampledProcessGroupIds = options.runtime === undefined @@ -281,6 +291,20 @@ const finishProcessRun = async (options: { temporaryRoot: options.temporaryRoot, ...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }), }); + const observedExit = + options.observations?.exit.state === "available" + ? options.observations.exit.value.reason + : options.capture?.exit.reason; + await options.progress.report({ + phase: "cleanup", + counts: options.progressCounts(), + terminal: true, + disposition: captureProgressDisposition( + observedExit, + options.executionFailure, + ), + cleanup, + }); const cleanupFailure = cleanupReportFailure(cleanup); let { capture, executionFailure } = options; let verifiedCapture: ProcessCapture | undefined; @@ -336,6 +360,7 @@ const completeCapture = async (options: { readonly eventJournal: readonly ProcessCaptureEventJournalEntry[]; readonly observationBuffer: ProcessCaptureObservationBuffer; readonly recordEvent: RecordProcessCaptureEvent; + readonly onLiveProgress?: () => void; }): Promise => { const unavailableReason = (stage: string, cause: unknown): string => `${stage} failed: ${cause instanceof Error ? cause.message : "unknown failure"}`; @@ -351,6 +376,7 @@ const completeCapture = async (options: { options.recordEvent, options.hostPlatform, options.signal, + options.onLiveProgress, ); } catch (cause: unknown) { options.observationBuffer.settlement = { @@ -460,6 +486,25 @@ const completeCapture = async (options: { }); }; +const captureProgressDisposition = ( + exitReason: string | undefined, + executionFailure: unknown, +): ProcessCaptureProgressDisposition => { + if ( + exitReason === "exited" || + exitReason === "timeout" || + exitReason === "idle_timeout" || + exitReason === "cancelled" + ) + return exitReason; + if ( + executionFailure instanceof ProcessCaptureError && + executionFailure.reason === "cancelled" + ) + return "cancelled"; + return "failed"; +}; + /** Execute one caller-selected scenario and return bounded observations. */ const runProcessScenario = async ( scenario: ProcessScenario, @@ -468,12 +513,44 @@ const runProcessScenario = async ( hostPlatform: NodeJS.Platform = process.platform, captureSnapshot: typeof snapshotRoots = snapshotRoots, cleanupHost?: ProcessCaptureCleanupHost, + progress?: ProcessCaptureProgress, ): Promise => { - const { temporaryRoot, runId, ownershipBaseline, before } = - await prepareProcessCapture(scenario, signal, captureSnapshot); + const progressTracker = createProcessCaptureProgressTracker(progress); const frames: TerminalFrame[] = []; const samples: ProcessSample[] = []; const interactions: InteractionEvent[] = []; + const progressCounts = (): ProcessCaptureProgressCounts => ({ + frames: frames.length, + samples: samples.length, + interactions: interactions.length, + }); + const reportRunning = () => { + void progressTracker.report({ phase: "running", counts: progressCounts() }); + }; + const reportSettling = () => { + void progressTracker.report({ + phase: "settling", + counts: progressCounts(), + }); + }; + let prepared: Awaited>; + try { + await progressTracker.report({ + phase: "prepare", + counts: progressCounts(), + }); + prepared = await prepareProcessCapture(scenario, signal, captureSnapshot); + } catch (cause: unknown) { + progressTracker.closeLive(); + await progressTracker.report({ + phase: "cleanup", + counts: progressCounts(), + terminal: true, + disposition: signal?.aborted === true ? "cancelled" : "failed", + }); + throw cause; + } + const { temporaryRoot, runId, ownershipBaseline, before } = prepared; const journal = createProcessCaptureJournal(); const { entries: eventJournal, recordEvent } = journal; recordEvent("filesystem_checkpoints", 0); @@ -523,6 +600,7 @@ const runProcessScenario = async ( interactions, dispatchedEventIndexes, recordEvent, + ...(progress === undefined ? {} : { onLiveProgress: reportRunning }), }); observations.exit = { state: "available", @@ -549,6 +627,7 @@ const runProcessScenario = async ( recordEvent, captureSnapshot, ...(signal === undefined ? {} : { signal }), + ...(progress === undefined ? {} : { onLiveProgress: reportSettling }), }); } catch (cause: unknown) { executionFailure = normalizeCaptureFailure(cause, signal); @@ -590,6 +669,8 @@ const runProcessScenario = async ( executionFailure, observations, ...(actualRootPid === undefined ? {} : { rootPid: actualRootPid }), + progress: progressTracker, + progressCounts, }); }; @@ -599,8 +680,9 @@ export const captureProcessScenario = async ( signal?: AbortSignal, platform: NodeJS.Platform = process.platform, environment: Readonly> = process.env, - captureSnapshot: typeof snapshotRoots = snapshotRoots, + captureSnapshot?: typeof snapshotRoots, cleanupHost?: ProcessCaptureCleanupHost, + progress?: ProcessCaptureProgress, ): Promise> => { const ownershipReason = processCaptureOwnershipUnavailableReason(platform); if (ownershipReason !== undefined) @@ -624,8 +706,9 @@ export const captureProcessScenario = async ( signal, environment, platform, - captureSnapshot, + captureSnapshot ?? snapshotRoots, cleanupHost, + progress, ), ); } catch (cause: unknown) { diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index 9631c66f3..9c9ba1951 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -59,18 +59,68 @@ const scenario = (normalization: { readonly pids?: boolean; readonly paths?: boolean; readonly ports?: boolean; + readonly patterns?: readonly { + readonly pattern: string; + readonly replacement: string; + }[]; }) => parseProcessScenario({ executable: "/usr/bin/node", working_directory: "/workspace", normalization: { paths: true, - ...normalization, time_bucket_ms: 10, patterns: [{ pattern: "marker", replacement: "selected" }], + ...normalization, }, }); +it("replaces caller-declared patterns literally, without expanding replacement patterns", () => { + const declared = scenario({ + paths: false, + patterns: [ + { pattern: "8080", replacement: "$&$&" }, + { pattern: "READY", replacement: "$$" }, + { pattern: "NAME", replacement: "$1" }, + ], + }); + expect( + normalizeProcessText( + "listening on 8080; READY; NAME=alpha; tail NAME", + declared, + "/temporary", + rootPid, + ), + ).toBe("listening on $&$&; $$; $1=alpha; tail $1"); +}); + +it("preserves empty-pattern boundaries, including empty text", () => { + const declared = scenario({ + patterns: [{ pattern: "", replacement: "$$" }], + }); + expect(normalizeProcessText("ab", declared, "/temporary", rootPid)).toBe( + "$$a$$b$$", + ); + expect(normalizeProcessText("", declared, "/temporary", rootPid)).toBe("$$"); +}); + +it.each(["$`", "$'"])( + "retains the selected replacement %s verbatim", + (replacement) => { + const declared = scenario({ + patterns: [{ pattern: "marker", replacement }], + }); + expect( + normalizeProcessText( + "before marker after", + declared, + "/temporary", + rootPid, + ), + ).toBe(`before ${replacement} after`); + }, +); + it("preserves compact JSON, counters, line numbers, and ambiguous endpoint spellings", () => { const input = [ '{"pid":32,"size":108,"height":480,"width":640,"score":20}', diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index 24209012c..9364ec95b 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -34,8 +34,13 @@ export const normalizeProcessText = ( normalized = normalizePortTokens(normalized); if (scenario.normalization.pids) normalized = normalizePidTokens(normalized, [pid]); + // A callback keeps replacement text literal while preserving replaceAll's + // empty-pattern boundaries, which the process scenario contract permits. for (const pattern of scenario.normalization.patterns) - normalized = normalized.replaceAll(pattern.pattern, pattern.replacement); + normalized = normalized.replaceAll( + pattern.pattern, + () => pattern.replacement, + ); return normalized; }; diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts index 9a3bb923f..fcf23e6ef 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts @@ -1,4 +1,11 @@ -import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -21,6 +28,38 @@ it("preserves the caller-selected executable symlink for process invocation", as } }); +it("resolves a Windows executable through the canonical PATH spelling", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-process-path-")); + try { + const selected = join(root, "selected"); + const ambient = join(root, "ambient"); + await mkdir(selected); + await mkdir(ambient); + await writeFile(join(selected, "tool.exe"), ""); + await chmod(join(selected, "tool.exe"), 0o644); + const scenario = parseProcessScenario({ + executable: "tool.exe", + working_directory: root, + environment: { Path: selected }, + }); + const candidate = join(selected, "tool.exe"); + try { + const resolved = await resolveProcessScenarioRuntimePaths( + scenario, + { PATH: ambient }, + "win32", + ); + expect(resolved.executable).toBe(candidate); + } catch (cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + expect(message).toContain(candidate); + expect(message).not.toContain(ambient); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + it("retains the selected missing working directory and OS reason", async () => { const selected = join(tmpdir(), "rea-process-missing-cwd-unique"); const scenario = parseProcessScenario({ diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.ts b/src/process/capture/ProcessScenarioRuntimeValidation.ts index 23444b133..5196005b2 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; +import { snapshotEnvironment } from "../snapshotEnvironment.js"; import { ProcessCaptureError, processCaptureCancelled, @@ -12,6 +13,7 @@ import { export const resolveProcessScenarioRuntimePaths = async ( scenario: ProcessScenario, environment: Readonly> = process.env, + platform: NodeJS.Platform = process.platform, ): Promise => { const selectedWorkingDirectory = resolve(scenario.working_directory); let workingDirectory: string; @@ -39,7 +41,9 @@ export const resolveProcessScenarioRuntimePaths = async ( const executable = await resolveExecutable( scenario.executable, workingDirectory, - scenario.environment.PATH ?? environment.PATH ?? "", + snapshotEnvironment(scenario.environment, platform).PATH ?? + snapshotEnvironment(environment, platform).PATH ?? + "", ); const observationPaths = scenario.filesystem_observation_paths.map((path) => resolve(workingDirectory, path), diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 706ad0577..d5f5e0598 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -5,14 +5,22 @@ import { isJSONRPCNotification, isJSONRPCResultResponse, type Implementation, + type Icon, type JSONRPCMessage, type McpServerOptions, + type RegisteredTool, type RequestId, + type ScopeChallengeHandler, + type StandardSchemaWithJSON, + type ToolAnnotations, + type ToolCallback, type Transport, type TransportSendOptions, } from "@modelcontextprotocol/server"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; +import { compactAdvertisedInputSchema } from "../contracts/compactInputSchema.js"; +import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -21,6 +29,24 @@ import { jsonObjectSchema } from "../domain/jsonValue.js"; import type { ToolResultDelivery } from "./toolResult.js"; import { encodeToolResult } from "./toolResultEncoding.js"; +/** Compact advertised input schemas within this per-schema byte budget. */ +export interface CompactInputSchemaPresentation { + readonly budgetBytes: number; +} + +/** A Zod schema whose standard interface exposes a JSON Schema projection. */ +const hasAdvertisedInputJsonSchema = (value: unknown): boolean => { + if (typeof value !== "object" || value === null) return false; + const standard = Reflect.get(value, "~standard"); + if (typeof standard !== "object" || standard === null) return false; + const jsonSchema = Reflect.get(standard, "jsonSchema"); + return ( + typeof jsonSchema === "object" && + jsonSchema !== null && + Reflect.get(jsonSchema, "input") !== undefined + ); +}; + /** Bind oversized MCP error recovery to this server's Evidence ledger. */ export class EvidenceMcpServer extends McpServer { constructor( @@ -30,10 +56,60 @@ export class EvidenceMcpServer extends McpServer { | EvidenceWriter["recordEvidence"] | undefined, readonly delivery: ToolResultDelivery, + private readonly compactPresentation: + | CompactInputSchemaPresentation + | undefined = undefined, ) { super(info, options); } + /** + * Compact the advertised input schema of every registered tool when the + * compact profile is active. The registered canonical schema, its parser, + * and output schemas are untouched; only the advertised JSON Schema + * projection is rendered within the provider's per-schema size budget. + * The signatures mirror the SDK's registrations so call sites keep their + * inferred handler argument types. + */ + override registerTool< + OutputArgs extends StandardSchemaWithJSON, + InputArgs extends StandardSchemaWithJSON | undefined = undefined, + >( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; + _meta?: Record; + }, + callback: ToolCallback, + ): RegisteredTool { + const budget = this.compactPresentation?.budgetBytes; + const inputSchema = + budget !== undefined && + config !== undefined && + hasAdvertisedInputJsonSchema(config.inputSchema) + ? transformAdvertisedInputJsonSchema( + config.inputSchema as Parameters< + typeof transformAdvertisedInputJsonSchema + >[0], + (projected) => + compactAdvertisedInputSchema(projected, budget).schema, + ) + : undefined; + return super.registerTool( + name, + (inputSchema === undefined + ? config + : { ...config, inputSchema }) as never, + callback as never, + ); + } + /** Preserve the SDK transport lifecycle while retaining oversized errors. */ override connect(transport: Transport): Promise { return super.connect( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index b578e26c5..f5d3884bf 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -15,6 +15,11 @@ import { type ToolResultDelivery, } from "./toolResult.js"; import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js"; +import { + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + parseMcpInputSchemaProfile, +} from "../config/mcpInputSchemaProfile.js"; +import type { CompactInputSchemaPresentation } from "./EvidenceMcpServer.js"; import { isAbsolute } from "node:path"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; @@ -148,6 +153,7 @@ const installSessionToolAvailability = ( const createMcpServer = ( session: BinarySessionPort | undefined, delivery: ToolResultDelivery, + compactPresentation: CompactInputSchemaPresentation | undefined, ): EvidenceMcpServer => new EvidenceMcpServer( { @@ -165,6 +171,7 @@ const createMcpServer = ( ? undefined : (evidence) => session.recordEvidence(evidence), delivery, + compactPresentation, ); /** @@ -183,7 +190,11 @@ export const createServer = ( const selectedOptions = { ...options, environment, delivery }; const startedAt = new Date().toISOString(); const logger = options.logger ?? silentLogger; - const server = createMcpServer(session, delivery); + const server = createMcpServer( + session, + delivery, + selectCompactInputSchemaPresentation(environment), + ); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); const availability = installSessionToolAvailability( @@ -256,6 +267,18 @@ const selectToolResultDelivery = ( return createToolResultDelivery(configured.value); }; +const selectCompactInputSchemaPresentation = ( + environment: Readonly, +): CompactInputSchemaPresentation | undefined => { + const configured = parseMcpInputSchemaProfile( + environment.REA_MCP_INPUT_SCHEMA_PROFILE, + ); + if (!configured.ok) throw configured.error; + return configured.value === "compact" + ? { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES } + : undefined; +}; + const registerConfiguredAnalysisTools = ( { server, diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 5d0b1bf38..da36c8767 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -90,24 +90,20 @@ const registerProcessTools = ({ toolRegistrationOptions(captureContract), async (input, context) => { const progress = mcpProgressReporter(context); - await progress.report({ - phase: captureContract.name, - completed: 0, - total: 1, - message: "started", - }); const captured = await logToolExecution( logger, captureContract.name, - () => captureProcessScenario(input, context.mcpReq.signal), + () => + captureProcessScenario( + input, + context.mcpReq.signal, + process.platform, + process.env, + undefined, + undefined, + progress, + ), ); - await progress.report({ - phase: captureContract.name, - completed: 1, - total: 1, - message: captured.ok ? "completed" : "failed", - terminal: true, - }); if (!captured.ok) return server.delivery.toCallToolResult(captured, captureContract); const evidence = createProcessCaptureEvidence(input, captured.value); diff --git a/src/windows/WindowsPrivateRuntime.ts b/src/windows/WindowsPrivateRuntime.ts index 24f876746..e17544a49 100644 --- a/src/windows/WindowsPrivateRuntime.ts +++ b/src/windows/WindowsPrivateRuntime.ts @@ -44,7 +44,7 @@ export class WindowsPrivateRuntime { authority.call("runtime_create", [parent, prefix]), ); const runtime = new WindowsPrivateRuntime(observation, authority); - runtimes.set(observation.path.toLowerCase(), runtime); + runtimes.set(windowsRuntimeIdentityKey(observation), runtime); return runtime; } @@ -142,16 +142,30 @@ export class WindowsPrivateRuntime { await this.#pending?.catch(() => undefined); this.authority.call("runtime_close", [this.observation.handle]); this.#closed = true; - runtimes.delete(this.observation.path.toLowerCase()); + runtimes.delete(windowsRuntimeIdentityKey(this.observation)); } } -/** Select an already-owned runtime; an arbitrary pathname cannot grant authority. */ +/** File id returned by the OS. Path spelling is not part of the lease key. */ +const windowsRuntimeIdentityKey = (identity: { + readonly volumeSerial: string; + readonly fileId: string; +}): string => `${identity.volumeSerial}:${identity.fileId}`; + +/** + * Select an already-owned runtime. Only the observed `path` or `finalPath` + * matches, character for character. A case variant fails closed. + */ export const windowsPrivateRuntime = ( rootPath: string, ): WindowsPrivateRuntime => { - const runtime = runtimes.get(rootPath.toLowerCase()); - if (runtime === undefined) + const matches = [...runtimes.values()].filter( + (runtime) => + runtime.observation.finalPath === rootPath || + runtime.observation.path === rootPath, + ); + const runtime = matches[0]; + if (matches.length !== 1 || runtime === undefined) throw new Error(`No native private runtime owns ${rootPath}`); return runtime; }; diff --git a/tests/acceptance/applications/javascriptReturnedReferences.test.ts b/tests/acceptance/applications/javascriptReturnedReferences.test.ts index bd9a05c76..5bbd36ff6 100644 --- a/tests/acceptance/applications/javascriptReturnedReferences.test.ts +++ b/tests/acceptance/applications/javascriptReturnedReferences.test.ts @@ -47,6 +47,10 @@ const writes = { constructor: "function Box() { return shared; } const t = new Box(); t.x = 2;", callback: "const get = () => shared; [0].forEach(() => { get().x = 2; });", + wideMethods: `const box = { ${Array.from( + { length: 64 }, + (_, index) => `m${index}() { return shared; }`, + ).join(", ")} }; box.m0().x = 2; box.m63().other = 3;`, }; const source = [ ...Object.entries(writes).map( @@ -61,6 +65,8 @@ const source = [ "export function unused(){ const shared = {x: 1}; const get = () => shared; get(); return shared.x; }", "export function nestedCallable(){ const shared = {x: 1}; const get = () => { const inner = () => shared; return {x: 0}; }; get().x = 2; return shared.x; }", "export function unrelated(){ const shared = {x: 1}; const other = {x: 9}; const get = () => shared; get().x = 2; return other.x; }", + "export function receiverDistinctScopes(){ const shared = {x: 1}; const box = {outer(){ return shared; }, inner(){ const shared = {x: 9}; return shared; }, noop(){}}; box.noop(); return {outer: shared.x, inner: box.inner().x}; }", + "export function receiverDistinctPaths(){ const parent = {left: {x: 1}, right: {x: 9}, keep: 7}; const box = {left(){ return parent.left; }, right(){ return parent.right; }, noop(){}}; box.noop(); return {left: parent.left.x, right: parent.right.x, keep: parent.keep}; }", ].join("\n"); const assertReturns = (fields: JavaScriptReturnFields): void => { @@ -91,6 +97,17 @@ const assertReturns = (fields: JavaScriptReturnFields): void => { expect(fields("unrelated")).toContainEqual( expect.objectContaining({ path: "", state: "literal", value: 9 }), ); + for (const path of ["/outer", "/inner"]) + expect(fields("receiverDistinctScopes")).toContainEqual( + expect.objectContaining({ path, state: "unknown" }), + ); + for (const path of ["/left", "/right"]) + expect(fields("receiverDistinctPaths")).toContainEqual( + expect.objectContaining({ path, state: "unknown" }), + ); + expect(fields("receiverDistinctPaths")).toContainEqual( + expect.objectContaining({ path: "/keep", state: "literal", value: 7 }), + ); }; cliTest( @@ -98,3 +115,39 @@ cliTest( ({ cli }) => verifyJavaScriptReturnShapes(cli, source, assertReturns), 120_000, ); + +cliTest( + "analyzes wide receiver returns without losing escaped reference facts", + ({ cli }) => { + const width = 160_000; + const object = Array.from( + { length: width }, + (_, index) => `p${index}:0`, + ).join(","); + const array = "0,".repeat(width); + const wideSource = [ + ["wideObject", `{${object},last:shared}`], + ["wideArray", `[${array}shared]`], + ] + .map( + ([name, value]) => + `export function ${name}(){const shared={x:1};const box={wide(){return ${value}},noop(){}};box.noop();return {x:shared.x,keep:7};}`, + ) + .join("\n"); + return verifyJavaScriptReturnShapes(cli, wideSource, (fields) => { + for (const name of ["wideObject", "wideArray"]) { + expect(fields(name)).toContainEqual( + expect.objectContaining({ path: "/x", state: "unknown" }), + ); + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/keep", + state: "literal", + value: 7, + }), + ); + } + }); + }, + 120_000, +); diff --git a/tests/acceptance/setup/hermesClientSetup.test.ts b/tests/acceptance/setup/hermesClientSetup.test.ts new file mode 100644 index 000000000..5a7baa333 --- /dev/null +++ b/tests/acceptance/setup/hermesClientSetup.test.ts @@ -0,0 +1,290 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "Hermes setup, doctor and uninstall use the expanded profile and its personal skill", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-hermes-cli-"); + const profile = join(home, "profile"); + const config = join(profile, "config.yaml"); + const skill = join(profile, "skills/reverse-engineer-anything/SKILL.md"); + const original = + "# user settings\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n"; + await mkdir(profile); + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { + USERPROFILE: home, + REA_CLIENT_PROFILE_HOME: home, + HERMES_HOME: " ${REA_CLIENT_PROFILE_HOME}/profile ", + }, + timeoutMs: 20_000, + }); + const planned = await run([ + "setup", + "--client", + "hermes", + "--dry-run", + "--json", + ]); + expect(planned.exitCode).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + plannedActions: expect.arrayContaining([ + expect.objectContaining({ target: config }), + expect.objectContaining({ + target: join(profile, "skills/reverse-engineer-anything"), + }), + ]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + const applied = await run([ + "setup", + "--client", + "hermes", + "--yes", + "--json", + ]); + expect(applied.exitCode).toBe(0); + expect(applied.json).toMatchObject({ status: "ready" }); + expect(parse(await readFile(config, "utf8"))).toMatchObject({ + model: "custom", + mcp_servers: { + other: { command: "other-server" }, + rea: { enabled: true }, + }, + }); + expect(await readFile(config + ".rea.backup", "utf8")).toBe(original); + expect(await readFile(skill, "utf8")).toContain( + "reverse-engineer-anything", + ); + await expect( + access(join(home, ".agents/skills/reverse-engineer-anything/SKILL.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + const doctor = await run([ + "doctor", + "--client", + "hermes", + "--skill", + "--json", + ]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { skill: { state: "aligned" } }, + }); + const repeat = await run([ + "setup", + "--client", + "hermes", + "--yes", + "--json", + ]); + expect(repeat.json).toMatchObject({ status: "ready", appliedActions: [] }); + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode).toBe(0); + expect(parse(await readFile(config, "utf8"))).toEqual(parse(original)); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +for (const selection of ["default", "custom", "suffix", "explicit"] as const) { + cliTest( + `Hermes ${selection} sticky-profile selection preserves the root configuration`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-hermes-sticky-"); + const suffix = selection === "suffix" ? "-dev" : ""; + const native = + process.platform === "win32" + ? join(home, "AppData", "Local", `hermes${suffix}`) + : join(home, `.hermes${suffix}`); + const root = selection === "custom" ? join(home, "custom root") : native; + const profile = join( + root, + "profiles", + selection === "explicit" ? "other" : "coder", + ); + await mkdir(profile, { recursive: true }); + const original = + "# profile settings\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n"; + const config = join(profile, "config.yaml"); + const rootConfig = join(root, "config.yaml"); + await writeFile(config, original); + await writeFile(rootConfig, "model: root-model\n"); + await writeFile(join(root, "active_profile"), "\uFEFFCoDeR\r\n"); + const run = (args: readonly string[]) => + cli.run({ + arguments: args, + cwd: home, + environment: { + USERPROFILE: home, + LOCALAPPDATA: join(home, "AppData", "Local"), + HERMES_DATA_DIR_SUFFIX: suffix, + HERMES_HOME: + selection === "explicit" + ? profile + : selection === "custom" + ? root + : undefined, + }, + timeoutMs: 20_000, + }); + const plan = await run([ + "setup", + "--client", + "hermes", + "--dry-run", + "--json", + ]); + expect(plan.exitCode).toBe(0); + expect(plan.json).toMatchObject({ + status: "planned", + plannedActions: expect.arrayContaining([ + expect.objectContaining({ target: config }), + expect.objectContaining({ + target: join(profile, "skills", "reverse-engineer-anything"), + }), + ]), + }); + expect( + (await run(["setup", "--client", "hermes", "--yes", "--json"])) + .exitCode, + ).toBe(0); + expect(parse(await readFile(config, "utf8"))).toMatchObject({ + mcp_servers: { + rea: { enabled: true }, + other: { command: "other-server" }, + }, + }); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + const skill = join( + profile, + "skills", + "reverse-engineer-anything", + "SKILL.md", + ); + expect(await readFile(skill, "utf8")).toContain( + "name: reverse-engineer-anything", + ); + if (selection === "custom") { + const doctor = await run([ + "doctor", + "--client", + "hermes", + "--skill", + "--json", + ]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { + skill: { state: "aligned" }, + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "hermes", + config_path: config, + state: "aligned", + }), + ]), + }, + }); + expect( + (await run(["setup", "--client", "hermes", "--yes", "--json"])).json, + ).toMatchObject({ appliedActions: [] }); + expect((await run(["uninstall", "--json"])).exitCode).toBe(0); + expect(parse(await readFile(config, "utf8"))).toEqual(parse(original)); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + } + expect(await readFile(rootConfig, "utf8")).toBe("model: root-model\n"); + await expect(access(`${rootConfig}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect( + access(join(root, "skills", "reverse-engineer-anything")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); +} +for (const name of ["missing", "deleted", "ghost", "../escape", "root"]) { + cliTest( + `Hermes rejects unusable sticky profile ${name} without writing a fallback home`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-hermes-invalid-profile-"); + const root = join(home, "hermes root"); + await mkdir(root); + await writeFile(join(root, "active_profile"), name); + if (name === "deleted" || name === "ghost") { + await mkdir(join(root, "profiles", name), { recursive: true }); + if (name === "deleted") { + await writeFile( + join(root, "profiles", name, "config.yaml"), + "model: custom\n", + ); + await mkdir(join(root, "profiles", ".deleted")); + await writeFile( + join(root, "profiles", ".deleted", name), + "deleted\n", + ); + } + } + const run = (args: readonly string[]) => + cli.run({ + arguments: args, + cwd: home, + environment: { USERPROFILE: home, HERMES_HOME: root }, + timeoutMs: 20_000, + }); + const rejected = await run([ + "setup", + "--client", + "hermes", + "--yes", + "--json", + ]); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("Hermes profile"), + }); + await expect(access(join(root, "config.yaml"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(join(root, "skills"))).rejects.toMatchObject({ + code: "ENOENT", + }); + if (name !== "missing") return; + const doctor = await run(["doctor", "--client", "hermes", "--json"]); + expect(doctor.exitCode).not.toBe(0); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "hermes", + state: "invalid", + remediation: expect.stringContaining("Hermes profile"), + }), + ]), + }, + }); + const unrelated = await run([ + "setup", + "--client", + "codex", + "--yes", + "--json", + ]); + expect(unrelated.exitCode).toBe(0); + }, + ); +} diff --git a/tests/boundary/cli/piClientSetup.test.ts b/tests/acceptance/setup/piClientSetup.test.ts similarity index 95% rename from tests/boundary/cli/piClientSetup.test.ts rename to tests/acceptance/setup/piClientSetup.test.ts index 6c586999a..bfb1d1b0a 100644 --- a/tests/boundary/cli/piClientSetup.test.ts +++ b/tests/acceptance/setup/piClientSetup.test.ts @@ -98,7 +98,11 @@ cliTest( expect(doctor.exitCode).toBe(0); expect(doctor.json).toMatchObject({ healthy: true, - identity: { registrations: [{ client: "pi", state: "aligned" }] }, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "pi", state: "aligned" }), + ]), + }, }); const repeat = await run(["setup", "--client", "pi", "--yes", "--json"]); @@ -159,7 +163,11 @@ cliTest( expect(doctor.exitCode).toBe(0); expect(doctor.json).toMatchObject({ healthy: true, - identity: { registrations: [{ client: "pi", state: "aligned" }] }, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "pi", state: "aligned" }), + ]), + }, }); await run(["uninstall", "--json"]); expect( @@ -215,7 +223,9 @@ cliTest( expect(doctorMalformed.json).toMatchObject({ healthy: false, identity: { - registrations: [{ client: "pi", state: "invalid" }], + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "pi", state: "invalid" }), + ]), }, }); const removed = await runMalformed(["uninstall", "--json"]); diff --git a/tests/acceptance/setup/qwenProfileSetup.test.ts b/tests/acceptance/setup/qwenProfileSetup.test.ts new file mode 100644 index 000000000..9f52ba08f --- /dev/null +++ b/tests/acceptance/setup/qwenProfileSetup.test.ts @@ -0,0 +1,96 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { parse } from "jsonc-parser"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "keeps Qwen profile registration and shared skill consistent through setup, doctor and uninstall", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-qwen-profile-"); + const home = join(root, "account"); + const profile = join(root, "selected profile"); + const settings = join(profile, "settings.json"); + const skill = join( + home, + ".agents", + "skills", + "reverse-engineer-anything", + "SKILL.md", + ); + const environment = { USERPROFILE: home, QWEN_HOME: profile }; + const original = + '{\n // Keep my configuration.\n "model": {"name": "existing"},\n "mcpServers": {"other": {"command": "other-server"}}\n}\n'; + await mkdir(profile, { recursive: true }); + await writeFile(settings, original); + const run = (arguments_: readonly string[]) => + cli.run({ arguments: [...arguments_, "--json"], environment }); + + const plan = await run(["setup", "--client", "qwen_code", "--dry-run"]); + expect(plan.exitCode, plan.stderr).toBe(0); + expect(plan.json).toMatchObject({ + status: "planned", + plannedActions: [ + { kind: "configure_client", target: settings }, + { + kind: "install_skill", + target: join(home, ".agents", "skills", "reverse-engineer-anything"), + }, + ], + }); + expect(await readFile(settings, "utf8")).toBe(original); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + + const installed = await run(["setup", "--client", "qwen_code", "--yes"]); + expect(installed.exitCode, installed.stderr).toBe(0); + expect(installed.json).toMatchObject({ status: "ready" }); + expect(await readFile(`${settings}.rea.backup`, "utf8")).toBe(original); + const configured = await readFile(settings, "utf8"); + expect(configured).toContain("// Keep my configuration."); + expect(parse(configured)).toMatchObject({ + model: { name: "existing" }, + mcpServers: { + other: { command: "other-server" }, + rea: { + command: + process.platform === "win32" + ? process.execPath + : resolve("scripts/rea.mjs"), + args: + process.platform === "win32" + ? [resolve("scripts/rea.mjs"), "mcp"] + : ["mcp"], + }, + }, + }); + expect(await readFile(skill, "utf8")).toContain( + "name: reverse-engineer-anything", + ); + + const doctor = await run(["doctor", "--client", "qwen_code", "--skill"]); + expect(doctor.exitCode, doctor.stderr).toBe(0); + expect(doctor.json).toMatchObject({ healthy: true }); + const repeated = await run(["setup", "--client", "qwen_code", "--yes"]); + expect(repeated.exitCode, repeated.stderr).toBe(0); + expect(repeated.json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + expect(await readFile(settings, "utf8")).toBe(configured); + + const removed = await run(["uninstall"]); + expect(removed.exitCode, removed.stderr).toBe(0); + expect(removed.json).toMatchObject({ status: "complete" }); + expect(parse(await readFile(settings, "utf8"))).toEqual({ + model: { name: "existing" }, + mcpServers: { other: { command: "other-server" } }, + }); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(join(home, ".qwen"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + 120_000, +); diff --git a/tests/boundary/cli/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts index bfd167a7e..9084e87a9 100644 --- a/tests/boundary/cli/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -32,157 +32,156 @@ afterEach(async () => { await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); }); -describe("browser CLI parity", () => { - it( - "returns the same Evidence discovery and inspection contracts", - async () => { - const browser = await startFakeCdpBrowser({ - sessionTimeline: "same_origin", - webMcpTools: true, - sensitiveShapes: true, - commandEvents: partialScriptEvents, - }); - browsers.push(browser); - const listed = await runCli( - ["list-browser-targets", browser.endpoint, "--json"], - process.env, - ); - expect(listed).toMatchObject({ - operation: "list_browser_targets", - provider: { id: "rea-cdp-browser" }, - normalized_result: { - targets: expect.arrayContaining([ - expect.objectContaining({ target_id: "allowed-page" }), - expect.objectContaining({ target_id: "disallowed-page" }), - ]), +it( + "browser CLI parity returns the same Evidence discovery and inspection contracts", + async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "same_origin", + webMcpTools: true, + sensitiveShapes: true, + commandEvents: partialScriptEvents, + eventsBeforeReply: ["Network.enable"], + }); + browsers.push(browser); + const listed = await runCli( + ["list-browser-targets", browser.endpoint, "--json"], + process.env, + ); + expect(listed).toMatchObject({ + operation: "list_browser_targets", + provider: { id: "rea-cdp-browser" }, + normalized_result: { + targets: expect.arrayContaining([ + expect.objectContaining({ target_id: "allowed-page" }), + expect.objectContaining({ target_id: "disallowed-page" }), + ]), + }, + }); + const inspected = await runCli( + [ + "inspect-web-page", + browser.endpoint, + "allowed-page", + "--observation-ms", + "0", + "--include-console-text", + "--include-json-body-shapes", + "--include-websocket-shapes", + "--json", + ], + process.env, + ); + expect(inspected).toMatchObject({ + operation: "inspect_web_page", + provider: { id: "rea-cdp-browser" }, + normalized_result: { + target: { target_id: "allowed-page" }, + scripts: { + items: [{ cdp_hash: null, length: null, is_module: null }], }, - }); - const inspected = await runCli( - [ - "inspect-web-page", - browser.endpoint, - "allowed-page", - "--observation-ms", - "0", - "--include-console-text", - "--include-json-body-shapes", - "--include-websocket-shapes", - "--json", - ], - process.env, - ); - expect(inspected).toMatchObject({ - operation: "inspect_web_page", - provider: { id: "rea-cdp-browser" }, - normalized_result: { - target: { target_id: "allowed-page" }, - scripts: { - items: [{ cdp_hash: null, length: null, is_module: null }], - }, - network: { - prior_activity_available: false, - requests: [ - expect.objectContaining({ - body_shapes: expect.objectContaining({ status: "included" }), - }), - ], - }, - }, - }); - const inspection = normalizedResult(inspected); - const compared = await runCli( - [ - "compare-web-captures", - JSON.stringify({ inspection }), - JSON.stringify({ inspection }), - "--json", - ], - process.env, - ); - expect(compared).toMatchObject({ - operation: "compare_web_captures", - normalized_result: { - overall_status: "unknown", - dimensions: { - network: { status: "unknown", total_changes: 0 }, - }, - }, - }); - const scenarioCapture = completeScenarioCapture(); - const scenarioCompared = await runCli( - [ - "compare-web-captures", - JSON.stringify(scenarioCapture), - JSON.stringify(scenarioCapture), - "--normalization-json", - '{"rules":[]}', - "--json", - ], - process.env, - ); - expect(scenarioCompared).toMatchObject({ - operation: "compare_web_captures", - normalized_result: { - comparison_kind: "browser_scenario", - overall_status: "unchanged", - alignment: { status: "aligned", aligned_steps: 2 }, - }, - }); - const intentionalPolicy = await runCli( - [ - "compare-web-captures", - JSON.stringify(scenarioCapture), - JSON.stringify(scenarioCapture), - "--normalization-json", - JSON.stringify({ - rules: [ - { - rule_id: "domain", - artifacts: ["url"], - match: "app.example.test", - replacement: "normalized.test", - }, - ], - }), - "--json", - ], - process.env, - ); - expect(intentionalPolicy).toMatchObject({ - normalized_result: { - normalization: { rules: [{ rule_id: "domain" }] }, - }, - }); - - const malformedPolicy = await runCli( - [ - "compare-web-captures", - JSON.stringify(scenarioCapture), - JSON.stringify(scenarioCapture), - "--normalization-json", - "{invalid-policy", - "--json", - ], - process.env, - ); - expect(malformedPolicy).toMatchObject({ - error: "Browser observation failed", - code: "invalid_request", - details: { - operation: "compare_web_captures", - issues: [ - { - path: ["normalization"], - reason: "invalid_format", - expected: "JSON", - }, + network: { + prior_activity_available: false, + requests: [ + expect.objectContaining({ + body_shapes: expect.objectContaining({ status: "included" }), + }), ], }, - }); - }, - INTEGRATION_TEST_TIMEOUT_MS, - ); -}); + }, + }); + const inspection = normalizedResult(inspected); + const compared = await runCli( + [ + "compare-web-captures", + JSON.stringify({ inspection }), + JSON.stringify({ inspection }), + "--json", + ], + process.env, + ); + expect(compared).toMatchObject({ + operation: "compare_web_captures", + normalized_result: { + overall_status: "unknown", + dimensions: { + network: { status: "unknown", total_changes: 0 }, + }, + }, + }); + const scenarioCapture = completeScenarioCapture(); + const scenarioCompared = await runCli( + [ + "compare-web-captures", + JSON.stringify(scenarioCapture), + JSON.stringify(scenarioCapture), + "--normalization-json", + '{"rules":[]}', + "--json", + ], + process.env, + ); + expect(scenarioCompared).toMatchObject({ + operation: "compare_web_captures", + normalized_result: { + comparison_kind: "browser_scenario", + overall_status: "unchanged", + alignment: { status: "aligned", aligned_steps: 2 }, + }, + }); + const intentionalPolicy = await runCli( + [ + "compare-web-captures", + JSON.stringify(scenarioCapture), + JSON.stringify(scenarioCapture), + "--normalization-json", + JSON.stringify({ + rules: [ + { + rule_id: "domain", + artifacts: ["url"], + match: "app.example.test", + replacement: "normalized.test", + }, + ], + }), + "--json", + ], + process.env, + ); + expect(intentionalPolicy).toMatchObject({ + normalized_result: { + normalization: { rules: [{ rule_id: "domain" }] }, + }, + }); + + const malformedPolicy = await runCli( + [ + "compare-web-captures", + JSON.stringify(scenarioCapture), + JSON.stringify(scenarioCapture), + "--normalization-json", + "{invalid-policy", + "--json", + ], + process.env, + ); + expect(malformedPolicy).toMatchObject({ + error: "Browser observation failed", + code: "invalid_request", + details: { + operation: "compare_web_captures", + issues: [ + { + path: ["normalization"], + reason: "invalid_format", + expected: "JSON", + }, + ], + }, + }); + }, + INTEGRATION_TEST_TIMEOUT_MS, +); describe("browser CLI capture parity", () => { it( diff --git a/tests/boundary/cli/clientProfiles.test.ts b/tests/boundary/cli/clientProfiles.test.ts new file mode 100644 index 000000000..996958986 --- /dev/null +++ b/tests/boundary/cli/clientProfiles.test.ts @@ -0,0 +1,786 @@ +import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; +import { parse } from "jsonc-parser"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = [ + { + client: "gemini_cli", + env: { GEMINI_CLI_HOME: "profile" }, + config: "profile/.gemini/settings.json", + skill: "profile/.agents/skills", + }, + { + client: "opencode", + env: { OPENCODE_CONFIG_DIR: "profile" }, + config: "profile/opencode.json", + skill: ".agents/skills", + }, + { + client: "opencode", + env: { OPENCODE_CONFIG_DIR: "" }, + config: ".config/opencode/opencode.json", + skill: ".agents/skills", + }, + { + client: "codex", + env: { CODEX_HOME: "" }, + config: ".codex/config.toml", + skill: ".agents/skills", + }, + { + client: "grok_build", + env: { GROK_HOME: "" }, + config: ".grok/config.toml", + skill: ".agents/skills", + }, + { + client: "omp", + env: { PI_CODING_AGENT_DIR: "profile" }, + config: "profile/mcp.json", + skill: ".agents/skills", + }, + { + client: "devin", + env: { XDG_CONFIG_HOME: "profile" }, + config: "profile/devin/mcp_config.json", + skill: ".agents/skills", + }, + { + client: "antigravity", + env: {}, + config: ".gemini/config/mcp_config.json", + skill: ".gemini/config/skills", + }, +]; +for (const testCase of cases.filter( + ({ client }) => process.platform !== "win32" || client !== "devin", +)) { + cliTest( + `${testCase.client} lifecycle follows the selected roots (${testCase.config})`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-client-profile-"); + const environment: NodeJS.ProcessEnv = { HOME: home, USERPROFILE: home }; + for (const [key, value] of Object.entries(testCase.env)) + environment[key] = value === "profile" ? join(home, value) : value; + // OMP's relative override is relative to the caller, not silently ignored. + if (testCase.client === "omp") + environment.PI_CODING_AGENT_DIR = "profile"; + const config = join(home, testCase.config); + const skillDirectory = join( + home, + testCase.skill, + "reverse-engineer-anything", + ); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment, + timeoutMs: 20_000, + }); + const planned = await run([ + "setup", + "--client", + testCase.client, + "--dry-run", + "--json", + ]); + expect(planned.exitCode).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + plannedActions: expect.arrayContaining([ + expect.objectContaining({ target: config }), + expect.objectContaining({ target: skillDirectory }), + ]), + }); + await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" }); + const setup = await run([ + "setup", + "--client", + testCase.client, + "--yes", + "--json", + ]); + expect(setup.exitCode).toBe(0); + expect(setup.json).toMatchObject({ status: "ready" }); + await expect(access(config)).resolves.toBeUndefined(); + expect( + await readFile(join(skillDirectory, "SKILL.md"), "utf8"), + ).toContain("reverse-engineer-anything"); + await expect(access(join(home, "config.toml"))).rejects.toMatchObject({ + code: "ENOENT", + }); + const doctor = await run([ + "doctor", + "--client", + testCase.client, + "--skill", + "--json", + ]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { skill: { state: "aligned" } }, + }); + if (testCase.client === "grok_build") { + const original = await readFile(config, "utf8"); + await writeFile( + config, + original.replace( + "[mcp_servers.rea]", + "[mcp_servers.rea]\ndisabled = true", + ), + ); + expect( + (await run(["doctor", "--client", "grok_build", "--json"])).exitCode, + ).toBe(0); + await writeFile(config, original); + } + const repeat = await run([ + "setup", + "--client", + testCase.client, + "--yes", + "--json", + ]); + expect(repeat.json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode).toBe(0); + expect(uninstall.json).toMatchObject({ status: "complete" }); + await expect( + access(join(skillDirectory, "SKILL.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); +} + +for (const policy of [{ excluded: ["rea"] }, { allowed: ["other"] }]) { + cliTest( + `Gemini refuses a registration blocked by ${Object.keys(policy)[0]}`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-policy-"); + const config = join(home, ".gemini/settings.json"); + const original = JSON.stringify({ + mcp: policy, + mcpServers: { + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }); + await mkdir(dirname(config), { recursive: true }); + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { HOME: home, USERPROFILE: home }, + timeoutMs: 20_000, + }); + const setup = await run([ + "setup", + "--client", + "gemini_cli", + "--yes", + "--json", + ]); + expect(setup.exitCode).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("mcp." + Object.keys(policy)[0]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(access(config + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + const doctor = await run(["doctor", "--client", "gemini_cli", "--json"]); + expect(doctor.exitCode).toBe(1); + expect(doctor.json).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "stale", + remediation: expect.stringContaining("mcp."), + }), + ]), + }, + }); + }, + ); +} + +cliTest( + "OpenCode diagnoses merged JSON/JSONC and removes owned entries from both files", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-layers-"); + const directory = join(home, ".config/opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "opencode.json"); + const higher = join(directory, "opencode.jsonc"); + const originalLower = JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ], + enabled: true, + }, + other: { type: "local", command: ["other"] }, + }, + }); + const originalHigher = + '// keep this comment\n{"mcp":{"rea":{"enabled":false}},"theme":"system"}\n'; + await writeFile(lower, originalLower); + await writeFile(higher, originalHigher); + const run = (arguments_: readonly string[]) => + cli.run({ + 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); + expect(before.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "stale" }), + ]), + }, + }); + const setup = await run([ + "setup", + "--client", + "opencode", + "--yes", + "--json", + ]); + expect(setup.exitCode).toBe(0); + expect(setup.json).toMatchObject({ status: "ready" }); + expect(await readFile(lower, "utf8")).toBe(originalLower); + expect(await readFile(higher + ".rea.backup", "utf8")).toBe(originalHigher); + expect(await readFile(higher, "utf8")).toContain("// keep this comment"); + expect( + (await run(["doctor", "--client", "opencode", "--json"])).exitCode, + ).toBe(0); + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode).toBe(0); + const report = uninstall.json as { + items?: readonly { name: string; status: string }[]; + }; + const opencodeItems = report.items?.filter( + ({ name }) => name === "opencode", + ); + expect(opencodeItems).toEqual([ + expect.objectContaining({ status: "removed" }), + ]); + expect(parse(await readFile(lower, "utf8"))).toEqual({ + mcp: { other: { type: "local", command: ["other"] } }, + }); + expect(parse(await readFile(higher, "utf8"))).toEqual({ + mcp: {}, + theme: "system", + }); + }, +); + +cliTest( + "malformed lower-priority OpenCode config blocks setup before any writes", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-invalid-layer-"); + const directory = join(home, ".config/opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "opencode.json"); + const higher = join(directory, "opencode.jsonc"); + await writeFile(lower, "malformed"); + await writeFile(higher, "{}"); + const result = await cli.run({ + arguments: ["setup", "--client", "opencode", "--yes", "--json"], + cwd: home, + environment: { HOME: home, USERPROFILE: home }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + }); + expect(await readFile(lower, "utf8")).toBe("malformed"); + expect(await readFile(higher, "utf8")).toBe("{}"); + await expect(access(higher + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini system policy blocks a new registration without modifying managed files", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-managed-"); + const policy = join(home, "managed.json"); + const original = JSON.stringify({ + mcp: { excluded: ["rea"] }, + unrelated: true, + }); + await writeFile(policy, original); + const environment = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: policy, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + }; + const setup = await cli.run({ + arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"], + cwd: home, + environment, + }); + expect(setup.exitCode).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("mcp.excluded"), + }); + expect(await readFile(policy, "utf8")).toBe(original); + await expect( + access(join(home, ".gemini/settings.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(policy + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini intersects system/default/user allowlists and accepts an empty effective allowlist", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-allowlists-"); + const system = join(home, "system.json"); + const defaults = join(home, "defaults.json"); + const user = join(home, ".gemini/settings.json"); + await mkdir(dirname(user), { recursive: true }); + await writeFile( + system, + JSON.stringify({ mcp: { allowed: ["rea", "other"] } }), + ); + await writeFile(defaults, JSON.stringify({ mcp: { allowed: ["other"] } })); + await writeFile( + user, + JSON.stringify({ mcp: { allowed: ["rea", "other"] } }), + ); + const environment = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults, + }; + const run = () => + cli.run({ + arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"], + cwd: home, + environment, + }); + const blocked = await run(); + expect(blocked.exitCode).toBe(1); + expect(blocked.json).toMatchObject({ + status: "needs_human", + appliedActions: [], + }); + await writeFile(defaults, JSON.stringify({ mcp: { allowed: ["rea"] } })); + const allowed = await run(); + expect(allowed.exitCode).toBe(0); + expect(allowed.json).toMatchObject({ status: "ready" }); + expect(JSON.parse(await readFile(defaults, "utf8"))).toEqual({ + mcp: { allowed: ["rea"] }, + }); + // The consumer treats an empty consolidated allowlist as unrestricted. + await writeFile(defaults, JSON.stringify({ mcp: { allowed: [] } })); + const empty = await run(); + expect(empty.exitCode).toBe(0); + expect(empty.json).toMatchObject({ status: "ready", appliedActions: [] }); + }, +); + +cliTest( + "OpenCode doctor sees a global registration when the custom directory is absent", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-absent-custom-"); + const global = join(home, ".config/opencode/opencode.json"); + const custom = join(home, "missing-custom"); + await mkdir(dirname(global), { recursive: true }); + await writeFile( + global, + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ], + enabled: true, + }, + }, + }), + ); + const run = (args: readonly string[]) => + cli.run({ + arguments: args, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + OPENCODE_CONFIG_DIR: custom, + }, + }); + const doctor = await run(["doctor", "--client", "opencode", "--json"]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "aligned" }), + ]), + }, + }); + const planned = await run([ + "setup", + "--client", + "opencode", + "--dry-run", + "--json", + ]); + expect(planned.json).toMatchObject({ + clientStates: expect.arrayContaining([ + expect.objectContaining({ + client: expect.objectContaining({ name: "opencode" }), + detected: true, + }), + ]), + }); + await expect(access(custom)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +cliTest( + "OpenCode retains the XDG global layer when a custom directory is selected", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-opencode-global-custom-"); + const global = join(home, ".config/opencode/opencode.json"); + const custom = join(home, "custom/opencode.jsonc"); + await mkdir(dirname(global), { recursive: true }); + await mkdir(dirname(custom), { recursive: true }); + const owned = { + type: "local", + command: [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ], + enabled: true, + }; + const original = JSON.stringify({ + mcp: { rea: owned, other: { type: "local", command: ["other"] } }, + }); + await writeFile(global, original); + await writeFile(custom, "{}"); + const run = (args: readonly string[]) => + cli.run({ + arguments: args, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + OPENCODE_CONFIG_DIR: dirname(custom), + }, + }); + const doctor = await run(["doctor", "--client", "opencode", "--json"]); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "opencode", state: "aligned" }), + ]), + }, + }); + await writeFile(global, "malformed"); + expect( + (await run(["setup", "--client", "opencode", "--yes", "--json"])).json, + ).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(global), + }); + expect(await readFile(custom, "utf8")).toBe("{}"); + await writeFile(global, original); + expect( + (await run(["setup", "--client", "opencode", "--yes", "--json"])) + .exitCode, + ).toBe(0); + expect((await run(["uninstall", "--json"])).exitCode).toBe(0); + expect(parse(await readFile(global, "utf8"))).toEqual({ + mcp: { other: { type: "local", command: ["other"] } }, + }); + expect(parse(await readFile(custom, "utf8"))).toEqual({ mcp: {} }); + }, +); + +cliTest( + "Gemini blocks a conflicting system definition and accepts an identical managed entry", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-managed-server-"); + const user = join(home, ".gemini/settings.json"); + const system = join(home, "system.json"); + const defaults = join(home, "defaults.json"); + const environment = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults, + }; + const run = (args: readonly string[]) => + cli.run({ arguments: args, cwd: home, environment }); + const command = ["setup", "--client", "gemini_cli", "--yes", "--json"]; + // A user registration overrides a default definition, including its entire env object. + await writeFile( + defaults, + JSON.stringify({ + mcpServers: { + rea: { command: "managed-default", env: { EXTRA: "default" } }, + }, + }), + ); + expect((await run(command)).exitCode).toBe(0); + const original = await readFile(user, "utf8"); + const managed = JSON.stringify({ + mcpServers: { rea: { command: "managed-override", args: [] } }, + unrelated: true, + }); + await writeFile(system, managed); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(system), + }); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).json, + ).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "stale", + remediation: expect.stringContaining("mcpServers.rea"), + }), + ]), + }, + }); + expect(await readFile(user, "utf8")).toBe(original); + expect(await readFile(system, "utf8")).toBe(managed); + const aligned = JSON.stringify({ + mcpServers: JSON.parse(original).mcpServers, + }); + await writeFile(system, aligned); + expect((await run(command)).json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).exitCode, + ).toBe(0); + expect(await readFile(system, "utf8")).toBe(aligned); + await expect(access(system + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini workspace policy follows persisted trust and explicit trust overrides", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-workspace-policy-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const trust = join(home, "trust.json"); + await mkdir(dirname(workspace), { recursive: true }); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + GEMINI_CLI_TRUSTED_FOLDERS_PATH: trust, + }; + const run = (args: readonly string[]) => + cli.run({ arguments: args, cwd: project, environment }); + const command = ["setup", "--client", "gemini_cli", "--yes", "--json"]; + const original = JSON.stringify({ + mcp: { excluded: ["rea"] }, + unrelated: true, + }); + await writeFile(workspace, original); + await writeFile( + trust, + JSON.stringify({ [home]: "TRUST_FOLDER", [project]: "DO_NOT_TRUST" }), + ); + expect((await run(command)).exitCode).toBe(0); + await writeFile( + trust, + JSON.stringify({ [join(project, "child")]: "TRUST_PARENT" }), + ); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + appliedActions: [], + remediation: expect.stringContaining(workspace), + }); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).json, + ).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "stale", + remediation: expect.stringContaining("mcp.excluded"), + }), + ]), + }, + }); + environment.GEMINI_CLI_TRUST_WORKSPACE = "false"; + expect((await run(command)).json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + environment.GEMINI_CLI_TRUST_WORKSPACE = "true"; + await writeFile(workspace, JSON.stringify({ mcp: { allowed: ["other"] } })); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("mcp.allowed"), + }); + // Restricted mode takes precedence over an explicit trust opt-in. + environment.GEMINI_RESTRICTED_MODE = "true"; + expect((await run(command)).json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + expect(JSON.parse(await readFile(workspace, "utf8"))).toEqual({ + mcp: { allowed: ["other"] }, + }); + await expect(access(workspace + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini workspace definitions use shallow precedence and remain read-only", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-workspace-server-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const system = join(home, "system.json"); + await mkdir(dirname(workspace), { recursive: true }); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + GEMINI_CLI_TRUST_WORKSPACE: "true", + }; + const run = (args: readonly string[]) => + cli.run({ arguments: args, cwd: project, environment }); + const command = ["setup", "--client", "gemini_cli", "--yes", "--json"]; + const original = JSON.stringify({ + mcpServers: { rea: { command: "project-server", args: [] } }, + }); + await writeFile(workspace, original); + expect((await run(command)).json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(workspace), + }); + await expect( + access(join(home, ".gemini/settings.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + // Obtain this setup invocation's actual command while workspace settings are untrusted. + environment.GEMINI_CLI_TRUST_WORKSPACE = "false"; + expect((await run(command)).exitCode).toBe(0); + const user = JSON.parse( + await readFile(join(home, ".gemini/settings.json"), "utf8"), + ); + environment.GEMINI_CLI_TRUST_WORKSPACE = "true"; + // System's entire entry wins over the workspace entry. + await writeFile(system, JSON.stringify({ mcpServers: user.mcpServers })); + expect((await run(command)).exitCode).toBe(0); + expect( + (await run(["doctor", "--client", "gemini_cli", "--json"])).exitCode, + ).toBe(0); + expect(await readFile(workspace, "utf8")).toBe(original); + await expect(access(workspace + ".rea.backup")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "Gemini validates trust configuration and honors a disabled folder trust feature", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-gemini-trust-invalid-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const user = join(home, ".gemini/settings.json"); + const trust = join(home, ".gemini/trustedFolders.json"); + await mkdir(dirname(workspace), { recursive: true }); + await mkdir(dirname(user), { recursive: true }); + await writeFile(workspace, JSON.stringify({ mcp: { excluded: ["rea"] } })); + await writeFile(trust, JSON.stringify({ [project]: "INVALID" })); + const run = () => + cli.run({ + arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"], + cwd: project, + environment: { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + }, + }); + expect((await run()).json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(trust), + }); + await expect(access(user)).rejects.toMatchObject({ code: "ENOENT" }); + await rm(trust); + await writeFile( + user, + JSON.stringify({ security: { folderTrust: { enabled: false } } }), + ); + expect((await run()).json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("mcp.excluded"), + }); + }, +); diff --git a/tests/boundary/cli/hermesClientSetup.test.ts b/tests/boundary/cli/hermesClientSetup.test.ts deleted file mode 100644 index bd65d735b..000000000 --- a/tests/boundary/cli/hermesClientSetup.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { parse } from "yaml"; -import { expect } from "vitest"; - -import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { cliTest } from "../../support/cli/cliFixture.js"; - -cliTest( - "Hermes setup, doctor and uninstall use the expanded profile and its personal skill", - async ({ cli }) => { - const home = await createTestTempDirectory("rea-hermes-cli-"); - const profile = join(home, "profile"); - const config = join(profile, "config.yaml"); - const skill = join(profile, "skills/reverse-engineer-anything/SKILL.md"); - const original = - "# user settings\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n"; - await mkdir(profile); - await writeFile(config, original); - const run = (arguments_: readonly string[]) => - cli.run({ - arguments: arguments_, - cwd: home, - environment: { - HOME: home, - USERPROFILE: home, - HERMES_HOME: " ${HOME}/profile ", - }, - timeoutMs: 20_000, - }); - const planned = await run([ - "setup", - "--client", - "hermes", - "--dry-run", - "--json", - ]); - expect(planned.exitCode).toBe(0); - expect(planned.json).toMatchObject({ - status: "planned", - plannedActions: expect.arrayContaining([ - expect.objectContaining({ target: config }), - expect.objectContaining({ - target: join(profile, "skills/reverse-engineer-anything"), - }), - ]), - }); - expect(await readFile(config, "utf8")).toBe(original); - await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); - const applied = await run([ - "setup", - "--client", - "hermes", - "--yes", - "--json", - ]); - expect(applied.exitCode).toBe(0); - expect(applied.json).toMatchObject({ status: "ready" }); - expect(parse(await readFile(config, "utf8"))).toMatchObject({ - model: "custom", - mcp_servers: { - other: { command: "other-server" }, - rea: { enabled: true }, - }, - }); - expect(await readFile(config + ".rea.backup", "utf8")).toBe(original); - expect(await readFile(skill, "utf8")).toContain( - "reverse-engineer-anything", - ); - await expect( - access(join(home, ".agents/skills/reverse-engineer-anything/SKILL.md")), - ).rejects.toMatchObject({ code: "ENOENT" }); - const doctor = await run([ - "doctor", - "--client", - "hermes", - "--skill", - "--json", - ]); - expect(doctor.exitCode).toBe(0); - expect(doctor.json).toMatchObject({ - healthy: true, - identity: { skill: { state: "aligned" } }, - }); - const repeat = await run([ - "setup", - "--client", - "hermes", - "--yes", - "--json", - ]); - expect(repeat.json).toMatchObject({ status: "ready", appliedActions: [] }); - const uninstall = await run(["uninstall", "--json"]); - expect(uninstall.exitCode).toBe(0); - expect(parse(await readFile(config, "utf8"))).toEqual(parse(original)); - await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); - }, -); diff --git a/tests/boundary/filesystem/ompClientConfiguration.test.ts b/tests/boundary/filesystem/ompClientConfiguration.test.ts index 0f5aeb2cc..7a961e08d 100644 --- a/tests/boundary/filesystem/ompClientConfiguration.test.ts +++ b/tests/boundary/filesystem/ompClientConfiguration.test.ts @@ -38,9 +38,9 @@ describe("OMP configuration paths", () => { expect(ompPath({ PI_CODING_AGENT_DIR: "/custom/agent" })?.configPath).toBe( "/custom/agent/mcp.json", ); - // A relative override names a different file in every working directory. + // Match OMP by resolving the override against the caller working directory. expect(ompPath({ PI_CODING_AGENT_DIR: "agent" })?.configPath).toBe( - "/home/a/.omp/agent/mcp.json", + resolve("agent", "mcp.json"), ); }); diff --git a/tests/boundary/filesystem/zipDetection.test.ts b/tests/boundary/filesystem/zipDetection.test.ts index 3e1878db4..07336e61a 100644 --- a/tests/boundary/filesystem/zipDetection.test.ts +++ b/tests/boundary/filesystem/zipDetection.test.ts @@ -33,6 +33,80 @@ describe("ZIP signature detection", () => { }, ); + it("uses ZIP magic before the filename suffix", async () => { + const root = await createTestTempDirectory("rea-zip-suffix-"); + const apk = join(root, "renamed.apk"); + const program = join(root, "program.apk"); + await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + await writeFile(program, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + expect( + (await classifyAndHashRoot(apk, false, await lstat(apk))).format, + ).toBe("apk"); + expect( + (await classifyAndHashRoot(program, false, await lstat(program))).format, + ).toBe("elf"); + }); + + it("requires container magic for asar, pkg, and dmg suffixes", async () => { + const root = await createTestTempDirectory("rea-container-magic-"); + const asar = join(root, "app.asar"); + const textAsar = join(root, "notes.asar"); + const pkg = join(root, "install.pkg"); + const textPkg = join(root, "notes.pkg"); + const dmg = join(root, "image.dmg"); + const textDmg = join(root, "notes.dmg"); + const headerSize = 20; + await writeFile( + asar, + Buffer.from([ + 4, + 0, + 0, + 0, + headerSize, + 0, + 0, + 0, + headerSize - 4, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0x7b, + 0x7d, + ]), + ); + await writeFile(textAsar, Buffer.from("not an asar")); + await writeFile(pkg, Buffer.from("xar!")); + await writeFile(textPkg, Buffer.from("not a pkg")); + const image = Buffer.alloc(512); + image.write("koly", 0, "ascii"); + await writeFile(dmg, image); + await writeFile(textDmg, Buffer.from("not a dmg")); + expect( + (await classifyAndHashRoot(asar, false, await lstat(asar))).format, + ).toBe("asar"); + expect( + (await classifyAndHashRoot(textAsar, false, await lstat(textAsar))) + .format, + ).toBe("file"); + expect( + (await classifyAndHashRoot(pkg, false, await lstat(pkg))).format, + ).toBe("pkg"); + expect( + (await classifyAndHashRoot(textPkg, false, await lstat(textPkg))).format, + ).toBe("file"); + expect( + (await classifyAndHashRoot(dmg, false, await lstat(dmg))).format, + ).toBe("dmg"); + expect( + (await classifyAndHashRoot(textDmg, false, await lstat(textDmg))).format, + ).toBe("file"); + }); + it.each([[], [0x50], [0x50, 0x4b], [0x50, 0x4b, 0x03]])( "rejects an incomplete signature %j", async (...bytes) => { diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index 2a2e9dff8..3924f37ab 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -324,7 +324,7 @@ it("cancels a waiting request promptly while keeping later launches behind the a await assertFirmwareCleanup(fixture.launches); }); -it("retains an uncertain workspace and blocks new launches after cleanup failure", async () => { +it("retains an uncertain workspace and lets the next operation create a new root", async () => { const fixture = await firmwareFixture("cleanup-failure"); const first = await fixture.service.execute("inspect_firmware_regions", { path: fixture.path, @@ -335,12 +335,18 @@ it("retains an uncertain workspace and blocks new launches after cleanup failure }); const workspace = fixture.launches[0]?.cwd; expect(workspace).toBeDefined(); - if (workspace !== undefined) await access(workspace); + if (workspace === undefined) return; + await access(workspace); const next = await fixture.service.execute("inspect_firmware_regions", { path: fixture.path, }); expect(next).toMatchObject({ ok: false, error: { cleanupIncomplete: true } }); - expect(fixture.launches).toHaveLength(1); + expect(fixture.launches).toHaveLength(2); + const nextWorkspace = fixture.launches[1]?.cwd; + expect(nextWorkspace).toBeDefined(); + expect(nextWorkspace).not.toBe(workspace); + await access(workspace); + if (nextWorkspace !== undefined) await access(nextWorkspace); }); it("accepts another build on the verified release line and reports it", async () => { diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts new file mode 100644 index 000000000..536b7afeb --- /dev/null +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -0,0 +1,281 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { compactAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; +import { + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + parseMcpInputSchemaProfile, +} from "../../../src/config/mcpInputSchemaProfile.js"; +import { processScenarioSchema } from "../../../src/domain/process/processScenario.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { ToolResultDelivery } from "../../../src/server/toolResult.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; + +interface AdvertisedTool { + readonly name: string; + readonly inputSchema: Record; + readonly outputSchema?: Record | undefined; + readonly description?: string | undefined; +} + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const listToolsThroughTransport = async ( + server: EvidenceMcpServer, +): Promise => { + const client = new Client({ name: "compact-profile", version: "0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + return (await client.listTools()).tools; + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}; + +const registerCatalog = (server: EvidenceMcpServer): void => { + for (const contract of TOOL_CONTRACTS) + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => ({ content: [] }), + ); +}; + +const compactServer = (): EvidenceMcpServer => + new EvidenceMcpServer( + { name: "compact-profile", version: "0" }, + { capabilities: {} }, + undefined, + new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE), + { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES }, + ); + +const fullServer = (): EvidenceMcpServer => + new EvidenceMcpServer( + { name: "full-profile", version: "0" }, + { capabilities: {} }, + undefined, + new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE), + ); + +describe("MCP compact input schema profile", () => { + it("advertises every tool input schema within the provider budget", async () => { + const server = compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + expect(tools.map(({ name }) => name).sort()).toEqual( + TOOL_CONTRACTS.map(({ name }) => name).sort(), + ); + const oversized = tools.filter( + ({ inputSchema }) => + serializedBytes(inputSchema) > COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + ); + expect(oversized.map(({ name }) => name)).toEqual([]); + }); + + it("keeps output schemas and tool descriptions identical to the default profile", async () => { + const compact = compactServer(); + const full = fullServer(); + registerCatalog(compact); + registerCatalog(full); + const [compactTools, fullTools] = await Promise.all([ + listToolsThroughTransport(compact), + listToolsThroughTransport(full), + ]); + const fullByName = new Map(fullTools.map((tool) => [tool.name, tool])); + expect(compactTools.length).toBe(fullTools.length); + for (const tool of compactTools) { + const reference = fullByName.get(tool.name); + if (reference === undefined) throw new Error(`Missing ${tool.name}`); + expect(tool.outputSchema, tool.name).toEqual(reference.outputSchema); + expect(tool.description, tool.name).toBe(reference.description); + } + }); + + it("keeps compact schemas valid and accepting canonical examples", async () => { + const server = compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const contractsByName = new Map( + TOOL_CONTRACTS.map((contract) => [contract.name, contract]), + ); + for (const tool of tools) { + expect( + ajv.validateSchema(tool.inputSchema), + `${tool.name}: ${ajv.errorsText(ajv.errors)}`, + ).toBe(true); + const contract = contractsByName.get(tool.name); + const validate = ajv.compile(tool.inputSchema); + for (const example of contract?.examples ?? []) + expect(validate(example.input), `${tool.name}: ${example.title}`).toBe( + true, + ); + } + }); + + it("reduces at most the structurally oversized tools", async () => { + const full = fullServer(); + registerCatalog(full); + const tools = await listToolsThroughTransport(full); + const reduced: string[] = []; + for (const tool of tools) { + const { presentation } = compactAdvertisedInputSchema( + tool.inputSchema, + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + ); + if (presentation === "reduced") reduced.push(tool.name); + } + expect(reduced.sort()).toEqual([ + "build_reconstruction_obligation_ledger", + "capture_browser_scenario", + "compare_application_versions", + "compare_web_captures", + "evaluate_reconstruction_coverage", + "project_managed_application_graph", + ]); + expect(tools.length).toBe(TOOL_CONTRACTS.length); + }); + + it("retains parameter types and avoids client-side reference expansion", async () => { + const server = compactServer(); + registerCatalog(server); + const tools = await listToolsThroughTransport(server); + for (const tool of tools) { + // Kimi Code 2.1.1 expands references and defaults description-only + // properties to string. Inline schemas with explicit shallow types + // avoid both conversions for the structurally oversized tools. + expect(JSON.stringify(tool.inputSchema), tool.name).not.toContain( + '"$ref":', + ); + } + const comparison = tools.find( + ({ name }) => name === "compare_managed_members", + ); + expect(comparison?.inputSchema.properties).toMatchObject({ + left: { + properties: { + normalized_result: { + type: ["null", "boolean", "object", "array", "number", "string"], + }, + }, + }, + }); + const ledger = tools.find( + ({ name }) => name === "build_reconstruction_obligation_ledger", + ); + expect(ledger?.inputSchema.properties).toMatchObject({ + evidence_bundle: { type: "object" }, + reviewed_obligations: { type: "array" }, + manifest: { type: "object" }, + }); + const coverage = tools.find( + ({ name }) => name === "evaluate_reconstruction_coverage", + ); + expect(coverage?.inputSchema.properties).toMatchObject({ + coverage: { type: "object" }, + boundary_id: { type: "string" }, + }); + }); + + it("parses the profile selection the server startup reads", () => { + expect(parseMcpInputSchemaProfile("compact")).toEqual({ + ok: true, + value: "compact", + }); + expect(parseMcpInputSchemaProfile("surprise")).not.toHaveProperty("value"); + }); +}); + +describe("MCP compact input schema canonical enforcement", () => { + it("still rejects canonical-invalid arguments the compact advertisement accepts", async () => { + const server = compactServer(); + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === "capture_process_scenario", + ); + if (contract === undefined) + throw new Error("Process capture contract was not registered"); + let handlerCalled = false; + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => { + handlerCalled = true; + return { + content: [{ type: "text" as const, text: "handler ran" }], + isError: true, + }; + }, + ); + const client = new Client({ name: "compact-validation", version: "0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === contract.name, + ); + if (advertised === undefined) + throw new Error("Process capture tool was not advertised"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const validate = ajv.compile(advertised.inputSchema); + const valid = { executable: "node", environment: { APP_MODE: "test" } }; + const reserved = { + executable: "node", + environment: { REA_PROCESS_RUN_ID: "caller-value" }, + }; + expect(validate(valid)).toBe(true); + expect(processScenarioSchema.safeParse(valid).success).toBe(true); + expect(processScenarioSchema.safeParse(reserved).success).toBe(false); + const result = await client.callTool({ + name: contract.name, + arguments: reserved, + }); + expect(result.isError).toBe(true); + expect(handlerCalled).toBe(false); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } + }); + + it("compacts plain standard schemas that are not Zod instances", async () => { + // Mirrors registerWebNetworkCaptureTool: a plain standard schema reusing + // the advertised projection while owning its own permissive validator. + const server = compactServer(); + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === "inspect_web_network_capture", + ); + if (contract === undefined) + throw new Error("Network capture contract was not registered"); + const registration = toolRegistrationOptions(contract); + const inputSchema = { + "~standard": { + ...registration.inputSchema["~standard"], + vendor: "rea-application", + validate: (value: unknown) => ({ value }), + }, + }; + server.registerTool( + contract.name, + { ...registration, inputSchema }, + async () => ({ content: [] }), + ); + const tools = await listToolsThroughTransport(server); + const advertised = tools.find(({ name }) => name === contract.name); + if (advertised === undefined) + throw new Error("Network capture tool was not advertised"); + expect(serializedBytes(advertised.inputSchema)).toBeLessThanOrEqual( + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + ); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(advertised.inputSchema)).toBe(true); + }); +}); diff --git a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts index ba90eafdf..72b616562 100644 --- a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts +++ b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts @@ -43,12 +43,14 @@ const exerciseDeliveryFailure = async ( recordEvidence, delivery, ); - server.registerTool("large_failure", { inputSchema: {} }, async () => - oversizedFailure(), + server.registerTool( + "large_failure", + { inputSchema: z.object({}) }, + async () => oversizedFailure(), ); server.registerTool( "healthy", - { inputSchema: {} }, + { inputSchema: z.object({}) }, async (): Promise => ({ content: [{ type: "text", text: "still connected" }], }), @@ -161,7 +163,7 @@ it("delivers an error whose text fits although structured and text copies would }, delivery, ); - server.registerTool("mid_failure", { inputSchema: {} }, async () => + server.registerTool("mid_failure", { inputSchema: z.object({}) }, async () => failureWithName(nameBytes), ); const client = new Client({ name: "mid-sized-error-client", version: "1" }); diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 8156aff97..965dc2af5 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -11,6 +11,7 @@ import { parseProcessScenario, } from "../../../src/domain/process/processScenario.js"; import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; +import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { itWithCaptureCapability } from "./processCaptureCapability.js"; @@ -66,6 +67,9 @@ itWithCaptureCapability( port_normalization_version: "contextual-endpoints-v2", rendered_line_format: "trailing-spaces-trimmed-v1", }); + expect(capture.manifest.comparison_contract).not.toHaveProperty( + "pattern_normalization_version", + ); expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); expect(capture.cleanup).toMatchObject({ owned_process_group: "verified", @@ -74,6 +78,67 @@ itWithCaptureCapability( }, ); +itWithCaptureCapability.each([ + { pattern: "AB", replacement: "$&$&", expected: "$&$&" }, + { + pattern: "AB", + replacement: "$$ $` $' $1", + expected: "$$ $` $' $1", + }, + { pattern: "", replacement: "-", expected: "-A-B-" }, +])( + "preserves literal replacement $replacement and rejects legacy comparison semantics", + async ({ pattern, replacement, expected }) => { + const root = await createTestTempDirectory("rea-process-literal-"); + const scenario = parseProcessScenario({ + executable: process.execPath, + arguments: ["-e", 'process.stdout.write("AB")'], + working_directory: root, + normalization: { + paths: false, + pids: false, + ports: false, + patterns: [{ pattern, replacement }], + }, + settle_ms: 100, + }); + const result = await captureProcessScenario(scenario); + if (!result.ok) throw result.error; + const capture = parseProcessCapture( + createProcessCaptureEvidence(scenario, result.value).normalized_result, + ); + expect(capture.frames.map(({ data }) => data).join("")).toBe(expected); + expect( + capture.frames.map(({ raw_data, data }) => raw_data ?? data).join(""), + ).toBe("AB"); + expect(capture.cleanup).toMatchObject({ + owned_process_group: "verified", + temporary_root: "removed", + }); + expect(compareProcessCaptures(capture, capture).terminal).toBe("unchanged"); + const legacyContract = { ...capture.manifest.comparison_contract }; + delete legacyContract.pattern_normalization_version; + const legacy = parseProcessCapture({ + ...capture, + manifest: { + ...capture.manifest, + comparison_contract: legacyContract, + comparison_contract_sha256: digestProcessCommitment(legacyContract), + }, + }); + expect(() => compareProcessCaptures(legacy, capture)).toThrow( + expect.objectContaining({ + issues: [ + expect.objectContaining({ + expected: ["pattern_normalization_version"], + }), + ], + }), + ); + }, + 20_000, +); + itWithLinuxCaptureCapability.each([false, true])( "honors pids=%s in owned Linux capture samples and committed Evidence", async (pids) => { diff --git a/tests/boundary/process/processCaptureProgress.test.ts b/tests/boundary/process/processCaptureProgress.test.ts new file mode 100644 index 000000000..3ffaafd12 --- /dev/null +++ b/tests/boundary/process/processCaptureProgress.test.ts @@ -0,0 +1,558 @@ +import { spawn } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { itWithCaptureCapability } from "./processCaptureCapability.js"; + +const PROGRESS_INTERVAL_MS = 100; + +const progressLineSchema = z.object({ + rea_progress: z.object({ + phase: z.string(), + completed: z.number().nonnegative(), + total: z.null(), + message: z.string(), + sequence: z.number().int().positive(), + terminal: z.boolean().optional(), + }), +}); + +const progressMessageSchema = z.object({ + elapsed_ms: z.number().int().nonnegative(), + frames: z.number().int().nonnegative(), + samples: z.number().int().nonnegative(), + interactions: z.number().int().nonnegative(), + disposition: z + .enum(["exited", "timeout", "idle_timeout", "cancelled", "failed"]) + .optional(), + owned_process_group: z + .enum(["cleaned", "failed", "unverified", "not_required"]) + .optional(), + terminal_renderer: z + .enum(["cleaned", "failed", "unverified", "not_required"]) + .optional(), + temporary_root: z + .enum(["cleaned", "failed", "unverified", "not_required"]) + .optional(), +}); + +type ProgressLine = z.infer["rea_progress"]; +type ParsedProgress = ProgressLine & { + readonly details: z.infer; +}; + +const parseProgressMessage = (message: string) => { + const matched = + /^elapsed_ms=(?\d+) frames=(?\d+) samples=(?\d+) interactions=(?\d+)(?: disposition=(?exited|timeout|idle_timeout|cancelled|failed))?(?: owned_process_group=(?cleaned|failed|unverified|not_required) terminal_renderer=(?cleaned|failed|unverified|not_required) temporary_root=(?cleaned|failed|unverified|not_required))?$/u.exec( + message, + ); + if (matched?.groups === undefined) + throw new Error(`Progress message is not capture status: ${message}`); + return progressMessageSchema.parse({ + elapsed_ms: Number(matched.groups.elapsed_ms), + frames: Number(matched.groups.frames), + samples: Number(matched.groups.samples), + interactions: Number(matched.groups.interactions), + ...(matched.groups.disposition === undefined + ? {} + : { disposition: matched.groups.disposition }), + ...(matched.groups.owned_process_group === undefined + ? {} + : { + owned_process_group: matched.groups.owned_process_group, + terminal_renderer: matched.groups.terminal_renderer, + temporary_root: matched.groups.temporary_root, + }), + }); +}; + +interface CliCapture { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; + readonly lines: readonly { + readonly update: ParsedProgress; + }[]; +} + +const writeScenario = async ( + prefix: string, + scenario: Readonly>, +): Promise => { + const root = await createTestTempDirectory(prefix); + const path = join(root, "scenario.json"); + await writeFile(path, JSON.stringify(scenario)); + return path; +}; + +const runCaptureCli = async ( + scenarioPath: string, + options: { readonly stopOnRunning?: boolean } = {}, +): Promise => { + const child = spawn( + process.execPath, + ["scripts/rea.mjs", "capture-process", scenarioPath, "--json"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let pending = ""; + const lines: string[] = []; + let sawRunning: (() => void) | undefined; + const running = options.stopOnRunning + ? new Promise((resolve) => { + sawRunning = resolve; + }) + : undefined; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + pending += chunk.toString(); + const parts = pending.split("\n"); + pending = parts.pop() ?? ""; + for (const line of parts) { + if (line.length === 0) continue; + lines.push(line); + if (line.includes('"phase":"running"')) sawRunning?.(); + } + }); + const closed = new Promise<{ code: number | null }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve({ code })); + }); + if (running !== undefined) { + let timer: ReturnType | undefined; + const appeared = await Promise.race([ + running.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 8_000); + }), + ]).finally(() => { + clearTimeout(timer); + }); + if (!appeared) { + child.kill("SIGKILL"); + await closed; + throw new Error( + `capture produced no running progress on stderr:\n${lines.join("\n")}`, + ); + } + child.kill("SIGINT"); + } + const result = await closed; + if (pending.length > 0) lines.push(pending); + stderr = lines.join("\n"); + return { + stdout, + stderr, + exitCode: result.code, + lines: lines.map((line) => { + const update = progressLineSchema.parse(JSON.parse(line)).rea_progress; + return { + update: { ...update, details: parseProgressMessage(update.message) }, + }; + }), + }; +}; + +const assertMonotonic = (updates: readonly ParsedProgress[]): void => { + for (const [index, update] of updates.entries()) { + expect(update.total).toBeNull(); + expect(update.message).not.toMatch(/%|\bpercent/iu); + const previous = updates[index - 1]; + if (previous === undefined) continue; + expect(update.completed).toBeGreaterThanOrEqual(previous.completed); + expect(update.sequence).toBe(previous.sequence + 1); + } +}; + +const assertRateBound = ( + intermediateCount: number, + elapsedMs: number, +): void => { + // Pipe/transport buffering can deliver correctly spaced sends together. + // Exact send spacing belongs to the reporter's controlled-clock test. + expect(intermediateCount).toBeGreaterThan(1); + expect(intermediateCount).toBeLessThanOrEqual( + Math.floor(elapsedMs / PROGRESS_INTERVAL_MS) + 1, + ); +}; + +const childScript = (source: string) => ["-e", source]; + +const connectProgressClient = async ( + recordNotifications = false, +): Promise<{ + readonly client: Client; + readonly notifications: string[]; +}> => { + const session = createTestBinarySession(() => { + throw new Error("Process capture must not launch a binary provider"); + }); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ + name: "process-capture-progress", + version: "1", + }); + const notifications: string[] = []; + if (recordNotifications) { + client.setNotificationHandler("notifications/progress", (notification) => { + notifications.push(JSON.stringify(notification)); + }); + } + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + onTestFinished(async () => { + await Promise.allSettled([client.close(), server.close(), session.close()]); + }); + return { client, notifications }; +}; + +itWithCaptureCapability( + "keeps capture Evidence on stdout and live status on stderr for a sparse child", + async () => { + const scenario = await writeScenario("rea-process-progress-sparse-", { + executable: process.execPath, + arguments: childScript( + "process.stdout.write('SPARSE_CHILD_STARTED\\n'); setTimeout(() => process.stdout.write('SPARSE_CHILD_DONE\\n'), 1600);", + ), + timeout_ms: 10_000, + idle_timeout_ms: 10_000, + }); + const capture = await runCaptureCli(scenario); + expect(capture.exitCode, capture.stderr).toBe(0); + expect(capture.stdout).not.toContain("rea_progress"); + const evidence = parseEvidence(JSON.parse(capture.stdout)); + const recorded = parseProcessCapture(evidence.normalized_result); + expect(evidence).toMatchObject({ + operation: "capture_process_scenario", + predicate_type: "rea.process-capture", + }); + expect(recorded.frames.map(({ data }) => data).join("")).toContain( + "SPARSE_CHILD_STARTED", + ); + expect(capture.stderr).not.toContain("SPARSE_CHILD_STARTED"); + expect(capture.stderr).not.toContain("SPARSE_CHILD_DONE"); + const updates = capture.lines.map(({ update }) => update); + assertMonotonic(updates); + const running = updates.filter(({ phase }) => phase === "running"); + expect(running.length).toBeGreaterThanOrEqual(3); + const elapsed = running.map(({ details }) => details.elapsed_ms); + expect(Math.max(...elapsed) - Math.min(...elapsed)).toBeGreaterThanOrEqual( + 1_000, + ); + expect(running.every(({ details }) => details.frames <= 2)).toBe(true); + expect(updates.at(-1)).toMatchObject({ + phase: "cleanup", + terminal: true, + details: { + disposition: "exited", + owned_process_group: "cleaned", + terminal_renderer: "cleaned", + temporary_root: "cleaned", + }, + }); + }, + 30_000, +); + +itWithCaptureCapability( + "bounds stderr progress while a busy child emits many output chunks", + async () => { + const scenario = await writeScenario("rea-process-progress-busy-", { + executable: process.execPath, + arguments: childScript( + "let count = 0; const timer = setInterval(() => { process.stdout.write('BUSY_CHILD_LINE\\n'); count += 1; if (count >= 50) clearInterval(timer); }, 12);", + ), + timeout_ms: 10_000, + idle_timeout_ms: 10_000, + }); + const capture = await runCaptureCli(scenario); + expect(capture.exitCode, capture.stderr).toBe(0); + const evidence = parseEvidence(JSON.parse(capture.stdout)); + const recorded = parseProcessCapture(evidence.normalized_result); + const occurrences = + recorded.frames + .map(({ data }) => data) + .join("") + .split("BUSY_CHILD_LINE").length - 1; + expect(occurrences).toBeGreaterThanOrEqual(40); + expect(capture.stderr).not.toContain("BUSY_CHILD_LINE"); + const updates = capture.lines.map(({ update }) => update); + assertMonotonic(updates); + expect(updates.length).toBeLessThan(occurrences / 2); + const nonTerminal = capture.lines.filter( + ({ update }) => update.terminal !== true, + ); + assertRateBound( + nonTerminal.length, + updates.at(-1)?.details.elapsed_ms ?? 0, + ); + expect( + Math.max(...updates.map(({ details }) => details.frames)), + ).toBeGreaterThanOrEqual(10); + expect(updates.at(-1)?.details.owned_process_group).toBe("cleaned"); + }, + 30_000, +); + +itWithCaptureCapability( + "reports timeout cleanup on stderr without changing Evidence shape", + async () => { + const scenario = await writeScenario("rea-process-progress-timeout-", { + executable: process.execPath, + arguments: childScript("setInterval(() => undefined, 1000);"), + timeout_ms: 700, + idle_timeout_ms: 10_000, + }); + const capture = await runCaptureCli(scenario); + expect(capture.exitCode, capture.stderr).toBe(0); + const evidence = parseEvidence(JSON.parse(capture.stdout)); + const recorded = parseProcessCapture(evidence.normalized_result); + expect(recorded.exit.reason).toBe("timeout"); + expect(capture.stdout).not.toContain("rea_progress"); + const updates = capture.lines.map(({ update }) => update); + assertMonotonic(updates); + expect(updates.at(-1)).toMatchObject({ + phase: "cleanup", + terminal: true, + details: { + disposition: "timeout", + owned_process_group: "cleaned", + }, + }); + }, + 30_000, +); + +itWithCaptureCapability( + "reports cleanup status on stderr when capture is cancelled", + async () => { + const scenario = await writeScenario("rea-process-progress-cancel-", { + executable: process.execPath, + arguments: childScript("setInterval(() => undefined, 1000);"), + timeout_ms: 15_000, + idle_timeout_ms: 15_000, + }); + const capture = await runCaptureCli(scenario, { stopOnRunning: true }); + expect(capture.exitCode).toBe(130); + expect( + analysisCliErrorEnvelopeSchema.parse(JSON.parse(capture.stdout)), + ).toMatchObject({ + code: "cancelled", + details: { operation: "process_capture", cleanup: "complete" }, + }); + expect(capture.stdout).not.toContain("rea_progress"); + const updates = capture.lines.map(({ update }) => update); + assertMonotonic(updates); + expect(updates.at(-1)).toMatchObject({ + phase: "cleanup", + terminal: true, + details: { + disposition: "cancelled", + owned_process_group: "cleaned", + }, + }); + }, + 30_000, +); + +itWithCaptureCapability( + "forwards live capture progress only when the MCP client supplies a token", + async () => { + const silentClient = await connectProgressClient(true); + const { client: tokenClient } = await connectProgressClient(); + const { notifications } = silentClient; + const silent = await silentClient.client.callTool({ + name: "capture_process_scenario", + arguments: { + executable: process.execPath, + arguments: childScript("process.stdout.write('SILENT_CHILD_OK\\n');"), + timeout_ms: 10_000, + idle_timeout_ms: 10_000, + }, + }); + expect(silent.isError, JSON.stringify(silent)).not.toBe(true); + expect(notifications).toEqual([]); + const evidence = toolContract( + "capture_process_scenario", + ).outputSchema.parse(silent.structuredContent); + expect( + evidence.normalized_result.frames.map((frame) => frame.data).join(""), + ).toContain("SILENT_CHILD_OK"); + + const progress: Array<{ + readonly progress: number; + readonly total: number | undefined; + readonly message: string | undefined; + }> = []; + const captured = await tokenClient.callTool( + { + name: "capture_process_scenario", + arguments: { + executable: process.execPath, + arguments: childScript( + "process.stdout.write('TOKEN_CHILD_STARTED\\n'); setTimeout(() => process.stdout.write('TOKEN_CHILD_DONE\\n'), 1200);", + ), + timeout_ms: 10_000, + idle_timeout_ms: 10_000, + }, + }, + { + onprogress: (update) => { + progress.push({ + progress: update.progress, + total: update.total, + message: update.message, + }); + }, + }, + ); + expect(captured.isError, JSON.stringify(captured)).not.toBe(true); + expect(progress.length).toBeGreaterThanOrEqual(3); + expect(progress.every(({ total }) => total === undefined)).toBe(true); + for (const [index, update] of progress.entries()) { + const previous = progress[index - 1]; + if (previous !== undefined) + expect(update.progress).toBeGreaterThanOrEqual(previous.progress); + expect(update.message ?? "").not.toContain("TOKEN_CHILD_STARTED"); + expect(update.message ?? "").not.toContain("TOKEN_CHILD_DONE"); + } + const messages = progress.map(({ message }) => message ?? ""); + expect(messages.some((message) => message.startsWith("running:"))).toBe( + true, + ); + expect(messages.at(-1)).toContain("cleanup:"); + expect(messages.at(-1)).toContain("owned_process_group=cleaned"); + expect(messages.at(-1)).toContain("disposition=exited"); + const nonTerminal = progress.slice(0, -1); + assertRateBound( + nonTerminal.length, + parseProgressMessage((messages.at(-1) ?? "").slice("cleanup: ".length)) + .elapsed_ms, + ); + const tokenEvidence = toolContract( + "capture_process_scenario", + ).outputSchema.parse(captured.structuredContent); + expect( + tokenEvidence.normalized_result.frames + .map((frame) => frame.data) + .join(""), + ).toContain("TOKEN_CHILD_STARTED"); + }, + 30_000, +); + +itWithCaptureCapability( + "reports timeout and cancellation cleanup through MCP progress", + async () => { + const { client } = await connectProgressClient(); + const timeoutProgress: Array<{ + readonly message: string | undefined; + readonly total: number | undefined; + }> = []; + const timedOut = await client.callTool( + { + name: "capture_process_scenario", + arguments: { + executable: process.execPath, + arguments: childScript("setInterval(() => undefined, 1000);"), + timeout_ms: 600, + idle_timeout_ms: 10_000, + }, + }, + { + onprogress: (update) => { + timeoutProgress.push({ + message: update.message, + total: update.total, + }); + }, + }, + ); + expect(timedOut.isError, JSON.stringify(timedOut)).not.toBe(true); + const timedOutEvidence = toolContract( + "capture_process_scenario", + ).outputSchema.parse(timedOut.structuredContent); + expect(timedOutEvidence.normalized_result.exit.reason).toBe("timeout"); + expect(timeoutProgress.every(({ total }) => total === undefined)).toBe( + true, + ); + expect(timeoutProgress.at(-1)?.message).toContain("disposition=timeout"); + expect(timeoutProgress.at(-1)?.message).toContain( + "owned_process_group=cleaned", + ); + + const cancelling = await connectProgressClient(true); + // Prepare the SDK's full output-schema cache before timing capture control. + await cancelling.client.callTool({ name: "binary_session", arguments: {} }); + const controller = new AbortController(); + const stall = setTimeout(() => controller.abort(), 5_000); + onTestFinished(() => clearTimeout(stall)); + const call = cancelling.client.callTool( + { + name: "capture_process_scenario", + arguments: { + executable: process.execPath, + arguments: childScript("setInterval(() => undefined, 1000);"), + timeout_ms: 15_000, + idle_timeout_ms: 15_000, + }, + _meta: { progressToken: "capture-cancel" }, + }, + { signal: controller.signal }, + ); + const completion = call.then( + (reply) => ({ kind: "reply" as const, reply }), + (cause: unknown) => ({ kind: "error" as const, cause }), + ); + try { + await expect + .poll( + () => + cancelling.notifications.some((note) => note.includes("running:")), + { timeout: 4_000 }, + ) + .toBe(true); + controller.abort(); + const outcome = await completion; + if (outcome.kind !== "error") + throw new Error("Cancelled capture must reject its client wait"); + expect(outcome.cause).toMatchObject({ + message: expect.stringMatching(/abort/iu), + }); + } finally { + clearTimeout(stall); + controller.abort(); + await completion; + } + await expect + .poll(() => cancelling.notifications.join("\n")) + .toContain("disposition=cancelled"); + expect(cancelling.notifications.join("\n")).toContain( + "owned_process_group=cleaned", + ); + expect(cancelling.notifications.join("\n")).not.toMatch( + /setInterval|CHILD/u, + ); + }, + 30_000, +); diff --git a/tests/composition/android/application.test.ts b/tests/composition/android/application.test.ts index 3529dc4e8..67e2e1c6f 100644 --- a/tests/composition/android/application.test.ts +++ b/tests/composition/android/application.test.ts @@ -37,12 +37,14 @@ describe("Android application projection", () => { Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]), ), ); - await writer.add( - "lib/arm64-v8a/libreactnativejni.so", + const elf = () => new Uint8ArrayReader( Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), - ), - ); + ); + await writer.add("lib/arm64-v8a/libreactnativejni.so", elf()); + await writer.add("lib/arm64-v8a/libflutter.so", elf()); + await writer.add("lib/arm64-v8a/libunity.so", elf()); + await writer.add("lib/arm64-v8a/libcommunity.so", elf()); await writer.add("assets/index.js", new TextReader("bridge();")); await writer.add("META-INF/FIXTURE.RSA", new TextReader("opaque signing")); await writeFile(path, await writer.close()); @@ -72,20 +74,34 @@ describe("Android application projection", () => { }); expect(left.components.manifests).toHaveLength(1); expect(left.components.dex).toHaveLength(1); - expect(left.components.native_libraries).toHaveLength(1); + expect(left.components.native_libraries).toHaveLength(4); expect(left.components.javascript).toHaveLength(1); expect(left.components.signing).toHaveLength(1); expect(left.runtime_families).toEqual( expect.arrayContaining([ "dalvik-art", + "flutter", "javascript", "native", "react-native", + "unity", ]), ); - expect(left.bridge_candidates).toEqual([ - expect.objectContaining({ basis: "react-native-convention" }), - ]); + expect(left.runtime_families).not.toContain("community"); + expect(left.bridge_candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ basis: "react-native-convention" }), + expect.objectContaining({ basis: "flutter-convention" }), + expect.objectContaining({ basis: "unity-convention" }), + ]), + ); + const community = left.bridge_candidates.filter(({ native_path }) => + native_path.endsWith("libcommunity.so"), + ); + expect(community.length).toBeGreaterThan(0); + expect( + community.every(({ basis }) => basis === "jni-library-convention"), + ).toBe(true); expect(left.limitations).toEqual( expect.arrayContaining([ "A bridge basis is inferred from the native path and is repeated for every managed component.", diff --git a/tests/conformance/contracts/mcpInputSchemaProfile.test.ts b/tests/conformance/contracts/mcpInputSchemaProfile.test.ts new file mode 100644 index 000000000..ababc135e --- /dev/null +++ b/tests/conformance/contracts/mcpInputSchemaProfile.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { parseMcpInputSchemaProfile } from "../../../src/config/mcpInputSchemaProfile.js"; + +describe("MCP input schema profile configuration", () => { + it("accepts the full and compact presentations", () => { + expect(parseMcpInputSchemaProfile(undefined)).toEqual({ + ok: true, + value: undefined, + }); + expect(parseMcpInputSchemaProfile("full")).toEqual({ + ok: true, + value: "full", + }); + expect(parseMcpInputSchemaProfile("compact")).toEqual({ + ok: true, + value: "compact", + }); + }); + + it.each(["", "FULL", "compact ", "minimal", "0"])( + "rejects unknown profiles: %j", + (value) => { + const parsed = parseMcpInputSchemaProfile(value); + if (parsed.ok) throw new Error("Expected invalid profile"); + expect(parsed.error.message).toContain("REA_MCP_INPUT_SCHEMA_PROFILE"); + }, + ); +}); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index bdc6d0102..91ccb92cf 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -17,11 +17,18 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "CODEX_HOME", "COPILOT_HOME", "GROK_HOME", + "GEMINI_CLI_HOME", + "GEMINI_CLI_TRUST_WORKSPACE", + "GEMINI_RESTRICTED_MODE", + "GEMINI_CLI_TRUSTED_FOLDERS_PATH", + "GEMINI_CLI_SYSTEM_SETTINGS_PATH", + "GEMINI_CLI_SYSTEM_DEFAULTS_PATH", "HERMES_DATA_DIR_SUFFIX", "HERMES_HOME", "LOCALAPPDATA", "OMP_PROFILE", "OPENCODE_CONFIG", + "OPENCODE_CONFIG_DIR", "PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "PI_PROFILE", diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index f9d2ddf08..c085b1806 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -196,22 +196,28 @@ const replyToCommand = ( ); return; } - socket.send( - JSON.stringify({ - id: command.id, - result, - }), - ); - if (options.malformedEventOnMethod === command.method) - socket.send("{not-json"); - if (options.malformedEventShapeOnMethod === command.method) - socket.send(JSON.stringify({ method: 42 })); + const reply = () => { + socket.send( + JSON.stringify({ + id: command.id, + result, + }), + ); + if (options.malformedEventOnMethod === command.method) + socket.send("{not-json"); + if (options.malformedEventShapeOnMethod === command.method) + socket.send(JSON.stringify({ method: 42 })); + }; + const eventsFirst = + options.eventsBeforeReply?.includes(command.method) === true; + if (!eventsFirst) reply(); const customEvents = options.commandEvents?.( command, `http://127.0.0.1:${String(port)}`, ); if (customEvents === undefined) emitEvents(socket, command, port, options); else for (const event of customEvents) socket.send(JSON.stringify(event)); + if (eventsFirst) reply(); if (options.closeAfterMethod === command.method) setImmediate(() => socket.close()); }; diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 00d8807c5..54fc76563 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -28,6 +28,8 @@ export interface FakeCdpBrowser { } export interface FakeOptions { + /** Send these commands' producer events before acknowledging completion, as a wire barrier. */ + readonly eventsBeforeReply?: readonly string[]; /** Return a native-shaped command rejection through the actual wire parser. */ readonly commandError?: ( command: FakeCdpCommand, diff --git a/tests/process-global/mcpDeliveryEnvironment.test.ts b/tests/process-global/mcpDeliveryEnvironment.test.ts index 6d8751d5e..51e371b45 100644 --- a/tests/process-global/mcpDeliveryEnvironment.test.ts +++ b/tests/process-global/mcpDeliveryEnvironment.test.ts @@ -1,6 +1,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; import { afterEach, expect, it, vi } from "vitest"; +import { z } from "zod"; import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; import { AnalysisInputError } from "../../src/domain/analysisErrorCore.js"; @@ -73,16 +74,19 @@ it("keeps independent server budgets for successful results and oversized errors Math.ceil((smallBudget + 65536) / 17), ); for (const server of [small, large]) - server.registerTool("selected_failure", { inputSchema: {} }, async () => - server.delivery.toErrorToolResult( - new AnalysisInputError("fixture", undefined, [ - { - path: ["procedure"], - reason: "invalid_value", - message: diagnostic, - }, - ]), - ), + server.registerTool( + "selected_failure", + { inputSchema: z.object({}) }, + async () => + server.delivery.toErrorToolResult( + new AnalysisInputError("fixture", undefined, [ + { + path: ["procedure"], + reason: "invalid_value", + message: diagnostic, + }, + ]), + ), ); const [smallClient, largeClient] = await Promise.all([ diff --git a/website/README.md b/website/README.md index 93faf5e59..298bc4b80 100644 --- a/website/README.md +++ b/website/README.md @@ -43,7 +43,7 @@ HTML files. No URL list needs to be maintained. - `public/first-investigation/index.html`: a guided Notes export investigation, from setup to a checked CSV prediction. - `public/showcase/index.html`: the case-study index. - `public/blog/index.html`: articles about reconstruction methods, ports, mods and reverse engineering. -- `public/blog/touhou-reconstruction/index.html`: a personal essay about agent-driven reconstruction, knowledge that compounds across TH08 and TH095, and the workflow's applicability to PC-98 TH04. +- `public/blog/touhou-reconstruction/index.html`: a personal essay about agent autonomy, oracle quality, knowledge carried between TH08 and TH095, PC-98 TH04, and the path from exact reconstruction to readable source and modern ports. - `public/showcase/aegis/index.html`: Aegis's Android login-code calculation, with an adjustable clock and reference checks. - `public/showcase/dx-ball/index.html`: sound-pan investigation and project status. - `public/showcase/notion/index.html`: Notion's Electron clipboard bridge and rich clipboard format. @@ -136,6 +136,10 @@ quoted README passage and milestone counting rules are in The completed text has normal search metadata and is included in the generated sitemap. +Its opening flywheel connects agent autonomy, reference-based oracles and +repository memory. The essay develops those ideas through the project examples. +Figure labels are selectable HTML; inline SVG supplies the feedback arrows. + Keep the copy direct and specific. Explain the task and the result before listing tool names. Setup commands and runtime requirements should match the released package. Keep the core learning path inside the site. Source, issue and evidence diff --git a/website/evidence/touhou-reconstruction-method.md b/website/evidence/touhou-reconstruction-method.md index 99f3ceb33..9d5b91565 100644 --- a/website/evidence/touhou-reconstruction-method.md +++ b/website/evidence/touhou-reconstruction-method.md @@ -173,3 +173,115 @@ observations, also confirmed by the author in this revision request. It is not attributed to a new automated website test or a fresh runtime replay. The 64-bit port is described as in development. The earlier four-product build and function-count milestones retain their historical scopes. + +## Autonomy, oracles and the compounding flywheel + +The author's requested revision emphasizes two principles: agents have broad +autonomy to investigate, and their hypotheses are tested by oracles with concrete +references and explicit acceptance conditions. These ideas are developed in the +Factory's agent-autonomy and semantic-reconstruction documents listed above. +An oracle result supports the property and inputs actually checked; the essay +retains human responsibility for project milestones and release decisions. + +The flywheel is an explanatory model of this relationship. Its passing branch +retains a checked result and its evidence; a mismatch returns to investigation. +Code, checks and recorded lessons improve the next starting point, with TH08, +TH095 and TH04 illustrating knowledge carried between projects. It does not +depict a measured performance ratio or give REA sole credit for reconstruction. + +The first inline reference to GensokyoClub's public README links to the author's +selected [Internet Archive capture](https://web.archive.org/web/20260909051514/https://github.com/GensokyoClub/th08#important-notice). +The pinned source caption and 23-word excerpt retain their original provenance. +The author approved this revision for publication on 10 October 2026. + +## TH08's floating-literal oracle failure + +The author recalled a floating-point comparison bug. The source history and +public GitHub record identify it as a reference-data verification gap, rather +than a rounding-tolerance problem. The original Japanese TH08 1.00d executable +uses double `128.0` in the power arm of `ItemManager::OnUpdate`'s item-attraction +condition. The reconstructed source used `0.0`. + +- [Issue #10](https://github.com/N0zoM1z0/th08/issues/10), opened on 2 September + 2026, records the downstream Switch-port report, the target instructions and + the constant bytes. Normal power is non-negative, so the erroneous power arm + was effectively unconditional after crossing the collection line. Focus and + shot-type exceptions were separate, correct arms of the condition. +- [PR #11](https://github.com/N0zoM1z0/th08/pull/11), merged on 2 September, + contains commit + [`7148a76b`](https://github.com/N0zoM1z0/th08/commit/7148a76ba224742677895c7160807a68897957d5). + It changes the threshold from `0.0` to `128.0`, fixes the manifest literal + identity, and adds source-symbol/declared-data/target-data checks for + explicitly recorded floating literals. +- [PR #13](https://github.com/N0zoM1z0/th08/pull/13), merged the same day, + contains commit + [`a393f400`](https://github.com/N0zoM1z0/th08/commit/a393f400786e4cc6cbb1000af0dad3276da12c78). + It audits 1,548 configured floating-literal references, corrects twelve more + stale references across five accepted functions, and makes verification + automatic for every 32-bit or 64-bit `__real@...` relocation. Its tests include + deliberately invalid literal declarations and representations. + +The older comparison replayed the target address into the instruction's +relocation field, then compared instruction bytes without checking the +floating-literal data at that address. Source `0.0` could therefore appear +exact against target `128.0`. The essay explains this mechanism in ordinary +language and keeps implementation details in the linked record. The count is +of references, not distinct values or new functions. + +The website work reexamined the historical diffs and retrieved the issue and +PR metadata with `gh`; it did not rerun the game's historical compiler or +runtime. The author supplies the first-person lesson about working as one +human with agents and about responsibility for oracle quality. + +## Readability and the learned phase order + +The semantic discussion is based on the author's requested explanation, +[TH08's semantic playbook](https://github.com/N0zoM1z0/th08/blob/d2a00f4acb4a13916e07a00bd13f714961714628/docs/SEMANTIC_PLAYBOOK.md), +and the Factory's +[semantic-reconstruction workflow](https://github.com/N0zoM1z0/touhou-reconstruction-factory/blob/717e1b8560783ed17a39fdf159d30aa16bb307a7/docs/semantic-reconstruction.md). +The workflow's latest public change was checked using `gh` on 10 October. + +The recommended order is an exact baseline, a working historical-platform +product, semantic reconstruction with both validation paths, then portable +products. The original target/compiler comparisons and the historical +build/runtime checks answer different questions. Both are preserved during +semantic work. Names, ownership and protocol interpretations also need +producer/consumer evidence; a passing check does not establish their English +meaning. Modern ports compare behavior with the established historical build +without requiring the new architecture's machine code to match the old one. + +This is a method learned from the projects. TH08's earlier portable releases +preceded some later historical-platform audits. The essay states that history +explicitly rather than presenting the current order as the original chronology. +The game-RE background and the assembly/raw-offset aside are author-provided +perspective. Transfer to other fields is a proposal to carry the principles +while choosing references appropriate to the new domain. + +## Industrial analogy: instruments develop with machines + +The [Science Museum Group's steam-engine indicator record](https://collection.sciencemuseumgroup.org.uk/objects/co51439/watts-steam-engine-indicator), +read on 10 October 2026, describes Boulton and Watt's introduction of the +instrument in 1796 to assist with valve adjustment. It also explains how adding +a pencil and moving paper made cylinder pressure through the stroke recordable. +The essay paraphrases this description and links it directly. It does not use +the record's images, quote its text, or date the displayed object or pressure +diagram to 1796. + +The connection to reconstruction tools is the author's analogy: rapid +execution needs measurement, and the instruments themselves require improvement. +The claim that this is an early industrial stage is the essay's interpretation +of the project experience. The historical example supports the measurement +comparison rather than a measured prediction about AI productivity. + +## Prose revision + +The later prose pass reduces repeated enumerations and develops each lesson +through its project example. It preserves the milestone figures, quotation, +source links and four-stage method. The two author-selected emphasized sentences +retain their wording. + +The opening takeaway now uses five bullets: agent autonomy, repository/Git +memory, evidence and explicit unknowns, testing the oracle, and the early stage +of the industrial shift. Each has a direct lead and a short explanation. The +historical-build/semantic/port sequence remains in the body rather than becoming +another takeaway item. diff --git a/website/figures.md b/website/figures.md index 534d8ac24..73fc70c42 100644 --- a/website/figures.md +++ b/website/figures.md @@ -96,9 +96,25 @@ formula, default settings, slider outputs and reference cases consistent. ## Touhou reconstruction essay -The essay opens with a takeaway and develops its argument in prose. It has no -opening flow figure. Project dates and the attributed README excerpt support -the narrative; detailed milestone references are available in a native +The essay opens with a takeaway and a flywheel explaining how experience +compounds. The figure uses selectable HTML labels and inline SVG connectors: +an autonomous agent proposes an experiment, an oracle tests it against a +reference, and code, evidence, checks and lessons remain in the repository. +A dashed mismatch branch returns to the agent for revision. The repository +supplies a better starting point for the next investigation; TH08 → TH095 → +TH04 illustrates knowledge carried between projects. Human direction names +the goal and acceptance criteria above the cycle. + +The caption connects reuse with improving the checks themselves. The TH08 +floating-literal case in the essay explains why a passing result can need +reexamination when an oracle's blind spot is discovered. Preserve that link +between the figure's ideal loop and the infrastructure lessons in the prose. + +The shared stylesheet positions the labels and gives narrow screens separate +connector geometry. Keep the arrows clear of the text and retain the same +meaning at every width. This is an explanatory model, with no measured speed +or productivity multiplier. Project dates and the attributed README excerpt +support the narrative; detailed milestone references are available in a native disclosure. Their provenance is documented in [evidence/touhou-reconstruction-method.md](evidence/touhou-reconstruction-method.md). diff --git a/website/public/assets/styles.css b/website/public/assets/styles.css index 542baf892..fe8539d0c 100644 --- a/website/public/assets/styles.css +++ b/website/public/assets/styles.css @@ -692,6 +692,178 @@ pre code { .blog-takeaway p:last-child { margin-bottom: 0; } +.blog-takeaway ul { + margin: 0; + padding-left: 1.15rem; +} +.blog-takeaway li + li { + margin-top: 0.75rem; +} +.reconstruction-flywheel { + max-width: 880px; + margin: 0 auto 4rem; + padding-block: 1.5rem 1.75rem; + border-block: 1px solid var(--line); +} +.flywheel-heading { + text-align: center; +} +.flywheel-heading .eyebrow { + margin-bottom: 0.6rem; + color: var(--accent); +} +.flywheel-heading > p:last-child { + color: var(--muted); + font-size: 0.875rem; +} +.flywheel-diagram { + position: relative; + max-width: 760px; + height: 440px; + margin-inline: auto; +} +.flywheel-arrows { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.flywheel-path-mobile { + display: none; +} +.flywheel-node { + position: absolute; + width: 30%; + text-align: center; + transform: translate(-50%, -50%); +} +.flywheel-node p { + margin: 0.6rem 0 0; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.5; +} +.flywheel-node .flywheel-kicker { + margin: 0 0 0.4rem; + color: var(--accent); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.flywheel-node h3 { + margin: 0; + font-size: 1.5rem; + line-height: 1.2; +} +.flywheel-agent { + top: 15%; + left: 50%; + width: 38%; +} +.flywheel-oracle { + top: 58%; + left: 79%; +} +.flywheel-memory { + top: 58%; + left: 21%; +} +.flywheel-retry, +.flywheel-center, +.flywheel-reuse, +.flywheel-pass { + position: absolute; + margin: 0; + padding: 0.25rem; + background: var(--page); + color: var(--muted); + font-size: 0.75rem; + line-height: 1.5; + text-align: center; + transform: translate(-50%, -50%); +} +.flywheel-retry { + top: 36%; + left: 68%; + width: 23%; +} +.flywheel-center { + top: 57%; + left: 50%; + width: 20%; + color: var(--accent); + font-size: 0.9375rem; +} +.flywheel-reuse { + top: 33%; + left: 23%; + color: var(--accent); +} +.flywheel-pass { + top: 94%; + left: 50%; + width: 75%; + color: var(--accent); + font-size: 0.8125rem; +} +.flywheel-projects { + display: flex; + justify-content: center; + align-items: center; + gap: 1.4rem; + margin-top: 0.3rem; +} +.flywheel-projects a { + font-size: 0.9375rem; + font-weight: 600; +} +.flywheel-projects span { + color: var(--muted); +} +.reconstruction-flywheel figcaption { + margin: 1rem auto 0; + max-width: 600px; + color: var(--muted); + font-size: 0.875rem; + text-align: center; +} +@media (max-width: 640px) { + .reconstruction-flywheel { + margin-bottom: 2.75rem; + } + .flywheel-diagram { + height: 405px; + } + .flywheel-agent { + width: 52%; + } + .flywheel-path-desktop { + display: none; + } + .flywheel-path-mobile { + display: initial; + } + .flywheel-node h3 { + font-size: 1.125rem; + } + .flywheel-node p { + font-size: 0.75rem; + } + .flywheel-node .flywheel-kicker { + font-size: 0.6rem; + letter-spacing: 0.05em; + } + .flywheel-center { + font-size: 0.75rem; + } + .flywheel-retry { + font-size: 0.625rem; + } + .flywheel-projects { + gap: 1rem; + } +} .blog-article blockquote { margin: 1.6rem 0; padding-left: 1.25rem; diff --git a/website/public/blog/touhou-reconstruction/index.html b/website/public/blog/touhou-reconstruction/index.html index 2afa3f1d9..10201ec25 100644 --- a/website/public/blog/touhou-reconstruction/index.html +++ b/website/public/blog/touhou-reconstruction/index.html @@ -147,21 +147,166 @@

By N0zoM1z0 · 10 October 2026

Takeaway

-

- Agent-driven reverse engineering changes how the work can - accumulate. - An investigation can become a checked result, a failed experiment - can become a reusable test, and one project's experience can become - the next project's starting point. -

-

- Reconstructing TH08, TH095 and TH04 convinced me that RE is entering - its industrial age. -

+
    +
  • + Give agents autonomy over the investigation. Set + a clear goal and acceptance criteria, then let the agent decide + what to try next. +
  • +
  • + Use the repository and Git as working memory. + Keep the reasoning with the code. Git checkpoints let the next + session continue after the conversation ends. +
  • +
  • + Let evidence decide what you claim. A confident + explanation still needs testing. “Unknown” is a valid answer when + the evidence is incomplete. +
  • +
  • + Test the oracle too. Try cases it should reject. + If it misses a bug, revisit the earlier results that depended on + it. +
  • +
  • + We are in the early days of RE's industrial revolution. + The methods are still taking shape. There is plenty left to + explore. +
  • +
+
+
+

How experience compounds

+

Human direction · Goal and acceptance criteria

+
+
+ +
+

Autonomy

+

Agent

+

Choose an experiment.
Propose a hypothesis.

+
+
+

Verification

+

Oracle

+

Test against a
concrete reference.

+
+
+

Repository

+

Working memory

+

Code, evidence,
checks and lessons.

+
+

+ Mismatch
Revise the hypothesis +

+

+ A better
starting point +

+

Reuse

+

+ Pass · Keep the result and its evidence +

+
+
+ TH08TH095TH04 +
+
+ Each project leaves the next with a better starting point. That + includes fixing the checks when we discover a gap. +
+