mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
* fix(desktop): follow a database mapping and own limitation text A selected database keeps its log when a parent folder moves or the file sits outside the home. Trash and seed-lock tests compare the owning notice with the paths, and launch options that are not captured paths stay as written. * test(desktop): pass the selected log into the draft The export's selected home paths decide which log stays in the draft. The line-guard test now names that log instead of relying on its reason. * fix(desktop): retarget a settings folder that holds captured files A nested profile directory is a captured path when a seeded file is inside it. An option value that only resolves under the home stays as written. * fix(desktop): keep a program word and a directory's neighbor The program after env stays the program even when a captured file sits in a folder of that name. A selected directory is not a database, so a neighboring file keeps the parent folder's spelling. * fix(desktop): record whether a selected path is a directory Capture stores the walked path's kind. A neighboring file of a selected directory keeps the parent folder's spelling even when that directory never changes and its children are not content roots. * fix(desktop): mark containing paths without a pairwise scan A recorded path marks its known ancestors as directories in one walk up the path, instead of comparing every recorded path with every other. * fix(desktop): keep a selected folder that is named like a database log A folder the caller selected stays a selected root when its name ends in -journal or -wal. A generated log beside a database file still does not. * fix(desktop): refuse a build command that exceeds the argument vector Splitting one argument still leaves the whole command under Linux's E2BIG limit. A request past that budget is refused before the process starts. * fix(desktop): remap a captured path in an env assignment The program word stays the program. A NAME=value assignment keeps its name and follows a captured path value when that path's spelling moves. * fix(desktop): keep oversized text out of draft guards A value or line longer than the draft's text bound is not copied into a guard, so one large frame cannot fill the task by itself. * docs(desktop): correct dconf and check-preview guidance A dconf version kept without its bytes is unreadable, not an empty key list. A values preview is copied into a check only when it is an uncut scalar. * fix(desktop): keep an empty env value empty An empty assignment is not a path. Resolving it named the working directory and rewrote the value when that directory's spelling moved. * fix(desktop): keep a run's font on the run, not the frame's text A formatting attribute such as Scribus FONT is not the document's words. Joining it across runs made a frame-wide text check of ItalicBold that failed when a run was split. The font stays a value check on its own run. * fix(desktop): do not pair JSON records by a selector-shaped field A field named @record_id matched the id suffix and became [@record_id=one], which reads an XML attribute. Records paired while their pointers matched nothing. Names that start with @ or > are not id fields. * fix(desktop): count a save-numbered id once when pairing members xml:id and draw:id values such as id123456 were both generated and numbered, so the same selector was counted twice and refused. A text edit of two elements keyed only by those ids now keeps its check. * fix(desktop): preserve selected state directory aliases * fix(desktop): align nested state selections with aliases * fix(desktop): check build runtime export capability * fix(desktop): provide complete runtime repack command * fix(desktop): retain runtime probe failures * test(desktop): identify capture independently of schema preflight * fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): exclude unseeded locks from task checks * fix(desktop): preserve draft bounds and launch URLs * fix(desktop): render draft values using their source format * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve captured URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * fix(desktop): classify added line values in one pass * fix(desktop): apply XML quote rules to text drafts * fix(desktop): distinguish XML attributes from quoted text * fix(desktop): preserve parsed XML line context * fix(desktop): retain partial XML syntax facts * fix(desktop): recognize markdown autolinks in drafts * fix(desktop): reuse export exclusions in task drafts * docs(desktop): clarify gzip scrub limits * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * fix(xml): avoid scanning selected ancestors per tag * fix(desktop): exclude covered text runs from draft counts * fix: preserve text when drafting desktop checks * fix: restore nested desktop state aliases * refactor: share UUID recognition across draft checks * fix: retain multiline XML attribute context * fix: retain relationship identifiers in task drafts * docs: clarify desktop draft identifier handling * test(desktop): type formatting run cases as pairs * test(desktop): separate markup boundary regression group * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * fix: align desktop numeric seed comparisons * test(desktop): keep one draft exclusion fixture import * test(desktop): retain log eligibility across draft test groups * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix: retain quoted identifier text in task drafts * fix: recognize Unicode XML in desktop state readers * fix(desktop): bound nested draft checks * fix(desktop): explain missing arrays in truncated views * fix(desktop): find XML type references in archives * test(desktop): remove duplicated capture assertions * fix(desktop): retain nested omitted text boundaries * style(desktop): format draft value imports * test(desktop): group model and draft value checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): retain placed-item sibling guards * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * style(desktop): format capture review fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
335 lines
15 KiB
Python
335 lines
15 KiB
Python
"""Run REA inside an agentenv build computer for REA on the host: capture the app, draft its task and export it.
|
|
|
|
REA sends this file's source through the computer's ``computer-exec/v1`` route as
|
|
``python3 -I -c <source> <command> <JSON arguments>``. JSON that would exceed the bytes Linux
|
|
passes as one string travels as several arguments, which this file joins before parsing. Each command prints
|
|
one JSON line and exits. ``start``
|
|
leaves the capture running on its own: its stdin is a FIFO it holds open for reading and writing, so it never
|
|
sees the end of its input, and ``mark`` and ``stop`` write their requests there and wait for the capture's own
|
|
acknowledgement on its progress log. A small shell around the capture writes its exit status to a file, the
|
|
mark a later command reads. REA runs as the session user in the home, with no ``AGENTENV_*`` variable, so
|
|
neither REA nor the app it launches holds the computer's control token, and with ``APT_CONFIG`` naming the
|
|
package lists ``start`` downloads as that user, so the capture finds the package's sources.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
POLL_S = 0.2
|
|
TAIL_BYTES = 4096
|
|
|
|
|
|
def _say(value: dict) -> int:
|
|
print(json.dumps(value), flush=True)
|
|
return 0
|
|
|
|
|
|
def _tail(path: str) -> str:
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
handle.seek(max(0, os.path.getsize(path) - TAIL_BYTES))
|
|
return handle.read().decode("utf-8", "replace")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def _environment(work: str) -> dict:
|
|
environment = {name: value for name, value in os.environ.items() if not name.startswith("AGENTENV_")}
|
|
environment["APT_CONFIG"] = os.path.join(work, "apt", "apt.conf")
|
|
return environment
|
|
|
|
|
|
def _apt(work: str, timeout: float) -> dict:
|
|
"""Download the package lists into the work folder as the session user, until one update succeeds."""
|
|
root = os.path.join(work, "apt")
|
|
lists = os.path.join(root, "lists")
|
|
updated = os.path.join(root, "updated")
|
|
os.makedirs(os.path.join(lists, "partial"), exist_ok=True)
|
|
os.makedirs(os.path.join(root, "cache", "archives", "partial"), exist_ok=True)
|
|
with open(os.path.join(root, "apt.conf"), "w", encoding="utf-8") as conf:
|
|
conf.write(
|
|
f'Dir::State::Lists "{lists}";\n'
|
|
f'Dir::Cache "{root}/cache";\n'
|
|
'Debug::NoLocking "true";\n'
|
|
'APT::Update::Error-Mode "any";\n'
|
|
)
|
|
# apt keeps lists compressed as it fetched them, such as *_Packages.lz4 on Ubuntu.
|
|
count = lambda: len([name for name in os.listdir(lists) if re.search(r"_Packages(\.\w+)?$", name)]) # noqa: E731
|
|
# A marker left by an update that fetched nothing is not a success; download again.
|
|
if os.path.exists(updated) and count() > 0:
|
|
return {"lists": count(), "updated": False}
|
|
log = os.path.join(root, "update.log")
|
|
try:
|
|
with open(log, "wb") as output:
|
|
code = subprocess.run(["apt-get", "update"], env=_environment(work), stdin=subprocess.DEVNULL,
|
|
stdout=output, stderr=subprocess.STDOUT, timeout=timeout).returncode
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
return {"lists": count(), "updated": True, "error": f"{type(error).__name__}: {error}"}
|
|
lists_fetched = count()
|
|
if code == 0 and lists_fetched > 0:
|
|
# Only a whole update that fetched lists is kept, so a failed or empty one runs again.
|
|
open(updated, "w").close()
|
|
return {"lists": lists_fetched, "updated": True, "exit_code": code}
|
|
if code == 0:
|
|
return {"lists": lists_fetched, "updated": True, "exit_code": code,
|
|
"error": "apt-get update exited 0 without a package list"}
|
|
return {"lists": lists_fetched, "updated": True, "exit_code": code,
|
|
"error": f"apt-get update exited {code}: {_tail(log)[-1500:].strip()}"}
|
|
|
|
|
|
def _exit_code(capture: str):
|
|
try:
|
|
with open(os.path.join(capture, "exit"), encoding="utf-8") as handle:
|
|
return int(handle.read().strip() or "-1")
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def _ended(capture: str, code: int) -> dict:
|
|
"""A capture that has exited: its status, the end of its progress log and its output's size, which REA reads
|
|
whole for the Evidence or failure it ended with, whichever step was waiting on it."""
|
|
return {"exited": code, "progress": _tail(os.path.join(capture, "progress.log")),
|
|
"output_bytes": os.path.getsize(os.path.join(capture, "capture.out"))}
|
|
|
|
|
|
def _event_line(path: str, offset: int, kinds: set, name):
|
|
"""The first progress line after ``offset`` whose event is of one of ``kinds``, for ``name`` when given."""
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
handle.seek(offset)
|
|
lines = handle.read().decode("utf-8", "replace").splitlines()
|
|
except OSError:
|
|
return None
|
|
for line in lines:
|
|
try:
|
|
event = json.loads(line)["rea_progress"]["event"]
|
|
except (ValueError, KeyError, TypeError):
|
|
continue
|
|
if isinstance(event, dict) and event.get("kind") in kinds and (name is None or event.get("name") == name):
|
|
return line
|
|
return None
|
|
|
|
|
|
def _wait(capture: str, offset: int, kinds: set, name, timeout: float) -> dict:
|
|
"""Wait for the capture's event, or for it to exit, or for time to run out."""
|
|
log = os.path.join(capture, "progress.log")
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
line = _event_line(log, offset, kinds, name)
|
|
if line is not None:
|
|
return {"line": line}
|
|
code = _exit_code(capture)
|
|
if code is not None:
|
|
return _ended(capture, code)
|
|
if time.monotonic() > deadline:
|
|
return {"timed_out": True, "progress": _tail(log)}
|
|
time.sleep(POLL_S)
|
|
|
|
|
|
def _within(path: str, folder: str) -> bool:
|
|
return path == folder or path.startswith(folder + os.sep)
|
|
|
|
|
|
def _state_paths(values: list, work: str):
|
|
"""Each state path as an absolute path strictly under the home and outside REA's work folder, both as named
|
|
and with its links resolved, or the refusal of the first that is not. The capture resolves the links again
|
|
and retains what they lead to, so a link out of the home is refused here. Each passes on as named, so the
|
|
capture keeps what its spelling means, such as a hidden settings folder."""
|
|
home = os.path.normpath(os.path.expanduser("~"))
|
|
real_home, real_work = os.path.realpath(home), os.path.realpath(work)
|
|
spellings = {home, real_home}
|
|
paths = []
|
|
for value in values:
|
|
relative = value[2:] if value.startswith("~/") else value
|
|
path = os.path.normpath(os.path.join(home, relative))
|
|
if ".." in relative.split("/") or not any(path.startswith(spelling + os.sep) for spelling in spellings):
|
|
return None, f"The state path {value} is not under the computer's home, {home}."
|
|
# Every link on the way is followed, a dangling one too; a path that does not exist yet resolves
|
|
# through its nearest existing folder.
|
|
resolved = os.path.realpath(path)
|
|
if not resolved.startswith(real_home + os.sep):
|
|
return None, f"The state path {value} leads through a link to {resolved}, which is not under the computer's home, {home}."
|
|
if _within(path, work) or _within(resolved, real_work):
|
|
return None, f"The state path {value} is in REA's work folder {work}, which every capture leaves out."
|
|
paths.append(path)
|
|
return paths, None
|
|
|
|
|
|
def start(arguments: dict) -> int:
|
|
work, capture = os.path.abspath(arguments["work"]), os.path.abspath(arguments["capture"])
|
|
rea = shutil.which("rea")
|
|
if rea is None:
|
|
return _say({"error": "REA is not installed in this computer; start the computer from a request, which installs it."})
|
|
state_paths, refusal = _state_paths(arguments.get("state_paths", []), work)
|
|
if refusal is not None:
|
|
return _say({"refused": refusal})
|
|
if os.path.exists(capture):
|
|
return _say({"error": f"the capture folder {capture} is already used"})
|
|
apt = _apt(work, arguments.get("apt_timeout_s", 600))
|
|
try:
|
|
os.makedirs(capture)
|
|
except FileExistsError:
|
|
return _say({"error": f"a stop claimed {capture} before the capture launched", "apt": apt})
|
|
control = os.path.join(capture, "control")
|
|
os.mkfifo(control, 0o600)
|
|
argv = [rea, "capture-desktop-application", "--desktop-entry", arguments["desktop_entry"],
|
|
"--output", os.path.join(capture, "capture"), "--exclude", work, "--json"]
|
|
for value in arguments.get("arguments", []):
|
|
argv += ["--argument", value]
|
|
for path in state_paths:
|
|
argv += ["--state-path", path]
|
|
if arguments.get("package_file"):
|
|
argv += ["--package-file", arguments["package_file"]]
|
|
# Reading and writing, so the capture never reads the end of its input between requests.
|
|
reader = os.open(control, os.O_RDWR)
|
|
process = None
|
|
try:
|
|
try:
|
|
with open(os.path.join(capture, "capture.out"), "wb") as out, \
|
|
open(os.path.join(capture, "progress.log"), "wb") as progress:
|
|
process = subprocess.Popen(
|
|
["sh", "-c", 'done=$1; shift; "$@"; code=$?; printf "%s" "$code" > "$done.tmp" && mv "$done.tmp" "$done"',
|
|
"sh", os.path.join(capture, "exit"), *argv],
|
|
cwd=os.path.expanduser("~"), env=_environment(work), stdin=reader, stdout=out, stderr=progress,
|
|
start_new_session=True)
|
|
except Exception as cause:
|
|
# Nothing will write exit. Record one and drop the FIFO, so stop does not wait on it.
|
|
try:
|
|
with open(os.path.join(capture, "exit"), "w", encoding="utf-8") as handle:
|
|
handle.write("1")
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.unlink(control)
|
|
except OSError:
|
|
pass
|
|
return _say({"error": f"the capture did not start: {cause}"})
|
|
finally:
|
|
os.close(reader)
|
|
if process is None:
|
|
return _say({"error": "the capture did not start"})
|
|
waited = _wait(capture, 0, {"window_open"}, None, arguments.get("timeout_s", 120))
|
|
return _say({"pid": process.pid, "apt": apt, **waited})
|
|
|
|
|
|
def _request(capture: str, line: str) -> bool:
|
|
"""Write one request to the running capture; False when nothing reads its FIFO any more."""
|
|
try:
|
|
fd = os.open(os.path.join(capture, "control"), os.O_WRONLY | os.O_NONBLOCK)
|
|
except OSError:
|
|
return False
|
|
try:
|
|
os.write(fd, (line + "\n").encode("utf-8"))
|
|
finally:
|
|
os.close(fd)
|
|
return True
|
|
|
|
|
|
def mark(arguments: dict) -> int:
|
|
"""Request a checkpoint and answer with the capture's acknowledgement line as it wrote it: the checkpoint's
|
|
counts and up to ten of the files that changed, home-relative, with a count of the rest."""
|
|
capture, name = os.path.abspath(arguments["capture"]), arguments["name"]
|
|
code = _exit_code(capture)
|
|
log = os.path.join(capture, "progress.log")
|
|
if code is not None:
|
|
return _say(_ended(capture, code))
|
|
# A checkpoint already taken, whose answer was lost, is answered from the capture's own log.
|
|
taken = _event_line(log, 0, {"checkpoint"}, name)
|
|
if taken is not None:
|
|
return _say({"line": taken})
|
|
offset = os.path.getsize(log)
|
|
if not _request(capture, f"checkpoint {name}"):
|
|
return _say({"error": "the capture is not reading its requests"})
|
|
return _say(_wait(capture, offset, {"checkpoint", "checkpoint_refused"}, name, arguments.get("timeout_s", 600)))
|
|
|
|
|
|
def stop(arguments: dict) -> int:
|
|
capture = os.path.abspath(arguments["capture"])
|
|
if _exit_code(capture) is None and not os.path.exists(os.path.join(capture, "control")):
|
|
# Never launched: claim the folder, so a start still on its way finds it used and launches nothing.
|
|
os.makedirs(capture, exist_ok=True)
|
|
return _say({"absent": True})
|
|
if _exit_code(capture) is None:
|
|
_request(capture, "stop")
|
|
deadline = time.monotonic() + arguments.get("timeout_s", 600)
|
|
while (code := _exit_code(capture)) is None and time.monotonic() < deadline:
|
|
time.sleep(POLL_S)
|
|
log = os.path.join(capture, "progress.log")
|
|
if code is None:
|
|
return _say({"timed_out": True, "progress": _tail(log)})
|
|
output = os.path.join(capture, "capture.out")
|
|
return _say({"exit_code": code, "progress": _tail(log), "output_bytes": os.path.getsize(output)})
|
|
|
|
|
|
def run(arguments: dict) -> int:
|
|
"""Run one REA command with its output in a file, after writing the files it reads."""
|
|
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:
|
|
handle.write(text)
|
|
with open(arguments["stdout"], "wb") as out:
|
|
try:
|
|
done = subprocess.run([rea, *arguments["argv"]], cwd=os.path.expanduser("~"),
|
|
env=_environment(os.path.abspath(arguments["work"])), stdin=subprocess.DEVNULL, stdout=out,
|
|
stderr=subprocess.PIPE, timeout=arguments.get("timeout_s", 1800))
|
|
except subprocess.TimeoutExpired:
|
|
return _say({"timed_out": True})
|
|
return _say({"exit_code": done.returncode, "stdout_bytes": os.path.getsize(arguments["stdout"]),
|
|
"stderr": done.stderr[-TAIL_BYTES:].decode("utf-8", "replace")})
|
|
|
|
|
|
def listing(arguments: dict) -> int:
|
|
"""Every regular file under a folder, by its path relative to the home, with its size and SHA-256.
|
|
|
|
The list goes to the file ``output`` names, outside the folder, and the answer gives its size: a large
|
|
bundle's list outgrows the 1 MiB of stdout a command answers with, so REA reads it through ``data/get``.
|
|
"""
|
|
home = os.path.expanduser("~")
|
|
files, skipped = [], []
|
|
|
|
def fail_walk(error: OSError) -> None:
|
|
raise error
|
|
|
|
for directory, folders, names in os.walk(os.path.abspath(arguments["root"]), onerror=fail_walk):
|
|
folders.sort()
|
|
# os.walk does not follow directory symlinks; disclose them to the consumer.
|
|
for name in folders[:]:
|
|
path = os.path.join(directory, name)
|
|
if os.path.islink(path):
|
|
skipped.append(os.path.relpath(path, home))
|
|
folders.remove(name)
|
|
for name in sorted(names):
|
|
path = os.path.join(directory, name)
|
|
relative = os.path.relpath(path, home)
|
|
if os.path.islink(path) or not os.path.isfile(path):
|
|
skipped.append(relative)
|
|
continue
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
digest.update(chunk)
|
|
files.append({"path": relative, "size": os.path.getsize(path), "sha256": digest.hexdigest()})
|
|
output = os.path.abspath(arguments["output"])
|
|
os.makedirs(os.path.dirname(output), exist_ok=True)
|
|
with open(output, "w", encoding="utf-8") as handle:
|
|
json.dump({"files": files, "skipped": skipped}, handle)
|
|
return _say({"listing_bytes": os.path.getsize(output)})
|
|
|
|
|
|
COMMANDS = {"start": start, "mark": mark, "stop": stop, "run": run, "list": listing}
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(COMMANDS[sys.argv[1]](json.loads("".join(sys.argv[2:]))))
|