Compare commits

...
Author SHA1 Message Date
morluto c78c8aed40 test(process): isolate startup failure from shared helper state 2026-11-09 08:40:39 +08:00
morluto 6b3372e259 perf(tests): reduce repeated schema compilation and CLI startup 2026-11-09 08:40:39 +08:00
morlutoandFelix 52fe342c73 fix(evidence): preserve subjects in derived workflows (#9492)
* fix(javascript): preserve verified trace evidence subjects

Co-authored-by: Felix <[email protected]>

* fix(evidence): preserve selected subject names in views

* fix(evidence): preserve subjects across related workflows

* fix(evidence): retain inventory projection subjects

* fix(managed): validate projected graph root identity

* style: format evidence and native command code

* fix(javascript): validate static graph root identity

* test(javascript): update feature trace graph expectations

---------

Co-authored-by: Felix <[email protected]>
2026-11-09 08:12:17 +08:00
4d7c6efe9a feat(desktop): open the seed's document at launch, and say when a seed was made for the task (#9054)
* feat(desktop): capture, draft, export and witness a task inside a build computer

- start_build_capture runs REA's capture inside the computer through a
  small controller over computer-exec, after a user-space apt-get update;
  mark_build_checkpoint returns once the capture confirms the checkpoint;
  stop_build_capture returns the draft between seed and end.
- export_build_environment exports with the requester's task inside the
  computer and fetches the bundle through data/get in bounded batches.
- check_build_task grades a computer's data/get with REA's evaluator and,
  through seed, after_edit, near_miss and after_reset stages, records a
  witness with the action log and the screen recording; act_on_build_computer
  gains reset.
- Capture progress lines carry window, checkpoint and refusal events; apps
  start in the home; a pack script builds the runtime archive; long tools
  send progress notifications.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): make build capture and witness steps retryable, and close the near-miss reset hole

- A capture whose start answer is lost is stopped at once and kept
  unreleased until the stop is confirmed; a lost checkpoint answer is
  recovered from the capture's own log, and draft and export use the
  checkpoints its Evidence records; stop can be repeated to resume
  reading its result.
- A witness can check its seed again after a failed read, and keeps its
  stages in a saving state until the record is written, so a failed save
  can be retried without reading the app again.
- after_edit and near_miss count only click, drag, scroll, type and key,
  so a reset no longer stands in for the near-miss edit.
- A .deb build keeps its package file for capture and passes its URL to
  export; bundle downloads write each checked file before fetching the
  next; an apt-get update failure reports its exit code and log tail;
  reset sends progress while it waits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep the Trash out of seeds, bundle folders unwritten and first frames drawn

From the blind Inkscape rebuild:
- Paths in ~/.local/share/Trash get a desktop_trash reason and the
  shared_desktop role, so a file trashed before seed is neither owned nor
  seeded; the export names it in a limitation.
- The keep helper runs with PYTHONDONTWRITEBYTECODE=1, so starting a
  computer from a bundle folder no longer writes __pycache__ into it.
- start_build_computer waits, bounded, until the first frame is not one
  colour before returning it.
From review: a .deb that apt also offers exports from apt, since export
passes --package-url only when the capture says the package must come
from its file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): bound the first-frame wait's retries to the time left, with progress throughout

Each retry of the drawn-first-frame wait runs under an abort that fires at
the 10 s deadline, and the latest frame is returned when time runs out.
connect takes the first frame itself while its progress heartbeat still
runs, reporting "waiting for the desktop to draw". The wait moves to
DrawnScreenshot.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): serialize each build computer's calls, keep one recording id, and separate unreadable checks from failures

- One queue per computer: actions, capture steps and witness checks run one
  at a time, so two overlapping after_edit checks can no longer both be filed
  and let the witness hold without a near miss. Close cancels the running and
  queued calls; different computers still run concurrently.
- The witness keeps one recording request id per computer, so a seed retried
  after a lost reply recovers the recording it started.
- A check that could not read its file is undecided, not a failure, so a
  temporary read error can't satisfy seed, near_miss or after_reset; such a
  stage is refused and can be checked again.
- The export's file listing is written to a file and read back in bounded
  batches, so a large bundle no longer overruns exec's 1 MiB output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): treat failures on cut views as undecided, require a decided seed failure at export, and keep a close's cancellation

- A presence check that misses its line, row or pointed value in a view cut
  short is undecided rather than failed, since the data may sit in the part
  left out; a witness stage can no longer be proved by such a failure.
- Export counts only decided failures on the seed, and refuses a task whose
  every seed failure is undecided.
- A step whose turn was aborted by a close or the caller returns cancelled
  even when its work finished, so start_build_capture no longer reports
  success for a computer being removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep a step's saved result when a cancellation arrives during its commit

Reporting cancelled for any step whose turn was aborted after its work
replaced a completed export or witness save with a bare cancellation, losing
what was saved and leaving the witness unreadable. Steps now return what they
completed; only start_build_capture, which commits nothing, checks its turn
after the post-launch screenshot and reports cancelled. Asking again for a
saved witness's final check returns its record.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): refuse a saved witness's repeated check for another folder

Asking again for the check that ended a witness returned its saved record
whatever directory came with it, so a caller naming another folder got
success though nothing was saved there. A different folder is now refused,
as an unfinished seed's retry already is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(desktop): assert the facts of cut-view and undecided outcomes, not their sentences

The new cut-view, missing-value, undecided-refusal and witness tests froze
whole descriptions, so a harmless rewording broke them. They now check each
outcome's id, passed and undecided exactly and the facts its message must
carry (file, pointer, line, row, stage) separately.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text

- Both readers accept a DOCTYPE with only a name and an optional PUBLIC
  or SYSTEM identifier, record it as the view's doctype, and never fetch
  the DTD or expand entities beyond XML's five; an internal subset, an
  external entity and a parameter entity stay refused alike.
- text_equals and text_contains compare the concatenated text of the
  element (or text node or attribute) a pointer reaches, as XPath
  string() does, with optional normalize_whitespace; both evaluators
  agree, and a text check on a truncated element is undecided.
- Whitespace-only text between elements is dropped only when the element
  holds no other text, so text split into runs keeps its spaces.
- The draft suggests text_equals for an element whose text changed.

So an AbiWord .abw reads as XML, and its paragraph's text is checked
whole however AbiWord splits it into <c> runs, as Writer's spans are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): draft whole-text checks on the paragraph, read only text as text, and skip asctime stamps

- A changed value is read at both ends, so text that became a run still
  marks its paragraph, and the drafted text_equals moves up out of mixed
  content to the nearest ancestor named by an attribute, such as
  AbiWord's p by its xid.
- Text checks accept only a text node or an attribute value; element
  names and doctype fields fail, in both evaluators.
- isTimestamp also recognises asctime dates (Www Mmm dd hh:mm:ss yyyy).
- XML files are drafted with the keyed diff the archive entries use, so
  no suggestion reaches an element by position; the shared logic moves to
  appTaskDraftNamed.ts.
- Text-check tests assert facts, not whole sentences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep checks on a changed attribute inside a paragraph whose text check replaces its runs'

A drafted whole-text check on a paragraph hid every change inside it, so a
link whose label and href both changed drafted only the paragraph's text and
the new destination went unchecked. The text check now covers text and runs
added or removed whole; a changed name or attribute of an element both
versions hold keeps its own check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): read an attribute called name or attributes as an attribute when the draft keeps its check

The owner of `.../attributes/name` was taken as `.../attributes`, so a
changed `name` attribute inside a paragraph with a text check was dropped
again. The owner is now read by segment, attributes first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): draft text checks from text in runs added whole, and name a text element by its own id before its container

Text typed into a run the diff lists whole was never read, so the draft kept
only a presence check on the run, which wrong text also passed. Such text now
marks the nearest element both ends hold, or, when none can take the check,
the added element's own text. The climb also names an element by an
identifier of its own (id, xml:id, xid) before a larger container, so a
headline under a long layer keeps a whole-text check that survives re-split
runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(desktop): expect the whole-text check where #8988's pairing test meets text checks

On this branch a changed element's text is drafted as `text_equals` on the
element, so the identity pairing test now expects that check, under the
same `[@role=body]` key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(skills): build a computer-app environment from a request

The product skill gains references/desktop-build.md, a procedure that
takes Claude from a request such as "an Inkscape environment where the
agent changes a poster's headline" to a witnessed bundle and a report,
through start_build_computer, act_on_build_computer, the build capture
tools, export_build_environment and check_build_task: questions asked
once upfront, pauses only when blocked, a seed made in the app, content
checks by selector, a witness with a near miss and a reset in a fresh
computer, and solver runs only within a budget. setup installs the new
reference; skillVersion 61.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skills): fold the blind Inkscape rebuild's friction into the build skill

key takes an array of one chord's keys; check for a text cursor before
typing in apps with single-key shortcuts; in Save As, click Documents and
type only the file name; anything saved before seed, the Trash included,
is part of the seed; dismiss first-run dialogs before seed; a bundle's app
starts with the seed's files but need not open the document; Inkscape's
window class is Inkscape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skills): fold the held-out AbiWord build's friction into the build skill

Name the act fields and link the guide; a region screenshot becomes the
latest frame; a drawn caret is not focus; confirm dialogs with Return;
check the dialog's folder when the app shows only a name; an XML file the
draft shows as lines was read as text, so use short line checks; and keep
text checks to the value the edit types, since some apps save a reloaded
document differently. Later witness stages take no directory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skills): state the export's seed rule, the Trash exception and draft refusals exactly

The export refuses a task unless at least one check fails on the seed and
every check passes at the end; a file moved to the Trash is left out of
the seed, so trash a stray file rather than starting over; a null draft
names its cause in draft_refusal; and a line check on a format read as
text only approximates a value, so bound it and call it approximate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skills): teach the build skill to use DOCTYPE and whole-text checks

The skill now checks text an app splits into runs whole with text_equals or
text_contains, finds an AbiWord paragraph by its xid, and drops the Writer
revision-id workaround.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(skills): point text checks at the paragraph, and say what the scrub replaces

From the AbiWord rebuild: point a text check at the paragraph or another
element the edit keeps, never at a run a reopened document splits; how to
name an unchanged element; the export replaces the host name, user@host
and home path while a bare user name stays; AbiWord's entry and class.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep every text check on a cut element undecided, in both evaluators

The XML reader drops text deeper than its depth limit but keeps later
siblings, and an element's whole text joins both sides, so a text_contains
on a cut element could match text the file never holds ("at 3" + deep
"WRONG" + ":30 PM" read as "at 3:30 PM"). Any text check on an element of a
cut view is now undecided again, as #9050 had it, in REA's evaluator and the
exported verifier; a text node or attribute value stays decided. The text
check tests assert facts rather than sentences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(desktop): open the seed's document at launch, and say when a seed was made for the task

An exported app now opens the seed's one document at launch when its desktop
entry's Exec takes files, so a solving agent starts in the document rather
than on the app's start screen; a command that already names a file is kept,
and several documents or an Exec without a file code keep the launch and say
why. `--synthetic-seed`, which the build export passes, words the README and
limitations for a seed made in the build computer instead of asking to keep
the user's data local.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): count only a document the launch names, absolute or from its working directory, as already opened

A settings path under the home, such as geany's --config, kept the seed's
document from opening, and a relative document argument was missed and
appended again. An argument now counts only when it resolves, from the
captured working directory, to a file the capture gave the document role.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(desktop): check the seed and launch wording against their formatters, and the facts on their own

The tests froze the README's and limitations' sentences, so a harmless
rewrite broke them. The wording now comes from exported formatters
(`seedWording`, `seedLimitation`, `launchDocumentNote`,
`NO_LAUNCH_DOCUMENT`), the tests compare against those, and check the facts
separately: which document opens, the respelled path in the command, the
README naming it, `--synthetic-seed` and the Evidence's `synthetic_seed`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(desktop): plan the export's seeds and launch in their own module

The merged export module passed the file and function size limits; the seed
digests and the launch command now come from `DesktopExportLaunch.ts`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep a reset applied when the screenshot asked after it fails

Once data/reset and the ready wait succeed, act_on_build_computer's reset
is applied in the action log and returns success; a screenshot asked of it
that is then refused or throws is named in the result's screenshot_error,
with no frame, so the next after_reset stage is not refused. Cancellation
still propagates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep checks for elements without text beside a text edit, and normalize DOCTYPE system id line endings

A review found two gaps.

- A paragraph's whole-text check covered every element added or removed
  inside it, including ones holding no text: an image frame, a line break.
  A solver who typed the text but never inserted the image passed. Such an
  element now keeps its own presence or absence check.
- The Python reader kept a DOCTYPE system literal's CRLF where TypeScript
  normalizes it to LF. Both now report LF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): wait out long build commands, fail a reset that restores nothing, and tie the witness to its bundle

A review found five gaps.

- Commands in the computer answer only when they end, but fetch stops
  waiting for headers after 300 s. So a slow export, draft or start always
  failed. A command with its own deadline now waits on node:http, bounded
  by that deadline.
- after_reset held whenever some check failed, so a reset that changed
  nothing passed on the near-miss state. Each check must now come out as it
  did at the seed.
- A failed or partial apt-get update left lists that looked complete, so a
  second start skipped the update. The update is now recorded only when it
  succeeds, and rerun otherwise.
- A witness on a bundle computer graded whatever task it was given. Its
  seed stage now refuses a task whose steps or checks differ from the
  bundle's.
- A task too large for one command argument (96 KiB) is refused before the
  export runs, instead of failing with E2BIG in the computer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): advertise a checkpoint-name pattern that compiles in every JS regex mode

main's advertised-pattern test compiles every input schema pattern in v
mode, which refuses an unescaped hyphen in a character class. The
checkpoint name's hyphen is now escaped, and it means the same in u mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(desktop): import textSyntaxFormat once in the state format tests

main's mixed-case JSON suffix fix and this branch both added the import,
and the merge kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(desktop): keep a completed reset, an unread seed, and a failed apt update

A cancel after data/reset no longer drops the restore from the action log,
so a near miss cannot treat the restored seed as an edit. An unreadable
seed check that later passes leaves after_reset undecided instead of
failing the witness. apt-get update is kept only when Error-Mode reports
success and a package list was fetched. The controller reply schema lives
with the controller, and capture progress lines are read by their domain
parser.

* fix(desktop): keep a whitespace-only run under the paragraph text check

A run whose only text is whitespace is still text the paragraph check
reads. It no longer gets its own presence check, which a later save of
the same words without that run would fail. Images and breaks, whose
text is empty, still keep their own checks.

* fix(desktop): keep an unread reset and a cut text check undecided

after_reset stays undecided when any check was not read, even if another
check still fails and the unread result's boolean matches the seed. A
text check for a child past a truncated view is undecided in both
evaluators. A reset whose reply is lost blocks a near miss until a later
input or a reset whose reply arrives.

* fix(desktop): retry a witness recording and a published record's cleanup

A recording that fails to end or download is not written into the witness
folder, and a manifest already received is fetched again without ending
the recording twice. A folder that is published while its handles fail to
close keeps that record and retries the same cleanup instead of writing
the folder again.

* fix(desktop): keep a published witness error and tag an app data refusal

A second failure to close a published witness returns the original error,
so its record and cleanup lease stay bound. A reset the app itself
refuses is an AppDataRefusal; only a reply that is not that refusal stays
unsettled. A missing child of a cut view is undecided with the same
description in both evaluators, and the shared cases cover it.

* fix(desktop): align a cut view's missing text with the Python check

A missing child of a truncated view is undecided for the same sentence in both evaluators, including the pointer the view does not contain.

* fix(desktop): keep one published witness cleanup across retries

A published record and its bound cleanup error stay together. A later close failure returns that error, and the unused path that built a fresh adapter error is gone.

* fix(desktop): keep a bundle task read inside the witness result

A cancelled, refused, or failed read of the bundle's task files returns the witness's Result, including a cleanup lease when a handle would not close.

* fix(desktop): check build runtime export capability

* fix(desktop): provide complete runtime repack command

* fix(desktop): retain runtime probe failures

* fix(desktop): recognize captured file URL documents

* fix(desktop): render captured file URLs at launch

* fix(desktop): isolate launcher URL encoding

* test(desktop): move launch execution to boundary suite

* style: fix desktop launch test formatting

* style: format native command path schema

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: morluto <[email protected]>
Co-authored-by: morluto <[email protected]>
2026-11-09 08:07:40 +08:00
morluto 0b42dc56d1 perf(tests): reuse CLI sessions for analysis data cases 2026-11-09 07:59:49 +08:00
morluto 0d3a2b815e perf(tests): consolidate MCP schema validation 2026-11-09 07:59:49 +08:00
morluto a961db280f fix(filesystem): explain refused file symlinks (#9476) 2026-11-09 07:57:52 +08:00
82 changed files with 2059 additions and 340 deletions

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`.
+1
View File
@@ -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:
+26 -1
View File
@@ -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`.
+12 -4
View File
@@ -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);
});
});
+18 -4
View File
@@ -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 -1
View File
@@ -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,
}
: {};
+4 -17
View File
@@ -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,
+3 -1
View File
@@ -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"),
+7
View File
@@ -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,
+6 -1
View File
@@ -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: [],
});
+15 -5
View File
@@ -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() });
+5
View File
@@ -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"
+8 -2
View 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,
+1 -1
View File
@@ -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,
+3
View File
@@ -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);
}
}
+75
View File
@@ -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/`");
});
});
+77 -6
View File
@@ -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. */
+6
View File
@@ -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.
+157
View File
@@ -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" },
});
});
});
+102
View File
@@ -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
View File
@@ -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;
+59 -42
View File
@@ -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
+50 -1
View File
@@ -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,
+2 -1
View File
@@ -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 &&
+2 -1
View File
@@ -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,
+2
View File
@@ -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,
+35 -35
View File
@@ -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),
);
+63 -5
View File
@@ -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"]),
);
+1 -1
View File
@@ -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;
+2
View File
@@ -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(
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
};
+14 -48
View File
@@ -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;
+15 -5
View File
@@ -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,
};