Compare commits

..
Author SHA1 Message Date
Kaoru0822 c6c78b84f8 test(setup): supply registration profile in catalog fixture 2026-11-12 06:45:44 -08:00
729 changed files with 5732 additions and 11679 deletions

No files matched your search

+1 -3
View File
@@ -464,9 +464,7 @@ class ReaJadxBridge {
if (element.getTagName().equals("uses-sdk")) {
minSdk = element.getAttributeNS(android, "minSdkVersion");
targetSdk = element.getAttributeNS(android, "targetSdkVersion");
} else if (element.getTagName().equals("uses-permission")
|| element.getTagName().equals("uses-permission-sdk-23")
|| element.getTagName().equals("uses-permission-sdk-m")) {
} else if (element.getTagName().equals("uses-permission")) {
String permission = element.getAttributeNS(android, "name");
if (!permission.isEmpty()) permissions.add(permission);
}
+9
View File
@@ -958,6 +958,15 @@ class _BridgeServer(threading.Thread):
proof.update(fragment.encode("utf-8"))
return proof.hexdigest()
def _encode_authenticated_response(
self, value: dict[str, Any], client_nonce: str, server_nonce: str
) -> bytes | None:
"""Return one exact frame. The socket path writes the same pieces."""
proof = self._authenticated_proof(value, client_nonce, server_nonce)
if proof is None:
return None
return b"".join(self._iter_authenticated_frame(value, proof))
def _iter_authenticated_frame(self, value: dict[str, Any], proof: str):
yield _AUTHENTICATED_PREFIX
for fragment in _response_fragments(value):
+7
View File
@@ -154,6 +154,13 @@ class HopperApiFacade:
)
def _configure_hopper_api(document_provider):
"""Inject Hopper's document provider or a standalone test facade."""
global _hopper_api, _rea_session_document
_hopper_api = HopperApiFacade(document_provider)
_rea_session_document = None
def _api():
"""Resolve Hopper globals lazily so importing this module needs no Hopper."""
global _hopper_api
@@ -215,10 +215,7 @@ Resolution follows dyld:
image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image
loads once per process. A later @rpath request whose install name matches an
already loaded image reuses it. Loading an image and expanding its dependencies
are separate: an already loaded sibling can first be expanded through another
dependency, inheriting that traversal's rpath stack. Cycles do not expand an
image twice, and lazy edges do not establish a launch-time traversal chain.
already loaded image reuses it.
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`.
- Dyld path components are POSIX names. On Windows hosts, link text uses `\`
@@ -255,12 +252,8 @@ one of:
A `conditional` image is still traced, because it is an in-root candidate
that may load. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. A definitely loaded
image can also first expand through a conditional chain: its dependency edges
then have `loader_conditional: true`, while later reuse of the image retains its
definite first-load result.
An in-root dylib with no platform load command remains unknown unless the root is an executable
request that reuses it by install name is `conditional` too. An in-root dylib
with no platform load command remains unknown unless the root is an executable
with observed macOS platform and the candidate's CPU type is x86_64, i386, or arm64. In that macOS context,
[Apple's dyld](https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/mach_o/UnsafeHeader.cpp#L1137-L1145) supplies an implicit macOS platform to legacy dylibs on those CPU
types. The candidate records `platform_basis: "macos-dyld-implicit"`; the
+8 -14
View File
@@ -222,7 +222,7 @@ cannot answer, such as an address without one exact file offset, omits this
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
the operation and the REA server/provider deadline. Inspect partial Evidence
and reported cleanup state, then follow the returned remediation. Change a
request field or REA\_\* setting only when the returned remediation explicitly
request field or REA_* setting only when the returned remediation explicitly
recommends that change. Restart REA when that text says the value is read at
startup. A mention that a control does not raise the deadline is not a
recommendation to change it. When it names no setting, reduce supported work;
@@ -1065,17 +1065,6 @@ from an older request does not override a newer outcome. Request deadlines and
cancellations neither mark nor clear availability. A new provider client starts
without these outcomes.
A `provider_missing` group may also list `alternatives`: advertised tools the
current provider makes available for part of that operation's question. When
the provider does not declare `next_address` or `prev_address`, they list
whichever of `inspect_native_instruction`, `read_function_instructions` and
`procedure_assembly` qualify. These answer instruction-level questions only:
none navigates arbitrary analyzed objects such as data or addresses outside a
procedure, and neither listing order nor address plus length proves executed
control flow. Calling the missing operation returns `capability_unavailable`
with the same tools in `details.alternatives` and a remediation stating each
one's scope. Without a qualifying alternative, both keep their generic advice.
Standalone command operations (such as `inspect_with_objdump` and
`execute_rizin_command`) similarly reflect observed executable launch failures:
an unprobed executable is reported available; once a launch failure is observed,
@@ -1922,10 +1911,13 @@ support for omitted historical values.
known and differ. If only one value is known, storage comparison is unknown;
when both are omitted, storage is not compared.
`compare_artifacts` also compares the declarations in the `format_metadata` of <!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->UnityFS members (`unityfs-entry`), PyInstaller entries
<!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->
`compare_artifacts` also compares the declarations in the `format_metadata`
of UnityFS members (`unityfs-entry`), PyInstaller entries
(`pyinstaller-entry`), Node SEA members (`node-sea-entry`),
ESP image segments (`esp-image-segment`), DfuSe elements (`dfuse-element`),
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`)<!-- END:compare-artifacts:compared-format-metadata-kinds -->;
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`);
each format's guide names the compared fields. A changed declaration is a
metadata change, including when content is unavailable. A declaration that
only one side records, or that the other side records as another kind, is
@@ -1936,6 +1928,8 @@ container or from caller options that identify the target, or repeat
occurrence fields, except Godot PCK source paths, which
[Godot PCK archives](../godot-pck.md) explains.
<!-- END:compare-artifacts:compared-format-metadata-kinds -->
Every occurrence records `entry_kind`, `executable` and `encrypted`. A
different entry kind or `encrypted` value is a `metadata` difference, and a
different `executable` value is an `executable` difference. Occurrence and node
@@ -10,8 +10,8 @@
4. Verifier: `appTaskPython.ts` renders `READER_RUNNER`, `READERS`,
`_reader_views` and `evaluate_with_readers` only for a task with readers,
so a task without renders as before.
5. Application: `readerViewsOfBytes` in `DesktopTaskReaders.ts`;
`runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
5. Application: `readTaskReaderViews` and `readerViewsOfBytes` in
`DesktopTaskReaders.ts`; `runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
the witness runs readers through `AgentEnvComputer.exec`; the export runs
them on `shippedSeedBytes` and the end's retained content.
6. Docs: the tasks reference, the witness page, the build skill's recipes for
-5
View File
@@ -19,11 +19,6 @@ if (
const child = spawn(
process.execPath,
[
// Reuse the development runner's POSIX process-group owner. Windows
// source tests must remain usable without the built native Job Object.
...(process.platform === "win32"
? []
: [resolve("scripts/run-exclusive.mjs"), "--unlocked", process.execPath]),
resolve("node_modules/vitest/vitest.mjs"),
"run",
...projects.flatMap(({ name }) => ["--project", name]),
-11
View File
@@ -2,17 +2,6 @@ import type {
Client,
CallToolRequestOptions,
} from "@modelcontextprotocol/client";
import type { VerifierCommands } from "./verify-commands.mjs";
/** Keep the opened target and its owned stdio server alive through verification. */
export function withArtifactMcp(
target: string,
verify: (client: Client) => Promise<void>,
options?: {
readonly commands?: VerifierCommands;
readonly signal?: AbortSignal;
},
): Promise<void>;
/** Call with the server's advertised schema; callers cannot replace it. */
export function artifactMcpCall(
-1
View File
@@ -15,7 +15,6 @@ export function parseDevelopmentTestRequest(
export function developmentTestPlan(
request: DevelopmentTestRequest,
baseCommit?: string,
resolvedPaths?: readonly string[],
): {
readonly needsBuild: boolean;
readonly artifactTasks: readonly string[];
+3 -7
View File
@@ -58,11 +58,7 @@ const artifactConsumers = {
};
/** Plan source feedback or explicit tests with their runtime/artifact prerequisites. */
export const developmentTestPlan = (
request,
baseCommit,
resolvedPaths = request.paths,
) => {
export const developmentTestPlan = (request, baseCommit) => {
const explicit = request.paths.length > 0;
if (
request.mode === "changed" &&
@@ -74,12 +70,12 @@ export const developmentTestPlan = (
? []
: SOURCE_TEST_PROJECTS.map(({ name }) => name);
return {
needsBuild: resolvedPaths.some(
needsBuild: request.paths.some(
(path) => projectForTest(path).group === "built",
),
artifactTasks: Object.entries(artifactConsumers).flatMap(
([task, consumers]) =>
resolvedPaths.some((path) => consumers.includes(path)) ? [task] : [],
request.paths.some((path) => consumers.includes(path)) ? [task] : [],
),
vitestArguments: [
"run",
+2 -5
View File
@@ -10,6 +10,7 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { GHIDRA_ANNOTATIONS_RETAINED } from "../../dist/ghidra/GhidraProviderClient.js";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { OFFICIAL_TOOL_CONTRACTS } from "../../dist/contracts/officialToolContracts.js";
@@ -570,11 +571,7 @@ export async function verifyGhidraBoundaries(
// The producer reason describes the failure; continuing-session guidance
// is projected separately and the following live calls verify it.
assert.match(regexError.details.reason, /stack while matching pattern/u);
assert.ok(
regexError.message.includes(
"The active analysis session and annotations remain available.",
),
);
assert.ok(regexError.message.includes(GHIDRA_ANNOTATIONS_RETAINED));
rejectedCalls++;
const nestedPattern = "(".repeat(12000) + "a" + ")".repeat(12000);
const compileFailure = await client.callTool(
+13 -35
View File
@@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js";
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../../src/artifacts/ArtifactResourceScope.js";
import {
prepareOwnedCommand,
runOwnedCommand,
@@ -58,7 +61,6 @@ export interface VerifierCommands {
arguments_: readonly string[],
options?: VerifierCommandOptions,
): Promise<VerifierCommandOutput>;
/** Retain a live resource until close, independently of ordinary command execution. */
adopt(owner: VerifierCleanupOwner, resource: string): void;
readonly cleanupResources: readonly string[];
close(): Promise<void>;
@@ -150,17 +152,13 @@ const commandLimits = (options: VerifierCommandOptions) => {
};
};
type AdoptedVerifierOwner = {
readonly kind: "lease";
readonly resource: string;
readonly lease: AnalysisCleanupLease;
};
class VerifierCommandOwner implements VerifierCommands {
readonly #resources = new ArtifactResourceScope();
readonly #controller = new AbortController();
readonly #adopted = new WeakMap<VerifierCleanupOwner, AdoptedVerifierOwner>();
readonly #liveAdopted = new Set<AdoptedVerifierOwner>();
readonly #adopted = new WeakMap<
VerifierCleanupOwner,
ArtifactResourceOwner
>();
readonly #resourceOwner: RetainedResourceScope;
#closed = false;
#released = false;
@@ -172,21 +170,13 @@ class VerifierCommandOwner implements VerifierCommands {
) => Promise<SpawnedOwnedProviderProcess>,
) {
this.#resourceOwner = {
retain: (owner, resource) =>
this.#resources.retain(this.#adoptedOwner(owner, resource)),
retain: (owner, resource) => this.adopt(owner, resource),
run: (work) => this.#resources.run(work),
};
}
get cleanupResources(): readonly string[] {
return [
...new Set([
...this.#resources.cleanupResources,
...[...this.#liveAdopted].flatMap(({ lease, resource }) =>
lease.released ? [] : (lease.cleanupResources ?? [resource]),
),
]),
];
return this.#resources.cleanupResources;
}
exec(
@@ -243,15 +233,6 @@ class VerifierCommandOwner implements VerifierCommands {
}
adopt(owner: VerifierCleanupOwner, resource: string): void {
const retained = this.#adoptedOwner(owner, resource);
if (this.#closed) this.#resources.retain(retained);
else this.#liveAdopted.add(retained);
}
#adoptedOwner(
owner: VerifierCleanupOwner,
resource: string,
): AdoptedVerifierOwner {
if (this.#released)
throw new Error("Verifier command resource release has completed");
let retained = this.#adopted.get(owner);
@@ -263,18 +244,15 @@ class VerifierCommandOwner implements VerifierCommands {
};
this.#adopted.set(owner, retained);
}
return retained;
// An admitted command can transfer a late preparation/cleanup capability
// while close is joining it. Acquisition must precede this scope's release.
this.#resources.retain(retained);
}
close(): Promise<void> {
this.#closed = true;
this.#controller.abort(new Error("Verifier command owner closed"));
if (this.#closing !== undefined) return this.#closing;
// Live adoption owns a lifetime, not a failed cleanup. Only closure moves
// these leases into the retry scope; command failures enter it immediately.
// Late transfers during admitted work still join that same close below.
for (const owner of this.#liveAdopted) this.#resources.retain(owner);
this.#liveAdopted.clear();
let attempt: Promise<void>;
attempt = this.#resources
.close()
+3 -17
View File
@@ -1,13 +1,12 @@
import { spawn } from "node:child_process";
import { realpath, stat } from "node:fs/promises";
import { relative, resolve, sep } from "node:path";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
developmentTestPlan,
parseDevelopmentTestRequest,
} from "./lib/development-tests.mjs";
import { projectForTest } from "./lib/test-projects.mjs";
let child;
let forceTimer;
@@ -30,22 +29,9 @@ for (const signal of Object.keys(signalExitCodes))
try {
const [mode, ...arguments_] = process.argv.slice(2);
const request = parseDevelopmentTestRequest(mode, arguments_);
const resolvedPaths = [];
const repository = await realpath(".");
for (const path of request.paths) {
if (!(await stat(path)).isFile())
throw new Error(`Test path is not a file: ${path}`);
// Ownership follows the filesystem's canonical spelling. Execution keeps
// the caller path so exact discovery still rejects excluded symlink aliases.
const resolvedPath = relative(repository, await realpath(path))
.split(sep)
.join("/");
if (
request.mode !== "focused" &&
projectForTest(resolvedPath).group !== "source"
)
throw new Error(`Use test:focused for built test projects: ${path}`);
resolvedPaths.push(resolvedPath);
}
let baseCommit;
if (request.mode === "changed") {
@@ -56,7 +42,7 @@ try {
);
baseCommit = base.output.trim();
}
const plan = developmentTestPlan(request, baseCommit, resolvedPaths);
const plan = developmentTestPlan(request, baseCommit);
if (request.dryRun) {
console.log(JSON.stringify({ ...request, baseCommit, ...plan }, null, 2));
} else {
+30 -44
View File
@@ -1,23 +1,22 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { isUtf8 } from "node:buffer";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { goBinarySchema } from "../dist/domain/go/goBinary.js";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import { artifactMcpCall } from "./lib/artifact-e2e.mjs";
import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { createOwnedMcpStdioTransport } from "./lib/owned-mcp-stdio.mjs";
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
const commands = await createVerifierCommands();
const execute = promisify(execFile);
const compiler = process.env.REA_VERIFY_GO_COMMAND ?? "go";
if (process.env.REA_VERIFY_GO_COMMAND !== undefined && !isAbsolute(compiler))
throw new Error(
@@ -52,16 +51,13 @@ const goEnvironment = {
// Go's own verifier seam isolates telemetry; no global configuration is changed.
TEST_TELEMETRY_DIR: join(root.path, "telemetry"),
};
let client;
let transport;
const cleanup = new AnalysisCleanupLease(async () => {
await closeVerifierResources([
() => client?.close(),
() => transport?.close(),
() => commands.close(),
]);
await root.close();
}, [root.path]);
const client = new Client({ name: "go-binary-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
const targets = [
["linux", "amd64", "elf", 64, "little"],
["linux", "386", "elf", 32, "little"],
@@ -77,19 +73,10 @@ const verified = [];
const omittedCoverage = [];
const failures = [];
try {
client = new Client({ name: "go-binary-verifier", version: "1" });
transport = await createOwnedMcpStdioTransport({
command: process.execPath,
arguments: [entrypoint, "mcp"],
hostEnvironment: environment,
cwd: process.cwd(),
expectedCommand: null,
sweepTokenOwnedProcesses: true,
});
await prepareFixture();
try {
compilerVersion = (
await commands.exec(compiler, ["version"], {
await execute(compiler, ["version"], {
cwd: root.path,
env: goEnvironment,
timeout: 10_000,
@@ -198,7 +185,7 @@ try {
const path = join(root.path, "linux-amd64-byte-strings");
const expected = JSON.parse(
(
await commands.exec(oracle, ["--build-byte-fixture", compiler, path], {
await execute(oracle, ["--build-byte-fixture", compiler, path], {
cwd: sourceRoot,
env: { ...goEnvironment, GOOS: "linux", GOARCH: "amd64" },
timeout: 120_000,
@@ -211,16 +198,21 @@ try {
} catch (cause) {
failures.push(cause);
} finally {
try {
await cleanup.release();
} catch (cause) {
failures.push(cause);
for (const close of [
() => client.close(),
() => transport.close(),
() => root.close(),
]) {
try {
await close();
} catch (cause) {
failures.push(cause);
}
}
}
const verifier = await completeVerifierRun(run);
try {
if (cleanup.released)
await assert.rejects(lstat(root.path), { code: "ENOENT" });
await assert.rejects(lstat(root.path), { code: "ENOENT" });
if (verifier.process_lineage.status === "verified")
assert.deepEqual(verifier.process_lineage.descendants, []);
} catch (cause) {
@@ -234,21 +226,15 @@ if (failures.length > 0) {
compiler: compilerVersion,
verified,
omitted_coverage: omittedCoverage,
retained_root: cleanup.released ? null : root.path,
verifier,
},
null,
2,
),
);
throw Object.assign(
new AggregateError(
failures,
cleanup.released
? "Real Go metadata verification failed; cleanup failures are retained."
: `Real Go metadata verification failed; cleanup is incomplete at ${root.path}.`,
),
{ cleanupLeases: cleanup.released ? [] : [cleanup] },
throw new AggregateError(
failures,
"Real Go metadata verification failed; cleanup failures are retained.",
);
}
console.log(
@@ -270,7 +256,7 @@ console.log(
);
async function go(args, overrides = {}) {
return commands.exec(compiler, args, {
return execute(compiler, args, {
cwd: sourceRoot,
env: { ...goEnvironment, ...overrides },
timeout: 120_000,
@@ -370,7 +356,7 @@ async function inspect(mode, path, includeStringLiterals = false) {
...(includeStringLiterals ? ["--include-string-literals"] : []),
"--json",
];
const { stdout } = await commands.exec(process.execPath, args, {
const { stdout } = await execute(process.execPath, args, {
env: environment,
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
@@ -438,7 +424,7 @@ async function verify(
byteExpected ??
JSON.parse(
(
await commands.exec(oracle, [oraclePath], {
await execute(oracle, [oraclePath], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
})
+5 -3
View File
@@ -99,7 +99,8 @@ async function verifyPublishedUpdate({
environment,
);
await commands.execNpm(
await commands.exec(
"npm",
[
"install",
"--global",
@@ -117,7 +118,7 @@ async function verifyPublishedUpdate({
);
const npmRoot = (
await commands.execNpm(["root", "--global"], {
await commands.exec("npm", ["root", "--global"], {
cwd: canaryRoot,
env: environment,
timeout: 30000,
@@ -185,7 +186,8 @@ if (result.status === "failed") process.exitCode = 1;
}
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
const { stdout } = await commands.execNpm(
const { stdout } = await commands.exec(
"npm",
["view", "rea-agents", "versions", "--json"],
{ cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 },
);
+7 -2
View File
@@ -151,7 +151,7 @@ export async function verifyAndroidFailureRecovery({
failure_code: "runtime_unavailable",
},
availabilityReason: "provider_unavailable",
availabilityRemediation: /selected Java launcher/u,
availabilityRemediation: /selected Java runtime/u,
message: /selected Java runtime/u,
remediation: /selected launcher/u,
});
@@ -194,7 +194,12 @@ export async function verifyAndroidFailureRecovery({
repository,
available: false,
unavailableReason: scenario.availabilityReason ?? "provider_missing",
remediation: scenario.availabilityRemediation ?? scenario.remediation,
remediation:
scenario.availabilityRemediation ??
new RegExp(
scenario.observation.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
"u",
),
});
console.log(`PASS CLI applicable recovery: ${scenario.label}`);
}
+9 -38
View File
@@ -94,36 +94,29 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
ts.forEachChild(node, collect);
};
collect(sourceFile);
const potential = (node, matchesCandidate) => {
const potential = (node) => {
const expression = unwrapExpression(node);
if (exactLimit(literalText(expression)) !== undefined) return true;
if (ts.isIdentifier(expression)) return matchesCandidate(expression);
if (ts.isIdentifier(expression)) return candidates.has(expression.text);
if (ts.isPropertyAccessExpression(expression))
return (
exactLimit(expression.name.text) !== undefined ||
potential(expression.expression, matchesCandidate)
potential(expression.expression)
);
if (ts.isElementAccessExpression(expression))
return (
expression.argumentExpression !== undefined &&
potential(expression.argumentExpression, matchesCandidate)
potential(expression.argumentExpression)
);
if (ts.isObjectLiteralExpression(expression))
return expression.properties.some(
(property) =>
ts.isPropertyAssignment(property) &&
potential(property.initializer, matchesCandidate),
ts.isPropertyAssignment(property) && potential(property.initializer),
);
if (ts.isConditionalExpression(expression))
return (
potential(expression.whenTrue, matchesCandidate) ||
potential(expression.whenFalse, matchesCandidate)
);
return potential(expression.whenTrue) || potential(expression.whenFalse);
if (ts.isBinaryExpression(expression))
return (
potential(expression.left, matchesCandidate) ||
potential(expression.right, matchesCandidate)
);
return potential(expression.left) || potential(expression.right);
return false;
};
let changed = true;
@@ -132,34 +125,13 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
for (const declaration of declarations) {
if (
!candidates.has(declaration.name.text) &&
potential(declaration.initializer, (identifier) =>
candidates.has(identifier.text),
)
potential(declaration.initializer)
) {
candidates.add(declaration.name.text);
changed = true;
}
}
}
// Names narrow the search cheaply, but only the lexical declaration can
// establish that a particular use is an alias of a limit.
const lexicalCandidates = new Set();
changed = true;
while (changed) {
changed = false;
for (const declaration of declarations) {
if (
candidates.has(declaration.name.text) &&
!lexicalCandidates.has(declaration) &&
potential(declaration.initializer, (identifier) =>
lexicalCandidates.has(symbols(identifier)?.valueDeclaration),
)
) {
lexicalCandidates.add(declaration);
changed = true;
}
}
}
const unknown = (node, reason) => {
const path = sourceFile.fileName;
const offset = node.getStart();
@@ -180,8 +152,7 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
declaration === undefined ||
!ts.isVariableDeclaration(declaration) ||
!ts.isVariableDeclarationList(declaration.parent) ||
declaration.initializer === undefined ||
!lexicalCandidates.has(declaration)
declaration.initializer === undefined
)
return undefined;
if ((declaration.parent.flags & ts.NodeFlags.Const) === 0) {
+7 -5
View File
@@ -84,7 +84,6 @@ import {
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { createEvidence } from "../domain/evidence.js";
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
import {
PrivateRuntimeRoot,
type PrivateRuntimeRootOptions,
@@ -994,12 +993,15 @@ export class AdbProvider {
const partialObservation =
execution === undefined
? undefined
: createAnalysisExecutionEvidence(
: createEvidence(execution.subject ?? undefined, execution.provider, {
operation,
parameters,
execution,
"observed",
);
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence: "observed",
});
return new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
resources,
+16 -12
View File
@@ -58,7 +58,7 @@ export const androidSnapshotCopy = (
: undefined;
/** Adopt a snapshot descriptor whose close rejected, keyed by its exact path. */
type AndroidSnapshotCloseRetention = (
export type AndroidSnapshotCloseRetention = (
path: string,
owner: OwnedFileHandle,
) => void;
@@ -69,8 +69,8 @@ export const snapshotAndroidTarget = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
): Promise<string> => {
const snapshot = join(root, "target.apk");
try {
@@ -105,8 +105,8 @@ export const snapshotAndroidEngine = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
try {
@@ -159,14 +159,17 @@ const copyAndroidSnapshot = async (
snapshotPath: string,
expectedSha256: string,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
retainFailedClose: AndroidSnapshotCloseRetention | undefined,
): Promise<AndroidSnapshotCopy> => {
assertRegularFileActive(signal);
let removeSnapshot = false;
const retain = (path: string) => ({
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
});
const retain = (path: string) =>
retainFailedClose === undefined
? {}
: {
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
};
try {
// Close each descriptor through its owner. A rejected close keeps the
// copy outcome and stays retained until that owner is retried; the
@@ -284,7 +287,7 @@ const openSnapshotSource = async (
path: string,
signal: AbortSignal | undefined,
retention: {
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
},
): Promise<StableRegularFileDescriptor> => {
try {
@@ -302,9 +305,10 @@ const openSnapshotSource = async (
};
const retainAdmissionOwner = (
retain: (owner: OwnedFileHandle) => void,
retain: ((owner: OwnedFileHandle) => void) | undefined,
owner: OwnedFileHandle,
): RegularFileCleanupFailure["retention"] => {
if (retain === undefined) return { kind: "not_requested" };
try {
retain(owner);
return { kind: "callback_returned" };
+3 -3
View File
@@ -415,11 +415,11 @@ export class ApkSignatureProvider implements ApkSignaturePort {
const { v1, decoded: v1Decoded, duplicates } = v1Read.value;
const v2 = decodeScheme(block.value, APK_BLOCK_ID.signatureV2, "v2");
if (!v2.ok) return v2;
if (!v2.ok) return err(v2.error);
const v3 = decodeScheme(block.value, APK_BLOCK_ID.signatureV3, "v3");
if (!v3.ok) return v3;
if (!v3.ok) return err(v3.error);
const v31 = decodeScheme(block.value, APK_BLOCK_ID.signatureV31, "v3.1");
if (!v31.ok) return v31;
if (!v31.ok) return err(v31.error);
// Account for representation expansion before allocating the report.
const projected = projectedOutputBytes(
-5
View File
@@ -53,11 +53,6 @@ export interface ExecutionOptions {
* supplied it.
*/
readonly selectorOrigin?: "provider";
/**
* Whether the caller's catalog advertises an operation; refusal recovery
* names only advertised operations. Absent means every operation is.
*/
readonly isOperationAdvertised?: (operation: AnalysisOperation) => boolean;
}
/** Closed operations accepted by provider-backed analysis sessions. */
+9
View File
@@ -6,6 +6,7 @@ import {
resolveWindowsChangeTimeSettle,
} from "../config/windowsChangeTimeSettle.js";
import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js";
import { resolveBridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import { resolveCaptureDecoderHeap } from "../config/captureDecoderHeap.js";
import { resolveRegexMatchingDeadline } from "../config/regexMatchingDeadline.js";
import { resolveRegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js";
@@ -365,6 +366,7 @@ it("reads an accepted override before an invalid launch seed", () => {
{
revision: 1,
overrides: {
"bridge.max_request_bytes": 8192,
"capture.decoder_old_generation_mib": 64,
"regex.matching_deadline_ms": 250,
"regex.worker_heap_request_mib": 128,
@@ -373,6 +375,9 @@ it("reads an accepted override before an invalid launch seed", () => {
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
@@ -388,6 +393,10 @@ it("reads an accepted override before an invalid launch seed", () => {
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
it.each([
["no comment", ""],
@@ -22,7 +22,7 @@ it.each([
await writeFile(infoPlist, source);
await writeFile(executable, "fixture - never executed");
expect(await resolveAppleBundleExecutable(bundle)).toEqual({
expect(await resolveAppBundleExecutable(bundle)).toEqual({
ok: true,
value: { executable, infoPlist },
});
+13 -9
View File
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js";
import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
const binaryPlistExecutableWithThreeByteUid = (): Buffer => {
const ascii = (value: string): Buffer => {
@@ -53,7 +53,7 @@ it.each([
);
await writeFile(executable, "inert fixture; never executed");
const result = await resolveAppleBundleExecutable(bundle);
const result = await resolveAppBundleExecutable(bundle);
expect(result).toEqual({
ok: true,
value: { executable, infoPlist: plistPath },
@@ -71,7 +71,7 @@ describe("app bundle Info.plist magic", () => {
await writeFile(plistPath, binaryPlistExecutableWithThreeByteUid());
await writeFile(executable, "inert fixture; never executed");
expect(await resolveAppleBundleExecutable(bundle)).toEqual({
expect(await resolveAppBundleExecutable(bundle)).toEqual({
ok: true,
value: { executable, infoPlist: plistPath },
});
@@ -86,13 +86,17 @@ describe("app bundle Info.plist magic", () => {
const plist = Buffer.from("bplist00");
plist[0] = (plist[0] ?? 0) | 0x80;
await writeFile(plistPath, plist);
const result = await resolveAppleBundleExecutable(bundle);
expect(result).toMatchObject({
ok: false,
error: {
cause: { name: "XmlPlistTextDecodeError", reason: "invalid-bytes" },
let decoded = false;
const result = await resolveAppBundleExecutable(bundle, {
readFile: async () => plist,
realpath: async (path) => path,
decodeBinaryPlist: async () => {
decoded = true;
return "Fixture";
},
});
expect(decoded).toBe(false);
expect(result.ok).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -114,7 +118,7 @@ describe("app bundle program directory containment", () => {
await writeFile(join(bundle, "Contents", "Info.plist"), plist);
let childResolved = false;
const result = await resolveAppleBundleExecutable(bundle, {
const result = await resolveAppBundleExecutable(bundle, {
readFile: async () => Buffer.from(plist),
realpath: async (path) => {
if (path === bundle) return canonicalBundle;
+22 -4
View File
@@ -88,9 +88,10 @@ export interface ResolvedAppBundleResourceRoot {
}
/** Filesystem operations used by the resolver, injectable for boundary tests. */
interface AppBundleFileSystem {
export interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
readonly realpath: (path: string) => Promise<string>;
readonly decodeBinaryPlist?: (path: string) => Promise<string>;
}
const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
@@ -101,7 +102,7 @@ const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
* a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is
* found by listing `Wrapper`, never by following the `WrappedBundle` link.
*/
const resolveAppBundleExecutable = async (
export const resolveAppBundleExecutable = async (
path: string,
fileSystem: AppBundleFileSystem = defaultFileSystem,
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
@@ -395,6 +396,15 @@ const codeDescription = (cause: unknown): string => {
: "";
};
const isSystemErrorCode = (cause: unknown): boolean => {
const code = errorCode(cause);
return (
typeof code === "string" &&
/^[A-Z][A-Z0-9_]+$/u.test(code) &&
!code.startsWith("ERR_")
);
};
const isPermissionDenied = (cause: unknown): boolean =>
errorCode(cause) === "EACCES" || errorCode(cause) === "EPERM";
@@ -451,12 +461,20 @@ const resolveLayoutExecutable = async (
let name: string;
try {
name = executableName(decodeInfoPlistValue(plist));
name =
hasBinaryPlistSignature(plist) &&
fileSystem.decodeBinaryPlist !== undefined
? await fileSystem.decodeBinaryPlist(plistPath)
: executableName(decodeInfoPlistValue(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
plistPath,
`${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
isPermissionDenied(cause)
? `permission denied decoding ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: isSystemErrorCode(cause)
? `could not decode ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: `${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
{
cause,
// A bundle without code, such as a resource bundle, declares no
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe("OpenStep Info.plist nesting during app target resolution", () => {
@@ -20,7 +20,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => {
`{ CFBundleExecutable = App; payload = ${"(".repeat(depth)}x${")".repeat(depth)}; }`,
);
const result = await resolveAppleBundleExecutable(app);
const result = await resolveAppBundleExecutable(app);
if (depth === 127) {
expect(result).toMatchObject({
@@ -52,7 +52,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => {
`{ CFBundleExecutable = App; }${"(".repeat(3000)}x${")".repeat(3000)}`,
);
expect(await resolveAppleBundleExecutable(app)).toMatchObject({
expect(await resolveAppBundleExecutable(app)).toMatchObject({
ok: true,
value: { executable: join(programs, "App") },
});
+3 -3
View File
@@ -208,7 +208,7 @@ export const parseBinaryTarget = async (
throwIfTargetResolutionCancelled(signal);
const resolved = await resolveAppBundle(canonical);
throwIfTargetResolutionCancelled(signal);
if (!resolved.ok) return resolved;
if (!resolved.ok) return err(resolved.error);
const { executable: path, infoPlist } = resolved.value;
const bundle =
infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist };
@@ -747,8 +747,8 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
const detectArtifactFormat = async (
path: string,
source: StableRegularFileDescriptor,
signal: AbortSignal | undefined,
sourcePath: string,
signal?: AbortSignal,
sourcePath = path,
): Promise<
| Exclude<
BinaryTarget["format"],
+3 -30
View File
@@ -56,7 +56,6 @@ import {
type AnalysisCleanupRetry,
} from "../domain/analysisErrorRecovery.js";
import { isBundleResourceOperation } from "./binary/BundleResourceSession.js";
import { offeredOperationAlternatives } from "./OperationAlternatives.js";
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
FRIDA_TOOL_CONTRACTS.map(({ name }) => name),
@@ -127,8 +126,6 @@ export type AvailabilityPolicy = {
type AvailabilityFacts = {
readonly defaultModeAvailable?: boolean;
readonly modes?: readonly ToolAvailabilityMode[];
/** Advertised operations the current provider offers for part of the question. */
readonly alternatives?: readonly AnalysisOperation[];
};
type Availability = AvailabilityFacts &
(
@@ -165,7 +162,6 @@ type AvailabilityContext = {
readonly targetFormat: string | undefined;
readonly descriptors: ReadonlyMap<string, ProviderDescriptor>;
readonly policy: AvailabilityPolicy;
readonly isToolAdvertised: (name: string) => boolean;
};
type EnhancedContractName = Extract<
@@ -326,15 +322,10 @@ const ADDRESS_CONTEXT_EFFECT_COMPOSITION: ModeComposition = {
],
};
/**
* Build stable per-operation availability for the caller's advertised tools.
* Each tool's availability still derives from the complete provider facts;
* advertisement narrows only the reported tools and the alternatives named.
*/
/** Build stable per-operation availability for discovery and tool visibility. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
policy: AvailabilityPolicy,
isToolAdvertised: (name: string) => boolean = () => true,
): readonly ToolAvailability[] => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map<string, ProviderDescriptor>(
@@ -354,7 +345,6 @@ export const buildCapabilityInventory = (
targetFormat: status.format,
descriptors,
policy,
isToolAdvertised,
});
const effects = composedEffects(
contract.name,
@@ -408,13 +398,8 @@ export const buildCapabilityInventory = (
available: false,
reason: availability.reason,
remediation: availability.remediation,
...(availability.alternatives === undefined
? {}
: { alternatives: [...availability.alternatives] }),
};
})
.filter(({ name }) => isToolAdvertised(name))
.sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
}).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
};
type ClientRequirements = ToolAvailability["client_requirements"];
@@ -460,9 +445,6 @@ export function compactToolAvailability(
? {}
: { default_mode_available: entry.default_mode_available }),
...(entry.modes === undefined ? {} : { modes: [...entry.modes] }),
...(entry.available || entry.alternatives === undefined
? {}
: { alternatives: [...entry.alternatives] }),
...(JSON.stringify(entry.client_requirements) ===
JSON.stringify(clientRequirements)
? {}
@@ -977,7 +959,6 @@ const providerAvailability = ({
kind,
name,
policy,
isToolAdvertised,
}: AvailabilityContext): Availability => {
if (kind === "enhanced") return composedAvailability(name, descriptors);
const launchFailure =
@@ -995,20 +976,12 @@ const providerAvailability = ({
kind === "native-provider")
)
return { reason: "available", remediation: null };
if (descriptor === undefined) {
const offered = offeredOperationAlternatives(
name,
(alternative) =>
descriptors.get(alternative)?.available === true &&
isToolAdvertised(alternative),
);
if (descriptor === undefined)
return {
reason: "provider_missing",
remediation:
"Install or configure a provider that declares this operation.",
...(offered === undefined ? {} : { alternatives: offered.operations }),
};
}
return descriptor.available
? { reason: "available", remediation: null }
: {
+1 -6
View File
@@ -93,12 +93,7 @@ it("joins concurrent shutdowns and seals ownership before asynchronous cleanup s
try {
await awaitAcknowledgement(entered, first, "capability cleanup");
second = scope.close();
let secondSettled = false;
void second.then(() => {
secondSettled = true;
});
await Promise.resolve();
expect(secondSettled).toBe(false);
expect(second).toBe(first);
expect(close).toHaveBeenCalledTimes(1);
expect(() => scope.own("later-owner", {}, async () => undefined)).toThrow(
"after host shutdown",
@@ -1,14 +1,15 @@
import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../tests/fixtures/claudeDesktop.js";
import { basename, dirname, join, sep } from "node:path";
import { describe, expect, it } from "vitest";
import {
CLAUDE_DESKTOP_PACKAGE_FAMILY,
resolveWindowsClaudeDesktopLocation,
type ClaudeDesktopLocationFileSystem,
} from "./ClaudeDesktopConfigLocation.js";
import {
packagePrivateLauncherBlocker,
packagePrivateStorageFamily,
redirectedWriteBlocker,
type MsixPackageView,
windowsAppDataRoots,
@@ -641,19 +642,15 @@ describe("package-private REA launchers", () => {
"rea.mjs",
);
it("blocks private cache paths without treating other package paths as private", () => {
const identity = (path: string) => path;
it("names the package that privately stores a path", () => {
expect(packagePrivateStorageFamily(stored, packages)).toBe(
CLAUDE_DESKTOP_PACKAGE_FAMILY,
);
expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined();
expect(
packagePrivateLauncherBlocker([stored], packages, identity),
).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
expect(
packagePrivateLauncherBlocker([launcher], packages, identity),
).toBeUndefined();
expect(
packagePrivateLauncherBlocker(
[join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x")],
packagePrivateStorageFamily(
join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"),
packages,
identity,
),
).toBeUndefined();
});
@@ -29,7 +29,7 @@ const PROBES_PER_DIRECTORY = 2;
* publisher ID Windows derives from `CN="Anthropic, PBC", O="Anthropic, PBC",
* L=San Francisco, S=California, C=US, …`.
*/
const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
/** Observed Claude package names plus Windows' 13-character publisher ID. */
const CLAUDE_PACKAGE_FAMILY =
+3 -3
View File
@@ -23,14 +23,14 @@ export interface ClientConfigurationSource extends ClientConfigurationDocument {
const invalidEnvironment = (
path: string,
error: ConfigurationError,
overrides: Readonly<Record<string, string>> | undefined,
platform: NodeJS.Platform,
overrides: Readonly<Record<string, string>> = {},
platform: NodeJS.Platform = process.platform,
): ClientConfigurationFileError => {
const constraints = error.settings
.map(({ setting, constraint }) => `${setting}: ${constraint}`)
.join("; ");
const overrideNames = new Set(
Object.keys(overrides ?? {}).map((name) =>
Object.keys(overrides).map((name) =>
platform === "win32" ? name.toUpperCase() : name,
),
);
@@ -1,9 +1,15 @@
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
import { clientRegistrationSource } from "./ClientRegistrationSource.js";
import type { SetupClient } from "./SupportedClients.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import type { Environment } from "../config/environment.js";
import {
inspectAnalysisSettingSeeds,
launchAnalysisSetting,
} from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
@@ -308,24 +314,34 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async (
const validatedDetails = (
environment: Readonly<Record<string, string>>,
validation: "fragment" | "effective",
platform: NodeJS.Platform,
expressionSettings: ReadonlySet<string>,
platform: NodeJS.Platform = process.platform,
expressionSettings: ReadonlySet<string> = new Set(),
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
const validated = validateRegistrationEnvironment(
environment,
expressionSettings,
validation,
platform,
const runtimeEnvironment = snapshotEnvironment(environment, platform);
const unresolved = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
platform,
),
),
);
return validated.ok
? ok(
environmentDetails(
environment,
validated.value.configuration,
validated.value.unresolvedSettings,
),
)
: validated;
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolved)
: parseEnvironment(runtimeEnvironment, unresolved);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolved,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok(environmentDetails(environment, validated.value, unresolved));
};
const admittedTimeout = (
@@ -381,7 +397,7 @@ const registrationTimeoutInputs = (
const environmentDetails = (
environment: Readonly<Record<string, string>>,
configuration: Environment,
unresolvedSettings: ReadonlySet<string>,
unresolvedSettings: ReadonlySet<string> = new Set(),
): ClientRegistrationEnvironmentDetails => ({
values: Object.fromEntries(
Object.entries(environment).sort(([left], [right]) =>
+1 -1
View File
@@ -649,7 +649,7 @@ const packageVersionRemediation = (
const unavailableStatus = (
client: SetupClient,
state: "missing" | "invalid",
configPath: string,
configPath: string = client.configPath,
): ClientRegistrationStatus => ({
client: client.name,
config_path: configPath,
+7 -7
View File
@@ -296,7 +296,7 @@ export const runProviderAnalysis = async (
delivery.detail,
operationSignal,
);
return result.ok ? result : err(cliError(result.error, path));
return result.ok ? ok(result.value) : err(cliError(result.error, path));
}
const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
if (!admitted.ok) return err(cliError(admitted.error, path));
@@ -445,14 +445,14 @@ const runAnalysis = async (
let isDirectory = false;
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
const inspected = await inspectBundleResourceInput(path);
if (!inspected.ok) return inspected;
if (!inspected.ok) return err(inspected.error);
isDirectory = inspected.value;
}
if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) {
const opened = await session.openBundleResources(path, { signal });
if (!opened.ok) return opened;
if (!opened.ok) return err(opened.error);
const execution = await session.execute(tool, arguments_, { signal });
if (!execution.ok) return execution;
if (!execution.ok) return err(execution.error);
return ok(
withEvidenceCallerInputPath(
createAnalysisExecutionEvidence(
@@ -472,7 +472,7 @@ const runAnalysis = async (
signal,
...targetSelection(options),
});
if (!prepared.ok) return prepared;
if (!prepared.ok) return err(prepared.error);
const { snapshot } = prepared.value;
let resolvedTarget: ResolvedSessionOpen | undefined;
if (snapshot !== undefined && prepared.value.target !== undefined) {
@@ -484,7 +484,7 @@ const runAnalysis = async (
? {}
: { providerId: options.providerId }),
});
if (!preview.ok) return preview;
if (!preview.ok) return err(preview.error);
resolvedTarget = preview.value;
const route = preview.value.route;
const bindingProfile =
@@ -548,7 +548,7 @@ const runAnalysis = async (
resolvedTarget === undefined
? await session.open(path, openOptions)
: await session.openResolvedTarget(resolvedTarget, openOptions);
if (!opened.ok) return opened;
if (!opened.ok) return err(opened.error);
const evidenceProfile = analysisProfileForEvidence(session, tool);
const executed = await executeAnalysisTool({
session,
+38 -11
View File
@@ -1,11 +1,18 @@
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
import { isMap, isSeq, type Node, type YAMLMap } from "yaml";
import type { Environment } from "../config/environment.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
import { mergeEnvironmentOverrides } from "../process/snapshotEnvironment.js";
import {
mergeEnvironmentOverrides,
snapshotEnvironment,
} from "../process/snapshotEnvironment.js";
export const DSH_ENTRY_ID = "rea-mcp";
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
@@ -248,15 +255,35 @@ export class DshCordisDocument {
);
literals[key] = value;
}
const validated = validateRegistrationEnvironment(
literals,
unresolvedSettings,
validation,
platform,
const runtimeEnvironment = snapshotEnvironment(literals, platform);
const unresolved = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...unresolvedSettings].map((key) => [key, ""])),
platform,
),
),
);
return validated.ok
? ok({ values: literals, ...validated.value })
: validated;
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolved)
: parseEnvironment(runtimeEnvironment, unresolved);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolved,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok({
values: literals,
configuration: validated.value,
unresolvedSettings: unresolved,
});
}
desired(
+3 -1
View File
@@ -307,7 +307,9 @@ const projectWrite = (
bundle: EvidenceBundle,
written: Result<PublishedEvidenceFile, AnalysisError>,
): Result<JsonValue, AnalysisError> =>
written.ok ? ok(evidenceBundleExportReceipt(bundle, written.value)) : written;
written.ok
? ok(evidenceBundleExportReceipt(bundle, written.value))
: err(written.error);
/** Standalone bundle discovery shares the same read owner as import and comparison. */
export const inspectEvidenceBundleCommand = async (
+11 -6
View File
@@ -31,11 +31,16 @@ import {
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
type EvidenceBundleFileFailure =
type EvidenceReadFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisResourceConstraintError
| AnalysisCancelledError;
type EvidenceWriteFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisCancelledError
| AnalysisResourceConstraintError;
/** Project a published bundle with record and residual-unknown revision counts. */
export const evidenceBundleExportReceipt = (
@@ -54,7 +59,7 @@ export const readEvidenceBundle = (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> =>
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, false, signal, resources, operation);
/** Read an authenticated immutable snapshot for a subsequent session import. */
@@ -63,7 +68,7 @@ export const readImmutableEvidenceBundle = (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> =>
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, true, signal, resources, operation);
/** Join both comparison reads and preserve cleanup owned by either failed input. */
@@ -104,7 +109,7 @@ const readBundle = async (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> => {
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
@@ -130,7 +135,7 @@ export const authenticateEvidenceBundleValue = (
value: unknown,
path: string,
options: { readonly operation: string; readonly immutable?: boolean },
): Result<EvidenceBundle, EvidenceBundleFileFailure> => {
): Result<EvidenceBundle, EvidenceReadFailure> => {
const { operation } = options;
const headroom = requireAuthenticationHeadroom(value, {
operation,
@@ -221,7 +226,7 @@ export const writeEvidenceBundle = async (
): Promise<
Result<
{ readonly path: string; readonly bytes: number },
EvidenceBundleFileFailure
EvidenceWriteFailure
>
> => {
if (signal?.aborted === true)
@@ -4,6 +4,7 @@ import { parseDocument } from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
editGooseConfigurationYaml,
GooseConfigurationYamlError,
parseGooseConfigurationYaml,
stringifyGooseConfigurationYaml,
} from "./GooseConfigurationYaml.js";
@@ -110,15 +111,11 @@ describe("Goose YAML 1.2 source facts", () => {
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(ConfigurationError);
expect(failure).toBeInstanceOf(GooseConfigurationYamlError);
expect(failure).toMatchObject({ kind: "shadowed_member", path });
expect(failure).toHaveProperty(
"message",
`Goose YAML contains a duplicate mapping member at ${path.join(".")}`,
);
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
).toThrow(`duplicate mapping member at ${path.join(".")}`);
).toThrow(GooseConfigurationYamlError);
},
);
+1 -1
View File
@@ -143,7 +143,7 @@ const gooseYamlOptions = {
} as const;
/** A duplicate YAML member cannot establish one selected configuration value. */
class GooseConfigurationYamlError extends ConfigurationError {
export class GooseConfigurationYamlError extends ConfigurationError {
readonly kind = "shadowed_member";
constructor(
readonly path: readonly string[],
+2 -2
View File
@@ -49,13 +49,13 @@ export type HopperRequestFailure = HopperDownloadFailure & {
export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
/** Longest wait for the response or its next bytes before a download stalls. */
const HOPPER_DOWNLOAD_STALL_MS = 30_000;
export const HOPPER_DOWNLOAD_STALL_MS = 30_000;
/**
* Overall bound for one download. A slow connection that keeps delivering
* bytes is bounded here rather than by a deadline sized for fast networks.
*/
const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
export const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
/** Small release feed and key requests must not use the package bounds. */
export const HOPPER_METADATA_TIMEOUT_MS = 10_000;
+47 -20
View File
@@ -4,8 +4,9 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
formatCommandForDisplay,
installLinuxHopper,
linuxHopperInstallDisclosure,
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
@@ -30,6 +31,19 @@ import * as nativeCommands from "../process/ExecFileOutput.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe("Linux package-manager command display", () => {
it("preserves argument boundaries for paths with spaces and quotes", () => {
expect(
formatCommandForDisplay("pkexec", [
"pacman",
"-U",
"/tmp/rea test/hopper.arch",
"apostrophe'path",
]),
).toBe('pkexec pacman -U "/tmp/rea test/hopper.arch" "apostrophe\'path"');
});
});
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
@@ -249,34 +263,47 @@ describe("Linux Hopper installation", () => {
] as const)(
"selects the %s native package manager and dependencies",
(family, executable, packages) => {
const root = linuxHopperInstallDisclosure(family, true).commands;
const nonRoot = linuxHopperInstallDisclosure(family, false).commands;
expect(root.some((command) => command.startsWith(`${executable} `))).toBe(
true,
);
for (const dependency of packages)
expect(
root.some((command) => command.split(" ").includes(dependency)),
).toBe(true);
expect(
nonRoot.some(
(command) =>
command.startsWith(`pkexec ${executable} `) &&
command.includes(`"<verified-hopper.${family}>"`),
),
).toBe(true);
linuxPackageManagerCommands(family, "/tmp/Hopper package", true),
).toContainEqual(
expect.objectContaining({
executable,
args: expect.arrayContaining([...packages]),
}),
);
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", false),
).toContainEqual(
expect.objectContaining({
executable: "pkexec",
args: expect.arrayContaining([executable, "/tmp/Hopper package"]),
}),
);
},
);
it.each([true, false])(
"keeps installed apt dependencies at their versions while Hopper may upgrade (root: %s)",
(isRoot) => {
const prefix = isRoot ? "" : "pkexec ";
const prefix = isRoot ? [] : ["pkexec"];
expect(
linuxHopperInstallDisclosure("deb", isRoot).commands.slice(0, 2),
linuxPackageManagerCommands("deb", "/tmp/Hopper package", isRoot).map(
({ executable, args }) => [executable, ...args],
),
).toEqual([
`${prefix}apt-get install -y --no-upgrade xvfb xauth python3 libx11-6 libxtst6`,
`${prefix}apt-get install -y "<verified-hopper.deb>"`,
[
...prefix,
"apt-get",
"install",
"-y",
"--no-upgrade",
"xvfb",
"xauth",
"python3",
"libx11-6",
"libxtst6",
],
[...prefix, "apt-get", "install", "-y", "/tmp/Hopper package"],
]);
},
);
+3 -3
View File
@@ -511,7 +511,7 @@ export const installLinuxHopper = async (
};
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
const linuxHopperLauncherPath = (home: string): string =>
export const linuxHopperLauncherPath = (home: string): string =>
appendCallerPath(home, ".local/share/rea/hopper/bin/Hopper", "linux");
/** REA's managed Linux install path, preferred when no explicit launcher is set. */
@@ -796,7 +796,7 @@ const runPackageManagerCommand = async (
};
/** Preserve argument boundaries in a command shown to the user. */
const formatCommandForDisplay = (
export const formatCommandForDisplay = (
executable: string,
args: readonly string[],
): string =>
@@ -807,7 +807,7 @@ const formatCommandForDisplay = (
.join(" ");
/** Select the native package manager and authorization boundary without shell evaluation. */
const linuxPackageManagerCommands = (
export const linuxPackageManagerCommands = (
family: LinuxPackageFamily,
archive: string,
isRoot: boolean,
@@ -6,11 +6,16 @@ import { err, ok } from "../domain/result.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import {
linuxHopperInstallDisclosure,
linuxPackageManagerCommands,
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
/** Package transactions that install Hopper before any runtime library repair. */
const debCommands = 2;
const debCommands = linuxPackageManagerCommands(
"deb",
"/tmp/verified-hopper.deb",
false,
).length;
const missingPython = {
interpreter: "/usr/bin/python3",
@@ -36,34 +41,6 @@ const commandSuccess = {
};
describe("Linux Hopper package runtime", () => {
it("preserves package path boundaries in the reported failed command", async () => {
const directory = await createTestTempDirectory("rea-hopper-command-");
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
async () => undefined,
);
const archive = "/tmp/rea test/hopper's package.rpm";
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValue({
exitCode: 1,
stdout: "",
stderr: "fixture failure",
outputTruncated: false,
});
try {
const result = await host.installPackage("rpm", archive);
expect(command.mock.calls[0]?.[1]).toContain(archive);
expect(result).toMatchObject({
status: "failed",
failure: { command: expect.stringContaining(`"${archive}"`) },
});
} finally {
command.mockRestore();
await host.close?.();
}
});
it.each([
{
name: "installs and verifies the matching future Python library",
+20 -7
View File
@@ -11,7 +11,7 @@ import {
import { tmpdir } from "node:os";
import { join, posix, relative } from "node:path";
import { installMacHopperBundle } from "./MacHopper.js";
import { linuxHopperLauncherCandidates } from "./LinuxHopper.js";
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { describe, expect, it } from "vitest";
@@ -19,17 +19,34 @@ import {
localMacHopperRenamedLauncherCandidates,
macHopperCanonicalLauncherCandidates,
macHopperUserApplicationPath,
macHopperRenamedLauncherCandidates,
readMacHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
describe("macOS Hopper launcher paths", () => {
const home = "/fixture/home";
it("keeps canonical system and user candidates in order", () => {
it("keeps canonical system and user candidates ahead of renamed bundles", () => {
expect(macHopperCanonicalLauncherCandidates(home)).toEqual([
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
]);
expect(
macHopperRenamedLauncherCandidates(
home,
new Map([
[
"/Applications",
["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"],
],
["/fixture/home/Applications", ["Hopper User.app"]],
]),
),
).toEqual([
"/Applications/Hopper a.app/Contents/MacOS/hopper",
"/Applications/Hopper z.app/Contents/MacOS/hopper",
"/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper",
]);
});
it.skipIf(process.platform === "win32")(
@@ -67,7 +84,7 @@ describe("macOS Hopper launcher paths", () => {
expect(macHopperCanonicalLauncherCandidates(home)[1]).toBe(
`${home}/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`,
);
expect(linuxHopperLauncherCandidates(home)[1]).toBe(
expect(linuxHopperLauncherPath(home)).toBe(
`${home}/.local/share/rea/hopper/bin/Hopper`,
);
const source = join(temporary, "source.app");
@@ -137,17 +154,13 @@ describe("macOS Hopper launcher paths", () => {
recursive: true,
});
await mkdir(join(applications, "Hopper Disassembler.app"));
await mkdir(join(applications, "Hopper Alpha.app"));
await mkdir(join(applications, "Hopper Zeta.app"));
await writeFile(join(applications, "Hopper File.app"), "");
const runtime = [...localMacHopperRenamedLauncherCandidates(home)];
expect(runtime.filter((path) => path.startsWith(home))).toEqual([
posix.join(home, "Applications/Hopper Alpha.app/Contents/MacOS/hopper"),
posix.join(
home,
"Applications/Hopper Preview.app/Contents/MacOS/hopper",
),
posix.join(home, "Applications/Hopper Zeta.app/Contents/MacOS/hopper"),
]);
expect(await readMacHopperRenamedLauncherCandidates(home)).toEqual(
runtime,
+1 -1
View File
@@ -43,7 +43,7 @@ export const macHopperCanonicalLauncherCandidates = (
];
/** Build deterministic candidates from the discovered app bundle names. */
const macHopperRenamedLauncherCandidates = (
export const macHopperRenamedLauncherCandidates = (
homeDirectory: string,
bundleNamesByRoot: ReadonlyMap<string, readonly string[]>,
): readonly string[] =>
+8 -36
View File
@@ -5,28 +5,7 @@ import { z } from "zod";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js";
import { resolveMcpRegistrationClients } from "./McpRegistrationCatalog.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
const selectAgent = async (agent: string) => {
const root = await createTestTempDirectory("rea-mcp-alias-");
const resources = new ArtifactResourceScope();
try {
return await resolveMcpRegistrationClients(
{
agents: [agent],
global: true,
cwd: root,
home: root,
platform: process.platform,
environment: {},
},
resources,
);
} finally {
await resources.close();
}
};
import { resolveMcpRegistrationClientId } from "./McpRegistrationCatalog.js";
it.each([
["cline-vscode", "cline"],
@@ -44,22 +23,15 @@ it.each([
["kiro", "kiro-cli"],
["mastra", "mastracode"],
["pi-agent", "pi"],
])("resolves registration alias %s as %s", async (alias, expected) => {
const result = await selectAgent(alias.toUpperCase());
if (!result.ok) throw result.error;
expect(result.value.map(({ id }) => id)).toEqual([expected]);
])("retains helper alias %s as %s", (alias, expected) => {
expect(resolveMcpRegistrationClientId(alias.toUpperCase())).toBe(expected);
});
it.each(["__proto__", "constructor", "unknown-client"])(
"rejects unknown registration identity %s",
async (agent) => {
const result = await selectAgent(agent);
expect(result).toMatchObject({
ok: false,
error: { _tag: "ConfigurationError" },
});
},
);
it("does not treat prototype names or unknown strings as client identities", () => {
expect(resolveMcpRegistrationClientId("__proto__")).toBeUndefined();
expect(resolveMcpRegistrationClientId("constructor")).toBeUndefined();
expect(resolveMcpRegistrationClientId("unknown-client")).toBeUndefined();
});
const clientSchema = z.object({
id: z.string(),
+1 -1
View File
@@ -356,7 +356,7 @@ const ALIASES = {
const isClientId = (value: string): value is McpRegistrationClientId =>
Object.hasOwn(MCP_REGISTRATION_CLIENTS, value);
const resolveMcpRegistrationClientId = (
export const resolveMcpRegistrationClientId = (
input: string,
): McpRegistrationClientId | undefined => {
const lower = input.toLowerCase();
+18 -4
View File
@@ -1,4 +1,3 @@
import { readElfAttributeUleb } from "../domain/elfAttributeUleb.js";
import type { MipsGnuAttributes } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -23,6 +22,21 @@ export const parseMipsGnuAttributes = (
const readU32 = (offset: number): number =>
littleEndian ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const readUleb = (
offset: number,
end: number,
): { readonly value: number; readonly next: number } | undefined => {
let value = 0;
for (let index = 0; index < 5 && offset + index < end; index += 1) {
const byte = bytes[offset + index]!;
value += (byte & 0x7f) * 2 ** (7 * index);
if ((byte & 0x80) === 0)
return value <= 0xffffffff
? { value, next: offset + index + 1 }
: undefined;
}
return undefined;
};
let fpAbi: number | null = null;
let msa: number | null = null;
@@ -51,7 +65,7 @@ export const parseMipsGnuAttributes = (
let cursor = vendorEnd + 1;
while (cursor < end) {
const scopeStart = cursor;
const scope = readElfAttributeUleb(bytes, cursor, end);
const scope = readUleb(cursor, end);
if (scope === undefined)
return err("malformed MIPS GNU attribute scope tag");
if (scope.value !== TAG_FILE)
@@ -71,7 +85,7 @@ export const parseMipsGnuAttributes = (
const scopeEnd = scopeStart + scopeLength;
cursor = scope.next + 4;
while (cursor < scopeEnd) {
const tag = readElfAttributeUleb(bytes, cursor, scopeEnd);
const tag = readUleb(cursor, scopeEnd);
if (tag === undefined) return err("malformed MIPS GNU attribute tag");
if (
tag.value !== TAG_GNU_MIPS_ABI_FP &&
@@ -80,7 +94,7 @@ export const parseMipsGnuAttributes = (
return err(
`MIPS ELF declares GNU attribute tag ${tag.value}, which REA does not inspect`,
);
const value = readElfAttributeUleb(bytes, tag.next, scopeEnd);
const value = readUleb(tag.next, scopeEnd);
if (value === undefined)
return err("malformed MIPS GNU attribute value");
if (tag.value === TAG_GNU_MIPS_ABI_FP) {
+1 -1
View File
@@ -54,7 +54,7 @@ const pathWithin = (path: string, root: string): boolean => {
* processes see those files at virtual AppData locations; other processes do
* not, and Windows removes them with the package.
*/
const packagePrivateStorageFamily = (
export const packagePrivateStorageFamily = (
canonicalPath: string,
packagesDirectory: string,
): string | undefined => {
-84
View File
@@ -1,84 +0,0 @@
import type { AnalysisOperation } from "./AnalysisProvider.js";
/** One operation that observes part of what a missing operation answers. */
interface OperationAlternative {
readonly operation: AnalysisOperation;
/** What it returns, as a noun phrase. */
readonly observes: string;
}
/**
* Follow-ups for one question shape that an undeclared operation would have
* answered. They are conditional rather than substitutes: `limitation`
* states what none of them establishes.
*/
interface OperationAlternativeRelation {
readonly question: string;
readonly alternatives: readonly OperationAlternative[];
readonly limitation: string;
}
const INSTRUCTION_OBSERVATIONS: OperationAlternativeRelation = {
question: "instruction-level questions",
alternatives: [
{
operation: "inspect_native_instruction",
observes:
"the decoded instruction or data at one exact address, with its length and static flow",
},
{
operation: "read_function_instructions",
observes: "one analyzed procedure's instruction listing in address order",
},
{
operation: "procedure_assembly",
observes: "one analyzed procedure's assembly text",
},
],
limitation:
"None navigates arbitrary analyzed objects such as data or addresses outside a procedure, and neither listing order nor address plus length proves executed control flow.",
};
/** Operations absent from this relation keep their consumer's generic advice. */
const OPERATION_ALTERNATIVES: ReadonlyMap<
string,
OperationAlternativeRelation
> = new Map<AnalysisOperation, OperationAlternativeRelation>([
["next_address", INSTRUCTION_OBSERVATIONS],
["prev_address", INSTRUCTION_OBSERVATIONS],
]);
/** Declared alternatives the caller can run now, in declaration order. */
export interface OfferedOperationAlternatives {
readonly operations: readonly AnalysisOperation[];
/** Each offer's scope, what none establishes, and the provider choice. */
readonly remediation: string;
}
/**
* Select the declared alternatives for an operation the current provider does
* not declare. `isOffered` holds only for operations that provider makes
* available and the caller advertises. Undefined means none qualifies.
*/
export const offeredOperationAlternatives = (
operation: string,
isOffered: (alternative: AnalysisOperation) => boolean,
): OfferedOperationAlternatives | undefined => {
const relation = OPERATION_ALTERNATIVES.get(operation);
if (relation === undefined) return undefined;
const offered = relation.alternatives.filter((alternative) =>
isOffered(alternative.operation),
);
if (offered.length === 0) return undefined;
const described = offered.map(
({ operation: alternative, observes }) => `${alternative} (${observes})`,
);
const listed =
described.length === 1
? described[0]
: `${described.slice(0, -1).join(", ")} and ${described.at(-1)}`;
return {
operations: offered.map((alternative) => alternative.operation),
remediation: `For ${relation.question}, the current provider offers ${listed}. ${relation.limitation} Running ${operation} itself requires a provider that declares it.`,
};
};
@@ -34,7 +34,7 @@ export interface ParsedReferenceSourceEntries {
}
/** Preserve the observed reader failure and add entry-specific recovery guidance. */
const projectReferenceSourceEntryFailure = (
export const projectReferenceSourceEntryFailure = (
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
): string => `${entry.message} ${entryRecoveryGuidance(entry)}`;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
projectReferenceSourceImportError,
referenceSourceImportAnalysisError,
type ReferenceSourceImportError,
} from "./ReferenceSourceImportTypes.js";
@@ -19,13 +20,11 @@ describe("reference-source import error projection", () => {
parse: "execution_failure",
} as const;
for (const [code, category] of Object.entries(expectedCategories)) {
const projected = projectAnalysisError(
referenceSourceImportAnalysisError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
}),
);
const projected = projectReferenceSourceImportError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
@@ -5,6 +5,10 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import type {
AnalysisError,
AnalysisErrorOptions,
@@ -121,6 +125,12 @@ export const referenceSourceImportAnalysisError = (
);
};
/** Canonical CLI/MCP projection, retaining source facts under diagnostic details. */
export const projectReferenceSourceImportError = (
error: ReferenceSourceImportError,
): AnalysisErrorProjection =>
projectAnalysisError(referenceSourceImportAnalysisError(error));
/** Import a caller-selected local source tree as historical reference. */
export interface ReferenceSourceImportOptions {
readonly root: string;
@@ -1,49 +0,0 @@
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
/** Validate literal registration settings while retaining expression-owned unknowns. */
export const validateRegistrationEnvironment = (
values: Readonly<Record<string, string>>,
expressionSettings: ReadonlySet<string>,
validation: "fragment" | "effective",
platform: NodeJS.Platform,
): Result<
{
readonly configuration: Environment;
readonly unresolvedSettings: ReadonlySet<string>;
},
ConfigurationError
> => {
const runtimeEnvironment = snapshotEnvironment(values, platform);
const unresolvedSettings = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
platform,
),
),
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolvedSettings)
: parseEnvironment(runtimeEnvironment, unresolvedSettings);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolvedSettings,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok({ configuration: validated.value, unresolvedSettings });
};
+1 -8
View File
@@ -101,16 +101,9 @@ it("starts child finalization synchronously and shares concurrent closes", async
]);
const first = owner.close();
expect(calls).toBe(1);
const second = owner.close();
let secondSettled = false;
void second.then(() => {
secondSettled = true;
});
await Promise.resolve();
expect(secondSettled).toBe(false);
expect(owner.close()).toBe(first);
complete?.();
expect(await first).toBeUndefined();
expect(await second).toBeUndefined();
expect(await owner.close()).toBeUndefined();
expect(calls).toBe(1);
});
+2 -2
View File
@@ -420,8 +420,8 @@ export const validateCanonicalSkillDirectories = async (
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
resources?: ArtifactResourceScope,
): Promise<readonly CanonicalSkillFile[]> => {
const destinations = skillDestinations(
+1 -1
View File
@@ -872,7 +872,7 @@ const readClientConfiguration = async (
client: UninstallClient,
fileSystem: UninstallFileSystem,
resources: ArtifactResourceScope,
ownershipEstablished: boolean,
ownershipEstablished = false,
): Promise<ClientConfigurationRead> => {
const applicabilityUnverified =
client.applicability === "unverified" && !ownershipEstablished;
+1 -1
View File
@@ -18,7 +18,7 @@ import { UpdateCommandProgress } from "./UpdateCommandProgress.js";
import type { UpdateOutput } from "./Update.js";
import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js";
const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024;
export const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024;
const UPDATE_PROBE_TIMEOUT_MS = 10_000;
/** The runner owns whether the process started; callers need not infer it from text. */
+2 -2
View File
@@ -144,7 +144,7 @@ const INVALID_NPM_RELEASE_METADATA =
"Invalid npm release metadata: expected a version string or a single-element version array.";
/** bun info arguments for dist-tags.latest; Bun resolves its configured registry. */
const BUN_LATEST_VERSION_COMMAND: readonly string[] = [
export const BUN_LATEST_VERSION_COMMAND: readonly string[] = [
"bun",
"info",
PRODUCT_IDENTITY.packageName,
@@ -173,7 +173,7 @@ const npmLatestVersionCommand = (
*/
const parseNpmLatestVersion = (metadata: string): Result<string, string> => {
const decoded = safeParseJson(metadata);
if (!decoded.ok) return decoded;
if (!decoded.ok) return err(decoded.error);
const version = readNpmLatestVersion(decoded.value);
return version === undefined
? err(INVALID_NPM_RELEASE_METADATA)
+1 -1
View File
@@ -217,7 +217,7 @@ export const executeComposedWorkflow = async (
};
/** Scope of the exact-address reference lookups composed by trace_feature. */
const TRACE_FEATURE_REFERENCE_LIMITATION =
export const TRACE_FEATURE_REFERENCE_LIMITATION =
"Reference queries cover only each matched start address, not interior bytes of strings and procedures. Empty references and truncated: false do not establish complete reference coverage.";
/** Build the shared Evidence record for a successful composed binary workflow. */
@@ -17,7 +17,7 @@ import {
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { validateAnalysisViewSubject } from "../../domain/analysisView/analysisViewParent.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import {
createEvidence,
@@ -249,11 +249,15 @@ const projectResolvedAnalysisView = (
},
});
if (!projected.ok) return projected;
const identity = validateAnalysisViewSubject(
parent.subject,
projected.value.artifact?.sha256,
);
if (!identity.ok) return identity;
if (
parent.subject !== null &&
parent.subject.digest.sha256 !== projected.value.artifact?.sha256
)
return err(
new EvidenceIntegrityError(
`Parent subject SHA-256 ${parent.subject.digest.sha256} differs from analysis artifact SHA-256 ${projected.value.artifact?.sha256 ?? "unknown"}.`,
),
);
return ok(createAnalysisViewEvidence(input, presentation, projected.value));
};
@@ -11,7 +11,7 @@ import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "./AnalysisViewService.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import type { Evidence } from "../../domain/evidence.js";
const trace = async (
@@ -173,7 +173,7 @@ export const requiresNativeSourceAdmissionBeforeReplay = (
): boolean => process.platform === "win32" && providerId === "ghidra";
/** Limitation that marks every observation replayed from a snapshot. */
const SNAPSHOT_REPLAY_LIMITATION =
export const SNAPSHOT_REPLAY_LIMITATION =
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.";
/** Bounded in-memory cache for one immutable binary identity. */
+6 -20
View File
@@ -54,10 +54,7 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js";
import { recoverProviderSourceFailure } from "../investigation/sourcePublicationRecovery.js";
import { BinarySessionRecords } from "./BinarySessionRecords.js";
import {
binarySessionStatus,
currentRouteCapability,
} from "./BinarySessionStatus.js";
import { binarySessionStatus } from "./BinarySessionStatus.js";
import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js";
import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js";
import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js";
@@ -1399,6 +1396,11 @@ export class BinarySession
: structuredClone(this.#active.target);
}
activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined {
const root = this.#activeBundleResources?.root;
return root === undefined ? undefined : structuredClone(root);
}
override exportAnalysisSnapshot() {
if (this.#activeBundleResources !== undefined)
return err(new ResourceSnapshotMismatchError("export"));
@@ -1565,8 +1567,6 @@ export class BinarySession
parameters: arguments_,
snapshotContext: options?.snapshotContext,
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
isOperationAvailable: (operation) => this.#currentlyAvailable(operation),
isOperationAdvertised: options?.isOperationAdvertised,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
lookupSnapshot: (target, profile, operation, parameters) =>
@@ -1705,20 +1705,6 @@ export class BinarySession
return execution;
}
/** Whether the active route can run an operation now, as status reports it. */
#currentlyAvailable(operation: string): boolean {
const active = this.#active;
const descriptor = active?.route.capabilities.get(operation);
return (
descriptor !== undefined &&
currentRouteCapability(
descriptor,
active?.target,
this.#runtimeAvailability.entries,
).available
);
}
#clearRuntimeAvailability(): void {
if (this.#runtimeAvailability.clear()) this.#emitAvailabilityChanged();
}
@@ -145,11 +145,9 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
path: opened.value.canonicalPath,
caller_path: opened.value.callerPath,
});
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
resource_root: { canonical_path: opened.value.canonicalPath },
});
expect(session.activeBundleResourceRoot()?.canonicalPath).toBe(
opened.value.canonicalPath,
);
expect(
bundleResourceRootSchema.parse({
caller_path: opened.value.callerPath,
@@ -17,7 +17,6 @@ import type {
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import { offeredOperationAlternatives } from "../OperationAlternatives.js";
import {
isSnapshotCacheable,
requestsFreshIndependentDecompile,
@@ -46,11 +45,6 @@ interface PrepareSessionExecutionInput {
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly snapshotContext?: ExecutionOptions["snapshotContext"];
readonly nativeSourceAdmitted: boolean;
/** Current availability as binary_session reports it, not the declaration. */
readonly isOperationAvailable: (operation: AnalysisOperation) => boolean;
readonly isOperationAdvertised:
| ((operation: AnalysisOperation) => boolean)
| undefined;
readonly unboundOperationError: (
operation: AnalysisOperation,
route: SessionProviderRoute,
@@ -64,9 +58,9 @@ interface PrepareSessionExecutionInput {
}
/** Recovery for an operation absent from the selected binary route. */
const undeclaredOperationGuidance = (
export const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable: boolean,
entryCommentAvailable = false,
): string => {
const entryComment =
entryCommentAvailable &&
@@ -85,8 +79,6 @@ export const prepareSessionExecution = (
operation,
parameters,
nativeSourceAdmitted,
isOperationAvailable,
isOperationAdvertised,
unboundOperationError,
lookupSnapshot,
} = input;
@@ -105,15 +97,6 @@ export const prepareSessionExecution = (
capability === undefined
? undeclaredOperationGuidance(operation, entryCommentAvailable)
: undefined;
const offered =
capability === undefined
? offeredOperationAlternatives(
operation,
(alternative) =>
isOperationAvailable(alternative) &&
(isOperationAdvertised?.(alternative) ?? true),
)
: undefined;
return err(
new AnalysisCapabilityUnavailableError(
active.route.binding?.identity.id ?? active.route.identity.id,
@@ -131,12 +114,6 @@ export const prepareSessionExecution = (
: {
userMessage: `The selected provider does not declare ${operation}.`,
fallbackRemediationAction: undeclaredGuidance,
...(offered === undefined
? {}
: {
alternatives: offered.operations,
remediationAction: offered.remediation,
}),
},
),
);
@@ -1,200 +0,0 @@
import { describe, expect, it, onTestFinished } from "vitest";
import { observed } from "../../../tests/fixtures/analysisExecution.js";
import {
createBinarySessionTargets,
createTestBinarySession,
} from "../../../tests/fixtures/binarySession.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { ok } from "../../domain/result.js";
import type {
AnalysisOperation,
AnalysisProvider,
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import { buildCapabilityInventory } from "../CapabilityInventory.js";
import { offeredOperationAlternatives } from "../OperationAlternatives.js";
const IDENTITY = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
} as const;
const GENERIC =
"This analysis is unavailable for the current target. Choose another analysis or target.";
/** The owning formatter's remediation for exactly these offered tools. */
const remediationFor = (operation: string, offered: readonly string[]) =>
offeredOperationAlternatives(operation, (alternative) =>
offered.includes(alternative),
)?.remediation;
const capability = (
operation: Exclude<AnalysisOperation, "health">,
available = true,
): CapabilityDescriptor => ({
provider: IDENTITY,
operation,
...(available
? ({ available: true, reason: null } as const)
: ({
available: false,
reason: `${operation} needs a loaded listing`,
} as const)),
cachePolicy: "live",
effects: {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
},
limitations: [],
});
/** A deep provider whose declared operations alone decide the offers. */
const openSession = async (capabilities: readonly CapabilityDescriptor[]) => {
const provider: AnalysisProvider = {
identity: () => IDENTITY,
resolveAnalysisProfile: () =>
Promise.resolve(
ok({ profile: createAnalysisProfile(IDENTITY, { fixture: true }) }),
),
capabilities: () => capabilities,
createClient: () => ({
execute: (operation) => Promise.resolve(observed(operation)),
close: () => Promise.resolve(ok(null)),
}),
};
const session = createTestBinarySession(provider);
onTestFinished(async () => {
expect(await session.close()).toEqual({ ok: true, value: null });
});
const [target] = await createBinarySessionTargets();
expect((await session.open(target)).ok).toBe(true);
return session;
};
const refusal = async (
session: Awaited<ReturnType<typeof openSession>>,
operation: "next_address" | "prev_address",
options?: ExecutionOptions,
) => {
const result = await session.execute(operation, {}, options);
if (result.ok) throw new Error(`${operation} unexpectedly succeeded`);
return projectAnalysisError(result.error);
};
const offeredInDiscovery = async (
session: Awaited<ReturnType<typeof openSession>>,
isToolAdvertised?: (name: string) => boolean,
) =>
buildCapabilityInventory(
await session.status(),
{ processCaptureEnabled: false },
isToolAdvertised,
)
.filter(({ name }) => name === "next_address" || name === "prev_address")
.map((tool) => (tool.available ? undefined : tool.alternatives));
describe("undeclared navigation refusals", () => {
it("describe only offered tools, what none establishes and the provider choice", () => {
const offered = offeredOperationAlternatives(
"prev_address",
(alternative) => alternative === "procedure_assembly",
);
expect(offered?.operations).toEqual(["procedure_assembly"]);
expect(offered?.remediation).toContain("procedure_assembly (");
expect(offered?.remediation).not.toMatch(
/inspect_native_instruction|read_function_instructions/u,
);
expect(offered?.remediation).toContain("executed control flow");
expect(offered?.remediation).toContain("prev_address itself");
expect(
offeredOperationAlternatives("list_procedures", () => true),
).toBeUndefined();
});
it("offer only alternatives the provider makes available", async () => {
const session = await openSession([
capability("inspect_native_instruction", false),
capability("read_function_instructions"),
capability("procedure_assembly"),
]);
for (const operation of ["next_address", "prev_address"] as const)
expect(await refusal(session, operation)).toMatchObject({
code: "capability_unavailable",
remediation: {
action: remediationFor(operation, [
"read_function_instructions",
"procedure_assembly",
]),
},
details: {
provider_id: "fixture",
operation,
alternatives: ["read_function_instructions", "procedure_assembly"],
},
});
expect(await offeredInDiscovery(session)).toEqual([
["read_function_instructions", "procedure_assembly"],
["read_function_instructions", "procedure_assembly"],
]);
});
it("offer only alternatives the caller advertises", async () => {
const session = await openSession([
capability("read_function_instructions"),
capability("procedure_assembly"),
]);
const withoutListing = (operation: string) =>
operation !== "read_function_instructions";
const narrowed = await refusal(session, "next_address", {
isOperationAdvertised: withoutListing,
});
expect(narrowed.details?.alternatives).toEqual(["procedure_assembly"]);
expect(narrowed.remediation.action).toBe(
remediationFor("next_address", ["procedure_assembly"]),
);
expect(await offeredInDiscovery(session, withoutListing)).toEqual([
["procedure_assembly"],
["procedure_assembly"],
]);
const hidden = (operation: string) =>
operation !== "read_function_instructions" &&
operation !== "procedure_assembly";
const generic = await refusal(session, "prev_address", {
isOperationAdvertised: hidden,
});
expect(generic.remediation.action).toEqual(
expect.stringContaining("binary_session"),
);
expect(generic.details).not.toHaveProperty("alternatives");
expect(await offeredInDiscovery(session, hidden)).toEqual([
undefined,
undefined,
]);
});
it("keep a declared operation's own unavailability reason", async () => {
const session = await openSession([
capability("next_address", false),
capability("read_function_instructions"),
]);
const declared = await refusal(session, "next_address");
expect(declared).toMatchObject({
code: "capability_unavailable",
remediation: { action: GENERIC },
details: { reason: "next_address needs a loaded listing" },
});
expect(declared.details).not.toHaveProperty("alternatives");
const [next, previous] = await offeredInDiscovery(session);
expect(next).toBeUndefined();
expect(previous).toEqual(["read_function_instructions"]);
});
});
+22 -31
View File
@@ -51,34 +51,6 @@ interface BinarySessionStatusInput {
readonly refreshCandidates?: boolean;
}
/**
* One route capability as the session currently reports it: target
* applicability and observed runtime unavailability override the declaration.
*/
export const currentRouteCapability = (
descriptor: CapabilityDescriptor,
target: BinaryTarget | undefined,
runtimeUnavailability: ReadonlyMap<string, { readonly reason: string }>,
) => {
const targetRefusal =
target === undefined
? undefined
: descriptor.targetApplicability === undefined
? artifactTargetRefusal(descriptor.operation, target)
: targetApplicabilityRefusal(descriptor.targetApplicability, target);
if (targetRefusal !== undefined && descriptor.available)
return {
...descriptor,
available: false as const,
availabilityCode: targetRefusal.code,
reason: targetRefusal.reason,
};
const unavailable = runtimeUnavailability.get(descriptor.operation);
return unavailable === undefined
? descriptor
: runtimeUnavailable(descriptor, unavailable.reason);
};
/** Project internal provider routing state into the caller-visible session status. */
export const binarySessionStatus = async ({
investigation,
@@ -107,9 +79,28 @@ export const binarySessionStatus = async ({
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
)
.map((descriptor) =>
currentRouteCapability(descriptor, target, runtimeUnavailability),
);
.map((descriptor) => {
const targetRefusal =
target === undefined
? undefined
: descriptor.targetApplicability === undefined
? artifactTargetRefusal(descriptor.operation, target)
: targetApplicabilityRefusal(
descriptor.targetApplicability,
target,
);
if (targetRefusal !== undefined && descriptor.available)
return {
...descriptor,
available: false as const,
availabilityCode: targetRefusal.code,
reason: targetRefusal.reason,
};
const unavailable = runtimeUnavailability.get(descriptor.operation);
return unavailable === undefined
? descriptor
: runtimeUnavailable(descriptor, unavailable.reason);
});
const capabilities = providerCapabilitySets(routeCapabilities);
const analysisProviderBinding =
bundleResourceRoot !== undefined
@@ -31,7 +31,9 @@ type RoutedOperation = Pick<
>;
/** Reason recorded for an operation's own refusal or provider-wide failure. */
const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
export const runtimeFailureReason = (
failure: AnalysisError,
): string | undefined => {
if (failure._tag === "AnalysisCapabilityUnavailableError")
return failure.userMessage ?? failure.message;
return PROVIDER_WIDE_FAILURES.has(failure._tag)
@@ -40,7 +42,7 @@ const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
};
/** Explain unavailability inferred for an operation no request failed for. */
const inferredUnavailabilityReason = (
export const inferredUnavailabilityReason = (
providerId: string,
failure: Pick<ProviderFailure, "operation" | "reason">,
): string =>
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { ElfNotesPort } from "./ElfNotesPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -9,6 +8,7 @@ import {
elfNotesSchema,
type ElfNotes,
} from "../../domain/native/elfNotes.js";
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
import { elfArchitecture } from "../../domain/elfFixedHeader.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
@@ -54,7 +54,9 @@ export class ElfNotesService {
),
);
const value = report.data;
const architecture = elfArchitecture(value.machine);
const architecture = BINARY_ARCHITECTURES.find(
(supported) => supported === elfArchitecture(value.machine),
);
const locations = [
{ kind: "artifact-path" as const, path: value.artifact.path },
...value.tables.map((table) => ({
@@ -91,7 +93,8 @@ const validReport = (
maxEntries: number,
maxBytes: number,
): boolean => {
const inside = insideArtifact(report.artifact.bytes);
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
if (
report.artifact.bytes > maxBytes ||
report.coverage.examined_entries > maxEntries ||
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { ElfUnwindRangesPort } from "./ElfUnwindRangesPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -13,6 +12,7 @@ import {
MAX_INSPECT_ELF_UNWIND_RANGES,
type ElfUnwindRanges,
} from "../../domain/native/elfUnwindRanges.js";
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
import { elfArchitecture } from "../../domain/elfFixedHeader.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
@@ -75,7 +75,9 @@ export class ElfUnwindRangesService {
),
);
const value = report.data;
const architecture = elfArchitecture(value.machine);
const architecture = BINARY_ARCHITECTURES.find(
(supported) => supported === elfArchitecture(value.machine),
);
const locations = [
{ kind: "artifact-path" as const, path: value.artifact.path },
...rangesOf(value).map((range) => ({
@@ -107,6 +109,11 @@ export class ElfUnwindRangesService {
}
}
const inside =
(fileBytes: number) =>
(range: Range): boolean =>
range.bytes <= fileBytes && range.offset <= fileBytes - range.bytes;
const rangesOf = (report: ElfUnwindRanges): Range[] => {
const found: Range[] = [];
const add = (range: Range | null): void => {
@@ -374,7 +381,7 @@ const validReport = (
report.type_name !== elfObjectTypeName(report.type) ||
report.page.offset !== offset ||
report.page.limit !== limit ||
!rangesOf(report).every(insideArtifact(report.artifact.bytes)) ||
!rangesOf(report).every(inside(report.artifact.bytes)) ||
!has(report, CFA_LIMITATION) ||
!has(report, UNTRUSTED_LIMITATION) ||
(indirectVisible(report) && !has(report, INDIRECT_LIMITATION))
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { KernelModulePort } from "./KernelModulePort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -16,6 +15,7 @@ import {
type KernelModule,
type InspectKernelModuleInput,
} from "../../domain/native/kernelModule.js";
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
import { elfArchitecture } from "../../domain/elfFixedHeader.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
@@ -59,8 +59,11 @@ export class KernelModuleService {
),
);
const value = report.data;
const architecture =
value.machine === null ? undefined : elfArchitecture(value.machine);
const machine = value.machine;
const mapped = machine === null ? undefined : elfArchitecture(machine);
const architecture = BINARY_ARCHITECTURES.find(
(supported) => supported === mapped,
);
const locations = [
{ kind: "artifact-path" as const, path: value.artifact.path },
...rangesOf(value).map((range) => ({
@@ -92,6 +95,11 @@ export class KernelModuleService {
}
}
const inside =
(fileBytes: number) =>
(range: Range): boolean =>
range.bytes <= fileBytes && range.offset <= fileBytes - range.bytes;
const rangesOf = (report: KernelModule): Range[] => {
const found: Range[] = [];
const add = (range: Range | null | undefined): void => {
@@ -282,7 +290,7 @@ const validReport = (
if (
report.artifact.bytes > input.max_file_bytes ||
report.coverage.examined_entries > input.max_entries ||
!rangesOf(report).every(insideArtifact(report.artifact.bytes))
!rangesOf(report).every(inside(report.artifact.bytes))
)
return false;
if (report.applicability === "unsupported")
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { NativeInitializersPort } from "./NativeInitializersPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -107,7 +106,8 @@ const validReport = (
report.architecture !== input.architecture
)
return false;
const inside = insideArtifact(report.artifact.bytes);
const insideArtifact = (offset: number, bytes: number): boolean =>
offset <= report.artifact.bytes - bytes;
// Entry file offsets name the selected image, not another slice in the container.
const imageStart = slice?.file_offset ?? 0;
const imageEnd =
@@ -115,8 +115,7 @@ const validReport = (
const insideImage = (offset: number | null, bytes: number): boolean =>
offset === null || (offset >= imageStart && bytes <= imageEnd - offset);
if (
(slice !== null &&
!inside({ offset: slice.file_offset, bytes: slice.size })) ||
(slice !== null && !insideArtifact(slice.file_offset, slice.size)) ||
report.artifact.bytes > input.max_file_bytes ||
coverage.offset !== input.offset ||
coverage.limit !== input.limit ||
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeCertificatesPort } from "./PeCertificatesPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -95,7 +94,8 @@ const validReport = (
maxEntries: number,
maxBytes: number,
): boolean => {
const inside = insideArtifact(report.artifact.bytes);
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
if (
report.artifact.bytes > maxBytes ||
report.coverage.examined_entries > maxEntries ||
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeImportsPort } from "./PeImportsPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -102,7 +101,8 @@ const validReport = (
readonly max_file_bytes: number;
},
): boolean => {
const inside = insideArtifact(report.artifact.bytes);
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
const dllCount = report.imports.length + report.delay_imports.length;
if (
report.artifact.bytes > input.max_file_bytes ||
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeLoadConfigPort } from "./PeLoadConfigPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -133,16 +132,17 @@ const validReport = (
...fileRange(report.load_config?.directory ?? null),
];
const structure = report.load_config?.structure_location;
const inside = insideArtifact(report.artifact.bytes);
return (
(structure === undefined ||
(structure.offset === report.load_config?.directory.file_offset &&
structure.bytes === report.load_config.size &&
inside(structure))) &&
structure.offset <= report.artifact.bytes - structure.bytes)) &&
ranges.every(
({ start, end }) => start <= end && end <= report.artifact.bytes,
) &&
(report.tls?.callback_array_locations ?? []).every(inside) &&
(report.tls?.callback_array_locations ?? []).every(
({ offset, bytes }) => offset <= report.artifact.bytes - bytes,
) &&
(report.tls?.callbacks ?? []).every(
({ file_offset }) =>
file_offset === null || file_offset < report.artifact.bytes,
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeResourcesPort } from "./PeResourcesPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -84,7 +83,8 @@ const validReport = (
maxEntries: number,
maxBytes: number,
): boolean => {
const inside = insideArtifact(report.artifact.bytes);
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
const identityInside = (identity: PeResourceIdentity): boolean =>
identity.kind === "id" || inside(identity.location);
if (
@@ -1,4 +1,3 @@
import { insideArtifact } from "./artifactRange.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { WindowsShortcutPort } from "./WindowsShortcutPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -122,7 +121,8 @@ const validReport = (
if (complete !== (report.issues.length === 0)) return false;
if (complete && report.coverage.failing_offset !== null) return false;
if (!complete && report.coverage.reason === null) return false;
const inside = insideArtifact(report.artifact.bytes);
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
if (!inside(report.header.location)) return false;
if (report.id_list !== null && !inside(report.id_list.location)) return false;
if (report.link_info !== null && !inside(report.link_info.location))
@@ -1,11 +0,0 @@
/** Check a schema-validated nonnegative byte range against its carrier size. */
export const insideArtifact =
(fileBytes: number) =>
({
offset,
bytes,
}: {
readonly offset: number;
readonly bytes: number;
}): boolean =>
offset <= fileBytes - bytes;
@@ -29,7 +29,16 @@ import type {
} from "../../domain/desktop/desktopCapture.js";
import { renderBuildBundle } from "../../domain/desktop/appBuild.js";
import { readDownloadInstall } from "./DesktopExportInstall.js";
import {
NO_LAUNCH_DOCUMENT,
launchDocumentNote,
seedWording,
} from "../../domain/desktop/appExport.js";
import { exportDesktopApplication } from "./DesktopApplicationExport.js";
import {
seedLimitation,
trashedFilesLimitation,
} from "./DesktopExportLimitations.js";
import {
EDITED_NOTE,
HOME,
@@ -520,9 +529,13 @@ describe("exportDesktopApplication and the desktop Trash", () => {
"Documents/today.txt",
],
});
expect(result.limitations).toContain(
"The desktop Trash held 2 file(s) at the seed checkpoint, which are not seeded: ~/.local/share/Trash/files/today.txt, ~/.local/share/Trash/info/today.txt.trashinfo. Restore one the task needs and capture again.",
);
const trashPaths = [
".local/share/Trash/files/today.txt",
".local/share/Trash/info/today.txt.trashinfo",
];
const notice = trashedFilesLimitation(trashPaths);
expect(result.limitations).toContain(notice);
for (const path of trashPaths) expect(notice).toContain(`~/${path}`);
});
});
@@ -1923,9 +1936,7 @@ const exportSeeded = async (
/** The limitations on what the launch opens, found by the lead their formatter gives them. */
const launchLimitations = (limitations: readonly string[]) =>
limitations.filter((line) =>
line.includes("The app opens no document at launch"),
);
limitations.filter((line) => line.includes(NO_LAUNCH_DOCUMENT));
/**
* The seed-then-end capture of an app whose titles name no file, given the
@@ -2026,7 +2037,9 @@ describe("exportDesktopApplication launch document", () => {
`command=["sh", "-c", "cd \\"$HOME\\" 2>/dev/null; exec 'notes' \\"$HOME\\"/'Documents/today.txt'"]`,
);
expect(readme).toContain(
"At launch the app opens `~/Documents/today.txt`, the seed's one document.",
launchDocumentNote({
opens: { kind: "seed", document: "Documents/today.txt" },
}),
);
expect(readme).toContain("`~/Documents/today.txt`");
expect(launchLimitations(result.limitations)).toEqual([]);
@@ -2138,13 +2151,8 @@ describe("exportDesktopApplication launch document", () => {
const made = await exportSeeded("notes %U", { syntheticSeed: true });
const captured = await exportSeeded("notes %U");
const notice = (syntheticSeed: boolean) =>
syntheticSeed
? "The seed was made for this task, not taken from a user's own data; read `envs/notes/seed/` before publishing the bundle."
: "The seed is the user's real data. Keep this bundle local; do not publish it.";
const limitation = (syntheticSeed: boolean) =>
syntheticSeed
? "The seed was made for the task, not taken from a user's own data; read it before publishing the bundle."
: "The seed is the user's real state at the seed checkpoint; keep the bundle local.";
seedWording({ name: "notes", seedCheckpoint: "seed", syntheticSeed })
.notice;
expect(made.result.parameters).toMatchObject({ synthetic_seed: true });
expect(captured.result.parameters).toMatchObject({ synthetic_seed: false });
@@ -2152,8 +2160,10 @@ describe("exportDesktopApplication launch document", () => {
[made, true],
[captured, false],
] as const) {
expect(exported.result.limitations).toContain(limitation(synthetic));
expect(exported.result.limitations).not.toContain(limitation(!synthetic));
expect(exported.result.limitations).toContain(seedLimitation(synthetic));
expect(exported.result.limitations).not.toContain(
seedLimitation(!synthetic),
);
expect(exported.readme).toContain(notice(synthetic));
expect(exported.readme).not.toContain(notice(!synthetic));
}
@@ -235,6 +235,10 @@ type CheckpointTaken = Omit<
readonly more_changed_files: number | null;
};
/** The capture's progress event in a line the controller waited for. */
const eventOf = (line: string): DesktopCaptureEvent | undefined =>
captureProgressEvent(line);
/**
* One capture in the computer. `unreleased` is a capture that may be
* running although its start failed, kept until a stop is confirmed;
@@ -437,8 +441,7 @@ export class DesktopBuildCapture {
),
answer,
});
const event =
answer.line === undefined ? undefined : captureProgressEvent(answer.line);
const event = answer.line === undefined ? undefined : eventOf(answer.line);
if (event?.kind !== "window_open")
return err({
failure: await endedCapture(
@@ -498,9 +501,7 @@ export class DesktopBuildCapture {
{ timeoutS: 600, signal },
);
const event =
answer.line === undefined
? undefined
: captureProgressEvent(answer.line);
answer.line === undefined ? undefined : eventOf(answer.line);
if (event?.kind === "checkpoint_refused")
return err(refused(MARK, event.reason, "Use another checkpoint name."));
if (event?.kind !== "checkpoint")
@@ -328,7 +328,7 @@ const liveComputer = (
* message.
*/
/** The start failure's notice that the bundle seed still holds lock files. */
const seededLockStartNotice = (locks: readonly string[]): string =>
export const seededLockStartNotice = (locks: readonly string[]): string =>
`The bundle's seed holds lock files, which can keep the app from starting when it finds its profile or document in use: ${counted(locks)}. Delete them from the bundle, or export it again, which leaves them out, and start again.`;
const withSeededLocks = async (
@@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { redactSecrets } from "../../domain/desktop/buildSecrets.js";
import { createEvidence } from "../../domain/evidence.js";
import { guestFileAnswerCeiling } from "../../desktop/AgentEnvComputer.js";
import {
GUEST_FILE_ANSWER_SOURCE,
guestFileAnswerCeiling,
} from "../../desktop/AgentEnvComputer.js";
import { readOutput } from "./DesktopBuildCommands.js";
import { endedCapture } from "./DesktopBuildCaptureFailure.js";
import {
@@ -35,7 +38,7 @@ describe("readOutput", () => {
guest_file_bytes: Number.MAX_SAFE_INTEGER,
file_count: 1,
answer_ceiling_bytes: guestFileAnswerCeiling(),
ceiling_source: "v8.heap_size_limit/4",
ceiling_source: GUEST_FILE_ANSWER_SOURCE,
});
});
@@ -98,7 +101,7 @@ describe("endedCapture", () => {
guest_file_bytes: Number.MAX_SAFE_INTEGER,
file_count: 1,
answer_ceiling_bytes: guestFileAnswerCeiling(),
ceiling_source: "v8.heap_size_limit/4",
ceiling_source: GUEST_FILE_ANSWER_SOURCE,
});
});
@@ -1,64 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { dumpDconfDatabase } from "../../desktop/DconfDatabaseReader.js";
import { desktopApplicationStateSchema } from "../../domain/desktop/desktopState.js";
import { inspectDesktopApplicationState } from "./DesktopApplicationState.js";
import {
HOME,
capture,
checkpoint,
readers,
retained,
writeCaptureFolder,
} from "./desktopCapture.fixture.js";
it.skipIf(process.platform === "win32")(
"retains a dconf command failure in state Evidence",
async () => {
const tools = await createTestTempDirectory("dconf-state-diagnostics-");
const command = join(tools, "dconf");
await writeFile(command, "#!/bin/sh\nexit 17\n", { mode: 0o600 });
const base = capture();
const path = `${HOME}/.config/dconf/user`;
const bytes = Buffer.from("GVariant\0\0\0\0");
const folder = await writeCaptureFolder(
{
...base,
state: {
...base.state,
content_roots: [path],
checkpoints: [
checkpoint("baseline", "baseline", [], [retained(path, bytes)]),
],
},
},
undefined,
[bytes],
);
const evidence = await inspectDesktopApplicationState(folder, {
...readers,
dconf: (database, signal) =>
dumpDconfDatabase(database, { REA_DCONF_COMMAND: command }, signal),
});
if (!("normalized_result" in evidence)) throw evidence;
const state = desktopApplicationStateSchema.parse(
evidence.normalized_result,
);
expect(state.paths).toEqual([
expect.objectContaining({
home_path: ".config/dconf/user",
versions: [
expect.objectContaining({
format: "dconf",
view: null,
view_error: expect.stringContaining("EACCES"),
}),
],
}),
]);
expect(state.paths[0]?.versions[0]?.view_error).toContain(command);
},
);
@@ -15,7 +15,7 @@ import type { HostIdentity } from "../../domain/desktop/hostIdentity.js";
import { recoveryListLimitation } from "../../domain/desktop/seedRecoveryList.js";
/** The limitation naming files the desktop Trash held at the seed; none when it held none. */
const trashedFilesLimitation = (
export const trashedFilesLimitation = (
files: readonly string[],
): string | undefined =>
files.length === 0
@@ -23,7 +23,7 @@ const trashedFilesLimitation = (
: `The desktop Trash held ${String(files.length)} file(s) at the seed checkpoint, which are not seeded: ${files.map((home) => `~/${home}`).join(", ")}. Restore one the task needs and capture again.`;
/** The limitation on where the seed came from: made for the task, or the user's real state, kept local. */
const seedLimitation = (syntheticSeed: boolean): string =>
export const seedLimitation = (syntheticSeed: boolean): string =>
syntheticSeed
? "The seed was made for the task, not taken from a user's own data; read it before publishing the bundle."
: "The seed is the user's real state at the seed checkpoint; keep the bundle local.";
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { exportDesktopApplication } from "./DesktopApplicationExport.js";
import { shippedSeedCheckView } from "./DesktopTaskExport.js";
import { namesApp, shippedSeedCheckView } from "./DesktopTaskExport.js";
import { draftDesktopApplicationTask } from "./DesktopTaskDraft.js";
import {
HOME,
@@ -802,20 +802,17 @@ describe("exportDesktopApplication with a task's prompt", () => {
expect(naming(named.result.limitations)).toEqual([]);
});
it("does not treat a word that only contains the app's name as naming it", async () => {
const checks = [
{ id: "milk", kind: "lines_present", file: NOTE, lines: ["milk"] },
];
const exported = await exportWithTasks({
...task(checks),
prompt: "Update the notesheet and save it.",
});
if (exported.result instanceof Error) throw exported.result;
expect(exported.result.limitations).toEqual(
expect.arrayContaining([
expect.stringMatching(/prompt.*does not name the app/iu),
]),
);
it("does not treat a word that only contains the app's name as naming it", () => {
const dia = { title: "Dia", name: "dia", aptPackage: "dia" };
expect(namesApp("Update the diagram and save it.", dia)).toBe(false);
expect(namesApp("In Dia, add a box.", dia)).toBe(true);
expect(
namesApp("In Notes, write down milk.", {
title: "Notes",
name: "notes",
aptPackage: "notes",
}),
).toBe(true);
});
});
+1 -1
View File
@@ -490,7 +490,7 @@ const admitScreenTask = async (
};
/** Whether a prompt names the app by its title, environment or package, in any letter case. */
const namesApp = (
export const namesApp = (
prompt: string,
{
title,
+30 -11
View File
@@ -30,6 +30,33 @@ export type ReaderFileBytes =
| { readonly kind: "error"; readonly error: string }
| { readonly kind: "bytes"; readonly bytes: Buffer };
/**
* Each reader's view of its file by reader id, read by REA's reader runner;
* an absent file has none. `pathOf` names bytes to read in place of the
* home's file. A runner that does not answer throws.
*/
export const readTaskReaderViews = async (
readers: readonly DesktopTaskReader[],
run: ReaderCommandRun,
pathOf: (reader: DesktopTaskReader) => string | undefined = () => undefined,
work?: string,
): Promise<ReadonlyMap<string, TaskFileView>> => {
const views = new Map<string, TaskFileView>();
for (const reader of readers) {
const result = await run(
readerCommand(reader, pathOf(reader), work),
readerTimeout(reader) + READER_RUNNER_SLACK_S,
);
if (result.exit_code !== 0 || result.timed_out)
throw new Error(
`REA's reader runner for ${reader.id} ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`}: ${result.stderr.slice(-1024) || result.stdout.slice(-1024)}`,
);
const view = readerAnswerView(result.stdout);
if (view !== undefined) views.set(reader.id, view);
}
return views;
};
/** Recover only a complete, valid answer emitted before later finalization failed. */
const completedReaderAnswer = (
cause: unknown,
@@ -85,17 +112,9 @@ export const readerViewsOfBytes = async (
const path = join(folder, reader.file.split("/").at(-1) ?? "file");
await writeFile(path, file.bytes);
// The runner works inside this folder, so removing it cleans up after a runner that was killed.
const result = await run(
readerCommand(reader, path, folder),
readerTimeout(reader) + READER_RUNNER_SLACK_S,
);
if (result.exit_code !== 0 || result.timed_out)
throw new Error(
`REA's reader runner for ${reader.id} ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`}: ${result.stderr.slice(-1024) || result.stdout.slice(-1024)}`,
);
const view = readerAnswerView(result.stdout);
if (view === undefined) absent.push(reader.id);
else views.set(reader.id, view);
const read = await readTaskReaderViews([reader], run, () => path, folder);
for (const [id, view] of read) views.set(id, view);
if (!read.has(reader.id)) absent.push(reader.id);
} catch (cause: unknown) {
failure = { cause };
const completed = completedReaderAnswer(cause);
@@ -26,13 +26,14 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { writeJsonOutput } from "../../cli/jsonOutput.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import { summarizeEnvironmentCoverage } from "../../domain/analysisView/environmentCoverageView.js";
import type { Evidence } from "../../domain/evidence.js";
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
import {
DEFAULT_BUILD_TEMPLATE_RULES,
buildEnvironment,
canonicalTemplateRules,
classifyBuildCapture,
type EnvironmentBuildInput,
} from "./environmentBuild.js";
@@ -496,20 +497,12 @@ describe("classifyBuildCapture", () => {
});
});
it("reports template rules in the order the views apply them", async () => {
const root = await createTestTempDirectory("rea-environment-rule-order-");
const built = await buildEnvironment(
await buildInput(root, undefined, {
dryRun: true,
directory: undefined,
templateRules: ["prefix-variance", "after-uid-literal"],
}),
);
if (!built.ok) throw built.error;
expect(built.value.manifest.template_rules).toEqual([
"after-uid-literal",
"prefix-variance",
]);
describe("canonicalTemplateRules", () => {
it("orders rules as the views apply them", () => {
expect(
canonicalTemplateRules(["prefix-variance", "after-uid-literal"]),
).toEqual(["after-uid-literal", "prefix-variance"]);
});
});
describe("buildEnvironment credential names", () => {
@@ -86,7 +86,7 @@ export const classifyBuildCapture = (
};
/** Rules in the views' fixed order, so the report and manifest list them canonically. */
const canonicalTemplateRules = (
export const canonicalTemplateRules = (
rules: readonly TemplateRule[],
): TemplateRule[] => TEMPLATE_RULES.filter((rule) => rules.includes(rule));
@@ -14,6 +14,8 @@ import {
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import {
BUNDLE_BASE_IMAGE,
bundleDockerfile,
ENVIRONMENT_EXPORT_OPERATION,
planEnvironmentBundle,
registryPackageSource,
@@ -113,7 +115,7 @@ describe("planEnvironmentBundle", () => {
it("pins the registry package to this build's exact version in the Dockerfile", () => {
const dockerfile = file(plan(), "envs/grafana-example/Dockerfile");
const lines = dockerfile.trimEnd().split("\n");
expect(lines[0]).toMatch(/^FROM node:\d+\.\d+\.\d+-bookworm-slim$/u);
expect(lines[0]).toBe(`FROM ${BUNDLE_BASE_IMAGE}`);
expect(lines).toContain("ENV MCP_HOST=0.0.0.0");
expect(lines).toContain("ENV MCP_PORT=18765");
expect(lines).toContain(
@@ -147,9 +149,7 @@ describe("planEnvironmentBundle", () => {
"RUN npm install -g --ignore-scripts --no-fund --no-audit /tmp/rea-agents.tgz && rm /tmp/rea-agents.tgz\n",
);
expect(dockerfile).not.toContain(`rea-agents@${PACKAGE_METADATA.version}`);
expect(file(plan(), "envs/grafana-example/Dockerfile")).not.toBe(
dockerfile,
);
expect(bundleDockerfile(registryPackageSource())).not.toBe(dockerfile);
});
it("writes a verifier that checks the served tool count and the status the server answers the first parameterless GET tool with", () => {
@@ -21,15 +21,15 @@ import type { EnvironmentToolDefinition } from "./environmentTools.js";
export const ENVIRONMENT_EXPORT_OPERATION = "environment-export";
/** The image the exported Dockerfile builds from; the agentenv runner builds it with Docker. */
const BUNDLE_BASE_IMAGE = `node:${PACKAGE_METADATA.pinnedNodeVersion}-bookworm-slim`;
export const BUNDLE_BASE_IMAGE = `node:${PACKAGE_METADATA.pinnedNodeVersion}-bookworm-slim`;
/** The port the agentenv framework maps for an MCP server env; it sets the host but not the port. */
const BUNDLE_MCP_PORT = 18765;
export const BUNDLE_MCP_PORT = 18765;
/** Host names the exported env container is addressed by: the published loopback port and the compose service name. */
const BUNDLE_ALLOWED_HOSTS = "127.0.0.1,localhost";
export const BUNDLE_ALLOWED_HOSTS = "127.0.0.1,localhost";
/** Name of the packed CLI inside the env folder when a tarball is exported. */
const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
export const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
/** Where the Dockerfile installs the CLI from. */
export type EnvironmentPackageSource =
@@ -152,7 +152,7 @@ const READINESS_PROBE_LAYER =
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*";
/** The Dockerfile text: pinned base image, readiness probe interpreter, pinned CLI, listener defaults, non-root user. */
const bundleDockerfile = (
export const bundleDockerfile = (
source: EnvironmentPackageSource,
name?: string,
): string =>
@@ -196,7 +196,7 @@ const pythonLiteral = (value: string | number | null): string =>
value === null ? "None" : JSON.stringify(value);
/** The verifier script in the shape of the agentenv echo example's `verify(mcp_url)`. */
const bundleVerifier = (
export const bundleVerifier = (
plan: Pick<EnvironmentBundlePlan, "toolCount" | "check" | "state">,
): string =>
[
@@ -241,7 +241,7 @@ const bundleVerifier = (
].join("\n");
/** The smoke task: deploy the env locally, then run the verifier against it. */
const bundleSmokeTask = (name: string): string =>
export const bundleSmokeTask = (name: string): string =>
`${JSON.stringify(
[
{ id: "env", type: "deploy_env", env_id: name, sandbox_type: "local" },
@@ -280,7 +280,10 @@ from their recordings because the state is unavailable
};
/** The bundle README: provenance, what the environment serves and does not establish, how to run it, and the rights note. */
const bundleReadme = (source: EnvironmentSource, plan: ReadmePlan): string => {
export const bundleReadme = (
source: EnvironmentSource,
plan: ReadmePlan,
): string => {
const { manifest } = source;
const check =
plan.check === null
@@ -28,7 +28,7 @@ describe("query values given as numbers or booleans", () => {
body: { maxSize: 3 },
});
const strings = { maxSize: "2", offset: ["0"] };
expect(withQueryText(tool, strings)).toEqual(strings);
expect(withQueryText(tool, strings)).toBe(strings);
});
it("answers a list paged by a number as it answers the same page size given as text", () => {
@@ -33,7 +33,10 @@ import {
STATE_INTAKE_LIMIT_BYTES,
parseStateDocument,
} from "./environmentStateDocument.js";
import { EnvironmentStateStore } from "./environmentStateStore.js";
import {
EnvironmentStateStore,
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
} from "./environmentStateStore.js";
const fixture = environmentStateServeFixture();
const { source, state } = environmentStateServeInputs(fixture);
@@ -186,12 +189,12 @@ describe("served collection history", () => {
if (refused === null)
throw new Error("Expected reported history budget details");
expect(refused).toEqual(run());
const maximum = refused.max_collection_history_bytes;
expect(maximum).toEqual(expect.any(Number));
if (typeof maximum !== "number")
throw new Error("Expected a history limit");
expect(maximum).toBeGreaterThan(0);
expect(refused.collection_history_bytes).toBeGreaterThan(maximum);
expect(refused).toMatchObject({
max_collection_history_bytes: ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
});
expect(refused.collection_history_bytes).toBeGreaterThan(
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
);
});
it("does not charge a deleted loaded collection again at the history limit", () => {
@@ -1,4 +1,3 @@
import { environmentEntityKey } from "../../domain/analysisView/environmentStateIdentity.js";
import { types } from "node:util";
import { z } from "zod";
@@ -29,6 +28,7 @@ import {
type JsonValue,
} from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import type { EnvironmentState } from "./environmentState.js";
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
import { REA_STATE_FORMAT } from "./environmentTools.js";
@@ -216,7 +216,7 @@ interface EntityCheck {
}
const entityKey = (entity: Pick<StateEntity, "type" | "collection" | "id">) =>
environmentEntityKey(entity.type, entity.collection, entity.id);
compositeKey([entity.type, entity.collection, entity.id]);
/** The first problem with an entity's view values: each names an item or list view of its type once and keeps its identity. */
const viewFieldsProblem = (
@@ -1,7 +1,3 @@
import {
environmentCollectionKey,
environmentEntityKey,
} from "../../domain/analysisView/environmentStateIdentity.js";
import {
markedDeleted,
type StateEntity,
@@ -15,6 +11,7 @@ import {
import type { JsonValue } from "../../domain/jsonValue.js";
import { jsonByteLength } from "../../domain/jsonSerialization.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
import type { EnvironmentState } from "./environmentState.js";
import {
@@ -25,7 +22,7 @@ import {
} from "./environmentStateDocument.js";
/** Maximum logical budget for collection spellings added by served creates since reset/load. */
const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024;
export const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024;
const collectionHistoryCost = (
state: EnvironmentState,
@@ -262,7 +259,7 @@ export class EnvironmentStateStore {
entity: StateEntity,
index: number,
): Result<void, AnalysisResourceConstraintError> {
const key = environmentEntityKey(entity.type, entity.collection, entity.id);
const key = compositeKey([entity.type, entity.collection, entity.id]);
const replaced = this.#storedDeleted.get(key);
const entities =
replaced === undefined
@@ -342,7 +339,7 @@ export class EnvironmentStateStore {
const replaced = new Map<StateEntity, StateEntity>();
for (const entity of entities) {
const held = this.#entities.get(
environmentEntityKey(entity.type, entity.collection, entity.id),
compositeKey([entity.type, entity.collection, entity.id]),
);
if (held !== undefined) replaced.set(held, entity);
}
@@ -354,7 +351,7 @@ export class EnvironmentStateStore {
const bytes = this.#grown(delta);
if (!bytes.ok) return bytes;
const key = (entity: StateEntity) =>
environmentEntityKey(entity.type, entity.collection, entity.id);
compositeKey([entity.type, entity.collection, entity.id]);
const placed = new Set(order.map(key));
let next = 0;
this.#install(
@@ -380,7 +377,7 @@ export class EnvironmentStateStore {
const held = new Set(
entities.flatMap((entity) => {
const found = this.#entities.get(
environmentEntityKey(entity.type, entity.collection, entity.id),
compositeKey([entity.type, entity.collection, entity.id]),
);
return found === undefined ? [] : [found];
}),
@@ -403,14 +400,14 @@ export class EnvironmentStateStore {
collection: string,
id: string,
): StateEntity | undefined {
const key = environmentEntityKey(type, collection, id);
const key = compositeKey([type, collection, id]);
return this.#entities.has(key)
? undefined
: (this.#storedDeleted.get(key) ?? this.#deleted.get(key));
}
#keepDeleted(entity: StateEntity): void {
const key = environmentEntityKey(entity.type, entity.collection, entity.id);
const key = compositeKey([entity.type, entity.collection, entity.id]);
const earlier = this.#deleted.get(key);
if (earlier !== undefined) {
this.#deleted.delete(key);
@@ -475,14 +472,12 @@ export class EnvironmentStateStore {
collection: string,
id: string,
): StateEntity | undefined {
return this.#entities.get(environmentEntityKey(type, collection, id));
return this.#entities.get(compositeKey([type, collection, id]));
}
/** One collection's entities in state order. */
inCollection(type: string, collection: string): readonly StateEntity[] {
return (
this.#collections.get(environmentCollectionKey(type, collection)) ?? []
);
return this.#collections.get(compositeKey([type, collection])) ?? [];
}
/** The collections holding entities of one type, in state order of their first entity. */
@@ -534,7 +529,7 @@ export class EnvironmentStateStore {
for (const entity of content.entities) {
if (markedDeleted(entity.fields)) {
storedDeleted.set(
environmentEntityKey(entity.type, entity.collection, entity.id),
compositeKey([entity.type, entity.collection, entity.id]),
entity,
);
const named = typeCollections.get(entity.type) ?? [];
@@ -543,10 +538,10 @@ export class EnvironmentStateStore {
continue;
}
entities.set(
environmentEntityKey(entity.type, entity.collection, entity.id),
compositeKey([entity.type, entity.collection, entity.id]),
entity,
);
const key = environmentCollectionKey(entity.type, entity.collection);
const key = compositeKey([entity.type, entity.collection]);
const members = collections.get(key);
if (members !== undefined) {
members.push(entity);
@@ -6,6 +6,7 @@ import type {
RecordedExchange,
} from "./environmentSource.js";
import {
ENVIRONMENT_CATALOG_LIMIT_BYTES,
environmentCard,
environmentToolArguments,
environmentToolDefinitions as prepareDefinitions,
@@ -25,14 +26,11 @@ const withTools = (tools: readonly EnvironmentManifestTool[]) => ({
manifest: { ...source.manifest, tools: [...tools] },
});
const environmentToolInputSchema = (tool: EnvironmentManifestTool) => {
const [definition] = environmentToolDefinitions(
withTools([tool]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([tool]));
if (definition === undefined) throw new Error("Missing tool definition");
return definition.inputSchema;
};
const definitions = environmentToolDefinitions(source, source.manifest.name);
const definitions = environmentToolDefinitions(source);
const tool = (name: string): EnvironmentManifestTool => {
const found = source.manifest.tools.find((entry) => entry.name === name);
@@ -49,9 +47,9 @@ describe("environmentToolDefinitions", () => {
expect(definition.description).toContain(
"state.source, not replay.exact_match, identifies state vs recording",
);
expect(
environmentToolDefinitions(source, source.manifest.name)[0]?.description,
).not.toContain("state.source");
expect(environmentToolDefinitions(source)[0]?.description).not.toContain(
"state.source",
);
});
it("maps every manifest tool in order with a replay description naming its records and evidence", () => {
@@ -156,13 +154,10 @@ const usersAnswering = (
record_ordinals: [...exchanges.keys()],
statuses: [...statuses],
};
const [definition] = environmentToolDefinitions(
{
...withTools([users]),
exchanges,
},
source.manifest.name,
);
const [definition] = environmentToolDefinitions({
...withTools([users]),
exchanges,
});
return definition?.description;
};
const returnsOf = (bodyText: string): string | undefined =>
@@ -319,31 +314,28 @@ describe("environment tool response summaries", () => {
const users = tool("get_users_by_id");
const ordinal = users.record_ordinals[0] ?? 0;
const catalog = (contentType: string) =>
prepareDefinitions(
{
...withTools(
Array.from({ length: 200 }, (_, index) => ({
...users,
name: `get_users_${String(index)}`,
record_ordinals: [ordinal],
})),
),
exchanges: new Map([
[
prepareDefinitions({
...withTools(
Array.from({ length: 200 }, (_, index) => ({
...users,
name: `get_users_${String(index)}`,
record_ordinals: [ordinal],
})),
),
exchanges: new Map([
[
ordinal,
{
ordinal,
{
ordinal,
path: "/api/users/2",
query: null,
status: 200,
contentType,
bodyText: JSON.stringify(fields),
},
],
]),
},
source.manifest.name,
);
path: "/api/users/2",
query: null,
status: 200,
contentType,
bodyText: JSON.stringify(fields),
},
],
]),
});
expect(catalog("text/html").ok).toBe(true);
const bounded = catalog("application/json");
expect(bounded).toMatchObject({
@@ -351,24 +343,21 @@ describe("environment tool response summaries", () => {
error: {
_tag: "AnalysisResourceConstraintError",
resource: "capacity",
reportedLimits: { max_catalog_bytes: expect.any(Number) },
reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES },
},
});
const [single] = environmentToolDefinitions(
{
...withTools([users]),
exchanges: new Map([
[
ordinal,
{
...source.exchanges.get(ordinal)!,
bodyText: JSON.stringify(fields),
},
],
]),
},
source.manifest.name,
);
const [single] = environmentToolDefinitions({
...withTools([users]),
exchanges: new Map([
[
ordinal,
{
...source.exchanges.get(ordinal)!,
bodyText: JSON.stringify(fields),
},
],
]),
});
expect(Buffer.byteLength(single?.description ?? "")).toBeLessThan(1700);
expect(single?.description).toContain(
"/300 shown; full list in capture Evidence)",
@@ -385,10 +374,7 @@ describe("environment catalog description budgets", () => {
path_template: `/${"😀".repeat(1000)}`,
query_parameter_names: [query],
};
const [definition] = environmentToolDefinitions(
withTools([expanded]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([expanded]));
expect(definition?.tool).toBe(expanded);
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(3500);
expect(definition?.description).toContain(
@@ -420,10 +406,7 @@ describe("environment catalog description budgets", () => {
})),
record_ordinals: ordinals,
};
const [definition] = environmentToolDefinitions(
withTools([expanded]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([expanded]));
expect(definition?.tool).toBe(expanded);
expect(expanded.params[0]?.observed_values).toBe(observations);
expect(expanded.record_ordinals).toBe(ordinals);
@@ -448,10 +431,7 @@ describe("environment catalog description budgets", () => {
...users,
path_template: `/api/${"segment/".repeat(200_000)}{id}`,
};
const [definition] = environmentToolDefinitions(
withTools([long]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([long]));
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(2048);
expect(definition?.description).toContain(
"path display truncated; full value in manifest Evidence",
@@ -615,7 +595,6 @@ describe("environment tool annotations", () => {
method,
})),
),
source.manifest.name,
).map((definition) => definition.annotations);
expect(hints).toEqual([
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
@@ -231,7 +231,7 @@ const toolDescription = (
`${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${describeResponses(tool, source.exchanges)}; ${describe?.(tool) ?? answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${source.evidenceId}`;
/** Captured argument names longer than this are advertised under a short alias; the manifest keeps the name. */
const MAX_ARGUMENT_NAME_CHARS = 64;
export const MAX_ARGUMENT_NAME_CHARS = 64;
const bodyDescription = (tool: EnvironmentManifestTool): string => {
const properties = tool.body_shape?.properties;
@@ -377,7 +377,7 @@ function* environmentToolProperties(
}
/** Serialization reservation calibrated with 1500 fields and eight concurrent SDK discovery requests. */
const ENVIRONMENT_CATALOG_LIMIT_BYTES = 1024 * 1024;
export const ENVIRONMENT_CATALOG_LIMIT_BYTES = 1024 * 1024;
const EMPTY_PROPERTY_BYTES =
JSON.stringify({ type: "string", description: "" }).length + 4;
const EMPTY_TOOL_BYTES = JSON.stringify({
@@ -432,7 +432,7 @@ const catalogBody = (
/** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */
export const environmentToolDefinitions = (
source: EnvironmentSource,
name: string,
name: string = source.manifest.name,
{
operation = ENVIRONMENT_SERVE_OPERATION,
dataPlane = "none",
@@ -530,10 +530,10 @@ export const environmentToolDefinitions = (
export const REA_STATE_FORMAT = "rea-state/v1";
/** The data-plane methods an environment served from a state answers. */
const STATE_OPERATIONS = ["data/reset", "data/add", "data/get"] as const;
export const STATE_OPERATIONS = ["data/reset", "data/add", "data/get"] as const;
/** The intake declaration a state-served card carries: what `data/add` loads and `data/get` returns. */
const STATE_INTAKE_EXTENSION = {
export const STATE_INTAKE_EXTENSION = {
uri: "urn:agentenv:intake/v1",
description: `data/add replaces the whole environment state with one ${REA_STATE_FORMAT} document; data/get returns the current state in the same format.`,
params: {
@@ -172,7 +172,7 @@ const electronModuleReferencedBy = ({
};
/** Whether any analyzed source loads an Electron module. */
const electronModuleReferenced = (
export const electronModuleReferenced = (
analysis: JavaScriptArtifactAnalysis,
): boolean => analysis.files.some(electronModuleReferencedBy);
Loaded 100 of 729 files, more files were not shown because too many files have changed in this diff. Show more