mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 17:31:51 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c78c8aed40 | ||
|
|
6b3372e259 | ||
|
|
52fe342c73 | ||
|
|
4d7c6efe9a | ||
|
|
0b42dc56d1 | ||
|
|
0d3a2b815e | ||
|
|
a961db280f |
No files matched your search
@@ -136,7 +136,9 @@ the first source that has the app:
|
||||
`~/Documents`: build the document in the app, or write its source where
|
||||
the app edits its format as text, then save it again from the app. Make
|
||||
it look real but synthetic: the thing the task changes, plus a few things
|
||||
the change must leave alone. For a starting file of the requester's,
|
||||
the change must leave alone. Keep it to one document: the bundle's app
|
||||
opens the seed's document at launch only when there is exactly one, and
|
||||
otherwise starts on its own screen. For a starting file of the requester's,
|
||||
read it here and rebuild it in the app with synthetic data in place of
|
||||
personal data; REA cannot copy a file into the computer.
|
||||
4. Only once the seed is saved, `mark_build_checkpoint` with name `seed`.
|
||||
@@ -225,9 +227,11 @@ checks. Give every `check_build_task` the task exactly as exported.
|
||||
|
||||
1. `start_build_computer` with
|
||||
`{"source": {"kind": "bundle", "path": "<bundle>"}}`. The app starts
|
||||
with the seed's files in place but need not open the document: open it
|
||||
from the prompt's path, as a solving agent would. Take a `wait`
|
||||
screenshot first, since the desktop may not have drawn yet.
|
||||
with the seed's files in place and opens the seed's document when the
|
||||
seed holds exactly one and the app takes a file. When the export's
|
||||
`limitations` say it opens no document, open it from the prompt's path,
|
||||
as a solving agent would. Take a `wait` screenshot first, since the
|
||||
desktop may not have drawn yet.
|
||||
2. `check_build_task` with `stage: "seed"` and `directory`, the absolute
|
||||
path of a new or empty folder for the record. A check must fail.
|
||||
Recording starts. Later stages take no `directory`.
|
||||
|
||||
@@ -213,6 +213,7 @@ def run(arguments: dict) -> int:
|
||||
rea = shutil.which("rea")
|
||||
if rea is None:
|
||||
return _say({"error": "REA is not installed in this computer"})
|
||||
os.makedirs(os.path.dirname(arguments["stdout"]), exist_ok=True)
|
||||
for path, text in arguments.get("files", {}).items():
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
|
||||
@@ -25,6 +25,9 @@ a `.deb` file; see [Applications from a .deb file](#applications-from-a-deb-file
|
||||
`--seed-checkpoint` names the checkpoint whose state seeds the bundle, the
|
||||
baseline by default, and `--end-checkpoint` the one a task must pass on, the
|
||||
capture's last by default; see [The seed checkpoint](#the-seed-checkpoint).
|
||||
`--synthetic-seed` says the seed was made for the task, as a build agent makes
|
||||
it in a [build computer](desktop-build-witness.md), rather than taken from the
|
||||
user's own data; see [A seed made for the task](#a-seed-made-for-the-task).
|
||||
|
||||
The command returns Evidence with operation `export_desktop_application` and
|
||||
predicate type `rea.desktop-application-export`, listing the environment name,
|
||||
@@ -124,6 +127,17 @@ The class declares:
|
||||
desktop entry without `Path`), which a relative argument or program path
|
||||
such as `./app` also needs, and still starts if that folder is missing. The
|
||||
export refuses a capture that saw no application window.
|
||||
- The seed's document, opened at launch, so the app shows it rather than its
|
||||
start screen: when the seed holds exactly one file the state view gives the
|
||||
`document` role, and the desktop entry's `Exec` takes files (a `%f`, `%F`,
|
||||
`%u` or `%U` field code), its path under `$HOME` is appended to the command.
|
||||
A command that already names a document, absolute or from its working
|
||||
directory, as one captured with `--argument <file>` does, is kept as it is;
|
||||
a settings path under the home does not count. A seed with no document launches
|
||||
as captured. With several, no desktop entry or an `Exec` that takes no file,
|
||||
the app launches as captured and the README and the result's limitations say
|
||||
why. A reset
|
||||
relaunches the app, so it opens the document again.
|
||||
- For an Electron application (the capture found its fuse sentinel or a
|
||||
`resources/app.asar`), `--no-sandbox` right after the program unless the
|
||||
command already has it: Chromium's sandbox needs user namespaces the
|
||||
@@ -185,6 +199,16 @@ The export refuses a name the capture does not hold exactly once, and an end
|
||||
checkpoint taken before the seed checkpoint; `inspect-desktop-application-state`
|
||||
lists the names in `capture.checkpoints`.
|
||||
|
||||
## A seed made for the task
|
||||
|
||||
By default the seed is the user's real data: the README and the result's
|
||||
limitations say to keep the bundle local and not publish it. With
|
||||
`--synthetic-seed` they say instead that the seed was made for the task, and
|
||||
to read `envs/<name>/seed/` before publishing the bundle; the export cannot
|
||||
tell the two apart, so the flag is the caller's word. `export_build_environment`
|
||||
passes it, since the building agent makes the seed in the build computer. The
|
||||
Evidence records it as `synthetic_seed`.
|
||||
|
||||
## Host name and user
|
||||
|
||||
A seed can hold the capture host's name and the user's: Geany saved its mail
|
||||
@@ -337,6 +361,7 @@ when a DOCTYPE names a DTD. Known differences remain:
|
||||
and then gives the file an `error`, and so does a table or column name that
|
||||
is not UTF-8.
|
||||
|
||||
The seed is the user's real data. Keep the bundle local and do not publish it.
|
||||
Unless it was [made for the task](#a-seed-made-for-the-task), the seed is the
|
||||
user's real data. Keep the bundle local and do not publish it.
|
||||
The class was checked against agentenv-framework 0.9.1431 with
|
||||
`python -m agentenv_protocol.computer._declare` and `agent-env run --dry-run`.
|
||||
@@ -27,7 +27,10 @@ See [desktop builds](https://github.com/morluto/rea/blob/main/.agents/skills/rev
|
||||
## Build
|
||||
|
||||
1. `start_build_computer` from a request. REA is installed in the computer.
|
||||
2. `start_build_capture`. REA writes an apt configuration under
|
||||
2. `start_build_capture`. REA first checks the installed runtime's export
|
||||
schema for synthetic-seed support. If the archive is too old, repack a
|
||||
current REA, update `REA_BUILD_COMPUTER_REA_ARCHIVE`, restart the REA MCP
|
||||
server and start a new build computer. REA then writes an apt configuration under
|
||||
`~/.rea-build/apt` and runs `apt-get update` as the computer's user, so the
|
||||
capture finds the package's sources (`package_lists` counts the lists,
|
||||
and `package_lists_error` gives apt's exit status and last lines when the
|
||||
@@ -55,8 +58,12 @@ See [desktop builds](https://github.com/morluto/rea/blob/main/.agents/skills/rev
|
||||
--end-checkpoint end`, so it seeds what was saved at `seed`, scrubs the
|
||||
computer's host name and user from it, and refuses a task whose checks
|
||||
all pass on the seed. A task larger than 96 KiB as REA passes it to the
|
||||
computer is refused before anything runs. A file moved to the desktop Trash is not seeded,
|
||||
and the export's limitations name what the Trash held at `seed`. For a
|
||||
computer is refused before anything runs. It also passes `--synthetic-seed`,
|
||||
since the agent made the seed for the task, so the README does not call it
|
||||
the user's real data. When the seed holds exactly one document and the
|
||||
app's desktop entry takes files, the bundle's app opens that document at
|
||||
launch. A file moved to the desktop Trash is not seeded, and the export's
|
||||
limitations name what the Trash held at `seed`. For a
|
||||
`.deb` app that no apt source the capture found offers, the export takes
|
||||
the request's URL with `--package-url`; one apt offers installs from apt. REA lists the bundle's files with their sizes
|
||||
and SHA-256 in the computer, into a file beside the bundle that it reads
|
||||
@@ -108,7 +115,8 @@ cancels the call in progress on it and refuses the calls still waiting.
|
||||
## Witness
|
||||
|
||||
Start a fresh computer from the exported folder with `start_build_computer`
|
||||
and `{"kind": "bundle", "path": ...}`; agentenv boots the app with the seed.
|
||||
and `{"kind": "bundle", "path": ...}`; agentenv boots the app with the seed,
|
||||
on the seed's document when the export opens one.
|
||||
Then, with the task exactly as exported:
|
||||
|
||||
| `stage` | Before it | Its checks must |
|
||||
|
||||
@@ -84,6 +84,12 @@ and the recording and writes the record as Evidence.
|
||||
and the export's limitations name what the Trash held at the seed; a
|
||||
start from a bundle folder writes no bytecode into it; the first
|
||||
screenshot waits, briefly and bounded, until the frame is not one colour.
|
||||
- Gaps of the blind validation's solve runs: the exported app opens the
|
||||
seed's one document at launch, when its desktop entry takes files, so a
|
||||
solving agent starts in the document rather than on the app's start
|
||||
screen; and `export_build_environment` passes `--synthetic-seed`, so the
|
||||
README says the seed was made for the task instead of calling it the
|
||||
user's real data.
|
||||
|
||||
- Gaps of the second review: calls on one computer (actions, capture
|
||||
steps, checks and close) run one at a time in arrival order, so a stage's
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
- [x] Fix the blind validation run's gaps and the export's install source for a `.deb`.
|
||||
- [x] Fix the second review's gaps: one call at a time per computer, the recording's request id, undecided checks and the bundle's file list.
|
||||
- [x] Leave a failure that a cut view may have caused undecided: a missing line, row or value.
|
||||
- [x] Open the seed's one document at launch, and word a seed made for the task as such.
|
||||
@@ -245,6 +245,41 @@ tests added, and passes now.
|
||||
- `DesktopBuildTurns.test.ts`: a running call that finishes after a close
|
||||
keeps its result.
|
||||
|
||||
## Opening the seed's document
|
||||
|
||||
Run on 2026-11-05: source tests and gates on macOS; the desktop source tests
|
||||
again on the Ubuntu 24.04 amd64 VM (40 files, 422 passed, none skipped).
|
||||
|
||||
- `seedDocument.test.ts`: the seed's one document is appended when `Exec`
|
||||
holds `%F`, `%U`, `%f` or `%u`, and the launch respells it as
|
||||
`"$HOME"/'Documents/poster.svg'`; two documents, none, an `Exec` without a
|
||||
file field code and a capture without a desktop entry keep the command and
|
||||
give the reason; a command already naming a file under the home is kept.
|
||||
- `appExport.test.ts`: the README says what the launch opens, or why it
|
||||
opens nothing; a seed made for the task is worded as such in the README
|
||||
and the class, and a captured one as the user's real data.
|
||||
- `DesktopApplicationExport.test.ts`: from a seed-and-end capture launched
|
||||
through `notes %U`, the class launches `notes` with the note; through
|
||||
`notes`, or without a desktop entry, and from a baseline seed with no
|
||||
document, it launches as captured and a limitation says why;
|
||||
`--synthetic-seed` changes the limitation and the README and is recorded
|
||||
as `synthetic_seed`.
|
||||
- `buildCaptureMcp.test.ts`: `export_build_environment` runs the export
|
||||
with `--synthetic-seed`, and the real CLI's README for that capture says
|
||||
the seed was made for the task.
|
||||
|
||||
The blind Inkscape build's capture was gone with its build computer, so the
|
||||
bundle could not be exported again. Instead, a copy of that bundle had only
|
||||
its `Launch` command replaced by the one this code renders for its seed (one
|
||||
document, `Documents/poster.svg`; the image's `Exec=inkscape %F`). A computer
|
||||
of it came up in 47 s with Inkscape showing `poster.svg` and no Welcome
|
||||
screen, and `data/reset` relaunched it on the poster again. Three Claude Code
|
||||
runs with Sonnet 5.5 all passed, in 11, 9 and 10 turns for $0.70, $0.46 and
|
||||
$0.72 ($0.63 on average); the same bundle opening on the Welcome screen had
|
||||
taken 18, 23 and 21 turns for $0.70, $1.55 and $0.51 ($0.92). Cache reads
|
||||
fell from 585-714K tokens to 181-225K; the cost is mostly cache writes,
|
||||
which varied from 17K to 189K tokens whatever the turns.
|
||||
|
||||
## Limits of this verification
|
||||
|
||||
- The witness acts were scripted by this agent from the task's prompt; no
|
||||
|
||||
@@ -27,7 +27,12 @@ state through `data/add`, `data/get` and `data/reset`.
|
||||
class match with a window ready check, `paths` from the state view's roles
|
||||
(state directories of application state files, or a folder beside one that
|
||||
differs only in letter case in the files' spelling, and folders of
|
||||
documents, nested ones collapsed) and the skill file.
|
||||
documents, nested ones collapsed) and the skill file. When the seed holds
|
||||
exactly one document and the desktop entry's `Exec` takes files, the
|
||||
launch appends its path under `$HOME`; a command that already names a
|
||||
document, absolute or from its working directory, is kept, a seed with no document launches as captured,
|
||||
and otherwise the README and the limitations say why the app opens no
|
||||
document.
|
||||
- With paths, it declares all three data hooks and calls agentenv's helpers:
|
||||
seed the home once per boot then `keep_initial`, `add`, `reset`, and `get`
|
||||
plus a data part holding a typed JSON view of each file. Without paths it
|
||||
@@ -38,7 +43,8 @@ state through `data/add`, `data/get` and `data/reset`.
|
||||
application are compiled into the user's dconf database with `dconf-cli`,
|
||||
without D-Bus.
|
||||
- The README and the result state the agentenv-framework release the class was
|
||||
checked against (0.9.1431), and that the seed is real user data.
|
||||
checked against (0.9.1431), and that the seed is real user data, unless
|
||||
`--synthetic-seed` says it was made for the task.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceSubjectTarget,
|
||||
type Evidence,
|
||||
type EvidenceProvider,
|
||||
type EvidenceSubjectTarget,
|
||||
} from "../domain/evidence.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ParsedInventorySet } from "../domain/artifactInventoryEvidence.js";
|
||||
@@ -42,7 +42,6 @@ interface InventoryProjectionOptions<
|
||||
readonly operation: string;
|
||||
readonly predicateType: string;
|
||||
readonly provider: EvidenceProvider;
|
||||
readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"];
|
||||
}
|
||||
|
||||
/** Resolve source inventory once, then wrap its family projection in Evidence. */
|
||||
@@ -70,13 +69,7 @@ export const projectInventoryEvidence = <
|
||||
const first = source.value.evidence[0];
|
||||
return ok({
|
||||
evidence: createEvidence(
|
||||
first?.subject === null || first?.subject === undefined
|
||||
? undefined
|
||||
: {
|
||||
path: first.subject.local_path,
|
||||
sha256: result.root_sha256,
|
||||
format: options.subjectFormat(first),
|
||||
},
|
||||
evidenceSubjectTarget(first?.subject ?? null),
|
||||
options.provider,
|
||||
{
|
||||
predicateType: options.predicateType,
|
||||
|
||||
@@ -79,10 +79,32 @@ const applicationWithUnknowns = (
|
||||
],
|
||||
}),
|
||||
);
|
||||
const root = createJavaScriptApplicationNode({
|
||||
kind: "artifact",
|
||||
identity: {
|
||||
strategy: "content-digest",
|
||||
stability: "global-exact",
|
||||
sha256: APPLICATION_GRAPH_DIGESTS.asar,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "managed root",
|
||||
properties: {},
|
||||
evidence: artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.asar,
|
||||
"artifact-root",
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
const graph = createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: base.root_node_ids,
|
||||
nodes: [...base.nodes, ...unknownNodes],
|
||||
root_node_ids: [root.node_id],
|
||||
nodes: [
|
||||
...base.nodes.filter(({ node_id }) => node_id !== root.node_id),
|
||||
...unknownNodes,
|
||||
root,
|
||||
],
|
||||
edges: base.edges,
|
||||
coverage: completeCoverage,
|
||||
limitations: base.limitations,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceSubjectTarget,
|
||||
rebindEvidenceToTargetPath,
|
||||
type Evidence,
|
||||
type EvidenceSubjectTarget,
|
||||
@@ -327,21 +328,11 @@ export const projectRetainedAnalysis = (
|
||||
if (!retained.ok) return retained;
|
||||
// Duplicate semantic records retain their original ledger metadata. Detail
|
||||
// selection changes presentation, not the historical lookup or payload owner.
|
||||
const subject = evidence.subject;
|
||||
return projectResolvedAnalysisView(
|
||||
{ source, view },
|
||||
prepareAnalysisViewProjection(view),
|
||||
retained.value,
|
||||
subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
},
|
||||
evidenceSubjectTarget(evidence.subject),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -383,17 +374,7 @@ const createAnalysisViewEvidence = (
|
||||
parent: Evidence,
|
||||
result: AnalysisViewResult,
|
||||
): Evidence => {
|
||||
const target =
|
||||
parent.subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: parent.subject.local_path,
|
||||
sha256: parent.subject.digest.sha256,
|
||||
format: parent.subject.format,
|
||||
...(parent.subject.architecture === null
|
||||
? {}
|
||||
: { architecture: parent.subject.architecture }),
|
||||
};
|
||||
const target = evidenceSubjectTarget(parent.subject);
|
||||
const nativeFunctionView =
|
||||
result.kind === "native" ||
|
||||
(result.kind === "summary" &&
|
||||
|
||||
@@ -2,6 +2,33 @@ import { expect, it, vi } from "vitest";
|
||||
import { AndroidAnalysisService } from "./AndroidAnalysisService.js";
|
||||
import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js";
|
||||
|
||||
it("retains the selected subject in bounded search Evidence", () => {
|
||||
const source = createEvidence(
|
||||
{
|
||||
path: "/fixture/app.apk",
|
||||
sha256: "a".repeat(64),
|
||||
format: "apk",
|
||||
name: "Selected app",
|
||||
},
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "search_android_classes",
|
||||
parameters: {},
|
||||
result: { matches: [] },
|
||||
rawResult: { complete: true },
|
||||
},
|
||||
);
|
||||
const result = summarizeRetainedAndroidSearch(source, { pages: 1 }, () =>
|
||||
ok("added"),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.subject).toEqual(source.subject);
|
||||
expect(result.value.evidence_links).toContain(source.evidence_id);
|
||||
});
|
||||
|
||||
it("retains selector correction issues before target acquisition", async () => {
|
||||
const execute = vi.fn<AndroidAnalysisPort["execute"]>();
|
||||
|
||||
@@ -25,6 +25,5 @@ export const projectAndroidApplicationEvidence = (
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.android-application-graph",
|
||||
provider: ANDROID_APPLICATION_PROVIDER,
|
||||
subjectFormat: () => "apk",
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceSubjectTarget,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
@@ -18,30 +22,16 @@ export const summarizeRetainedAndroidSearch = (
|
||||
if (recordEvidence === undefined) return ok(evidence);
|
||||
const recorded = recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
const subject = evidence.subject;
|
||||
return ok(
|
||||
createEvidence(
|
||||
subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
},
|
||||
evidence.provider,
|
||||
{
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: { ...summary, complete_evidence_id: evidence.evidence_id },
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
evidenceLinks: [evidence.evidence_id],
|
||||
},
|
||||
),
|
||||
createEvidence(evidenceSubjectTarget(evidence.subject), evidence.provider, {
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: { ...summary, complete_evidence_id: evidence.evidence_id },
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
evidenceLinks: [evidence.evidence_id],
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,5 @@ export const projectAppleApplicationEvidence = (
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.apple-application-graph",
|
||||
provider: APPLE_APPLICATION_PROVIDER,
|
||||
subjectFormat: (first) => first.subject?.format ?? "unknown",
|
||||
});
|
||||
};
|
||||
@@ -14,14 +14,24 @@ import type {
|
||||
AptRepository,
|
||||
DesktopApplicationCapture,
|
||||
} from "../../domain/desktop/desktopCapture.js";
|
||||
import { exportDesktopApplication } from "./DesktopApplicationExport.js";
|
||||
import {
|
||||
NO_LAUNCH_DOCUMENT,
|
||||
launchDocumentNote,
|
||||
seedWording,
|
||||
} from "../../domain/desktop/appExport.js";
|
||||
import {
|
||||
exportDesktopApplication,
|
||||
seedLimitation,
|
||||
} from "./DesktopApplicationExport.js";
|
||||
import {
|
||||
EDITED_NOTE,
|
||||
HOME,
|
||||
capture,
|
||||
change,
|
||||
checkpoint,
|
||||
readers,
|
||||
retained,
|
||||
seedEndCapture,
|
||||
writeCaptureFolder,
|
||||
} from "./desktopCapture.fixture.js";
|
||||
|
||||
@@ -660,6 +670,153 @@ describe("exportDesktopApplication home paths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Export the seed-then-end capture, launched from a desktop entry with `exec`, seeded at `seed`. */
|
||||
const exportSeeded = async (
|
||||
exec: string | null,
|
||||
options: { readonly syntheticSeed?: boolean } = {},
|
||||
) => {
|
||||
const local = seedEndCapture();
|
||||
const folder = await writeCaptureFolder(
|
||||
{
|
||||
...local,
|
||||
application: {
|
||||
...local.application,
|
||||
desktop_entry:
|
||||
exec === null
|
||||
? null
|
||||
: {
|
||||
id: "org.example.notes",
|
||||
path: "/usr/share/applications/org.example.notes.desktop",
|
||||
name: "Notes",
|
||||
exec,
|
||||
icon: null,
|
||||
startup_wm_class: null,
|
||||
categories: [],
|
||||
mime_types: [],
|
||||
terminal: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
[EDITED_NOTE],
|
||||
);
|
||||
const directory = join(
|
||||
await createTestTempDirectory("desktop-export-"),
|
||||
"bundle",
|
||||
);
|
||||
const result = await exportDesktopApplication(
|
||||
{
|
||||
capture: folder,
|
||||
directory,
|
||||
name: "notes",
|
||||
tasks: [],
|
||||
seedCheckpoint: "seed",
|
||||
endCheckpoint: "end",
|
||||
...options,
|
||||
},
|
||||
readers,
|
||||
);
|
||||
if (result instanceof Error) throw result;
|
||||
const read = (path: string) => readFile(join(directory, path), "utf8");
|
||||
return {
|
||||
result,
|
||||
module: await read("envs/notes/notes_app.py"),
|
||||
readme: await read("README.md"),
|
||||
};
|
||||
};
|
||||
|
||||
/** 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(NO_LAUNCH_DOCUMENT));
|
||||
|
||||
describe("exportDesktopApplication launch document", () => {
|
||||
it("opens the seed's one document when the desktop entry takes files", async () => {
|
||||
const { result, module, readme } = await exportSeeded("notes %U");
|
||||
|
||||
expect(module).toContain(
|
||||
`command=["sh", "-c", "cd \\"$HOME\\" 2>/dev/null; exec 'notes' \\"$HOME\\"/'Documents/today.txt'"]`,
|
||||
);
|
||||
expect(readme).toContain(
|
||||
launchDocumentNote({
|
||||
opens: { kind: "seed", document: "Documents/today.txt" },
|
||||
}),
|
||||
);
|
||||
expect(readme).toContain("`~/Documents/today.txt`");
|
||||
expect(launchLimitations(result.limitations)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["notes", null])(
|
||||
"launches as captured, and says why in the README and limitations, when Exec is %j",
|
||||
async (exec) => {
|
||||
const opened = await exportSeeded("notes %U");
|
||||
const { result, module, readme } = await exportSeeded(exec);
|
||||
const launch = launchLimitations(result.limitations);
|
||||
|
||||
expect(module).toContain(
|
||||
`command=["sh", "-c", "cd \\"$HOME\\" 2>/dev/null; exec 'notes'"]`,
|
||||
);
|
||||
expect(launch).toHaveLength(1);
|
||||
expect(
|
||||
result.limitations.filter((line) => !launch.includes(line)),
|
||||
).toEqual(opened.result.limitations);
|
||||
for (const line of launch) expect(readme).toContain(line);
|
||||
},
|
||||
);
|
||||
|
||||
it("launches as captured from a baseline seed, which holds no document", async () => {
|
||||
const local = capture();
|
||||
const { result, directory } = await exportCapture({
|
||||
...local,
|
||||
application: {
|
||||
...local.application,
|
||||
desktop_entry: {
|
||||
id: "org.example.notes",
|
||||
path: "/usr/share/applications/org.example.notes.desktop",
|
||||
name: "Notes",
|
||||
exec: "notes %F",
|
||||
icon: null,
|
||||
startup_wm_class: null,
|
||||
categories: [],
|
||||
mime_types: [],
|
||||
terminal: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result instanceof Error) throw result;
|
||||
expect(launchLimitations(result.limitations)).toEqual([]);
|
||||
expect(
|
||||
await readFile(join(directory, "envs/notes/notes_app.py"), "utf8"),
|
||||
).toContain(
|
||||
`command=["sh", "-c", "cd \\"$HOME\\" 2>/dev/null; exec 'notes'"]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("says a seed made for the task was made for it, and a captured one is the user's own", async () => {
|
||||
const made = await exportSeeded("notes %U", { syntheticSeed: true });
|
||||
const captured = await exportSeeded("notes %U");
|
||||
const notice = (syntheticSeed: boolean) =>
|
||||
seedWording({ name: "notes", seedCheckpoint: "seed", syntheticSeed })
|
||||
.notice;
|
||||
|
||||
expect(made.result.parameters).toMatchObject({ synthetic_seed: true });
|
||||
expect(captured.result.parameters).toMatchObject({ synthetic_seed: false });
|
||||
for (const [exported, synthetic] of [
|
||||
[made, true],
|
||||
[captured, false],
|
||||
] as const) {
|
||||
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));
|
||||
}
|
||||
expect(made.readme).toContain("`envs/notes/seed/`");
|
||||
expect(captured.readme).not.toContain("`envs/notes/seed/`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportDesktopApplication bundle writes", () => {
|
||||
it.each(["cancelled", "out of space"])(
|
||||
"removes the unpublished bundle when its write fails (%s)",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
appModuleFile,
|
||||
className,
|
||||
exportedStateFiles,
|
||||
launchCommand,
|
||||
launchDocumentLimitation,
|
||||
renderAppClass,
|
||||
renderEnvToml,
|
||||
renderInstallScript,
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
type HomePathCollision,
|
||||
type SeedScrubReport,
|
||||
} from "../../domain/desktop/appExport.js";
|
||||
import {
|
||||
INSTALL_SCRIPT,
|
||||
electronLaunchArguments,
|
||||
} from "../../domain/desktop/appExportInstall.js";
|
||||
import { INSTALL_SCRIPT } from "../../domain/desktop/appExportInstall.js";
|
||||
import { installLimitations } from "../../domain/desktop/appExportInstallLimitations.js";
|
||||
import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js";
|
||||
import {
|
||||
@@ -29,7 +26,6 @@ import {
|
||||
import { isWithinAny } from "../../domain/desktop/desktopStateDiff.js";
|
||||
import type { HostIdentity } from "../../domain/desktop/hostIdentity.js";
|
||||
import { scrubDconf } from "../../domain/desktop/seedScrub.js";
|
||||
import { isTextualFormat } from "../../domain/desktop/stateFormats.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
@@ -56,6 +52,7 @@ import {
|
||||
type SeedVersion,
|
||||
} from "./DesktopExportFiles.js";
|
||||
import { withRetainedContent } from "./DesktopRetainedContent.js";
|
||||
import { planLaunch, seedVersions } from "./DesktopExportLaunch.js";
|
||||
import { selectStateEnds } from "./DesktopStateVersions.js";
|
||||
import { admitDesktopTask, type AdmittedTask } from "./DesktopTaskExport.js";
|
||||
|
||||
@@ -76,6 +73,8 @@ export interface DesktopExportRequest {
|
||||
readonly seedCheckpoint?: string | undefined;
|
||||
/** The checkpoint a task's end state is read at; the last when omitted. */
|
||||
readonly endCheckpoint?: string | undefined;
|
||||
/** The seed was made for the task, as in a build computer, rather than taken from the user's own data. */
|
||||
readonly syntheticSeed?: boolean | undefined;
|
||||
}
|
||||
|
||||
/** What the export wrote. */
|
||||
@@ -197,15 +196,8 @@ const planExport = (
|
||||
context.seed,
|
||||
context.identity,
|
||||
);
|
||||
const seeds = new Map<string, SeedVersion>();
|
||||
const textSeeds: string[] = [];
|
||||
for (const [home, path] of files) {
|
||||
const version = versionAt(path, context.seed);
|
||||
if (!isWithinAny(home, paths) || version?.present !== true) continue;
|
||||
if (version.sha256 === null) continue;
|
||||
seeds.set(home, { sha256: version.sha256, format: version.format });
|
||||
if (isTextualFormat(version.format)) textSeeds.push(home);
|
||||
}
|
||||
const { seeds, textSeeds } = seedVersions(files, paths, context.seed);
|
||||
const launch = planLaunch(capture, electron, seeds, files);
|
||||
const plan: DesktopAppExportPlan = {
|
||||
name: source,
|
||||
className: className(source),
|
||||
@@ -221,17 +213,15 @@ const planExport = (
|
||||
? {}
|
||||
: { executablePackage: install.executablePackage }),
|
||||
electron,
|
||||
command: launchCommand(
|
||||
electronLaunchArguments(application.argv, electron),
|
||||
host.home,
|
||||
application.working_directory,
|
||||
),
|
||||
command: launch.command,
|
||||
opens: launch.opens,
|
||||
windowClass,
|
||||
paths,
|
||||
capturedHome: host.home,
|
||||
seedCheckpoint: state.capture.checkpoints[context.seed] ?? "baseline",
|
||||
syntheticSeed: request.syntheticSeed === true,
|
||||
seedFiles: [...seeds.keys()].sort(),
|
||||
textSeeds: textSeeds.sort(),
|
||||
textSeeds,
|
||||
unseededFiles: unseededFiles(capture, homePath, files, {
|
||||
paths,
|
||||
seed: context.seed,
|
||||
@@ -255,10 +245,14 @@ const planExport = (
|
||||
};
|
||||
};
|
||||
|
||||
const EXPORT_LIMITATIONS = [
|
||||
"The seed is the user's real state at the seed checkpoint; keep the bundle local.",
|
||||
"Binary state files are seeded as captured; only text files, UTF-8 or UTF-16 with a byte-order mark, have the capture host's name, user@host and home path replaced.",
|
||||
];
|
||||
/** The limitation on where the seed came from: made for the task, or the user's real state, kept local. */
|
||||
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.";
|
||||
|
||||
const SCRUB_LIMITATION =
|
||||
"Binary state files are seeded as captured; only text files, UTF-8 or UTF-16 with a byte-order mark, have the capture host's name, user@host and home path replaced.";
|
||||
|
||||
/** The bundle's generated files, the class module flagged when its dconf keys hold the identity, and its README, made once the seeds' scrub is known. */
|
||||
const bundleText = (
|
||||
@@ -308,8 +302,11 @@ const exportLimitations = (
|
||||
},
|
||||
): string[] => {
|
||||
const opened = capture.state.unretained_opened_directories;
|
||||
const opens = launchDocumentLimitation(plan.opens);
|
||||
return [
|
||||
...EXPORT_LIMITATIONS,
|
||||
seedLimitation(plan.syntheticSeed),
|
||||
SCRUB_LIMITATION,
|
||||
...(opens === undefined ? [] : [opens]),
|
||||
...facts.install,
|
||||
...(plan.unseededFiles.length === 0
|
||||
? []
|
||||
@@ -454,6 +451,7 @@ export const exportDesktopApplication = async (
|
||||
package_url: request.packageUrl !== undefined,
|
||||
seed_checkpoint: request.seedCheckpoint ?? null,
|
||||
end_checkpoint: request.endCheckpoint ?? null,
|
||||
synthetic_seed: request.syntheticSeed === true,
|
||||
},
|
||||
result: {
|
||||
...result,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import type { BuildRequest } from "../../domain/desktop/appBuild.js";
|
||||
import { BUILD_PACKAGE_FILE } from "../../domain/desktop/appBuild.js";
|
||||
import {
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
controller,
|
||||
errorText,
|
||||
refused,
|
||||
runRea,
|
||||
WORK,
|
||||
type Answer,
|
||||
} from "./DesktopBuildCommands.js";
|
||||
@@ -40,6 +44,87 @@ const NOT_RUNNING = [
|
||||
"Start one with start_build_capture.",
|
||||
] as const;
|
||||
|
||||
const exportOptionsSchema = z.object({
|
||||
options: z.object({ properties: z.record(z.string(), z.unknown()) }),
|
||||
});
|
||||
const repackRuntime =
|
||||
"Run npm run pack:build-computer-runtime -- /absolute/path/rea-prod.tar.xz in a current REA checkout, set REA_BUILD_COMPUTER_REA_ARCHIVE to that archive, restart the REA MCP server, and start a new build computer.";
|
||||
const schemaCommandError = (exitCode: number | null, detail: string) =>
|
||||
new ProviderAdapterError(AGENTENV_PROVIDER, START, {
|
||||
userMessage: `The build computer's REA export schema command ${exitCode === null ? "ended without an exit status" : `exited ${String(exitCode)}`}: ${detail}`,
|
||||
remediationAction: `Inspect the command error. If the runtime is outdated, ${repackRuntime}`,
|
||||
});
|
||||
|
||||
/** Check the installed runtime's advertised export contract before capturing the user's work. */
|
||||
const checkExportRuntime = async (
|
||||
computer: ControlledComputer,
|
||||
signal: AbortSignal,
|
||||
): Promise<Result<void, AnalysisError>> => {
|
||||
try {
|
||||
const path = `${WORK}/export-schema.json`;
|
||||
const command = await runRea(
|
||||
computer,
|
||||
["export-desktop-application", "--schema", "--json"],
|
||||
{ stdout: path, timeoutS: 30, signal },
|
||||
);
|
||||
if (command.exitCode !== 0 && command.stderr !== "")
|
||||
return err(schemaCommandError(command.exitCode, command.stderr));
|
||||
if (command.stdoutBytes > 1024 * 1024)
|
||||
return err(
|
||||
adapterError(
|
||||
START,
|
||||
"The build computer's REA export schema exceeds 1 MiB.",
|
||||
),
|
||||
);
|
||||
const content = (
|
||||
await computer.homeFiles([path], command.stdoutBytes, signal)
|
||||
).get(path);
|
||||
if (content === undefined)
|
||||
return err(
|
||||
adapterError(
|
||||
START,
|
||||
"The build computer did not return its REA export schema.",
|
||||
),
|
||||
);
|
||||
if (command.exitCode !== 0)
|
||||
return err(
|
||||
schemaCommandError(
|
||||
command.exitCode,
|
||||
content.toString("utf8").slice(-2048),
|
||||
),
|
||||
);
|
||||
const schema = exportOptionsSchema.safeParse(
|
||||
JSON.parse(content.toString("utf8")),
|
||||
);
|
||||
if (!schema.success)
|
||||
return err(
|
||||
adapterError(
|
||||
START,
|
||||
"The build computer's REA returned an invalid export schema.",
|
||||
),
|
||||
);
|
||||
if (!("syntheticSeed" in schema.data.options.properties))
|
||||
return err(
|
||||
refused(
|
||||
START,
|
||||
"The REA runtime in this build computer does not support synthetic seeds required by build export.",
|
||||
repackRuntime,
|
||||
),
|
||||
);
|
||||
return ok(undefined);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
signal.aborted
|
||||
? new AnalysisCancelledError(START, { cause: signal.reason })
|
||||
: adapterError(
|
||||
START,
|
||||
`Could not read the build computer's REA export schema: ${errorText(cause)}`,
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type CheckpointTaken = Omit<
|
||||
Extract<DesktopCaptureEvent, { kind: "checkpoint" }>,
|
||||
"kind"
|
||||
@@ -154,6 +239,8 @@ export class DesktopBuildCapture {
|
||||
!(await this.#release(previous, signal))
|
||||
)
|
||||
return err(unreleasedError(START, previous, undefined));
|
||||
const runtime = await checkExportRuntime(this.computer, signal);
|
||||
if (!runtime.ok) return runtime;
|
||||
this.#count += 1;
|
||||
const run: CaptureRun = {
|
||||
folder: `${WORK}/captures/${String(this.#count)}`,
|
||||
|
||||
@@ -74,9 +74,10 @@ export const runRea = (
|
||||
options: {
|
||||
readonly stdout: string;
|
||||
readonly files?: Readonly<Record<string, string>>;
|
||||
readonly timeoutS?: number;
|
||||
readonly signal: AbortSignal;
|
||||
},
|
||||
): Promise<number> =>
|
||||
): ReturnType<typeof runControllerRea> =>
|
||||
runControllerRea(computer, argv, { ...options, work: WORK });
|
||||
|
||||
/** The Evidence result, or the reported failure, a REA command wrote to a file of `bytes` in the computer. */
|
||||
|
||||
@@ -137,7 +137,7 @@ export const exportBuild = async (
|
||||
),
|
||||
);
|
||||
try {
|
||||
const bytes = await runRea(
|
||||
const { stdoutBytes: bytes } = await runRea(
|
||||
computer,
|
||||
[
|
||||
"export-desktop-application",
|
||||
@@ -146,6 +146,8 @@ export const exportBuild = async (
|
||||
"--task",
|
||||
task,
|
||||
...ENDS,
|
||||
// The building agent made the seed in this computer for the task.
|
||||
"--synthetic-seed",
|
||||
...(request.name === undefined ? [] : ["--name", request.name]),
|
||||
...(capture.packageUrl === undefined
|
||||
? []
|
||||
|
||||
@@ -87,7 +87,7 @@ const draftTask = async (
|
||||
draft_refusal: `No draft: the capture has no ${missing.join(" or ")} checkpoint; capture again and mark ${SEED_CHECKPOINT} once the seed is saved and ${END_CHECKPOINT} once the edit is.`,
|
||||
};
|
||||
const stdout = `${folder}/draft.json`;
|
||||
const bytes = await runRea(
|
||||
const { stdoutBytes: bytes } = await runRea(
|
||||
computer,
|
||||
[
|
||||
"describe-desktop-application-task",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { BuildComputerActionLog } from "./DesktopBuildActionLog.js";
|
||||
import {
|
||||
DesktopBuildWitness,
|
||||
type WitnessedComputer,
|
||||
} from "./DesktopBuildWitness.js";
|
||||
|
||||
const task = {
|
||||
name: "add-eggs",
|
||||
prompt: "Add eggs.",
|
||||
checks: [
|
||||
{
|
||||
kind: "lines_present" as const,
|
||||
file: "Documents/today.txt",
|
||||
lines: ["eggs"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const computer = {
|
||||
appData: () => Promise.reject(new Error("unread")),
|
||||
startRecording: () => Promise.reject(new Error("unstarted")),
|
||||
endRecording: () => Promise.reject(new Error("unended")),
|
||||
recordingSegments: () => Promise.reject(new Error("unfetched")),
|
||||
} as WitnessedComputer;
|
||||
|
||||
const witnessAt = async (bundle: string) =>
|
||||
new DesktopBuildWitness(computer, new BuildComputerActionLog(), bundle);
|
||||
|
||||
describe("bundle task reads", () => {
|
||||
it("returns a cancelled result when reading the bundle's task is aborted", async () => {
|
||||
const bundle = await createTestTempDirectory("witness-bundle-");
|
||||
const directory = await createTestTempDirectory("witness-record-");
|
||||
const signal = AbortSignal.abort();
|
||||
const result = await witnessAt(bundle).then((witness) =>
|
||||
witness.check({ task, stage: "seed", directory }, signal),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
if (!(result.error instanceof AnalysisCancelledError)) return;
|
||||
expect(result.error.operation).toBe("check_build_task");
|
||||
});
|
||||
|
||||
it("returns an adapter error when the bundle path cannot be read", async () => {
|
||||
const root = await createTestTempDirectory("witness-bundle-file-");
|
||||
const bundle = join(root, "not-a-directory");
|
||||
await writeFile(bundle, "bundle");
|
||||
const directory = await createTestTempDirectory("witness-record-");
|
||||
const result = await witnessAt(bundle).then((witness) =>
|
||||
witness.check(
|
||||
{ task, stage: "seed", directory },
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!(result.error instanceof AnalysisInputError)) return;
|
||||
expect(result.error.userMessage).toContain(bundle);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import { readHostFile } from "../../desktop/HostFileRead.js";
|
||||
import { isPortableBundleName } from "../environment/environmentBundle.js";
|
||||
import type { ReadonlyActionLog } from "./DesktopBuildActionLog.js";
|
||||
import { errorText } from "./DesktopBuildCommands.js";
|
||||
import { desktopOperationFailure } from "./DesktopCaptureRefusals.js";
|
||||
import {
|
||||
writeWitnessRecord,
|
||||
type EndedWitness,
|
||||
@@ -491,10 +492,23 @@ export class DesktopBuildWitness {
|
||||
"Give a new or empty folder.",
|
||||
),
|
||||
);
|
||||
const differs =
|
||||
this.bundle === undefined
|
||||
? undefined
|
||||
: await bundleTaskDifference(this.bundle, task, signal);
|
||||
let differs: string | undefined;
|
||||
if (this.bundle !== undefined) {
|
||||
try {
|
||||
differs = await bundleTaskDifference(this.bundle, task, signal);
|
||||
} catch (cause: unknown) {
|
||||
// A cancelled read, an I/O failure, or a handle that would not close
|
||||
// stays a Result. A cleanup lease stays bound to that error.
|
||||
return err(
|
||||
desktopOperationFailure(
|
||||
cause,
|
||||
signal,
|
||||
CHECK,
|
||||
"Reading the bundle's task",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (differs !== undefined)
|
||||
return err(
|
||||
refused(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
launchCommand,
|
||||
type DesktopAppExportPlan,
|
||||
} from "../../domain/desktop/appExport.js";
|
||||
import { electronLaunchArguments } from "../../domain/desktop/appExportInstall.js";
|
||||
import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js";
|
||||
import {
|
||||
versionAt,
|
||||
type DesktopApplicationState,
|
||||
} from "../../domain/desktop/desktopState.js";
|
||||
import { isWithinAny } from "../../domain/desktop/desktopStateDiff.js";
|
||||
import { openSeedDocument } from "../../domain/desktop/seedDocument.js";
|
||||
import { isTextualFormat } from "../../domain/desktop/stateFormats.js";
|
||||
import type { SeedVersion } from "./DesktopExportFiles.js";
|
||||
|
||||
type StatePath = DesktopApplicationState["paths"][number];
|
||||
|
||||
/** The owned files present at the seed with their digests, and those read as text. */
|
||||
export const seedVersions = (
|
||||
files: ReadonlyMap<string, StatePath>,
|
||||
paths: readonly string[],
|
||||
seed: number,
|
||||
): {
|
||||
readonly seeds: ReadonlyMap<string, SeedVersion>;
|
||||
readonly textSeeds: readonly string[];
|
||||
} => {
|
||||
const seeds = new Map<string, SeedVersion>();
|
||||
const textSeeds: string[] = [];
|
||||
for (const [home, path] of files) {
|
||||
const version = versionAt(path, seed);
|
||||
if (!isWithinAny(home, paths) || version?.present !== true) continue;
|
||||
if (version.sha256 === null) continue;
|
||||
seeds.set(home, { sha256: version.sha256, format: version.format });
|
||||
if (isTextualFormat(version.format)) textSeeds.push(home);
|
||||
}
|
||||
return { seeds, textSeeds: textSeeds.sort() };
|
||||
};
|
||||
|
||||
/** The launch command, opening the seed's one document when the app takes files. */
|
||||
export const planLaunch = (
|
||||
{ application, host }: DesktopApplicationCapture,
|
||||
electron: boolean,
|
||||
seeds: ReadonlyMap<string, SeedVersion>,
|
||||
files: ReadonlyMap<string, StatePath>,
|
||||
): Pick<DesktopAppExportPlan, "command" | "opens"> => {
|
||||
const isDocument = (home: string) => files.get(home)?.role === "document";
|
||||
const launch = openSeedDocument(
|
||||
electronLaunchArguments(application.argv, electron),
|
||||
{
|
||||
home: host.home,
|
||||
workingDirectory: application.working_directory,
|
||||
exec: application.desktop_entry?.exec ?? null,
|
||||
documents: [...seeds.keys()].filter(isDocument).sort(),
|
||||
isDocument,
|
||||
},
|
||||
);
|
||||
return {
|
||||
command: launchCommand(
|
||||
launch.argv,
|
||||
host.home,
|
||||
application.working_directory,
|
||||
),
|
||||
opens: launch.opens,
|
||||
};
|
||||
};
|
||||
@@ -25,6 +25,5 @@ export const projectHarmonyApplicationEvidence = (
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.harmony-application-graph",
|
||||
provider: HARMONY_APPLICATION_PROVIDER,
|
||||
subjectFormat: (first) => first.subject?.format ?? "unknown",
|
||||
});
|
||||
};
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
} from "../../domain/investigationProviders.js";
|
||||
import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
isImmutableEvidence,
|
||||
evidenceSubjectTarget,
|
||||
type Evidence,
|
||||
type EvidenceSubjectTarget,
|
||||
} from "../../domain/evidence.js";
|
||||
import { readEvidence } from "../../domain/evidenceValidation.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import {
|
||||
@@ -35,6 +40,32 @@ export interface ApplicationGraphEvidenceSource {
|
||||
readonly semanticGraph: JavaScriptSemanticGraph | null;
|
||||
}
|
||||
|
||||
/** Carry a subject only when it identifies the graph's root artifact. */
|
||||
export const applicationGraphSubjectTarget = (
|
||||
source: ApplicationGraphEvidenceSource,
|
||||
): EvidenceSubjectTarget | undefined => {
|
||||
const subject = source.evidence.subject;
|
||||
const staticRootObserved =
|
||||
source.kind === "static-application" &&
|
||||
source.graph.nodes.some(
|
||||
(node) =>
|
||||
node.kind === "artifact" &&
|
||||
node.identity.strategy === "content-digest" &&
|
||||
node.identity.sha256 === source.rootArtifactSha256 &&
|
||||
node.observations.some(
|
||||
({ evidence }) => evidence.extractor.operation === "inventory-root",
|
||||
),
|
||||
);
|
||||
return evidenceSubjectTarget(
|
||||
staticRootObserved ||
|
||||
(source.kind === "managed-application" &&
|
||||
subject?.format === "pe" &&
|
||||
subject.digest.sha256 === source.rootArtifactSha256)
|
||||
? subject
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
/** Caller-owned Evidence failed validation at the application graph boundary. */
|
||||
export interface ApplicationGraphEvidenceInputFailure {
|
||||
readonly issues: readonly AnalysisInputIssue[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
type EvidenceSubjectTarget,
|
||||
} from "../../domain/evidence.js";
|
||||
import type { ApplicationVersionComparisonResult } from "../../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import type { JavaScriptExportShapeComparisonResult } from "../../domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
@@ -12,10 +13,11 @@ import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../../domain/investiga
|
||||
|
||||
/** Create derived Evidence for one bounded application-graph feature trace. */
|
||||
export const createApplicationFeatureTraceEvidence = (
|
||||
subject: EvidenceSubjectTarget | undefined,
|
||||
parameters: EvidenceObservation["parameters"],
|
||||
result: ApplicationFeatureTraceResult,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
|
||||
createEvidence(subject, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
|
||||
predicateType: "rea.application-feature-trace",
|
||||
operation: "trace_application_feature",
|
||||
parameters,
|
||||
@@ -30,10 +32,11 @@ export const createApplicationFeatureTraceEvidence = (
|
||||
|
||||
/** Create derived Evidence for one bounded JavaScript semantic trace. */
|
||||
export const createJavaScriptSemanticTraceEvidence = (
|
||||
subject: EvidenceSubjectTarget | undefined,
|
||||
parameters: EvidenceObservation["parameters"],
|
||||
result: JavaScriptSemanticTraceResult,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
|
||||
createEvidence(subject, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
|
||||
predicateType: "rea.javascript-semantic-trace",
|
||||
operation: "trace_javascript_semantics",
|
||||
parameters,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleCom
|
||||
import type { SourceToBundleComparisonResult } from "../../domain/javascript/sourceToBundleComparisonSchemas.js";
|
||||
import {
|
||||
applicationGraphEvidenceInputError,
|
||||
applicationGraphSubjectTarget,
|
||||
parseApplicationGraphEvidence,
|
||||
parseNativeApplicationEvidence,
|
||||
type ApplicationGraphEvidenceSource,
|
||||
@@ -132,6 +133,7 @@ export const traceApplicationFeatureEvidence = (
|
||||
direction: input.direction,
|
||||
});
|
||||
const evidence = createApplicationFeatureTraceEvidence(
|
||||
applicationGraphSubjectTarget(source),
|
||||
{
|
||||
application_evidence_id: source.evidence.evidence_id,
|
||||
native_evidence_ids: nativeEvidence.map(({ evidence_id: id }) => id),
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
rebindEvidenceToTargetPath,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { createJavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import { createJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import {
|
||||
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
|
||||
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
@@ -14,18 +23,165 @@ import {
|
||||
} from "./JavaScriptApplicationWorkflowService.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "./JavaScriptRuntimeReconciliationService.js";
|
||||
import { traceJavaScriptSemanticsEvidence } from "./JavaScriptSemanticTraceService.js";
|
||||
import {
|
||||
inspectAnalysisView,
|
||||
projectRetainedAnalysis,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
|
||||
const reference = ({ evidence_id }: Evidence) => ({
|
||||
kind: "retained-evidence" as const,
|
||||
evidence_id,
|
||||
});
|
||||
|
||||
it("keeps a static trace subject unknown when legacy graph root evidence is omitted", () => {
|
||||
const original = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application;
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
original.normalized_result,
|
||||
);
|
||||
const { graph_id: _graphId, ...graphInput } = result.graph;
|
||||
const graph = createJavaScriptApplicationGraph({
|
||||
...graphInput,
|
||||
nodes: graphInput.nodes.filter(({ kind }) => kind !== "artifact"),
|
||||
root_node_ids: graphInput.nodes
|
||||
.filter(({ kind }) => kind === "javascript-asset")
|
||||
.map(({ node_id }) => node_id),
|
||||
edges: [],
|
||||
});
|
||||
const { graph_id: _semanticId, ...semanticInput } = result.semantic_graph;
|
||||
const semanticGraph = createJavaScriptSemanticGraph({
|
||||
...semanticInput,
|
||||
application_graph_id: graph.graph_id,
|
||||
});
|
||||
if (original.subject === null)
|
||||
throw new Error("Expected application subject");
|
||||
const legacy = createEvidence(
|
||||
{
|
||||
path: original.subject.local_path,
|
||||
sha256: original.subject.digest.sha256,
|
||||
format: original.subject.format,
|
||||
},
|
||||
original.provider,
|
||||
{
|
||||
operation: original.operation,
|
||||
predicateType: original.predicate_type,
|
||||
parameters: original.parameters,
|
||||
result: { ...result, graph, semantic_graph: semanticGraph },
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
const trace = traceApplicationFeatureEvidence({
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application: legacy,
|
||||
});
|
||||
if (!trace.ok) throw trace.error;
|
||||
expect(trace.value.evidence.subject).toBeNull();
|
||||
});
|
||||
|
||||
const native = createEvidence(
|
||||
{ path: "/example/addon.node", sha256: "e".repeat(64), format: "elf" },
|
||||
{ id: "fixture", name: "Native fixture", version: "1" },
|
||||
{ operation: "procedure_info", parameters: {}, result: {} },
|
||||
);
|
||||
|
||||
it("preserves the verified application subject in both trace workflows", async () => {
|
||||
const original = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application;
|
||||
if (original.subject === null)
|
||||
throw new Error("Expected application subject");
|
||||
const application = createEvidence(
|
||||
{
|
||||
path: original.subject.local_path,
|
||||
sha256: original.subject.digest.sha256,
|
||||
format: original.subject.format,
|
||||
architecture: "x86_64",
|
||||
name: "caller-selected-name",
|
||||
},
|
||||
original.provider,
|
||||
{
|
||||
operation: original.operation,
|
||||
predicateType: original.predicate_type,
|
||||
parameters: original.parameters,
|
||||
result: original.normalized_result,
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
const feature = traceApplicationFeatureEvidence({
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application,
|
||||
});
|
||||
const semantic = traceJavaScriptSemanticsEvidence({
|
||||
application,
|
||||
query: {
|
||||
seed: { kind: "property", name: "unobserved" },
|
||||
direction: "ownership",
|
||||
},
|
||||
});
|
||||
for (const result of [feature, semantic]) {
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence.subject).toEqual(application.subject);
|
||||
expect(result.value.evidence.limitations).not.toContain(
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
);
|
||||
}
|
||||
if (!feature.ok) throw feature.error;
|
||||
const view = await inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: feature.value.evidence },
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
if (!view.ok) throw view.error;
|
||||
expect(view.value.subject).toEqual(application.subject);
|
||||
const selected = parseEvidence({
|
||||
...feature.value.evidence,
|
||||
subject: { ...feature.value.evidence.subject, name: "selected display" },
|
||||
});
|
||||
const retained = projectRetainedAnalysis(
|
||||
selected,
|
||||
{
|
||||
recordEvidence: () => ok("added"),
|
||||
evidenceById: () => feature.value.evidence,
|
||||
},
|
||||
{ kind: "summary" },
|
||||
);
|
||||
if (!retained.ok) throw retained.error;
|
||||
expect(retained.value.subject?.name).toBe("selected display");
|
||||
expect(
|
||||
rebindEvidenceToTargetPath(feature.value.evidence, {
|
||||
path: application.subject!.local_path,
|
||||
sha256: "f".repeat(64),
|
||||
format: application.subject!.format,
|
||||
architecture: "x86_64",
|
||||
name: "wrong artifact",
|
||||
}),
|
||||
).toBe(feature.value.evidence);
|
||||
});
|
||||
|
||||
it("does not attribute a multi-source reconciliation graph to its envelope subject", () => {
|
||||
const original = JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE.right;
|
||||
const reconciliation = createEvidence(
|
||||
{ path: "/other/artifact", sha256: "f".repeat(64), format: "file" },
|
||||
original.provider,
|
||||
{
|
||||
operation: original.operation,
|
||||
predicateType: original.predicate_type,
|
||||
parameters: original.parameters,
|
||||
result: original.normalized_result,
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: original.evidence_links,
|
||||
},
|
||||
);
|
||||
const result = traceApplicationFeatureEvidence({
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
application: reconciliation,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence.subject).toBeNull();
|
||||
expect(result.value.evidence.limitations).toContain(
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves feature native arrays without changing source identity or unknowns", () => {
|
||||
const input = {
|
||||
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
applicationGraphEvidenceInputError,
|
||||
applicationGraphSubjectTarget,
|
||||
parseApplicationGraphEvidence,
|
||||
} from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
|
||||
@@ -63,6 +64,7 @@ export const traceJavaScriptSemanticsEvidence = (
|
||||
};
|
||||
const { allowed_relations, ...queryParameters } = input.query;
|
||||
const evidence = createJavaScriptSemanticTraceEvidence(
|
||||
applicationGraphSubjectTarget(source),
|
||||
{
|
||||
application_evidence_id: source.evidence.evidence_id,
|
||||
query:
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceSubjectTarget,
|
||||
type Evidence,
|
||||
type EvidenceSubjectTarget,
|
||||
} from "../../domain/evidence.js";
|
||||
@@ -90,12 +91,5 @@ const subjectTarget = (
|
||||
input.boundaries?.evidence.subject;
|
||||
if (subject === undefined || subject === null || subject.format !== "pe")
|
||||
return undefined;
|
||||
return {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: "pe",
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
};
|
||||
return evidenceSubjectTarget(subject);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { RejectedRegularFileSymlink } from "../filesystem/RegularFile.js";
|
||||
import { regularFileSymlinkRecovery } from "./StableArtifactFailure.js";
|
||||
|
||||
/** Project a selected-file admission failure without reinterpreting its path. */
|
||||
export const selectedFileTargetError = (
|
||||
path: string,
|
||||
primary: Error,
|
||||
cause: unknown,
|
||||
): BinaryTargetError =>
|
||||
new BinaryTargetError(path, primary.message, {
|
||||
cause,
|
||||
...(primary instanceof RejectedRegularFileSymlink &&
|
||||
primary.resolvedPath !== undefined
|
||||
? { remediationAction: regularFileSymlinkRecovery(primary.resolvedPath) }
|
||||
: {}),
|
||||
});
|
||||
@@ -5,11 +5,15 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js";
|
||||
import { completedStableArtifactObservation } from "./StableArtifactObservation.js";
|
||||
|
||||
/** Name an observed regular-file retry that admission will validate again. */
|
||||
export const regularFileSymlinkRecovery = (resolvedPath: string): string =>
|
||||
`Retry with the resolved regular file path ${resolvedPath}. The selected symlink is not admitted; the retry will validate the target again.`;
|
||||
|
||||
/** Offer the verified regular-file target after a selected symlink is rejected. */
|
||||
export const stableArtifactInputRecovery = (cause: ArtifactReaderFailure) =>
|
||||
cause.reason === "path" && cause.resolvedPath !== undefined
|
||||
? {
|
||||
remediationAction: `Retry with the resolved regular file path ${cause.resolvedPath}. The selected symlink is not admitted; the retry will validate the target again.`,
|
||||
remediationAction: regularFileSymlinkRecovery(cause.resolvedPath),
|
||||
conformsToInputContract: true,
|
||||
}
|
||||
: {};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, type BigIntStats } from "node:fs";
|
||||
import { lstat, open, realpath, type FileHandle } from "node:fs/promises";
|
||||
import { sameRegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
resolvedRegularFile,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
import {
|
||||
@@ -47,22 +50,6 @@ const FILE_SYSTEM: StableArtifactFileSystem = {
|
||||
realpath,
|
||||
};
|
||||
|
||||
/** Suggest a retry only when the rejected link currently resolves to a regular file. */
|
||||
const resolvedRegularFile = async (
|
||||
path: string,
|
||||
fileSystem: StableArtifactFileSystem,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const resolved = await fileSystem.realpath?.(path);
|
||||
if (resolved === undefined) return undefined;
|
||||
const target = await fileSystem.lstat(resolved);
|
||||
return target.isFile() && !target.isSymbolicLink() ? resolved : undefined;
|
||||
} catch {
|
||||
// A missing or unreadable target changes only the recovery hint.
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const afterValidation = async <T>(
|
||||
path: string,
|
||||
stage: string,
|
||||
|
||||
@@ -336,7 +336,9 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.inspectNativeDataType, {
|
||||
description: "Inspect one recovered database type or typed data object",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe("Local native executable or app path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Local native executable or app path",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
type: z.string().optional().describe("Exact database type category path"),
|
||||
|
||||
@@ -168,6 +168,12 @@ const registerDesktopExportCommand = (
|
||||
.describe(
|
||||
"http or https URL the computer downloads the captured --package-file from when the bundle is put, for an application installed from a .deb file",
|
||||
),
|
||||
syntheticSeed: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"The seed was made for the task, as in a build computer, not taken from the user's own data; the README says so instead of keeping the bundle local",
|
||||
),
|
||||
...checkpointOptions,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
@@ -194,6 +200,7 @@ const registerDesktopExportCommand = (
|
||||
packageUrl: options.packageUrl,
|
||||
seedCheckpoint: options.seedCheckpoint,
|
||||
endCheckpoint: options.endCheckpoint,
|
||||
syntheticSeed: options.syntheticSeed,
|
||||
},
|
||||
readers,
|
||||
signal,
|
||||
|
||||
@@ -49,7 +49,12 @@ it("advertises only that operation's summary from analysis detail outputs", () =
|
||||
binaryLayoutSummaryViewResultSchema.safeParse(javascript.value).success,
|
||||
).toBe(false);
|
||||
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const ajv = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
});
|
||||
const validators = Object.fromEntries(
|
||||
(
|
||||
[
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
createJavaScriptApplicationEdge,
|
||||
} from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import { createWebTextArtifact } from "../../domain/webContentArtifact.js";
|
||||
import {
|
||||
@@ -63,11 +64,46 @@ const asset = createJavaScriptApplicationNode({
|
||||
},
|
||||
],
|
||||
});
|
||||
const root = createJavaScriptApplicationNode({
|
||||
kind: "artifact",
|
||||
identity: {
|
||||
strategy: "content-digest",
|
||||
stability: "global-exact",
|
||||
sha256: applicationSha256,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "artifact root",
|
||||
properties: { format: "directory" },
|
||||
evidence: {
|
||||
...graphEvidence,
|
||||
artifact: {
|
||||
available: true,
|
||||
artifact_id: `art_${applicationSha256}`,
|
||||
sha256: applicationSha256,
|
||||
},
|
||||
location: {
|
||||
available: true,
|
||||
value: { kind: "artifact-path", path: "artifact-root" },
|
||||
},
|
||||
extractor: { ...graphEvidence.extractor, operation: "inventory-root" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const graph = createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: [asset.node_id],
|
||||
nodes: [asset],
|
||||
edges: [],
|
||||
root_node_ids: [root.node_id],
|
||||
nodes: [asset, root],
|
||||
edges: [
|
||||
createJavaScriptApplicationEdge({
|
||||
source_node_id: root.node_id,
|
||||
target_node_id: asset.node_id,
|
||||
relation: "contains",
|
||||
properties: { path: "renderer.js" },
|
||||
evidence: graphEvidence,
|
||||
}),
|
||||
],
|
||||
coverage: completeCoverage,
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
@@ -90,7 +90,8 @@ export const controller = async (
|
||||
|
||||
/**
|
||||
* Run one `rea` command in the computer with its output in a file under the
|
||||
* work folder, after writing the files it reads; the output's size.
|
||||
* work folder, after writing the files it reads. Preserve the exit status and
|
||||
* stderr alongside the output size so callers can diagnose command failures.
|
||||
*/
|
||||
export const runRea = async (
|
||||
computer: ControlledComputer,
|
||||
@@ -99,9 +100,14 @@ export const runRea = async (
|
||||
readonly work: string;
|
||||
readonly stdout: string;
|
||||
readonly files?: Readonly<Record<string, string>>;
|
||||
readonly timeoutS?: number;
|
||||
readonly signal: AbortSignal;
|
||||
},
|
||||
): Promise<number> => {
|
||||
): Promise<{
|
||||
readonly stdoutBytes: number;
|
||||
readonly exitCode: number | null;
|
||||
readonly stderr: string;
|
||||
}> => {
|
||||
const answer = answerSchema.parse(
|
||||
await runBuildController(
|
||||
computer,
|
||||
@@ -111,16 +117,20 @@ export const runRea = async (
|
||||
argv: [...argv],
|
||||
stdout: options.stdout,
|
||||
files: { ...options.files },
|
||||
timeout_s: 1800,
|
||||
timeout_s: options.timeoutS ?? 1800,
|
||||
},
|
||||
{ timeoutS: 1800, signal: options.signal },
|
||||
{ timeoutS: options.timeoutS ?? 1800, signal: options.signal },
|
||||
),
|
||||
);
|
||||
if (answer.exit_code === undefined || answer.stdout_bytes === undefined)
|
||||
throw new Error(
|
||||
answer.error ?? `rea ${argv[0] ?? ""} did not finish in the computer.`,
|
||||
);
|
||||
return answer.stdout_bytes;
|
||||
return {
|
||||
stdoutBytes: answer.stdout_bytes,
|
||||
exitCode: answer.exit_code,
|
||||
stderr: answer.stderr ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const listedSchema = z.object({ listing_bytes: z.int().nonnegative() });
|
||||
|
||||
@@ -170,6 +170,11 @@ export const analysisErrorRemediationAction = (
|
||||
error.referenceScope === "retained-closure"
|
||||
? "Import the originating Evidence bundle with the selected records and their complete linked sources into the current investigation, or re-run their producers there. Retry after the required records are retained. reset_investigation clears retained records; close_binary preserves them."
|
||||
: "Use an exact Evidence reference available from the selected source, supply the complete inline Evidence, re-run its producer, or select/import its Evidence bundle. reset_investigation clears retained records; close_binary preserves them.";
|
||||
if (
|
||||
error instanceof BinaryTargetError &&
|
||||
error.remediationAction !== undefined
|
||||
)
|
||||
return error.remediationAction;
|
||||
if (
|
||||
error instanceof BinaryTargetError &&
|
||||
error.constraint === "directory_requires_file"
|
||||
|
||||
@@ -883,7 +883,9 @@ it("projects typed module facts without source bodies and keeps source coverage
|
||||
|
||||
it("counts retained range observations including repeats and skips unavailable locations", () => {
|
||||
const analysis = analysisViewJavaScriptAnalysis();
|
||||
const original = analysis.graph.nodes[0];
|
||||
const original = analysis.graph.nodes.find(
|
||||
({ kind }) => kind === "javascript-asset",
|
||||
);
|
||||
const observed = original?.observations[0];
|
||||
if (!original || !observed) throw new Error("missing module observation");
|
||||
const range = {
|
||||
@@ -929,6 +931,7 @@ it("counts retained range observations including repeats and skips unavailable l
|
||||
...application,
|
||||
nodes: [node],
|
||||
root_node_ids: [node.node_id],
|
||||
edges: [],
|
||||
});
|
||||
const parent = javascriptParent(
|
||||
analysisViewBindJavaScriptGraphs(analysis, graph),
|
||||
@@ -997,7 +1000,9 @@ it("validates typed module selectors without coercing null or empty paths", () =
|
||||
|
||||
it("keeps mixed observation support distinct instead of promoting a module to observed", () => {
|
||||
const analysis = analysisViewJavaScriptAnalysis();
|
||||
const original = analysis.graph.nodes[0];
|
||||
const original = analysis.graph.nodes.find(
|
||||
({ kind }) => kind === "javascript-asset",
|
||||
);
|
||||
const observed = original?.observations[0];
|
||||
if (!original || !observed) throw new Error("missing module observation");
|
||||
const inferred = {
|
||||
@@ -1025,6 +1030,7 @@ it("keeps mixed observation support distinct instead of promoting a module to ob
|
||||
...application,
|
||||
nodes: [node],
|
||||
root_node_ids: [node.node_id],
|
||||
edges: [],
|
||||
});
|
||||
const result = projectAnalysisView(
|
||||
javascriptParent(analysisViewBindJavaScriptGraphs(analysis, graph)),
|
||||
|
||||
@@ -1144,7 +1144,7 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({
|
||||
/** Compact retained feature-trace summary; pages carry the same trace context. */
|
||||
export const featureTraceSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
artifact: z.null(),
|
||||
artifact: artifactSchema.nullable(),
|
||||
parent_operation: z.literal("trace_application_feature"),
|
||||
summary: featureTraceSummarySchema,
|
||||
});
|
||||
@@ -1157,7 +1157,7 @@ const featureTracePage = <
|
||||
...viewResultBase,
|
||||
...featureTraceViewContextSchema.shape,
|
||||
kind: z.literal("feature-trace"),
|
||||
artifact: z.null(),
|
||||
artifact: artifactSchema.nullable(),
|
||||
parent_operation: z.literal("trace_application_feature"),
|
||||
view: featureTraceViewCollections[collection].request,
|
||||
items: featureTraceViewCollections[collection].rows,
|
||||
|
||||
@@ -317,7 +317,7 @@ export const projectFeatureTraceView = (
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: OPERATION,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: null,
|
||||
artifact: parent.artifact ?? null,
|
||||
limitations: boundedViewMessages(
|
||||
limitations,
|
||||
parent.evidenceId,
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface BinaryTargetErrorOptions extends AnalysisErrorOptions {
|
||||
readonly infoPlist?: string;
|
||||
/** Bundle paths examined before resolution failed. */
|
||||
readonly probedPaths?: readonly string[];
|
||||
readonly remediationAction?: string;
|
||||
}
|
||||
|
||||
/** A supplied target path could not be safely opened as a supported app or binary. */
|
||||
@@ -70,6 +71,7 @@ export class BinaryTargetError extends AnalysisError {
|
||||
readonly constraint: BinaryTargetErrorOptions["constraint"];
|
||||
readonly infoPlist: string | undefined;
|
||||
readonly probedPaths: readonly string[] | undefined;
|
||||
readonly remediationAction: string | undefined;
|
||||
/** The OS read-permission code of the cause, when the host denied access. */
|
||||
readonly systemCode: "EACCES" | "EPERM" | undefined;
|
||||
constructor(
|
||||
@@ -81,6 +83,7 @@ export class BinaryTargetError extends AnalysisError {
|
||||
this.constraint = options?.constraint;
|
||||
this.infoPlist = options?.infoPlist;
|
||||
this.probedPaths = options?.probedPaths;
|
||||
this.remediationAction = options?.remediationAction;
|
||||
this.systemCode = readPermissionCode(options?.cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import {
|
||||
className,
|
||||
environmentName,
|
||||
launchCommand,
|
||||
launchDocumentLimitation,
|
||||
launchDocumentNote,
|
||||
ownedPaths,
|
||||
renderAppClass,
|
||||
renderEnvToml,
|
||||
renderInstallScript,
|
||||
renderReadme,
|
||||
seedWording,
|
||||
type DesktopAppExportPlan,
|
||||
} from "./appExport.js";
|
||||
import type { LaunchDocument } from "./seedDocument.js";
|
||||
|
||||
const plan: DesktopAppExportPlan = {
|
||||
name: "mousepad",
|
||||
@@ -26,10 +30,12 @@ const plan: DesktopAppExportPlan = {
|
||||
packageFile: null,
|
||||
electron: false,
|
||||
command: ["mousepad"],
|
||||
opens: { kind: "empty" },
|
||||
windowClass: "Mousepad",
|
||||
paths: [".config/Mousepad", "Documents"],
|
||||
capturedHome: "/home/ada",
|
||||
seedCheckpoint: "baseline",
|
||||
syntheticSeed: false,
|
||||
seedFiles: ["Documents/todo.txt"],
|
||||
textSeeds: ["Documents/todo.txt"],
|
||||
unseededFiles: [],
|
||||
@@ -309,3 +315,72 @@ describe("renderers", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("README and class wording", () => {
|
||||
it("adds one README line on what the launch opens: the seed's document, or why it opens none", () => {
|
||||
const command = launchCommand(
|
||||
["mousepad", "/home/ada/Documents/todo.txt"],
|
||||
"/home/ada",
|
||||
"/home/ada",
|
||||
);
|
||||
const readme = (opens: LaunchDocument) =>
|
||||
renderReadme({ ...plan, command, opens });
|
||||
const bare = readme({ kind: "empty" });
|
||||
const seed = { kind: "seed", document: "Documents/todo.txt" } as const;
|
||||
const none = {
|
||||
kind: "none",
|
||||
reason: "the test gives this reason",
|
||||
} as const;
|
||||
|
||||
expect(command.join(" ")).toContain(`"$HOME"/'Documents/todo.txt'`);
|
||||
expect(bare).toContain(`\`${command.join(" ")}\``);
|
||||
for (const opens of [seed, none]) {
|
||||
const note = launchDocumentNote({ opens });
|
||||
expect(note).not.toBe("");
|
||||
expect(readme(opens).replace(note, "")).toBe(bare);
|
||||
}
|
||||
expect(readme(seed)).toContain("`~/Documents/todo.txt`");
|
||||
expect(bare).not.toContain("~/Documents/todo.txt");
|
||||
const limitation = launchDocumentLimitation(none);
|
||||
expect(limitation).toContain(none.reason);
|
||||
expect(launchDocumentNote({ opens: none })).toContain(limitation);
|
||||
expect(readme({ kind: "captured" })).toBe(bare);
|
||||
for (const opens of [
|
||||
seed,
|
||||
{ kind: "captured" },
|
||||
{ kind: "empty" },
|
||||
] as const)
|
||||
expect(launchDocumentLimitation(opens)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("says the seed is the user's own data to keep local, unless it was made for the task", () => {
|
||||
const own = { ...plan, seedCheckpoint: "drafted" };
|
||||
const made = { ...own, syntheticSeed: true };
|
||||
const wording = { own: seedWording(own), made: seedWording(made) };
|
||||
// The README's seed line and closing notice, and the class's docstring.
|
||||
const renderedIn = {
|
||||
origin: renderReadme,
|
||||
notice: renderReadme,
|
||||
docstring: renderAppClass,
|
||||
} satisfies Record<
|
||||
keyof typeof wording.own,
|
||||
(from: DesktopAppExportPlan) => string
|
||||
>;
|
||||
|
||||
for (const [from, kept, other] of [
|
||||
[own, wording.own, wording.made],
|
||||
[made, wording.made, wording.own],
|
||||
] as const)
|
||||
for (const key of ["origin", "notice", "docstring"] as const) {
|
||||
const rendered = renderedIn[key](from);
|
||||
expect(rendered).toContain(kept[key]);
|
||||
expect(rendered).not.toContain(other[key]);
|
||||
}
|
||||
for (const { origin, docstring } of [wording.own, wording.made]) {
|
||||
expect(origin).toContain("`drafted`");
|
||||
expect(docstring).toContain("drafted");
|
||||
}
|
||||
expect(renderReadme(made)).toContain("`envs/mousepad/seed/`");
|
||||
expect(renderReadme(own)).not.toContain("`envs/mousepad/seed/`");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import type { DesktopApplicationCapture } from "./desktopCapture.js";
|
||||
import type { DesktopApplicationState } from "./desktopState.js";
|
||||
import { isWithinAny } from "./desktopStateDiff.js";
|
||||
import { SEED_HOME } from "./hostIdentity.js";
|
||||
import { capturedFileUrl, type LaunchDocument } from "./seedDocument.js";
|
||||
import { posix } from "node:path";
|
||||
import { stateDirectoryOf } from "./stateRoles.js";
|
||||
|
||||
/** agentenv-framework release the exported class was checked against. */
|
||||
@@ -42,12 +44,16 @@ export interface DesktopAppExportPlan {
|
||||
/** Whether the app is Electron, whose launch then runs without Chromium's sandbox. */
|
||||
readonly electron: boolean;
|
||||
readonly command: readonly string[];
|
||||
/** What the launch opens: the seed's one document, a file the captured command names, or nothing. */
|
||||
readonly opens: LaunchDocument;
|
||||
readonly windowClass: string;
|
||||
/** Home-relative paths the app owns for data add, get and reset. */
|
||||
readonly paths: readonly string[];
|
||||
readonly capturedHome: string;
|
||||
/** The checkpoint whose state the seed holds. */
|
||||
readonly seedCheckpoint: string;
|
||||
/** Whether the seed was made for the task, as in a build computer, rather than taken from the user's own data. */
|
||||
readonly syntheticSeed: boolean;
|
||||
/** Home-relative files copied into the computer's home before the first launch. */
|
||||
readonly seedFiles: readonly string[];
|
||||
/** Seed files read as text, which get the seed's home path respelled. */
|
||||
@@ -241,12 +247,37 @@ export const launchCommand = (
|
||||
capturedHome: string,
|
||||
workingDirectory: string,
|
||||
): readonly string[] => {
|
||||
const homeUrl = (value: string): string | undefined => {
|
||||
const parsed = capturedFileUrl(value);
|
||||
if (parsed === undefined) return undefined;
|
||||
const { url, path } = parsed;
|
||||
const root = posix.normalize(capturedHome);
|
||||
if (
|
||||
path !== root &&
|
||||
!(root === "/" ? path.startsWith("/") : path.startsWith(`${root}/`))
|
||||
)
|
||||
return undefined;
|
||||
const segments = url.pathname.split("/");
|
||||
const count = root.split("/").filter(Boolean).length;
|
||||
const encodedSuffix = segments.slice(count + 1).join("/");
|
||||
const suffix =
|
||||
(segments.length > count + 1 ? `/${encodedSuffix}` : "") +
|
||||
url.search +
|
||||
url.hash;
|
||||
// The framework executes this command in a Python environment. Encode the
|
||||
// runtime home as a URL path; shell quoting alone does not escape # or spaces.
|
||||
const encodedHome = `"$(python3 -I -c 'import os,urllib.parse;print(urllib.parse.quote(os.environ["HOME"],safe="/"),end="")')"`;
|
||||
const prefix =
|
||||
/^file:\/\/(?:localhost)?(?=\/)/iu.exec(value)?.[0] ?? "file://";
|
||||
return `${shellQuote(prefix)}${encodedHome}${shellQuote(suffix)}`;
|
||||
};
|
||||
const underHome = (path: string) =>
|
||||
path === capturedHome
|
||||
homeUrl(path) ??
|
||||
(path === capturedHome
|
||||
? `"$HOME"`
|
||||
: path.startsWith(`${capturedHome}/`)
|
||||
? `"$HOME"/${shellQuote(path.slice(capturedHome.length + 1))}`
|
||||
: undefined;
|
||||
: undefined);
|
||||
const respell = (argument: string) => {
|
||||
const equals = argument.indexOf("=");
|
||||
const value =
|
||||
@@ -355,13 +386,32 @@ export const renderInstallScript = (file: ExportPackageFile): string =>
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
/** How the class and README say where the seed came from: made for the task, or the user's own data, kept local. */
|
||||
export const seedWording = ({
|
||||
name,
|
||||
seedCheckpoint,
|
||||
syntheticSeed,
|
||||
}: Pick<DesktopAppExportPlan, "name" | "seedCheckpoint" | "syntheticSeed">) =>
|
||||
syntheticSeed
|
||||
? {
|
||||
docstring: `The seed was made for the task, as saved at checkpoint ${docText(seedCheckpoint)}.`,
|
||||
origin: `made for the task, as saved at checkpoint \`${seedCheckpoint}\``,
|
||||
notice: `The seed was made for this task, not taken from a user's own data; read \`envs/${name}/seed/\` before publishing the bundle.`,
|
||||
}
|
||||
: {
|
||||
docstring: `The seed is the user's state at checkpoint ${docText(seedCheckpoint)}: keep this bundle local.`,
|
||||
origin: `from the user's home at checkpoint \`${seedCheckpoint}\``,
|
||||
notice:
|
||||
"The seed is the user's real data. Keep this bundle local; do not publish it.",
|
||||
};
|
||||
|
||||
/** The computer app class: install, launch, owned paths, seed and data hooks. */
|
||||
export const renderAppClass = (plan: DesktopAppExportPlan): string => {
|
||||
const hooks = classBody(plan);
|
||||
return `"""${docText(plan.title)} as an agentenv computer app, exported by REA from a desktop application capture.
|
||||
|
||||
Package: ${plan.aptPackage}${plan.packageVersion === null ? "" : ` (captured at ${plan.packageVersion})`}. Capture Evidence: ${plan.captureEvidenceId}.
|
||||
The seed is the user's state at checkpoint ${docText(plan.seedCheckpoint)}: keep this bundle local.
|
||||
${seedWording(plan).docstring}
|
||||
"""
|
||||
|
||||
${PYTHON_IMPORTS}
|
||||
@@ -451,6 +501,27 @@ const installNotes = (plan: DesktopAppExportPlan): string =>
|
||||
.map((line) => `\n${line}`)
|
||||
.join("");
|
||||
|
||||
/** The start of the limitation of a launch that opens no document; its reason follows. */
|
||||
export const NO_LAUNCH_DOCUMENT = "The app opens no document at launch";
|
||||
|
||||
/** The limitation of a launch that opens no document; none when it opens one. */
|
||||
export const launchDocumentLimitation = (
|
||||
opens: LaunchDocument,
|
||||
): string | undefined =>
|
||||
opens.kind === "none"
|
||||
? `${NO_LAUNCH_DOCUMENT}, since ${opens.reason}.`
|
||||
: undefined;
|
||||
|
||||
/** The README line on what the launch opens; none when the captured command names its own file. */
|
||||
export const launchDocumentNote = ({
|
||||
opens,
|
||||
}: Pick<DesktopAppExportPlan, "opens">): string =>
|
||||
opens.kind === "seed"
|
||||
? `\n- At launch the app opens \`~/${opens.document}\`, the seed's one document.`
|
||||
: opens.kind === "none"
|
||||
? `\n- ${launchDocumentLimitation(opens)}`
|
||||
: "";
|
||||
|
||||
/** Bundle files: seeds the export rewrote to remove the capture host's identity, and files that still hold it. */
|
||||
export interface SeedScrubReport {
|
||||
readonly scrubbed: readonly string[];
|
||||
@@ -490,10 +561,10 @@ ${DESKTOP_EXPORT_FRAMEWORK_VERSION}.
|
||||
|
||||
- \`envs/${plan.name}/\` is a computer app on the \`ubuntu-24.04\` base with the \`reference\` driver, for
|
||||
${plan.architectures.map((architecture) => `\`${architecture}\``).join(" and ")}. It installs ${installs(plan)}, launches \`${plan.command.join(" ")}\` at boot and is ready
|
||||
when a window of class \`${plan.windowClass}\` appears.${installNotes(plan)}
|
||||
when a window of class \`${plan.windowClass}\` appears.${installNotes(plan)}${launchDocumentNote(plan)}
|
||||
- Owned paths: ${plan.paths.length === 0 ? "none" : plan.paths.map((path) => `\`~/${path}\``).join(", ")}.
|
||||
\`data/get\` returns their files and a typed JSON view of each; \`data/reset\` restores the seed.
|
||||
- Seed: ${plan.seedFiles.length} file(s) from the user's home at checkpoint \`${plan.seedCheckpoint}\`${plan.dconf.length === 0 ? "" : ", and the application's dconf keys"}.
|
||||
- Seed: ${plan.seedFiles.length} file(s) ${seedWording(plan).origin}${plan.dconf.length === 0 ? "" : ", and the application's dconf keys"}.
|
||||
Text files and dconf keys spell the capture host's name as \`localhost\`, \`user@host\` as \`user@localhost\`
|
||||
and the home as \`${SEED_HOME}\`, which the computer respells to its own at boot.${scrubNotes(scrub)}${
|
||||
plan.unseededFiles.length === 0
|
||||
@@ -506,7 +577,7 @@ ${DESKTOP_EXPORT_FRAMEWORK_VERSION}.
|
||||
|
||||
${taskSection.length === 0 ? "Run it with `agent-env run <this folder>`." : "Run the task with `agent-env run <this folder>`, or only the deploy with `--task smoke`."}
|
||||
|
||||
The seed is the user's real data. Keep this bundle local; do not publish it.
|
||||
${seedWording(plan).notice}
|
||||
${plan.dconf.length === 0 ? "" : "dconf keys are compiled into the user's dconf database, with `dconf-cli`, once when the app first starts; `data/reset` does not restore them.\n"}${taskSection}`;
|
||||
|
||||
/** A smoke task that deploys the app. */
|
||||
|
||||
@@ -207,6 +207,12 @@ export interface DesktopExecContext {
|
||||
|
||||
const FILE_FIELD_CODES = new Set(["%f", "%F", "%u", "%U"]);
|
||||
|
||||
/** Whether an `Exec` value takes files: it holds a `%f`, `%F`, `%u` or `%U` field code. */
|
||||
export const execTakesFiles = (exec: string): boolean => {
|
||||
const tokens = tokenizeDesktopExec(exec);
|
||||
return tokens.ok && tokens.value.some((token) => FILE_FIELD_CODES.has(token));
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand field codes for a launch with no files, then append caller arguments.
|
||||
* File and URL codes are dropped, so the caller's arguments take their place.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { launchCommand } from "./appExport.js";
|
||||
import { openSeedDocument } from "./seedDocument.js";
|
||||
|
||||
const HOME = "/home/ada";
|
||||
const poster = ["Documents/poster.svg"];
|
||||
/** The capture's home and working directory, with documents under `Documents`. */
|
||||
const AT_HOME = {
|
||||
home: HOME,
|
||||
workingDirectory: HOME,
|
||||
isDocument: (path: string) => path.startsWith("Documents/"),
|
||||
};
|
||||
|
||||
/** The reason given for a launch that opens no document, which must not be blank. */
|
||||
const reasonOf = ({ opens }: ReturnType<typeof openSeedDocument>): string => {
|
||||
if (opens.kind !== "none")
|
||||
throw new Error(`the launch opens ${opens.kind}, not none`);
|
||||
expect(opens.reason.trim()).not.toBe("");
|
||||
return opens.reason;
|
||||
};
|
||||
|
||||
it("keeps a captured file URL without appending the same document", () => {
|
||||
const url = `file://${HOME}/Documents/poster%20one.svg?mode=edit#page`;
|
||||
const argv = ["inkscape", url];
|
||||
expect(
|
||||
openSeedDocument(argv, {
|
||||
...AT_HOME,
|
||||
exec: "inkscape %U",
|
||||
documents: ["Documents/poster one.svg"],
|
||||
}),
|
||||
).toEqual({ argv, opens: { kind: "captured" } });
|
||||
});
|
||||
|
||||
it("recognizes a captured relative filename containing a colon", () => {
|
||||
const argv = ["app", "Budget:2026.svg"];
|
||||
expect(
|
||||
openSeedDocument(argv, {
|
||||
...AT_HOME,
|
||||
workingDirectory: `${HOME}/Documents`,
|
||||
exec: "app %U",
|
||||
documents: ["Documents/Budget:2026.svg"],
|
||||
isDocument: (path) => path === "Documents/Budget:2026.svg",
|
||||
}),
|
||||
).toEqual({ argv, opens: { kind: "captured" } });
|
||||
});
|
||||
|
||||
describe("openSeedDocument", () => {
|
||||
it("appends the seed's one document when Exec takes files, respelled under $HOME by the launch", () => {
|
||||
const opened = openSeedDocument(["inkscape"], {
|
||||
...AT_HOME,
|
||||
exec: "inkscape %F",
|
||||
documents: poster,
|
||||
});
|
||||
|
||||
expect(opened).toEqual({
|
||||
argv: ["inkscape", `${HOME}/Documents/poster.svg`],
|
||||
opens: { kind: "seed", document: "Documents/poster.svg" },
|
||||
});
|
||||
expect(launchCommand(opened.argv, HOME, HOME)).toEqual([
|
||||
"sh",
|
||||
"-c",
|
||||
`cd "$HOME" 2>/dev/null; exec 'inkscape' "$HOME"/'Documents/poster.svg'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["mousepad %U", "app %f", "env A=1 app %u", "app %U --new-window"])(
|
||||
"opens the document for Exec %j",
|
||||
(exec) => {
|
||||
expect(
|
||||
openSeedDocument(["app"], { ...AT_HOME, exec, documents: poster })
|
||||
.opens,
|
||||
).toEqual({ kind: "seed", document: "Documents/poster.svg" });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the launch and says why when the seed holds several documents, and opens none when it holds none", () => {
|
||||
const documents = ["Documents/a.svg", "Documents/b.svg"];
|
||||
const several = openSeedDocument(["inkscape"], {
|
||||
...AT_HOME,
|
||||
exec: "inkscape %F",
|
||||
documents,
|
||||
});
|
||||
|
||||
expect(several.argv).toEqual(["inkscape"]);
|
||||
const reason = reasonOf(several);
|
||||
for (const document of documents) expect(reason).toContain(`~/${document}`);
|
||||
expect(
|
||||
openSeedDocument(["inkscape"], {
|
||||
...AT_HOME,
|
||||
exec: "inkscape %F",
|
||||
documents: [],
|
||||
}),
|
||||
).toEqual({
|
||||
argv: ["inkscape"],
|
||||
opens: { kind: "empty" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the launch and says why when Exec takes no file or there is no desktop entry", () => {
|
||||
const reasons = ["xcalc", "app %i %c", null].map((exec) => {
|
||||
const opened = openSeedDocument(["app"], {
|
||||
...AT_HOME,
|
||||
exec,
|
||||
documents: poster,
|
||||
});
|
||||
expect(opened.argv).toEqual(["app"]);
|
||||
return reasonOf(opened);
|
||||
});
|
||||
|
||||
// Both Execs that take no file have the same cause.
|
||||
expect(reasons[1]).toBe(reasons[0]);
|
||||
});
|
||||
|
||||
it("gives each cause of opening no document its own reason", () => {
|
||||
const reasons = [
|
||||
{ exec: "app %F", documents: ["Documents/a.svg", "Documents/b.svg"] },
|
||||
{ exec: "xcalc", documents: poster },
|
||||
{ exec: null, documents: poster },
|
||||
].map((facts) =>
|
||||
reasonOf(openSeedDocument(["app"], { ...AT_HOME, ...facts })),
|
||||
);
|
||||
|
||||
expect(new Set(reasons).size).toBe(reasons.length);
|
||||
});
|
||||
|
||||
it("leaves a command that already names a document alone, absolute or from its working directory", () => {
|
||||
for (const [argv, workingDirectory] of [
|
||||
[["typora", `${HOME}/Documents/trip.md`], HOME],
|
||||
[["typora", "Documents/trip.md"], HOME],
|
||||
[["typora", "trip.md"], `${HOME}/Documents`],
|
||||
] as const)
|
||||
expect(
|
||||
openSeedDocument(argv, {
|
||||
...AT_HOME,
|
||||
workingDirectory,
|
||||
exec: "typora %U",
|
||||
documents: poster,
|
||||
}),
|
||||
).toEqual({ argv, opens: { kind: "captured" } });
|
||||
});
|
||||
|
||||
it("still opens the seed's document when the command names only settings under the home", () => {
|
||||
const argv = ["geany", "--config", `${HOME}/.config/geany`];
|
||||
|
||||
expect(
|
||||
openSeedDocument(argv, {
|
||||
...AT_HOME,
|
||||
exec: "geany %F",
|
||||
documents: poster,
|
||||
}),
|
||||
).toEqual({
|
||||
argv: [...argv, `${HOME}/Documents/poster.svg`],
|
||||
opens: { kind: "seed", document: "Documents/poster.svg" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { posix } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { execTakesFiles } from "./desktopEntry.js";
|
||||
|
||||
/** What the exported app opens at launch. */
|
||||
export type LaunchDocument =
|
||||
/** The seed's one document, by home path. */
|
||||
| { readonly kind: "seed"; readonly document: string }
|
||||
/** A document the captured command already names. */
|
||||
| { readonly kind: "captured" }
|
||||
/** Nothing, since the seed holds no document. */
|
||||
| { readonly kind: "empty" }
|
||||
/** No document, for the reason given, which follows "since". */
|
||||
| { readonly kind: "none"; readonly reason: string };
|
||||
|
||||
/** Parse a captured Linux file URL independently of the exporter host OS. */
|
||||
export const capturedFileUrl = (
|
||||
value: string,
|
||||
): { readonly url: URL; readonly path: string } | undefined => {
|
||||
if (!/^file:/iu.test(value)) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "file:"
|
||||
? { url, path: fileURLToPath(url, { windows: false }) }
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** The home path an argument names, absolute or from the working directory; none for an option or a path outside the home. */
|
||||
const homePathOf = (
|
||||
argument: string,
|
||||
home: string,
|
||||
workingDirectory: string,
|
||||
known: (path: string) => boolean,
|
||||
): string | undefined => {
|
||||
if (argument === "" || argument.startsWith("-")) return undefined;
|
||||
let input = argument;
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(argument)) {
|
||||
const candidate = posix.resolve(workingDirectory, argument);
|
||||
if (candidate.startsWith(`${home}/`)) {
|
||||
const relative = candidate.slice(home.length + 1);
|
||||
if (known(relative)) return relative;
|
||||
}
|
||||
const parsed = capturedFileUrl(argument);
|
||||
if (parsed === undefined) return undefined;
|
||||
input = parsed.path;
|
||||
}
|
||||
const path = posix.resolve(workingDirectory, input);
|
||||
return path.startsWith(`${home}/`) ? path.slice(home.length + 1) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The launch arguments, with the seed's document appended when the seed holds
|
||||
* exactly one and the desktop entry's `Exec` takes a file, so the app opens on
|
||||
* it rather than on its start screen. A command that already names a document,
|
||||
* absolute or from its working directory, is kept as it is.
|
||||
*/
|
||||
export const openSeedDocument = (
|
||||
argv: readonly string[],
|
||||
facts: {
|
||||
readonly home: string;
|
||||
readonly workingDirectory: string;
|
||||
readonly exec: string | null;
|
||||
readonly documents: readonly string[];
|
||||
/** Whether a home path is a document the capture saw. */
|
||||
readonly isDocument: (homePath: string) => boolean;
|
||||
},
|
||||
): { readonly argv: readonly string[]; readonly opens: LaunchDocument } => {
|
||||
const { home, exec, documents } = facts;
|
||||
const none = (reason: string) => ({
|
||||
argv,
|
||||
opens: { kind: "none", reason } as const,
|
||||
});
|
||||
const named = argv
|
||||
.slice(1)
|
||||
.map((argument) =>
|
||||
homePathOf(argument, home, facts.workingDirectory, facts.isDocument),
|
||||
);
|
||||
if (named.some((path) => path !== undefined && facts.isDocument(path)))
|
||||
return { argv, opens: { kind: "captured" } };
|
||||
const [document] = documents;
|
||||
if (document === undefined) return { argv, opens: { kind: "empty" } };
|
||||
if (documents.length > 1)
|
||||
return none(
|
||||
`the seed holds ${String(documents.length)} documents (${documents.map((path) => `~/${path}`).join(", ")}) and the app opens one only when it holds exactly one`,
|
||||
);
|
||||
if (exec === null)
|
||||
return none(
|
||||
"the capture launched the program without a desktop entry, so whether it takes a file is unknown",
|
||||
);
|
||||
if (!execTakesFiles(exec))
|
||||
return none(
|
||||
"its desktop entry's Exec takes no file (no %f, %F, %u or %U field code)",
|
||||
);
|
||||
return {
|
||||
argv: [...argv, `${home}/${document}`],
|
||||
opens: { kind: "seed", document },
|
||||
};
|
||||
};
|
||||
+40
-17
@@ -184,16 +184,40 @@ export interface EvidenceSubjectTarget {
|
||||
readonly sha256: string;
|
||||
readonly format: z.infer<typeof subjectSchema>["format"];
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
/** Preserve an existing Evidence subject's display name when deriving observations. */
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** Preserve the complete subject when deriving a new Evidence observation. */
|
||||
export const evidenceSubjectTarget = (
|
||||
subject: Evidence["subject"],
|
||||
): EvidenceSubjectTarget | undefined =>
|
||||
subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
name: subject.name,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
};
|
||||
|
||||
const sameEvidenceSubjectTargetIdentity = (
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: EvidenceSubjectTarget,
|
||||
): boolean =>
|
||||
subject.sha256 === target.sha256 &&
|
||||
subject.format === target.format &&
|
||||
(subject.architecture ?? null) === (target.architecture ?? null);
|
||||
|
||||
/** Rebind a matching immutable artifact subject to the caller's selected path. */
|
||||
export const rebindEvidenceSubjectTargetPath = (
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: EvidenceSubjectTarget,
|
||||
): EvidenceSubjectTarget =>
|
||||
subject.sha256 === target.sha256 &&
|
||||
subject.format === target.format &&
|
||||
(subject.architecture ?? null) === (target.architecture ?? null)
|
||||
sameEvidenceSubjectTargetIdentity(subject, target)
|
||||
? { ...subject, path: target.path }
|
||||
: subject;
|
||||
|
||||
@@ -324,21 +348,18 @@ export const rebindEvidenceToTargetPath = (
|
||||
): Evidence => {
|
||||
const subject = evidence.subject;
|
||||
if (subject === null) return evidence;
|
||||
const rebound = rebindEvidenceSubjectTargetPath(
|
||||
{
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
},
|
||||
target,
|
||||
);
|
||||
if (rebound.path === subject.local_path) return evidence;
|
||||
if (
|
||||
!sameEvidenceSubjectTargetIdentity(evidenceSubjectTarget(subject)!, target)
|
||||
)
|
||||
return evidence;
|
||||
if (
|
||||
target.path === subject.local_path &&
|
||||
(target.name === undefined || target.name === subject.name)
|
||||
)
|
||||
return evidence;
|
||||
const reboundSubject = subjectSchema.parse({
|
||||
...subject,
|
||||
name: basename(target.path) || "artifact",
|
||||
name: target.name ?? (basename(target.path) || "artifact"),
|
||||
local_path: target.path,
|
||||
});
|
||||
const reboundEvidence = { ...evidence, subject: reboundSubject };
|
||||
@@ -466,7 +487,9 @@ export const evidenceEnvelopeForAdmission = (
|
||||
target === undefined
|
||||
? null
|
||||
: {
|
||||
name: basename(target.path) || "artifact",
|
||||
name:
|
||||
("name" in target ? target.name : undefined) ??
|
||||
(basename(target.path) || "artifact"),
|
||||
digest: { sha256: target.sha256 },
|
||||
format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
|
||||
@@ -132,6 +132,27 @@ function* checkApplicationBindingsSteps(
|
||||
try {
|
||||
for (const node of result.graph.nodes) {
|
||||
applicationNodeIds.add(node.node_id);
|
||||
if (
|
||||
node.kind === "artifact" &&
|
||||
(node.identity.strategy !== "content-digest" ||
|
||||
node.identity.sha256 !== result.root_artifact_sha256)
|
||||
) {
|
||||
for (const observation of node.observations) {
|
||||
if (observation.evidence.extractor.operation === "inventory-root") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["root_artifact_sha256"],
|
||||
message:
|
||||
"Application artifact root must match the declared root SHA-256",
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (++examined === 4096) {
|
||||
examined = 0;
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (++examined === 4096) {
|
||||
examined = 0;
|
||||
yield;
|
||||
|
||||
@@ -94,48 +94,65 @@ const managedGraphRequestSchema = getEvidenceInputContract(
|
||||
).frameSchema;
|
||||
|
||||
/** Managed-code projection result containing a validated application graph. */
|
||||
export const managedApplicationGraphResultSchema = z.strictObject({
|
||||
projection_id: prefixedDigestSchema("magp"),
|
||||
root_artifact_sha256: digestSchema,
|
||||
source_evidence: z.strictObject({
|
||||
managed_artifact_evidence_id: evidenceIdSchema.nullable(),
|
||||
managed_members_evidence_id: evidenceIdSchema.nullable(),
|
||||
managed_native_boundaries_evidence_id: evidenceIdSchema.nullable(),
|
||||
}),
|
||||
summary: z.strictObject({
|
||||
graph_nodes: z.int().min(0),
|
||||
graph_edges: z.int().min(0),
|
||||
assemblies: z.int().min(0),
|
||||
modules: z.int().min(0),
|
||||
types: z.int().min(0),
|
||||
methods: z.int().min(0),
|
||||
fields: z.int().min(0),
|
||||
pinvoke_imports: z.int().min(0),
|
||||
native_implementations: z.int().min(0),
|
||||
// Graphs projected before call and field-access edges existed omit these
|
||||
// counters; null means those relations were not projected, not zero.
|
||||
calls: z
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe("calls edges; null for graphs projected before calls edges"),
|
||||
field_accesses: z
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe(
|
||||
"accesses edges; null for graphs projected before accesses edges",
|
||||
),
|
||||
}),
|
||||
graph: javascriptApplicationGraphSchema,
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete-within-inputs", "partial"]),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(3),
|
||||
limitations: z.array(textSchema),
|
||||
});
|
||||
export const managedApplicationGraphResultSchema = z
|
||||
.strictObject({
|
||||
projection_id: prefixedDigestSchema("magp"),
|
||||
root_artifact_sha256: digestSchema,
|
||||
source_evidence: z.strictObject({
|
||||
managed_artifact_evidence_id: evidenceIdSchema.nullable(),
|
||||
managed_members_evidence_id: evidenceIdSchema.nullable(),
|
||||
managed_native_boundaries_evidence_id: evidenceIdSchema.nullable(),
|
||||
}),
|
||||
summary: z.strictObject({
|
||||
graph_nodes: z.int().min(0),
|
||||
graph_edges: z.int().min(0),
|
||||
assemblies: z.int().min(0),
|
||||
modules: z.int().min(0),
|
||||
types: z.int().min(0),
|
||||
methods: z.int().min(0),
|
||||
fields: z.int().min(0),
|
||||
pinvoke_imports: z.int().min(0),
|
||||
native_implementations: z.int().min(0),
|
||||
// Graphs projected before call and field-access edges existed omit these
|
||||
// counters; null means those relations were not projected, not zero.
|
||||
calls: z
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe("calls edges; null for graphs projected before calls edges"),
|
||||
field_accesses: z
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe(
|
||||
"accesses edges; null for graphs projected before accesses edges",
|
||||
),
|
||||
}),
|
||||
graph: javascriptApplicationGraphSchema,
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete-within-inputs", "partial"]),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(1).max(3),
|
||||
limitations: z.array(textSchema),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
const [rootId] = result.graph.root_node_ids;
|
||||
const root = result.graph.nodes.find(({ node_id }) => node_id === rootId);
|
||||
if (
|
||||
result.graph.root_node_ids.length !== 1 ||
|
||||
root?.kind !== "artifact" ||
|
||||
root.identity.strategy !== "content-digest" ||
|
||||
root.identity.sha256 !== result.root_artifact_sha256
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["root_artifact_sha256"],
|
||||
message:
|
||||
"Managed graph root must identify the declared root artifact SHA-256.",
|
||||
});
|
||||
});
|
||||
|
||||
export type ManagedApplicationGraphResult = z.infer<
|
||||
typeof managedApplicationGraphResultSchema
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { constants } from "node:fs";
|
||||
import { constants as osConstants } from "node:os";
|
||||
import type { BigIntStats, Stats } from "node:fs";
|
||||
import { lstat, open, stat, type FileHandle } from "node:fs/promises";
|
||||
import { lstat, open, realpath, stat, type FileHandle } from "node:fs/promises";
|
||||
|
||||
import { settleChangeTimeBaseline } from "./ChangeTimeBaseline.js";
|
||||
import { OwnedFileHandle } from "./OwnedFileHandle.js";
|
||||
@@ -34,6 +34,41 @@ export class NonRegularFileReadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** O_NOFOLLOW refused a selected leaf observed as a symbolic link. */
|
||||
export class RejectedRegularFileSymlink extends Error {
|
||||
readonly code = "ELOOP";
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly resolvedPath: string | undefined,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(`Selected path is a symbolic link and is not admitted: ${path}`, {
|
||||
cause,
|
||||
});
|
||||
this.name = "RejectedRegularFileSymlink";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve recovery only when the selected path currently names a regular file. */
|
||||
export const resolvedRegularFile = async (
|
||||
path: string,
|
||||
fileSystem: {
|
||||
readonly realpath?: (path: string) => Promise<string>;
|
||||
readonly lstat: (
|
||||
path: string,
|
||||
) => Promise<{ isFile(): boolean; isSymbolicLink(): boolean }>;
|
||||
} = { realpath, lstat },
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const resolved = await fileSystem.realpath?.(path);
|
||||
if (resolved === undefined) return undefined;
|
||||
const target = await fileSystem.lstat(resolved);
|
||||
return target.isFile() && !target.isSymbolicLink() ? resolved : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Admission failed and the opened descriptor could not be confirmed closed. */
|
||||
export class RegularFileAdmissionFailure extends Error {
|
||||
override readonly cause: unknown;
|
||||
@@ -124,6 +159,20 @@ export const openRegularFile = async (
|
||||
(constants.O_NOCTTY ?? 0) |
|
||||
(options.symlinks === "reject" ? (constants.O_NOFOLLOW ?? 0) : 0),
|
||||
).catch(async (cause: unknown) => {
|
||||
if (
|
||||
options.symlinks === "reject" &&
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ELOOP"
|
||||
) {
|
||||
const named = await lstat(path).catch(() => undefined);
|
||||
if (named?.isSymbolicLink())
|
||||
throw new RejectedRegularFileSymlink(
|
||||
path,
|
||||
await resolvedRegularFile(path),
|
||||
cause,
|
||||
);
|
||||
}
|
||||
const nonRegular = await nonRegularOpenFailure(
|
||||
path,
|
||||
options.symlinks,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "./GpuSource.js";
|
||||
import { GpuInventory } from "./GpuInventory.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { selectedFileTargetError } from "../artifacts/RegularFileErrorProjection.js";
|
||||
import {
|
||||
isSelectedRegularFileError,
|
||||
sameRegularFileState,
|
||||
@@ -169,7 +170,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort {
|
||||
{ cause: primary },
|
||||
);
|
||||
else if (isSelectedRegularFileError(primary))
|
||||
error = new BinaryTargetError(input.path, primary.message, { cause });
|
||||
error = selectedFileTargetError(input.path, primary, cause);
|
||||
else if (
|
||||
origin === "owned-or-classified" &&
|
||||
primary instanceof ArtifactReaderFailure &&
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "./ModelFormat.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { selectedFileTargetError } from "../artifacts/RegularFileErrorProjection.js";
|
||||
import {
|
||||
isSelectedRegularFileError,
|
||||
sameRegularFileState,
|
||||
@@ -178,7 +179,7 @@ export class ModelArtifactProvider implements ModelArtifactPort {
|
||||
{ cause: primary },
|
||||
);
|
||||
else if (isSelectedRegularFileError(primary))
|
||||
error = new BinaryTargetError(input.path, primary.message, { cause });
|
||||
error = selectedFileTargetError(input.path, primary, cause);
|
||||
else
|
||||
error = new ProviderAdapterError(this.identity.id, OPERATION, {
|
||||
cause,
|
||||
|
||||
@@ -437,6 +437,8 @@ const expectResourceSchemaBranches = async (client: Client): Promise<void> => {
|
||||
const validateAdvertised = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
}).compile(advertised as AnySchema);
|
||||
const resourceResult = structuredClone(validResult) as Record<
|
||||
string,
|
||||
|
||||
@@ -771,15 +771,16 @@ describe("application workflow CLI parity", () => {
|
||||
expect(traced).toMatchObject({
|
||||
operation: "trace_application_feature",
|
||||
predicate_type: "rea.application-feature-trace",
|
||||
subject: JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application.subject,
|
||||
normalized_result: {
|
||||
seed: { kind: "module", value: "renderer.js", match: "exact" },
|
||||
summary: {
|
||||
matched_seeds: 1,
|
||||
traced_nodes: 1,
|
||||
traced_edges: 0,
|
||||
traced_nodes: 2,
|
||||
traced_edges: 1,
|
||||
terminal_paths: 0,
|
||||
native_handoffs: 0,
|
||||
observed_facts: 1,
|
||||
observed_facts: 3,
|
||||
inferred_facts: 0,
|
||||
unknown_facts: 0,
|
||||
unavailable_facts: 0,
|
||||
@@ -809,7 +810,7 @@ describe("application workflow CLI parity", () => {
|
||||
predicate_type: "rea.application-version-comparison",
|
||||
normalized_result: {
|
||||
summary: {
|
||||
unchanged: 0,
|
||||
unchanged: 1,
|
||||
added: 2,
|
||||
removed: 0,
|
||||
changed: 1,
|
||||
@@ -852,7 +853,7 @@ describe("application workflow CLI parity", () => {
|
||||
reference_inventory_state: "complete",
|
||||
application_graph_status: "complete",
|
||||
retained_source_files: 1,
|
||||
retained_application_nodes: 1,
|
||||
retained_application_nodes: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -890,6 +891,7 @@ describe("application workflow CLI parity", () => {
|
||||
expect(traced).toMatchObject({
|
||||
operation: "trace_javascript_semantics",
|
||||
predicate_type: "rea.javascript-semantic-trace",
|
||||
subject: analyzed.value.subject,
|
||||
normalized_result: {
|
||||
source_evidence_id: analyzed.value.evidence_id,
|
||||
source_graph_id: result.semantic_graph.graph_id,
|
||||
@@ -936,6 +938,7 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
|
||||
const cliEvidence = parseEvidence(
|
||||
await runCliWithRequestFile(root, "trace-javascript-semantics", input),
|
||||
);
|
||||
expect(cliEvidence.subject).toEqual(application.subject);
|
||||
const mcpResponse = await client.callTool({
|
||||
name: "trace_javascript_semantics",
|
||||
arguments: input,
|
||||
@@ -1920,6 +1923,11 @@ const runCliWithRequestFile = async (
|
||||
/** The CLI keeps complete trace JSON; the shared view owner selects from the saved file. */
|
||||
const assertTraceFileView = async (root: string, traced: unknown) => {
|
||||
const parent = parseEvidence(traced);
|
||||
if (parent.subject === null) throw new Error("Expected trace subject");
|
||||
const artifact = {
|
||||
path: parent.subject.local_path,
|
||||
sha256: parent.subject.digest.sha256,
|
||||
};
|
||||
const tracePath = join(root, "trace.json");
|
||||
await writeFile(tracePath, JSON.stringify(parent));
|
||||
const source = { kind: "evidence-file", path: tracePath };
|
||||
@@ -1934,7 +1942,8 @@ const assertTraceFileView = async (root: string, traced: unknown) => {
|
||||
kind: "summary",
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
parent_operation: "trace_application_feature",
|
||||
summary: { summary: { matched_seeds: 1, traced_nodes: 1 } },
|
||||
artifact,
|
||||
summary: { summary: { matched_seeds: 1, traced_nodes: 2 } },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
@@ -1949,8 +1958,9 @@ const assertTraceFileView = async (root: string, traced: unknown) => {
|
||||
).toMatchObject({
|
||||
normalized_result: {
|
||||
kind: "feature-trace",
|
||||
artifact,
|
||||
items: [{ seed: true, observation_count: 1 }],
|
||||
coverage: { total: 1, exhausted: true },
|
||||
coverage: { total: 2, exhausted: false },
|
||||
},
|
||||
});
|
||||
// Without a session ledger the CLI cannot retain a parent for a selected view.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"explains a refused file symlink through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-link-cli-mcp-");
|
||||
const target = join(root, "file.bin");
|
||||
const selected = join(root, "selected.bin");
|
||||
await writeFile(target, "fixture");
|
||||
await symlink(target, selected);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
for (const [command, tool] of [
|
||||
["inspect-model-artifact", "inspect_model_artifact"],
|
||||
["inspect-gpu-code-objects", "inspect_gpu_code_objects"],
|
||||
] as const) {
|
||||
const result = await cli.run({
|
||||
arguments: [command, selected, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const mcp = await call(tool, { path: selected });
|
||||
const mcpError = parseMcpToolError(mcp).error;
|
||||
for (const error of [result.json as typeof mcpError, mcpError]) {
|
||||
expect(error.message).toContain("symbolic link");
|
||||
expect(error.message).toContain(selected);
|
||||
expect(error.remediation.action).toContain(target);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -173,7 +173,7 @@ const verifyPortableCli = async (
|
||||
);
|
||||
const original = await readFile(bundlePath);
|
||||
await writeFile(inputPath, JSON.stringify({ ...input, detail: "complete" }));
|
||||
const cliFull = await cli.run({
|
||||
const cliFull = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"match-native-functions",
|
||||
inputPath,
|
||||
@@ -187,7 +187,7 @@ const verifyPortableCli = async (
|
||||
expect(cliFull.exitCode, cliFull.stderr).toBe(0);
|
||||
expect(parseEvidence(cliFull.json).evidence_id).toBe(evidenceId);
|
||||
await writeFile(inputPath, JSON.stringify(input));
|
||||
const cliSummary = await cli.run({
|
||||
const cliSummary = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"match-native-functions",
|
||||
inputPath,
|
||||
@@ -222,7 +222,7 @@ const verifyPortableCli = async (
|
||||
expect(ids.has(unrelated.evidence_id)).toBe(false);
|
||||
expect(ids.has(evidenceId)).toBe(true);
|
||||
expect(Buffer.compare(await readFile(bundlePath), original)).toBe(0);
|
||||
const incomplete = await cli.run({
|
||||
const incomplete = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"match-native-functions",
|
||||
inputPath,
|
||||
@@ -583,7 +583,7 @@ cliTest(
|
||||
detail: "complete",
|
||||
};
|
||||
await writeFile(requestPath, JSON.stringify(inlineRequest));
|
||||
const inline = await cli.run({
|
||||
const inline = await runFileCli(cli, {
|
||||
arguments: ["match-native-functions", requestPath, "--json"],
|
||||
});
|
||||
await writeFile(
|
||||
@@ -591,7 +591,7 @@ cliTest(
|
||||
JSON.stringify(createEvidenceBundle([wrongRole, right])),
|
||||
);
|
||||
await writeFile(requestPath, JSON.stringify(portableRequest));
|
||||
const portable = await cli.run({
|
||||
const portable = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"match-native-functions",
|
||||
requestPath,
|
||||
|
||||
@@ -119,7 +119,7 @@ cliTest(
|
||||
);
|
||||
await writeFile(path, JSON.stringify(createEvidenceBundle([parent])));
|
||||
const inspect = async (view: JsonValue) => {
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
@@ -177,7 +177,7 @@ cliTest(
|
||||
]);
|
||||
const original = JSON.stringify(bundle);
|
||||
await writeFile(path, original);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify(request),
|
||||
@@ -280,7 +280,7 @@ cliTest(
|
||||
view: { kind: "native", facet: "callees", offset: 0, limit: 1 },
|
||||
}),
|
||||
);
|
||||
const response = await cli.run({
|
||||
const response = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", request, "--json"],
|
||||
});
|
||||
expect(response.exitCode).toBe(0);
|
||||
@@ -319,7 +319,7 @@ cliTest(
|
||||
const expected = await inspectAnalysisView(input);
|
||||
if (!expected.ok) throw expected.error;
|
||||
await writeFile(request, JSON.stringify(input));
|
||||
const response = await cli.run({
|
||||
const response = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", request, "--json"],
|
||||
});
|
||||
expect(response.exitCode).toBe(0);
|
||||
@@ -360,7 +360,7 @@ cliTest(
|
||||
view: { kind: "api-surface", include_query_parameter_names: true },
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
@@ -390,7 +390,7 @@ cliTest(
|
||||
await writer.add("main.js", new TextReader("console.log('portable');\n"));
|
||||
await writeFile(archive, await writer.close());
|
||||
const bundlePath = join(root, "inspection-bundle.json");
|
||||
const inspected = await cli.run({
|
||||
const inspected = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-artifact",
|
||||
archive,
|
||||
@@ -440,7 +440,7 @@ cliTest(
|
||||
view: { kind: "page", collection: "sections", offset: 0, limit: 20 },
|
||||
};
|
||||
await writeFile(unsupportedRequest, JSON.stringify(unsupportedInput));
|
||||
const unsupported = await cli.run({
|
||||
const unsupported = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", unsupportedRequest, "--json"],
|
||||
});
|
||||
expect(unsupported.exitCode).toBe(1);
|
||||
@@ -468,7 +468,7 @@ cliTest(
|
||||
},
|
||||
}),
|
||||
);
|
||||
const projected = await cli.run({
|
||||
const projected = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", request, "--json"],
|
||||
});
|
||||
expect(projected.exitCode).toBe(0);
|
||||
@@ -497,7 +497,7 @@ cliTest(
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("main.js", new TextReader("console.log(1);\n"));
|
||||
await writeFile(archive, await writer.close());
|
||||
const inspected = await cli.run({
|
||||
const inspected = await runFileCli(cli, {
|
||||
arguments: ["inspect-artifact", archive, "--json"],
|
||||
});
|
||||
expect(inspected.exitCode).toBe(0);
|
||||
@@ -599,7 +599,7 @@ cliTest(
|
||||
],
|
||||
});
|
||||
await writeFile(path, JSON.stringify(request));
|
||||
const refused = await cli.run({
|
||||
const refused = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(refused.exitCode).toBe(1);
|
||||
@@ -648,7 +648,7 @@ cliTest(
|
||||
view: { kind: "api-surface" },
|
||||
}),
|
||||
);
|
||||
const refused = await cli.run({
|
||||
const refused = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
environment,
|
||||
});
|
||||
@@ -679,7 +679,7 @@ cliTest(
|
||||
view: { kind: "api-surface" },
|
||||
}),
|
||||
);
|
||||
const response = await cli.run({
|
||||
const response = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
environment,
|
||||
});
|
||||
@@ -716,7 +716,7 @@ cliTest(
|
||||
detail: "summary" as const,
|
||||
filters: { evidence_id: parent.evidence_id },
|
||||
};
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-evidence-bundle",
|
||||
JSON.stringify({ path, ...options }),
|
||||
@@ -729,7 +729,7 @@ cliTest(
|
||||
if (!expected.ok) throw expected.error;
|
||||
expect(result.json).toEqual(expected.value);
|
||||
// The same shared owner selects the complete record from the portable file.
|
||||
const records = await cli.run({
|
||||
const records = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-evidence-bundle",
|
||||
JSON.stringify({ path, ...options, detail: "records", limit: 1 }),
|
||||
@@ -746,7 +746,7 @@ cliTest(
|
||||
records: [parent],
|
||||
missing_evidence_links: [],
|
||||
});
|
||||
const complete = await cli.run({
|
||||
const complete = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-evidence-bundle",
|
||||
JSON.stringify({ path }),
|
||||
@@ -781,7 +781,7 @@ cliTest(
|
||||
detail: "links",
|
||||
traversal: "retained-closure",
|
||||
} as const;
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-evidence-bundle",
|
||||
JSON.stringify({ path, ...options }),
|
||||
@@ -850,7 +850,7 @@ cliTest(
|
||||
};
|
||||
|
||||
{
|
||||
const json = await cli.run({
|
||||
const json = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
input,
|
||||
@@ -869,7 +869,7 @@ cliTest(
|
||||
).toHaveLength(rowCount);
|
||||
}
|
||||
|
||||
const markdown = await cli.run({
|
||||
const markdown = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
input,
|
||||
@@ -908,7 +908,7 @@ cliTest(
|
||||
const input = join(root, "view.json");
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(input, JSON.stringify(request));
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", input, "--json"],
|
||||
environment: {
|
||||
HOME: root,
|
||||
@@ -934,7 +934,7 @@ cliTest(
|
||||
const root = await createTestTempDirectory("rea-empty-layout-view-cli-");
|
||||
const empty = analysisViewLayoutEvidence(binaryLayoutFixture());
|
||||
for (const collection of ["sections", "symbols"] as const) {
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
@@ -965,7 +965,7 @@ cliTest(
|
||||
expect(result.json).not.toHaveProperty("details.issues.0.minimum");
|
||||
expect(result.json).not.toHaveProperty("details.issues.0.maximum");
|
||||
}
|
||||
const populated = await cli.run({
|
||||
const populated = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
@@ -1022,7 +1022,7 @@ cliTest(
|
||||
view: { kind: "native", facet: "pseudocode", offset: 0, limit: 12 },
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", input, "--json"],
|
||||
environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root },
|
||||
});
|
||||
@@ -1065,7 +1065,7 @@ cliTest(
|
||||
{ kind: "summary" },
|
||||
{ kind: "evm", facet: "bytecode_hex", offset: 0, limit: 4 },
|
||||
]) {
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
@@ -1087,7 +1087,7 @@ cliTest(
|
||||
|
||||
cliTest("rejects malformed JSON before projection", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-analysis-view-cli-bad-");
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", "{", "--json"],
|
||||
environment: {
|
||||
HOME: root,
|
||||
@@ -1107,7 +1107,7 @@ cliTest("rejects a request that omits source", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-analysis-view-cli-missing-");
|
||||
const input = join(root, "missing-source.json");
|
||||
await writeFile(input, JSON.stringify({ view: { kind: "summary" } }));
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", input, "--json"],
|
||||
environment: {
|
||||
HOME: root,
|
||||
@@ -1142,7 +1142,7 @@ for (const [view, reason] of [
|
||||
view,
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", input, "--json"],
|
||||
environment: {
|
||||
HOME: root,
|
||||
@@ -1195,7 +1195,7 @@ cliTest(
|
||||
XDG_CACHE_HOME: root,
|
||||
};
|
||||
await writeFile(capturePath, bytes);
|
||||
const capture = await cli.run({
|
||||
const capture = await runFileCli(cli, {
|
||||
arguments: ["inspect-web-network-capture", capturePath, "har", "--json"],
|
||||
environment,
|
||||
});
|
||||
@@ -1212,7 +1212,7 @@ cliTest(
|
||||
view: { kind: "api-surface" },
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", input, "--json"],
|
||||
environment,
|
||||
});
|
||||
@@ -1266,7 +1266,7 @@ cliTest(
|
||||
view,
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", requestPath, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout).toBe(0);
|
||||
@@ -1312,7 +1312,7 @@ cliTest(
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout).toBe(0);
|
||||
@@ -1348,7 +1348,7 @@ cliTest(
|
||||
view: { kind: "environment-manifest" },
|
||||
}),
|
||||
);
|
||||
const refused = await cli.run({
|
||||
const refused = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(refused.exitCode).toBe(1);
|
||||
@@ -1383,7 +1383,7 @@ cliTest(
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout).toBe(0);
|
||||
@@ -1457,7 +1457,7 @@ cliTest.for([
|
||||
view: { kind: "environment-state" },
|
||||
});
|
||||
await writeFile(path, request);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout).toBe(0);
|
||||
@@ -1485,7 +1485,7 @@ cliTest(
|
||||
const root = await createTestTempDirectory("rea-manifest-chain-cli-");
|
||||
const capturePath = join(root, "capture.har");
|
||||
await writeFile(capturePath, JSON.stringify(environmentManifestHar()));
|
||||
const capture = await cli.run({
|
||||
const capture = await runFileCli(cli, {
|
||||
arguments: ["inspect-web-network-capture", capturePath, "har", "--json"],
|
||||
});
|
||||
expect(capture.exitCode, capture.stdout).toBe(0);
|
||||
@@ -1498,7 +1498,7 @@ cliTest(
|
||||
view: { kind: "environment-manifest" },
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-analysis-view", input, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout).toBe(0);
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
} from "../../../src/domain/managed/managedArtifact.js";
|
||||
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||
import {
|
||||
cliTest,
|
||||
runFileCli,
|
||||
type TestCli,
|
||||
} from "../../support/cli/cliFixture.js";
|
||||
|
||||
const memberFixture = (): Buffer =>
|
||||
buildManagedPeFixture({
|
||||
@@ -82,7 +86,7 @@ const verifyCliSelection = async ({
|
||||
parent,
|
||||
report,
|
||||
}: CliMemberViewContext) => {
|
||||
const selected = await cli.run({
|
||||
const selected = await runFileCli(cli, {
|
||||
cwd,
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
@@ -150,7 +154,7 @@ const verifyCliFullPages = async (
|
||||
expect(reportedCounts[collection]).toBe(rows.length);
|
||||
const paged: unknown[] = [];
|
||||
for (let offset: number | null = 0; offset !== null;) {
|
||||
const response = await cli.run({
|
||||
const response = await runFileCli(cli, {
|
||||
cwd,
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
@@ -349,6 +353,8 @@ cliTest(
|
||||
parent,
|
||||
report,
|
||||
};
|
||||
// The producer has exited and its target is gone. Reuse only the reader
|
||||
// process across page selections; every command still loads the bundle.
|
||||
await verifyCliFullPages(pageContext, summary.summary.reported_counts);
|
||||
await verifyCliSelection(pageContext);
|
||||
expect(await readFile(bundlePath, "utf8")).toBe(originalBytes);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { projectAndroidApplicationEvidence } from "../../../../src/application/android/AndroidApplicationService.js";
|
||||
import { androidApplicationProjectionResultSchema } from "../../../../src/domain/android/androidApplication.js";
|
||||
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
|
||||
describe("Android application projection", () => {
|
||||
it("projects deterministic APK components and explicit bridge hypotheses", async () => {
|
||||
@@ -172,6 +173,23 @@ describe("Android application projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the selected inventory subject through application projection", async () => {
|
||||
const root = await createTestTempDirectory("rea-android-subject-");
|
||||
const path = join(root, "Fixture.apk");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("AndroidManifest.xml", new TextReader("manifest"));
|
||||
await writeFile(path, await writer.close());
|
||||
const inventory = await inventoryApplicationPackage(path);
|
||||
const selected = parseEvidence({
|
||||
...inventory,
|
||||
subject: { ...inventory.subject, name: "Selected application" },
|
||||
});
|
||||
const result = projectAndroidApplicationEvidence({
|
||||
inventory_evidence: [selected],
|
||||
});
|
||||
expect(requireSuccessfulProjection(result).subject).toEqual(selected.subject);
|
||||
});
|
||||
|
||||
it("projects APK v1 metadata candidates at any META-INF depth", async () => {
|
||||
const root = await createTestTempDirectory("rea-android-v1-metadata-");
|
||||
const path = join(root, "NestedSigning.apk");
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { GpuCodeObjectsProvider } from "../../../src/gpu/GpuCodeObjectsProvider.js";
|
||||
import { ModelArtifactProvider } from "../../../src/model/ModelArtifactProvider.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"explains rejected links and suggests only resolved regular-file targets",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-rejected-links-");
|
||||
const file = join(root, "file.bin");
|
||||
const directory = join(root, "directory");
|
||||
await writeFile(file, "fixture");
|
||||
await mkdir(directory);
|
||||
const links = [
|
||||
{ path: join(root, "file-link"), target: file, kind: "file" },
|
||||
{
|
||||
path: join(root, "selected-directory"),
|
||||
target: directory,
|
||||
kind: "directory",
|
||||
},
|
||||
{
|
||||
path: join(root, "selected-missing"),
|
||||
target: join(root, "missing"),
|
||||
kind: "missing",
|
||||
},
|
||||
] as const;
|
||||
for (const link of links) await symlink(link.target, link.path);
|
||||
const model = new ModelArtifactProvider();
|
||||
const gpu = new GpuCodeObjectsProvider();
|
||||
onTestFinished(async () => {
|
||||
await model.close();
|
||||
await gpu.close();
|
||||
});
|
||||
for (const link of links) {
|
||||
const results = [
|
||||
await model.inspect({ path: link.path, hash: "header" }),
|
||||
await gpu.inspect({ path: link.path, analysis: "inventory" }),
|
||||
];
|
||||
for (const result of results) {
|
||||
if (result.ok) throw new Error("symbolic link was admitted");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.message, link.kind).toMatch(/symbolic link/iu);
|
||||
expect(projected.message, link.kind).toContain(link.path);
|
||||
if (link.kind === "file")
|
||||
expect(projected.remediation.action).toContain(file);
|
||||
else expect(projected.remediation.action).not.toContain(link.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps a traversal loop distinct when the selected leaf cannot be inspected",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-loop-links-");
|
||||
const loop = join(root, "loop");
|
||||
await symlink(loop, loop);
|
||||
const path = join(loop, "selected");
|
||||
const provider = new ModelArtifactProvider();
|
||||
onTestFinished(() => provider.close());
|
||||
const result = await provider.inspect({ path, hash: "header" });
|
||||
if (result.ok) throw new Error("traversal loop was admitted");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.message).toContain("ELOOP");
|
||||
expect(projected.remediation.action).not.toContain(
|
||||
"resolved regular file path",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||
@@ -45,6 +47,40 @@ cliTest(
|
||||
});
|
||||
expect(cliResponse.exitCode, cliResponse.stderr).toBe(0);
|
||||
const cliEvidence = parseEvidence(cliResponse.json);
|
||||
const parsed = javascriptApplicationAnalysisResultSchema.parse(
|
||||
cliEvidence.normalized_result,
|
||||
);
|
||||
const { graph_id: _id, ...semantic } = parsed.semantic_graph;
|
||||
const foreignSha = "f".repeat(64);
|
||||
const forged = createEvidence(
|
||||
{ path: parsed.input_path, sha256: foreignSha, format: parsed.format },
|
||||
cliEvidence.provider,
|
||||
{
|
||||
operation: cliEvidence.operation,
|
||||
predicateType: cliEvidence.predicate_type,
|
||||
parameters: cliEvidence.parameters,
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
result: {
|
||||
...parsed,
|
||||
root_artifact_sha256: foreignSha,
|
||||
semantic_graph: createJavaScriptSemanticGraph({
|
||||
...semantic,
|
||||
root_artifact_sha256: foreignSha,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(parseApplicationGraphEvidence(forged)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["normalized_result", "root_artifact_sha256"],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
for (const command of ["analyze", "analyze-javascript-application"]) {
|
||||
const relativeResponse = await cli.run({
|
||||
|
||||
@@ -413,7 +413,12 @@ const assertSuccessfulTraceParity = async (
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("Semantic trace SDK output schema is missing");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const ajv = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
});
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
expect(ajv.validate(outputSchema, mcpResponse.structuredContent)).toBe(true);
|
||||
|
||||
@@ -163,7 +163,21 @@ async function runInlineWorkflowScenarios(
|
||||
expect(tracedInline.structuredContent).toMatchObject({
|
||||
normalized_result: {
|
||||
seed: { kind: "module", value: "renderer.js", match: "exact" },
|
||||
summary: { matched_seeds: 1, traced_nodes: 1, unknown_facts: 0 },
|
||||
summary: {
|
||||
matched_seeds: 1,
|
||||
traced_nodes: 2,
|
||||
traced_edges: 1,
|
||||
unknown_facts: 0,
|
||||
},
|
||||
graph: {
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "artifact" }),
|
||||
expect.objectContaining({ kind: "javascript-asset" }),
|
||||
]),
|
||||
edges: expect.arrayContaining([
|
||||
expect.objectContaining({ relation: "contains" }),
|
||||
]),
|
||||
},
|
||||
coverage: {
|
||||
status: "complete-within-source",
|
||||
source_graph_status: "complete",
|
||||
|
||||
@@ -215,6 +215,8 @@ describe("application workflow MCP parity", () => {
|
||||
const validate = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
}).compile(
|
||||
z.record(z.string(), z.unknown()).parse(advertised.outputSchema),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { seedWording } from "../../../src/domain/desktop/appExport.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
BUILD_REQUEST as REQUEST,
|
||||
BUILD_TASK as TASK,
|
||||
@@ -140,6 +142,7 @@ it("captures the app inside the computer, acknowledges checkpoints, drafts the t
|
||||
agentenv: string[];
|
||||
}[];
|
||||
expect(calls.map(({ argv }) => argv[0])).toEqual([
|
||||
"export-desktop-application",
|
||||
"capture-desktop-application",
|
||||
"describe-desktop-application-task",
|
||||
"export-desktop-application",
|
||||
@@ -149,7 +152,7 @@ it("captures the app inside the computer, acknowledges checkpoints, drafts the t
|
||||
agentenv: [],
|
||||
apt_config: join(rea.home, ".rea-build/apt/apt.conf"),
|
||||
});
|
||||
expect(calls[0]?.argv).toEqual(
|
||||
expect(calls[1]?.argv).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--desktop-entry",
|
||||
"org.example.notes",
|
||||
@@ -157,7 +160,7 @@ it("captures the app inside the computer, acknowledges checkpoints, drafts the t
|
||||
join(rea.home, ".rea-build"),
|
||||
]),
|
||||
);
|
||||
expect(calls.slice(1).map(({ argv }) => argv)).toEqual([
|
||||
expect(calls.slice(2).map(({ argv }) => argv)).toEqual([
|
||||
expect.arrayContaining([
|
||||
"--seed-checkpoint",
|
||||
"seed",
|
||||
@@ -169,11 +172,63 @@ it("captures the app inside the computer, acknowledges checkpoints, drafts the t
|
||||
"seed",
|
||||
"--end-checkpoint",
|
||||
"end",
|
||||
// The agent made the seed in the computer, so the README need not keep it local.
|
||||
"--synthetic-seed",
|
||||
]),
|
||||
]);
|
||||
expect(
|
||||
parseEvidence(JSON.parse(await readFile(outputs.export, "utf8")))
|
||||
.parameters,
|
||||
).toMatchObject({ synthetic_seed: true });
|
||||
expect(await readFile(join(directory, "README.md"), "utf8")).toContain(
|
||||
seedWording({
|
||||
name: exported.environment,
|
||||
seedCheckpoint: "seed",
|
||||
syntheticSeed: true,
|
||||
}).notice,
|
||||
);
|
||||
expectNoToken(computer, [started, seed, stopped, exported]);
|
||||
});
|
||||
|
||||
it("refuses an older build runtime before capturing the app", async () => {
|
||||
const { rea, client } = await fakes({ env: { FAKE_REA_OLD_EXPORT: "1" } });
|
||||
const id = await startBuildComputer(client, REQUEST);
|
||||
const { error } = parseMcpToolError(
|
||||
await client.callTool({
|
||||
name: "start_build_capture",
|
||||
arguments: { computer_id: id },
|
||||
}),
|
||||
);
|
||||
expect(error.message).toContain("does not support synthetic seeds");
|
||||
expect(JSON.stringify(error)).toContain("pack:build-computer-runtime");
|
||||
expect(JSON.stringify(error)).toContain("restart the REA MCP server");
|
||||
const calls = (await rea.notes("calls.jsonl")) as { argv: string[] }[];
|
||||
expect(calls.map(({ argv }) => argv[0])).not.toContain(
|
||||
"capture-desktop-application",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unsuccessful runtime schema command without diagnosing its cause", async () => {
|
||||
const { rea, client } = await fakes({
|
||||
env: { FAKE_REA_SCHEMA_FAILURE: "1" },
|
||||
});
|
||||
const id = await startBuildComputer(client, REQUEST);
|
||||
const { error } = parseMcpToolError(
|
||||
await client.callTool({
|
||||
name: "start_build_capture",
|
||||
arguments: { computer_id: id },
|
||||
}),
|
||||
);
|
||||
expect(error.message).toContain("exited 23");
|
||||
expect(error.message).toContain("runtime cannot load its export command");
|
||||
expect(error.message).not.toContain("does not support synthetic seeds");
|
||||
expect(JSON.stringify(error)).toContain("restart the REA MCP server");
|
||||
const calls = (await rea.notes("calls.jsonl")) as { argv: string[] }[];
|
||||
expect(calls.map(({ argv }) => argv[0])).not.toContain(
|
||||
"capture-desktop-application",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a task too large for one command argument before the export runs in the computer", async () => {
|
||||
const { rea, client } = await fakes();
|
||||
const id = await startBuildComputer(client, REQUEST);
|
||||
@@ -193,9 +248,12 @@ it("refuses a task too large for one command argument before the export runs in
|
||||
);
|
||||
expect(error.code).toBe("invalid_request");
|
||||
const calls = (await rea.notes("calls.jsonl")) as { argv: string[] }[];
|
||||
expect(calls.map(({ argv }) => argv[0])).not.toContain(
|
||||
"export-desktop-application",
|
||||
);
|
||||
expect(
|
||||
calls.some(
|
||||
({ argv }) =>
|
||||
argv[0] === "export-desktop-application" && !argv.includes("--schema"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses capture steps out of order and a capture of a bundle's computer", async () => {
|
||||
|
||||
@@ -91,7 +91,9 @@ it("returns a checkpoint taken in the computer when its reply was lost and the n
|
||||
});
|
||||
|
||||
it("finishes a stop whose draft reply was lost when stop is asked again, and repeats its result", async () => {
|
||||
const { rea, client } = await fakes({ dropReplies: ["run"] });
|
||||
const { rea, client } = await fakes({
|
||||
dropReplies: ["run:describe-desktop-application-task"],
|
||||
});
|
||||
const id = await startBuildComputer(client, BUILD_REQUEST);
|
||||
await captureSeedAndEnd(client, id);
|
||||
const stop = () => call(client, "stop_build_capture", { computer_id: id });
|
||||
@@ -250,7 +252,10 @@ const captureDebRequest = async (
|
||||
({ argv }) => argv,
|
||||
);
|
||||
const argv = (command: string) =>
|
||||
calls.find((candidate) => candidate[0] === command) ?? [];
|
||||
calls.find(
|
||||
(candidate) =>
|
||||
candidate[0] === command && !candidate.includes("--schema"),
|
||||
) ?? [];
|
||||
expect(argv("capture-desktop-application")).toEqual(
|
||||
expect.arrayContaining(["--package-file", "/opt/rea/package.deb"]),
|
||||
);
|
||||
|
||||
@@ -92,8 +92,8 @@ it.each([
|
||||
expect(new Set(initial.tools.map(({ name }) => name))).toEqual(
|
||||
new Set([...HOT_TOOL_NAMES, "discover_tools"]),
|
||||
);
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
for (const tool of initial.tools) {
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
expect(ajv.validateSchema(tool.inputSchema)).toBe(true);
|
||||
if (tool.outputSchema !== undefined)
|
||||
expect(ajv.validateSchema(tool.outputSchema as AnySchemaObject)).toBe(
|
||||
|
||||
@@ -143,7 +143,13 @@ it.each(["full", "compact"] as const)(
|
||||
expect(summary.evidence.normalized_result).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_operation: "trace_application_feature",
|
||||
artifact: null,
|
||||
artifact:
|
||||
parent.subject === null
|
||||
? null
|
||||
: {
|
||||
path: parent.subject.local_path,
|
||||
sha256: parent.subject.digest.sha256,
|
||||
},
|
||||
summary: {
|
||||
trace_id: full.trace_id,
|
||||
source_evidence_id: full.source_evidence_id,
|
||||
|
||||
@@ -35,7 +35,7 @@ const scenarios = [
|
||||
name: "trace_application_feature",
|
||||
input: JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
sourceIds: [application.evidence_id],
|
||||
facts: { summary: { matched_seeds: 1, traced_nodes: 1 } },
|
||||
facts: { summary: { matched_seeds: 1, traced_nodes: 2, traced_edges: 1 } },
|
||||
},
|
||||
{
|
||||
name: "trace_javascript_semantics",
|
||||
|
||||
@@ -321,6 +321,9 @@ itWithCaptureCapability(
|
||||
TMPDIR: missingTempRoot,
|
||||
TMP: missingTempRoot,
|
||||
TEMP: missingTempRoot,
|
||||
// Select caller-owned compilation so a shared Darwin helper cannot
|
||||
// bypass the preparation failure this case exercises.
|
||||
REA_DARWIN_MODULE_CACHE_PATH: join(missingTempRoot, "modules"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -332,7 +335,8 @@ itWithCaptureCapability(
|
||||
code: "process_capture_failed",
|
||||
details: { execution_failure: expect.stringContaining(missingTempRoot) },
|
||||
});
|
||||
// Darwin prepares its native inspector before acquiring capture resources.
|
||||
// With caller-owned compilation, Darwin fails preparing its inspector
|
||||
// before acquiring capture resources.
|
||||
// Other hosts reach the capture root allocation, whose failure is accounted.
|
||||
const cleanupState =
|
||||
process.platform === "darwin" ? "unverified" : "not_required";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { launchCommand } from "../../../src/domain/desktop/appExport.js";
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not import seeded Python modules while encoding a file URL",
|
||||
() => {
|
||||
const home = mkdtempSync(join(tmpdir(), "rea-url-home-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "urllib.py"),
|
||||
'open("seed-ran", "w").write("unexpected")\n',
|
||||
);
|
||||
const command = launchCommand(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.stdout.write(process.argv[1])",
|
||||
"file:///home/ada/Documents/report.txt",
|
||||
],
|
||||
"/home/ada",
|
||||
"/home/ada",
|
||||
);
|
||||
const result = spawnSync(command[0]!, command.slice(1), {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, HOME: home },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe(
|
||||
pathToFileURL(join(home, "Documents/report.txt")).href,
|
||||
);
|
||||
expect(existsSync(join(home, "seed-ran"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"launches captured file URLs under the runtime home with their encoded suffix, query, and fragment",
|
||||
() => {
|
||||
for (const prefix of ["file://", "file://localhost"]) {
|
||||
const captured = `${prefix}/home/ada/Documents/%41%20budget.svg?mode=edit#page`;
|
||||
const command = launchCommand(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.stdout.write(process.argv[1])",
|
||||
captured,
|
||||
],
|
||||
"/home/ada",
|
||||
"/home/ada",
|
||||
);
|
||||
const result = spawnSync(command[0]!, command.slice(1), {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, HOME: "/tmp/Run #测" },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe(
|
||||
`${prefix}/tmp/Run%20%23%E6%B5%8B/Documents/%41%20budget.svg?mode=edit#page`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -273,6 +273,85 @@ describe("managed application graph projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves only a managed graph's matching subject in feature traces", () => {
|
||||
const { artifactEvidence } = createManagedInteropEvidence();
|
||||
const selectedArtifact = parseEvidence({
|
||||
...artifactEvidence,
|
||||
subject: { ...artifactEvidence.subject, name: "selected managed image" },
|
||||
});
|
||||
const projected = projectManagedApplicationGraphEvidence({
|
||||
managed_artifact: selectedArtifact,
|
||||
});
|
||||
if (!projected.ok) throw projected.error;
|
||||
const parsed = parseEvidence(projected.value);
|
||||
expect(parsed.subject).toEqual(selectedArtifact.subject);
|
||||
const query = {
|
||||
seed: {
|
||||
kind: "string" as const,
|
||||
value: "MessageBoxW",
|
||||
match: "exact" as const,
|
||||
case_sensitive: true,
|
||||
},
|
||||
direction: "incoming" as const,
|
||||
};
|
||||
const trace = traceApplicationFeatureEvidence({
|
||||
application: parsed,
|
||||
...query,
|
||||
});
|
||||
if (!trace.ok) throw trace.error;
|
||||
expect(trace.value.evidence.subject).toEqual(selectedArtifact.subject);
|
||||
const foreign = createEvidence(
|
||||
{ path: "/other.exe", sha256: "f".repeat(64), format: "pe" },
|
||||
parsed.provider,
|
||||
{
|
||||
operation: parsed.operation,
|
||||
predicateType: parsed.predicate_type,
|
||||
parameters: parsed.parameters,
|
||||
result: parsed.normalized_result,
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: parsed.evidence_links,
|
||||
},
|
||||
);
|
||||
const foreignTrace = traceApplicationFeatureEvidence({
|
||||
application: foreign,
|
||||
...query,
|
||||
});
|
||||
if (!foreignTrace.ok) throw foreignTrace.error;
|
||||
expect(foreignTrace.value.evidence.subject).toBeNull();
|
||||
const contradictory = createEvidence(
|
||||
{ path: "/other.exe", sha256: "f".repeat(64), format: "pe" },
|
||||
parsed.provider,
|
||||
{
|
||||
operation: parsed.operation,
|
||||
predicateType: parsed.predicate_type,
|
||||
parameters: parsed.parameters,
|
||||
result: {
|
||||
...(parsed.normalized_result as Record<string, unknown>),
|
||||
root_artifact_sha256: "f".repeat(64),
|
||||
},
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: parsed.evidence_links,
|
||||
},
|
||||
);
|
||||
const contradictoryTrace = traceApplicationFeatureEvidence({
|
||||
application: contradictory,
|
||||
...query,
|
||||
});
|
||||
expect(contradictoryTrace).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["application", "normalized_result", "root_artifact_sha256"],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed application graph request", () => {
|
||||
it("accepts any nonempty set of inline sources and rejects duplicates", () => {
|
||||
const evidence = MANAGED_APPLICATION_GRAPH_EXAMPLE.managed_members;
|
||||
|
||||
Vendored
+2
@@ -21,6 +21,8 @@ export const advertisedMcpToolSchemas = async (
|
||||
ajv: AdvertisedSchemaAssertions = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
}),
|
||||
) => {
|
||||
const matches = (await client.listTools()).tools.filter(
|
||||
|
||||
Vendored
+9
@@ -53,6 +53,13 @@ const realCliOutputs = async (install: CapturedInstall | undefined) => {
|
||||
const task = join(work, "task.json");
|
||||
await writeFile(task, JSON.stringify(BUILD_TASK));
|
||||
const bundle = join(work, "bundle");
|
||||
const schema = await serveCli([
|
||||
"export-desktop-application",
|
||||
"--schema",
|
||||
"--json",
|
||||
]);
|
||||
expect(schema.exitCode, schema.stdout).toBe(0);
|
||||
await writeFile(join(work, "schema.json"), schema.stdout);
|
||||
const draft = await serveCli([
|
||||
"describe-desktop-application-task",
|
||||
capture,
|
||||
@@ -66,6 +73,7 @@ const realCliOutputs = async (install: CapturedInstall | undefined) => {
|
||||
"--task",
|
||||
task,
|
||||
...ENDS,
|
||||
"--synthetic-seed",
|
||||
...(install?.packageUrl === undefined
|
||||
? []
|
||||
: ["--package-url", install.packageUrl]),
|
||||
@@ -79,6 +87,7 @@ const realCliOutputs = async (install: CapturedInstall | undefined) => {
|
||||
draft: join(work, "draft.json"),
|
||||
export: join(work, "export.json"),
|
||||
bundle,
|
||||
schema: join(work, "schema.json"),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+8
-1
@@ -304,7 +304,14 @@ const execRoute = async (
|
||||
) => {
|
||||
const answer = await runExec(exec, token, argv);
|
||||
const { dropReplies } = exec;
|
||||
if (dropReplies === undefined || dropReplies[0] !== argv[4]) return answer;
|
||||
const requested = dropReplies?.[0];
|
||||
const [command, operation] = requested?.split(":") ?? [];
|
||||
if (
|
||||
command !== argv[4] ||
|
||||
(operation !== undefined && !argv[5]?.includes(`\"${operation}\"`))
|
||||
)
|
||||
return answer;
|
||||
if (dropReplies === undefined) return answer;
|
||||
dropReplies.shift();
|
||||
return LOST;
|
||||
};
|
||||
|
||||
+12
@@ -69,6 +69,16 @@ elif command == "describe-desktop-application-task":
|
||||
with open(os.environ["FAKE_REA_DRAFT"]) as handle:
|
||||
sys.stdout.write(handle.read())
|
||||
elif command == "export-desktop-application":
|
||||
if "--schema" in args:
|
||||
if os.environ.get("FAKE_REA_SCHEMA_FAILURE"):
|
||||
print("runtime cannot load its export command", file=sys.stderr)
|
||||
sys.exit(23)
|
||||
with open(os.environ["FAKE_REA_SCHEMA"]) as handle:
|
||||
schema = json.load(handle)
|
||||
if os.environ.get("FAKE_REA_OLD_EXPORT"):
|
||||
schema["options"]["properties"].pop("syntheticSeed", None)
|
||||
print(json.dumps(schema))
|
||||
sys.exit(0)
|
||||
with open(option(args, "--task")) as handle:
|
||||
note("tasks.jsonl", json.load(handle))
|
||||
exported = os.environ["FAKE_REA_EXPORT"]
|
||||
@@ -116,6 +126,7 @@ export interface FakeReaOutputs {
|
||||
readonly draft: string;
|
||||
readonly export: string;
|
||||
readonly bundle: string;
|
||||
readonly schema: string;
|
||||
}
|
||||
|
||||
export interface FakeBuildComputerRea {
|
||||
@@ -155,6 +166,7 @@ export const fakeBuildComputerRea = async (
|
||||
FAKE_REA_DRAFT: outputs.draft,
|
||||
FAKE_REA_EXPORT: outputs.export,
|
||||
FAKE_REA_BUNDLE: outputs.bundle,
|
||||
FAKE_REA_SCHEMA: outputs.schema,
|
||||
},
|
||||
notes: async (name) => {
|
||||
const path = join(notes, name);
|
||||
|
||||
+9
-3
@@ -8,7 +8,8 @@ import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { projectedExportReturnShapesSchema } from "../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
||||
import type { TestCli } from "../support/cli/cliFixture.js";
|
||||
import { runFileCli, type TestCli } from "../support/cli/cliFixture.js";
|
||||
import { createMcpSchemaValidation } from "./mcpSchemaValidation.js";
|
||||
|
||||
const exportedReturnFields = (value: unknown) => {
|
||||
const analysis = javascriptApplicationAnalysisResultSchema.parse(value);
|
||||
@@ -44,7 +45,7 @@ const analyzeReturnShapesThroughCli = async (
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-return-shapes-");
|
||||
await writeFile(join(root, fileName), source);
|
||||
const cliResponse = await cli.run({
|
||||
const cliResponse = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"analyze-javascript-application",
|
||||
root,
|
||||
@@ -82,7 +83,11 @@ export const verifyJavaScriptReturnShapes = async (
|
||||
check,
|
||||
fileName,
|
||||
);
|
||||
const client = new Client({ name: "return-shapes-e2e", version: "1" });
|
||||
const validation = createMcpSchemaValidation();
|
||||
const client = new Client(
|
||||
{ name: "return-shapes-e2e", version: "1" },
|
||||
{ jsonSchemaValidator: validation },
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
@@ -112,5 +117,6 @@ export const verifyJavaScriptReturnShapes = async (
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
validation.clear();
|
||||
}
|
||||
};
|
||||
Vendored
+14
-48
@@ -7,35 +7,9 @@ import {
|
||||
StdioClientTransport,
|
||||
type StdioServerParameters,
|
||||
} from "@modelcontextprotocol/client/stdio";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
// validateSchema checks against the meta-schema without registering user $ids.
|
||||
const schemaValidator = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
});
|
||||
const validatedSchemas = new Set<string>();
|
||||
const compiledValidators = new Map<string, ValidateFunction<unknown>>();
|
||||
|
||||
const compileValidator = (schema: unknown): ValidateFunction<unknown> => {
|
||||
const schemaRecord = z.record(z.string(), z.unknown()).parse(schema);
|
||||
const serialized = JSON.stringify(schemaRecord);
|
||||
const cached = compiledValidators.get(serialized);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Keep a private Ajv registry per schema: distinct tool schemas can carry
|
||||
// identical $id values, while this cache only reuses exact schema content.
|
||||
const validate = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile<unknown>(schemaRecord);
|
||||
compiledValidators.set(serialized, validate);
|
||||
return validate;
|
||||
};
|
||||
import { createMcpSchemaValidation } from "./mcpSchemaValidation.js";
|
||||
|
||||
/** Default to native tools while preserving explicit caller configuration. */
|
||||
export const localToolsMcpEnvironment = (
|
||||
@@ -65,32 +39,20 @@ export async function connectLocalToolsMcp(
|
||||
env: localToolsMcpEnvironment(environment),
|
||||
stderr: "pipe",
|
||||
});
|
||||
const validation = createMcpSchemaValidation();
|
||||
const client = new Client(
|
||||
{ name: "local-tools-e2e", version: "1" },
|
||||
jsonSchemaValidator === undefined ? undefined : { jsonSchemaValidator },
|
||||
{ jsonSchemaValidator: jsonSchemaValidator ?? validation },
|
||||
);
|
||||
registerCleanup(async () => {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
validation.clear();
|
||||
});
|
||||
await client.connect(transport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
for (const tool of tools)
|
||||
for (const [kind, schema] of [
|
||||
["inputSchema", tool.inputSchema],
|
||||
["outputSchema", tool.outputSchema],
|
||||
] as const) {
|
||||
if (schema === undefined) continue;
|
||||
const serialized = JSON.stringify(schema);
|
||||
if (validatedSchemas.has(serialized)) continue;
|
||||
const schemaRecord = z.record(z.string(), z.unknown()).parse(schema);
|
||||
const valid = schemaValidator.validateSchema(schemaRecord);
|
||||
expect(
|
||||
valid,
|
||||
`${tool.name}.${kind}: ${schemaValidator.errorsText(schemaValidator.errors)}`,
|
||||
).toBe(true);
|
||||
validatedSchemas.add(serialized);
|
||||
}
|
||||
// Catalog dialect/reference validity belongs to toolSchemaValidity and the
|
||||
// compact-profile catalog tests. SDK callTool validates each successful reply.
|
||||
const definitions = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
const call = async (
|
||||
name: string,
|
||||
@@ -98,7 +60,6 @@ export async function connectLocalToolsMcp(
|
||||
options?: Pick<CallToolRequestOptions, "signal" | "onprogress">,
|
||||
) => {
|
||||
const definition = definitions.get(name);
|
||||
if (definition !== undefined) compileValidator(definition.inputSchema);
|
||||
const response = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{
|
||||
@@ -106,14 +67,19 @@ export async function connectLocalToolsMcp(
|
||||
...(definition === undefined ? {} : { toolDefinition: definition }),
|
||||
},
|
||||
);
|
||||
// The SDK skips error payloads. A caller-supplied validator also retains
|
||||
// the fixture's independent advertised-schema assertion.
|
||||
if (
|
||||
(response.isError === true || jsonSchemaValidator !== undefined) &&
|
||||
response.structuredContent !== undefined &&
|
||||
definition?.outputSchema !== undefined
|
||||
) {
|
||||
const validate = compileValidator(definition.outputSchema);
|
||||
expect(
|
||||
validate(response.structuredContent),
|
||||
JSON.stringify(validate.errors),
|
||||
validation.assertions.validate(
|
||||
definition.outputSchema,
|
||||
response.structuredContent,
|
||||
),
|
||||
JSON.stringify(validation.assertions.errors),
|
||||
).toBe(true);
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import { test as base } from "vitest";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
|
||||
/** Client identity used during an in-memory MCP negotiation. */
|
||||
export interface McpClientIdentity {
|
||||
@@ -32,6 +33,7 @@ interface McpConnectionOwner {
|
||||
readonly client: Client;
|
||||
readonly server: McpServer;
|
||||
readonly handshake: AbortController;
|
||||
readonly validation: ReturnType<typeof createMcpSchemaValidation> | undefined;
|
||||
clientClosed: boolean;
|
||||
serverClosed: boolean;
|
||||
}
|
||||
@@ -69,8 +71,10 @@ export const createTestMcp = (): TestMcp => {
|
||||
connection: McpConnectionOwner,
|
||||
): Promise<unknown[]> => {
|
||||
const failures = await closeConnection(connection);
|
||||
if (connection.clientClosed && connection.serverClosed)
|
||||
if (connection.clientClosed && connection.serverClosed) {
|
||||
connection.validation?.clear();
|
||||
connections.delete(connection);
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
@@ -81,16 +85,22 @@ export const createTestMcp = (): TestMcp => {
|
||||
jsonSchemaValidator,
|
||||
) => {
|
||||
if (closed) return Promise.reject(registryClosed());
|
||||
const client = new Client(
|
||||
identity,
|
||||
jsonSchemaValidator === undefined ? undefined : { jsonSchemaValidator },
|
||||
);
|
||||
// Compile shared schema references once per connection instead of
|
||||
// expanding them into each SDK response validator. Caller validators
|
||||
// retain their own policy and lifetime.
|
||||
let validation: ReturnType<typeof createMcpSchemaValidation> | undefined;
|
||||
if (jsonSchemaValidator === undefined) {
|
||||
validation = createMcpSchemaValidation();
|
||||
jsonSchemaValidator = validation;
|
||||
}
|
||||
const client = new Client(identity, { jsonSchemaValidator });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const connection: McpConnectionOwner = {
|
||||
client,
|
||||
server,
|
||||
handshake: new AbortController(),
|
||||
validation,
|
||||
clientClosed: false,
|
||||
serverClosed: false,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user