From 9ec248bc35f5a783ef57b53374dccc472afe1c99 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sat, 9 May 2026 01:38:19 +0100 Subject: [PATCH 01/23] installer: drop host bootstrap dep; bundle AmiDock prefs; clean COMBI: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diskimage tools (MountDiskImage / diskimage.device / CDFileSystem) ship with AOS 4.1, so the running AmigaOS already has them and the dest drive picks up fresh copies via copy_base_os from the install ISO. installer.preflight + installer.stage no longer require a host-side diskimage-bootstrap/ directory; stage_diskimage_tools is now a probe that fails loudly if the running system is missing any of the three tools. AmiDock.amiga.com.xml ships as a bundled package resource under installer/resources/ — the previous behaviour silently no-op'd patch_amidock_prefs when the user hadn't staged a copy by hand. New dismount_combi_device step runs after unmount_iso to clean up the COMBI: scaffolding the install creates: ejects the unit (belt- and-braces, even though unmount_iso already did), deletes DEVS:DOSDrivers/COMBI when it carries the installer's marker, and issues `c:Dismount COMBI: FORCE`. (Assign DEVICE: REMOVE doesn't work for Mount-style DOS devices on AOS4 — only true assigns. And Dismount with media still inserted has been observed to wedge.) Validated end-to-end on real X5000 → TEST: + TEST2: drives. Drops [defaults] bootstrap_dir field; --init wizard no longer prompts for it. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/config.example.toml | 3 - host/src/amiga_fleet_mcp/config.py | 1 - .../installer/resources/AmiDock.amiga.com.xml | 381 ++++++++++++++++++ .../installer/sequences/_generic.py | 1 + .../installer/sequences/_steps.py | 123 +++++- host/src/amiga_fleet_mcp/server.py | 26 +- host/src/amiga_fleet_mcp/setup_wizard.py | 1 - host/src/amiga_fleet_mcp/tools/installer.py | 135 ++----- .../unit/test_installer_other_machines.py | 7 +- .../tests/unit/test_installer_stage_verify.py | 16 +- host/tests/unit/test_installer_x5000_seq.py | 1 + 11 files changed, 556 insertions(+), 139 deletions(-) create mode 100644 host/src/amiga_fleet_mcp/installer/resources/AmiDock.amiga.com.xml diff --git a/host/config.example.toml b/host/config.example.toml index f6b5709..d303bc1 100644 --- a/host/config.example.toml +++ b/host/config.example.toml @@ -49,8 +49,6 @@ mcp_transport = "stdio" # stdio | sse | streamable-http # (the AmigaDOS volume to install into). # sources_dir Default for installer.scan_sources / preflight / # stage / install_x5000 / run. -# bootstrap_dir Default for installer.stage's diskimage-bootstrap/ -# directory. # machine Default for installer.required_files / preflight / # stage / run / verify (e.g. "X5000", "A1222"). # iso_filename Default ISO filename inside sources_dir; rarely @@ -58,7 +56,6 @@ mcp_transport = "stdio" # stdio | sse | streamable-http # # dest_volume = "BootTest:" # sources_dir = "" -# bootstrap_dir = "" # machine = "X5000" # ---- Example: QEMU Pegasos2 target ------------------------------- diff --git a/host/src/amiga_fleet_mcp/config.py b/host/src/amiga_fleet_mcp/config.py index 1dc70e3..d8626b0 100644 --- a/host/src/amiga_fleet_mcp/config.py +++ b/host/src/amiga_fleet_mcp/config.py @@ -203,7 +203,6 @@ class DefaultsConfig(BaseModel): # installer.* defaults dest_volume: str | None = None sources_dir: str | None = None - bootstrap_dir: str | None = None machine: str | None = None # installer_run / installer_install_x5000 iso_filename: str | None = None diff --git a/host/src/amiga_fleet_mcp/installer/resources/AmiDock.amiga.com.xml b/host/src/amiga_fleet_mcp/installer/resources/AmiDock.amiga.com.xml new file mode 100644 index 0000000..89286b4 --- /dev/null +++ b/host/src/amiga_fleet_mcp/installer/resources/AmiDock.amiga.com.xml @@ -0,0 +1,381 @@ + + + + + AutosaveConfig + TRUE + FlashOnClick + TRUE + RespectTitlebar + TRUE + AppDockNr + 0 + Docks + + + Name + Maindock + OriginX + 1024 + OriginY + 768 + BarBackground + sys:prefs/presets/AmiDock/def_bar + ObjectBackground + SYS:Prefs/Presets/AmiDock/def_masterbackground + PopupIcon + SYS:Prefs/Presets/AmiDock/def_masterpopup.info + Orientation + HORIZONTALR + ObjectNames + ONE + IconAlignment + BOTTOM + SnapToBorder + BOTTOMRIGHT + PopupSize + MEDIUM + Placement + BACKDROP + DockType + ICONS + HotkeyAction + TOFRONTTOBACK + Hidden + FALSE + Minimizeable + TRUE + Minimized + FALSE + HasDragBar + TRUE + HasBorders + TRUE + DragBarTransparency + PICTURE + ObjectAreaTransparency + PICTRANS + Transparency + 75 + UsesTextShadow + TRUE + FontName + Bitstream Vera Sans.font + FontSize + 12 + FontStyle + 16 + FontFlags + 34 + FontFrontPen + 2 + FontBackPen + 1 + FontDrawMode + 0 + Categories + + + Name + Default Category + LockSize + TRUE + Width + 850 + Height + 80 + Icons + + + Name + NotePad + FileName + SYS:Utilities/NotePad + DndAtOnce + FALSE + + + Name + MultiView + FileName + SYS:Utilities/MultiView + DndAtOnce + FALSE + + + Name + Shell + FileName + SYS:System/Shell + DndAtOnce + FALSE + + + Name + Unarc + FileName + SYS:Utilities/Unarc + DndAtOnce + FALSE + + + Name + AmiPDF + FileName + SYS:Utilities/AmiPDF/AmiPDF + DndAtOnce + FALSE + + + Name + Separator + FileName + SYS:Utilities/Dockies/Separator.docky + DndAtOnce + FALSE + + + Name + WBHelp + FileName + S:ARexx/WBHelp + DndAtOnce + FALSE + + + Name + Separator + FileName + SYS:Utilities/Dockies/Separator.docky + DndAtOnce + FALSE + + + Name + Extras + FileName + SYS:Utilities/Dockies/SubDock.docky + DndAtOnce + FALSE + DockyPrefs + + OwnDockNr + 1 + IconFile + ENV:Sys/def_extras_dock + PopUpMode + TRUE + + + + Name + Internet + FileName + SYS:Utilities/Dockies/SubDock.docky + DndAtOnce + FALSE + DockyPrefs + + OwnDockNr + 2 + IconFile + ENV:Sys/def_internet_dock + PopUpMode + TRUE + + + + + + + + Name + Extras + OriginX + 720 + OriginY + 600 + BarBackground + SYS:Prefs/Presets/AmiDock/def_bar + ObjectBackground + SYS:Prefs/Presets/AmiDock/def_background + PopupIcon + SYS:Prefs/Presets/AmiDock/def_popup.info + Orientation + VERTICALB + ObjectNames + FULLTEXT + IconAlignment + CENTERED + SnapToBorder + NONE + PopupSize + SMALL + Placement + NORMAL + DockType + ICONS + HotkeyAction + TOFRONTTOBACK + Hidden + TRUE + Minimizeable + TRUE + Minimized + FALSE + HasDragBar + TRUE + HasBorders + TRUE + DragBarTransparency + PICTURE + ObjectAreaTransparency + PICTURE + Transparency + 50 + UsesTextShadow + TRUE + FontName + DejaVu Sans Oblique.font + FontSize + 14 + FontStyle + 16 + FontFlags + 34 + FontFrontPen + 2 + FontBackPen + 1 + FontDrawMode + 0 + Categories + + + Name + Default category + LockSize + FALSE + Width + 82 + Height + 240 + Icons + + + Name + Filer + FileName + SYS:Utilities/Filer/Filer + DndAtOnce + FALSE + + + Name + Ranger + FileName + SYS:Utilities/Ranger/Ranger + DndAtOnce + FALSE + + + Name + DiskImageGUI + FileName + SYS:System/DiskImageGUI + DndAtOnce + FALSE + + + + + + + Name + Internet + OriginX + 820 + OriginY + 600 + BarBackground + SYS:Prefs/Presets/AmiDock/def_bar + ObjectBackground + SYS:Prefs/Presets/AmiDock/def_background + PopupIcon + SYS:Prefs/Presets/AmiDock/def_popup.info + Orientation + VERTICALB + ObjectNames + FULLTEXT + IconAlignment + CENTERED + SnapToBorder + NONE + PopupSize + SMALL + Placement + NORMAL + DockType + ICONS + HotkeyAction + TOFRONTTOBACK + Hidden + TRUE + Minimizeable + TRUE + Minimized + FALSE + HasDragBar + TRUE + HasBorders + TRUE + DragBarTransparency + PICTURE + ObjectAreaTransparency + PICTURE + Transparency + 50 + UsesTextShadow + TRUE + FontName + DejaVu Sans Oblique.font + FontSize + 14 + FontStyle + 16 + FontFlags + 34 + FontFrontPen + 2 + FontBackPen + 1 + FontDrawMode + 0 + Categories + + + Name + Default category + LockSize + FALSE + Width + 68 + Height + 100 + Icons + + + Name + IBrowse + FileName + SYS:Internet/IBrowse/IBrowse + DndAtOnce + FALSE + + + + + + + + diff --git a/host/src/amiga_fleet_mcp/installer/sequences/_generic.py b/host/src/amiga_fleet_mcp/installer/sequences/_generic.py index d66294e..07575da 100644 --- a/host/src/amiga_fleet_mcp/installer/sequences/_generic.py +++ b/host/src/amiga_fleet_mcp/installer/sequences/_generic.py @@ -54,6 +54,7 @@ def build_sequence(config: MachineConfig, *, iso_filename: str) -> Sequence: # appends to). seq_steps.append(steps.install_mcpd()) seq_steps.append(steps.unmount_iso()) + seq_steps.append(steps.dismount_combi_device()) seq_steps.append(steps.cleanup_tmp()) return Sequence( diff --git a/host/src/amiga_fleet_mcp/installer/sequences/_steps.py b/host/src/amiga_fleet_mcp/installer/sequences/_steps.py index b9d4036..0d1e0c9 100644 --- a/host/src/amiga_fleet_mcp/installer/sequences/_steps.py +++ b/host/src/amiga_fleet_mcp/installer/sequences/_steps.py @@ -115,25 +115,39 @@ async def _maybe_rename(mcpd, src_path: str, dst_path: str) -> None: def stage_diskimage_tools() -> Step: - """Copy MountDiskImage / diskimage.device / CDFileSystem onto the - running system + write COMBI: DOSDriver mountfile. Idempotent.""" + """Verify the running AmigaOS has the diskimage tools needed to + mount the install ISO + write the COMBI: DOSDriver mountfile. + + AOS 4.1 ships C:MountDiskImage, DEVS:diskimage.device, and + L:CDFileSystem out of the box. We don't stage them from the host + -- if the running system is missing any of them, the install + can't go ahead and we fail loudly here rather than mounting + later with a confusing error. + + The COMBI: DOSDriver mountfile is generated inline by this step + (no separate file shipped) and removed at end-of-install by + dismount_combi_device.""" async def fn(ctx): mcpd = ctx["mcpd"] - dest = ctx["dest_volume"] - bootstrap = f"{dest}tmp/diskimage-bootstrap/" - for src_name, sys_path in ( - ("MountDiskImage", "C:MountDiskImage"), - ("diskimage.device", "DEVS:diskimage.device"), - ("CDFileSystem", "L:CDFileSystem"), - ): + required = ( + ("C:MountDiskImage", "MountDiskImage tool"), + ("DEVS:diskimage.device", "diskimage.device kernel module"), + ("L:CDFileSystem", "CDFileSystem handler"), + ) + missing: list[str] = [] + for path, _desc in required: try: - await mcpd.request("fs.stat", {"path": sys_path}) - continue + await mcpd.request("fs.stat", {"path": path}) except Exception: - pass - await mcpd.request("fs.copy", { - "src": bootstrap + src_name, "dst": sys_path, - }) + missing.append(path) + if missing: + raise RuntimeError( + "running AmigaOS is missing required diskimage tool(s): " + + ", ".join(missing) + + ". These ship with AOS 4.1 -- the install host " + "needs a working AOS 4.1 install before it can install " + "to a fresh dest drive." + ) drv_path = "DEVS:DOSDrivers/COMBI" try: await mcpd.request("fs.stat", {"path": drv_path}) @@ -169,12 +183,13 @@ async def fn(ctx): mountfile.encode("ascii") ).decode("ascii"), }) - return {"installed": ["C:MountDiskImage", "DEVS:diskimage.device", - "L:CDFileSystem", drv_path]} + return {"verified": ["C:MountDiskImage", "DEVS:diskimage.device", + "L:CDFileSystem"], + "wrote": [drv_path]} return Step( name="stage_diskimage_tools", - doc="Copy MountDiskImage/diskimage.device/CDFileSystem onto " - "the running system + write COMBI: DOSDriver mountfile.", + doc="Verify running AmigaOS has the diskimage tools + write " + "COMBI: DOSDriver mountfile inline.", fn=fn, ) @@ -884,6 +899,76 @@ async def fn(ctx): ) +_COMBI_MARKER = "written by amiga-fleet-mcp installer" + + +def dismount_combi_device() -> Step: + """Remove the COMBI: DOSDriver mountfile from the running system. + + `unmount_iso` ejects the media but leaves the DOS device entry in + place + leaves DEVS:DOSDrivers/COMBI on disk so it auto-mounts on + next reboot. After an install, that's pollution: the device only + existed to host the install ISO. + + Deletes the mountfile if it carries the installer's comment marker + (so the cleanup also catches files left behind by an earlier + install run, not just this one). A user-owned COMBI: with no + marker is left untouched. + + Issues `c:Dismount COMBI: FORCE` to drop the live DOS entry from + the running system. (`Assign COMBI: REMOVE` doesn't apply to + Mount-style DOS devices on AOS4 -- only true assigns.) + + The ISO MUST be ejected before Dismount runs, otherwise AOS4 has + been observed to wedge / leave the device in a half-state. The + install sequence puts `unmount_iso` (MountDiskImage EJECT) before + this step; we additionally fire an idempotent `EJECT` here as a + belt-and-braces guard in case the step is called standalone. + """ + async def fn(ctx): + from ...tools import installer as it + mcpd = ctx["mcpd"] + drv_path = "DEVS:DOSDrivers/COMBI" + actions: list[str] = [] + # Belt-and-braces eject -- idempotent on an already-empty + # unit, so safe to issue even when unmount_iso ran first. + try: + await it.installer_unmount_iso( + ctx["fleet"], ctx["target"], unit=COMBI_UNIT, + ) + actions.append(f"ejected unit {COMBI_UNIT} (pre-dismount guard)") + except Exception as e: + actions.append(f"eject-failed: {str(e)[:80]}") + try: + raw = await mcpd.request("fs.read", {"path": drv_path}) + content = base64.b64decode( + raw.get("content_b64", "") + ).decode("ascii", errors="replace") + if _COMBI_MARKER in content: + await mcpd.request("fs.delete", {"path": drv_path}) + actions.append(f"deleted {drv_path}") + else: + actions.append(f"kept {drv_path} (no installer marker)") + except Exception as e: + actions.append(f"read-failed {drv_path}: {str(e)[:80]}") + try: + await mcpd.request("exec.cmd", { + "command": "C:Dismount >NIL: COMBI: FORCE", + "timeout_ms": 10000, + }, timeout_s=15.0) + actions.append("Dismount COMBI: FORCE issued") + except Exception as e: + actions.append(f"dismount-failed: {str(e)[:80]}") + return {"actions": actions} + return Step( + name="dismount_combi_device", + doc=("Remove the running system's COMBI: DOS device + its " + "DEVS:DOSDrivers/COMBI mountfile so the install ISO " + "scaffolding doesn't persist after reboot."), + fn=fn, + ) + + def install_extras() -> Step: """Pre-install Filer, Ranger, IBrowse, DiskImage from `:Installation-Files/Extras/`. diff --git a/host/src/amiga_fleet_mcp/server.py b/host/src/amiga_fleet_mcp/server.py index 2fa09b3..5f7ba6b 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -1750,10 +1750,9 @@ async def installer_install_x5000( ) -> Any: """Run the AmigaOne X5000 install sequence. Default dry_run=True returns the planned step list; pass dry_run=False, confirm=True - to actually execute. Caller must stage ISO + LHAs + - diskimage-bootstrap/ into :tmp/ before calling. - dest_volume / sources_dir / iso_filename fall back to - `[defaults]`.""" + to actually execute. Caller must stage ISO + LHAs into + :tmp/ before calling. dest_volume / sources_dir / + iso_filename fall back to `[defaults]`.""" dest_volume = _default(dest_volume, "dest_volume", "dest_volume") if sources_dir is None: sources_dir = fleet.config.defaults.sources_dir @@ -1805,27 +1804,29 @@ async def installer_stage( machine: str | None = None, iso_filename: str | None = None, iso_lha_path: str | None = None, - bootstrap_dir: str | None = None, confirm: bool = False, ) -> Any: - """Upload ISO + LHAs + diskimage-bootstrap/ into :tmp/. - Mutating; requires confirm=True (multi-GB upload). + """Upload ISO + Update LHAs + Enhancer + extras + MCPd + + AmiDock prefs into :tmp/. Mutating; requires confirm=True + (multi-GB upload). Auto-detects iso_filename from sources_dir scan if omitted. - bootstrap_dir defaults to a search of standard host paths. + + AOS 4.1 diskimage tools (MountDiskImage / diskimage.device / + CDFileSystem) are NOT staged from the host -- they're sourced + from the running AmigaOS at install time and from the install + ISO into the dest drive via copy_base_os. If `iso_lha_path` is provided (or `.lha` exists next to the iso in sources_dir), the LHA-of-ISO form is uploaded instead of the raw ISO -- saves ~60% bandwidth on incompressible ISO data. The install sequence's `extract_iso_lha` step recovers the .iso file on the target before mount. - dest_volume / sources_dir / machine / bootstrap_dir / - iso_filename fall back to `[defaults]`.""" + dest_volume / sources_dir / machine / iso_filename fall back + to `[defaults]`.""" dest_volume = _default(dest_volume, "dest_volume", "dest_volume") sources_dir = _default(sources_dir, "sources_dir", "sources_dir") machine = _default(machine, "machine", "machine") - if bootstrap_dir is None: - bootstrap_dir = fleet.config.defaults.bootstrap_dir if iso_filename is None: iso_filename = fleet.config.defaults.iso_filename return await installer_tool.installer_stage( @@ -1835,7 +1836,6 @@ async def installer_stage( machine=machine, iso_filename=iso_filename, iso_lha_path=iso_lha_path, - bootstrap_dir=bootstrap_dir, confirm=confirm, ) diff --git a/host/src/amiga_fleet_mcp/setup_wizard.py b/host/src/amiga_fleet_mcp/setup_wizard.py index 4b29893..51f7cf7 100644 --- a/host/src/amiga_fleet_mcp/setup_wizard.py +++ b/host/src/amiga_fleet_mcp/setup_wizard.py @@ -294,7 +294,6 @@ def _wizard_defaults(prompt: Prompt, cfg: dict[str, Any]) -> None: for key, hint in [ ("dest_volume", 'AmigaDOS volume to install into (e.g. "BootTest:")'), ("sources_dir", "host directory holding source ISOs / LHAs"), - ("bootstrap_dir", "diskimage-bootstrap directory"), ("machine", 'canonical machine identifier (e.g. "X5000")'), ]: v = _ask_optional(prompt, f"{key} — {hint}") diff --git a/host/src/amiga_fleet_mcp/tools/installer.py b/host/src/amiga_fleet_mcp/tools/installer.py index e9b789a..e2a5d83 100644 --- a/host/src/amiga_fleet_mcp/tools/installer.py +++ b/host/src/amiga_fleet_mcp/tools/installer.py @@ -336,10 +336,11 @@ def add(name: str, status: str, detail: str) -> None: add("required_files", "ok", f"all {len(manifest) - 1} non-ISO files present") - # Mandatory binaries: MCPd + diskimage-bootstrap files. - # Auto-resolves from sources_dir / repo / known host paths. + # Mandatory host-side binaries: just MCPd. The AOS 4.1 + # diskimage tools come from the running AmigaOS / the install + # ISO at install time -- no host-side dependency. src_path = Path(scan["sources_dir"]) - mand = _mandatory_binaries(src_path, None) + mand = _mandatory_binaries(src_path) missing_mand = [m["name"] for m in mand if m["path"] is None] if missing_mand: add("mandatory_binaries", "fail", @@ -919,9 +920,9 @@ async def installer_run( omitted and `sources_dir` is given, we auto-detect from a host-side scan_sources of `sources_dir`. - The caller is responsible for having already staged ISO + LHAs + - diskimage-bootstrap/ into `:tmp/` before this runs (use - `installer.stage` to do that). + The caller is responsible for having already staged ISO + LHAs + into `:tmp/` before this runs (use `installer.stage` to do + that). """ canonical = _resolve_sequence_machine(machine) @@ -1020,8 +1021,8 @@ class InstallerStageResult(BaseModel): def _install_support_root() -> Path | None: """Resolve the host-side install-support root (a directory that - holds diskimage-bootstrap/, AmiDock prefs XML, and other small - files that ship with the install pipeline). + holds AmiDock prefs XML and any other small files a user wants to + override the bundled defaults with). Lookup order: 1. $AMIGA_INSTALL_SUPPORT_DIR if set and exists. @@ -1044,36 +1045,6 @@ def _install_support_root() -> Path | None: return None -def _candidate_bootstrap_dirs( - sources_dir: Path | None = None, -) -> list[Path]: - """Where to look for the diskimage-bootstrap files on the host.""" - out: list[Path] = [] - if sources_dir is not None: - out.append(sources_dir / "diskimage-bootstrap") - root = _install_support_root() - if root is not None: - out.append(root / "diskimage-bootstrap") - out.extend([ - Path.cwd() / "diskimage-bootstrap", - Path.home() / "diskimage-bootstrap", - ]) - return out - - -def _resolve_bootstrap_dir( - explicit: str | None, - sources_dir: Path | None = None, -) -> Path | None: - if explicit: - p = Path(explicit).expanduser().resolve() - return p if p.is_dir() else None - for cand in _candidate_bootstrap_dirs(sources_dir): - if cand.is_dir(): - return cand - return None - - def _candidate_install_support_dirs() -> list[Path]: """Where to look for AmiDock.amiga.com.xml + other host-side install-support files. Each install copies them to :tmp/ @@ -1086,13 +1057,18 @@ def _candidate_install_support_dirs() -> list[Path]: Path.cwd(), Path.home(), ]) + # Bundled package resources ship a default AmiDock.amiga.com.xml + # so the install pipeline doesn't depend on the user staging a copy + # by hand. Other resources can sit alongside it. + out.append(Path(__file__).resolve().parent.parent + / "installer" / "resources") return out def _find_in_sources_or_support(filename: str, sources_dir: Path) -> Path | None: """Look for `filename` in sources_dir first, then in the host-side - install-support directories.""" + install-support directories (including bundled package resources).""" cand = sources_dir / filename if cand.is_file(): return cand @@ -1127,35 +1103,24 @@ def _resolve_mcpd_binary(sources_dir: Path) -> Path | None: # What the preflight + stage need to find before allowing an install. # Each entry has: name, friendly description, and a resolver function # returning a Path or None. -def _mandatory_binaries(sources_dir: Path, - bootstrap_dir_arg: str | None = None) -> list[dict]: - """Build the list of mandatory binaries + bootstrap files for an - install, with their resolved location (or None if missing). +def _mandatory_binaries(sources_dir: Path) -> list[dict]: + """Build the list of mandatory host-side binaries for an install + with each resolved location (or None if missing). + + Currently MCPd is the only host-side binary required: AOS 4.1's + diskimage tools (MountDiskImage / diskimage.device / CDFileSystem) + are sourced from the running AmigaOS at install time and from the + install ISO into the dest drive via copy_base_os, so they don't + need to be staged from the host. This list MUST be all-found before installer_stage runs. preflight cross-checks the same list and reports them in `missing_files`.""" mcpd = _resolve_mcpd_binary(sources_dir) - bootstrap = _resolve_bootstrap_dir(bootstrap_dir_arg, sources_dir) - bootstrap_files: dict[str, Path | None] = {} - if bootstrap is not None: - for fname in ("MountDiskImage", "diskimage.device", "CDFileSystem"): - f = bootstrap / fname - bootstrap_files[fname] = f if f.is_file() else None - else: - for fname in ("MountDiskImage", "diskimage.device", "CDFileSystem"): - bootstrap_files[fname] = None - out = [ + return [ {"name": "MCPd", "desc": "MCPd daemon binary (auto-starts on first boot)", "path": mcpd}, ] - for fname, p in bootstrap_files.items(): - out.append({ - "name": f"diskimage-bootstrap/{fname}", - "desc": "diskimage-bootstrap file (mount_iso step)", - "path": p, - }) - return out async def installer_stage( @@ -1165,20 +1130,22 @@ async def installer_stage( machine: str, iso_filename: str | None = None, iso_lha_path: str | None = None, - bootstrap_dir: str | None = None, confirm: bool = False, ) -> InstallerStageResult: - """Upload ISO + LHAs + diskimage-bootstrap/ into `:tmp/`. + """Upload ISO + Update LHAs + Enhancer + extras + MCPd + AmiDock + prefs into `:tmp/`. Mutating; requires `confirm=True` (a multi-GB upload is not - something to fire by accident). Composes: - - chunked_upload (zlib auto, 16 MiB chunks) for the ISO + LHAs - - chunked_upload for each of the 3 diskimage-bootstrap files + something to fire by accident). + + The AOS 4.1 diskimage tools (MountDiskImage / diskimage.device / + CDFileSystem) are NOT staged from the host -- the running + AmigaOS uses its own copies at mount time, and the dest drive + receives them via copy_base_os from the install ISO's System/ + tree. No host-side `diskimage-bootstrap/` directory is needed. `iso_filename` may be omitted; we detect from `sources_dir` via - scan_sources. `bootstrap_dir` defaults to a search of standard - host paths; pass explicitly if your install-support tree is - elsewhere. + scan_sources. `iso_lha_path` (optional): path to a `.iso.lha` file (an LHA archive containing the bare .iso file as a single member). When @@ -1233,29 +1200,18 @@ async def installer_stage( f"found in sources_dir, and no iso_lha_path given", ) - bootstrap = _resolve_bootstrap_dir(bootstrap_dir) - if bootstrap is None: - raise InvalidParams( - "diskimage-bootstrap directory not found; pass " - "bootstrap_dir explicitly. Looked in: " - f"{[str(p) for p in _candidate_bootstrap_dirs()]}", - ) - - # Mandatory-binaries check: MCPd + bootstrap files. - # Fail loudly if any are missing -- previously these were silently - # skipped which left installs without auto-starting MCPd / unable - # to mount the ISO. - mand = _mandatory_binaries(src, bootstrap_dir) + # Mandatory-binaries check: just MCPd. Fail loudly if missing -- + # previously this was silently skipped, which left installs + # without auto-starting MCPd. + mand = _mandatory_binaries(src) missing_mand = [m for m in mand if m["path"] is None] if missing_mand: raise InvalidParams( "installer.stage cannot proceed: mandatory file(s) missing.\n" + "\n".join( f" - {m['name']}: {m['desc']}" for m in missing_mand - ) + "\nPut MCPd in sources_dir or build it with `make -C mcpd " - "docker-build`. Bootstrap files come from the install-" - "support tree (set $AMIGA_INSTALL_SUPPORT_DIR or pass " - "bootstrap_dir).", + ) + "\nPut MCPd in sources_dir or build it with " + "`make -C mcpd docker-build`.", data={"missing": [m["name"] for m in missing_mand]}, ) @@ -1311,12 +1267,6 @@ async def installer_stage( continue items.append((entry["role"], local, staging + entry["filename"])) - # diskimage-bootstrap files (mandatory; presence already verified - # above via _mandatory_binaries). - bootstrap_dst = staging + "diskimage-bootstrap/" - for fname in ("MountDiskImage", "diskimage.device", "CDFileSystem"): - items.append(("bootstrap", bootstrap / fname, bootstrap_dst + fname)) - # MCPd binary (mandatory; presence already verified above). mcpd_local = _resolve_mcpd_binary(src) assert mcpd_local is not None # _mandatory_binaries enforced this @@ -1352,10 +1302,9 @@ async def installer_stage( "step will be a no-op (dock will use stock layout)" ) - # Ensure tmp/ + tmp/diskimage-bootstrap/ exist on the target + # Ensure tmp/ exists on the target mcpd = fleet.mcpd(target) await amiga_makedir(mcpd, staging) - await amiga_makedir(mcpd, bootstrap_dst) # 24 MiB raw + base64 (1.33x) lands at ~32 MiB which exceeds the # MCPD_FRAME_MAX_PAYLOAD cap once envelope overhead is included. diff --git a/host/tests/unit/test_installer_other_machines.py b/host/tests/unit/test_installer_other_machines.py index 9a47ea9..1cd3cbd 100644 --- a/host/tests/unit/test_installer_other_machines.py +++ b/host/tests/unit/test_installer_other_machines.py @@ -47,7 +47,8 @@ def test_pegasos2_sequence_shape(): assert names[0] == "stage_diskimage_tools" assert names[1] == "extract_iso_lha" assert names[2] == "mount_iso" - assert names[-2] == "unmount_iso" + assert names[-3] == "unmount_iso" + assert names[-2] == "dismount_combi_device" assert names[-1] == "cleanup_tmp" @@ -112,7 +113,9 @@ def test_all_implemented_sequences_have_same_skeleton(): # SerialShell auto-start, MCPd auto-start, then unmount + clean. # quarantine_emu10kx (X5000-only) sits between SerialShell and # MCPd when present, so we only pin the tail to the universal bits. - expected_epilogue = ["install_mcpd", "unmount_iso", "cleanup_tmp"] + expected_epilogue = [ + "install_mcpd", "unmount_iso", "dismount_combi_device", "cleanup_tmp", + ] for seq in ( pegasos2_seq.build(iso_filename="Pegasos2InstallCD-53.42.iso"), amigaone_seq.build(iso_filename="AmigaOneInstallCD-53.42.iso"), diff --git a/host/tests/unit/test_installer_stage_verify.py b/host/tests/unit/test_installer_stage_verify.py index 4ec7d8e..099f063 100644 --- a/host/tests/unit/test_installer_stage_verify.py +++ b/host/tests/unit/test_installer_stage_verify.py @@ -120,23 +120,26 @@ async def fake_upload(fleet_, target, local, remote, **_): dest_volume="BootTest:", sources_dir=str(tmp_path), machine="X5000", - bootstrap_dir=str(bootstrap), confirm=True, ) - # ISO + 2 updates + enhancer + 2 extras + 3 bootstrap + MCPd - # + SerialShell + AmiDock XML = 12 (when all auto-detect succeeds) - assert len(uploaded) == 12 + # ISO + 2 updates + enhancer + 2 extras + MCPd + SerialShell + + # AmiDock XML = 9 (no bootstrap upload -- diskimage tools come + # from the running AmigaOS / install ISO). + assert len(uploaded) == 9 assert res.machine == "X5000" assert res.iso_filename == "AmigaOneX5000InstallCD-53.42.iso" assert res.skipped == [] - # Spot check: ISO landed under tmp/, bootstrap files under tmp/diskimage-bootstrap/ + # Spot check: ISO landed under tmp/, no bootstrap files staged. iso_dst = next(dst for src, dst in uploaded if src.endswith(".iso")) assert iso_dst == "BootTest:tmp/AmigaOneX5000InstallCD-53.42.iso" bootstrap_dsts = [dst for src, dst in uploaded if "diskimage-bootstrap" in dst] - assert len(bootstrap_dsts) == 3 + assert bootstrap_dsts == [] + # Make sure pyflakes doesn't complain about the unused fixture + # output (kept so future tests can assert on it). + assert bootstrap is not None @pytest.mark.asyncio @@ -169,7 +172,6 @@ async def fake_upload(*a, **kw): dest_volume="BootTest:", sources_dir=str(tmp_path), machine="X5000", - bootstrap_dir=str(bs), confirm=True, ) assert len(res.skipped) > 0 diff --git a/host/tests/unit/test_installer_x5000_seq.py b/host/tests/unit/test_installer_x5000_seq.py index edfd371..c4b5d67 100644 --- a/host/tests/unit/test_installer_x5000_seq.py +++ b/host/tests/unit/test_installer_x5000_seq.py @@ -45,6 +45,7 @@ def test_x5000_sequence_step_order(): "quarantine_devs_monitors", "install_mcpd", "unmount_iso", + "dismount_combi_device", "cleanup_tmp", ] From 8b169d4c66dd3f3880cfd82ba68e9974d74c8926 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 13:47:11 +0100 Subject: [PATCH 02/23] =?UTF-8?q?sandbox.*=20phase=20A=20=E2=80=94=20probe?= =?UTF-8?q?=20+=20deploy=20+=20run=5Fguest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps SandboxVM (the AOS4 in-process sandbox host) so an agent can deploy a guest ELF and run it on a target without power-cycling on every crash. Three host-side tools registered: sandbox.probe — path resolution + executability + Pegasos2 refusal (raises NotCapable). Eager; bypasses the 60 s in-process probe cache. sandbox.deploy — fs.upload wrapper; resolves source from [paths] sandboxvm, dest from [targets..sandbox.path] (defaults to SYS:Tools/sandboxvm). Verifies via SHA-256 and re-probes the just-uploaded path so the cache can't be shadowed by a legacy default-path binary. confirm=True required. sandbox.run_guest — runs one guest ELF, slurps T:sandboxvm- .{out,err}, decodes SandboxVM's exit-code convention into trap_kind (DSI/ISI/alignment/ program/fp_unavailable), and matches the documented kmod-libcall NULL+4 fingerprint against captured stderr. Per-target asyncio lock so concurrent calls serialise instead of racing the T:capture files. Every public sandbox.* tool routes through _ensure_available first, which keeps a 60 s positive-result cache and raises typed errors (InvalidParams for SANDBOXVM_MISSING / SANDBOXVM_BROKEN, NotCapable for SANDBOXVM_INCOMPATIBLE_TARGET) so a misconfigured target fails fast instead of running half a workflow. Config additions: [paths] sandboxvm -- host source [targets..sandbox.path] -- AOS dest [targets..sandbox.default_extmem_mb] [targets..sandbox.default_window_mb] [targets..sandbox.deny_libs] -- always-on -x Validated end-to-end: 227 unit tests pass (208 prior + 19 new), ruff + mypy clean, server smoke check shows the four sandbox tools register correctly (count 121 -> 124). Phase B (sys.debug_ring + sandbox.run_driver + sandbox.last_trap) and Phase C (sandbox.run_batch) follow in later commits per SANDBOX_PLAN.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + host/config.example.toml | 16 + host/src/amiga_fleet_mcp/config.py | 30 + host/src/amiga_fleet_mcp/server.py | 118 ++++ host/src/amiga_fleet_mcp/tools/sandbox.py | 805 ++++++++++++++++++++++ host/tests/unit/test_sandbox.py | 567 +++++++++++++++ 6 files changed, 1537 insertions(+) create mode 100644 host/src/amiga_fleet_mcp/tools/sandbox.py create mode 100644 host/tests/unit/test_sandbox.py diff --git a/.gitignore b/.gitignore index 178a34c..2c48638 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ REQUIREMENTS.md INVESTIGATION.md IMPLEMENTATION.md MCP_CAPABILITIES.md +SANDBOX_PLAN.md docs/private/ plans/ notes/ diff --git a/host/config.example.toml b/host/config.example.toml index d303bc1..b7eac9b 100644 --- a/host/config.example.toml +++ b/host/config.example.toml @@ -34,10 +34,14 @@ mcp_transport = "stdio" # stdio | sse | streamable-http # of derfsss/AmigaQemuTests. # qemu_binary Required by `qemu.start`. Absolute path to # qemu-system-ppc (qemu-system-ppc.exe on Windows). +# sandboxvm Used by `sandbox.deploy` to resolve the upload source +# when no explicit `source` arg is given. Absolute path +# to a built `bin/sandboxvm` (PPC AOS4 binary). # # qemu_runner = "" # amiga_qemu_tests = "" # qemu_binary = "" +# sandboxvm = "" [defaults] # Per-tool parameter defaults. When a tool is invoked without a @@ -99,3 +103,15 @@ tags = ["real-hw", "x5000"] # enabled = true # port = "COM5" # or /dev/ttyUSB1 on Linux/macOS # baud = 38400 + + # Optional: SandboxVM overrides for this target. The `sandbox.*` + # namespace runs guest ELFs inside SandboxVM (an in-process + # sandbox host that survives guest crashes). When omitted, + # `sandbox.probe` searches SYS:Tools/sandboxvm, + # SYS:Utilities/sandboxvm, then C:sandboxvm; default extmem + # window is 1024 MB and default vmem window is 256 MB. + # [targets.x5000-real.sandbox] + # path = "Tools:sandboxvm" + # default_extmem_mb = 1024 + # default_window_mb = 256 + # deny_libs = ["intuition.library"] # always denied diff --git a/host/src/amiga_fleet_mcp/config.py b/host/src/amiga_fleet_mcp/config.py index d8626b0..6e0333e 100644 --- a/host/src/amiga_fleet_mcp/config.py +++ b/host/src/amiga_fleet_mcp/config.py @@ -165,6 +165,31 @@ class TargetChannels(BaseModel): mcu: SerialChannel | None = None +class SandboxTargetConfig(BaseModel): + """Per-target SandboxVM overrides. + + Lives under `[targets..sandbox]` in config.toml. All fields + optional; the `sandbox.*` namespace falls back to built-in defaults + when an entry is omitted. + """ + + model_config = ConfigDict(extra="ignore") + + path: str | None = None + """AOS path to sandboxvm on this target (e.g. ``"Tools:sandboxvm"``). + When omitted, `sandbox.probe` searches a default list.""" + + default_extmem_mb: int = 1024 + """Default ``-m `` for sandbox.run_guest.""" + + default_window_mb: int = 256 + """Default ``-w `` for sandbox.run_guest.""" + + deny_libs: list[str] = Field(default_factory=list) + """Libraries always denied via ``-x`` for every guest run on this + target. Merged with per-call ``deny_libs``.""" + + class TargetConfig(BaseModel): model_config = ConfigDict(extra="ignore") @@ -177,6 +202,7 @@ class TargetConfig(BaseModel): headless: bool = False tags: list[str] = Field(default_factory=list) channels: TargetChannels = Field(default_factory=TargetChannels) + sandbox: SandboxTargetConfig | None = None class PathsConfig(BaseModel): @@ -188,6 +214,10 @@ class PathsConfig(BaseModel): spe_tests: Path | None = None adtools_gdb: Path | None = None qemu_binary: Path | None = None + sandboxvm: Path | None = None + """Host-side path to a built ``bin/sandboxvm``. Used by + ``sandbox.deploy`` to resolve the upload source when no explicit + ``source`` is passed.""" class DefaultsConfig(BaseModel): diff --git a/host/src/amiga_fleet_mcp/server.py b/host/src/amiga_fleet_mcp/server.py index 5f7ba6b..43fa853 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -31,6 +31,7 @@ from .tools import installer as installer_tool from .tools import power as power_tool from .tools import qemu as qemu_tool +from .tools import sandbox as sandbox_tool from .tools import serial as serial_tool from .tools import snapshots as snap_tool from .tools import sys as sys_tool @@ -2085,6 +2086,123 @@ async def serial_ns(*, method: str, params: dict[str, Any] | None = None) -> Any "clear": serial_tool.serial_clear, }, method, p) + # ============================================================ + # sandbox.* — SandboxVM-driven dev loop on AOS4 targets + # ============================================================ + + @mcp.tool(name="sandbox_probe", title="sandbox.probe") + @archived("sandbox.probe", archive) + async def sandbox_probe( + *, target: str | None = None, + ) -> sandbox_tool.SandboxProbeResult: + """Probe a target for SandboxVM availability. + + Returns a structured result describing whether sandboxvm is + reachable, runnable, and on a compatible machine. Bypasses + the in-process probe cache (always re-checks). Other + ``sandbox.*`` tools call into the cached version internally + before doing work, so the typical caller never has to invoke + ``sandbox.probe`` directly — but it's exposed for diagnosis.""" + return await sandbox_tool.sandbox_probe( + fleet, fleet.resolve_target(target), + ) + + @mcp.tool(name="sandbox_deploy", title="sandbox.deploy") + @archived("sandbox.deploy", archive) + async def sandbox_deploy( + *, target: str | None = None, + source: str | None = None, + dest_path: str | None = None, + confirm: bool = False, + ) -> sandbox_tool.DeployResult: + """Upload sandboxvm to a target. Convenience wrapper around + ``fs.upload``: resolves source path (`[paths] sandboxvm` or + explicit ``source`` arg), resolves dest path + (``[targets..sandbox.path]`` or default + ``SYS:Tools/sandboxvm``), runs the upload with SHA-256 + verification, and invalidates the probe cache so the next + ``sandbox.*`` call re-validates against the fresh binary. + + Mutating; requires ``confirm=True``.""" + return await sandbox_tool.sandbox_deploy( + fleet, fleet.resolve_target(target), + source=source, dest_path=dest_path, confirm=confirm, + ) + + @mcp.tool(name="sandbox_run_guest", title="sandbox.run_guest") + @archived("sandbox.run_guest", archive) + async def sandbox_run_guest( + *, target: str | None = None, + guest: str, + args: list[str] | None = None, + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: list[str] | None = None, + name: str | None = None, + timeout_s: float = 120.0, + ) -> sandbox_tool.GuestRunResult: + """Run one guest ELF inside SandboxVM and return a structured + result. + + Probe-gated: a missing / broken / Pegasos2 target raises a + typed error before any work happens. Output goes to + ``T:sandboxvm-.{out,err}`` on the target; the wrapper + slurps + decodes both files after the run and deletes them. + + ``exit_code`` follows SandboxVM's convention: 0 on a clean + guest exit, positive for the guest's own rc, negative for + traps (-768 == DSI, -1024 == ISI, ...). When the exit code + matches a known trap shape, ``trap_kind`` is populated. + + For arbitrary AmigaDOS commands without the SandboxVM + harness, use ``exec.cmd``. For JSON-config-driven multi-step + test bundles, use ``tests.run_suite``.""" + return await sandbox_tool.sandbox_run_guest( + fleet, fleet.resolve_target(target), + guest=guest, args=args, + extmem_mb=extmem_mb, window_mb=window_mb, + deny_libs=deny_libs, name=name, timeout_s=timeout_s, + ) + + @mcp.tool(name="sandbox", title="sandbox.dispatch") + @archived("sandbox.dispatch", archive) + async def sandbox_ns( + *, method: str, params: dict[str, Any] | None = None, + ) -> Any: + """sandbox.* dispatcher. ``method`` is one of: + + probe(target=None) + deploy(target=None, source=None, dest_path=None, confirm=False) + run_guest(target=None, guest, args=None, extmem_mb=None, + window_mb=None, deny_libs=None, name=None, + timeout_s=120.0) + + target falls back to ``[server] default_target``. Mutating + methods need ``confirm=True``.""" + p = params or {} + if method == "probe": + return await sandbox_tool.sandbox_probe( + fleet, fleet.resolve_target(p.get("target")), + ) + if method == "deploy": + return await sandbox_tool.sandbox_deploy( + fleet, fleet.resolve_target(p.get("target")), + source=p.get("source"), + dest_path=p.get("dest_path"), + confirm=p.get("confirm", False), + ) + if method == "run_guest": + kwargs = {k: v for k, v in p.items() if k != "target"} + return await sandbox_tool.sandbox_run_guest( + fleet, fleet.resolve_target(p.get("target")), **kwargs, + ) + raise MethodNotFound( + f"unknown method: {method!r}", + data={"namespace_methods": [ + "probe", "deploy", "run_guest", + ]}, + ) + def _build_runtime(config: Config) -> tuple[Fleet, Archive]: fleet = Fleet(config) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py new file mode 100644 index 0000000..2bd7fc8 --- /dev/null +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -0,0 +1,805 @@ +"""sandbox.* — drive SandboxVM on AmigaOS 4 targets. + +Phase A surface (this file): probe, deploy, run_guest. Each call goes +through `_ensure_available` first so a missing / broken / Pegasos2 +target fails fast with a typed error, never half-runs. + +Surface contract: + + sandbox.probe(target=None) -> SandboxProbeResult + Eager probe. Path resolution + executable check + machine + compatibility. Writes to the in-process cache (60 s TTL). + + sandbox.deploy(target=None, source=None, dest_path=None, + confirm=False) -> DeployResult + Convenience wrapper around fs.upload that places the host's + bin/sandboxvm onto the target's resolved path. Invalidates + the probe cache for that target on success. + + sandbox.run_guest(target=None, guest=None, args=None, + extmem_mb=None, window_mb=None, deny_libs=None, + name=None, timeout_s=120) -> GuestRunResult + Run one guest ELF inside SandboxVM, slurp its T:capture + files, decode SandboxVM's exit-code convention into a + structured result (trap_kind / trap_fingerprint). + +Cross-references: + + - For arbitrary AmigaDOS commands without the SandboxVM harness: + use `exec.cmd`. + - For JSON-config-driven multi-step test bundles without + SandboxVM: use `tests.run_suite`. + - For the source upload step in isolation: `fs.upload`. + +The full kernel debug ring + SandboxVM trap-line filtering arrive +in Phase B (`sys.debug_ring` + `sandbox.last_trap`). +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import time +from collections.abc import Sequence +from pathlib import Path + +from pydantic import BaseModel, Field + +from ..errors import InvalidParams, NotCapable, TargetError +from ..fleet import Fleet +from . import fs as fs_tool + +# --------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------- + +PROBE_CACHE_TTL_S = 60.0 +"""How long a successful probe stays cached per target.""" + +DEFAULT_AOS_SEARCH = ( + "SYS:Tools/sandboxvm", + "SYS:Utilities/sandboxvm", + "C:sandboxvm", +) +"""Where `sandbox.probe` looks for sandboxvm when no per-target path is +configured. First `fs.stat` hit wins.""" + +DEFAULT_DEPLOY_PATH = "SYS:Tools/sandboxvm" +"""Where `sandbox.deploy` writes when no explicit `dest_path` is set +and no per-target `[targets..sandbox.path]` is configured.""" + +# Machine identifiers that explicitly lack ExtMem and therefore can't +# host SandboxVM. Both `targets..machine` (QEMU) and +# `targets..tags` (real-hw) are checked. +INCOMPATIBLE_MACHINES = frozenset({"pegasos2"}) + +# SandboxVM's exit-code convention: trap_type negated. See +# upstream PROGRESS.md / README.md. +TRAP_KIND_BY_EXIT_CODE: dict[int, str] = { + -0x300: "DSI", + -0x400: "ISI", + -0x600: "alignment", + -0x700: "program", + -0x800: "fp_unavailable", +} + +# Regexes used to fingerprint specific trap shapes in the (rare) cases +# where the exit-code alone isn't enough. Currently only the upstream- +# documented kmod-libcall NULL+4 signature; more arrive in Phase B +# when sandbox.last_trap lands. +KMOD_LIBCALL_NULL4_HINTS = ( + "Traptype=0x300", + "dar=0x4", + "dsisr=0x00800000", +) + + +# --------------------------------------------------------------------- +# Result models +# --------------------------------------------------------------------- + + +class SandboxProbeResult(BaseModel): + """Return shape for `sandbox.probe`.""" + + target: str + available: bool + code: str | None = None + """Error code when ``available is False``. One of + ``SANDBOXVM_MISSING``, ``SANDBOXVM_BROKEN``, + ``SANDBOXVM_INCOMPATIBLE_TARGET``. ``None`` when available.""" + path: str | None = None + """Resolved on-target AOS path when available.""" + searched: list[str] = Field(default_factory=list) + """Paths actually consulted by `fs.stat` (in order). Useful when + diagnosing a missing-binary hit.""" + version_banner: str | None = None + """First line of sandboxvm's stderr/stdout when invoked with no + args (its usage banner). Acts as a coarse identity check. ``None`` + when the binary failed to start or wasn't found.""" + machine: str | None = None + """The target's machine identifier (e.g. ``"X5000"``, + ``"pegasos2"``) or ``None`` when the target config doesn't + specify one.""" + machine_ok: bool | None = None + """``False`` only when the machine is on + `INCOMPATIBLE_MACHINES`. ``None`` when machine is unknown.""" + hint: str | None = None + """Human-readable next-step suggestion when ``available is + False``.""" + + +class DeployResult(BaseModel): + """Return shape for `sandbox.deploy`.""" + + target: str + source: str + dest_path: str + bytes_total: int + elapsed_s: float + sha256: str | None = None + probe_after: SandboxProbeResult + + +class GuestRunResult(BaseModel): + """Return shape for `sandbox.run_guest`.""" + + target: str + guest: str + name: str + command: str + exit_code: int + trap_kind: str | None = None + trap_fingerprint: str | None = None + stdout: str = "" + stderr: str = "" + duration_s: float = 0.0 + capture_paths: list[str] = Field(default_factory=list) + + +# --------------------------------------------------------------------- +# Module-level state +# --------------------------------------------------------------------- + +# Probe cache: target -> (monotonic-expiry-seconds, last-result). +_PROBE_CACHE: dict[str, tuple[float, SandboxProbeResult]] = {} + +# Per-target serialisation locks. Two simultaneous run_guest calls on +# the same target would step on each other's T:capture files; we +# serialise to keep things deterministic. Phase D is the place to add +# parallelism if a real workload needs it. +_TARGET_LOCKS: dict[str, asyncio.Lock] = {} + + +def _lock_for(target: str) -> asyncio.Lock: + if target not in _TARGET_LOCKS: + _TARGET_LOCKS[target] = asyncio.Lock() + return _TARGET_LOCKS[target] + + +def _invalidate_probe(target: str) -> None: + _PROBE_CACHE.pop(target, None) + + +def _resolve_target_machine(fleet: Fleet, target: str) -> str | None: + """Return the canonical machine name for `target` (lowercased) or + ``None`` when the config doesn't pin one. We consult both the + explicit ``machine`` field (QEMU targets) and the ``tags`` list + (real-hw targets carry e.g. ``["real-hw", "x5000", "p5020"]``).""" + cfg = fleet.target_config(target) + if cfg.machine: + return cfg.machine.lower() + for tag in cfg.tags: + low = tag.lower() + if low in INCOMPATIBLE_MACHINES: + return low + # Common machine tags surface here for the human-readable + # `machine` field in the result. + if low in {"x5000", "x1000", "a1222", "amigaone", "sam460ex"}: + return low + return None + + +def _candidate_target_paths(fleet: Fleet, target: str) -> list[str]: + """Where to look for sandboxvm on the target.""" + out: list[str] = [] + cfg = fleet.target_config(target) + if cfg.sandbox and cfg.sandbox.path: + out.append(cfg.sandbox.path) + out.extend(p for p in DEFAULT_AOS_SEARCH if p not in out) + return out + + +# --------------------------------------------------------------------- +# Probe +# --------------------------------------------------------------------- + + +async def _execute_banner_probe(fleet: Fleet, target: str, + path: str) -> str | None: + """Run sandboxvm with no args and capture the usage banner. + + Returns the first non-empty line of output when the binary + started and produced something. Returns ``None`` (caller treats + as broken) when invocation failed entirely. + + SandboxVM exits non-zero with a usage banner when called with no + args (per `src/main.c:usage()`); we therefore *expect* a non-zero + exit and don't treat it as failure. We pin to "binary started + + produced a short banner" rather than a specific exit code so an + upstream change to the exit value doesn't break the probe.""" + mcpd = fleet.mcpd(target) + try: + raw = await mcpd.request( + "exec.cmd", + { + "command": f'"{path}"', + "timeout_ms": 5000, + }, + timeout_s=10.0, + ) + except Exception: + return None + output = str(raw.get("output", "")).strip() + if not output: + return None + # Take the first line; safer than returning multi-line garbage in + # the probe result. + return output.splitlines()[0] + + +async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: + """Eager probe — bypass the cache. Three checks, in order: + + 1. Target machine is compatible (not Pegasos2 / not on + `INCOMPATIBLE_MACHINES`). + 2. sandboxvm binary is reachable on the target (`fs.stat` walks + the search list). + 3. Binary actually starts and produces a banner. + + The return shape is deliberately verbose so an MCP client can + decide what to do without re-probing.""" + machine = _resolve_target_machine(fleet, target) + if machine in INCOMPATIBLE_MACHINES: + result = SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_INCOMPATIBLE_TARGET", + machine=machine, machine_ok=False, + hint=(f"target machine {machine!r} lacks ExtMem; SandboxVM " + "refuses to run on it. Use a different target " + "(X5000 / A1222 / QEMU AmigaOne)."), + ) + # Machine compatibility never changes for a target, so it's + # safe to keep this result in the cache for the full TTL. + _PROBE_CACHE[target] = ( + time.monotonic() + PROBE_CACHE_TTL_S, result, + ) + return result + + machine_ok = None if machine is None else True + candidates = _candidate_target_paths(fleet, target) + found: str | None = None + mcpd = fleet.mcpd(target) + for path in candidates: + try: + await mcpd.request("fs.stat", {"path": path}) + found = path + break + except Exception: + continue + + if found is None: + result = SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_MISSING", + searched=candidates, + machine=machine, machine_ok=machine_ok, + hint=("sandboxvm binary not found on target. Run " + "`sandbox.deploy` to upload it, or set " + "`[targets..sandbox.path]` in config.toml " + "if it's installed at a non-default location."), + ) + # Negative result deliberately NOT cached — a follow-up + # `sandbox.deploy` would otherwise have to explicitly + # invalidate before subsequent calls work. + return result + + banner = await _execute_banner_probe(fleet, target, found) + if banner is None: + result = SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_BROKEN", + path=found, searched=candidates, + machine=machine, machine_ok=machine_ok, + hint=(f"sandboxvm at {found!r} did not start. Re-deploy " + "with `sandbox.deploy` (a corrupt upload is the " + "most common cause)."), + ) + return result + + result = SandboxProbeResult( + target=target, available=True, + path=found, searched=candidates, + version_banner=banner, + machine=machine, machine_ok=machine_ok, + ) + _PROBE_CACHE[target] = ( + time.monotonic() + PROBE_CACHE_TTL_S, result, + ) + return result + + +async def _probe_specific_path(fleet: Fleet, target: str, + path: str) -> SandboxProbeResult: + """Like `sandbox_probe` but pinned to a specific AOS path. + + Used by `sandbox_deploy` to verify the freshly-uploaded binary + rather than re-walking the default search list — which can find + a legacy binary at a higher-priority default location and + silently shadow the deploy.""" + machine = _resolve_target_machine(fleet, target) + if machine in INCOMPATIBLE_MACHINES: + return SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_INCOMPATIBLE_TARGET", + machine=machine, machine_ok=False, + ) + machine_ok = None if machine is None else True + + mcpd = fleet.mcpd(target) + try: + await mcpd.request("fs.stat", {"path": path}) + except Exception: + return SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_MISSING", + searched=[path], + machine=machine, machine_ok=machine_ok, + ) + + banner = await _execute_banner_probe(fleet, target, path) + if banner is None: + return SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_BROKEN", + path=path, searched=[path], + machine=machine, machine_ok=machine_ok, + ) + + return SandboxProbeResult( + target=target, available=True, + path=path, searched=[path], + version_banner=banner, + machine=machine, machine_ok=machine_ok, + ) + + +async def _ensure_available(fleet: Fleet, target: str) -> SandboxProbeResult: + """Cached probe gate used by every public sandbox.* tool except + `sandbox.probe` itself. Hits the in-process cache first; on miss + or expiry runs the full probe. + + Raises a typed error when the target isn't usable, so callers can + skip the "did probe pass?" boilerplate.""" + cached = _PROBE_CACHE.get(target) + now = time.monotonic() + if cached is not None and cached[0] > now and cached[1].available: + return cached[1] + res = await sandbox_probe(fleet, target) + if not res.available: + if res.code == "SANDBOXVM_INCOMPATIBLE_TARGET": + raise NotCapable( + res.hint or "target incompatible with SandboxVM", + data={"code": res.code, "target": target, + "machine": res.machine}, + ) + raise InvalidParams( + res.hint or "sandboxvm not available on target", + data={"code": res.code, "target": target, + "searched": res.searched}, + ) + return res + + +# --------------------------------------------------------------------- +# Deploy +# --------------------------------------------------------------------- + + +def _resolve_source(source: str | None, + config_default: Path | None) -> Path: + """Pick the host-side sandboxvm binary to upload. + + Order: + 1. explicit `source` arg + 2. `[paths] sandboxvm` in config.toml + + No magic search list — keep the resolution explicit and easy to + audit. The user wires it once in config.toml or passes it on the + call.""" + if source: + p = Path(source).expanduser().resolve() + if not p.is_file(): + raise InvalidParams( + f"sandbox.deploy source not found: {source!r}", + ) + return p + if config_default is not None: + p = Path(config_default).expanduser().resolve() + if not p.is_file(): + raise InvalidParams( + "sandbox.deploy: [paths] sandboxvm points at " + f"{config_default!s} but no file is there. Update " + "config.toml or pass `source` explicitly.", + ) + return p + raise InvalidParams( + "sandbox.deploy: no source given and [paths] sandboxvm is " + "not set. Pass `source=...` or add the path to config.toml.", + ) + + +def _resolve_dest_path(fleet: Fleet, target: str, + dest_path: str | None) -> str: + if dest_path: + return dest_path + cfg = fleet.target_config(target) + if cfg.sandbox and cfg.sandbox.path: + return cfg.sandbox.path + return DEFAULT_DEPLOY_PATH + + +async def sandbox_deploy( + fleet: Fleet, target: str, *, + source: str | None = None, + dest_path: str | None = None, + confirm: bool = False, +) -> DeployResult: + """Upload sandboxvm to a target's tools dir. + + Convenience wrapper around `fs.upload`; the only material logic + here is source/dest resolution and probe-cache invalidation. + + Mutating: requires ``confirm=True`` (overwrites the on-target + binary). The probe cache for `target` is cleared on success so + the next sandbox call re-validates against the freshly-deployed + file.""" + if not confirm: + raise InvalidParams( + "sandbox.deploy requires confirm=True (overwrites the " + "on-target sandboxvm binary).", + ) + + src = _resolve_source(source, fleet.config.paths.sandboxvm) + dst = _resolve_dest_path(fleet, target, dest_path) + + # Make sure the parent dir exists. AmigaDOS Copy + fs.write_chunk + # both refuse to create intermediate dirs, so do it ourselves + # idempotently. Strip any trailing slash and split off the file + # part. AOS paths use `/` as separator within an assign and `:` + # for the assign itself; we only need to handle the in-assign + # part. + parent = _aos_parent_dir(dst) + if parent: + try: + await fleet.mcpd(target).request( + "exec.cmd", + {"command": f'MakeDir >NIL: "{parent}"', + "timeout_ms": 5000}, + timeout_s=10.0, + ) + except Exception: + # Already exists or unwritable — let fs.upload surface the + # real failure rather than blocking on the makedir. + pass + + upload = await fs_tool.fs_upload( + fleet, target, + local_path=str(src), + remote_path=dst, + compression="auto", + verify=True, + ) + + _invalidate_probe(target) + probe_after = await _probe_specific_path(fleet, target, dst) + # Cache the path-pinned result so the next sandbox.* call doesn't + # re-walk the search list and risk picking up a legacy binary at + # a higher-priority default location. + if probe_after.available: + _PROBE_CACHE[target] = ( + time.monotonic() + PROBE_CACHE_TTL_S, probe_after, + ) + + return DeployResult( + target=target, + source=str(src), + dest_path=dst, + bytes_total=upload.bytes_total, + elapsed_s=upload.elapsed_s, + sha256=upload.sha256, + probe_after=probe_after, + ) + + +def _aos_parent_dir(path: str) -> str | None: + """Return the parent directory of an AOS path, or None when + `path` is just an assign root (e.g. ``"SYS:"``). + + Examples: + ``"SYS:Tools/sandboxvm"`` -> ``"SYS:Tools"`` + ``"Tools:bin/foo"`` -> ``"Tools:bin"`` + ``"SYS:foo"`` -> ``None`` (parent is the assign root) + ``"SYS:"`` -> ``None`` + """ + # Split off the assign prefix (everything up to and including ":"). + if ":" not in path: + return None + assign, _, rest = path.partition(":") + if not rest: + return None + parts = rest.rstrip("/").split("/") + if len(parts) <= 1: + return None + return f"{assign}:" + "/".join(parts[:-1]) + + +# --------------------------------------------------------------------- +# Run guest +# --------------------------------------------------------------------- + + +def _derive_guest_name(guest: str) -> str: + """Default `name` for a guest run: basename minus extension.""" + # Strip everything before the last `/` (AOS path) or `:` (assign). + base = guest + for sep in ("/", ":"): + if sep in base: + base = base.rsplit(sep, 1)[1] + if "." in base: + base = base.rsplit(".", 1)[0] + return base or "guest" + + +def _build_command(sandboxvm_path: str, *, + guest: str, + args: Sequence[str] | None, + extmem_mb: int, + window_mb: int, + deny_libs: Sequence[str], + name: str) -> str: + """Construct the AmigaDOS command line for one guest run. + + Format: + "" -m -w -n [-x lib]... [-- args...] + + Quoting note: AmigaDOS quoting is conservative. We wrap the + sandboxvm path and the guest path in double quotes so spaces / + colons survive; the daemon's `exec.cmd` handler is happy with + that. We don't quote -m / -w / -n values because they're integers + or simple identifiers.""" + parts: list[str] = [f'"{sandboxvm_path}"'] + parts.extend(["-m", str(extmem_mb)]) + parts.extend(["-w", str(window_mb)]) + parts.extend(["-n", _shell_safe(name)]) + for lib in deny_libs: + parts.extend(["-x", _shell_safe(lib)]) + parts.append(f'"{guest}"') + if args: + parts.append("--") + parts.extend(_shell_safe(a) for a in args) + return " ".join(parts) + + +def _shell_safe(token: str) -> str: + """Wrap a token in double quotes when it contains whitespace or + AmigaDOS metacharacters; otherwise return as-is. Daemon-side + quoting handles the bytes correctly either way; this just keeps + the command readable in logs.""" + if not token: + return '""' + needs_quoting = any(c in token for c in ' \t"<>|;') + if needs_quoting: + # Escape embedded quotes by AmigaDOS convention (`*"`). + escaped = token.replace('"', '*"') + return f'"{escaped}"' + return token + + +def _classify_exit(exit_code: int, + stderr: str) -> tuple[str | None, str | None]: + """Decode SandboxVM's exit code into a (trap_kind, fingerprint) + pair. Either or both may be ``None``. + + Trap kinds come from `TRAP_KIND_BY_EXIT_CODE` (the upstream + convention: trap_type negated and returned as the rc). + + Fingerprints are best-effort string-match against the trap + output; the only one currently recognised is the documented + kmod-libcall NULL+4 signature for `HD_SCSICMD via DoIO`. Phase B's + `sandbox.last_trap` adds debug-ring-based fingerprints for + cases where stderr alone doesn't carry the signature.""" + trap_kind = TRAP_KIND_BY_EXIT_CODE.get(exit_code) + fingerprint: str | None = None + if all(h in stderr for h in KMOD_LIBCALL_NULL4_HINTS): + fingerprint = "kmod_libcall_null4" + return trap_kind, fingerprint + + +async def _read_capture(fleet: Fleet, target: str, + path: str) -> tuple[str, bool]: + """fs.read a T:capture file and decode to text. Returns + ``(decoded, present)``: when the file doesn't exist we return + ``("", False)`` so the caller can distinguish missing-on-purpose + (guest never wrote anything) from genuine read failures.""" + try: + raw = await fleet.mcpd(target).request("fs.read", {"path": path}) + except TargetError: + return "", False + content_b64 = raw.get("content_b64", "") + if not content_b64: + return "", True + try: + decoded = base64.b64decode(content_b64).decode( + "utf-8", errors="replace", + ) + except Exception: + decoded = "" + return decoded, True + + +async def _delete_capture(fleet: Fleet, target: str, path: str) -> None: + """Best-effort cleanup of a T:capture file. Failures are + swallowed — leaving stale capture files in T: is mildly ugly + but never load-bearing.""" + try: + await fleet.mcpd(target).request("fs.delete", {"path": path}) + except Exception: + pass + + +async def sandbox_run_guest( + fleet: Fleet, target: str, *, + guest: str, + args: Sequence[str] | None = None, + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: Sequence[str] | None = None, + name: str | None = None, + timeout_s: float = 120.0, +) -> GuestRunResult: + """Run a single guest ELF inside SandboxVM on `target`. + + Probe-gated: a missing / broken / Pegasos2 target raises a typed + error before any work happens. + + Args: + guest: AOS path to the guest ELF (e.g. + ``"Tools:tests/clib4hello"``). + args: Optional list of strings appended via ``--``. + extmem_mb / window_mb: SandboxVM ``-m`` / ``-w`` values. + Defaults come from + ``[targets..sandbox.default_*]`` (1024 / 256 + when unset). + deny_libs: Per-call ``-x`` flags. Merged with any + ``[targets..sandbox.deny_libs]`` from config. + name: Used as ``-n`` (also drives the T:capture filenames). + Defaults to the basename of ``guest`` minus extension. + timeout_s: Host-side request timeout for the run.""" + probe = await _ensure_available(fleet, target) + assert probe.path is not None # guaranteed by _ensure_available + + # Resolve config-driven defaults. + cfg = fleet.target_config(target).sandbox + if extmem_mb is None: + extmem_mb = cfg.default_extmem_mb if cfg else 1024 + if window_mb is None: + window_mb = cfg.default_window_mb if cfg else 256 + + merged_deny: list[str] = [] + if cfg and cfg.deny_libs: + merged_deny.extend(cfg.deny_libs) + if deny_libs: + for lib in deny_libs: + if lib not in merged_deny: + merged_deny.append(lib) + + run_name = name or _derive_guest_name(guest) + out_path = f"T:sandboxvm-{run_name}.out" + err_path = f"T:sandboxvm-{run_name}.err" + + cmd = _build_command( + probe.path, + guest=guest, + args=args, + extmem_mb=extmem_mb, + window_mb=window_mb, + deny_libs=merged_deny, + name=run_name, + ) + + # Per-target lock so concurrent run_guest calls on the same + # target serialise instead of racing the T:capture files. + lock = _lock_for(target) + t0 = time.monotonic() + async with lock: + mcpd = fleet.mcpd(target) + try: + raw = await mcpd.request( + "exec.cmd", + { + "command": cmd, + "timeout_ms": int(timeout_s * 1000), + }, + timeout_s=max(timeout_s + 5.0, 30.0), + ) + except Exception: + # Try to slurp captures anyway — guest may have written + # something before the timeout fired. + stdout, _ = await _read_capture(fleet, target, out_path) + stderr, _ = await _read_capture(fleet, target, err_path) + await _delete_capture(fleet, target, out_path) + await _delete_capture(fleet, target, err_path) + raise + + exit_code = int(raw.get("exit_code", 0)) + # Daemon-side `exec.cmd` returns its own captured stdout, but + # SandboxVM's per-guest output goes via the T:files. Combine: + # daemon-output is the SandboxVM host wrapper's chatter (and + # any banners), the T:files carry the guest itself. + wrapper_output = str(raw.get("output", "")) + stdout, stdout_present = await _read_capture( + fleet, target, out_path, + ) + stderr, stderr_present = await _read_capture( + fleet, target, err_path, + ) + await _delete_capture(fleet, target, out_path) + await _delete_capture(fleet, target, err_path) + + # Wrapper output lands in stdout when no T:.out captured anything, + # in stderr when no T:.err did. Avoids losing useful diagnostics + # like "guest [0] returned -768" emitted by SandboxVM itself. + if wrapper_output and not (stdout_present or stderr_present): + stdout = wrapper_output + + trap_kind, fingerprint = _classify_exit(exit_code, stderr) + + capture_paths: list[str] = [] + if stdout_present: + capture_paths.append(out_path) + if stderr_present: + capture_paths.append(err_path) + + return GuestRunResult( + target=target, + guest=guest, + name=run_name, + command=cmd, + exit_code=exit_code, + trap_kind=trap_kind, + trap_fingerprint=fingerprint, + stdout=stdout, + stderr=stderr, + duration_s=time.monotonic() - t0, + capture_paths=capture_paths, + ) + + +# --------------------------------------------------------------------- +# Test-only helpers +# --------------------------------------------------------------------- + + +def _reset_module_state_for_tests() -> None: + """Clear caches/locks. Called by the test suite between + parametrised runs so probe results from one test don't leak + into the next. Not part of the public surface.""" + _PROBE_CACHE.clear() + _TARGET_LOCKS.clear() + + +# Suppress unused-import warning when the file is loaded in unusual +# contexts (e.g. module import tests). +_ = os diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py new file mode 100644 index 0000000..1a993c9 --- /dev/null +++ b/host/tests/unit/test_sandbox.py @@ -0,0 +1,567 @@ +"""sandbox.* unit tests via fake transport. + +Covers Phase A surface: probe (hit / miss / broken / Pegasos2), +deploy (cache invalidation), run_guest (happy path / trap exit / +config-driven defaults / per-target deny merge / per-target lock). +""" + +from __future__ import annotations + +import asyncio +import base64 +from pathlib import Path +from unittest import mock + +import pytest + +from amiga_fleet_mcp.config import ( + Config, + McpdChannel, + SandboxTargetConfig, + TargetChannels, + TargetConfig, +) +from amiga_fleet_mcp.errors import InvalidParams, NotCapable, TargetError +from amiga_fleet_mcp.fleet import Fleet +from amiga_fleet_mcp.tools import sandbox as sb + +# ---- fake transport ------------------------------------------------ + + +class FakeMcpd: + """Records calls and serves canned responses keyed by (method, + matcher). The matchers are tiny lambdas over the params dict so + individual tests can stage exact behaviour.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + self.existing_paths: set[str] = set() + self.file_contents: dict[str, bytes] = {} + self.exec_responses: list[tuple[ + object, dict[str, object] + ]] = [] + self.exec_default: dict[str, object] = { + "output": "", "exit_code": 0, + } + + def add_path(self, p: str, content: bytes = b"") -> None: + self.existing_paths.add(p) + if content: + self.file_contents[p] = content + + def queue_exec(self, matcher, response: dict) -> None: + """Add a one-shot canned response for the next exec.cmd call + whose params satisfy `matcher` (callable taking the params + dict, returns bool).""" + self.exec_responses.append((matcher, response)) + + async def request(self, method, params=None, timeout_s=30.0): + params = params or {} + self.calls.append((method, params)) + if method == "fs.stat": + if params["path"] in self.existing_paths: + return {"type": "file", "size": 1024} + raise TargetError( + "not found", data={"path": params["path"]}, + ) + if method == "fs.read": + path = params["path"] + if path not in self.existing_paths: + raise TargetError("not found", data={"path": path}) + content = self.file_contents.get(path, b"") + return { + "path": path, + "size": len(content), + "content_b64": base64.b64encode(content).decode("ascii"), + } + if method == "fs.delete": + self.existing_paths.discard(params["path"]) + self.file_contents.pop(params["path"], None) + return {"path": params["path"], "ok": True} + if method == "fs.hash": + return {"hash": "abc123" * 10 + "ab", "algo": "sha256"} + if method == "exec.cmd": + for i, (matcher, resp) in enumerate(self.exec_responses): + if matcher(params): + self.exec_responses.pop(i) + return resp + return self.exec_default + # Tests should never reach this; surface clearly. + raise AssertionError(f"unexpected method: {method!r}") + + +def _make_config(machine: str | None = None, + tags: list[str] | None = None, + sandbox_cfg: SandboxTargetConfig | None = None, + sandboxvm_path: Path | None = None) -> Config: + cfg = Config(targets={ + "tgt": TargetConfig( + type="remote", + machine=machine, + tags=tags or [], + sandbox=sandbox_cfg, + channels=TargetChannels( + mcpd=McpdChannel(endpoint="192.168.0.99:4322"), + ), + ), + }) + if sandboxvm_path is not None: + cfg.paths.sandboxvm = sandboxvm_path + return cfg + + +@pytest.fixture(autouse=True) +def _reset_state(): + sb._reset_module_state_for_tests() + yield + sb._reset_module_state_for_tests() + + +@pytest.fixture +def fleet_with_fake(): + fleet = Fleet(_make_config()) + fake = FakeMcpd() + fleet._mcpd["tgt"] = fake # type: ignore[assignment] + return fleet, fake + + +# ---- probe --------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_probe_finds_default_path_and_runs_banner(fleet_with_fake): + fleet, fake = fleet_with_fake + fake.add_path("SYS:Tools/sandboxvm") + fake.queue_exec( + lambda p: "sandboxvm" in p["command"], + {"output": "sandboxvm — usage banner\n args: -m -w ...\n", + "exit_code": 5}, + ) + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is True + assert res.code is None + assert res.path == "SYS:Tools/sandboxvm" + assert res.version_banner == "sandboxvm — usage banner" + # All three default search candidates surfaced (transparency). + assert res.searched == list(sb.DEFAULT_AOS_SEARCH) + + +@pytest.mark.asyncio +async def test_probe_per_target_path_takes_precedence(fleet_with_fake): + fleet, fake = fleet_with_fake + # Override the target's per-target sandbox path. Re-build the + # fleet so the new TargetConfig is in place. + fleet._config.targets["tgt"].sandbox = SandboxTargetConfig( + path="Tools:sandboxvm", + ) + fake.add_path("Tools:sandboxvm") + fake.queue_exec(lambda p: True, {"output": "banner", "exit_code": 5}) + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is True + assert res.path == "Tools:sandboxvm" + # Per-target override shows up first in the search order. + assert res.searched[0] == "Tools:sandboxvm" + + +@pytest.mark.asyncio +async def test_probe_missing_returns_typed_code(fleet_with_fake): + fleet, _fake = fleet_with_fake + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is False + assert res.code == "SANDBOXVM_MISSING" + assert res.path is None + assert "sandbox.deploy" in (res.hint or "") + # Negative result NOT cached -- a follow-up deploy must work + # without an explicit invalidate. + assert "tgt" not in sb._PROBE_CACHE + + +@pytest.mark.asyncio +async def test_probe_broken_binary_returns_typed_code(fleet_with_fake): + fleet, fake = fleet_with_fake + fake.add_path("SYS:Tools/sandboxvm") + + # Default exec response has empty output → banner probe returns + # None → SANDBOXVM_BROKEN. + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is False + assert res.code == "SANDBOXVM_BROKEN" + assert res.path == "SYS:Tools/sandboxvm" + assert "Re-deploy" in (res.hint or "") + + +@pytest.mark.asyncio +async def test_probe_pegasos2_refused_via_machine_field(): + fleet = Fleet(_make_config(machine="pegasos2")) + fleet._mcpd["tgt"] = FakeMcpd() # type: ignore[assignment] + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is False + assert res.code == "SANDBOXVM_INCOMPATIBLE_TARGET" + assert res.machine == "pegasos2" + assert res.machine_ok is False + + +@pytest.mark.asyncio +async def test_probe_pegasos2_refused_via_tag(): + fleet = Fleet(_make_config(tags=["qemu", "pegasos2"])) + fleet._mcpd["tgt"] = FakeMcpd() # type: ignore[assignment] + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is False + assert res.code == "SANDBOXVM_INCOMPATIBLE_TARGET" + + +@pytest.mark.asyncio +async def test_probe_caches_positive_result(fleet_with_fake): + fleet, fake = fleet_with_fake + fake.add_path("SYS:Tools/sandboxvm") + fake.queue_exec(lambda p: True, {"output": "banner", "exit_code": 5}) + + first = await sb.sandbox_probe(fleet, "tgt") + assert first.available is True + assert "tgt" in sb._PROBE_CACHE + + # Second call via the cached path: no new exec.cmd should fire + # because _ensure_available short-circuits on cached available. + pre_exec_count = sum(1 for m, _ in fake.calls if m == "exec.cmd") + await sb._ensure_available(fleet, "tgt") + post_exec_count = sum(1 for m, _ in fake.calls if m == "exec.cmd") + assert post_exec_count == pre_exec_count + + +# ---- deploy -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_deploy_requires_confirm(fleet_with_fake, tmp_path): + fleet, _fake = fleet_with_fake + src = tmp_path / "sandboxvm" + src.write_bytes(b"\x7fELF" + b"x" * 256) + + with pytest.raises(InvalidParams, match="confirm=True"): + await sb.sandbox_deploy( + fleet, "tgt", source=str(src), confirm=False, + ) + + +@pytest.mark.asyncio +async def test_deploy_explicit_source_and_dest(fleet_with_fake, tmp_path): + fleet, fake = fleet_with_fake + src = tmp_path / "sandboxvm" + src.write_bytes(b"\x7fELF" + b"x" * 1024) + + # Pre-stage probe to populate cache; verify deploy clears it. + fake.add_path("SYS:Tools/sandboxvm") + fake.queue_exec(lambda p: True, {"output": "banner", "exit_code": 5}) + await sb.sandbox_probe(fleet, "tgt") + assert "tgt" in sb._PROBE_CACHE + + # Stub fs_upload so we don't depend on the chunked upload path + # (covered by its own tests). + async def fake_upload(fleet_, target, *, local_path, remote_path, + **kwargs): + from amiga_fleet_mcp.tools.fs import UploadResult + # Mark the destination as existing so the post-deploy probe + # finds it. + fake.add_path(remote_path) + return UploadResult( + target=target, local_path=local_path, + remote_path=remote_path, + bytes_total=Path(local_path).stat().st_size, + bytes_sent_compressed=Path(local_path).stat().st_size, + chunks=1, compressed_chunks=0, + elapsed_s=0.05, speed_mib_s=20.0, + compression_ratio=1.0, + sha256_verified=True, + sha256="deadbeef" * 8, + ) + + # Re-arm exec banner for the post-deploy probe call. + fake.queue_exec(lambda p: True, {"output": "banner", "exit_code": 5}) + with mock.patch.object(sb.fs_tool, "fs_upload", fake_upload): + res = await sb.sandbox_deploy( + fleet, "tgt", + source=str(src), + dest_path="Tools:sandboxvm", + confirm=True, + ) + + assert res.dest_path == "Tools:sandboxvm" + assert res.bytes_total == src.stat().st_size + assert res.sha256 is not None + # Post-deploy probe ran + reports available. + assert res.probe_after.available is True + assert res.probe_after.path == "Tools:sandboxvm" + + +@pytest.mark.asyncio +async def test_deploy_falls_back_to_paths_config(fleet_with_fake, tmp_path): + src = tmp_path / "sandboxvm" + src.write_bytes(b"\x7fELF" + b"x" * 32) + + fleet = Fleet(_make_config(sandboxvm_path=src)) + fake = FakeMcpd() + fleet._mcpd["tgt"] = fake # type: ignore[assignment] + + async def fake_upload(fleet_, target, *, local_path, remote_path, + **kwargs): + from amiga_fleet_mcp.tools.fs import UploadResult + fake.add_path(remote_path) + return UploadResult( + target=target, local_path=local_path, + remote_path=remote_path, + bytes_total=Path(local_path).stat().st_size, + bytes_sent_compressed=Path(local_path).stat().st_size, + chunks=1, compressed_chunks=0, + elapsed_s=0.01, speed_mib_s=1.0, + compression_ratio=1.0, + ) + + fake.queue_exec(lambda p: True, {"output": "banner", "exit_code": 5}) + with mock.patch.object(sb.fs_tool, "fs_upload", fake_upload): + res = await sb.sandbox_deploy(fleet, "tgt", confirm=True) + + # Default dest path used when neither arg nor per-target override + # exists. + assert res.dest_path == sb.DEFAULT_DEPLOY_PATH + # Source resolved from [paths] sandboxvm. + assert res.source == str(src) + + +@pytest.mark.asyncio +async def test_deploy_source_missing_raises(fleet_with_fake): + fleet, _ = fleet_with_fake + with pytest.raises(InvalidParams, match="not set"): + await sb.sandbox_deploy(fleet, "tgt", confirm=True) + + +# ---- run_guest ----------------------------------------------------- + + +def _make_available_fleet(deny_libs=None, + extmem_mb=None, window_mb=None): + """A fleet whose probe will succeed.""" + sandbox_cfg = None + if any(v is not None for v in (deny_libs, extmem_mb, window_mb)): + sandbox_cfg = SandboxTargetConfig( + path="SYS:Tools/sandboxvm", + deny_libs=list(deny_libs or []), + default_extmem_mb=extmem_mb if extmem_mb is not None else 1024, + default_window_mb=window_mb if window_mb is not None else 256, + ) + fleet = Fleet(_make_config(sandbox_cfg=sandbox_cfg)) + fake = FakeMcpd() + fleet._mcpd["tgt"] = fake # type: ignore[assignment] + fake.add_path("SYS:Tools/sandboxvm") + return fleet, fake + + +@pytest.mark.asyncio +async def test_run_guest_clean_exit(): + fleet, fake = _make_available_fleet() + # Probe banner, then guest run. + fake.queue_exec( + lambda p: '"SYS:Tools/sandboxvm"' == p["command"], + {"output": "banner", "exit_code": 5}, + ) + fake.queue_exec( + lambda p: "clib4hello" in p["command"], + {"output": "[guest.0] hello\n", "exit_code": 0}, + ) + fake.add_path("T:sandboxvm-clib4hello.out", b"hello\nworld\n") + + res = await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:tests/clib4hello", + ) + + assert res.exit_code == 0 + assert res.trap_kind is None + assert res.trap_fingerprint is None + assert res.name == "clib4hello" + assert "hello\nworld\n" == res.stdout + assert "T:sandboxvm-clib4hello.out" in res.capture_paths + # Capture files cleaned up. + assert "T:sandboxvm-clib4hello.out" not in fake.existing_paths + + +@pytest.mark.asyncio +async def test_run_guest_dsi_trap_classified(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + fake.queue_exec( + lambda p: "crashy" in p["command"], + {"output": "guest [0] returned -768\n", "exit_code": -768}, + ) + + res = await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:tests/crashy", + ) + + assert res.exit_code == -768 + assert res.trap_kind == "DSI" + + +@pytest.mark.asyncio +async def test_run_guest_kmod_libcall_fingerprint_in_stderr(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + fake.queue_exec( + lambda p: "scsi" in p["command"], + {"output": "", "exit_code": -768}, + ) + err_blob = ( + "Trap caught\n" + "Traptype=0x300 dar=0x4 dsisr=0x00800000\n" + ) + fake.add_path("T:sandboxvm-scsi.err", err_blob.encode("utf-8")) + + res = await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:scsi", name="scsi", + ) + + assert res.trap_kind == "DSI" + assert res.trap_fingerprint == "kmod_libcall_null4" + + +@pytest.mark.asyncio +async def test_run_guest_uses_per_target_defaults(): + fleet, fake = _make_available_fleet( + deny_libs=["intuition.library"], + extmem_mb=512, window_mb=128, + ) + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + captured: dict[str, str] = {} + + def capture_run(p): + if "hello" in p["command"]: + captured["cmd"] = p["command"] + return True + return False + + fake.queue_exec(capture_run, {"output": "", "exit_code": 0}) + + await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:hello", deny_libs=["graphics.library"], + ) + + cmd = captured["cmd"] + # Per-target default applied. + assert "-m 512" in cmd + assert "-w 128" in cmd + # Per-target deny + per-call deny merged (target's first). + assert "-x intuition.library" in cmd + assert "-x graphics.library" in cmd + # Quoted guest path survived intact. + assert '"Tools:hello"' in cmd + + +@pytest.mark.asyncio +async def test_run_guest_args_appended_after_double_dash(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + captured: dict[str, str] = {} + + def cap(p): + if "hello" in p["command"]: + captured["cmd"] = p["command"] + return True + return False + + fake.queue_exec(cap, {"output": "", "exit_code": 0}) + + await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:hello", + args=["one", "two with spaces", "three"], + ) + + cmd = captured["cmd"] + # Order: ... -- one "two with spaces" three + after_dd = cmd.split(" -- ", 1)[1] + assert after_dd.startswith("one ") + assert '"two with spaces"' in after_dd + assert after_dd.endswith(" three") + + +@pytest.mark.asyncio +async def test_run_guest_pegasos2_raises_not_capable(): + fleet = Fleet(_make_config(machine="pegasos2")) + fleet._mcpd["tgt"] = FakeMcpd() # type: ignore[assignment] + + with pytest.raises(NotCapable): + await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:hello", + ) + + +@pytest.mark.asyncio +async def test_run_guest_missing_binary_raises_invalid_params( + fleet_with_fake, +): + fleet, _ = fleet_with_fake + with pytest.raises(InvalidParams): + await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:hello", + ) + + +@pytest.mark.asyncio +async def test_run_guest_serialises_per_target(): + """Two concurrent run_guest calls on the same target should + serialise: the per-target lock means the second waits for the + first to finish.""" + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + + started: list[float] = [] + finished: list[float] = [] + + async def slow_request(method, params=None, timeout_s=30.0): + # Patched for the actual guest exec only; everything else + # falls through to the fake's normal handler. + params = params or {} + if method == "exec.cmd" and "guest" in params.get("command", ""): + started.append(asyncio.get_event_loop().time()) + await asyncio.sleep(0.1) + finished.append(asyncio.get_event_loop().time()) + return {"output": "", "exit_code": 0} + return await orig_request(method, params, timeout_s) + + orig_request = fake.request + fake.request = slow_request # type: ignore[method-assign] + + a = sb.sandbox_run_guest(fleet, "tgt", guest="Tools:guestA", + name="guestA") + b = sb.sandbox_run_guest(fleet, "tgt", guest="Tools:guestB", + name="guestB") + await asyncio.gather(a, b) + + assert len(started) == 2 + assert len(finished) == 2 + # Second guest only starts after first finishes — proves the + # per-target lock did its job. + assert started[1] >= finished[0] - 1e-6 From e00cae1898778db675e8960b73d4a9c8c375602a Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 14:48:17 +0100 Subject: [PATCH 03/23] sandbox.probe: don't gate on banner content, only on exec.cmd success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-X5000 hardware testing showed sandboxvm's printf-based usage banner is captured as empty by MCPd's exec.cmd (truncated=true, output=""). Best guess: clib4/newlib stdio buffering not flushed before the process detaches from its inherited Output() handle. Result: probe declared SANDBOXVM_BROKEN even though the binary loaded, parsed args, and returned exit code 5 (RETURN_FAIL) cleanly. Relaxed the gate. _execute_banner_probe now returns (ran: bool, banner: str | None) — `ran=True` whenever exec.cmd returned a structured exit code, regardless of captured stdout content. Probe still reports the first line of any non-empty output as `version_banner` (best-effort), but availability no longer hinges on it. Validated end-to-end on real X5000: - probe (post-deploy) -> available=true, banner=null - run_guest hello -> exit_code 0 - run_guest crashy -> exit_code -768, trap_kind DSI - run_guest hello-survives -> exit_code 0, AFTER the crash (daemon survived; key Phase A win) 20/20 sandbox unit tests pass (added an explicit test_probe_empty_banner_still_available case to prevent regression). Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/tools/sandbox.py | 51 +++++++++++++---------- host/tests/unit/test_sandbox.py | 35 ++++++++++++++-- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index 2bd7fc8..a436ede 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -217,18 +217,28 @@ def _candidate_target_paths(fleet: Fleet, target: str) -> list[str]: async def _execute_banner_probe(fleet: Fleet, target: str, - path: str) -> str | None: - """Run sandboxvm with no args and capture the usage banner. - - Returns the first non-empty line of output when the binary - started and produced something. Returns ``None`` (caller treats - as broken) when invocation failed entirely. - - SandboxVM exits non-zero with a usage banner when called with no - args (per `src/main.c:usage()`); we therefore *expect* a non-zero - exit and don't treat it as failure. We pin to "binary started + - produced a short banner" rather than a specific exit code so an - upstream change to the exit value doesn't break the probe.""" + path: str) -> tuple[bool, str | None]: + """Run sandboxvm with no args and confirm the binary actually + starts. + + Returns ``(ran, banner_first_line)``: + + - ``ran=True`` when exec.cmd returned successfully, meaning + the binary loaded and exited (with whatever rc). Banner + text is best-effort: it'll be the first line of stdout when + captured, otherwise None. + - ``ran=False`` when exec.cmd raised (target unreachable, + path not executable, hard timeout etc) -- treated as + ``SANDBOXVM_BROKEN``. + + We deliberately don't gate on banner content. SandboxVM's usage + banner is emitted via stdio (printf) but the daemon's exec.cmd + capture pipe sometimes returns empty + truncated=true for + clib4/newlib-linked binaries (stdio buffering not flushed before + the process detaches from its Output() handle). Pinning the + probe to "exec.cmd returned a structured exit code" rather than + "exec.cmd captured non-empty output" makes the probe robust + across that quirk.""" mcpd = fleet.mcpd(target) try: raw = await mcpd.request( @@ -240,13 +250,10 @@ async def _execute_banner_probe(fleet: Fleet, target: str, timeout_s=10.0, ) except Exception: - return None + return False, None output = str(raw.get("output", "")).strip() - if not output: - return None - # Take the first line; safer than returning multi-line garbage in - # the probe result. - return output.splitlines()[0] + banner = output.splitlines()[0] if output else None + return True, banner async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: @@ -305,8 +312,8 @@ async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: # invalidate before subsequent calls work. return result - banner = await _execute_banner_probe(fleet, target, found) - if banner is None: + ran, banner = await _execute_banner_probe(fleet, target, found) + if not ran: result = SandboxProbeResult( target=target, available=False, code="SANDBOXVM_BROKEN", @@ -358,8 +365,8 @@ async def _probe_specific_path(fleet: Fleet, target: str, machine=machine, machine_ok=machine_ok, ) - banner = await _execute_banner_probe(fleet, target, path) - if banner is None: + ran, banner = await _execute_banner_probe(fleet, target, path) + if not ran: return SandboxProbeResult( target=target, available=False, code="SANDBOXVM_BROKEN", diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index 1a993c9..d1e99e3 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -49,10 +49,11 @@ def add_path(self, p: str, content: bytes = b"") -> None: if content: self.file_contents[p] = content - def queue_exec(self, matcher, response: dict) -> None: + def queue_exec(self, matcher, response) -> None: """Add a one-shot canned response for the next exec.cmd call whose params satisfy `matcher` (callable taking the params - dict, returns bool).""" + dict, returns bool). `response` is either a dict (returned + verbatim) or an Exception (raised when the matcher fires).""" self.exec_responses.append((matcher, response)) async def request(self, method, params=None, timeout_s=30.0): @@ -84,6 +85,8 @@ async def request(self, method, params=None, timeout_s=30.0): for i, (matcher, resp) in enumerate(self.exec_responses): if matcher(params): self.exec_responses.pop(i) + if isinstance(resp, Exception): + raise resp return resp return self.exec_default # Tests should never reach this; surface clearly. @@ -186,9 +189,15 @@ async def test_probe_missing_returns_typed_code(fleet_with_fake): async def test_probe_broken_binary_returns_typed_code(fleet_with_fake): fleet, fake = fleet_with_fake fake.add_path("SYS:Tools/sandboxvm") + # Make exec.cmd against the binary raise — that's what + # "binary won't start" looks like to the probe (vs empty + # captured output, which is benign and just means the + # daemon's stdout pipe didn't catch anything). + fake.queue_exec( + lambda p: '"SYS:Tools/sandboxvm"' == p["command"], + Exception("simulated load failure"), + ) - # Default exec response has empty output → banner probe returns - # None → SANDBOXVM_BROKEN. res = await sb.sandbox_probe(fleet, "tgt") assert res.available is False @@ -197,6 +206,24 @@ async def test_probe_broken_binary_returns_typed_code(fleet_with_fake): assert "Re-deploy" in (res.hint or "") +@pytest.mark.asyncio +async def test_probe_empty_banner_still_available(fleet_with_fake): + """Reaching exec.cmd with a structured exit code is sufficient + proof the binary works — the daemon's stdout pipe sometimes + captures nothing for clib4/newlib-linked binaries (buffering + quirk) and that should NOT mark the binary as broken.""" + fleet, fake = fleet_with_fake + fake.add_path("SYS:Tools/sandboxvm") + # Default exec response: empty output, exit_code 0. + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is True + assert res.code is None + assert res.path == "SYS:Tools/sandboxvm" + # Empty banner is acceptable. + assert res.version_banner is None + + @pytest.mark.asyncio async def test_probe_pegasos2_refused_via_machine_field(): fleet = Fleet(_make_config(machine="pegasos2")) From 5f2441eef71755d6d50f566a5ee49b62f3462b91 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 14:49:38 +0100 Subject: [PATCH 04/23] sandbox.probe: surface SandboxVM upstream URL in MISSING hint When a target lacks sandboxvm, the SANDBOXVM_MISSING probe result now points the caller at https://github.com/derfsss/SandboxVM in its `hint` field. Saves a fresh user from grepping docs for "where do I get the binary". Constant lives at module scope (`SANDBOXVM_UPSTREAM_URL`) so the test asserts on the exact value rather than substring-matching the URL inside the hint sentence. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/tools/sandbox.py | 15 +++++++++++---- host/tests/unit/test_sandbox.py | 3 +++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index a436ede..1ddc841 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -69,6 +69,10 @@ """Where `sandbox.deploy` writes when no explicit `dest_path` is set and no per-target `[targets..sandbox.path]` is configured.""" +SANDBOXVM_UPSTREAM_URL = "https://github.com/derfsss/SandboxVM" +"""Upstream repo for SandboxVM. Surfaced in the SANDBOXVM_MISSING +hint so a fresh user has somewhere to go without grepping docs.""" + # Machine identifiers that explicitly lack ExtMem and therefore can't # host SandboxVM. Both `targets..machine` (QEMU) and # `targets..tags` (real-hw) are checked. @@ -302,10 +306,13 @@ async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: code="SANDBOXVM_MISSING", searched=candidates, machine=machine, machine_ok=machine_ok, - hint=("sandboxvm binary not found on target. Run " - "`sandbox.deploy` to upload it, or set " - "`[targets..sandbox.path]` in config.toml " - "if it's installed at a non-default location."), + hint=("sandboxvm binary not found on target. Build it " + f"from {SANDBOXVM_UPSTREAM_URL} (see its README.md " + "for the docker-cc cross-compile flow), then run " + "`sandbox.deploy` to upload to the target -- or " + "set `[targets..sandbox.path]` in " + "config.toml if it's already installed at a " + "non-default location."), ) # Negative result deliberately NOT cached — a follow-up # `sandbox.deploy` would otherwise have to explicitly diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index d1e99e3..065157c 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -180,6 +180,9 @@ async def test_probe_missing_returns_typed_code(fleet_with_fake): assert res.code == "SANDBOXVM_MISSING" assert res.path is None assert "sandbox.deploy" in (res.hint or "") + # GitHub URL surfaced so a fresh user has a build target + # without grepping docs. + assert sb.SANDBOXVM_UPSTREAM_URL in (res.hint or "") # Negative result NOT cached -- a follow-up deploy must work # without an explicit invalidate. assert "tgt" not in sb._PROBE_CACHE From cd58f1a7aac28b358998b6f2ef2cd49383b5e5ac Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 14:51:26 +0100 Subject: [PATCH 05/23] sandbox.probe: prefer pre-built release URL over from-source URL SandboxVM ships pre-built PPC AOS4 binaries on its GitHub releases page (v1.0 currently). The MISSING hint now leads with the /releases/latest link as the fast path, mentioning the build-from- source flow as the alternative. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/tools/sandbox.py | 21 +++++++++++++++------ host/tests/unit/test_sandbox.py | 5 +++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index 1ddc841..6a3ee06 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -73,6 +73,13 @@ """Upstream repo for SandboxVM. Surfaced in the SANDBOXVM_MISSING hint so a fresh user has somewhere to go without grepping docs.""" +SANDBOXVM_RELEASES_URL = ( + "https://github.com/derfsss/SandboxVM/releases/latest" +) +"""Pre-built sandboxvm binary download. Mentioned in the +SANDBOXVM_MISSING hint as the easy alternative to a from-source +build. Always points at the latest tagged release.""" + # Machine identifiers that explicitly lack ExtMem and therefore can't # host SandboxVM. Both `targets..machine` (QEMU) and # `targets..tags` (real-hw) are checked. @@ -306,12 +313,14 @@ async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: code="SANDBOXVM_MISSING", searched=candidates, machine=machine, machine_ok=machine_ok, - hint=("sandboxvm binary not found on target. Build it " - f"from {SANDBOXVM_UPSTREAM_URL} (see its README.md " - "for the docker-cc cross-compile flow), then run " - "`sandbox.deploy` to upload to the target -- or " - "set `[targets..sandbox.path]` in " - "config.toml if it's already installed at a " + hint=("sandboxvm binary not found on target. Get it " + f"from {SANDBOXVM_RELEASES_URL} (pre-built PPC " + "AOS4 binary -- the fast path), or build from " + f"source at {SANDBOXVM_UPSTREAM_URL} (see its " + "README.md for the docker-cc cross-compile flow). " + "Then run `sandbox.deploy` to upload to the " + "target -- or set `[targets..sandbox.path]` " + "in config.toml if it's already installed at a " "non-default location."), ) # Negative result deliberately NOT cached — a follow-up diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index 065157c..9ad74f9 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -180,8 +180,9 @@ async def test_probe_missing_returns_typed_code(fleet_with_fake): assert res.code == "SANDBOXVM_MISSING" assert res.path is None assert "sandbox.deploy" in (res.hint or "") - # GitHub URL surfaced so a fresh user has a build target - # without grepping docs. + # Both download paths surfaced: pre-built release (fast path) + # and from-source repo. Saves a fresh user from grepping docs. + assert sb.SANDBOXVM_RELEASES_URL in (res.hint or "") assert sb.SANDBOXVM_UPSTREAM_URL in (res.hint or "") # Negative result NOT cached -- a follow-up deploy must work # without an explicit invalidate. From 40dc19127e8eac92341b897e100ddda867410bd4 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 15:05:24 +0100 Subject: [PATCH 06/23] =?UTF-8?q?sandbox.*=20phase=20B=20=E2=80=94=20sys.d?= =?UTF-8?q?ebug=5Fring=20+=20run=5Fdriver=20+=20last=5Ftrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a kernel-debug-ring primitive in sys.* and the SandboxVM- specific tools that consume it. Three new MCP tools (127 total). sys.debug_ring(target=None, since_s=60.0, max_lines=500) Wraps `c:DumpDebugBuffer` via exec.cmd. Returns the most recent `max_lines` lines (oldest-first), the truncated flag, raw byte size, and a UTC ISO-8601 capture timestamp. since_s is accepted today but reserved for a future timestamp-aware trim -- DumpDebugBuffer entries don't carry per-line timestamps. Useful well outside sandbox: post-install forensics, hardware bring-up. sandbox.run_driver(target, driver, test=None, ...) Loads a driver via SandboxVM's resident-driver mode (`-r`). When `test` is set, chains a follow-on guest in the same Guest context so OpenLibrary() resolves through the resident-lib registry. Same return shape and lifecycle as sandbox.run_guest; the result's `guest` field carries the driver path. Internally _run_sandboxvm is the shared core for both run_guest and run_driver -- just two flag knobs differ. sandbox.last_trap(target=None, since_s=60.0, max_lines=500, retry_count=3, retry_delay_s=0.2) Filters sys.debug_ring for SandboxVM trap signatures. Anchor lines must contain a `traptype=0x...` hex OR a hard trap keyword (dsi/isi/alignment/illegal/privilege/fault/trap) -- a bare [sandboxvm] prefix isn't enough since SandboxVM emits routine status lines too. Block extends across adjacent [sandboxvm]-prefixed lines so the caller gets the full dump (registers, dsisr, dar, callsite). Maps 0x300/0x400/0x600/ 0x700/0x800 -> DSI/ISI/alignment/program/fp_unavailable. The documented kmod-libcall NULL+4 fingerprint surfaces as `kmod_libcall_null4` when Traptype=0x300 + dar=0x4 + dsisr=0x00800000 all appear in the trap block. Built-in 3 attempts × 200 ms retry handles the ring-write race when last_trap is called immediately after run_guest -- the kernel ring write is asynchronous to SandboxVM exit. Tests: 17 new (7 sys.debug_ring + 9 sandbox + 1 import collateral). Total 244/244 unit tests pass; ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/server.py | 95 +++++++ host/src/amiga_fleet_mcp/tools/sandbox.py | 321 ++++++++++++++++++++-- host/src/amiga_fleet_mcp/tools/sys.py | 81 ++++++ host/tests/unit/test_sandbox.py | 242 ++++++++++++++++ host/tests/unit/test_sys_debug_ring.py | 142 ++++++++++ 5 files changed, 855 insertions(+), 26 deletions(-) create mode 100644 host/tests/unit/test_sys_debug_ring.py diff --git a/host/src/amiga_fleet_mcp/server.py b/host/src/amiga_fleet_mcp/server.py index 43fa853..2a42a3e 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -365,6 +365,28 @@ async def sys_lastalert(*, target: str | None = None) -> sys_tool.LastAlertResul the four-word raw payload).""" return await sys_tool.sys_lastalert(fleet, fleet.resolve_target(target)) + @mcp.tool(name="sys_debug_ring", title="sys.debug_ring") + @archived("sys.debug_ring", archive) + async def sys_debug_ring( + *, target: str | None = None, + since_s: float = 60.0, + max_lines: int = 500, + ) -> sys_tool.DebugRingResult: + """Read the kernel debug ring via `c:DumpDebugBuffer`. + + Returns the most recent `max_lines` lines (oldest-first) + plus the raw byte size of the full capture and an + ISO-8601 host timestamp. Useful for post-install forensics, + hardware bring-up, and as the primitive that + `sandbox.last_trap` filters against. `since_s` is reserved + for a future timestamp-aware trim — accepted but not yet + used (DumpDebugBuffer lines have no per-line timestamps to + filter against).""" + return await sys_tool.sys_debug_ring( + fleet, fleet.resolve_target(target), + since_s=since_s, max_lines=max_lines, + ) + @mcp.tool(name="sys_uptime", title="sys.uptime") @archived("sys.uptime", archive) async def sys_uptime(*, target: str | None = None) -> sys_tool.UptimeResult: @@ -1370,6 +1392,7 @@ async def sys_ns(*, method: str, params: dict[str, Any] | None = None) -> Any: "lastalert": sys_tool.sys_lastalert, "alert_decode": sys_tool.sys_alert_decode, "cold_reboot": sys_tool.sys_cold_reboot, + "debug_ring": sys_tool.sys_debug_ring, # memory introspection (PA / TLB / CCSR) "read_ccsr": sys_tool.sys_read_ccsr, "read_pa": sys_tool.sys_read_pa, @@ -2164,6 +2187,62 @@ async def sandbox_run_guest( deny_libs=deny_libs, name=name, timeout_s=timeout_s, ) + @mcp.tool(name="sandbox_run_driver", title="sandbox.run_driver") + @archived("sandbox.run_driver", archive) + async def sandbox_run_driver( + *, target: str | None = None, + driver: str, + test: str | None = None, + args: list[str] | None = None, + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: list[str] | None = None, + name: str | None = None, + timeout_s: float = 120.0, + ) -> sandbox_tool.GuestRunResult: + """Load a driver via SandboxVM resident-driver mode (`-r`). + + Scans the driver's RTF_AUTOINIT Resident, applies PPC ELF + relocations, and calls CLT_InitFunc with the sandboxed + IExec. When `test` is set, runs that follow-on guest in the + same Guest context after a successful init so + OpenLibrary() resolves through the resident-lib + registry. + + Same return shape and lifecycle as `sandbox.run_guest`; the + result's `guest` field carries the driver path (the thing + being loaded). Probe-gated; lock-serialised; T:capture + files slurped + deleted.""" + return await sandbox_tool.sandbox_run_driver( + fleet, fleet.resolve_target(target), + driver=driver, test=test, args=args, + extmem_mb=extmem_mb, window_mb=window_mb, + deny_libs=deny_libs, name=name, timeout_s=timeout_s, + ) + + @mcp.tool(name="sandbox_last_trap", title="sandbox.last_trap") + @archived("sandbox.last_trap", archive) + async def sandbox_last_trap( + *, target: str | None = None, + since_s: float = 60.0, + max_lines: int = 500, + retry_count: int = 3, + retry_delay_s: float = 0.2, + ) -> sandbox_tool.LastTrapResult: + """Filter the kernel debug ring for SandboxVM trap signatures. + + Wraps `sys.debug_ring` with a small retry loop (default 3 + attempts, 200 ms apart) so a `last_trap` call immediately after a + crashed `run_guest` doesn't race the kernel ring write. + Returns the matched trap block plus a structured `trap_kind` + / `fingerprint` / `traptype_hex` summary. `found=False` is + the legitimate "no trap to report" outcome.""" + return await sandbox_tool.sandbox_last_trap( + fleet, fleet.resolve_target(target), + since_s=since_s, max_lines=max_lines, + retry_count=retry_count, retry_delay_s=retry_delay_s, + ) + @mcp.tool(name="sandbox", title="sandbox.dispatch") @archived("sandbox.dispatch", archive) async def sandbox_ns( @@ -2176,6 +2255,11 @@ async def sandbox_ns( run_guest(target=None, guest, args=None, extmem_mb=None, window_mb=None, deny_libs=None, name=None, timeout_s=120.0) + run_driver(target=None, driver, test=None, args=None, + extmem_mb=None, window_mb=None, deny_libs=None, + name=None, timeout_s=120.0) + last_trap(target=None, since_s=60.0, max_lines=500, + retry_count=3, retry_delay_s=0.2) target falls back to ``[server] default_target``. Mutating methods need ``confirm=True``.""" @@ -2196,10 +2280,21 @@ async def sandbox_ns( return await sandbox_tool.sandbox_run_guest( fleet, fleet.resolve_target(p.get("target")), **kwargs, ) + if method == "run_driver": + kwargs = {k: v for k, v in p.items() if k != "target"} + return await sandbox_tool.sandbox_run_driver( + fleet, fleet.resolve_target(p.get("target")), **kwargs, + ) + if method == "last_trap": + kwargs = {k: v for k, v in p.items() if k != "target"} + return await sandbox_tool.sandbox_last_trap( + fleet, fleet.resolve_target(p.get("target")), **kwargs, + ) raise MethodNotFound( f"unknown method: {method!r}", data={"namespace_methods": [ "probe", "deploy", "run_guest", + "run_driver", "last_trap", ]}, ) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index 6a3ee06..8326711 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -40,6 +40,7 @@ import asyncio import base64 import os +import re import time from collections.abc import Sequence from pathlib import Path @@ -49,6 +50,7 @@ from ..errors import InvalidParams, NotCapable, TargetError from ..fleet import Fleet from . import fs as fs_tool +from . import sys as sys_tool # --------------------------------------------------------------------- # Constants @@ -591,12 +593,22 @@ def _build_command(sandboxvm_path: str, *, extmem_mb: int, window_mb: int, deny_libs: Sequence[str], - name: str) -> str: + name: str, + resident_driver: bool = False, + test_elf: str | None = None) -> str: """Construct the AmigaDOS command line for one guest run. - Format: + Plain guest mode: "" -m -w -n [-x lib]... [-- args...] + Resident-driver mode (`resident_driver=True`): + "" -m -w -n [-x lib]... + -r [-t ] [-- args...] + + `test_elf` is only meaningful with `resident_driver=True`; the + caller is responsible for raising before this point if both + aren't set together. + Quoting note: AmigaDOS quoting is conservative. We wrap the sandboxvm path and the guest path in double quotes so spaces / colons survive; the daemon's `exec.cmd` handler is happy with @@ -608,6 +620,10 @@ def _build_command(sandboxvm_path: str, *, parts.extend(["-n", _shell_safe(name)]) for lib in deny_libs: parts.extend(["-x", _shell_safe(lib)]) + if resident_driver: + parts.append("-r") + if test_elf: + parts.extend(["-t", f'"{test_elf}"']) parts.append(f'"{guest}"') if args: parts.append("--") @@ -682,34 +698,23 @@ async def _delete_capture(fleet: Fleet, target: str, path: str) -> None: pass -async def sandbox_run_guest( +async def _run_sandboxvm( fleet: Fleet, target: str, *, guest: str, - args: Sequence[str] | None = None, - extmem_mb: int | None = None, - window_mb: int | None = None, - deny_libs: Sequence[str] | None = None, - name: str | None = None, - timeout_s: float = 120.0, + args: Sequence[str] | None, + extmem_mb: int | None, + window_mb: int | None, + deny_libs: Sequence[str] | None, + name: str | None, + timeout_s: float, + resident_driver: bool = False, + test_elf: str | None = None, ) -> GuestRunResult: - """Run a single guest ELF inside SandboxVM on `target`. + """Shared core for `sandbox_run_guest` and `sandbox_run_driver`. - Probe-gated: a missing / broken / Pegasos2 target raises a typed - error before any work happens. - - Args: - guest: AOS path to the guest ELF (e.g. - ``"Tools:tests/clib4hello"``). - args: Optional list of strings appended via ``--``. - extmem_mb / window_mb: SandboxVM ``-m`` / ``-w`` values. - Defaults come from - ``[targets..sandbox.default_*]`` (1024 / 256 - when unset). - deny_libs: Per-call ``-x`` flags. Merged with any - ``[targets..sandbox.deny_libs]`` from config. - name: Used as ``-n`` (also drives the T:capture filenames). - Defaults to the basename of ``guest`` minus extension. - timeout_s: Host-side request timeout for the run.""" + Performs the probe gate, resolves config-driven defaults, builds + the command, runs it under the per-target lock, and slurps + + classifies the T:capture files.""" probe = await _ensure_available(fleet, target) assert probe.path is not None # guaranteed by _ensure_available @@ -740,6 +745,8 @@ async def sandbox_run_guest( window_mb=window_mb, deny_libs=merged_deny, name=run_name, + resident_driver=resident_driver, + test_elf=test_elf, ) # Per-target lock so concurrent run_guest calls on the same @@ -810,6 +817,268 @@ async def sandbox_run_guest( ) +async def sandbox_run_guest( + fleet: Fleet, target: str, *, + guest: str, + args: Sequence[str] | None = None, + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: Sequence[str] | None = None, + name: str | None = None, + timeout_s: float = 120.0, +) -> GuestRunResult: + """Run a single guest ELF inside SandboxVM on `target`. + + Probe-gated: a missing / broken / Pegasos2 target raises a typed + error before any work happens. + + Args: + guest: AOS path to the guest ELF (e.g. + ``"Tools:tests/clib4hello"``). + args: Optional list of strings appended via ``--``. + extmem_mb / window_mb: SandboxVM ``-m`` / ``-w`` values. + Defaults come from + ``[targets..sandbox.default_*]`` (1024 / 256 + when unset). + deny_libs: Per-call ``-x`` flags. Merged with any + ``[targets..sandbox.deny_libs]`` from config. + name: Used as ``-n`` (also drives the T:capture filenames). + Defaults to the basename of ``guest`` minus extension. + timeout_s: Host-side request timeout for the run.""" + return await _run_sandboxvm( + fleet, target, + guest=guest, args=args, + extmem_mb=extmem_mb, window_mb=window_mb, + deny_libs=deny_libs, name=name, timeout_s=timeout_s, + resident_driver=False, test_elf=None, + ) + + +async def sandbox_run_driver( + fleet: Fleet, target: str, *, + driver: str, + test: str | None = None, + args: Sequence[str] | None = None, + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: Sequence[str] | None = None, + name: str | None = None, + timeout_s: float = 120.0, +) -> GuestRunResult: + """Load a driver via SandboxVM's resident-driver mode (`-r`). + + SandboxVM scans the driver's RTF_AUTOINIT Resident, applies PPC + ELF relocations, and calls the CLT_InitFunc with the sandboxed + IExec. When `test` is set, after a successful init SandboxVM + runs `test` as a follow-on guest in the same Guest context so + `OpenLibrary()` from the test resolves via the + resident-lib registry. + + Probe-gated, lock-serialised, T:-capture-slurped — same shape as + `sandbox.run_guest`. The result's `guest` field carries the + driver path (the thing being loaded). + + Args: + driver: AOS path to the .device or .library being loaded. + test: Optional AOS path to a follow-on guest ELF. Requires + the driver init to succeed first; SandboxVM aborts if + the resident-init returns non-zero. + args: Optional list of strings appended via ``--`` (only + meaningful with `test`). + extmem_mb / window_mb / deny_libs / name / timeout_s: Same + semantics as `sandbox.run_guest`.""" + return await _run_sandboxvm( + fleet, target, + guest=driver, args=args, + extmem_mb=extmem_mb, window_mb=window_mb, + deny_libs=deny_libs, name=name, timeout_s=timeout_s, + resident_driver=True, test_elf=test, + ) + + +# --------------------------------------------------------------------- +# last_trap — filter sys.debug_ring for SandboxVM trap signatures +# --------------------------------------------------------------------- + + +class LastTrapResult(BaseModel): + """Return shape for `sandbox.last_trap`.""" + + target: str + found: bool + trap_kind: str | None = None + """One of DSI / ISI / alignment / program / fp_unavailable when + the ring contained a recognised trap line.""" + fingerprint: str | None = None + """Specific shape match — currently only ``kmod_libcall_null4`` + (the documented HD_SCSICMD-via-DoIO limitation).""" + traptype_hex: str | None = None + """Raw trap_type as hex (e.g. ``"0x300"``) when extracted from + the ring text.""" + raw_lines: list[str] = Field(default_factory=list) + """The matching ring lines, in order. Empty when ``found`` is + False.""" + captured_at: str | None = None + """ISO-8601 host timestamp from the underlying sys.debug_ring + call. ``None`` when no debug ring was captured (e.g. retry + exhausted).""" + attempts: int = 0 + """Number of sys.debug_ring calls made before finding a trap. + Useful for diagnosing the ring-write race.""" + + +# Lines emitted by SandboxVM's tc_TrapCode trampoline carry an +# explicit trap-type prefix. We match a generous regex so upstream +# can change the exact prose without breaking the filter. +_RE_TRAPTYPE = re.compile(r"(?i)\btrap[_ ]?type\s*=\s*0x([0-9a-f]+)") +_RE_SANDBOXVM_LINE = re.compile(r"(?i)\[sandbox(vm)?\]") +_RE_TRAP_LINE = re.compile( + r"(?i)\b(trap|dsi|isi|alignment|illegal|privilege|fault)\b" +) + +# Map raw traptype hex (lower, no 0x) to a kind string. +_TRAP_KIND_BY_HEX: dict[str, str] = { + "300": "DSI", + "400": "ISI", + "600": "alignment", + "700": "program", + "800": "fp_unavailable", +} + +# Kmod-libcall NULL+4 fingerprint hints — same set as `_classify_exit` +# but applied to ring text rather than guest stderr. All three must +# appear within the candidate trap window. +_KMOD_HINTS = ("Traptype=0x300", "dar=0x4", "dsisr=0x00800000") + + +def _extract_trap(lines: Sequence[str]) -> tuple[ + str | None, str | None, str | None, list[str], +]: + """Walk `lines` and return (trap_kind, fingerprint, + traptype_hex, matching_lines). + + A trap is anchored by either a `traptype=` hex value or a hard + trap keyword (`dsi`, `isi`, `alignment`, `illegal`, `privilege`, + `fault`, `trap`). A bare `[sandboxvm]` prefix on its own is not + enough — SandboxVM emits routine status lines too, and we don't + want to count "guest 0 ready" as a trap. + + Once an anchor is found we expand it backwards (and forwards) + over adjacent [sandboxvm]-prefixed or anchor-matching lines so + the caller gets the surrounding dump (registers, dsisr, dar, + callsite hints) rather than just the headline.""" + if not lines: + return None, None, None, [] + + def _is_anchor(line: str) -> bool: + return bool( + _RE_TRAPTYPE.search(line) or _RE_TRAP_LINE.search(line), + ) + + def _is_block_member(line: str) -> bool: + return bool( + _is_anchor(line) or _RE_SANDBOXVM_LINE.search(line), + ) + + # Walk from the end backwards to find the last anchor. + last_trap_idx: int | None = None + for i in range(len(lines) - 1, -1, -1): + if _is_anchor(lines[i]): + last_trap_idx = i + break + if last_trap_idx is None: + return None, None, None, [] + + # Expand backwards over [sandboxvm] / anchor lines for context. + start = last_trap_idx + while start > 0 and _is_block_member(lines[start - 1]): + start -= 1 + + # Expand forwards similarly so a multi-line trap dump (registers, + # callsite, etc.) all lands in the block. + end = last_trap_idx + while end + 1 < len(lines) and _is_block_member(lines[end + 1]): + end += 1 + + block = list(lines[start:end + 1]) + block_text = "\n".join(block) + + # Extract first traptype hex in the block — the trampoline emits + # the trap_type field on the first line of its dump. + trap_kind: str | None = None + traptype_hex: str | None = None + m = _RE_TRAPTYPE.search(block_text) + if m: + hex_lower = m.group(1).lower().lstrip("0") or "0" + traptype_hex = f"0x{hex_lower}" + trap_kind = _TRAP_KIND_BY_HEX.get(hex_lower) + + fingerprint: str | None = None + if all(h in block_text for h in _KMOD_HINTS): + fingerprint = "kmod_libcall_null4" + + return trap_kind, fingerprint, traptype_hex, block + + +async def sandbox_last_trap( + fleet: Fleet, target: str, *, + since_s: float = 60.0, + max_lines: int = 500, + retry_count: int = 3, + retry_delay_s: float = 0.2, +) -> LastTrapResult: + """Filter the kernel debug ring for SandboxVM trap fingerprints. + + Calls `sys.debug_ring` with a small retry loop (default 3 tries, + 200 ms apart) because the kernel ring write is asynchronous to + SandboxVM exit — a `last_trap` immediately after `run_guest` + can race the trap line into the ring. Each retry re-reads the + full ring and re-scans for trap markers. + + `since_s` and `max_lines` are forwarded to `sys.debug_ring`. + + Returns an empty `found=False` result when no trap signature is + visible after all retries — that's the genuine "no trap to + report" case, not an error.""" + if retry_count < 1: + retry_count = 1 + if retry_delay_s < 0: + retry_delay_s = 0.0 + + last_lines: list[str] = [] + last_captured_at: str | None = None + attempts = 0 + for attempt in range(retry_count): + attempts = attempt + 1 + ring = await sys_tool.sys_debug_ring( + fleet, target, + since_s=since_s, max_lines=max_lines, + ) + last_lines = list(ring.lines) + last_captured_at = ring.captured_at + kind, fp, hex_str, block = _extract_trap(last_lines) + if block: + return LastTrapResult( + target=target, + found=True, + trap_kind=kind, + fingerprint=fp, + traptype_hex=hex_str, + raw_lines=block, + captured_at=ring.captured_at, + attempts=attempts, + ) + if attempt + 1 < retry_count: + await asyncio.sleep(retry_delay_s) + + return LastTrapResult( + target=target, + found=False, + captured_at=last_captured_at, + attempts=attempts, + ) + + # --------------------------------------------------------------------- # Test-only helpers # --------------------------------------------------------------------- diff --git a/host/src/amiga_fleet_mcp/tools/sys.py b/host/src/amiga_fleet_mcp/tools/sys.py index 2faabc9..3d22b2a 100644 --- a/host/src/amiga_fleet_mcp/tools/sys.py +++ b/host/src/amiga_fleet_mcp/tools/sys.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC from typing import Any from pydantic import BaseModel @@ -97,6 +98,86 @@ async def sys_lastalert(fleet: Fleet, target: str) -> LastAlertResult: return LastAlertResult.model_validate(raw) +# ---------- sys.debug_ring (Phase B for sandbox.last_trap) --------- + + +class DebugRingResult(BaseModel): + """Return shape for `sys.debug_ring`.""" + + target: str + lines: list[str] + """Most recent ring entries, oldest-first. Trimmed to + `max_lines` if the full ring exceeded it.""" + truncated: bool + """True when the underlying capture was longer than `max_lines` + and the head was dropped to keep the most recent entries.""" + raw_size: int + """Size in bytes of the full DumpDebugBuffer capture before any + line-count trimming. Useful for sizing future calls.""" + captured_at: str + """ISO-8601 host timestamp at the moment of capture.""" + + +async def sys_debug_ring( + fleet: Fleet, target: str, *, + since_s: float = 60.0, + max_lines: int = 500, +) -> DebugRingResult: + """Read the kernel debug ring via ``c:DumpDebugBuffer``. + + Returns the most recent ``max_lines`` lines of the ring contents. + `since_s` is currently a soft hint reserved for a future + timestamp-aware trim — DumpDebugBuffer entries don't carry their + own timestamps, so today we return raw line-count-bounded + output and the caller does any timing correlation against the + `captured_at` host timestamp. + + Useful for: post-install forensics, hardware bring-up, and as + the primitive that ``sandbox.last_trap`` filters against. Pure + read-only — no daemon mutation, no `confirm` needed. + + The call uses a generous 30 s timeout because rings can be + large on a long-running target (multi-MB on a busy X5000).""" + if max_lines < 1: + max_lines = 1 + + t = fleet.mcpd(target) + raw = await t.request( + "exec.cmd", + { + "command": "C:DumpDebugBuffer", + "timeout_ms": 30000, + }, + timeout_s=35.0, + ) + output = str(raw.get("output", "")) + raw_size = len(output) + # Split keeping no trailing empty line. Most rings end with a + # newline; splitlines() handles both \n and \r\n. + all_lines = output.splitlines() + truncated = len(all_lines) > max_lines + lines = all_lines[-max_lines:] if truncated else all_lines + + from datetime import datetime + captured_at = datetime.now(UTC).strftime( + "%Y-%m-%dT%H:%M:%SZ", + ) + + # since_s is consumed (parameter accepted) but we don't filter + # on it yet -- DumpDebugBuffer output has no per-line timestamps + # to filter against. Documenting the contract and ignoring is + # better than silently widening behaviour later. + _ = since_s + + return DebugRingResult( + target=target, + lines=lines, + truncated=truncated, + raw_size=raw_size, + captured_at=captured_at, + ) + + # ---------- phase-7 surface (sys.uptime / memory / volumes / assigns) ---- diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index 9ad74f9..10afae9 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -24,6 +24,7 @@ from amiga_fleet_mcp.errors import InvalidParams, NotCapable, TargetError from amiga_fleet_mcp.fleet import Fleet from amiga_fleet_mcp.tools import sandbox as sb +from amiga_fleet_mcp.tools import sys as sys_tool # ---- fake transport ------------------------------------------------ @@ -596,3 +597,244 @@ async def slow_request(method, params=None, timeout_s=30.0): # Second guest only starts after first finishes — proves the # per-target lock did its job. assert started[1] >= finished[0] - 1e-6 + + +# ---- run_driver ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_run_driver_init_only_sets_resident_flag(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + captured: dict[str, str] = {} + + def cap(p): + if "virtioscsi.device" in p["command"]: + captured["cmd"] = p["command"] + return True + return False + + fake.queue_exec(cap, {"output": "init ok", "exit_code": 0}) + + res = await sb.sandbox_run_driver( + fleet, "tgt", driver="Tools:virtioscsi.device", + ) + + cmd = captured["cmd"] + # -r flag present, no -t flag. + assert " -r " in cmd + assert " -t " not in cmd + # Driver path quoted as the positional guest argument. + assert '"Tools:virtioscsi.device"' in cmd + # Result mirrors run_guest's shape; `guest` field carries the + # driver path so the caller has consistent telemetry across the + # two entry points. + assert res.guest == "Tools:virtioscsi.device" + assert res.exit_code == 0 + + +@pytest.mark.asyncio +async def test_run_driver_with_test_chains_followon_guest(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + captured: dict[str, str] = {} + + def cap(p): + if "virtioscsi" in p["command"]: + captured["cmd"] = p["command"] + return True + return False + + fake.queue_exec(cap, {"output": "test ok", "exit_code": 0}) + + await sb.sandbox_run_driver( + fleet, "tgt", + driver="Tools:virtioscsi.device", + test="Tools:virtioscsi-test", + ) + + cmd = captured["cmd"] + assert " -r " in cmd + # -t comes immediately followed by the quoted test path. + assert ' -t "Tools:virtioscsi-test" ' in cmd or \ + ' -t "Tools:virtioscsi-test"' in cmd + # Driver path remains the positional guest arg, AFTER the -t flag. + assert cmd.index('"Tools:virtioscsi-test"') < \ + cmd.index('"Tools:virtioscsi.device"') + + +@pytest.mark.asyncio +async def test_run_driver_probe_gated(): + """Missing sandboxvm should fail before any exec happens.""" + fleet = Fleet(_make_config()) + fleet._mcpd["tgt"] = FakeMcpd() # type: ignore[assignment] + with pytest.raises(InvalidParams): + await sb.sandbox_run_driver( + fleet, "tgt", driver="Tools:foo.device", + ) + + +# ---- last_trap ----------------------------------------------------- + + +def _ring_with_dsi() -> list[str]: + """Synthetic ring contents containing a SandboxVM DSI dump.""" + return [ + "[unrelated] some earlier line", + "[graphics] driver opened display", + "[sandboxvm] guest 0: trap caught", + "[sandboxvm] Traptype=0x300 dar=0xdeadbeef dsisr=0x40000000", + "[sandboxvm] guest [0] returned -768", + ] + + +def _ring_with_kmod_libcall() -> list[str]: + """Synthetic ring contents matching the documented kmod-libcall + NULL+4 fingerprint.""" + return [ + "[graphics] earlier", + "[sandboxvm] guest 0: trap caught", + "[sandboxvm] Traptype=0x300 dar=0x4 dsisr=0x00800000", + "[sandboxvm] cross-kmod libcall hit -- HD_SCSICMD via DoIO?", + ] + + +def _ring_no_trap() -> list[str]: + return [ + "[graphics] driver opened display", + "[sandboxvm] guest 0 ready", + "[unrelated] random line", + ] + + +@pytest.mark.asyncio +async def test_last_trap_finds_dsi(): + fleet, _ = _make_available_fleet() + canned = sys_tool.DebugRingResult( + target="tgt", lines=_ring_with_dsi(), truncated=False, + raw_size=200, captured_at="2026-05-10T12:00:00Z", + ) + with mock.patch.object( + sb.sys_tool, "sys_debug_ring", + return_value=canned, + ): + res = await sb.sandbox_last_trap(fleet, "tgt") + + assert res.found is True + assert res.trap_kind == "DSI" + assert res.traptype_hex == "0x300" + assert res.fingerprint is None + # Block contains the trap-related lines, not the unrelated graphics + # one before them. + assert any("Traptype=0x300" in line for line in res.raw_lines) + assert not any("graphics" in line for line in res.raw_lines) + assert res.captured_at == "2026-05-10T12:00:00Z" + assert res.attempts == 1 + + +@pytest.mark.asyncio +async def test_last_trap_finds_kmod_libcall_fingerprint(): + fleet, _ = _make_available_fleet() + canned = sys_tool.DebugRingResult( + target="tgt", lines=_ring_with_kmod_libcall(), truncated=False, + raw_size=300, captured_at="2026-05-10T12:00:00Z", + ) + with mock.patch.object( + sb.sys_tool, "sys_debug_ring", + return_value=canned, + ): + res = await sb.sandbox_last_trap(fleet, "tgt") + + assert res.found is True + assert res.trap_kind == "DSI" + assert res.fingerprint == "kmod_libcall_null4" + + +@pytest.mark.asyncio +async def test_last_trap_retries_when_ring_empty_then_finds(): + """Tests the retry loop: first call returns empty ring, second + call has the trap. Should return found=True with attempts=2.""" + fleet, _ = _make_available_fleet() + empty = sys_tool.DebugRingResult( + target="tgt", lines=[], truncated=False, + raw_size=0, captured_at="2026-05-10T12:00:00Z", + ) + populated = sys_tool.DebugRingResult( + target="tgt", lines=_ring_with_dsi(), truncated=False, + raw_size=200, captured_at="2026-05-10T12:00:01Z", + ) + + call_log: list = [] + + async def staged(*a, **kw): + call_log.append(kw) + return empty if len(call_log) == 1 else populated + + with mock.patch.object(sb.sys_tool, "sys_debug_ring", staged): + res = await sb.sandbox_last_trap( + fleet, "tgt", + retry_count=3, retry_delay_s=0.01, + ) + + assert res.found is True + assert res.attempts == 2 + assert res.trap_kind == "DSI" + + +@pytest.mark.asyncio +async def test_last_trap_returns_found_false_after_retries(): + """No trap line ever appears -> found=False, attempts==retry_count.""" + fleet, _ = _make_available_fleet() + canned = sys_tool.DebugRingResult( + target="tgt", lines=_ring_no_trap(), truncated=False, + raw_size=80, captured_at="2026-05-10T12:00:00Z", + ) + with mock.patch.object( + sb.sys_tool, "sys_debug_ring", + return_value=canned, + ): + res = await sb.sandbox_last_trap( + fleet, "tgt", + retry_count=3, retry_delay_s=0.01, + ) + + assert res.found is False + assert res.attempts == 3 + assert res.trap_kind is None + assert res.fingerprint is None + assert res.raw_lines == [] + # captured_at still populated from the last (empty-of-traps) call. + assert res.captured_at == "2026-05-10T12:00:00Z" + + +@pytest.mark.asyncio +async def test_last_trap_extract_helper_handles_non_traps(): + """The block-extraction heuristic shouldn't grab unrelated noise + when no trap line is present.""" + kind, fp, hex_str, block = sb._extract_trap([ + "log line 1", "log line 2", "log line 3", + ]) + assert kind is None + assert fp is None + assert hex_str is None + assert block == [] + + +@pytest.mark.asyncio +async def test_last_trap_extract_helper_with_traptype_only(): + """A trap line with no recognised kind hex still surfaces the raw + hex value so the caller can investigate.""" + kind, fp, hex_str, block = sb._extract_trap([ + "noise", + "[sandboxvm] Traptype=0x999 unknown trap kind", + ]) + assert kind is None # 0x999 not in _TRAP_KIND_BY_HEX + assert hex_str == "0x999" + assert fp is None + assert len(block) == 1 diff --git a/host/tests/unit/test_sys_debug_ring.py b/host/tests/unit/test_sys_debug_ring.py new file mode 100644 index 0000000..38bad5d --- /dev/null +++ b/host/tests/unit/test_sys_debug_ring.py @@ -0,0 +1,142 @@ +"""sys.debug_ring unit tests via fake transport. + +The tool is a thin wrapper over `c:DumpDebugBuffer`; the interesting +behaviour is line-trim + truncation flag + UTC timestamp. Real ring +output isn't worth fixturing (target-specific) — synthetic line +sequences are sufficient. +""" + +from __future__ import annotations + +import re + +import pytest + +from amiga_fleet_mcp.config import ( + Config, + McpdChannel, + TargetChannels, + TargetConfig, +) +from amiga_fleet_mcp.fleet import Fleet +from amiga_fleet_mcp.tools import sys as sys_tool + + +class FakeMcpd: + def __init__(self, output: str = "") -> None: + self.output = output + self.calls: list[tuple[str, dict]] = [] + + async def request(self, method, params=None, timeout_s=30.0): + self.calls.append((method, params or {})) + if method == "exec.cmd": + return {"output": self.output, "exit_code": 0} + raise AssertionError(f"unexpected method: {method!r}") + + +def _fleet(output: str = "") -> tuple[Fleet, FakeMcpd]: + fleet = Fleet(Config(targets={ + "tgt": TargetConfig( + type="remote", + channels=TargetChannels( + mcpd=McpdChannel(endpoint="192.168.0.99:4322"), + ), + ), + })) + fake = FakeMcpd(output) + fleet._mcpd["tgt"] = fake # type: ignore[assignment] + return fleet, fake + + +@pytest.mark.asyncio +async def test_debug_ring_empty_output(): + fleet, fake = _fleet("") + + res = await sys_tool.sys_debug_ring(fleet, "tgt") + + assert res.lines == [] + assert res.truncated is False + assert res.raw_size == 0 + assert fake.calls[0][0] == "exec.cmd" + assert fake.calls[0][1]["command"] == "C:DumpDebugBuffer" + + +@pytest.mark.asyncio +async def test_debug_ring_returns_all_lines_when_under_limit(): + body = "line1\nline2\nline3\n" + fleet, _ = _fleet(body) + + res = await sys_tool.sys_debug_ring(fleet, "tgt", max_lines=500) + + assert res.lines == ["line1", "line2", "line3"] + assert res.truncated is False + assert res.raw_size == len(body) + + +@pytest.mark.asyncio +async def test_debug_ring_trims_to_most_recent_max_lines(): + # 50 lines, ask for last 10 — should keep 41..50 and flag truncated. + body = "\n".join(f"l{i}" for i in range(1, 51)) + "\n" + fleet, _ = _fleet(body) + + res = await sys_tool.sys_debug_ring(fleet, "tgt", max_lines=10) + + assert len(res.lines) == 10 + assert res.lines[0] == "l41" + assert res.lines[-1] == "l50" + assert res.truncated is True + assert res.raw_size == len(body) + + +@pytest.mark.asyncio +async def test_debug_ring_max_lines_floor_one(): + # max_lines=0 is silently bumped to 1 — surprises caused by an + # empty result list when the ring had content are worse than + # an off-by-one. + body = "a\nb\nc\n" + fleet, _ = _fleet(body) + + res = await sys_tool.sys_debug_ring(fleet, "tgt", max_lines=0) + + assert res.lines == ["c"] + assert res.truncated is True + + +@pytest.mark.asyncio +async def test_debug_ring_captured_at_is_iso8601_utc(): + fleet, _ = _fleet("x\n") + + res = await sys_tool.sys_debug_ring(fleet, "tgt") + + # 2026-05-10T12:34:56Z shape, UTC suffix. + assert re.match( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", res.captured_at, + ) + + +@pytest.mark.asyncio +async def test_debug_ring_handles_crlf_line_endings(): + """AmigaDOS occasionally emits \\r\\n; splitlines() handles both.""" + body = "alpha\r\nbeta\r\ngamma\r\n" + fleet, _ = _fleet(body) + + res = await sys_tool.sys_debug_ring(fleet, "tgt") + + assert res.lines == ["alpha", "beta", "gamma"] + + +@pytest.mark.asyncio +async def test_debug_ring_since_s_currently_accepted_but_ignored(): + """The plan reserves `since_s` for a future timestamp-aware + trim but doesn't act on it today. Pin the contract: passing + `since_s` doesn't change the output shape.""" + body = "\n".join(f"x{i}" for i in range(20)) + "\n" + fleet, _ = _fleet(body) + + a = await sys_tool.sys_debug_ring(fleet, "tgt", since_s=1.0, + max_lines=5) + b = await sys_tool.sys_debug_ring(fleet, "tgt", since_s=600.0, + max_lines=5) + + assert a.lines == b.lines + assert len(a.lines) == 5 From 358378fc81ce8953af4c1b057c4012ded1ecfa90 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 15:16:52 +0100 Subject: [PATCH 07/23] =?UTF-8?q?sandbox.*=20phase=20C=20=E2=80=94=20sandb?= =?UTF-8?q?ox.run=5Fbatch=20(multi-guest,=20ring-keyed=20exits)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds sandbox.run_batch: up to 16 guest ELFs sequentially in a single SandboxVM invocation (matches upstream SANDBOXVM_MAX_GUESTS). sandbox.run_batch(target=None, guests=[{guest, name?}, ...], extmem_mb=None, window_mb=None, deny_libs=None, name=None, timeout_s=600.0) Probe-gated, lock-serialised, capture-slurped/cleaned just like run_guest. New return shape: BatchRunResult { aggregate_exit_code, all_clean, duration_s, entries: [BatchEntryResult, ...] }. Each entry carries its own exit_code + trap classification + stdout/ stderr / capture_paths. Per-guest exit codes are recovered from the kernel debug ring after the batch completes (regex match on `[sandboxvm] guest_run_elf returned ` -- the format SandboxVM emits via DebugPrintF in src/main.c). Per-guest names default to . to line up with SandboxVM's own T:sandboxvm-.{out,err} convention. Deliberately doesn't support per-guest argv (SandboxVM forbids `--` with multi-guest) or per-guest deny-lists (SandboxVM `-x` is global) -- the docstring points to sandbox.run_guest for those cases. Edge case caught + fixed during testing: when the kernel ring rotates past older entries before we read it, the K captured "returned" lines align with the LAST K guests, not the first K (ring drops oldest first). Implementation now computes a ring_offset = len(specs) - len(per_guest_exits) and indexes accordingly; aggregate_exit_code is unaffected because SandboxVM itself returns the right last-non-zero rc regardless. 7 new tests added: 3-guest happy path; mixed clean+trap; explicit per-guest names; empty-list rejection; >16-guest rejection; probe-gated failure; ring-loss alignment. 251/251 unit tests pass; ruff + mypy clean. Tool count 127 -> 128. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/server.py | 39 ++- host/src/amiga_fleet_mcp/tools/sandbox.py | 280 ++++++++++++++++++++++ host/tests/unit/test_sandbox.py | 228 ++++++++++++++++++ 3 files changed, 546 insertions(+), 1 deletion(-) diff --git a/host/src/amiga_fleet_mcp/server.py b/host/src/amiga_fleet_mcp/server.py index 2a42a3e..963f941 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -2243,6 +2243,35 @@ async def sandbox_last_trap( retry_count=retry_count, retry_delay_s=retry_delay_s, ) + @mcp.tool(name="sandbox_run_batch", title="sandbox.run_batch") + @archived("sandbox.run_batch", archive) + async def sandbox_run_batch( + *, target: str | None = None, + guests: list[dict[str, Any]], + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: list[str] | None = None, + name: str | None = None, + timeout_s: float = 600.0, + ) -> sandbox_tool.BatchRunResult: + """Run up to 16 guests sequentially in a single SandboxVM + invocation. + + `guests` is a list of `{guest: , name?: }` + objects. Per-guest argv and per-guest deny-lists are NOT + supported (SandboxVM constraint); use `sandbox.run_guest` + per-binary if you need either. Per-guest exit codes are + parsed from the kernel debug ring after the batch completes. + + For JSON-config-driven multi-step bundles without the + SandboxVM harness, use `tests.run_suite`.""" + return await sandbox_tool.sandbox_run_batch( + fleet, fleet.resolve_target(target), + guests=guests, + extmem_mb=extmem_mb, window_mb=window_mb, + deny_libs=deny_libs, name=name, timeout_s=timeout_s, + ) + @mcp.tool(name="sandbox", title="sandbox.dispatch") @archived("sandbox.dispatch", archive) async def sandbox_ns( @@ -2258,6 +2287,9 @@ async def sandbox_ns( run_driver(target=None, driver, test=None, args=None, extmem_mb=None, window_mb=None, deny_libs=None, name=None, timeout_s=120.0) + run_batch(target=None, guests=[{guest, name?}, ...], + extmem_mb=None, window_mb=None, deny_libs=None, + name=None, timeout_s=600.0) last_trap(target=None, since_s=60.0, max_lines=500, retry_count=3, retry_delay_s=0.2) @@ -2285,6 +2317,11 @@ async def sandbox_ns( return await sandbox_tool.sandbox_run_driver( fleet, fleet.resolve_target(p.get("target")), **kwargs, ) + if method == "run_batch": + kwargs = {k: v for k, v in p.items() if k != "target"} + return await sandbox_tool.sandbox_run_batch( + fleet, fleet.resolve_target(p.get("target")), **kwargs, + ) if method == "last_trap": kwargs = {k: v for k, v in p.items() if k != "target"} return await sandbox_tool.sandbox_last_trap( @@ -2294,7 +2331,7 @@ async def sandbox_ns( f"unknown method: {method!r}", data={"namespace_methods": [ "probe", "deploy", "run_guest", - "run_driver", "last_trap", + "run_driver", "run_batch", "last_trap", ]}, ) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index 8326711..fbb2bba 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -1079,6 +1079,286 @@ async def sandbox_last_trap( ) +# --------------------------------------------------------------------- +# run_batch — multiple guests in one sandboxvm invocation +# --------------------------------------------------------------------- + +SANDBOXVM_MAX_GUESTS = 16 +"""Upstream SandboxVM caps a single invocation at 16 guests +(``src/main.c:SANDBOXVM_MAX_GUESTS``). We surface the same limit +rather than launching multiple sandboxvm processes — the +single-process flow has been validated end-to-end on real X5000.""" + + +class BatchGuestSpec(BaseModel): + """One entry in a `sandbox.run_batch` request.""" + + guest: str + """AOS path to the guest ELF.""" + name: str | None = None + """Optional override for this guest's name. When None, the + batch name is suffixed with ``.`` (mirrors SandboxVM's + own per-guest naming convention so the upstream + `T:sandboxvm-..{out,err}` capture files line up).""" + + +class BatchEntryResult(BaseModel): + """Per-guest sub-result inside a `BatchRunResult.entries` list.""" + + guest: str + name: str + exit_code: int + trap_kind: str | None = None + trap_fingerprint: str | None = None + stdout: str = "" + stderr: str = "" + capture_paths: list[str] = Field(default_factory=list) + + +class BatchRunResult(BaseModel): + """Return shape for `sandbox.run_batch`.""" + + target: str + batch_name: str + command: str + """The single sandboxvm invocation that ran the whole batch.""" + aggregate_exit_code: int + """Mirrors SandboxVM's ``last_nonzero`` semantics: 0 when every + guest returned 0, otherwise the last non-zero rc seen. Useful as + a quick "did the whole batch pass?" check.""" + all_clean: bool + """Convenience: ``aggregate_exit_code == 0``.""" + duration_s: float + entries: list[BatchEntryResult] + + +# Regex for parsing per-guest exit codes from the debug ring. The +# format is `[sandboxvm] guest_run_elf returned ; calling +# guest_destroy` (per src/main.c). Resident-mode invocations are +# single-guest only and therefore not run via run_batch, so this one +# pattern covers the whole batch flow. +_RE_GUEST_EXIT = re.compile( + r"\[sandboxvm\]\s+guest_run_elf\s+(\S+)\s+returned\s+(-?\d+)", +) + + +def _parse_per_guest_exits( + ring_lines: Sequence[str], expected: int, +) -> list[tuple[str, int]]: + """Walk `ring_lines` and return the most recent `expected` + matches of the per-guest exit format, preserving order + (oldest-first within the trailing window). + + Returns a list of (path, rc) pairs. May be shorter than + `expected` if the ring rotated past the older guests before we + could capture it -- the caller pads with a sentinel rc.""" + matches: list[tuple[str, int]] = [] + for line in ring_lines: + m = _RE_GUEST_EXIT.search(line) + if m: + matches.append((m.group(1), int(m.group(2)))) + return matches[-expected:] if expected > 0 else [] + + +def _build_batch_command(sandboxvm_path: str, *, + guests: Sequence[str], + extmem_mb: int, + window_mb: int, + deny_libs: Sequence[str], + name: str) -> str: + """Construct the AmigaDOS command line for a multi-guest run. + + Format: + "" -m -w -n [-x lib]... ... + + Multi-guest mode forbids ``--``-style argv passing (SandboxVM + enforces this in src/main.c) so per-guest args aren't part of + the contract here. Use `sandbox.run_guest` when you need argv.""" + parts: list[str] = [f'"{sandboxvm_path}"'] + parts.extend(["-m", str(extmem_mb)]) + parts.extend(["-w", str(window_mb)]) + parts.extend(["-n", _shell_safe(name)]) + for lib in deny_libs: + parts.extend(["-x", _shell_safe(lib)]) + for g in guests: + parts.append(f'"{g}"') + return " ".join(parts) + + +async def sandbox_run_batch( + fleet: Fleet, target: str, *, + guests: Sequence[BatchGuestSpec | dict], + extmem_mb: int | None = None, + window_mb: int | None = None, + deny_libs: Sequence[str] | None = None, + name: str | None = None, + timeout_s: float = 600.0, +) -> BatchRunResult: + """Run up to 16 guests sequentially inside a single SandboxVM + invocation. + + Probe-gated and lock-serialised like `run_guest` / `run_driver`. + Per-guest argv is **not** supported (SandboxVM forbids ``--`` + with multi-guest); for that, call `sandbox.run_guest` per + binary. Per-guest deny-lists also aren't supported — `deny_libs` + is a global list applied to every guest in the batch (mirrors + SandboxVM's ``-x`` semantics). + + Per-guest exit codes are parsed from the kernel debug ring + (``[sandboxvm] guest_run_elf returned ``). When the + ring rolls past older entries before we capture it, missing + per-guest results are reported as ``exit_code=0`` with + ``stdout=""`` -- the aggregate ``aggregate_exit_code`` is still + correct because SandboxVM itself returns the last non-zero rc. + + Args: + guests: List of BatchGuestSpec entries (or equivalent dicts + via Pydantic coercion). Min 1, max 16. + extmem_mb / window_mb / deny_libs / name: Same semantics as + `sandbox.run_guest`. Apply globally to the whole batch. + timeout_s: Host-side request timeout. Higher default than + run_guest (600 s vs 120 s) because a 16-guest batch can + comfortably exceed the single-guest budget. + + For JSON-config-driven multi-step bundles without the SandboxVM + harness, use ``tests.run_suite`` instead.""" + if not guests: + raise InvalidParams( + "sandbox.run_batch requires at least one guest entry", + ) + if len(guests) > SANDBOXVM_MAX_GUESTS: + raise InvalidParams( + f"sandbox.run_batch supports at most " + f"{SANDBOXVM_MAX_GUESTS} guests per invocation; " + f"got {len(guests)}", + data={"max": SANDBOXVM_MAX_GUESTS, "got": len(guests)}, + ) + + specs: list[BatchGuestSpec] = [ + g if isinstance(g, BatchGuestSpec) else BatchGuestSpec(**g) + for g in guests + ] + + probe = await _ensure_available(fleet, target) + assert probe.path is not None + + cfg = fleet.target_config(target).sandbox + if extmem_mb is None: + extmem_mb = cfg.default_extmem_mb if cfg else 1024 + if window_mb is None: + window_mb = cfg.default_window_mb if cfg else 256 + + merged_deny: list[str] = [] + if cfg and cfg.deny_libs: + merged_deny.extend(cfg.deny_libs) + if deny_libs: + for lib in deny_libs: + if lib not in merged_deny: + merged_deny.append(lib) + + # Batch name. Single base name; per-guest names get `.` + # appended by SandboxVM itself (we mirror that for capture-file + # path resolution). + batch_name = name or "batch" + per_guest_names = [ + s.name if s.name else f"{batch_name}.{i}" + for i, s in enumerate(specs) + ] + + cmd = _build_batch_command( + probe.path, + guests=[s.guest for s in specs], + extmem_mb=extmem_mb, + window_mb=window_mb, + deny_libs=merged_deny, + name=batch_name, + ) + + lock = _lock_for(target) + t0 = time.monotonic() + async with lock: + mcpd = fleet.mcpd(target) + raw = await mcpd.request( + "exec.cmd", + { + "command": cmd, + "timeout_ms": int(timeout_s * 1000), + }, + timeout_s=max(timeout_s + 5.0, 30.0), + ) + aggregate = int(raw.get("exit_code", 0)) + + # Read the kernel ring after the run to recover per-guest + # exit codes. We ask for a generous max_lines so a 16-guest + # batch's worth of "returned" lines + per-guest preludes all + # fit in the captured window. + ring = await sys_tool.sys_debug_ring( + fleet, target, + since_s=60.0, + max_lines=2000, + ) + per_guest_exits = _parse_per_guest_exits(ring.lines, len(specs)) + + # Per-guest captures. Names follow SandboxVM's + # `T:sandboxvm-.{out,err}` convention. + entries: list[BatchEntryResult] = [] + for idx, spec in enumerate(specs): + gname = per_guest_names[idx] + out_path = f"T:sandboxvm-{gname}.out" + err_path = f"T:sandboxvm-{gname}.err" + stdout, stdout_present = await _read_capture( + fleet, target, out_path, + ) + stderr, stderr_present = await _read_capture( + fleet, target, err_path, + ) + await _delete_capture(fleet, target, out_path) + await _delete_capture(fleet, target, err_path) + + # Match per-guest exit by ordinal — the ring entries we + # parsed are in batch order, but if the ring rotated + # past older guests, the K we captured map to the LAST + # K guests in the batch (oldest got dropped). Compute + # an alignment offset accordingly. Aggregate exit_code + # already covers the "did anything fail" question; the + # default-to-0 for missing entries is purely cosmetic. + ring_offset = len(specs) - len(per_guest_exits) + if idx >= ring_offset: + exit_code = per_guest_exits[idx - ring_offset][1] + else: + exit_code = 0 + + trap_kind, fingerprint = _classify_exit(exit_code, stderr) + + capture_paths: list[str] = [] + if stdout_present: + capture_paths.append(out_path) + if stderr_present: + capture_paths.append(err_path) + + entries.append(BatchEntryResult( + guest=spec.guest, + name=gname, + exit_code=exit_code, + trap_kind=trap_kind, + trap_fingerprint=fingerprint, + stdout=stdout, + stderr=stderr, + capture_paths=capture_paths, + )) + + return BatchRunResult( + target=target, + batch_name=batch_name, + command=cmd, + aggregate_exit_code=aggregate, + all_clean=(aggregate == 0 + and all(e.exit_code == 0 for e in entries)), + duration_s=time.monotonic() - t0, + entries=entries, + ) + + # --------------------------------------------------------------------- # Test-only helpers # --------------------------------------------------------------------- diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index 10afae9..bc4cf08 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -838,3 +838,231 @@ async def test_last_trap_extract_helper_with_traptype_only(): assert hex_str == "0x999" assert fp is None assert len(block) == 1 + + +# ---- run_batch ----------------------------------------------------- + + +def _ring_for_batch(per_guest_exits: list[tuple[str, int]]) -> list[str]: + """Synthesise a ring containing per-guest "returned" lines in + order, the way SandboxVM emits them via DebugPrintF.""" + return [ + f"[sandboxvm] guest_run_elf {path} returned {rc}; calling guest_destroy" + for path, rc in per_guest_exits + ] + + +def _stub_debug_ring(lines: list[str]) -> mock._patch: + """Convenience: return a mock.patch context that stubs + sys.debug_ring with the canned `lines`.""" + canned = sys_tool.DebugRingResult( + target="tgt", lines=lines, truncated=False, + raw_size=sum(len(line) for line in lines) + len(lines), + captured_at="2026-05-10T15:00:00Z", + ) + return mock.patch.object( + sb.sys_tool, "sys_debug_ring", return_value=canned, + ) + + +@pytest.mark.asyncio +async def test_run_batch_three_guests_all_clean(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + captured: dict[str, str] = {} + + def cap(p): + if "guest0" in p["command"]: + captured["cmd"] = p["command"] + return True + return False + + # Aggregate exit_code=0 since all guests cleaned up. + fake.queue_exec(cap, {"output": "", "exit_code": 0}) + fake.add_path("T:sandboxvm-batch.0.out", b"out0\n") + fake.add_path("T:sandboxvm-batch.1.out", b"out1\n") + fake.add_path("T:sandboxvm-batch.2.out", b"out2\n") + + ring = _ring_for_batch([ + ("Tools:guest0", 0), + ("Tools:guest1", 0), + ("Tools:guest2", 0), + ]) + with _stub_debug_ring(ring): + res = await sb.sandbox_run_batch( + fleet, "tgt", + guests=[ + {"guest": "Tools:guest0"}, + {"guest": "Tools:guest1"}, + {"guest": "Tools:guest2"}, + ], + ) + + cmd = captured["cmd"] + # Three positional guest paths in batch order, all quoted. + assert '"Tools:guest0"' in cmd + assert '"Tools:guest1"' in cmd + assert '"Tools:guest2"' in cmd + # No `--` (multi-guest disallows argv). + assert " -- " not in cmd + + assert res.aggregate_exit_code == 0 + assert res.all_clean is True + assert len(res.entries) == 3 + assert [e.guest for e in res.entries] == [ + "Tools:guest0", "Tools:guest1", "Tools:guest2", + ] + # Default per-guest names: . (0/1/2). + assert [e.name for e in res.entries] == [ + "batch.0", "batch.1", "batch.2", + ] + # Capture stdouts decoded. + assert res.entries[0].stdout == "out0\n" + assert res.entries[1].stdout == "out1\n" + assert res.entries[2].stdout == "out2\n" + # All entry exit codes parsed from ring. + assert all(e.exit_code == 0 for e in res.entries) + + +@pytest.mark.asyncio +async def test_run_batch_mixed_clean_and_trap(): + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + fake.queue_exec( + lambda p: "guest" in p["command"], + # SandboxVM aggregate: last non-zero rc seen across the + # batch. Guest #1 trapped (-768), guest #2 returned 0. + {"output": "", "exit_code": -768}, + ) + + ring = _ring_for_batch([ + ("Tools:guestA", 0), + ("Tools:guestB", -768), + ("Tools:guestC", 0), + ]) + with _stub_debug_ring(ring): + res = await sb.sandbox_run_batch( + fleet, "tgt", + guests=[ + {"guest": "Tools:guestA"}, + {"guest": "Tools:guestB"}, + {"guest": "Tools:guestC"}, + ], + ) + + assert res.aggregate_exit_code == -768 + assert res.all_clean is False + assert res.entries[0].exit_code == 0 + assert res.entries[0].trap_kind is None + assert res.entries[1].exit_code == -768 + # Trap classified per-entry from its own exit code. + assert res.entries[1].trap_kind == "DSI" + assert res.entries[2].exit_code == 0 + assert res.entries[2].trap_kind is None + + +@pytest.mark.asyncio +async def test_run_batch_explicit_per_guest_names(): + """Caller supplies a name override per entry; we honour it + instead of synthesising ..""" + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + fake.queue_exec( + lambda p: "guest" in p["command"], + {"output": "", "exit_code": 0}, + ) + + ring = _ring_for_batch([ + ("Tools:a", 0), + ("Tools:b", 0), + ]) + with _stub_debug_ring(ring): + res = await sb.sandbox_run_batch( + fleet, "tgt", + guests=[ + {"guest": "Tools:a", "name": "alpha"}, + {"guest": "Tools:b", "name": "beta"}, + ], + ) + + assert [e.name for e in res.entries] == ["alpha", "beta"] + + +@pytest.mark.asyncio +async def test_run_batch_rejects_empty_list(): + fleet, _ = _make_available_fleet() + with pytest.raises(InvalidParams, match="at least one"): + await sb.sandbox_run_batch(fleet, "tgt", guests=[]) + + +@pytest.mark.asyncio +async def test_run_batch_rejects_over_16_guests(): + fleet, _ = _make_available_fleet() + with pytest.raises(InvalidParams, match="at most"): + await sb.sandbox_run_batch( + fleet, "tgt", + guests=[{"guest": f"Tools:g{i}"} for i in range(17)], + ) + + +@pytest.mark.asyncio +async def test_run_batch_probe_gated(): + """No sandboxvm -> the validation runs first then the probe + gate fires before any exec.cmd is issued.""" + fleet = Fleet(_make_config()) + fleet._mcpd["tgt"] = FakeMcpd() # type: ignore[assignment] + with pytest.raises(InvalidParams): + await sb.sandbox_run_batch( + fleet, "tgt", + guests=[{"guest": "Tools:hello"}], + ) + + +@pytest.mark.asyncio +async def test_run_batch_handles_ring_loss_gracefully(): + """If the kernel ring rolled past older "returned" lines before + we read it, missing per-guest exits default to 0 -- the + aggregate stays correct and we don't blow up.""" + fleet, fake = _make_available_fleet() + fake.queue_exec( + lambda p: p["command"] == '"SYS:Tools/sandboxvm"', + {"output": "banner", "exit_code": 5}, + ) + fake.queue_exec( + lambda p: "Tools:" in p["command"], + {"output": "", "exit_code": 5}, + ) + + # Ring has only two of the three guests' returned lines. + ring = _ring_for_batch([ + ("Tools:second", 0), + ("Tools:third", 5), + ]) + with _stub_debug_ring(ring): + res = await sb.sandbox_run_batch( + fleet, "tgt", + guests=[ + {"guest": "Tools:first"}, + {"guest": "Tools:second"}, + {"guest": "Tools:third"}, + ], + ) + + assert res.aggregate_exit_code == 5 + assert len(res.entries) == 3 + # First entry got the rc=0 default since its "returned" line + # rolled out of the ring before we read it. The two captured + # ring entries align with entries[1] (Tools:second / rc=0) and + # entries[2] (Tools:third / rc=5). + assert res.entries[0].exit_code == 0 + assert res.entries[1].exit_code == 0 + assert res.entries[2].exit_code == 5 From 0d0e4146f77495c92b8e07b92504f1d0d5a1921e Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 15:25:06 +0100 Subject: [PATCH 08/23] docs: cover sandbox.* + sys.debug_ring + bootstrap_dir removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the SandboxVM integration (Phases A/B/C) and the installer refactor that dropped the host-side bootstrap_dir requirement, in the public-facing docs gating a release. README.md - "iterate without power-cycling" bullet under §At a glance. - Tool count 118 -> 128. - sandbox.* added to the namespace list. - bootstrap_dir removed from the [defaults] example. COMMANDS.md - sys.debug_ring row in the sys.* table. - New "Sandbox harness (sandbox.*)" section: probe / deploy / run_guest / run_driver / run_batch / last_trap with the typed error codes (SANDBOXVM_MISSING / _BROKEN / _INCOMPATIBLE_TARGET) and the upstream link. - sandbox row added to the namespace dispatcher table. - installer_stage description updated (no diskimage-bootstrap upload). - bootstrap_dir removed from frequently-repeated-parameters list under installer.*. USAGE.md - New "Driver / program iteration via SandboxVM" section between Out-of-band power and Validation: when to use it, prerequisites, typical inner loop, driver iteration flow, batched test bundles, cross-references to sys.debug_ring and tests.run_suite. - bootstrap_dir removed from the [defaults] example + the "Supported keys" table. INSTALL.md - Removed the "host-side diskimage-bootstrap directory required" prerequisite. Replaced with a brief note explaining the tools come from the running AmigaOS / install ISO instead. - bootstrap_dir removed from the [defaults] sample. CHANGELOG.md - New Unreleased section covering all sandbox.* tools, sys.debug_ring, the bundled AmiDock prefs, the new dismount_combi_device installer step, and the bootstrap_dir removal as a behaviour change. v1.2 entry left untouched. Phase D from SANDBOX_PLAN.md is deferred -- triggered only by an actual workload need (long-running streaming or multi-target parallel-guest patience regression). Not implemented this cycle. 251/251 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 74 +++++++++++++++++++++++++++++ COMMANDS.md | 44 +++++++++++++++-- INSTALL.md | 15 +++--- README.md | 27 +++++++---- USAGE.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 266 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ef041..dc1fb98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Change log +## Unreleased + +### Added + +- **`sandbox.*` namespace** — driver- and program-iteration loop on + top of [SandboxVM](https://github.com/derfsss/SandboxVM), an AOS4 + in-process sandbox host that survives guest crashes (DSI / ISI / + alignment / privilege traps). Five tools plus one shared primitive: + - `sandbox.probe` — path resolution + executability + + Pegasos II refusal. Three typed error codes + (`SANDBOXVM_MISSING` / `SANDBOXVM_BROKEN` / + `SANDBOXVM_INCOMPATIBLE_TARGET`). Probe cache (60 s TTL) for + chatty workflows; eager re-probe via the tool itself. + - `sandbox.deploy` — wraps `fs.upload` with SHA-256 verify + + cache invalidation + path-pinned post-deploy probe. + - `sandbox.run_guest` — runs one guest ELF, slurps + `T:sandboxvm-.{out,err}` captures, decodes SandboxVM's + exit-code convention into `trap_kind` (DSI / ISI / alignment / + program / fp_unavailable) and the documented kmod-libcall + NULL+4 fingerprint. + - `sandbox.run_driver` — resident-driver mode (`-r`) with + optional `-t` follow-on test guest. Loads the driver via + `RTF_AUTOINIT` Resident + `CLT_InitFunc` so a test program in + the same Guest can `OpenLibrary` the driver by name. + - `sandbox.run_batch` — up to 16 guests sequentially in one + sandboxvm invocation. Per-guest exit codes recovered from the + kernel debug ring; aggregate exit follows SandboxVM's + `last_nonzero` convention. Multi-target fan-out via + `fleet.run_on_all`. + - `sandbox.last_trap` — filter-on-top of `sys.debug_ring` for + SandboxVM trap signatures. 3-attempt × 200 ms retry handles + the kernel-ring write race after a crashed `run_guest`. +- **`sys.debug_ring`** — new primitive that reads the kernel debug + ring via `c:DumpDebugBuffer`. Lifted out of `sandbox.last_trap` + during the namespace-overlap review so the rest of the project + (post-install forensics, hardware bring-up) can use it too. +- **`[paths] sandboxvm`** — host-side path to a built + `bin/sandboxvm`, used by `sandbox.deploy` source resolution. +- **`[targets..sandbox]` block** — per-target SandboxVM + overrides: AOS path, default `-m` extmem MB, default `-w` + window MB, always-on `-x` deny-libs. +- **Bundled `AmiDock.amiga.com.xml`** as a package resource. The + installer's `patch_amidock_prefs` step previously skipped + silently when no XML was staged; it now uses the bundled prefs + by default, so a fresh install ships with Filer / Ranger / + IBrowse / DiskImageGUI in the dock subdrawers. +- **`installer.dismount_combi_device`** — new step at the end of + the install pipeline. Ejects the install ISO from + `diskimage.device` unit 50 (defensive, even though `unmount_iso` + ran), deletes the installer-written `DEVS:DOSDrivers/COMBI` + mountfile (only when it carries the installer's marker so a + user-owned COMBI: is left alone), and issues + `c:Dismount COMBI: FORCE` to drop the live DOS entry. + +### Changed + +- **`installer.*` no longer requires a host-side + `diskimage-bootstrap/` directory.** The AOS 4.1 diskimage tools + (`MountDiskImage` / `diskimage.device` / `CDFileSystem`) ship + with AOS 4.1 itself; `stage_diskimage_tools` is now a probe + that fails loudly if the running system is missing any of them + (which would mean the install host isn't a working AOS 4.1 + install). The dest drive picks up fresh copies via the normal + `copy_base_os` pull from `:System/`. Drops `bootstrap_dir` + from `installer.preflight` / `installer.stage` / + `[defaults] bootstrap_dir`; the `--init` wizard no longer + prompts for it. +- **`sandbox.probe` doesn't gate on banner content.** Real-X5000 + testing showed sandboxvm's printf-based usage banner is + occasionally captured as empty by MCPd's exec.cmd (clib4 / + newlib stdio buffering not flushed before the process detaches + from its inherited Output() handle). The probe now treats any + structured `exec.cmd` exit as proof the binary started. + ## 1.2 — Guided setup and whole-file transfers ### Added diff --git a/COMMANDS.md b/COMMANDS.md index cd174e8..6f75c28 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -82,6 +82,7 @@ prerequisites](INSTALL.md#per-feature-prerequisites). | `sys.read_pa` | DANGEROUS supervisor-mode read at any 36-bit physical address. No range checking; misuse can crash the connection-handling task (the daemon listener stays up by design). | | `sys.tlb_dump` | Dump all 64 TLB1 entries from a P5020 / P1022 target via `tlbre` / MAS registers. | | `sys.mcu_cmd` | X5000 Cyrus MCU supervisor protocol over `serial.device` unit 1 (38400 8N1). Documented commands `t` / `v` / `f` / `s` plus build-info `b`. The canonical sensor path on X5000 (board / CPU temperatures, voltage rails, fan PWM/RPM, soft power-off). | +| `sys.debug_ring` | Read the kernel debug ring via `c:DumpDebugBuffer`. Returns the most recent `max_lines` lines, the truncation flag, raw byte size, and an ISO-8601 host capture timestamp. Useful for post-install forensics, hardware bring-up, and as the primitive that `sandbox.last_trap` filters against. `since_s` is reserved for a future timestamp-aware trim — accepted but not yet used (DumpDebugBuffer entries don't carry per-line timestamps). | ### `fs.*` @@ -217,9 +218,9 @@ The captured QEMU serial output is exposed as the read-only resource End-to-end AmigaOS 4.1 FE install pipeline. The frequently-repeated parameters (`dest_volume`, `sources_dir`, -`bootstrap_dir`, `machine`, `iso_filename`) can be defaulted in -`config.toml` under `[defaults]` so a typical session does not have -to repeat them on every call — see [USAGE.md § Per-tool +`machine`, `iso_filename`) can be defaulted in `config.toml` +under `[defaults]` so a typical session does not have to repeat +them on every call — see [USAGE.md § Per-tool defaults](USAGE.md#per-tool-defaults). | Tool | Description | @@ -228,7 +229,7 @@ defaults](USAGE.md#per-tool-defaults). | `installer_required_files` | For a given machine, the file manifest the installer expects under `sources_dir`. Honours `[defaults] machine`. | | `installer_scan_sources` | Walk a host-side directory and report which install files are present, ambiguous, or unsupported. Honours `[defaults] sources_dir`. | | `installer_preflight` | Composite pre-install safety check: `sources_dir` validity, machine resolution, dest-volume mount + cleanliness, mandatory-binaries availability. Read-only. Honours `[defaults] dest_volume / sources_dir / machine`. | -| `installer_stage` | Upload ISO + LHAs + `diskimage-bootstrap/` into `:tmp/`. Multi-GB upload; requires `confirm: true`. Honours `[defaults] dest_volume / sources_dir / bootstrap_dir / machine / iso_filename`. | +| `installer_stage` | Upload ISO + Update LHAs + Enhancer + extras + MCPd + bundled AmiDock prefs into `:tmp/`. Multi-GB upload; requires `confirm: true`. The AOS 4.1 diskimage tools are sourced from the running AmigaOS / install ISO, not staged from the host. Honours `[defaults] dest_volume / sources_dir / machine / iso_filename`. | | `installer_mount_iso` | Mount an ISO via `diskimage.device` + `MountDiskImage`. Reversible via `installer_unmount_iso`. | | `installer_unmount_iso` | Eject an ISO from a `diskimage.device` unit. Idempotent. | | `installer_copy_tree` | Recursive AmigaDOS `Copy ALL CLONE QUIET` between paths on a target. | @@ -278,6 +279,38 @@ serial port + 38400 baud. Honours `[server] default_target`. | `power_off` | **yes** | Shut down all supplies (`s`). | | `power_shell` | **yes** | Generic shell-command passthrough (escape hatch). | +### Sandbox harness (`sandbox.*`) + +Driver- and program-iteration loop on top of +[SandboxVM](https://github.com/derfsss/SandboxVM) — a PPC AOS4 binary +that runs guest ELFs inside a sandboxed `IExec` clone with a +`tc_TrapCode` trampoline that catches DSI / ISI / illegal / +alignment / privilege traps so the host process survives. Lets an +agent edit-compile-deploy-run a binary without power-cycling on +every crash. + +Every `sandbox.*` call probes the target up front. The probe +fails fast with a typed code (`SANDBOXVM_MISSING`, +`SANDBOXVM_BROKEN`, `SANDBOXVM_INCOMPATIBLE_TARGET`) so a +misconfigured target raises an obvious error rather than +half-running. + +Pegasos II is refused at probe time — SandboxVM requires ExtMem +(X5000 / X1000 / A1222 / QEMU AmigaOne). + +| Tool | Confirm | Description | +|---|---|---| +| `sandbox_probe` | — | Path resolution + executability + machine compatibility. Returns a structured `SandboxProbeResult`. Bypasses the in-process probe cache (60 s TTL). | +| `sandbox_deploy` | **yes** | Upload sandboxvm to the target. Convenience wrapper around `fs.upload`: source from `[paths] sandboxvm` (or explicit `source` arg), dest from `[targets..sandbox.path]` (defaults to `SYS:Tools/sandboxvm`). Verifies via SHA-256 and re-probes the just-uploaded path so the cache can't be shadowed by a legacy default-path binary. | +| `sandbox_run_guest` | — | Run one guest ELF. Probe-gated, lock-serialised, `T:capture` files slurped + classified. Returns `exit_code` (signed: 0 clean, positive guest rc, negative trap), `trap_kind` (`DSI`/`ISI`/`alignment`/`program`/`fp_unavailable` when matched), `trap_fingerprint` (`kmod_libcall_null4` for the documented `HD_SCSICMD via DoIO` limitation), `stdout` / `stderr` / `capture_paths`. | +| `sandbox_run_driver` | — | Load a driver via SandboxVM resident-driver mode (`-r`). Scans the driver's `RTF_AUTOINIT` Resident, applies PPC ELF relocations, and calls `CLT_InitFunc` with the sandboxed `IExec`. With `test=` set, runs the test ELF as a follow-on guest in the same Guest context so `OpenLibrary()` resolves through the resident-lib registry. Same return shape as `run_guest`. | +| `sandbox_run_batch` | — | Run up to 16 guests sequentially in a single SandboxVM invocation. Returns a `BatchRunResult` with per-entry classification + the aggregate (last non-zero rc, mirrors SandboxVM's own convention). Per-guest argv and per-guest deny-lists are NOT supported (SandboxVM constraint); use `sandbox.run_guest` per-binary if you need either. | +| `sandbox_last_trap` | — | Filter the kernel debug ring for SandboxVM trap signatures. Wraps `sys.debug_ring` with a 3-attempt × 200 ms retry loop so a `last_trap` call immediately after a crashed `run_guest` doesn't race the kernel ring write. Returns the matched trap block plus structured `trap_kind` / `fingerprint` / `traptype_hex`. `found=False` is the legitimate "no trap to report" outcome. | + +For arbitrary AmigaDOS commands without the SandboxVM harness, use +`exec.cmd`. For JSON-config-driven multi-step test bundles without +SandboxVM, use `tests.run_suite`. + ### Namespace dispatchers Each major namespace also has a single dispatcher tool that takes a @@ -299,6 +332,7 @@ to script an operation by method name rather than by tool name. | `installer` | `installer.*` | | `serial` | `serial.*` | | `power` | `power.*` | +| `sandbox` | `sandbox.*` | ## MCP resources @@ -343,7 +377,7 @@ Located in `scripts/`. Run with `python scripts/.py`. | Script | Description | |---|---| | `run_installer_x5000.py` | Drive an end-to-end X5000 install via `installer_run` (defaults to dry-run). | -| `run_installer_stage.py` | Drive `installer_stage` to upload an ISO + LHAs + `diskimage-bootstrap/` to a target. | +| `run_installer_stage.py` | Drive `installer_stage` to upload the ISO + Update LHAs + Enhancer + extras + MCPd to a target. | | `deploy_mcpd_x5000.py` | One-shot deploy of a freshly built MCPd to a running X5000 (auto-start install + watchdog). | ### Diagnostics and probes diff --git a/INSTALL.md b/INSTALL.md index 4b64ebc..48665a0 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -162,15 +162,16 @@ Drives an end-to-end AmigaOS 4.1 FE install. Requirements: extras (X5000 needs `NGFCheck.lha` + `NGFS.lha`). Run `installer.required_files` for the machine to see the full list. - A built `MCPd` binary, either in `sources_dir` or in the - install-support tree (set `$AMIGA_INSTALL_SUPPORT_DIR` or pass - `bootstrap_dir=`). -- A `diskimage-bootstrap/` directory holding `MountDiskImage`, - `diskimage.device`, and `CDFileSystem`. The installer falls back - to `/diskimage-bootstrap/` if `bootstrap_dir` is not - given. + install-support tree (set `$AMIGA_INSTALL_SUPPORT_DIR`). - A formatted destination volume on the target that is **not** the current system volume. `installer.preflight` checks this and refuses to proceed against a system volume. + + Note: the AOS 4.1 diskimage tools (`MountDiskImage`, + `diskimage.device`, `CDFileSystem`) are sourced from the running + AmigaOS at install time and pulled onto the dest drive via + `copy_base_os` from the install ISO — no host-side + `diskimage-bootstrap/` directory is required. - The A1222 install path additionally requires a mounted RESCUEV2: or AAATECH0: USB volume on the target with the bootloader files; preflight emits a warning when targeting A1222. @@ -296,7 +297,7 @@ blocks. See [USAGE.md](USAGE.md) for the configuration schema. The starter config also ships with a commented `[defaults]` block — uncomment and fill it in to give frequently-repeated parameters -(`dest_volume`, `sources_dir`, `bootstrap_dir`, `machine`, ...) a +(`dest_volume`, `sources_dir`, `machine`, `iso_filename`) a fleet-wide default. See [USAGE.md § Per-tool defaults](USAGE.md#per-tool-defaults) for the full list of keys. diff --git a/README.md b/README.md index 8c75626..2540901 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,15 @@ can: `power.toggle_stream` drive the MCU debug shell directly from MCP. Works regardless of AOS / MCPd state; the only software path to boot a fully-off X5000. - -118 typed MCP tools, 8 live resources, validated end-to-end on QEMU +- **Iterate on programs and drivers without power-cycling** — + `sandbox.*` wraps [SandboxVM](https://github.com/derfsss/SandboxVM) + so a guest segfault no longer takes down the daemon. Edit → + upload → run → read structured exit code + trap classification + + captured stdout/stderr; load `.device` / `.library` drivers in + resident-init mode and chain a test program against them, all in + a single tool call. + +128 typed MCP tools, 8 live resources, validated end-to-end on QEMU pegasos2 and real AmigaOne X5000 hardware. If that sounds useful, the rest of this README and the docs below @@ -86,13 +93,13 @@ A summary follows; the full list of tools, resources, methods, and helper scripts lives in [COMMANDS.md](COMMANDS.md), with narrative context in [USAGE.md](USAGE.md). -- More than 100 typed MCP tools across `fs.*`, `exec.cmd`, `sys.*`, +- More than 120 typed MCP tools across `fs.*`, `exec.cmd`, `sys.*`, `wb.*`, `debug.*`, `qemu.*`, `fleet.*`, `tests.*`, `events.wait`, - `app.notify`, `notify.*`, `installer.*`, `serial.*`, and - `power.*`, plus a namespace dispatcher per group. (`events.subscribe`, - `events.unsubscribe`, and `events.test_emit` are exposed only as - daemon RPC methods, not MCP tools — clients call them directly - through the transport.) + `app.notify`, `notify.*`, `installer.*`, `serial.*`, `power.*`, + and `sandbox.*`, plus a namespace dispatcher per group. + (`events.subscribe`, `events.unsubscribe`, and `events.test_emit` + are exposed only as daemon RPC methods, not MCP tools — clients + call them directly through the transport.) - Eight live MCP resources for fleet status, per-target system snapshots, capability reports, and serial logs. - Parallel multi-target operations (`fleet.run_on_all`, @@ -112,8 +119,8 @@ context in [USAGE.md](USAGE.md). `S:Network-Startup`. Boot-to-bind is approximately eleven seconds on an AmigaOne X5000. - Per-tool parameter defaults via a `[defaults]` block in - `config.toml` (`dest_volume`, `sources_dir`, `bootstrap_dir`, - `machine`, ...). Set frequently-repeated values once and skip + `config.toml` (`dest_volume`, `sources_dir`, `machine`, + `iso_filename`). Set frequently-repeated values once and skip them in subsequent tool calls; explicit per-call values always override. See [USAGE.md § Per-tool defaults](USAGE.md#per-tool-defaults). diff --git a/USAGE.md b/USAGE.md index 298ee66..0d17682 100644 --- a/USAGE.md +++ b/USAGE.md @@ -80,7 +80,6 @@ default_target = "x5000" [defaults] dest_volume = "BootTest:" sources_dir = "/path/to/your/amiga-source-tree" -bootstrap_dir = "/path/to/your/diskimage-bootstrap" machine = "X5000" # iso_filename = "AmigaOneX5000InstallCD-53.42.iso" # rarely needed; auto-detected ``` @@ -100,7 +99,6 @@ Resolution order on every call: |---|---| | `dest_volume` | `installer.preflight`, `installer.read_kicklayout`, `installer.write_kicklayout`, `installer.patch_kicklayout`, `installer.install_x5000`, `installer.run`, `installer.stage`, `installer.verify` | | `sources_dir` | `installer.scan_sources`, `installer.preflight`, `installer.install_x5000`, `installer.run`, `installer.stage` | -| `bootstrap_dir` | `installer.stage` | | `machine` | `installer.required_files`, `installer.preflight`, `installer.run`, `installer.stage`, `installer.verify` | | `iso_filename` | `installer.install_x5000`, `installer.run`, `installer.stage` (auto-detected when omitted) | @@ -333,6 +331,134 @@ fleet.target_status(target="x5000-real") # confirm reachable `power.on` `p` empirically takes ~80 s for a full off-then-boot cycle before MCPd is reachable again on real X5000 hardware. +## Driver / program iteration via SandboxVM (`sandbox.*`) + +The `sandbox.*` namespace wraps +[SandboxVM](https://github.com/derfsss/SandboxVM) — a PPC AOS4 +binary that runs guest ELFs inside a sandboxed `IExec` clone with +a `tc_TrapCode` trampoline that survives DSI / ISI / illegal / +alignment / privilege traps. The integration lets an agent +edit-compile-deploy-run a binary on a real or emulated AmigaOS +target without power-cycling on every crash. + +### When to use it + +- Iterating on a new program where you expect occasional segfaults + during development. +- Loading a `.device` / `.library` driver and exercising it with a + follow-on test program, **without** doing a real `Mount` on the + live system. +- Running a regression bundle of test binaries (≤ 16 per call) and + collecting per-guest exit codes + trap classifications. + +For untrusted code or genuinely unsafe fuzz targets, use QEMU +instead — SandboxVM is API-boundary, not MMU-boundary, so a wild +store like `*(int *)0xDEADBEEF = 42` still corrupts host memory +(the sandbox catches the *trap that follows*, not the store). +This is documented and expected. + +### Prerequisites + +- Target must be ExtMem-capable: X5000 / X1000 / A1222 or QEMU + AmigaOne. Pegasos II is refused at probe time. +- A built `bin/sandboxvm` (PPC AOS4 binary). Fastest route: + download from + [the SandboxVM releases page](https://github.com/derfsss/SandboxVM/releases/latest). + Build-from-source documented in the SandboxVM repo's README + (Docker cross-compile via `walkero/amigagccondocker:os4-gcc11`). +- Optional `[paths] sandboxvm` in `config.toml` so `sandbox.deploy` + doesn't need an explicit `source=`. Optional + `[targets..sandbox]` block for per-target overrides + (binary path, default extmem / window size, always-on deny-libs). + +### Typical inner loop + +``` +sandbox.probe(target="x5000") +# -> SANDBOXVM_MISSING -- binary not on target yet + +sandbox.deploy(target="x5000", confirm=true) +# uploads bin/sandboxvm to SYS:Tools/sandboxvm, invalidates the +# probe cache, re-pins it to the deployed path + +# build + upload your guest binary via the host Docker SDK + +# fs.upload -- or just fs.upload an existing build. +fs.upload(target="x5000", + local_path="bin/my_test", + remote_path="RAM:my_test") + +sandbox.run_guest(target="x5000", guest="RAM:my_test") +# -> { exit_code: 0, trap_kind: null, stdout: "...", ... } + +# on a crash: +sandbox.run_guest(target="x5000", guest="RAM:crashy") +# -> { exit_code: -768, trap_kind: "DSI", ... } +sandbox.last_trap(target="x5000") +# -> { found: true, trap_kind: "DSI", traptype_hex: "0x300", +# raw_lines: [], ... } +# -- daemon survived the crash; the next sandbox call works +# without intervention. +``` + +### Driver iteration + +``` +fs.upload(target="x5000", + local_path="bin/virtioscsi.device", + remote_path="RAM:virtioscsi.device") +fs.upload(target="x5000", + local_path="bin/virtioscsi-test", + remote_path="RAM:virtioscsi-test") + +sandbox.run_driver(target="x5000", + driver="RAM:virtioscsi.device", + test="RAM:virtioscsi-test") +# Loads the driver via -r mode (CLT_InitFunc + IExec clone), +# then chains the test ELF in the SAME Guest so OpenLibrary +# resolves the freshly-loaded driver via the resident-lib +# registry. Per-iteration cycle ≈ 1 s on real X5000. +``` + +### Batched test bundles + +``` +sandbox.run_batch(target="x5000", guests=[ + {"guest": "RAM:test_1"}, + {"guest": "RAM:test_2"}, + {"guest": "RAM:test_3"}, + # ... up to 16 +]) +# -> { +# aggregate_exit_code: -768, // last non-zero rc seen +# all_clean: false, +# entries: [ +# { name: "batch.0", exit_code: 0, trap_kind: null, ... }, +# { name: "batch.1", exit_code: -768, trap_kind: "DSI", ... }, +# { name: "batch.2", exit_code: 0, trap_kind: null, ... }, +# ], +# } +``` + +Per-guest exit codes are recovered from the kernel debug ring +after the batch finishes (`[sandboxvm] guest_run_elf +returned ` lines emitted via `DebugPrintF`). Per-guest argv +and per-guest deny-lists are deliberately not supported — both +conflict with SandboxVM's actual CLI. Use `sandbox.run_guest` +per-binary when either matters. + +For multi-target fan-out, wrap a `sandbox.run_batch` call in +`fleet.run_on_all` to drive the same bundle across X5000 + A1222 ++ QEMU AmigaOne in parallel; per-target results come back keyed +by target name. + +### Cross-references + +- `sys.debug_ring` is the underlying primitive `sandbox.last_trap` + filters against. Useful well outside sandbox: post-install + forensics, hardware bring-up. +- `tests.run_suite` is the right tool for JSON-config-driven + multi-step test bundles **without** the SandboxVM harness. + ## Validation ```sh From ed0ac43c27abdad7e3588dd686c0b43762d108d5 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 15:37:07 +0100 Subject: [PATCH 09/23] fleet.run_on_all: register sandbox.* + sys.debug_ring The plan claimed multi-target fan-out across `sandbox.*` worked "for free" via fleet.run_on_all, but fleet.run_on_all uses an explicit allowlist (_FANOUT) and the new methods weren't in it -- calls returned "method 'sandbox.probe' cannot be fanned out". Adds: - sandbox.probe / .deploy / .run_guest / .run_driver / .run_batch / .last_trap to the fan-out registry. Mutating methods (deploy, run_*) make sense to fan out: same source + dest path deployed across every target, or same guest run on every target where it's reachable. Per-target probe gates + per-target asyncio locks already isolate failures. - sys.debug_ring -- read-only kernel-ring snapshot is naturally fanout-friendly. Also adds two test classes to lock the contract: - test_fanout_registry_includes_sandbox_methods: pins the registry against silent regressions (the plan's "free" claim was originally wrong because of exactly this). - test_classify_exit_known_traps + kmod fingerprint test: pins the upstream trap-code mapping so a SandboxVM rename surfaces in our test suite rather than at runtime. - test_aos_parent_dir / test_derive_guest_name: covers the edge cases (assign-root parent, no-extension guest names, empty-after-strip default-to-"guest") that aren't otherwise exercised. 256/256 unit tests pass; ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/tools/fleet.py | 12 ++++++ host/tests/unit/test_fanout.py | 16 +++++++ host/tests/unit/test_sandbox.py | 57 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/host/src/amiga_fleet_mcp/tools/fleet.py b/host/src/amiga_fleet_mcp/tools/fleet.py index 3739382..4d233e9 100644 --- a/host/src/amiga_fleet_mcp/tools/fleet.py +++ b/host/src/amiga_fleet_mcp/tools/fleet.py @@ -24,6 +24,7 @@ from ..fleet import Fleet from . import exec as exec_tool from . import fs as fs_tool +from . import sandbox as sandbox_tool from . import sys as sys_tool from . import wb as wb_tool @@ -242,8 +243,19 @@ async def fleet_snapshot( "sys.devices": sys_tool.sys_devices, "sys.ports": sys_tool.sys_ports, "sys.lastalert": sys_tool.sys_lastalert, + "sys.debug_ring": sys_tool.sys_debug_ring, "wb.screens": wb_tool.wb_screens, "wb.windows": wb_tool.wb_windows, + # sandbox.* — read paths fan out per-target naturally; the + # mutating run_* / deploy methods can also fan out when the + # caller wants the same operation on every target (e.g. roll a + # new sandboxvm to all hosts in one call). + "sandbox.probe": sandbox_tool.sandbox_probe, + "sandbox.deploy": sandbox_tool.sandbox_deploy, + "sandbox.run_guest": sandbox_tool.sandbox_run_guest, + "sandbox.run_driver": sandbox_tool.sandbox_run_driver, + "sandbox.run_batch": sandbox_tool.sandbox_run_batch, + "sandbox.last_trap": sandbox_tool.sandbox_last_trap, } diff --git a/host/tests/unit/test_fanout.py b/host/tests/unit/test_fanout.py index c3391c9..e924460 100644 --- a/host/tests/unit/test_fanout.py +++ b/host/tests/unit/test_fanout.py @@ -117,6 +117,22 @@ async def test_list_targets_tag_filter() -> None: assert fleet.list_targets(tags=["qemu", "pegasos2"]) == ["qemu-1"] +def test_fanout_registry_includes_sandbox_methods() -> None: + """The plan claims `fleet.run_on_all` works for `sandbox.*`. Pin + that in a test so the contract can't drift back to "not in + registry" silently.""" + methods = set(fleet_tool.fanout_methods()) + for required in ( + "sandbox.probe", "sandbox.deploy", + "sandbox.run_guest", "sandbox.run_driver", + "sandbox.run_batch", "sandbox.last_trap", + "sys.debug_ring", + ): + assert required in methods, ( + f"{required} missing from fleet.run_on_all registry" + ) + + @pytest.mark.asyncio async def test_run_on_all_with_tag_filter() -> None: cfg = Config(targets={ diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index bc4cf08..25f45a7 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -1066,3 +1066,60 @@ async def test_run_batch_handles_ring_loss_gracefully(): assert res.entries[0].exit_code == 0 assert res.entries[1].exit_code == 0 assert res.entries[2].exit_code == 5 + + +# ---- exit-code classification edge cases --------------------------- + + +def test_classify_exit_known_traps(): + """Pin the upstream trap-code mapping so an upstream rename + (e.g. -0x300 -> something else) is caught by the test suite.""" + assert sb._classify_exit(-0x300, "")[0] == "DSI" + assert sb._classify_exit(-0x400, "")[0] == "ISI" + assert sb._classify_exit(-0x600, "")[0] == "alignment" + assert sb._classify_exit(-0x700, "")[0] == "program" + assert sb._classify_exit(-0x800, "")[0] == "fp_unavailable" + # Clean exit / positive guest rc / unknown negative all + # classify as "no trap". + assert sb._classify_exit(0, "")[0] is None + assert sb._classify_exit(5, "")[0] is None + assert sb._classify_exit(-2, "")[0] is None + + +def test_classify_exit_kmod_libcall_fingerprint_via_stderr(): + """The fingerprint is best-effort string-match against + captured stderr; all three hints must be present together.""" + err_full = ( + "Trap caught\n" + "Traptype=0x300 dar=0x4 dsisr=0x00800000\n" + ) + assert sb._classify_exit(-0x300, err_full) == ( + "DSI", "kmod_libcall_null4", + ) + # Missing one hint -> no fingerprint, just trap_kind. + err_partial = "Traptype=0x300 dar=0x4\n" # no dsisr + assert sb._classify_exit(-0x300, err_partial) == ("DSI", None) + + +# ---- AOS path helper edge cases ----------------------------------- + + +def test_aos_parent_dir_handles_assign_root(): + assert sb._aos_parent_dir("SYS:") is None + assert sb._aos_parent_dir("SYS:foo") is None + assert sb._aos_parent_dir("SYS:Tools/sandboxvm") == "SYS:Tools" + assert sb._aos_parent_dir("Tools:bin/foo") == "Tools:bin" + assert sb._aos_parent_dir("DH0:nested/path/file") == "DH0:nested/path" + # Non-AOS-shaped path (no colon) returns None. + assert sb._aos_parent_dir("/unix/path") is None + + +def test_derive_guest_name_strips_path_and_extension(): + assert sb._derive_guest_name("RAM:hello") == "hello" + assert sb._derive_guest_name("Tools:tests/clib4hello") == "clib4hello" + assert sb._derive_guest_name("RAM:foo.elf") == "foo" + # No extension, no separators. + assert sb._derive_guest_name("bare") == "bare" + # Empty after stripping defaults to "guest" so we never produce + # an empty -n flag. + assert sb._derive_guest_name(":") == "guest" From ae7d7515a5e766366ac8bc29b91e5c3f0af26677 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 15:58:19 +0100 Subject: [PATCH 10/23] sandbox.probe: distinguish unreachable target from missing binary Real-target fan-out smoke surfaced a misleading result: probing an offline a1222 returned SANDBOXVM_MISSING (suggests "deploy the binary"), but the underlying fs.stat actually failed with a transport-level connection error -- the target was simply powered off and no binary deployment was going to fix that. Cause: a bare `except Exception` in the probe candidate-walk treated transport errors and "path not found" identically. Fix: catch only TargetError as "path not present, try next"; let other exceptions propagate as a new probe code SANDBOXVM_TARGET_UNREACHABLE with a hint that points at `fleet.target_status` for triage. The new code raises NotCapable through `_ensure_available`, so downstream tools (run_guest / run_driver / run_batch / deploy / last_trap) all surface "target unreachable" distinctly from "binary missing" -- the two cases have different fixes (power on the target vs run sandbox.deploy). Same fix applied to `_probe_specific_path`, the path-pinned verifier used by `sandbox.deploy`. Two new tests: - probe with a transport-error fake: confirms code + hint shape. - run_guest with the same fake: confirms NotCapable is raised (not InvalidParams) so the caller can branch on the JsonRpcError code. Probe cache deliberately left unchanged: unreachable results are NOT cached (only available=true and INCOMPATIBLE_TARGET cache), so the next probe will re-test once the target comes back. 258/258 unit tests pass; ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/tools/sandbox.py | 46 +++++++++++++++++++++-- host/tests/unit/test_sandbox.py | 43 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index fbb2bba..6d72193 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -121,7 +121,8 @@ class SandboxProbeResult(BaseModel): code: str | None = None """Error code when ``available is False``. One of ``SANDBOXVM_MISSING``, ``SANDBOXVM_BROKEN``, - ``SANDBOXVM_INCOMPATIBLE_TARGET``. ``None`` when available.""" + ``SANDBOXVM_INCOMPATIBLE_TARGET``, ``SANDBOXVM_TARGET_UNREACHABLE``. + ``None`` when available.""" path: str | None = None """Resolved on-target AOS path when available.""" searched: list[str] = Field(default_factory=list) @@ -301,13 +302,38 @@ async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: candidates = _candidate_target_paths(fleet, target) found: str | None = None mcpd = fleet.mcpd(target) + transport_error: Exception | None = None for path in candidates: try: await mcpd.request("fs.stat", {"path": path}) found = path break - except Exception: + except TargetError: + # Path doesn't exist (or isn't stat-able) on the target. + # Try the next candidate. continue + except Exception as e: + # Anything that isn't a TargetError is a transport-level + # failure (target unreachable, connection refused, + # asyncio.TimeoutError, etc.). Stop probing and surface + # it as a distinct error code -- a missing binary needs + # `sandbox.deploy`, an unreachable target needs power / + # network attention first. + transport_error = e + break + + if transport_error is not None: + return SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_TARGET_UNREACHABLE", + searched=candidates, + machine=machine, machine_ok=machine_ok, + hint=(f"target {target!r} did not respond to fs.stat " + f"({type(transport_error).__name__}: " + f"{str(transport_error)[:120]}). Confirm the " + "target is powered on and MCPd is reachable " + "(`fleet.target_status`) before deploying."), + ) if found is None: result = SandboxProbeResult( @@ -375,13 +401,22 @@ async def _probe_specific_path(fleet: Fleet, target: str, mcpd = fleet.mcpd(target) try: await mcpd.request("fs.stat", {"path": path}) - except Exception: + except TargetError: return SandboxProbeResult( target=target, available=False, code="SANDBOXVM_MISSING", searched=[path], machine=machine, machine_ok=machine_ok, ) + except Exception as e: + return SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_TARGET_UNREACHABLE", + searched=[path], + machine=machine, machine_ok=machine_ok, + hint=(f"target {target!r} did not respond to fs.stat " + f"({type(e).__name__}: {str(e)[:120]})."), + ) ran, banner = await _execute_banner_probe(fleet, target, path) if not ran: @@ -419,6 +454,11 @@ async def _ensure_available(fleet: Fleet, target: str) -> SandboxProbeResult: data={"code": res.code, "target": target, "machine": res.machine}, ) + if res.code == "SANDBOXVM_TARGET_UNREACHABLE": + raise NotCapable( + res.hint or "target unreachable", + data={"code": res.code, "target": target}, + ) raise InvalidParams( res.hint or "sandboxvm not available on target", data={"code": res.code, "target": target, diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index 25f45a7..f8b140e 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -229,6 +229,49 @@ async def test_probe_empty_banner_still_available(fleet_with_fake): assert res.version_banner is None +@pytest.mark.asyncio +async def test_probe_unreachable_target_returns_typed_code(fleet_with_fake): + """When fs.stat raises a non-TargetError exception (transport + failure, target offline), the probe should distinguish that + from a genuine "binary not found" miss. The user fixes the two + cases differently — power on the target vs run sandbox.deploy.""" + fleet, fake = fleet_with_fake + + # Patch FakeMcpd.request to raise a non-TargetError on every + # call, simulating an unreachable target. + async def unreachable(method, params=None, timeout_s=30.0): + raise ConnectionRefusedError("simulated offline target") + fake.request = unreachable # type: ignore[method-assign] + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is False + assert res.code == "SANDBOXVM_TARGET_UNREACHABLE" + assert "ConnectionRefusedError" in (res.hint or "") + assert "fleet.target_status" in (res.hint or "") + # Unreachable result is NOT cached (next call should re-probe + # in case the target came back). + assert "tgt" not in sb._PROBE_CACHE + + +@pytest.mark.asyncio +async def test_run_guest_unreachable_raises_not_capable(fleet_with_fake): + """An unreachable target should raise NotCapable so the caller + can distinguish it from a missing-binary case (which raises + InvalidParams).""" + fleet, fake = fleet_with_fake + + async def unreachable(method, params=None, timeout_s=30.0): + raise ConnectionRefusedError("simulated offline target") + fake.request = unreachable # type: ignore[method-assign] + + with pytest.raises(NotCapable) as exc: + await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:hello", + ) + assert "SANDBOXVM_TARGET_UNREACHABLE" in str(exc.value.data) + + @pytest.mark.asyncio async def test_probe_pegasos2_refused_via_machine_field(): fleet = Fleet(_make_config(machine="pegasos2")) From ea472a4b30e831b1106274223257d3a9d2dffd24 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 10 May 2026 16:07:36 +0100 Subject: [PATCH 11/23] sandbox.probe: detect MCPd-transport TargetError as unreachable Real-target follow-up smoke against an actually-off A1222 still returned SANDBOXVM_MISSING after the previous fix. Cause: `transports/mcpd.py:114` wraps connection failures as TargetError (not OSError or another distinct class), so the probe's `except TargetError: continue` swallowed transport errors as "path not found" all over again. The transport tags its connection-failure exceptions with a specific data shape: `data={"endpoint": ..., "error": ...}`. AOS-side path-not-found errors carry a different shape (typically `data={"path": ...}`). Use that as the discriminator via a new `_is_transport_error(exc)` helper -- TargetError with `endpoint` in its data dict, or any non-TargetError, counts as transport- level. Plain TargetError (no data, or no endpoint key) stays as "AOS-side path not found". Same logic in `_probe_specific_path` (the path-pinned verifier used by sandbox.deploy). Three new tests pin the contract: - probe with TargetError(endpoint=...) -> SANDBOXVM_TARGET_UNREACHABLE - probe with non-TargetError exception (belt-and-braces leak path) - test_is_transport_error_disambiguates: the helper itself, against all four cases (transport TargetError / AOS TargetError with path / AOS TargetError with no data / non-TargetError). A proper architectural fix would add a `TargetUnreachable(TargetError)` subclass on the transport side; the data-shape sniff is the minimal pragmatic patch. Other tools (installer.* etc.) have the same blind spot but aren't fixed in this commit -- scope creep. 260/260 unit tests pass; ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- host/src/amiga_fleet_mcp/tools/sandbox.py | 65 +++++++++++++++------ host/tests/unit/test_sandbox.py | 71 ++++++++++++++++++++--- 2 files changed, 109 insertions(+), 27 deletions(-) diff --git a/host/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py index 6d72193..60b93df 100644 --- a/host/src/amiga_fleet_mcp/tools/sandbox.py +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -215,6 +215,29 @@ def _resolve_target_machine(fleet: Fleet, target: str) -> str | None: return None +def _is_transport_error(exc: BaseException) -> bool: + """Disambiguate transport-level failures from AOS-side TargetErrors. + + The MCPd transport (`transports/mcpd.py`) wraps connection failures + as ``TargetError("MCPd not reachable at ", + data={"endpoint": ..., "error": ...})``. AOS-side failures carry a + different data shape (typically ``data={"path": ...}`` from + `fs.stat: Object not found`). We use the ``endpoint`` key as a + discriminator -- it's the load-bearing upstream marker for + transport-level failures. + + Non-TargetError exceptions are always treated as transport-level + (asyncio.TimeoutError, OSError if it leaks past the transport, + etc.). + """ + if not isinstance(exc, TargetError): + return True + data = getattr(exc, "data", None) + if isinstance(data, dict) and "endpoint" in data: + return True + return False + + def _candidate_target_paths(fleet: Fleet, target: str) -> list[str]: """Where to look for sandboxvm on the target.""" out: list[str] = [] @@ -308,17 +331,21 @@ async def sandbox_probe(fleet: Fleet, target: str) -> SandboxProbeResult: await mcpd.request("fs.stat", {"path": path}) found = path break - except TargetError: - # Path doesn't exist (or isn't stat-able) on the target. - # Try the next candidate. + except TargetError as e: + # The MCPd transport raises TargetError BOTH for + # AOS-side failures ("fs.stat: Object not found", + # data={"path": ...}) AND for transport-level failures + # ("MCPd not reachable at ", data={"endpoint": ...}). + # Use the data shape to disambiguate -- the endpoint key + # is the upstream's load-bearing marker. + if _is_transport_error(e): + transport_error = e + break + # Otherwise: AOS-side path-not-found, try next candidate. continue except Exception as e: - # Anything that isn't a TargetError is a transport-level - # failure (target unreachable, connection refused, - # asyncio.TimeoutError, etc.). Stop probing and surface - # it as a distinct error code -- a missing binary needs - # `sandbox.deploy`, an unreachable target needs power / - # network attention first. + # Non-TargetError exception (asyncio.TimeoutError, + # OSError if it ever leaks past the transport, etc.). transport_error = e break @@ -401,21 +428,21 @@ async def _probe_specific_path(fleet: Fleet, target: str, mcpd = fleet.mcpd(target) try: await mcpd.request("fs.stat", {"path": path}) - except TargetError: - return SandboxProbeResult( - target=target, available=False, - code="SANDBOXVM_MISSING", - searched=[path], - machine=machine, machine_ok=machine_ok, - ) except Exception as e: + if _is_transport_error(e): + return SandboxProbeResult( + target=target, available=False, + code="SANDBOXVM_TARGET_UNREACHABLE", + searched=[path], + machine=machine, machine_ok=machine_ok, + hint=(f"target {target!r} did not respond to fs.stat " + f"({type(e).__name__}: {str(e)[:120]})."), + ) return SandboxProbeResult( target=target, available=False, - code="SANDBOXVM_TARGET_UNREACHABLE", + code="SANDBOXVM_MISSING", searched=[path], machine=machine, machine_ok=machine_ok, - hint=(f"target {target!r} did not respond to fs.stat " - f"({type(e).__name__}: {str(e)[:120]})."), ) ran, banner = await _execute_banner_probe(fleet, target, path) diff --git a/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py index f8b140e..edb883a 100644 --- a/host/tests/unit/test_sandbox.py +++ b/host/tests/unit/test_sandbox.py @@ -231,29 +231,52 @@ async def test_probe_empty_banner_still_available(fleet_with_fake): @pytest.mark.asyncio async def test_probe_unreachable_target_returns_typed_code(fleet_with_fake): - """When fs.stat raises a non-TargetError exception (transport - failure, target offline), the probe should distinguish that - from a genuine "binary not found" miss. The user fixes the two + """When the transport raises a TargetError tagged with an + `endpoint` data key (the upstream marker for "MCPd not + reachable"), the probe should distinguish that from a + genuine "binary not found" miss. The user fixes the two cases differently — power on the target vs run sandbox.deploy.""" fleet, fake = fleet_with_fake - # Patch FakeMcpd.request to raise a non-TargetError on every - # call, simulating an unreachable target. + # Mirror exactly what `transports/mcpd.py` raises when its + # asyncio.open_connection fails: TargetError with a data dict + # carrying the endpoint marker. async def unreachable(method, params=None, timeout_s=30.0): - raise ConnectionRefusedError("simulated offline target") + raise TargetError( + "MCPd not reachable at 192.168.0.99:4322", + data={"endpoint": "192.168.0.99:4322", + "error": "[Errno 111] Connection refused"}, + ) fake.request = unreachable # type: ignore[method-assign] res = await sb.sandbox_probe(fleet, "tgt") assert res.available is False assert res.code == "SANDBOXVM_TARGET_UNREACHABLE" - assert "ConnectionRefusedError" in (res.hint or "") + assert "TargetError" in (res.hint or "") assert "fleet.target_status" in (res.hint or "") # Unreachable result is NOT cached (next call should re-probe # in case the target came back). assert "tgt" not in sb._PROBE_CACHE +@pytest.mark.asyncio +async def test_probe_unreachable_via_non_target_error(fleet_with_fake): + """Non-TargetError exceptions also count as transport failures. + Belt-and-braces in case a future transport leaks something past + the TargetError wrap.""" + fleet, fake = fleet_with_fake + + async def unreachable(method, params=None, timeout_s=30.0): + raise ConnectionRefusedError("simulated leak") + fake.request = unreachable # type: ignore[method-assign] + + res = await sb.sandbox_probe(fleet, "tgt") + + assert res.available is False + assert res.code == "SANDBOXVM_TARGET_UNREACHABLE" + + @pytest.mark.asyncio async def test_run_guest_unreachable_raises_not_capable(fleet_with_fake): """An unreachable target should raise NotCapable so the caller @@ -262,7 +285,11 @@ async def test_run_guest_unreachable_raises_not_capable(fleet_with_fake): fleet, fake = fleet_with_fake async def unreachable(method, params=None, timeout_s=30.0): - raise ConnectionRefusedError("simulated offline target") + raise TargetError( + "MCPd not reachable at 192.168.0.99:4322", + data={"endpoint": "192.168.0.99:4322", + "error": "[Errno 111] Connection refused"}, + ) fake.request = unreachable # type: ignore[method-assign] with pytest.raises(NotCapable) as exc: @@ -272,6 +299,34 @@ async def unreachable(method, params=None, timeout_s=30.0): assert "SANDBOXVM_TARGET_UNREACHABLE" in str(exc.value.data) +def test_is_transport_error_disambiguates(): + """Pin the transport-vs-AOS-error discriminator so a future + refactor of the transport's data shape can't silently regress + sandbox.probe back to "missing means unreachable too".""" + # Transport-level failure: TargetError carrying the endpoint + # marker. + assert sb._is_transport_error( + TargetError("MCPd not reachable at host:4322", + data={"endpoint": "host:4322"}), + ) is True + # AOS-level failure: TargetError carrying the path marker. + assert sb._is_transport_error( + TargetError("not found", data={"path": "SYS:foo"}), + ) is False + # AOS-level failure with no data dict at all -- must default + # to "not transport" so we don't false-positive. + assert sb._is_transport_error( + TargetError("not found"), + ) is False + # Non-TargetError -> always transport. + assert sb._is_transport_error( + ConnectionRefusedError("offline"), + ) is True + assert sb._is_transport_error( + TimeoutError("slow"), + ) is True + + @pytest.mark.asyncio async def test_probe_pegasos2_refused_via_machine_field(): fleet = Fleet(_make_config(machine="pegasos2")) From 6d2813592f4e2871971c6013b86be3d959156dfb Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Mon, 8 Jun 2026 14:34:19 +0100 Subject: [PATCH 12/23] cli: add qemu_start/stop/status + qemu_run (headless on-target smoke driver) qemu_run deploys a local exe to a target, launches it detached via AmigaDOS `run`, and (with --wait REGEX) polls the QEMU -serial log until the marker appears, with --start/--stop for a self-contained bounded smoke. This is the script-drivable equivalent of the qemu lifecycle + exec + serial tools, so a headless CI smoke or a `cargo-amiga test` can drive the fleet without an MCP client. Verified against qemu-amigaone with HorizonWM's --smoke build: qemu_run --start --stop --exe horizon --args=--smoke \ --wait "HZ-SMOKE: frames=[0-9]+ ok" -> started -> upload -> run -> marker seen -> stopped (qmp), exit 0. Co-Authored-By: Claude Opus 4.8 --- host/src/amiga_fleet_mcp/cli.py | 162 ++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/host/src/amiga_fleet_mcp/cli.py b/host/src/amiga_fleet_mcp/cli.py index 5434d94..ba02cf1 100644 --- a/host/src/amiga_fleet_mcp/cli.py +++ b/host/src/amiga_fleet_mcp/cli.py @@ -237,6 +237,137 @@ async def cmd_events_soak(args: argparse.Namespace) -> int: return 0 if (fails == 0 and drops == 0) else 1 +# ---------- subcommands: qemu lifecycle + headless run ------------- + + +async def cmd_qemu_start(args: argparse.Namespace) -> int: + fleet, _cfg = _make_fleet(args) + target = _resolve(fleet, args.target) + from .tools import qemu as qtool + + res = await qtool.qemu_start(fleet, target) + print(f"started {target} pid={res.pid}") + if res.serial_log: + print(f"serial_log={res.serial_log}") + await fleet.close_all() + return 0 + + +async def cmd_qemu_stop(args: argparse.Namespace) -> int: + fleet, _cfg = _make_fleet(args) + target = _resolve(fleet, args.target) + from .tools import qemu as qtool + + res = await qtool.qemu_stop(fleet, target) + print(f"stopped {target} method={res.method}") + await fleet.close_all() + return 0 + + +async def cmd_qemu_status(args: argparse.Namespace) -> int: + fleet, _cfg = _make_fleet(args) + target = _resolve(fleet, args.target) + from .tools import qemu as qtool + + st = await qtool.qemu_status(fleet, target) + print(f"{target} running={st.running} pid={st.pid} " + f"mcpd_reachable={st.mcpd_reachable}") + await fleet.close_all() + return 0 if st.running else 1 + + +def _printable(b: bytes) -> str: + """Filter a binary -serial log to printable text (NUL etc. -> newline).""" + return "".join( + chr(c) if (32 <= c < 127 or c in (9, 10, 13)) else "\n" for c in b + ) + + +async def _wait_serial(log_path: str, pattern: str, timeout_s: float) -> str | None: + """Poll a -serial log file until `pattern` (regex) appears, or timeout.""" + import re + + rx = re.compile(pattern) + p = Path(log_path) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if p.is_file(): + text = _printable(p.read_bytes()) + if rx.search(text): + return text + await asyncio.sleep(3) + return None + + +async def cmd_qemu_run(args: argparse.Namespace) -> int: + """Headless deploy + run + (optional) wait-for-serial-marker, for a bounded + on-target smoke. With --start/--stop it is a self-contained pass/fail: start + QEMU -> upload the exe -> `run ` (detached) -> wait for the + serial marker -> stop. Exit 0 = marker seen (or no --wait), 1 = timeout/fail. + """ + fleet, _cfg = _make_fleet(args) + target = _resolve(fleet, args.target) + from .tools import exec as etool + from .tools import qemu as qtool + from .upload import chunked_upload + + serial_log = args.serial_log + if args.start: + res = await qtool.qemu_start(fleet, target) + serial_log = res.serial_log + print(f"[qemu-run] started pid={res.pid} serial_log={serial_log}") + # Wait for MCPd to come up before deploying. + up = False + for _ in range(80): + st = await qtool.qemu_status(fleet, target) + if st.mcpd_reachable: + up = True + break + await asyncio.sleep(3) + if not up: + print("[qemu-run] MCPd never came up", file=sys.stderr) + if args.stop: + await qtool.qemu_stop(fleet, target) + await fleet.close_all() + return 1 + + remote = args.remote or ("SYS:Test/" + Path(args.exe).name) + print(f"[qemu-run] upload {args.exe} -> {remote}") + await chunked_upload(fleet, target, args.exe, remote, + chunk_size=10 * 1024 * 1024, compression="auto") + await etool.exec_cmd(fleet, target, "Protect", args=[remote, "+e"]) + + run_args = [remote] + (args.args.split() if args.args else []) + print(f"[qemu-run] run {' '.join(run_args)}") + try: + await etool.exec_cmd(fleet, target, "run", args=run_args, timeout_s=15) + except Exception as e: + # A detached `run` can surface a benign foreground-I/O quirk; tolerate it. + print(f"[qemu-run] launch returned: {e}") + + rc = 0 + if args.wait: + if not serial_log: + print("[qemu-run] --wait needs a serial log (use --start or " + "--serial-log)", file=sys.stderr) + rc = 1 + else: + print(f"[qemu-run] waiting for /{args.wait}/ in serial " + f"(timeout {args.timeout}s)") + text = await _wait_serial(serial_log, args.wait, args.timeout) + if text is None: + print("[qemu-run] TIMEOUT: marker not seen", file=sys.stderr) + rc = 1 + else: + print("[qemu-run] marker seen") + + if args.stop: + r = await qtool.qemu_stop(fleet, target) + print(f"[qemu-run] stopped method={r.method}") + await fleet.close_all() + return rc + + # ---------- argparse glue ------------------------------------------ @@ -285,6 +416,37 @@ def _build_parser() -> argparse.ArgumentParser: dest="timeout_ms") e.set_defaults(func=cmd_events_soak) + qs = sub.add_parser("qemu_start", help="start QEMU for a target") + qs.add_argument("--target", help="target name") + qs.set_defaults(func=cmd_qemu_start) + + qt = sub.add_parser("qemu_stop", help="stop QEMU for a target") + qt.add_argument("--target", help="target name") + qt.set_defaults(func=cmd_qemu_stop) + + qst = sub.add_parser("qemu_status", help="QEMU/MCPd reachability for a target") + qst.add_argument("--target", help="target name") + qst.set_defaults(func=cmd_qemu_status) + + qr = sub.add_parser( + "qemu_run", + help="headless deploy + run an exe, optionally wait for a serial marker " + "(a bounded on-target smoke)") + qr.add_argument("--target", help="target name") + qr.add_argument("--exe", required=True, help="local executable to deploy") + qr.add_argument("--remote", help="guest path (default SYS:Test/)") + qr.add_argument("--args", help="arguments passed to the exe (one string)") + qr.add_argument("--wait", help="regex to wait for in the -serial log") + qr.add_argument("--timeout", type=float, default=240.0, + help="seconds to wait for --wait (default 240)") + qr.add_argument("--serial-log", dest="serial_log", + help="serial log path (when not using --start)") + qr.add_argument("--start", action="store_true", + help="start QEMU first (captures the serial log)") + qr.add_argument("--stop", action="store_true", + help="stop QEMU when done") + qr.set_defaults(func=cmd_qemu_run) + return p From f0c453a71f30f319ddf1d45e95f0926bc4d1cafa Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Tue, 16 Jun 2026 15:11:19 +0100 Subject: [PATCH 13/23] wb.screenshot: on-Amiga screen capture to PNG New daemon method (mcpd/src/methods/screen.c) captures a screen (frontmost or by screen_index) via graphics.library ReadPixelArray into a 24-bit buffer and PNG-encodes it with the already-linked z.library (deflate + CRC32). AOS4.1 ships no PNG *writer* datatype (picture.datatype DTM_WRITE = ILBM only; warppngdt is decode-only), so the encoding is hand-rolled rather than going through datatypes. Host tool wb.screenshot captures to a temp path on the Amiga, then fs.download brings it to the host (mirroring qemu.screenshot), with optional inline base64 and remote-temp cleanup; wired into the wb.* dispatcher and fleet fan-out. Unlike qemu.screenshot (QMP screendump, QEMU-only) this works on real X5000 / A1222 hardware too. Validated end-to-end on QEMU pegasos2 (sm501 RTG): captured the 1280x800 Workbench screen to a structurally-valid PNG that renders correctly. Fixed a filter-byte bug found in testing: AVT_Clear is AVT_ClearWithValue (tag data is the fill byte, not a bool), so AVT_Clear,TRUE filled with 0x01 and corrupted every scanline filter byte; now zeroed explicitly post-capture. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 + COMMANDS.md | 1 + README.md | 2 +- host/src/amiga_fleet_mcp/server.py | 29 ++- host/src/amiga_fleet_mcp/tools/fleet.py | 4 + host/src/amiga_fleet_mcp/tools/wb.py | 86 +++++++ host/tests/unit/test_wb_screenshot.py | 146 +++++++++++ mcpd/Makefile | 1 + mcpd/src/methods/methods.h | 1 + mcpd/src/methods/screen.c | 307 ++++++++++++++++++++++++ mcpd/src/rpc.c | 1 + 11 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 host/tests/unit/test_wb_screenshot.py create mode 100644 mcpd/src/methods/screen.c diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1fb98..0d21cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ### Added +- **`wb.screenshot`** — capture an AmigaOS screen to a PNG on the + target and bring it back to the host. The daemon grabs the chosen + screen (frontmost, or by `screen_index`) with `graphics.library` + ReadPixelArray and PNG-encodes it using the already-linked + `z.library` (deflate + CRC32); AmigaOS 4.1 ships no PNG *writer* + datatype, so the encoding is done directly rather than via + `datatypes.library`. The host tool downloads the file (`fs.download`) + and optionally inlines it as base64. Unlike `qemu.screenshot` (QMP + `screendump`, QEMU-only), this works on real X5000 / A1222 hardware + too, and fans out via `fleet.run_on_all`. + - **`sandbox.*` namespace** — driver- and program-iteration loop on top of [SandboxVM](https://github.com/derfsss/SandboxVM), an AOS4 in-process sandbox host that survives guest crashes (DSI / ISI / diff --git a/COMMANDS.md b/COMMANDS.md index 6f75c28..581f591 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -124,6 +124,7 @@ auto-base64, auto-zlib, optional resume + SHA-256 verify. | `wb.windows` | List windows across all screens. | | `wb.publicscreens` | Public-screens registry via `LockPubScreenList`. | | `wb.frontmost` | Frontmost screen, active screen, and active window. | +| `wb.screenshot` | Capture a screen (frontmost or by `screen_index`) to a PNG on the target via `graphics.library` ReadPixelArray + `z.library` encode; the host tool downloads it. Works on real hardware **and** QEMU (unlike `qemu.screenshot`, which is QMP-only). | ### `debug.*` diff --git a/README.md b/README.md index 2540901..f8b41d5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ can: resident-init mode and chain a test program against them, all in a single tool call. -128 typed MCP tools, 8 live resources, validated end-to-end on QEMU +129 typed MCP tools, 8 live resources, validated end-to-end on QEMU pegasos2 and real AmigaOne X5000 hardware. If that sounds useful, the rest of this README and the docs below diff --git a/host/src/amiga_fleet_mcp/server.py b/host/src/amiga_fleet_mcp/server.py index 963f941..8a0e938 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -767,6 +767,32 @@ async def wb_frontmost(*, target: str | None = None) -> wb_tool.FrontmostResult: """Frontmost screen + active screen + active window.""" return await wb_tool.wb_frontmost(fleet, fleet.resolve_target(target)) + @mcp.tool(name="wb_screenshot", title="wb.screenshot") + @archived("wb.screenshot", archive) + async def wb_screenshot( + *, + target: str | None = None, + screen_index: int = 0, + save_path: str | None = None, + keep_remote: bool = False, + inline: bool = True, + ) -> wb_tool.ScreenshotResult: + """Capture a screen to a PNG on the Amiga and download it. + + Works on real X5000 / A1222 hardware as well as QEMU (unlike + qemu.screenshot, which is QMP-only). `screen_index` 0 = frontmost. + `save_path` keeps the PNG at a host path; otherwise a tempfile is + used. `inline=False` omits the base64 PNG from the result. + """ + return await wb_tool.wb_screenshot( + fleet, + fleet.resolve_target(target), + screen_index=screen_index, + save_path=save_path, + keep_remote=keep_remote, + inline=inline, + ) + # ---------- phase 5c (partial): GDB-stub debug -------------------- @mcp.tool(name="debug_read_registers", title="debug.read_registers") @@ -1433,13 +1459,14 @@ async def power_ns( async def wb_ns(*, method: str, params: dict[str, Any] | None = None) -> Any: """wb.* (Workbench) dispatcher. `method` is one of: - screens, windows, publicscreens, frontmost + screens, windows, publicscreens, frontmost, screenshot """ return await _ns({ "screens": wb_tool.wb_screens, "windows": wb_tool.wb_windows, "publicscreens": wb_tool.wb_publicscreens, "frontmost": wb_tool.wb_frontmost, + "screenshot": wb_tool.wb_screenshot, }, method, params or {}) @mcp.tool(name="debug", title="debug.dispatch") diff --git a/host/src/amiga_fleet_mcp/tools/fleet.py b/host/src/amiga_fleet_mcp/tools/fleet.py index 4d233e9..21bd680 100644 --- a/host/src/amiga_fleet_mcp/tools/fleet.py +++ b/host/src/amiga_fleet_mcp/tools/fleet.py @@ -246,6 +246,10 @@ async def fleet_snapshot( "sys.debug_ring": sys_tool.sys_debug_ring, "wb.screens": wb_tool.wb_screens, "wb.windows": wb_tool.wb_windows, + # wb.screenshot fans out per-target; with the default save_path + # each target downloads to its own host tempfile, so concurrent + # captures don't collide. + "wb.screenshot": wb_tool.wb_screenshot, # sandbox.* — read paths fan out per-target naturally; the # mutating run_* / deploy methods can also fan out when the # caller wants the same operation on every target (e.g. roll a diff --git a/host/src/amiga_fleet_mcp/tools/wb.py b/host/src/amiga_fleet_mcp/tools/wb.py index 5e2c4f0..716ce2c 100644 --- a/host/src/amiga_fleet_mcp/tools/wb.py +++ b/host/src/amiga_fleet_mcp/tools/wb.py @@ -2,6 +2,10 @@ from __future__ import annotations +import base64 +import os +import tempfile +from pathlib import Path from typing import Any from pydantic import BaseModel @@ -96,3 +100,85 @@ async def wb_publicscreens( async def wb_frontmost(fleet: Fleet, target: str) -> FrontmostResult: raw = await fleet.mcpd(target).request("wb.frontmost") return FrontmostResult.model_validate(raw) + + +class ScreenshotResult(BaseModel): + target: str + screen_index: int + width: int + height: int + depth: int + size: int # bytes of the PNG + format: str = "png" + remote_path: str # where the daemon wrote it on the Amiga + saved_to: str | None = None # host path it was downloaded to + image_b64: str | None = None # inline PNG (omitted when inline=False) + + +async def wb_screenshot( + fleet: Fleet, + target: str, + *, + screen_index: int = 0, + save_path: str | None = None, + remote_path: str = "T:mcpd-shot.png", + keep_remote: bool = False, + inline: bool = True, +) -> ScreenshotResult: + """Capture a target screen to a PNG and bring it back to the host. + + Unlike ``qemu.screenshot`` (QMP ``screendump``, QEMU-only), this + drives MCPd's on-Amiga ``wb.screenshot`` so it also works on real + X5000 / A1222 hardware. The daemon grabs the screen via + ``graphics.library`` ReadPixelArray and PNG-encodes it with + ``z.library``; this wrapper then ``fs.download``s the file. + + `screen_index`: 0 = frontmost; higher = further back in the screen + list. `save_path`: host path to keep the PNG (a tempfile is used + otherwise). `keep_remote`: leave the file on the Amiga (default + deletes the daemon-written temp). `inline`: embed the PNG bytes as + base64 in the result (default True, mirroring qemu.screenshot). + """ + from .fs import fs_download + + mcpd = fleet.mcpd(target) + raw = await mcpd.request( + "wb.screenshot", + {"screen_index": screen_index, "path": remote_path}, + ) + + if save_path: + out = Path(save_path) + out.parent.mkdir(parents=True, exist_ok=True) + else: + fd, tmp_path = tempfile.mkstemp( + prefix="amiga_fleet_wb_", suffix=".png", + ) + os.close(fd) + out = Path(tmp_path) + + await fs_download( + fleet, target, remote_path=raw["path"], local_path=str(out), + ) + data = out.read_bytes() + + if not keep_remote: + # Best-effort cleanup of the daemon-written temp; a failure + # here must not fail the screenshot itself. + try: + await mcpd.request("fs.delete", {"path": raw["path"]}) + except Exception: + pass + + return ScreenshotResult( + target=target, + screen_index=int(raw["screen_index"]), + width=int(raw["width"]), + height=int(raw["height"]), + depth=int(raw["depth"]), + size=len(data), + format=str(raw.get("format", "png")), + remote_path=str(raw["path"]), + saved_to=str(out), + image_b64=base64.b64encode(data).decode("ascii") if inline else None, + ) diff --git a/host/tests/unit/test_wb_screenshot.py b/host/tests/unit/test_wb_screenshot.py new file mode 100644 index 0000000..c1f2995 --- /dev/null +++ b/host/tests/unit/test_wb_screenshot.py @@ -0,0 +1,146 @@ +"""wb.screenshot host wrapper against a fake MCPd transport. + +The daemon-side capture/encode is exercised on hardware; here we only +verify the host orchestration: it asks MCPd to capture to a remote +path, downloads that file, optionally inlines it as base64, and cleans +up the remote temp unless asked to keep it. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Any + +import pytest + +from amiga_fleet_mcp.config import ( + Config, + McpdChannel, + TargetChannels, + TargetConfig, +) +from amiga_fleet_mcp.fleet import Fleet +from amiga_fleet_mcp.tools import fs as fs_tool +from amiga_fleet_mcp.tools import wb as wb_tool + +# A tiny stand-in PNG (signature + filler). Content is opaque to the +# host wrapper, which just moves the bytes around. +FAKE_PNG = bytes([137, 80, 78, 71, 13, 10, 26, 10]) + b"\x00fake-idat\xff" + + +class FakeMcpd: + """Stub MCPd transport: programmable per-method responses.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any] | None]] = [] + self.responses: dict[str, Any] = {} + + async def request(self, method: str, params: dict | None = None, + timeout_s: float = 30.0) -> Any: + self.calls.append((method, params)) + return self.responses.get(method) + + +@pytest.fixture +def fleet_with_fake() -> tuple[Fleet, FakeMcpd]: + cfg = Config( + targets={ + "qemu-pegasos2": TargetConfig( + type="qemu", + channels=TargetChannels( + mcpd=McpdChannel(endpoint="127.0.0.1:4322"), + ), + ) + } + ) + fleet = Fleet(cfg) + fake = FakeMcpd() + fleet._mcpd["qemu-pegasos2"] = fake # type: ignore[assignment] + return fleet, fake + + +@pytest.fixture +def stub_download(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace fs_download with a local writer so the test never hits a + real transport. wb.screenshot imports fs_download at call time, so + patching the module attribute is enough.""" + + async def _dl(fleet: Any, target: str, *, remote_path: str, + local_path: str, **kw: Any) -> None: + Path(local_path).write_bytes(FAKE_PNG) + + monkeypatch.setattr(fs_tool, "fs_download", _dl) + + +def _shot_response(**over: Any) -> dict[str, Any]: + base = { + "path": "T:mcpd-shot.png", + "format": "png", + "width": 640, + "height": 480, + "depth": 24, + "screen_index": 0, + "bytes": len(FAKE_PNG), + } + base.update(over) + return base + + +@pytest.mark.asyncio +async def test_screenshot_default_inlines_and_cleans_up( + fleet_with_fake: Any, stub_download: None +) -> None: + fleet, fake = fleet_with_fake + fake.responses["wb.screenshot"] = _shot_response() + + res = await wb_tool.wb_screenshot(fleet, "qemu-pegasos2") + + # capture request shape + assert fake.calls[0] == ( + "wb.screenshot", {"screen_index": 0, "path": "T:mcpd-shot.png"} + ) + # result reflects the daemon metadata + downloaded bytes + assert (res.width, res.height, res.depth) == (640, 480, 24) + assert res.size == len(FAKE_PNG) + assert res.image_b64 == base64.b64encode(FAKE_PNG).decode("ascii") + assert res.remote_path == "T:mcpd-shot.png" + assert res.saved_to is not None and Path(res.saved_to).exists() + # remote temp is cleaned up by default + assert ("fs.delete", {"path": "T:mcpd-shot.png"}) in fake.calls + + +@pytest.mark.asyncio +async def test_screenshot_save_path_keep_remote_no_inline( + fleet_with_fake: Any, stub_download: None, tmp_path: Path +) -> None: + fleet, fake = fleet_with_fake + fake.responses["wb.screenshot"] = _shot_response(screen_index=2) + out = tmp_path / "nested" / "shot.png" + + res = await wb_tool.wb_screenshot( + fleet, + "qemu-pegasos2", + screen_index=2, + save_path=str(out), + keep_remote=True, + inline=False, + ) + + # screen_index threaded into the capture call + assert fake.calls[0][1]["screen_index"] == 2 + # saved to the requested host path (parent dirs created) + assert out.read_bytes() == FAKE_PNG + assert res.saved_to == str(out) + assert res.screen_index == 2 + # inline disabled -> no base64 payload + assert res.image_b64 is None + # keep_remote -> no fs.delete issued + assert all(c[0] != "fs.delete" for c in fake.calls) + + +@pytest.mark.asyncio +async def test_screenshot_in_fanout_allowlist() -> None: + from amiga_fleet_mcp.tools.fleet import fanout_methods + + assert "wb.screenshot" in fanout_methods() diff --git a/mcpd/Makefile b/mcpd/Makefile index 46692ac..43d14c5 100644 --- a/mcpd/Makefile +++ b/mcpd/Makefile @@ -49,6 +49,7 @@ SRCS = \ $(METHODS_DIR)/exec.c \ $(METHODS_DIR)/sys.c \ $(METHODS_DIR)/wb.c \ + $(METHODS_DIR)/screen.c \ $(METHODS_DIR)/debug.c \ $(METHODS_DIR)/events.c \ $(METHODS_DIR)/hwres.c \ diff --git a/mcpd/src/methods/methods.h b/mcpd/src/methods/methods.h index 8ab7d48..f7ac3f5 100644 --- a/mcpd/src/methods/methods.h +++ b/mcpd/src/methods/methods.h @@ -79,6 +79,7 @@ MCPD_HANDLER(wb_screens); MCPD_HANDLER(wb_windows); MCPD_HANDLER(wb_publicscreens); MCPD_HANDLER(wb_frontmost); +MCPD_HANDLER(wb_screenshot); MCPD_HANDLER(debug_task_snapshot); MCPD_HANDLER(debug_symbol); diff --git a/mcpd/src/methods/screen.c b/mcpd/src/methods/screen.c new file mode 100644 index 0000000..3a7e72b --- /dev/null +++ b/mcpd/src/methods/screen.c @@ -0,0 +1,307 @@ +/* wb.screenshot - capture a screen to a PNG file on the target. + * + * Why this exists: qemu.screenshot (host-side, QMP screendump) only + * works for QEMU guests. This method runs inside MCPd on the Amiga + * itself, so it captures the framebuffer of a REAL X5000 / A1222 as + * well as a QEMU guest. + * + * Pipeline: + * 1. Find the target Screen (frontmost, or by index) under + * LockIBase, read its geometry. + * 2. IGraphics->ReadPixelArray() the RastPort into a chunky + * 24-bit RGB buffer (PIXF_R8G8B8). graphics.library converts + * from whatever the screen depth / RTG format happens to be. + * 3. PNG-encode that buffer ourselves: filtered scanlines deflated + * via z.library (the same shared zlib MCPd already opens for + * fs.c), framed into IHDR / IDAT / IEND chunks with CRC32. + * + * AmigaOS 4.1 ships no PNG *writer* datatype (picture.datatype's + * DTM_WRITE only emits ILBM; warppng.datatype is decode-only), so we + * encode the PNG directly rather than going through datatypes.library. + * + * IGraphics is auto-opened by -lauto. intuition.library is opened + * per-call (same idiom as wb.c). z.library (IZ) is opened once in + * main.c and shared via extern. + */ + +#include "../rpc.h" +#include "methods.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Shared zlib interface, opened in main.c. NULL if z.library v53+ + * was unavailable at startup; PNG encoding then returns NOTCAPABLE. */ +extern struct ZIFace *IZ; + +/* Safety ceiling on the raw (filtered) image buffer. 64 MiB covers a + * 4096x4096 truecolor screen; anything larger is refused rather than + * triggering an arbitrary multi-MiB allocation spike. */ +#define SHOT_RAW_MAX (64u * 1024u * 1024u) + +#define SHOT_DEFAULT_PATH "T:mcpd-shot.png" + + +static void be32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)(v); +} + + +/* Write one PNG chunk: length(BE) + type + data + CRC(BE). The CRC + * covers type+data (not the length), per the PNG spec. z.library's + * CRC32 is the same CRC-32 PNG uses (inversion handled internally). + * Returns 0 on success, -1 on a short write. */ +static int png_chunk(BPTR fh, const char *type, + const uint8_t *data, uint32_t len) { + uint8_t hdr[4]; + be32(hdr, len); + if (IDOS->Write(fh, hdr, 4) != 4) return -1; + if (IDOS->Write(fh, (APTR)type, 4) != 4) return -1; + if (len && IDOS->Write(fh, (APTR)data, (int32)len) != (int32)len) return -1; + + uint32_t crc = IZ->CRC32(0, NULL, 0); + crc = IZ->CRC32(crc, (const uint8 *)type, 4); + if (len) crc = IZ->CRC32(crc, data, len); + uint8_t crcbe[4]; + be32(crcbe, crc); + if (IDOS->Write(fh, crcbe, 4) != 4) return -1; + return 0; +} + + +/* ---- wb.screenshot ----------------------------------------------- */ + +int wb_screenshot(cJSON *params, cJSON **out_result, cJSON **out_err) { + if (!IZ) { + *out_err = rpc_make_error(MCPD_ERR_NOTCAPABLE, + "z.library v53+ required for PNG encoding", NULL); + return 0; + } + + int screen_index = (int)p_int(params, "screen_index", 0); + if (screen_index < 0) screen_index = 0; + + const char *path = SHOT_DEFAULT_PATH; + if (cJSON_IsObject(params)) { + cJSON *pj = cJSON_GetObjectItemCaseSensitive(params, "path"); + if (cJSON_IsString(pj) && pj->valuestring && pj->valuestring[0]) + path = pj->valuestring; + } + + /* Open intuition for the screen-list walk. */ + struct Library *ibase = IExec->OpenLibrary("intuition.library", 50); + if (!ibase) { + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "OpenLibrary intuition.library v50 failed", NULL); + return 0; + } + struct IntuitionIFace *ii = (struct IntuitionIFace *) + IExec->GetInterface(ibase, "main", 1, NULL); + if (!ii) { + IExec->CloseLibrary(ibase); + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "GetInterface(intuition main) failed", NULL); + return 0; + } + + /* Phase 1: locate the screen and read its geometry under the lock, + * but do NOT allocate or capture while holding LockIBase. */ + struct IntuitionBase *ib = (struct IntuitionBase *)ibase; + struct Screen *target = NULL; + int width = 0, height = 0, depth = 0; + + ULONG lock = ii->LockIBase(0); + int idx = 0; + for (struct Screen *s = ib->FirstScreen; s != NULL; s = s->NextScreen) { + if (idx == screen_index) { + target = s; + width = (int)s->Width; + height = (int)s->Height; + depth = (int)IGraphics->GetBitMapAttr(s->RastPort.BitMap, BMA_DEPTH); + break; + } + idx++; + } + ii->UnlockIBase(lock); + + if (!target || width <= 0 || height <= 0) { + IExec->DropInterface((struct Interface *)ii); + IExec->CloseLibrary(ibase); + cJSON *data = cJSON_CreateObject(); + if (data) cJSON_AddNumberToObject(data, "screen_index", + (double)screen_index); + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "no such screen", data); + return 0; + } + + /* PNG truecolor (color type 2): one filter byte then RGB triplets + * per row. Lay the rows out so ReadPixelArray writes pixels right + * after each row's filter byte; the filter bytes are set to 0 + * (None) explicitly below. */ + uint32_t rowstride = 1u + (uint32_t)width * 3u; + uint64_t rawlen64 = (uint64_t)rowstride * (uint64_t)height; + if (rawlen64 > SHOT_RAW_MAX) { + IExec->DropInterface((struct Interface *)ii); + IExec->CloseLibrary(ibase); + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "screen too large to capture", NULL); + return 0; + } + uint32_t rawlen = (uint32_t)rawlen64; + + /* No AVT_Clear here: AVT_Clear is AVT_ClearWithValue (the tag data + * is the FILL BYTE, not a boolean), so AVT_Clear,TRUE would fill + * with 0x01 and corrupt the PNG filter bytes. ReadPixelArray fills + * every pixel; the filter bytes are zeroed explicitly post-capture. */ + uint8_t *raw = (uint8_t *)IExec->AllocVecTags((uint32)rawlen, + AVT_Type, MEMF_SHARED, TAG_END); + if (!raw) { + IExec->DropInterface((struct Interface *)ii); + IExec->CloseLibrary(ibase); + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "out of memory for capture buffer", NULL); + return 0; + } + + /* Phase 2: re-lock, confirm the screen is still present at the same + * pointer + geometry (it could have closed between phases), then + * capture under the lock so the bitmap can't be freed mid-read. */ + int captured = 0; + lock = ii->LockIBase(0); + idx = 0; + for (struct Screen *s = ib->FirstScreen; s != NULL; s = s->NextScreen) { + if (idx == screen_index) { + if (s == target && (int)s->Width == width && + (int)s->Height == height) { + IGraphics->ReadPixelArray(&s->RastPort, 0, 0, + raw + 1, 0, 0, rowstride, PIXF_R8G8B8, + (uint32)width, (uint32)height); + captured = 1; + } + break; + } + idx++; + } + ii->UnlockIBase(lock); + + IExec->DropInterface((struct Interface *)ii); + IExec->CloseLibrary(ibase); + + if (!captured) { + IExec->FreeVec(raw); + *out_err = rpc_make_error(MCPD_ERR_BUSY, + "screen changed during capture; retry", NULL); + return 0; + } + + /* Set every scanline's filter byte to 0 (None). ReadPixelArray + * only wrote the pixel bytes (raw+1 onward at rowstride pitch), + * leaving the leading byte of each row uninitialised. */ + for (uint32_t r = 0; r < (uint32_t)height; r++) + raw[(size_t)r * rowstride] = 0; + + /* Phase 3a: deflate the filtered scanlines into a zlib stream + * (exactly what a PNG IDAT chunk carries). */ + z_stream zs; + memset(&zs, 0, sizeof(zs)); + if (IZ->DeflateInit(&zs, 6) != Z_OK) { + IExec->FreeVec(raw); + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "DeflateInit failed", NULL); + return 0; + } + uint32_t bound = IZ->DeflateBound(&zs, rawlen); + uint8_t *comp = (uint8_t *)IExec->AllocVecTags((uint32)bound, + AVT_Type, MEMF_SHARED, TAG_END); + if (!comp) { + IZ->DeflateEnd(&zs); + IExec->FreeVec(raw); + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "out of memory for compressed buffer", NULL); + return 0; + } + zs.next_in = raw; + zs.avail_in = rawlen; + zs.next_out = comp; + zs.avail_out = bound; + int rc = IZ->Deflate(&zs, Z_FINISH); + uint32_t complen = (uint32_t)zs.total_out; + IZ->DeflateEnd(&zs); + IExec->FreeVec(raw); + + if (rc != Z_STREAM_END) { + IExec->FreeVec(comp); + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "zlib Deflate failed", NULL); + return 0; + } + + /* Phase 3b: write the PNG file. */ + BPTR fh = IDOS->Open(path, MODE_NEWFILE); + if (!fh) { + IExec->FreeVec(comp); + *out_err = target_error("cannot open output path for writing", path); + return 0; + } + + static const uint8_t sig[8] = + { 137, 80, 78, 71, 13, 10, 26, 10 }; + uint8_t ihdr[13]; + be32(ihdr + 0, (uint32_t)width); + be32(ihdr + 4, (uint32_t)height); + ihdr[8] = 8; /* bit depth */ + ihdr[9] = 2; /* color type: truecolor RGB */ + ihdr[10] = 0; /* compression: deflate */ + ihdr[11] = 0; /* filter method: adaptive (we use filter 0/None) */ + ihdr[12] = 0; /* interlace: none */ + + int werr = 0; + if (IDOS->Write(fh, (APTR)sig, 8) != 8) werr = 1; + if (!werr && png_chunk(fh, "IHDR", ihdr, sizeof(ihdr)) != 0) werr = 1; + if (!werr && png_chunk(fh, "IDAT", comp, complen) != 0) werr = 1; + if (!werr && png_chunk(fh, "IEND", NULL, 0) != 0) werr = 1; + + IDOS->Close(fh); + IExec->FreeVec(comp); + + if (werr) { + IDOS->Delete(path); + *out_err = target_error("write error while saving PNG", path); + return 0; + } + + /* Total file size: 8-byte signature + each chunk's 12 bytes of + * framing (length+type+CRC) plus its payload. */ + uint64_t bytes = 8 + + (12 + 13) + + (12 + (uint64_t)complen) + + (12 + 0); + + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "path", path); + cJSON_AddStringToObject(r, "format", "png"); + cJSON_AddNumberToObject(r, "width", (double)width); + cJSON_AddNumberToObject(r, "height", (double)height); + cJSON_AddNumberToObject(r, "depth", (double)depth); + cJSON_AddNumberToObject(r, "screen_index", (double)screen_index); + cJSON_AddNumberToObject(r, "bytes", (double)bytes); + *out_result = r; + return 0; +} diff --git a/mcpd/src/rpc.c b/mcpd/src/rpc.c index 99d9dab..7fb6606 100644 --- a/mcpd/src/rpc.c +++ b/mcpd/src/rpc.c @@ -154,6 +154,7 @@ const method_entry mcpd_methods[] = { { "wb.windows", wb_windows, "List windows across all screens" }, { "wb.publicscreens", wb_publicscreens, "Public screens registry (LockPubScreenList)" }, { "wb.frontmost", wb_frontmost, "Frontmost screen + active screen + active window" }, + { "wb.screenshot", wb_screenshot, "Capture a screen (frontmost or by screen_index) to a PNG file on the target. params: screen_index (default 0), path (default T:mcpd-shot.png). Captures via graphics.library ReadPixelArray + encodes PNG via z.library; works on real hardware AND QEMU (unlike qemu.screenshot which is QMP-only). Returns path/format/width/height/depth/bytes." }, { "debug.task_snapshot", debug_task_snapshot, "Snapshot a named task: registers + backtrace via IDebug->ReadTaskContext" }, { "debug.symbol", debug_symbol, From 45411638702e53f4f28e9cc0640e72b3f0f445bc Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Sun, 2 Aug 2026 19:30:49 +0100 Subject: [PATCH 14/23] mcpd: listener priority 1 + debug-ring readiness beacon, bump to 1.3 The listener never set its own priority, so it inherited 0 from the launching Shell (Run >NIL: Printf banner to stdout, which the auto-start path sends to NIL:, so a boot-time start was not observable anywhere. It now emits via IExec->DebugPrintF: [MCPd] ready name=MCPd version=1.3 build_date=... build_time=... port=... [MCPd] startup_failed version=... reason=bsdsocket|listen [port=N] [MCPd] shutdown version=... The ready line is emitted after bind+listen succeed, so its presence means the port is accepting rather than merely that the binary loaded. startup_failed explains a daemon that never came up, and shutdown marks a clean exit so a reader can tell a normal stop from a crash. The "[MCPd] " prefix and key=value shape are a parsed interface. Build time is a new MCPD_TIME macro so two same-day builds are distinguishable. It is deliberately kept out of the $VER cookie, which AmigaDOS Version parses as (DD.MM.YYYY); verified that Version MCPd FULL still returns "MCPd 1.3 (02/08/2026)". Time is surfaced in the beacon, --version, proto.version.build_time and proto.capabilities.build.time instead. Version bumped to 1.3 across main.c, rpc.h and the Makefile. The Makefile knob had drifted to 1.1 while the shipped 1.2 binary reported 1.2; it only feeds the dist tarball name, so nothing broke. Validated on QEMU AmigaOne across two cold boots: beacon present in both the serial log and sys.debug_ring, Status FULL reports priority 1 for the listener with exec.cmd subprocesses still at -1, and the shutdown line is emitted on a clean Break. Caveat documented in mcpd/README.md: sys.debug_ring reaches DumpDebugBuffer through MCPd, so it cannot detect a daemon that failed to start. Reading the beacon in that case needs a serial capture, which does not depend on the daemon. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 55 +++++++++++++++++++++++++++++++++++ README.md | 7 ++++- mcpd/Makefile | 10 ++++++- mcpd/README.md | 63 ++++++++++++++++++++++++++++++++++++++++ mcpd/src/main.c | 53 +++++++++++++++++++++++++++++++-- mcpd/src/methods/proto.c | 6 ++++ mcpd/src/rpc.h | 2 +- 7 files changed, 190 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d21cc0..2bc9761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,63 @@ user-owned COMBI: is left alone), and issues `c:Dismount COMBI: FORCE` to drop the live DOS entry. +- **MCPd readiness beacon in the kernel debug ring.** MCPd now emits + a single machine-parseable line via `IExec->DebugPrintF` once the + listen socket is bound and accepting: + + ``` + [MCPd] ready name=MCPd version=1.3 build_date=02.08.2026 build_time=18:18:04 port=4322 + ``` + + The pre-existing `Printf` banner goes to stdout, which is `NIL:` on + the auto-start path (`S:Network-Startup` -> `Run >NIL: DebugPrintF`: + +``` +[MCPd] ready name=MCPd version=1.3 build_date=02.08.2026 build_time=18:18:04 port=4322 +``` + +The line is emitted **after** `bind` + `listen` succeed, so its +presence means the port is accepting connections — not merely that +the binary loaded. Two companion lines cover the other outcomes: + +``` +[MCPd] startup_failed version=1.3 reason=bsdsocket +[MCPd] startup_failed version=1.3 reason=listen port=4322 +[MCPd] shutdown version=1.3 +``` + +`shutdown` marks a clean exit, so a reader can distinguish a normal +stop from a crash (which produces no line at all). + +The `[MCPd] ` prefix and the `key=value` shape are a parsed +interface — keep them stable. + +The daemon also prints a banner to stdout, but on the auto-start +path stdout is `NIL:` (`Run >NIL: Printf("MCPd %s (%s)\n", MCPD_VERSION, MCPD_DATE); + IDOS->Printf("MCPd %s (%s %s)\n", + MCPD_VERSION, MCPD_DATE, MCPD_TIME); IDOS->Printf("Built with: %s\n", MCPD_SDK_VERSION); return 0; } @@ -403,7 +415,16 @@ int main(int argc, char **argv) { return 64; } - if (open_socket_lib() != 0) return 1; + /* Raise the listener above Workbench. Done here rather than with + * the pr_WindowPtr setup above so `--version` / `--help` exit + * without touching scheduling. */ + IExec->SetTaskPri((struct Task *)me, MCPD_LISTENER_PRI); + + if (open_socket_lib() != 0) { + IExec->DebugPrintF("[MCPd] startup_failed version=%s " + "reason=bsdsocket\n", MCPD_VERSION); + return 1; + } /* z.library is best-effort - if the system doesn't have it, the * non-compressed paths still work and compression='zlib' returns @@ -412,6 +433,9 @@ int main(int argc, char **argv) { int listen_sock = make_listen_socket(port); if (listen_sock < 0) { + IExec->DebugPrintF("[MCPd] startup_failed version=%s " + "reason=listen port=%lu\n", + MCPD_VERSION, (unsigned long)port); close_z_lib(); close_socket_lib(); return 1; @@ -446,6 +470,26 @@ int main(int argc, char **argv) { IDOS->Printf("MCPd %s listening on :%lu (Ctrl-C to stop)\n", MCPD_VERSION, (unsigned long)port); + /* Readiness beacon in the kernel debug ring. Printf above goes to + * stdout, which is NIL: on the auto-start path + * (S:Network-Startup -> Run >NIL: DebugPrintF("[MCPd] ready name=MCPd version=%s " + "build_date=%s build_time=%s port=%lu\n", + MCPD_VERSION, MCPD_DATE, MCPD_TIME, + (unsigned long)port); + /* Multi-client (§19.3 P1 #7): the parent task does nothing but * accept + spawn. Each child Process owns its own connection * lifecycle. The single-client wedge that bit us when an old @@ -480,5 +524,8 @@ int main(int argc, char **argv) { close_z_lib(); close_socket_lib(); IDOS->Printf("MCPd: shutdown\n"); + /* Counterpart to the readiness beacon - lets a debug-ring reader + * tell "was up, exited cleanly" from "was up, crashed". */ + IExec->DebugPrintF("[MCPd] shutdown version=%s\n", MCPD_VERSION); return 0; } diff --git a/mcpd/src/methods/proto.c b/mcpd/src/methods/proto.c index 13713ac..6279fb6 100644 --- a/mcpd/src/methods/proto.c +++ b/mcpd/src/methods/proto.c @@ -13,6 +13,10 @@ #define MCPD_DATE "00.00.0000" #endif +#ifndef MCPD_TIME +#define MCPD_TIME "00:00:00" +#endif + #ifndef MCPD_SDK_VERSION #define MCPD_SDK_VERSION "AmigaOS 4 SDK (unknown revision)" #endif @@ -48,6 +52,7 @@ int proto_capabilities(cJSON *params, cJSON **out_result, cJSON **out_err) { /* Build info. */ cJSON *build = cJSON_AddObjectToObject(r, "build"); cJSON_AddStringToObject(build, "date", MCPD_DATE); + cJSON_AddStringToObject(build, "time", MCPD_TIME); cJSON_AddStringToObject(build, "compiler", "ppc-amigaos-gcc"); cJSON_AddStringToObject(build, "sdk", MCPD_SDK_VERSION); @@ -106,6 +111,7 @@ int proto_version(cJSON *params, cJSON **out_result, cJSON **out_err) { cJSON_AddStringToObject(r, "server", MCPD_SERVER_VERSION); cJSON_AddStringToObject(r, "protocol", MCPD_PROTOCOL_VERSION); cJSON_AddStringToObject(r, "build_date", MCPD_DATE); + cJSON_AddStringToObject(r, "build_time", MCPD_TIME); cJSON_AddStringToObject(r, "sdk", MCPD_SDK_VERSION); *out_result = r; return 0; diff --git a/mcpd/src/rpc.h b/mcpd/src/rpc.h index 80123c0..d75d059 100644 --- a/mcpd/src/rpc.h +++ b/mcpd/src/rpc.h @@ -11,7 +11,7 @@ #include "cJSON.h" #define MCPD_PROTOCOL_VERSION "1.0" -#define MCPD_SERVER_VERSION "mcpd/1.2" +#define MCPD_SERVER_VERSION "mcpd/1.3" typedef int (*method_handler_fn)(cJSON *params, cJSON **out_result, From f8b04894f7ce721a5a8b912bb73b9fc950ec43e5 Mon Sep 17 00:00:00 2001 From: Costel Mincea Date: Mon, 20 Jul 2026 19:46:42 +0200 Subject: [PATCH 15/23] input.*: keyboard and mouse injection, disabled by default Add seven input.* methods driving input.device IND_WRITEEVENT: state, type, key, mouse_move, click, drag, scroll. Goes through MCPd rather than QMP, so it works on real hardware and QEMU alike. Injection is off at the daemon and returns -32003 until the operator passes --enable-input or creates SYS:System/MCPd/ENABLE-INPUT. The gate is read once at startup, so no RPC can switch it on. The sentinel file is the primary mechanism because MCPd-Watchdog and the S:Network-Startup line both relaunch without arguments, which would silently drop a flag-only gate on the first restart. The host-side [targets..input] block is a wrong-target guard, not access control. type/key/click/drag also require confirm:true; ctrl+lamiga+ramiga additionally requires confirm_reset:true. Typing maps characters through the target's keymap.library MapANSI, including dead-key sequences, so non-US layouts get the intended characters; a built-in US table is the fallback and the result reports which path ran. Caps enforced daemon-side: 256 events, 20s wall clock, 512 chars, 64 drag steps. Held modifiers and buttons are always released before returning, including on the abort path, so a truncated drag cannot leave a stuck mouse button. Also unify the daemon version behind MCPD_VERSION_STR in rpc.h -- main.c and rpc.h previously carried independent literals, so MCPd --version and proto.version could disagree. Bump to 1.3; the wire protocol stays at 1.0 since input.* is purely additive. Verified against a QEMU Pegasos2 guest (AmigaOS 4.1 FE, Kickstart 54.57): gate closed by default and after a cold boot, enable/disable lifecycle, pointer positioning, and typing end-to-end through a Shell on a German keymap. --- CHANGELOG.md | 52 + COMMANDS.md | 36 + CONTRIBUTING.md | 20 +- SECURITY.md | 42 +- USAGE.md | 94 ++ host/config.example.toml | 22 + host/src/amiga_fleet_mcp/config.py | 30 + host/src/amiga_fleet_mcp/server.py | 169 +++ host/src/amiga_fleet_mcp/setup_wizard.py | 18 + host/src/amiga_fleet_mcp/tools/input.py | 457 ++++++ host/tests/unit/test_config.py | 43 + host/tests/unit/test_input.py | 418 ++++++ host/tests/unit/test_setup_wizard.py | 42 + mcpd/Makefile | 1 + mcpd/install/MCPd-Disable-Input | 32 + mcpd/install/MCPd-Enable-Input | 58 + mcpd/install/MCPd-Install | 15 + mcpd/install/MCPd-Watchdog | 7 + mcpd/src/main.c | 33 +- mcpd/src/methods/input.c | 1611 ++++++++++++++++++++++ mcpd/src/methods/methods.h | 11 + mcpd/src/methods/proto.c | 15 + mcpd/src/rpc.c | 14 + mcpd/src/rpc.h | 16 +- 24 files changed, 3242 insertions(+), 14 deletions(-) create mode 100644 host/src/amiga_fleet_mcp/tools/input.py create mode 100644 host/tests/unit/test_input.py create mode 100644 mcpd/install/MCPd-Disable-Input create mode 100644 mcpd/install/MCPd-Enable-Input create mode 100644 mcpd/src/methods/input.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc9761..63f17d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,48 @@ ### Added +- **`input.*` — keyboard and mouse injection**, via `input.device` + `IND_WRITEEVENT` on the Amiga side. Seven methods: `input.state` + (read-only pointer / focus / geometry), `input.type`, `input.key`, + `input.mouse_move`, `input.click`, `input.drag`, `input.scroll`. + Works on real hardware and QEMU alike since it goes through MCPd + rather than QMP. Registered as seven fine-grained MCP tools plus an + `input` namespace dispatcher. +- **Disabled by default at both layers.** The daemon gate is the real + control: `input.*` returns `-32003` unless MCPd was started with the + new `--enable-input` flag *or* the sentinel file + `SYS:System/MCPd/ENABLE-INPUT` exists. The gate is read once at + startup, so no RPC method can switch it on — enabling requires + filesystem access to the target and a daemon restart. The sentinel + is the primary mechanism because `MCPd-Watchdog` relaunches MCPd + with no arguments, so a CLI-flag-only gate would be silently lost on + the first restart. `proto.capabilities` now reports + `input.enabled`, and the methods stay advertised when off so clients + can distinguish "switched off" from "old daemon". +- **Host-side gate**: new `[targets..input]` config block + (`enabled`, `max_text_len`, `max_events`, `default_delay_ms`, + `allow_drag`), defaulting to absent. Raises `NotCapable` naming both + gates. The `--init` wizard asks about it, defaulting to no. +- **New install scripts** `MCPd-Enable-Input` / `MCPd-Disable-Input`, + copied by `MCPd-Install` but never run by it. +- `confirm: true` required on the committing operations (`type`, + `key`, `click`, `drag`); not on `mouse_move`, `scroll`, `state`. + `ctrl+lamiga+ramiga` additionally requires `confirm_reset: true` + because it reboots the machine. +- Per-call caps enforced daemon-side: 256 events, 20 s wall clock, 512 + characters, 64 drag steps, `delay_ms` 0–1000. Held modifiers and + mouse buttons are always released before returning, including on the + abort path, so a truncated drag cannot leave a stuck mouse button. + +- **Layout-correct typing.** `input.type` maps characters through the + target's `keymap.library` (`MapANSI`), including dead-key sequences, + so non-US keymaps receive the intended characters. Verified + end-to-end on a German (QWERTZ) AmigaOS 4.1 FE guest: typing + `Echo TYPED-OK >T:typed.txt` into a Shell produced exactly that + file. `keymap="us"` forces the built-in table, which is also the + automatic fallback; the result reports which path ran. + + - **`wb.screenshot`** — capture an AmigaOS screen to a PNG on the target and bring it back to the host. The daemon grabs the chosen screen (frontmost, or by `screen_index`) with `graphics.library` @@ -140,6 +182,16 @@ from its inherited Output() handle). The probe now treats any structured `exec.cmd` exit as proof the binary started. +### Known limitations + +- F11/F12 rawkey codes and the NewMouse wheel constants are still + flagged in `mcpd/src/methods/input.c` as unverified. + `input.mouse_move` defaults to a read-position-then-relative-delta + strategy (verified accurate); `absolute_mode="raw"` remains + available to A/B the true-absolute event form. +- `input.click` does not report the achieved pointer position the way + `input.mouse_move` and `input.drag` do. + ## 1.2 — Guided setup and whole-file transfers ### Added diff --git a/COMMANDS.md b/COMMANDS.md index 581f591..f5ddfb8 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -33,6 +33,7 @@ auto-start watchdog. | `--port N` | Listen on TCP port `N` instead of the default 4322. | | `--version` | Print the daemon version and exit. | | `--help`, `-h` | Print usage and exit. | +| `--enable-input` | Allow the `input.*` methods (keyboard / mouse injection). **Off by default.** Does *not* survive a restart — the watchdog relaunches without arguments — so for a persistent setting create `SYS:System/MCPd/ENABLE-INPUT` instead (`MCPd-Enable-Input`). See [SECURITY.md](SECURITY.md). | ## RPC methods (daemon side) @@ -126,6 +127,39 @@ auto-base64, auto-zlib, optional resume + SHA-256 verify. | `wb.frontmost` | Frontmost screen, active screen, and active window. | | `wb.screenshot` | Capture a screen (frontmost or by `screen_index`) to a PNG on the target via `graphics.library` ReadPixelArray + `z.library` encode; the host tool downloads it. Works on real hardware **and** QEMU (unlike `qemu.screenshot`, which is QMP-only). | +### `input.*` (keyboard / mouse injection) + +> ⚠️ **Disabled by default.** Every method here returns `-32003` +> unless `MCPd` was started with `--enable-input` *or* the sentinel +> file `SYS:System/MCPd/ENABLE-INPUT` exists (run `MCPd-Enable-Input` +> on the target, then restart the daemon). The gate is read once at +> startup, so no RPC can turn it on. Enabling it lets any client that +> can reach port 4322 type and click on the machine as the logged-in +> user. Read [SECURITY.md](SECURITY.md) first. +> +> The host side has a second, independent gate: +> `[targets..input] enabled = true`. Both must be open. + +| Method | Confirm | Description | +|---|---|---| +| `input.state` | — | Pointer position, frontmost/active screen, active window + geometry. **Call this before `input.click`** so you know what you are about to click on. | +| `input.type` | ✅ | Type a string as keystrokes. `text` (≤512 chars), `keymap` (`system` default / `us`), `delay_ms`. Layout-correct: characters are mapped through the target's `keymap.library` (`MapANSI`), including dead-key sequences. Unmappable characters are returned in `unmapped[]`, never silently dropped. | +| `input.key` | ✅ | Press a key or chord: `keys: ["lamiga","q"]`. All but the last entry must be modifiers. `ctrl+lamiga+ramiga` **reboots the machine** and additionally requires `confirm_reset: true`. | +| `input.mouse_move` | — | Move the pointer: `x`+`y` (absolute) or `dx`/`dy` (relative, ±4096). Absolute targets are clamped to the frontmost screen and the achieved position is returned. | +| `input.click` | ✅ | Click at the pointer, or at `x`/`y`. `button` (`left`/`right`/`middle`), `count` (1–8). | +| `input.drag` | ✅ | Press at `from_x`/`from_y`, move to `to_x`/`to_y`, release. The button is always released, even if the call aborts on a budget overrun. | +| `input.scroll` | — | Mouse wheel via NewMouse codes. `clicks` (1–32), `direction`. | + +Per-call caps enforced by the daemon: 256 events, 20 s wall clock, +512 characters of text, 64 drag steps, `delay_ms` 0–1000. A call that +hits a cap stops cleanly and returns `truncated: true`. + +Named keys for `input.key`: `shift` `lshift` `rshift` `ctrl` `alt` +`lalt` `ralt` `amiga` `lamiga` `ramiga` `capslock`; `f1`–`f10`; `esc` +`return` `tab` `backspace` `del` `help` `space` `up` `down` `left` +`right`; `kp0`–`kp9` `kpdot` `kpenter` `kpminus` `kpplus` `kpmul` +`kpdiv`. A single printable character (`"q"`) also works. + ### `debug.*` | Method | Description | @@ -167,6 +201,7 @@ prints the live list. | `fs_upload` / `fs_download` | Host-side whole-file wrappers around `fs.write_chunk` / `fs.read`. Auto-chunk, auto-base64, auto-zlib, optional `resume` + `verify=sha256`. **Use these for "I have a host file, please move it"** — they do not exist as daemon RPC methods, only as MCP tools. | | `exec_cmd` | `exec.cmd` | | `wb_screens` / `wb_windows` / `wb_publicscreens` / `wb_frontmost` | `wb.*` | +| `input_state` / `input_type` / `input_key` / `input_mouse_move` / `input_click` / `input_drag` / `input_scroll` | `input.*` — **disabled by default**, needs the daemon gate *and* `[targets..input] enabled = true`. See [`input.*`](#input-keyboard--mouse-injection). | | `debug_task_snapshot` / `debug_symbol` / `debug_stacktrace` / `debug_write_memory` / `debug_write_register` / `debug_read_registers` / `debug_read_memory` / `debug_set_breakpoint` / `debug_clear_breakpoint` / `debug_step` / `debug_continue` / `debug_backtrace` / `debug_stop_reason` / `debug_detach` | `debug.*` (per-task IDebug + whole-system GDB stub) | | `events_wait` | `events.wait` (long-poll only). The other three `events.*` methods are exposed only as daemon RPC; clients invoke them via the transport directly. | | `app_notify` | `app.notify` | @@ -324,6 +359,7 @@ to script an operation by method name rather than by tool name. | `fs` | `fs.*` | | `sys` | `sys.*` | | `wb` | `wb.*` | +| `input` | `input.*` (disabled by default) | | `debug` | `debug.*` | | `qemu` | `qemu.*` | | `fleet` | `fleet.*` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4f5ca3..5987a97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,11 +62,21 @@ The full build / install procedure for both pieces lives in `@mcp.tool` decorator and add it to the matching namespace dispatcher. 3. If it talks to the daemon, also register the JSON-RPC method in - `mcpd/src/rpc.c` and implement the handler under - `mcpd/src/methods/`. -4. Add unit tests under `host/tests/unit/` using a fake transport. -5. Document it in [COMMANDS.md](COMMANDS.md) and (if it's a major - addition) [USAGE.md](USAGE.md). + `mcpd/src/rpc.c`, add the handler prototype to + `mcpd/src/methods/methods.h`, and implement it under + `mcpd/src/methods/`. **A new `.c` file must also be added to + `SRCS` in `mcpd/Makefile`** — forgetting that is a link error, not + a compile error, and the symptom looks nothing like the cause. +4. If the tool has a daemon-side capability gate, report its state in + `proto.capabilities` (`mcpd/src/methods/proto.c`) too — and keep + the method advertised when the gate is closed, so clients can tell + "switched off" apart from "old daemon". +5. Add unit tests under `host/tests/unit/` using a fake transport. For + a gated or `confirm`-guarded tool, assert both that the error is + raised *and* that nothing reached the wire (`fake.calls == []`). +6. Document it in [COMMANDS.md](COMMANDS.md) and (if it's a major + addition) [USAGE.md](USAGE.md). Anything that widens the remote + attack surface also needs a [SECURITY.md](SECURITY.md) note. ## Adding a new target machine diff --git a/SECURITY.md b/SECURITY.md index 76f317f..b9a4622 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,12 +38,29 @@ operator. In particular: and works regardless of AOS state. Treat the host workstation running `amiga-fleet-mcp` with cable attached as having physical power-button access to the target. +- **Keyboard / mouse injection is off by default**: the `input.*` + methods (`input.type`, `input.key`, `input.click`, `input.drag`, + `input.mouse_move`, `input.scroll`, `input.state`) let a remote + client type and click on the target as the logged-in user, in + whatever window happens to be focused. They are **disabled at the + daemon** and return `-32003` until the operator either starts + `MCPd` with `--enable-input` or creates the sentinel file + `SYS:System/MCPd/ENABLE-INPUT` (see `MCPd-Enable-Input`) and + restarts the daemon. The gate is read once at startup, so **no RPC + method can switch it on** — enabling it needs filesystem access to + the target. The host-side `[targets..input] enabled` flag is + a wrong-target guard for the operator's convenience, **not** an + access control: anything that can reach TCP 4322 bypasses the host + entirely. Once the daemon gate is open, treat the machine as one + whose keyboard and mouse are on the network. Do not enable it on a + target reachable from an untrusted network. - **Confirm-gated destructive operations**: `sys.cold_reboot`, `sys.mcu_cmd cmd="s"`, the mutating `installer.*` / - `debug.write_*` tools, and the destructive `power.*` tools + `debug.write_*` tools, the destructive `power.*` tools (`power.on` / `power.off` / `power.toggle_stream` / - `power.shell`) all require an explicit `confirm: true` - parameter. This is a guardrail against accidental fire, **not** + `power.shell`), and the committing `input.*` tools + (`input.type` / `input.key` / `input.click` / `input.drag`) all + require an explicit `confirm: true` parameter. This is a guardrail against accidental fire, **not** an authentication mechanism. A connected client can always pass `confirm: true`. - **Per-connection fault isolation is not a security boundary**: @@ -54,7 +71,14 @@ operator. In particular: - **Sensitive material**: do not rely on the project for secrets hygiene. Anything readable on the target Amiga is reachable via `fs.read`. Do not store credentials, keys, or other sensitive - material on a volume `MCPd` can see. + material on a volume `MCPd` can see. This extends to the host-side + run archive: every tool call is recorded with its parameters, so + **any string typed through `input.type` is written to the archive + JSONL in cleartext**. That is intended — it is an audit log, and + silently redacting one is worse than not keeping it — but it means + you must not type passwords through `input.type`. The + `text_sha256` field in the result exists so a reviewer can confirm + what was typed without re-reading the plaintext. ## Design limits worth knowing @@ -70,6 +94,16 @@ These are documented behaviours, not vulnerabilities: - The MCP framing layer caps individual frames at 32 MiB. Larger payloads must use `fs.write_chunk` (resumable, optionally zlib-compressed). +- `input.type` maps characters through the target's own + `keymap.library`, so it is layout-correct. If `keymap.library` + cannot be opened it silently falls back to a built-in US table; + the result's `keymap` field reports which path ran, and callers + who care should check it. +- `input.*` calls are capped per call (256 events, 20 s wall clock, + 512 characters of text) and the daemon always releases any held + modifier or mouse button before returning, including on the abort + path — a truncated `input.drag` cannot leave the target with a + stuck mouse button. ## Supported versions diff --git a/USAGE.md b/USAGE.md index 0d17682..ef3a74a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -275,6 +275,100 @@ arbitrary address to module / function / source. For QEMU targets, the `gdb` channel exposes whole-system register and memory access through QEMU's GDB stub. +## Driving the target's UI (`input.*`) + +The `wb.*` tools only *look* at Intuition. `input.*` writes to it — +synthesising keyboard and mouse events through `input.device` so an +agent can dismiss a requester, click Proceed in a GUI installer, or +drive Workbench itself. + +This is the most dangerous surface in the project, and it is **off by +default behind two independent gates**. + +### Opening the gates + +**1. On the Amiga (the real control).** Either start the daemon with +`--enable-input`, or — better — create the sentinel file: + +``` +Execute SYS:System/MCPd/MCPd-Enable-Input +``` + +then restart MCPd. The flag does *not* survive a restart, because +`MCPd-Watchdog` relaunches with no arguments; the sentinel does. +MCPd prints `*** INPUT INJECTION ENABLED ***` at startup when the +gate is open — if you don't see that line, it isn't. + +The gate is read once at startup, so **no RPC method can turn it +on**. Enabling requires filesystem access to the target. + +**2. In the host config (a wrong-target guard).** + +```toml +[targets.x5000-real.input] +enabled = true +``` + +Absent or `false` raises `NotCapable` before anything reaches the +wire. This exists to stop an agent firing input at a target you never +meant to drive; it is *not* access control, since anything that can +reach TCP 4322 bypasses the host entirely. + +To revoke: `Execute SYS:System/MCPd/MCPd-Disable-Input`, restart MCPd. + +### Look before you click + +You are typing blind unless you check first. The idiom is: + +``` +input.state -> pointer position, active window +wb.windows -> where everything is +input.mouse_move x=.. y=.. -> returns the position ACHIEVED +input.state -> confirm before committing +input.click confirm=true +wb.frontmost -> confirm activation changed +``` + +`input.mouse_move` reports where the pointer actually ended up rather +than where you asked it to go, so the move is self-verifying. Absolute +coordinates are clamped to the frontmost screen. + +### Typing + +``` +input.type text="Hello" confirm=true +input.key keys=["lamiga","q"] confirm=true # or chord="lamiga+q" +``` + +For `input.key`, every entry but the last must be a modifier. The +`ctrl+lamiga+ramiga` reset chord additionally requires +`confirm_reset=true`. + +`input.type` is layout-correct: the daemon maps each character through +the target's own `keymap.library` (`MapANSI`), including dead-key +sequences for accented characters, so a German or French machine +receives what you asked for. Pass `keymap="us"` to force the built-in +US table; that table is also the automatic fallback if +`keymap.library` cannot be opened, and the result's `keymap` field +tells you which path ran. Characters that cannot be produced on the +active layout come back in `unmapped[]` rather than being silently +dropped, so check that field if the result matters. + +⚠️ Everything typed through `input.type` is recorded **in cleartext** +in the run archive. That is deliberate — it is an audit log — but it +means you must not type credentials through it. + +### Limits + +Each call is capped at 256 events, 20 s of wall clock, 512 characters +of text, and 64 drag steps. A call that hits a cap stops cleanly and +returns `truncated: true`. The daemon always releases held modifiers +and mouse buttons before returning, including on the abort path, so a +truncated `input.drag` cannot leave the machine with a stuck button. + +`[targets..input] allow_drag = false` disables `input.drag` alone +while leaving the rest of the surface available. + ## Out-of-band power control (`power.*`) For real-hardware targets with the FTDI USB-TTL cable wired to the diff --git a/host/config.example.toml b/host/config.example.toml index b7eac9b..c9b8fda 100644 --- a/host/config.example.toml +++ b/host/config.example.toml @@ -115,3 +115,25 @@ tags = ["real-hw", "x5000"] # default_extmem_mb = 1024 # default_window_mb = 256 # deny_libs = ["intuition.library"] # always denied + + # Optional: keyboard / mouse injection (the `input.*` namespace). + # + # DISABLED BY DEFAULT, and this block alone is NOT enough. There + # are two gates and both must be open: + # + # 1. This block (a wrong-target guard, host-side). + # 2. MCPd on the target, started with --enable-input or with + # SYS:System/MCPd/ENABLE-INPUT present. Run MCPd-Enable-Input + # on the Amiga and restart MCPd. THIS is the real control -- + # anything that can reach TCP 4322 bypasses the host entirely. + # + # Once both are open, any client that can reach the daemon can + # type and click on this machine as the logged-in user. Do not + # enable it on a target reachable from an untrusted network. + # See SECURITY.md. + # [targets.x5000-real.input] + # enabled = true + # max_text_len = 512 # chars per input.type + # max_events = 256 # events per call + # default_delay_ms = 15 # inter-event pacing + # allow_drag = true # false disables input.drag only diff --git a/host/src/amiga_fleet_mcp/config.py b/host/src/amiga_fleet_mcp/config.py index 6e0333e..3876397 100644 --- a/host/src/amiga_fleet_mcp/config.py +++ b/host/src/amiga_fleet_mcp/config.py @@ -153,6 +153,30 @@ class SerialChannel(BaseModel): baud: int = 115200 +class InputConfig(BaseModel): + """Per-target input-injection policy. Disabled by default. + + ⚠️ This is a *host-side convenience* gate, not a security boundary. + The real control lives in the daemon: MCPd must have been started + with `--enable-input`, or have the sentinel file + `SYS:System/MCPd/ENABLE-INPUT` present, or every `input.*` call + returns -32003 no matter what this file says. Anything that can + reach TCP 4322 bypasses this block entirely. + + Its purpose is to stop an agent from firing input at a target the + operator never intended to drive — a wrong-target guard, not an + access-control one. + """ + + model_config = ConfigDict(extra="ignore") + + enabled: bool = False + max_text_len: int = 512 + max_events: int = 256 + default_delay_ms: int = 15 + allow_drag: bool = True + + class TargetChannels(BaseModel): """Per-target channel configuration.""" @@ -204,6 +228,12 @@ class TargetConfig(BaseModel): channels: TargetChannels = Field(default_factory=TargetChannels) sandbox: SandboxTargetConfig | None = None + # Policy, not a transport — so it sits on the target rather than in + # `channels`. Defaults to None (absent) rather than a disabled + # instance so "never configured" and "explicitly turned off" stay + # distinguishable. + input: InputConfig | None = None + class PathsConfig(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/host/src/amiga_fleet_mcp/server.py b/host/src/amiga_fleet_mcp/server.py index 8a0e938..3b739af 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -28,6 +28,7 @@ from .tools import fleet as fleet_tool from .tools import fleet_discover as discover_tool from .tools import fs as fs_tool +from .tools import input as input_tool from .tools import installer as installer_tool from .tools import power as power_tool from .tools import qemu as qemu_tool @@ -793,6 +794,150 @@ async def wb_screenshot( inline=inline, ) + # ---------- input.* : keyboard / mouse injection ------------------- + # + # DISABLED BY DEFAULT behind two gates. The daemon one is the real + # control (MCPd --enable-input or SYS:System/MCPd/ENABLE-INPUT); + # the host one ([targets..input] enabled) is a wrong-target + # guard. See tools/input.py and SECURITY.md. + + @mcp.tool(name="input_state", title="input.state") + @archived("input.state", archive) + async def input_state(*, target: str | None = None) -> input_tool.InputState: + """Pointer position, focused window, and screen geometry. + Requires input injection to be enabled on both the target's host + config and the daemon. Call this BEFORE input.click so you know + what you are about to click on.""" + return await input_tool.input_state(fleet, fleet.resolve_target(target)) + + @mcp.tool(name="input_type", title="input.type") + @archived("input.type", archive) + async def input_type( + *, + text: str, + keymap: str = "system", + delay_ms: int | None = None, + confirm: bool = False, + target: str | None = None, + ) -> input_tool.TypeResult: + """Type a string as keystrokes. Requires input injection enabled + on both host config and daemon, plus confirm=True. Layout-correct + by default (mapped through the target's keymap.library); pass + keymap="us" to force the built-in US table. The text is recorded + in cleartext in the run archive; do not type credentials through + this tool.""" + return await input_tool.input_type( + fleet, fleet.resolve_target(target), + text=text, keymap=keymap, delay_ms=delay_ms, confirm=confirm, + ) + + @mcp.tool(name="input_key", title="input.key") + @archived("input.key", archive) + async def input_key( + *, + keys: list[str] | None = None, + chord: str | None = None, + delay_ms: int | None = None, + confirm: bool = False, + confirm_reset: bool = False, + target: str | None = None, + ) -> input_tool.KeyResult: + """Press a key or chord, e.g. keys=["lamiga","q"] or + chord="lamiga+q". All entries but the last must be modifiers. + Requires input injection enabled on both host config and daemon, + plus confirm=True. ctrl+lamiga+ramiga REBOOTS the machine and + also needs confirm_reset=True.""" + return await input_tool.input_key( + fleet, fleet.resolve_target(target), + keys=keys, chord=chord, delay_ms=delay_ms, + confirm=confirm, confirm_reset=confirm_reset, + ) + + @mcp.tool(name="input_mouse_move", title="input.mouse_move") + @archived("input.mouse_move", archive) + async def input_mouse_move( + *, + x: int | None = None, + y: int | None = None, + dx: int | None = None, + dy: int | None = None, + absolute_mode: str = "delta", + steps: int = 1, + delay_ms: int | None = None, + target: str | None = None, + ) -> input_tool.MouseResult: + """Move the pointer -- x+y absolute, or dx/dy relative. Requires + input injection enabled on both host config and daemon. No + confirm: motion commits nothing. Absolute targets are clamped to + the frontmost screen and the achieved position is returned.""" + return await input_tool.input_mouse_move( + fleet, fleet.resolve_target(target), + x=x, y=y, dx=dx, dy=dy, absolute_mode=absolute_mode, + steps=steps, delay_ms=delay_ms, + ) + + @mcp.tool(name="input_click", title="input.click") + @archived("input.click", archive) + async def input_click( + *, + button: str = "left", + count: int = 1, + x: int | None = None, + y: int | None = None, + delay_ms: int | None = None, + confirm: bool = False, + target: str | None = None, + ) -> input_tool.MouseResult: + """Click at the pointer, or at x/y if given. Requires input + injection enabled on both host config and daemon, plus + confirm=True -- the click lands on whatever is under the + pointer, so call input.state first.""" + return await input_tool.input_click( + fleet, fleet.resolve_target(target), + button=button, count=count, x=x, y=y, + delay_ms=delay_ms, confirm=confirm, + ) + + @mcp.tool(name="input_drag", title="input.drag") + @archived("input.drag", archive) + async def input_drag( + *, + from_x: int, + from_y: int, + to_x: int, + to_y: int, + button: str = "left", + steps: int = 16, + delay_ms: int | None = None, + confirm: bool = False, + target: str | None = None, + ) -> input_tool.MouseResult: + """Press at from_x/from_y, move to to_x/to_y, release. Requires + input injection enabled on both host config and daemon, plus + confirm=True. Can move, resize or drag-to-trash. The daemon + always releases the button, even on an aborted call.""" + return await input_tool.input_drag( + fleet, fleet.resolve_target(target), + from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y, + button=button, steps=steps, delay_ms=delay_ms, confirm=confirm, + ) + + @mcp.tool(name="input_scroll", title="input.scroll") + @archived("input.scroll", archive) + async def input_scroll( + *, + clicks: int = 1, + direction: str = "down", + delay_ms: int | None = None, + target: str | None = None, + ) -> input_tool.MouseResult: + """Mouse wheel. Requires input injection enabled on both host + config and daemon. No confirm: scrolling commits nothing.""" + return await input_tool.input_scroll( + fleet, fleet.resolve_target(target), + clicks=clicks, direction=direction, delay_ms=delay_ms, + ) + # ---------- phase 5c (partial): GDB-stub debug -------------------- @mcp.tool(name="debug_read_registers", title="debug.read_registers") @@ -1469,6 +1614,30 @@ async def wb_ns(*, method: str, params: dict[str, Any] | None = None) -> Any: "screenshot": wb_tool.wb_screenshot, }, method, params or {}) + @mcp.tool(name="input", title="input.dispatch") + @archived("input.dispatch", archive) + async def input_ns(*, method: str, params: dict[str, Any] | None = None) -> Any: + """input.* (keyboard / mouse injection) dispatcher. `method` is + one of: + + state, type, key, mouse_move, click, drag, scroll + + DISABLED BY DEFAULT behind two gates: the target's host config + ([targets..input] enabled = true) and the daemon itself + (MCPd --enable-input, or SYS:System/MCPd/ENABLE-INPUT on the + target). The daemon gate is the real control. type/key/click/ + drag additionally require confirm=True. + """ + return await _ns({ + "state": input_tool.input_state, + "type": input_tool.input_type, + "key": input_tool.input_key, + "mouse_move": input_tool.input_mouse_move, + "click": input_tool.input_click, + "drag": input_tool.input_drag, + "scroll": input_tool.input_scroll, + }, method, params or {}) + @mcp.tool(name="debug", title="debug.dispatch") @archived("debug.dispatch", archive) async def debug_ns(*, method: str, params: dict[str, Any] | None = None) -> Any: diff --git a/host/src/amiga_fleet_mcp/setup_wizard.py b/host/src/amiga_fleet_mcp/setup_wizard.py index 51f7cf7..d9c4e0a 100644 --- a/host/src/amiga_fleet_mcp/setup_wizard.py +++ b/host/src/amiga_fleet_mcp/setup_wizard.py @@ -260,6 +260,24 @@ def _wizard_target(prompt: Prompt) -> tuple[str, dict[str, Any]] | None: "port": _ask(prompt, "MCU serial port", default_port), "baud": 38400, } + + # Input injection. Default NO, and only written when the operator + # says yes -- an absent [input] block is the disabled state, so a + # wizard run should never produce one by accident. + if _ask_bool( + prompt, + "enable keyboard/mouse injection (input.*) for this target? " + "This lets an MCP client type and click on it", + False, + ): + t["input"] = {"enabled": True} + print( + " NOTE: this only opens the host-side gate. MCPd on the\n" + " target must ALSO be started with --enable-input, or have\n" + " SYS:System/MCPd/ENABLE-INPUT present (run\n" + " MCPd-Enable-Input there, then restart MCPd). Until then\n" + " input.* returns -32003." + ) return name, t diff --git a/host/src/amiga_fleet_mcp/tools/input.py b/host/src/amiga_fleet_mcp/tools/input.py new file mode 100644 index 0000000..91faf61 --- /dev/null +++ b/host/src/amiga_fleet_mcp/tools/input.py @@ -0,0 +1,457 @@ +"""input.* — keyboard and mouse injection on the target. + +These are the only tools in the project that *write* to the target's +UI. Everything else observes the machine or touches the filesystem; +these type and click as the logged-in user, into whatever window +happens to be focused. + +Two independent gates, and it matters which is which: + +1. **Daemon-side (the real one).** MCPd must have been started with + `--enable-input`, or have `SYS:System/MCPd/ENABLE-INPUT` present. + Otherwise every method returns -32003 regardless of host config. + Enabling it requires filesystem access to the target *and* an MCPd + restart — no RPC can turn it on. + +2. **Host-side (a wrong-target guard).** Per-target config:: + + [targets.x5000-real.input] + enabled = true + + Absent or `enabled = false` raises `NotCapable` before anything + reaches the wire. This stops an agent firing input at a target the + operator never meant to drive; it is *not* access control, since + anything that can reach TCP 4322 bypasses the host entirely. + +`confirm=True` is required on the operations that can commit an action +(`type`, `key`, `click`, `drag`) and not on the ones that cannot +(`mouse_move`, `scroll`, `state`). As everywhere else in this project, +confirm is an accidental-fire guard, **not** authentication. + +⚠️ Everything typed through `input.type` is recorded in cleartext in +the run archive, by design — it is an audit log. Do not type +credentials through it. + +`input.type` is layout-correct: the daemon maps characters through +`keymap.library`'s `MapANSI` against the target's configured keymap, +so a German or French machine receives the characters you asked for +(verified on a QWERTZ guest). Pass `keymap="us"` to force the +built-in US table instead, which is also the automatic fallback if +`keymap.library` cannot be opened. The result's `keymap` field says +which path ran. +""" + +from __future__ import annotations + +import hashlib + +from pydantic import BaseModel, Field + +from ..config import InputConfig +from ..errors import InvalidParams, NotCapable +from ..fleet import Fleet + +# ---- result models ------------------------------------------------- + + +class InputResult(BaseModel): + target: str + op: str + events: int = 0 + duration_ms: int = 0 + truncated: bool = False + + +class TypeResult(InputResult): + text_len: int = 0 + chars_mapped: int = 0 + #: Hex codes the daemon could not map to a key. Reported rather + #: than dropped so a caller can tell the target received a + #: *different* string than requested. + unmapped: list[str] = Field(default_factory=list) + keymap: str = "" + #: Lets a reviewer prove what was typed without the archive having + #: to be re-read. The plaintext is in the archive too — this is a + #: convenience, not a redaction. + text_sha256: str = "" + + +class KeyResult(InputResult): + keys: list[str] = Field(default_factory=list) + + +class MouseResult(InputResult): + mode: str = "relative" + button: str | None = None + count: int | None = None + direction: str | None = None + clicks: int | None = None + #: Pointer position actually achieved, read back after the move. + x: int | None = None + y: int | None = None + + +class InputState(BaseModel): + target: str + enabled: bool = False + pointer_x: int | None = None + pointer_y: int | None = None + frontmost_screen: str | None = None + active_screen: str | None = None + active_window: str | None = None + active_window_left: int | None = None + active_window_top: int | None = None + active_window_width: int | None = None + active_window_height: int | None = None + screen_width: int | None = None + screen_height: int | None = None + + +# ---- helpers ------------------------------------------------------- + + +def _resolve_input(fleet: Fleet, target: str) -> InputConfig: + """Return the target's `[input]` policy block, or raise NotCapable. + + Deliberately names *both* gates in the error: an operator who has + only flipped the host-side switch would otherwise get a confusing + -32003 from the daemon on the very next call. + """ + cfg = fleet.target_config(target) + ic = cfg.input + if ic is None or not ic.enabled: + raise NotCapable( + "input injection is not enabled for this target. TWO gates " + "must both be open: (1) host config — set " + "[targets..input] enabled = true; (2) the daemon — " + "start MCPd with --enable-input, or create " + "SYS:System/MCPd/ENABLE-INPUT on the target and restart " + "MCPd (see MCPd-Enable-Input). The daemon gate is the real " + "control; the host one is a wrong-target guard.", + data={"target": target}, + ) + return ic + + +def _require_confirm(confirm: bool, op: str) -> None: + if not confirm: + raise InvalidParams( + f"input.{op} requires confirm=True — this injects real " + f"keyboard/mouse input into the target's UI, acting on " + f"whatever window is currently focused", + data={"hint": "pass confirm=True to acknowledge"}, + ) + + +def _check_delay(delay_ms: int | None) -> None: + if delay_ms is not None and not (0 <= delay_ms <= 1000): + raise InvalidParams( + "delay_ms must be between 0 and 1000", + data={"delay_ms": delay_ms}, + ) + + +def _encode_text(text: str, cfg: InputConfig) -> str: + """Validate a string is typeable and return it unchanged. + + The daemon's keymap is byte-oriented (Latin-1), not UTF-8, so any + codepoint above 0xFF cannot be represented at all. Rejecting here — + naming the offending character — beats letting it reach the wire + and come back as a silent entry in `unmapped`. + """ + if not text: + raise InvalidParams("text must not be empty") + if len(text) > cfg.max_text_len: + raise InvalidParams( + f"text is {len(text)} characters; limit is " + f"{cfg.max_text_len}", + data={"len": len(text), "max": cfg.max_text_len}, + ) + for ch in text: + if ord(ch) > 0xFF: + raise InvalidParams( + f"text contains {ch!r} (U+{ord(ch):04X}), which cannot " + f"be represented on an AmigaOS keymap — input.type is " + f"limited to Latin-1", + data={"char": ch, "codepoint": ord(ch)}, + ) + return text + + +def normalise_keys( + keys: list[str] | None = None, chord: str | None = None, +) -> list[str]: + """Reduce either accepted chord form to the canonical `keys` list. + + `chord="lamiga+q"` is human shorthand; `keys=["lamiga","q"]` is + canonical. Normalising here means the daemon implements exactly one + form — and the two spellings produce byte-identical wire params. + """ + if keys and chord: + raise InvalidParams("pass either keys= or chord=, not both") + if chord: + keys = [p for p in (s.strip() for s in chord.split("+")) if p] + if not keys: + raise InvalidParams( + "provide keys=['lamiga','q'] or chord='lamiga+q'" + ) + if len(keys) > 8: + raise InvalidParams( + f"a chord may contain at most 8 keys, got {len(keys)}" + ) + return list(keys) + + +# ---- read-only (no confirm) ---------------------------------------- + + +async def input_state(fleet: Fleet, target: str) -> InputState: + """Pointer position, focused window, and screen geometry. + + Call this **before** `input.click` to know what you are about to + click on. Blind clicking is the main way this tool surface does + damage, and this is the cheap way to avoid it. + """ + _resolve_input(fleet, target) + raw = await fleet.mcpd(target).request("input.state", {}) + return InputState.model_validate({**raw, "target": target}) + + +async def input_mouse_move( + fleet: Fleet, target: str, *, + x: int | None = None, y: int | None = None, + dx: int | None = None, dy: int | None = None, + absolute_mode: str = "delta", + steps: int = 1, + delay_ms: int | None = None, +) -> MouseResult: + """Move the pointer. No confirm — motion commits nothing. + + Give either `x`+`y` (absolute) or `dx`/`dy` (relative). Absolute + targets are clamped to the frontmost screen, and the position + actually achieved is returned so the caller can verify rather than + assume. + + `absolute_mode="raw"` emits a true absolute event instead of the + default read-then-delta approach; it exists so the two can be A/B'd + on real hardware and should not be needed in normal use. + """ + cfg = _resolve_input(fleet, target) + _check_delay(delay_ms) + + if (x is None) != (y is None): + raise InvalidParams("x and y must be given together") + if (x is None) == (dx is None and dy is None): + raise InvalidParams( + "provide either x+y (absolute) or dx/dy (relative)" + ) + if absolute_mode not in ("delta", "raw"): + raise InvalidParams('absolute_mode must be "delta" or "raw"') + if not 1 <= steps <= 64: + raise InvalidParams("steps must be between 1 and 64") + + params: dict[str, object] = { + "steps": steps, + "delay_ms": delay_ms if delay_ms is not None + else cfg.default_delay_ms, + } + if x is not None: + params["x"] = int(x) + params["y"] = int(y) # type: ignore[arg-type] + params["absolute_mode"] = absolute_mode + else: + if dx is not None: + params["dx"] = int(dx) + if dy is not None: + params["dy"] = int(dy) + + raw = await fleet.mcpd(target).request("input.mouse_move", params) + return MouseResult.model_validate( + {**raw, "target": target, "op": "mouse_move"} + ) + + +async def input_scroll( + fleet: Fleet, target: str, *, + clicks: int = 1, direction: str = "down", + delay_ms: int | None = None, +) -> MouseResult: + """Mouse wheel. No confirm — scrolling commits nothing.""" + cfg = _resolve_input(fleet, target) + _check_delay(delay_ms) + if not 1 <= clicks <= 32: + raise InvalidParams("clicks must be between 1 and 32") + if direction not in ("up", "down", "left", "right"): + raise InvalidParams( + 'direction must be "up", "down", "left" or "right"' + ) + + raw = await fleet.mcpd(target).request("input.scroll", { + "clicks": clicks, + "direction": direction, + "delay_ms": delay_ms if delay_ms is not None + else cfg.default_delay_ms, + }) + return MouseResult.model_validate( + {**raw, "target": target, "op": "scroll"} + ) + + +# ---- committing operations (confirm: true) ------------------------- + + +async def input_type( + fleet: Fleet, target: str, *, + text: str, keymap: str = "system", + delay_ms: int | None = None, confirm: bool = False, +) -> TypeResult: + """Type a string as keystrokes. Requires `confirm=True`. + + Layout-correct by default: the daemon maps each character through + `keymap.library` against the target's configured keymap. Pass + `keymap="us"` to force the built-in US table. + + ⚠️ The text is recorded in cleartext in the run archive. Do not + type credentials through this tool. + """ + _require_confirm(confirm, "type") + cfg = _resolve_input(fleet, target) + _check_delay(delay_ms) + text = _encode_text(text, cfg) + if keymap not in ("system", "us"): + raise InvalidParams('keymap must be "system" or "us"') + + raw = await fleet.mcpd(target).request("input.type", { + "text": text, + "keymap": keymap, + "delay_ms": delay_ms if delay_ms is not None + else cfg.default_delay_ms, + "confirm": True, + }) + return TypeResult.model_validate({ + "text_len": len(text), + **raw, + "target": target, + "op": "type", + "text_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), + }) + + +async def input_key( + fleet: Fleet, target: str, *, + keys: list[str] | None = None, chord: str | None = None, + delay_ms: int | None = None, + confirm: bool = False, confirm_reset: bool = False, +) -> KeyResult: + """Press a key or chord. Requires `confirm=True`. + + All entries but the last must be modifiers (`shift`, `ctrl`, + `alt`, `lamiga`, `ramiga`, `capslock`); the last is the key pressed + and released. Accepts `keys=["lamiga","q"]` or the equivalent + shorthand `chord="lamiga+q"`. + + `ctrl+lamiga+ramiga` reboots the machine and additionally requires + `confirm_reset=True`. + """ + _require_confirm(confirm, "key") + cfg = _resolve_input(fleet, target) + _check_delay(delay_ms) + resolved = normalise_keys(keys, chord) + + params: dict[str, object] = { + "keys": resolved, + "delay_ms": delay_ms if delay_ms is not None + else cfg.default_delay_ms, + "confirm": True, + } + # Only send the extra acknowledgement when it was actually given — + # the daemon re-checks the reset chord itself, so this must not be + # set unconditionally the way tools/debug.py does with `confirm`. + if confirm_reset: + params["confirm_reset"] = True + + raw = await fleet.mcpd(target).request("input.key", params) + return KeyResult.model_validate( + {**raw, "target": target, "op": "key", "keys": resolved} + ) + + +async def input_click( + fleet: Fleet, target: str, *, + button: str = "left", count: int = 1, + x: int | None = None, y: int | None = None, + delay_ms: int | None = None, confirm: bool = False, +) -> MouseResult: + """Click at the pointer, or at `x`/`y` if given. Requires + `confirm=True` — the click lands on whatever is under the pointer, + which you cannot know for certain without calling `input.state` + first.""" + _require_confirm(confirm, "click") + cfg = _resolve_input(fleet, target) + _check_delay(delay_ms) + if button not in ("left", "right", "middle"): + raise InvalidParams('button must be "left", "right" or "middle"') + if not 1 <= count <= 8: + raise InvalidParams("count must be between 1 and 8") + if (x is None) != (y is None): + raise InvalidParams("x and y must be given together") + + params: dict[str, object] = { + "button": button, + "count": count, + "delay_ms": delay_ms if delay_ms is not None + else cfg.default_delay_ms, + "confirm": True, + } + if x is not None: + params["x"] = int(x) + params["y"] = int(y) # type: ignore[arg-type] + + raw = await fleet.mcpd(target).request("input.click", params) + return MouseResult.model_validate( + {**raw, "target": target, "op": "click"} + ) + + +async def input_drag( + fleet: Fleet, target: str, *, + from_x: int, from_y: int, to_x: int, to_y: int, + button: str = "left", steps: int = 16, + delay_ms: int | None = None, confirm: bool = False, +) -> MouseResult: + """Press at `from_*`, move to `to_*`, release. Requires + `confirm=True`. + + The riskiest tool here — a drag can move, resize, or drag-to-trash. + The daemon always releases the button, even if the call aborts on a + budget overrun, so a failed drag cannot leave the machine with a + stuck mouse button. + + Can be disabled per target with `[targets..input] allow_drag = + false` while leaving the rest of the surface available. + """ + _require_confirm(confirm, "drag") + cfg = _resolve_input(fleet, target) + _check_delay(delay_ms) + if not cfg.allow_drag: + raise NotCapable( + "input.drag is disabled for this target " + "([targets..input] allow_drag = false)", + data={"target": target}, + ) + if button not in ("left", "right", "middle"): + raise InvalidParams('button must be "left", "right" or "middle"') + if not 1 <= steps <= 64: + raise InvalidParams("steps must be between 1 and 64") + + raw = await fleet.mcpd(target).request("input.drag", { + "from_x": int(from_x), "from_y": int(from_y), + "to_x": int(to_x), "to_y": int(to_y), + "button": button, "steps": steps, + "delay_ms": delay_ms if delay_ms is not None + else cfg.default_delay_ms, + "confirm": True, + }) + return MouseResult.model_validate( + {**raw, "target": target, "op": "drag"} + ) diff --git a/host/tests/unit/test_config.py b/host/tests/unit/test_config.py index ff8b679..dc579c3 100644 --- a/host/tests/unit/test_config.py +++ b/host/tests/unit/test_config.py @@ -68,3 +68,46 @@ def test_default_config_path_env(monkeypatch: pytest.MonkeyPatch, p = tmp_path / "alt.toml" monkeypatch.setenv("AMIGA_FLEET_CONFIG", str(p)) assert default_config_path() == p + + +def test_input_block_round_trips_from_toml(tmp_path: Path) -> None: + p = tmp_path / "c.toml" + p.write_text( + '[targets.x5000]\n' + 'type = "remote"\n' + '[targets.x5000.input]\n' + 'enabled = true\n' + 'max_text_len = 128\n' + 'allow_drag = false\n', + encoding="utf-8", + ) + t = load_config(p).targets["x5000"] + assert t.input is not None + assert t.input.enabled is True + assert t.input.max_text_len == 128 + assert t.input.allow_drag is False + + +def test_input_absent_by_default(tmp_path: Path) -> None: + """No [input] block means input injection is off -- pin it, so a + refactor cannot make it opt-out.""" + p = tmp_path / "c.toml" + p.write_text('[targets.x5000]\ntype = "remote"\n', encoding="utf-8") + assert load_config(p).targets["x5000"].input is None + + +def test_input_unknown_keys_ignored(tmp_path: Path) -> None: + """extra="ignore" means a typo silently vanishes -- documented + behaviour, pinned here so it is at least a known trap.""" + p = tmp_path / "c.toml" + p.write_text( + '[targets.x5000]\n' + 'type = "remote"\n' + '[targets.x5000.input]\n' + 'enabled = true\n' + 'enabledd = true\n', + encoding="utf-8", + ) + t = load_config(p).targets["x5000"] + assert t.input is not None + assert t.input.enabled is True diff --git a/host/tests/unit/test_input.py b/host/tests/unit/test_input.py new file mode 100644 index 0000000..ad90bc4 --- /dev/null +++ b/host/tests/unit/test_input.py @@ -0,0 +1,418 @@ +"""input.* tool tests with a fake MCPd transport. + +input.* is the only tool surface in this project that *writes* to the +target's UI, so the disabled-by-default behaviour is the thing most +worth pinning down. In priority order, this file verifies: + +- with no [input] config block at all, every tool raises NotCapable + and NOTHING reaches the wire +- with enabled=false, the same +- TargetConfig defaults to input=None, so a future refactor cannot + silently flip the default open +- a daemon-side NotCapable propagates even when host config is open -- + the host must not mask the real gate +- confirm gates on type / key / click / drag, and their absence on + mouse_move / scroll / state +- chord and keys spellings produce byte-identical wire params +- host-side caps and Latin-1 validation reject before any wire call + +No conftest.py in this repo -- fakes are defined per-module by +convention (see test_tools_with_fake_transport.py). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from amiga_fleet_mcp.config import ( + Config, + InputConfig, + McpdChannel, + TargetChannels, + TargetConfig, +) +from amiga_fleet_mcp.errors import InvalidParams, NotCapable +from amiga_fleet_mcp.fleet import Fleet +from amiga_fleet_mcp.tools import input as input_tool + + +class FakeMcpd: + """Stub MCPd transport: programmable per-method responses.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any] | None]] = [] + self.responses: dict[str, Any] = {} + self.errors: dict[str, Exception] = {} + + async def request(self, method: str, params: dict | None = None, + timeout_s: float = 30.0) -> Any: + self.calls.append((method, params)) + if method in self.errors: + raise self.errors[method] + return self.responses.get(method, {}) + + +def _fleet(*, input_block: InputConfig | None) -> tuple[Fleet, FakeMcpd]: + cfg = Config(targets={ + "x5000": TargetConfig( + type="remote", + channels=TargetChannels( + mcpd=McpdChannel(endpoint="192.168.0.10:4322"), + ), + input=input_block, + ), + }) + fleet = Fleet(cfg) + fake = FakeMcpd() + fleet._mcpd["x5000"] = fake # type: ignore[assignment] + return fleet, fake + + +def _enabled() -> tuple[Fleet, FakeMcpd]: + return _fleet(input_block=InputConfig(enabled=True)) + + +async def _call_all(fleet: Fleet) -> list[Exception]: + """Invoke every input.* tool with otherwise-valid arguments and + collect whatever each one raised.""" + raised: list[Exception] = [] + attempts = [ + lambda: input_tool.input_state(fleet, "x5000"), + lambda: input_tool.input_type( + fleet, "x5000", text="hi", confirm=True), + lambda: input_tool.input_key( + fleet, "x5000", keys=["lamiga", "q"], confirm=True), + lambda: input_tool.input_mouse_move(fleet, "x5000", dx=5, dy=5), + lambda: input_tool.input_click(fleet, "x5000", confirm=True), + lambda: input_tool.input_drag( + fleet, "x5000", from_x=0, from_y=0, to_x=10, to_y=10, + confirm=True), + lambda: input_tool.input_scroll(fleet, "x5000", clicks=1), + ] + for make in attempts: + try: + await make() + except Exception as exc: # collecting for assertions + raised.append(exc) + return raised + + +# ---- disabled by default -- the point of this file ------------------- + + +@pytest.mark.asyncio +async def test_absent_input_block_raises_not_capable() -> None: + """No [input] block at all: every tool refuses, nothing hits the wire.""" + fleet, fake = _fleet(input_block=None) + raised = await _call_all(fleet) + + assert len(raised) == 7 + assert all(isinstance(e, NotCapable) for e in raised) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_explicitly_disabled_raises_not_capable() -> None: + fleet, fake = _fleet(input_block=InputConfig(enabled=False)) + raised = await _call_all(fleet) + + assert len(raised) == 7 + assert all(isinstance(e, NotCapable) for e in raised) + assert fake.calls == [] + + +def test_target_config_defaults_to_no_input() -> None: + """Pin the default. If this ever fails, someone has made input + injection opt-out instead of opt-in.""" + assert TargetConfig(type="remote").input is None + + +def test_input_config_defaults_to_disabled() -> None: + """And if the block IS present but says nothing, it is still off.""" + assert InputConfig().enabled is False + + +@pytest.mark.asyncio +async def test_not_capable_error_names_both_gates() -> None: + """The error must mention the daemon gate too -- an operator who + only flipped the host switch would otherwise hit a confusing + -32003 on the very next call.""" + fleet, _ = _fleet(input_block=None) + with pytest.raises(NotCapable) as ei: + await input_tool.input_state(fleet, "x5000") + msg = str(ei.value) + assert "--enable-input" in msg + assert "ENABLE-INPUT" in msg + + +@pytest.mark.asyncio +async def test_daemon_gate_propagates_through_open_host_gate() -> None: + """Host config open, daemon says no. The host must surface that + rather than swallowing or rewriting it.""" + fleet, fake = _enabled() + fake.errors["input.state"] = NotCapable( + "input injection is disabled on this daemon", + data={"reason": "input_disabled"}, + ) + with pytest.raises(NotCapable): + await input_tool.input_state(fleet, "x5000") + assert [c[0] for c in fake.calls] == ["input.state"] + + +# ---- confirm gates --------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("call", ["type", "key", "click", "drag"]) +async def test_committing_tools_require_confirm(call: str) -> None: + fleet, fake = _enabled() + kwargs: dict[str, Any] = { + "type": {"text": "hi"}, + "key": {"keys": ["lamiga", "q"]}, + "click": {}, + "drag": {"from_x": 0, "from_y": 0, "to_x": 5, "to_y": 5}, + }[call] + + fn = getattr(input_tool, f"input_{call}") + with pytest.raises(InvalidParams): + await fn(fleet, "x5000", **kwargs) + + # A gate that raises only after hitting the wire is not a gate. + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_non_committing_tools_need_no_confirm() -> None: + fleet, fake = _enabled() + await input_tool.input_state(fleet, "x5000") + await input_tool.input_mouse_move(fleet, "x5000", dx=1, dy=1) + await input_tool.input_scroll(fleet, "x5000", clicks=2) + assert [c[0] for c in fake.calls] == [ + "input.state", "input.mouse_move", "input.scroll", + ] + + +# ---- wire shape ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_type_wire_params() -> None: + fleet, fake = _enabled() + await input_tool.input_type( + fleet, "x5000", text="hello", delay_ms=20, confirm=True) + assert fake.calls == [ + ("input.type", {"text": "hello", "keymap": "system", + "delay_ms": 20, "confirm": True}), + ] + + +@pytest.mark.asyncio +async def test_type_defaults_to_system_keymap() -> None: + """Layout-correct by default. If this ever flips to "us", non-US + targets silently receive wrong characters.""" + fleet, fake = _enabled() + await input_tool.input_type(fleet, "x5000", text="hi", confirm=True) + params = fake.calls[0][1] + assert params is not None + assert params["keymap"] == "system" + + +@pytest.mark.asyncio +async def test_type_us_keymap_forced() -> None: + fleet, fake = _enabled() + await input_tool.input_type( + fleet, "x5000", text="hi", keymap="us", confirm=True) + params = fake.calls[0][1] + assert params is not None + assert params["keymap"] == "us" + + +@pytest.mark.asyncio +async def test_type_rejects_unknown_keymap() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_type( + fleet, "x5000", text="hi", keymap="dvorak", confirm=True) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_chord_and_keys_produce_identical_params() -> None: + fleet_a, fake_a = _enabled() + fleet_b, fake_b = _enabled() + await input_tool.input_key( + fleet_a, "x5000", keys=["lamiga", "q"], confirm=True) + await input_tool.input_key( + fleet_b, "x5000", chord="lamiga+q", confirm=True) + assert fake_a.calls == fake_b.calls + + +@pytest.mark.asyncio +async def test_confirm_reset_only_sent_when_given() -> None: + """Must NOT be set unconditionally -- that is the bug tools/debug.py + has with `confirm`, and it would defeat the daemon's reset gate.""" + fleet, fake = _enabled() + await input_tool.input_key( + fleet, "x5000", keys=["ctrl", "lamiga", "ramiga"], confirm=True) + params = fake.calls[0][1] + assert params is not None + assert "confirm_reset" not in params + + +@pytest.mark.asyncio +async def test_confirm_reset_forwarded_when_given() -> None: + fleet, fake = _enabled() + await input_tool.input_key( + fleet, "x5000", keys=["ctrl", "lamiga", "ramiga"], + confirm=True, confirm_reset=True) + params = fake.calls[0][1] + assert params is not None + assert params["confirm_reset"] is True + + +@pytest.mark.asyncio +async def test_mouse_move_absolute_wire_params() -> None: + fleet, fake = _enabled() + await input_tool.input_mouse_move(fleet, "x5000", x=100, y=200) + method, params = fake.calls[0] + assert method == "input.mouse_move" + assert params is not None + assert params["x"] == 100 + assert params["y"] == 200 + assert params["absolute_mode"] == "delta" + + +# ---- host-side validation (rejects before the wire) ------------------ + + +@pytest.mark.asyncio +async def test_text_length_cap() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_type( + fleet, "x5000", text="x" * 513, confirm=True) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_latin1_text_accepted() -> None: + fleet, fake = _enabled() + await input_tool.input_type(fleet, "x5000", text="café", confirm=True) + assert len(fake.calls) == 1 + + +@pytest.mark.asyncio +async def test_non_latin1_text_rejected_naming_the_char() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams) as ei: + await input_tool.input_type(fleet, "x5000", text="日本語", confirm=True) + assert "日" in str(ei.value) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_empty_text_rejected() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_type(fleet, "x5000", text="", confirm=True) + assert fake.calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delay", [-1, 1001]) +async def test_delay_range(delay: int) -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_scroll(fleet, "x5000", delay_ms=delay) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_scroll_clicks_cap() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_scroll(fleet, "x5000", clicks=33) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_scroll_direction_validated() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_scroll(fleet, "x5000", direction="sideways") + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_mouse_move_requires_one_coordinate_form() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_mouse_move(fleet, "x5000") + with pytest.raises(InvalidParams): + await input_tool.input_mouse_move(fleet, "x5000", x=1, y=2, dx=3) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_click_button_validated() -> None: + fleet, fake = _enabled() + with pytest.raises(InvalidParams): + await input_tool.input_click( + fleet, "x5000", button="thumb", confirm=True) + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_allow_drag_false_blocks_only_drag() -> None: + fleet, fake = _fleet( + input_block=InputConfig(enabled=True, allow_drag=False)) + with pytest.raises(NotCapable): + await input_tool.input_drag( + fleet, "x5000", from_x=0, from_y=0, to_x=5, to_y=5, + confirm=True) + assert fake.calls == [] + + # ...but the rest of the surface still works. + await input_tool.input_click(fleet, "x5000", confirm=True) + assert [c[0] for c in fake.calls] == ["input.click"] + + +def test_normalise_keys_rejects_both_forms() -> None: + with pytest.raises(InvalidParams): + input_tool.normalise_keys(keys=["a"], chord="lamiga+q") + + +def test_normalise_keys_rejects_empty() -> None: + with pytest.raises(InvalidParams): + input_tool.normalise_keys() + + +def test_normalise_keys_caps_chord_length() -> None: + with pytest.raises(InvalidParams): + input_tool.normalise_keys(keys=["shift"] * 9) + + +# ---- result models --------------------------------------------------- + + +def test_type_result_validates_truncated_payload() -> None: + r = input_tool.TypeResult.model_validate({ + "target": "x5000", "op": "type", + "events": 256, "duration_ms": 20000, "truncated": True, + "text_len": 400, "chars_mapped": 128, + "unmapped": ["0xE9"], "keymap": "us", + "text_sha256": "deadbeef", + }) + assert r.truncated is True + assert r.unmapped == ["0xE9"] + + +def test_mouse_result_carries_achieved_position() -> None: + r = input_tool.MouseResult.model_validate({ + "target": "x5000", "op": "mouse_move", "mode": "absolute", + "events": 1, "duration_ms": 10, "truncated": False, + "x": 640, "y": 400, + }) + assert (r.x, r.y) == (640, 400) diff --git a/host/tests/unit/test_setup_wizard.py b/host/tests/unit/test_setup_wizard.py index d0a1cb8..a2d6b90 100644 --- a/host/tests/unit/test_setup_wizard.py +++ b/host/tests/unit/test_setup_wizard.py @@ -111,6 +111,7 @@ def test_interactive_qemu_target_round_trip(tmp_path): "4422", # mcpd port "y", "14422", # qmp on, port "n", # gdb off + "n", # enable input injection? -> no "n", # add another? # default_target picker is auto-set when only one target exists # defaults block @@ -151,6 +152,7 @@ def test_interactive_remote_with_mcu_cable(tmp_path): "4322", "y", # FTDI cable wired? "COM5", # MCU port + "n", # enable input injection? -> no "n", # add another? "n", # configure defaults ] @@ -166,6 +168,46 @@ def test_interactive_remote_with_mcu_cable(tmp_path): assert t.channels.mcu.baud == 38400 +def test_wizard_declining_input_emits_no_input_block(tmp_path): + """Saying no must leave the key out entirely -- an absent block is + the disabled state, so the wizard must never write one by accident.""" + answers = [ + "", "", "info", "stdio", + "", "", "", + "y", "x5000", "remote", "X5000", "real-hw", + "192.168.1.50", "4322", + "n", # FTDI cable? -> no + "n", # enable input injection? -> no + "n", # add another? + "n", # configure defaults + ] + out = tmp_path / "config.toml" + rc = run_init(out_path=out, force=True, prompt=ScriptedPrompt(answers)) + assert rc == 0 + raw = tomllib.loads(out.read_text(encoding="utf-8")) + assert "input" not in raw["targets"]["x5000"] + assert load_config(out).targets["x5000"].input is None + + +def test_wizard_accepting_input_emits_enabled_block(tmp_path): + answers = [ + "", "", "info", "stdio", + "", "", "", + "y", "x5000", "remote", "X5000", "real-hw", + "192.168.1.50", "4322", + "n", # FTDI cable? -> no + "y", # enable input injection? -> yes + "n", # add another? + "n", # configure defaults + ] + out = tmp_path / "config.toml" + rc = run_init(out_path=out, force=True, prompt=ScriptedPrompt(answers)) + assert rc == 0 + t = load_config(out).targets["x5000"] + assert t.input is not None + assert t.input.enabled is True + + def test_interactive_aborts_when_user_says_no_to_overwrite(tmp_path): out = tmp_path / "config.toml" out.write_text("# keep me\n", encoding="utf-8") diff --git a/mcpd/Makefile b/mcpd/Makefile index b8a342e..6fe84b0 100644 --- a/mcpd/Makefile +++ b/mcpd/Makefile @@ -58,6 +58,7 @@ SRCS = \ $(METHODS_DIR)/sys.c \ $(METHODS_DIR)/wb.c \ $(METHODS_DIR)/screen.c \ + $(METHODS_DIR)/input.c \ $(METHODS_DIR)/debug.c \ $(METHODS_DIR)/events.c \ $(METHODS_DIR)/hwres.c \ diff --git a/mcpd/install/MCPd-Disable-Input b/mcpd/install/MCPd-Disable-Input new file mode 100644 index 0000000..33ab8a9 --- /dev/null +++ b/mcpd/install/MCPd-Disable-Input @@ -0,0 +1,32 @@ +; MCPd-Disable-Input - revoke remote keyboard / mouse injection. +; +; Usage from a Shell on the target: +; Execute SYS:System/MCPd/MCPd-Disable-Input +; +; Removes the SYS:System/MCPd/ENABLE-INPUT sentinel. MCPd reads the +; gate only at startup, so a currently-running MCPd keeps whatever +; state it started with -- you MUST restart it for this to take +; effect. +; +; Note this does not undo a --enable-input flag on a running MCPd's +; command line. If MCPd was launched with that flag, remove it from +; wherever the launch happens (S:Network-Startup, or your Shell +; history) as well as running this script. + +FailAt 21 + +If EXISTS SYS:System/MCPd/ENABLE-INPUT + Delete >NIL: SYS:System/MCPd/ENABLE-INPUT + Echo "MCPd-Disable-Input: sentinel removed" +Else + Echo "MCPd-Disable-Input: sentinel was not present (already disabled)" +EndIf + +Echo "" +Echo "NOW RESTART MCPd - a running daemon keeps the gate state it" +Echo "started with. After the restart MCPd should print:" +Echo " 'MCPd: input injection disabled (input.* -> -32003).'" +Echo "" +Echo "Also check that MCPd is not being launched with --enable-input:" +Echo " Search S:Network-Startup \"enable-input\"" +Quit 0 diff --git a/mcpd/install/MCPd-Enable-Input b/mcpd/install/MCPd-Enable-Input new file mode 100644 index 0000000..f3416ce --- /dev/null +++ b/mcpd/install/MCPd-Enable-Input @@ -0,0 +1,58 @@ +; MCPd-Enable-Input - allow remote keyboard / mouse injection. +; +; Usage from a Shell on the target: +; Execute SYS:System/MCPd/MCPd-Enable-Input +; +; Creates the sentinel file SYS:System/MCPd/ENABLE-INPUT. MCPd reads +; it ONCE at startup, so you must restart MCPd afterwards. That is +; deliberate: because the gate is only ever read at startup, no RPC +; method can turn input injection on at runtime. Turning it on +; requires filesystem access to this machine. +; +; The --enable-input command-line flag does the same thing, but does +; NOT survive a restart: MCPd-Watchdog relaunches MCPd with no +; arguments, and the S:Network-Startup line added by MCPd-Install has +; none either. Use this script for a persistent setting. +; +; To turn it back off: Execute SYS:System/MCPd/MCPd-Disable-Input + +FailAt 21 + +If NOT EXISTS SYS:System/MCPd + Echo "MCPd-Enable-Input: SYS:System/MCPd not found - run MCPd-Install first" + Quit 20 +EndIf + +; NOTE: in AmigaDOS Echo, '*' is the ESCAPE character -- a literal +; asterisk must be written '**'. Using '=' for the rules avoids the +; whole problem and prints identically on every locale. +Echo "" +Echo "========================= WARNING =========================" +Echo "Enabling input injection lets ANY client that can reach this" +Echo "machine on TCP port 4322 type on the keyboard and move and" +Echo "click the mouse, as the logged-in user, in whatever window" +Echo "happens to be focused." +Echo "" +Echo "MCPd has NO authentication. The only thing protecting this" +Echo "machine is who can reach the port. Do not enable this on a" +Echo "target reachable from an untrusted network." +Echo "===========================================================" +Echo "" + +Echo >SYS:System/MCPd/ENABLE-INPUT "input injection enabled by MCPd-Enable-Input" + +If NOT EXISTS SYS:System/MCPd/ENABLE-INPUT + Echo "MCPd-Enable-Input: FAILED to create the sentinel file" + Quit 20 +EndIf + +Echo "MCPd-Enable-Input: sentinel created at SYS:System/MCPd/ENABLE-INPUT" +Echo "" +Echo "NOW RESTART MCPd - the gate is only read at startup." +Echo " If running under the watchdog: Break C, it relaunches." +Echo " Otherwise: Break the MCPd process and run it again." +Echo "" +; '******' renders as '***' -- see the escaping note above. +Echo "MCPd prints '****** INPUT INJECTION ENABLED ******' at startup when" +Echo "the gate is open. If you do not see that line, it is still off." +Quit 0 diff --git a/mcpd/install/MCPd-Install b/mcpd/install/MCPd-Install index ce71bc4..9ef6e3c 100644 --- a/mcpd/install/MCPd-Install +++ b/mcpd/install/MCPd-Install @@ -22,6 +22,17 @@ Copy MCPd SYS:System/MCPd/MCPd CLONE ; bits 0..3 (read/write/execute/delete = all allowed). Protect SYS:System/MCPd/MCPd +rwed +; Input-injection helper scripts. Copied but NOT run -- input +; injection stays off until the operator explicitly enables it. +If EXISTS MCPd-Enable-Input + Copy MCPd-Enable-Input SYS:System/MCPd/MCPd-Enable-Input CLONE + Protect SYS:System/MCPd/MCPd-Enable-Input +rwed +EndIf +If EXISTS MCPd-Disable-Input + Copy MCPd-Disable-Input SYS:System/MCPd/MCPd-Disable-Input CLONE + Protect SYS:System/MCPd/MCPd-Disable-Input +rwed +EndIf + If NOT EXISTS S:Network-Startup.before-mcpd Echo "MCPd-Install: backing up S:Network-Startup -> S:Network-Startup.before-mcpd" Copy S:Network-Startup S:Network-Startup.before-mcpd @@ -44,4 +55,8 @@ EndIf Echo "" Echo "MCPd-Install: done. Reboot to auto-launch MCPd on :4322." Echo " Or launch immediately: Run >NIL: C). The Break interrupts the Wait or the running ; MCPd, the script exits cleanly. +; +; NOTE: MCPd is relaunched below with NO ARGUMENTS. Any command-line +; flag -- notably --enable-input -- is therefore LOST on the first +; restart. For settings that must persist, use the file-based gate +; instead: SYS:System/MCPd/ENABLE-INPUT (see MCPd-Enable-Input). +; Do not "fix" this by adding flags here; the sentinel file is the +; supported mechanism and it keeps one source of truth. FailAt 99999 diff --git a/mcpd/src/main.c b/mcpd/src/main.c index 239d270..5a8cf48 100644 --- a/mcpd/src/main.c +++ b/mcpd/src/main.c @@ -27,7 +27,13 @@ extern void applib_register(void); extern void applib_shutdown(void); -#define MCPD_VERSION "1.3" +/* In methods/input.c. Reads the input-injection gate (--enable-input + * or the SYS:System/MCPd/ENABLE-INPUT sentinel) exactly once, before + * any connection Process exists, and prints the startup banner. */ +extern void input_init_gate(int cli_flag); + +/* Derived from rpc.h so there is exactly one place to bump. */ +#define MCPD_VERSION MCPD_VERSION_STR #define DEFAULT_PORT 4322 #define BACKLOG 4 @@ -374,14 +380,23 @@ static int spawn_client_worker(int client_fd) { static void usage(void) { IDOS->Printf( - "Usage: MCPd [--version] [--port N]\n" - " --version print version and exit\n" - " --port N listen on TCP port N (default %lu)\n", + "Usage: MCPd [--version] [--port N] [--enable-input]\n" + " --version print version and exit\n" + " --port N listen on TCP port N (default %lu)\n" + " --enable-input allow input.* (keyboard/mouse injection).\n" + " OFF by default. Anyone who can reach the\n" + " listener can then type and click on this\n" + " machine. NOTE: MCPd-Watchdog relaunches\n" + " without arguments, so this flag does NOT\n" + " survive a restart -- for a persistent\n" + " setting create SYS:System/MCPd/ENABLE-INPUT\n" + " (see MCPd-Enable-Input).\n", (unsigned long)DEFAULT_PORT); } int main(int argc, char **argv) { uint16_t port = DEFAULT_PORT; + int enable_input = 0; /* Suppress system requesters in the parent process too. The * listener loop itself doesn't usually touch DOS, but Discovery @@ -410,6 +425,10 @@ int main(int argc, char **argv) { port = (uint16_t)p; continue; } + if (strcmp(argv[i], "--enable-input") == 0) { + enable_input = 1; + continue; + } IDOS->Printf("MCPd: unknown argument: %s\n", argv[i]); usage(); return 64; @@ -467,6 +486,12 @@ int main(int argc, char **argv) { "(non-fatal)\n"); } + /* Resolve the input-injection gate ONCE, here, before the accept + * loop spawns any child Process. g_input_enabled is read-only + * from this point on, so the children see it without locking and + * no RPC can flip it at runtime. Prints its own banner. */ + input_init_gate(enable_input); + IDOS->Printf("MCPd %s listening on :%lu (Ctrl-C to stop)\n", MCPD_VERSION, (unsigned long)port); diff --git a/mcpd/src/methods/input.c b/mcpd/src/methods/input.c new file mode 100644 index 0000000..ae44fc5 --- /dev/null +++ b/mcpd/src/methods/input.c @@ -0,0 +1,1611 @@ +/* input.* method handlers - keyboard + mouse injection via input.device. + * + * ============================================================ + * THIS FILE IS THE ONLY PLACE IN MCPd THAT *WRITES* TO THE UI. + * ============================================================ + * + * Every other method in this daemon observes the machine or touches + * the filesystem. These methods type and click as the logged-in user, + * into whatever window happens to be focused. That is categorically + * more dangerous than the rest of the surface, so: + * + * input injection is DISABLED BY DEFAULT and must be switched on + * by a deliberate operator action ON THE AMIGA ITSELF. + * + * See "The gate" below. SECURITY.md documents the operator-facing + * side of this. + * + * + * The gate + * -------- + * Two enable sources, OR'd, both default off: + * + * 1. --enable-input on the MCPd command line. + * 2. The sentinel file SYS:System/MCPd/ENABLE-INPUT existing. + * + * The sentinel is the PRIMARY mechanism, and this is deliberate: + * MCPd-Watchdog relaunches MCPd with NO arguments, and MCPd-Install + * appends an argument-less launch line to S:Network-Startup. A + * CLI-flag-only gate would therefore be silently dropped on the first + * watchdog restart -- an operator who enabled input would find it + * mysteriously off later, which is the worst possible failure mode for + * a security control (it fails *open* in the operator's mental model + * even though it fails closed in fact). The sentinel survives restarts, + * and MCPd-Uninstall's `Delete SYS:System/MCPd ALL` removes it for free. + * + * The gate is read ONCE at startup, before the accept loop, into a + * static that is read-only thereafter. Two consequences, both wanted: + * + * - No locking is needed. The per-connection child Processes + * (CreateNewProcTags, main.c) share the parent's data segment, so + * they all see the same value. + * - There is NO RUNTIME TOGGLE. No RPC method can turn injection on. + * Enabling it requires filesystem access to the target and an MCPd + * restart. A client that has compromised the RPC surface cannot + * escalate into input injection. + * + * Host-side config ([targets..input] enabled) is a convenience gate + * only. Anything that can reach port 4322 bypasses the host entirely, + * so the daemon must not trust it. + * + * + * Conventions + * ----------- + * Follows the house style: open libraries/devices per call and drop + * them on exit (see wb.c), device I/O via AllocSysObjectTags with a + * `goto cleanup` unwind (see mcu.c:66-130). + */ + +#include "../rpc.h" +#include "methods.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +/* NewMouse wheel codes. These are the de-facto AmigaOS wheel standard + * that MUI and ReAction understand, delivered as IECLASS_RAWMOUSE + * codes. They are NOT reliably present in on + * every SDK revision -- some ship them only in a third-party + * newmouse.h -- so define them defensively rather than let a missing + * header break the build. */ +#ifndef NM_WHEEL_UP +#define NM_WHEEL_UP 0x7A +#endif +#ifndef NM_WHEEL_DOWN +#define NM_WHEEL_DOWN 0x7B +#endif +#ifndef NM_WHEEL_LEFT +#define NM_WHEEL_LEFT 0x7C +#endif +#ifndef NM_WHEEL_RIGHT +#define NM_WHEEL_RIGHT 0x7D +#endif + + +/* ---- caps ---------------------------------------------------------- * + * + * Enforced HERE, in the daemon. The host-side equivalents in + * tools/input.py are advisory only -- a raw JSON-RPC client bypasses + * them entirely, so these are the ones that matter. + */ +#define INPUT_MAX_TEXT 512 /* chars per input.type */ +#define INPUT_MAX_CHORD_KEYS 8 /* keys in one chord */ +#define INPUT_MAX_MOVE 4096 /* |dx| / |dy| per mouse_move */ +#define INPUT_MAX_DRAG_STEPS 64 +#define INPUT_MAX_SCROLL 32 /* wheel clicks per call */ +#define INPUT_MAX_DELAY_MS 1000 +#define INPUT_MAX_EVENTS 256 /* events per call */ + +/* Wall-clock ceiling per call. Without this, delay_ms=1000 with a + * 512-char string would pin a connection Process for eight minutes -- + * and since MCPd serialises requests per connection, that is a + * self-inflicted denial of service on the client's own channel. */ +#define INPUT_MAX_BUDGET_MS 20000 + +/* Default pacing. Injecting events back-to-back with no delay tends to + * get them coalesced or dropped -- Intuition and the input handler + * chain are not expecting a burst faster than a human hand. */ +#define INPUT_DEF_KEY_MS 15 /* between a key down and its up */ +#define INPUT_DEF_CHAR_MS 25 /* between characters */ +#define INPUT_DEF_MOVE_MS 10 /* between mouse motion steps */ +#define INPUT_DEF_BUTTON_MS 50 /* around press/release in a drag */ + + +/* Sentinel file. Lives beside the MCPd binary so MCPd-Uninstall's + * recursive delete takes it with everything else. */ +#define INPUT_SENTINEL "SYS:System/MCPd/ENABLE-INPUT" + +/* How to turn it on. Repeated in the error payload of every gated + * method so a client sees the remedy without reading the docs. */ +#define INPUT_ENABLE_HINT \ + "start MCPd with --enable-input, or create " INPUT_SENTINEL \ + " on the target and restart MCPd" + + +/* ---- the gate ----------------------------------------------------- * + * + * Written exactly once by input_init_gate() from main(), before any + * connection Process exists. Read-only afterwards -- do not add a + * setter. + */ +static int g_input_enabled = 0; + + +int input_is_enabled(void) { + return g_input_enabled; +} + + +/* Does the sentinel file exist? Uses a shared lock so we don't disturb + * anything else holding it. Any failure to Lock (missing file, missing + * volume, unreadable) reads as "not enabled" -- the gate fails closed. */ +static int _sentinel_present(void) { + BPTR lock = IDOS->Lock(INPUT_SENTINEL, SHARED_LOCK); + if (!lock) return 0; + IDOS->UnLock(lock); + return 1; +} + + +/* Call ONCE from main() before the accept loop. cli_flag is 1 if + * --enable-input was passed. Prints the startup banner. */ +void input_init_gate(int cli_flag) { + int sentinel = _sentinel_present(); + g_input_enabled = (cli_flag || sentinel) ? 1 : 0; + + if (g_input_enabled) { + /* Deliberately loud. An operator who enabled this by accident + * -- or an operator who inherited a machine someone else + * enabled it on -- should find out at boot, not by watching + * the pointer move on its own. */ + IDOS->Printf( + "MCPd: *** INPUT INJECTION ENABLED *** (%s)\n" + "MCPd: remote clients can type and click on this machine.\n", + cli_flag ? (sentinel ? "--enable-input + sentinel file" + : "--enable-input") + : "sentinel file"); + } else { + IDOS->Printf( + "MCPd: input injection disabled (input.* -> -32003).\n" + "MCPd: to enable: %s\n", INPUT_ENABLE_HINT); + } +} + + +/* First statement of every input.* handler. Returns -32003 with a + * machine-readable reason and the remedy. */ +#define INPUT_GATE() \ + do { \ + if (!input_is_enabled()) { \ + cJSON *_d = cJSON_CreateObject(); \ + if (_d) { \ + cJSON_AddStringToObject(_d, "reason", "input_disabled"); \ + cJSON_AddStringToObject(_d, "enable", INPUT_ENABLE_HINT);\ + } \ + *out_err = rpc_make_error(MCPD_ERR_NOTCAPABLE, \ + "input injection is disabled on this daemon", _d); \ + return 0; \ + } \ + } while (0) + + +/* ---- shared helpers ----------------------------------------------- */ + +/* Same treatment wb.c gives strings bound for JSON: printable ASCII + * only, everything else becomes '?'. Window titles are attacker- + * influenced (any app can set one), so they never reach the client raw. */ +static char *sanitize_str(const char *src, char *dst, size_t dst_cap) { + if (!src) src = ""; + size_t n = 0; + while (src[n] && n < dst_cap - 1) { + unsigned char c = (unsigned char)src[n]; + dst[n] = (c >= 0x20 && c < 0x7f) ? (char)c : '?'; + n++; + } + dst[n] = '\0'; + return dst; +} + + +/* Open intuition.library + IIntuition for the duration of one call. + * Mirrors wb.c's _obtain_intuition. */ +static struct IntuitionIFace *_obtain_intuition(struct Library **out_base, + cJSON **out_err) { + struct Library *base = IExec->OpenLibrary("intuition.library", 50); + if (!base) { + if (out_err && !*out_err) + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "OpenLibrary intuition.library v50 failed", + NULL); + return NULL; + } + struct IntuitionIFace *iface = (struct IntuitionIFace *) + IExec->GetInterface(base, "main", 1, NULL); + if (!iface) { + IExec->CloseLibrary(base); + if (out_err && !*out_err) + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "GetInterface(intuition main) failed", + NULL); + return NULL; + } + *out_base = base; + return iface; +} + + +static void _release_intuition(struct IntuitionIFace *iface, + struct Library *base) { + if (iface) IExec->DropInterface((struct Interface *)iface); + if (base) IExec->CloseLibrary(base); +} + + +/* ---- rawkey tables ------------------------------------------------- * + * + * The standard Amiga rawkey matrix. These codes are positional (they + * identify a physical key, not a character), which is why typing a + * *character* needs a keymap -- see _ascii_to_raw() below. + */ + +/* Modifier keys. */ +#define RAW_LSHIFT 0x60 +#define RAW_RSHIFT 0x61 +#define RAW_CAPSLOCK 0x62 +#define RAW_CTRL 0x63 +#define RAW_LALT 0x64 +#define RAW_RALT 0x65 +#define RAW_LAMIGA 0x66 +#define RAW_RAMIGA 0x67 + +typedef struct { const char *name; uint8_t code; uint16_t qual; } named_key; + +/* Named keys accepted by input.key. `qual` is non-zero only for the + * modifiers -- pressing one both emits its rawkey event AND contributes + * its qualifier bit to subsequent events in the chord. */ +static const named_key g_named_keys[] = { + /* modifiers */ + { "lshift", RAW_LSHIFT, IEQUALIFIER_LSHIFT }, + { "rshift", RAW_RSHIFT, IEQUALIFIER_RSHIFT }, + { "shift", RAW_LSHIFT, IEQUALIFIER_LSHIFT }, + { "capslock", RAW_CAPSLOCK, IEQUALIFIER_CAPSLOCK }, + { "ctrl", RAW_CTRL, IEQUALIFIER_CONTROL }, + { "control", RAW_CTRL, IEQUALIFIER_CONTROL }, + { "lalt", RAW_LALT, IEQUALIFIER_LALT }, + { "ralt", RAW_RALT, IEQUALIFIER_RALT }, + { "alt", RAW_LALT, IEQUALIFIER_LALT }, + { "lamiga", RAW_LAMIGA, IEQUALIFIER_LCOMMAND }, + { "lcommand", RAW_LAMIGA, IEQUALIFIER_LCOMMAND }, + { "ramiga", RAW_RAMIGA, IEQUALIFIER_RCOMMAND }, + { "rcommand", RAW_RAMIGA, IEQUALIFIER_RCOMMAND }, + { "amiga", RAW_LAMIGA, IEQUALIFIER_LCOMMAND }, + + /* editing / navigation */ + { "space", 0x40, 0 }, + { "backspace", 0x41, 0 }, + { "tab", 0x42, 0 }, + { "return", 0x44, 0 }, + { "enter", 0x44, 0 }, + { "esc", 0x45, 0 }, + { "escape", 0x45, 0 }, + { "del", 0x46, 0 }, + { "delete", 0x46, 0 }, + { "help", 0x5F, 0 }, + { "up", 0x4C, 0 }, + { "down", 0x4D, 0 }, + { "right", 0x4E, 0 }, + { "left", 0x4F, 0 }, + + /* function keys. F1..F10 are the classic Amiga block and are + * solid. F11/F12 are a PC-keyboard extension; the codes below are + * the commonly-used AOS4 values but are NOT verified on hardware + * here -- see the FLAGGED list in the file header. */ + { "f1", 0x50, 0 }, { "f2", 0x51, 0 }, { "f3", 0x52, 0 }, + { "f4", 0x53, 0 }, { "f5", 0x54, 0 }, { "f6", 0x55, 0 }, + { "f7", 0x56, 0 }, { "f8", 0x57, 0 }, { "f9", 0x58, 0 }, + { "f10", 0x59, 0 }, + + /* numeric keypad */ + { "kp0", 0x0F, 0 }, { "kp1", 0x1D, 0 }, { "kp2", 0x1E, 0 }, + { "kp3", 0x1F, 0 }, { "kp4", 0x2D, 0 }, { "kp5", 0x2E, 0 }, + { "kp6", 0x2F, 0 }, { "kp7", 0x3D, 0 }, { "kp8", 0x3E, 0 }, + { "kp9", 0x3F, 0 }, + { "kpdot", 0x3C, 0 }, { "kpenter", 0x43, 0 }, + { "kpminus", 0x4A, 0 }, { "kpplus", 0x5E, 0 }, + { "kpmul", 0x5D, 0 }, { "kpdiv", 0x5C, 0 }, + { "kplparen", 0x5A, 0 }, { "kprparen", 0x5B, 0 }, + + { NULL, 0, 0 } +}; + + +/* ASCII -> (rawkey, needs-shift) for the US layout. + * + * ------------------------------------------------------------------ + * LIMITATION: this is a US keymap, hardcoded. On a machine configured + * for a different layout (German, French, Swedish, ...) the CHARACTERS + * produced by input.type will be wrong -- a German keymap will turn + * 'y' into 'z', and the symbol keys land in entirely different places. + * + * The correct fix is keymap.library's MapANSI() against the system + * default keymap, which is layout-aware. That is deliberately NOT + * implemented here: the exact MapANSI buffer layout (bytes per + * character, and whether the second byte is a qualifier or a qualifier + * index) must be read out of SDK:Documentation/Autodocs/keymap.doc on + * a real target first, and shipping a speculative, never-executed + * implementation as the default typing path would be worse than + * shipping a known-limited one. + * + * Until then: input.type is US-layout only, and input.key with + * explicit named keys is the layout-independent escape hatch. + * See the `keymap` parameter, which rejects "system" explicitly rather + * than silently doing the wrong thing. + * ------------------------------------------------------------------ + */ +typedef struct { uint8_t code; uint8_t shift; } ascii_key; + +#define NOKEY 0xFF + +static const ascii_key g_us_ascii[96] = { + /* 0x20 */ {0x40,0}, {0x01,1}, {0x2A,1}, {0x03,1}, + /* $%&' */ {0x04,1}, {0x05,1}, {0x07,1}, {0x2A,0}, + /* ()*+ */ {0x09,1}, {0x0A,1}, {0x08,1}, {0x0C,1}, + /* ,-./ */ {0x38,0}, {0x0B,0}, {0x39,0}, {0x3A,0}, + /* 0123 */ {0x0A,0}, {0x01,0}, {0x02,0}, {0x03,0}, + /* 4567 */ {0x04,0}, {0x05,0}, {0x06,0}, {0x07,0}, + /* 89:; */ {0x08,0}, {0x09,0}, {0x29,1}, {0x29,0}, + /* <=>? */ {0x38,1}, {0x0C,0}, {0x39,1}, {0x3A,1}, + /* @ABC */ {0x02,1}, {0x20,1}, {0x35,1}, {0x33,1}, + /* DEFG */ {0x22,1}, {0x12,1}, {0x23,1}, {0x24,1}, + /* HIJK */ {0x25,1}, {0x17,1}, {0x26,1}, {0x27,1}, + /* LMNO */ {0x28,1}, {0x37,1}, {0x36,1}, {0x18,1}, + /* PQRS */ {0x19,1}, {0x10,1}, {0x13,1}, {0x21,1}, + /* TUVW */ {0x14,1}, {0x16,1}, {0x34,1}, {0x11,1}, + /* XYZ[ */ {0x32,1}, {0x15,1}, {0x31,1}, {0x1A,0}, + /* \]^_ */ {0x0D,0}, {0x1B,0}, {0x06,1}, {0x0B,1}, + /* `abc */ {0x00,0}, {0x20,0}, {0x35,0}, {0x33,0}, + /* defg */ {0x22,0}, {0x12,0}, {0x23,0}, {0x24,0}, + /* hijk */ {0x25,0}, {0x17,0}, {0x26,0}, {0x27,0}, + /* lmno */ {0x28,0}, {0x37,0}, {0x36,0}, {0x18,0}, + /* pqrs */ {0x19,0}, {0x10,0}, {0x13,0}, {0x21,0}, + /* tuvw */ {0x14,0}, {0x16,0}, {0x34,0}, {0x11,0}, + /* xyz{ */ {0x32,0}, {0x15,0}, {0x31,0}, {0x1A,1}, + /* |}~ */ {0x0D,1}, {0x1B,1}, {0x00,1}, {NOKEY,0}, +}; + +/* Newline and tab are the two control characters worth honouring in a + * typed string; everything else non-printable is rejected. */ +static int _ascii_to_raw(unsigned char c, uint8_t *out_code, int *out_shift) { + if (c == '\n' || c == '\r') { *out_code = 0x44; *out_shift = 0; return 1; } + if (c == '\t') { *out_code = 0x42; *out_shift = 0; return 1; } + if (c < 0x20 || c > 0x7E) return 0; + const ascii_key *k = &g_us_ascii[c - 0x20]; + if (k->code == NOKEY) return 0; + *out_code = k->code; + *out_shift = k->shift; + return 1; +} + + +static const named_key *_find_named_key(const char *name) { + for (const named_key *k = g_named_keys; k->name != NULL; k++) { + /* Case-insensitive without depending on locale-sensitive + * tolower() from clib4. */ + const char *a = k->name, *b = name; + while (*a && *b) { + char ca = *a, cb = *b; + if (cb >= 'A' && cb <= 'Z') cb = (char)(cb - 'A' + 'a'); + if (ca != cb) break; + a++; b++; + } + if (*a == '\0' && *b == '\0') return k; + } + return NULL; +} + + +/* ---- injection context -------------------------------------------- */ + +typedef struct { + struct MsgPort *port; + struct IOStdReq *io; + int io_open; + + struct MsgPort *tport; + struct TimeRequest *tio; + int tio_open; + struct TimerIFace *itimer; + + /* keymap.library, for layout-correct typing. NULL if it could not + * be opened, in which case input.type falls back to the built-in + * US table (and says so in the result's `keymap` field). */ + struct Library *kbase; + struct KeymapIFace *ikeymap; + + /* The event buffer. ONE allocation per call, reused for every + * event, in PUBLIC memory (MEMF_SHARED) because IND_WRITEEVENT + * hands the pointer down the whole input-handler chain -- Intuition, + * Commodities, anything else installed. A stack buffer here would + * be a use-after-scope waiting to happen. Freed only in + * _inject_close(), after a settle delay. */ + struct InputEvent *ev; + + /* State we are responsible for undoing. See _inject_close(). */ + uint8_t held_mods[INPUT_MAX_CHORD_KEYS]; + int n_held_mods; + uint16_t held_quals; + uint16_t held_buttons; /* IEQUALIFIER_*BUTTON bits */ + uint8_t held_button_code; /* IECODE_LBUTTON etc, 0 if none */ + + int events; + int spent_ms; + int truncated; +} inject_ctx; + + +static void _delay_ms(inject_ctx *c, int ms) { + if (ms <= 0 || !c->tio_open) return; + c->tio->Request.io_Command = TR_ADDREQUEST; + c->tio->Time.Seconds = (uint32)(ms / 1000); + c->tio->Time.Microseconds = (uint32)((ms % 1000) * 1000); + IExec->DoIO((struct IORequest *)c->tio); + c->spent_ms += ms; +} + + +/* Emit whatever is currently in c->ev. Returns 0 on success, -1 if a + * budget was hit (caller must stop; ctx is marked truncated). */ +static int _write_event(inject_ctx *c) { + if (c->events >= INPUT_MAX_EVENTS || c->spent_ms >= INPUT_MAX_BUDGET_MS) { + c->truncated = 1; + return -1; + } + + /* Intuition uses the timestamp for double-click detection and key + * repeat. Leaving it zeroed makes every click look simultaneous, + * which turns a sequence of single clicks into an accidental + * multi-click. */ + if (c->itimer) c->itimer->GetSysTime(&c->ev->ie_TimeStamp); + + c->ev->ie_NextEvent = NULL; + c->io->io_Command = IND_WRITEEVENT; + c->io->io_Data = c->ev; + c->io->io_Length = sizeof(struct InputEvent); + IExec->DoIO((struct IORequest *)c->io); + + c->events++; + return 0; +} + + +static void _ev_reset(inject_ctx *c) { + memset(c->ev, 0, sizeof(struct InputEvent)); +} + + +/* Press or release one rawkey. `qual` is the full qualifier state that + * should accompany the event (held modifiers + any extras). */ +static int _key_event(inject_ctx *c, uint8_t code, int up, uint16_t qual) { + _ev_reset(c); + c->ev->ie_Class = IECLASS_RAWKEY; + c->ev->ie_Code = up ? (UWORD)(code | IECODE_UP_PREFIX) : (UWORD)code; + c->ev->ie_Qualifier = qual; + return _write_event(c); +} + + +/* Press a modifier and remember it, so _inject_close() can let go of it + * even if we bail out early. */ +static int _push_modifier(inject_ctx *c, const named_key *k) { + if (c->n_held_mods >= INPUT_MAX_CHORD_KEYS) return -1; + if (_key_event(c, k->code, 0, c->held_quals) != 0) return -1; + c->held_quals |= k->qual; + c->held_mods[c->n_held_mods++] = k->code; + return 0; +} + + +/* Release held modifiers in reverse order. Best-effort: this runs on + * the failure path too, so it must not itself be able to fail out. */ +static void _release_modifiers(inject_ctx *c) { + while (c->n_held_mods > 0) { + uint8_t code = c->held_mods[--c->n_held_mods]; + /* Bypass _write_event's budget check -- releasing a stuck key + * is exactly what we must still do once the budget is blown. */ + _ev_reset(c); + c->ev->ie_Class = IECLASS_RAWKEY; + c->ev->ie_Code = (UWORD)(code | IECODE_UP_PREFIX); + c->ev->ie_Qualifier = c->held_quals; + c->ev->ie_NextEvent = NULL; + if (c->itimer) c->itimer->GetSysTime(&c->ev->ie_TimeStamp); + c->io->io_Command = IND_WRITEEVENT; + c->io->io_Data = c->ev; + c->io->io_Length = sizeof(struct InputEvent); + IExec->DoIO((struct IORequest *)c->io); + } + c->held_quals = 0; +} + + +static void _release_buttons(inject_ctx *c) { + if (!c->held_button_code) return; + _ev_reset(c); + c->ev->ie_Class = IECLASS_RAWMOUSE; + c->ev->ie_Code = (UWORD)(c->held_button_code | IECODE_UP_PREFIX); + c->ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c->ev->ie_X = 0; + c->ev->ie_Y = 0; + c->ev->ie_NextEvent = NULL; + if (c->itimer) c->itimer->GetSysTime(&c->ev->ie_TimeStamp); + c->io->io_Command = IND_WRITEEVENT; + c->io->io_Data = c->ev; + c->io->io_Length = sizeof(struct InputEvent); + IExec->DoIO((struct IORequest *)c->io); + c->held_button_code = 0; + c->held_buttons = 0; +} + + +static int _inject_open(inject_ctx *c, cJSON **out_err) { + memset(c, 0, sizeof(*c)); + + c->port = (struct MsgPort *)IExec->AllocSysObjectTags(ASOT_PORT, TAG_END); + c->tport = (struct MsgPort *)IExec->AllocSysObjectTags(ASOT_PORT, TAG_END); + if (!c->port || !c->tport) { + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "AllocSysObject(MsgPort) failed", NULL); + return -1; + } + + c->io = (struct IOStdReq *)IExec->AllocSysObjectTags(ASOT_IOREQUEST, + ASOIOR_Size, sizeof(struct IOStdReq), + ASOIOR_ReplyPort, c->port, + TAG_END); + c->tio = (struct TimeRequest *)IExec->AllocSysObjectTags(ASOT_IOREQUEST, + ASOIOR_Size, sizeof(struct TimeRequest), + ASOIOR_ReplyPort, c->tport, + TAG_END); + if (!c->io || !c->tio) { + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "AllocSysObject(IORequest) failed", NULL); + return -1; + } + + /* input.device is always present on a running AOS4 system, so a + * failure here means something is badly wrong -- INTERNAL, not + * NOTCAPABLE. NOTCAPABLE is reserved for the gate, so the host can + * tell "switched off" apart from "broken". */ + if (IExec->OpenDevice("input.device", 0, + (struct IORequest *)c->io, 0) != 0) { + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "OpenDevice(input.device) failed", NULL); + return -1; + } + c->io_open = 1; + + /* UNIT_MICROHZ, not UNIT_VBLANK: IDOS->Delay() and the VBLANK unit + * are 20ms-granular, which is far too coarse for the 10-25ms + * inter-event pacing this code depends on. */ + if (IExec->OpenDevice("timer.device", UNIT_MICROHZ, + (struct IORequest *)c->tio, 0) == 0) { + c->tio_open = 1; + c->itimer = (struct TimerIFace *)IExec->GetInterface( + (struct Library *)c->tio->Request.io_Device, "main", 1, NULL); + } + /* Timer is best-effort: without it we lose pacing and timestamps + * but can still inject. Not worth failing the whole call. */ + + /* keymap.library is best-effort: without it input.type falls back + * to the built-in US table, which is wrong on a non-US layout but + * better than refusing to type at all. The result reports which + * path was actually used. */ + c->kbase = IExec->OpenLibrary("keymap.library", 53); + if (c->kbase) { + c->ikeymap = (struct KeymapIFace *) + IExec->GetInterface(c->kbase, "main", 1, NULL); + if (!c->ikeymap) { + IExec->CloseLibrary(c->kbase); + c->kbase = NULL; + } + } + + c->ev = (struct InputEvent *)IExec->AllocVecTags( + sizeof(struct InputEvent), + AVT_Type, MEMF_SHARED, + AVT_ClearWithValue, 0, + TAG_END); + if (!c->ev) { + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "AllocVecTags(InputEvent, MEMF_SHARED) failed", NULL); + return -1; + } + + return 0; +} + + +/* Tear down. MUST be reached on every path, including errors. + * + * The release-held-state step is the important part. A call that dies + * halfway through input.drag with the left button still down, or + * halfway through a chord with Ctrl still held, leaves the machine + * effectively unusable until someone walks over and touches the real + * mouse -- on an unattended lab box that means a trip to the machine + * room. This is the single most likely way this feature ruins + * somebody's afternoon, so it is unconditional. */ +static void _inject_close(inject_ctx *c) { + if (c->io_open && c->ev) { + _release_buttons(c); + _release_modifiers(c); + /* Settle before freeing the event buffer: the pointer has just + * been handed down the input-handler chain. */ + _delay_ms(c, 20); + } + + if (c->ikeymap) IExec->DropInterface((struct Interface *)c->ikeymap); + if (c->kbase) IExec->CloseLibrary(c->kbase); + if (c->itimer) IExec->DropInterface((struct Interface *)c->itimer); + if (c->tio_open) IExec->CloseDevice((struct IORequest *)c->tio); + if (c->io_open) IExec->CloseDevice((struct IORequest *)c->io); + if (c->tio) IExec->FreeSysObject(ASOT_IOREQUEST, c->tio); + if (c->io) IExec->FreeSysObject(ASOT_IOREQUEST, c->io); + if (c->tport) IExec->FreeSysObject(ASOT_PORT, c->tport); + if (c->port) IExec->FreeSysObject(ASOT_PORT, c->port); + if (c->ev) IExec->FreeVec(c->ev); + + /* Clear the handles so a double close is harmless -- but PRESERVE + * the statistics. Callers build their JSON result after closing + * (mouse_move deliberately re-reads the pointer only once the + * events have settled), so a blanket memset here silently zeroed + * every reported `events` / `duration_ms` and, worse, always + * reported truncated:false -- making a budget overrun look like a + * clean run. */ + int events = c->events; + int spent = c->spent_ms; + int trunc = c->truncated; + memset(c, 0, sizeof(*c)); + c->events = events; + c->spent_ms = spent; + c->truncated = trunc; +} + + +/* Common tail: attach the bookkeeping every input.* result carries. */ +static void _add_common(cJSON *r, inject_ctx *c) { + cJSON_AddNumberToObject(r, "events", (double)c->events); + cJSON_AddNumberToObject(r, "duration_ms", (double)c->spent_ms); + cJSON_AddBoolToObject(r, "truncated", c->truncated ? 1 : 0); +} + + +/* Shared param validation for delay_ms. */ +static int _get_delay(cJSON *params, int dflt, int *out, cJSON **out_err) { + long long d = p_int(params, "delay_ms", dflt); + if (d < 0 || d > INPUT_MAX_DELAY_MS) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "delay_ms out of range [0..1000]", NULL); + return -1; + } + *out = (int)d; + return 0; +} + + +/* Require confirm:true. Same accidental-fire guard as sys.cold_reboot + * and sys.mcu_cmd cmd="s" -- defence in depth against raw JSON-RPC + * callers that bypass the typed host wrapper. NOT authentication: a + * connected client can always pass it. The real control is the gate. */ +static int _require_confirm(cJSON *params, const char *what, cJSON **out_err) { + cJSON *confirm = cJSON_GetObjectItemCaseSensitive(params, "confirm"); + if (!cJSON_IsBool(confirm) || !cJSON_IsTrue(confirm)) { + char msg[128]; + snprintf(msg, sizeof(msg), + "%s requires confirm:true (injects real input)", what); + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, msg, NULL); + return -1; + } + return 0; +} + + +/* Read the frontmost screen's pointer position and dimensions. + * Returns 0 on success. Opens/closes intuition itself. */ +static int _pointer_pos(int *x, int *y, int *w, int *h, cJSON **out_err) { + struct Library *ibase = NULL; + struct IntuitionIFace *ii = _obtain_intuition(&ibase, out_err); + if (!ii) return -1; + + ULONG lock = ii->LockIBase(0); + struct IntuitionBase *ib = (struct IntuitionBase *)ibase; + struct Screen *s = ib->FirstScreen; + int ok = 0; + if (s) { + *x = s->MouseX; *y = s->MouseY; + *w = s->Width; *h = s->Height; + ok = 1; + } + ii->UnlockIBase(lock); + _release_intuition(ii, ibase); + + if (!ok) { + *out_err = rpc_make_error(MCPD_ERR_INTERNAL, + "no screen open; cannot determine pointer position", NULL); + return -1; + } + return 0; +} + + +/* ---- input.state -------------------------------------------------- * + * + * Read-only. Gated like the rest so that a disabled daemon gives one + * consistent answer everywhere rather than leaking UI state through a + * side door. + * + * This is the "look before you click" method: an agent calls it to + * learn where the pointer is and what is focused BEFORE committing to + * an input.click. That is a far more useful safety property than an + * idle-detection heuristic would be, and unlike idle detection it is + * implementable with supported APIs. + * + * Deliberately NOT included: "seconds since last real user input". + * There is no supported AOS4 API for it short of MCPd installing its + * own commodities input handler, which is a much larger change with + * its own risks. Omitted rather than approximated. + */ +int input_state(cJSON *params, cJSON **out_result, cJSON **out_err) { + (void)params; + INPUT_GATE(); + + struct Library *ibase = NULL; + struct IntuitionIFace *ii = _obtain_intuition(&ibase, out_err); + if (!ii) return 0; + + cJSON *r = cJSON_CreateObject(); + char buf[160]; + + ULONG lock = ii->LockIBase(0); + struct IntuitionBase *ib = (struct IntuitionBase *)ibase; + struct Screen *front = ib->FirstScreen; + struct Screen *active = ib->ActiveScreen; + struct Window *aw = ib->ActiveWindow; + + if (front) { + /* Pointer position is per-screen and lives on the frontmost + * screen -- the same one a synthetic mouse event will act on. */ + cJSON_AddNumberToObject(r, "pointer_x", (double)front->MouseX); + cJSON_AddNumberToObject(r, "pointer_y", (double)front->MouseY); + cJSON_AddNumberToObject(r, "screen_width", (double)front->Width); + cJSON_AddNumberToObject(r, "screen_height", (double)front->Height); + cJSON_AddStringToObject(r, "frontmost_screen", + sanitize_str((const char *)front->Title, buf, sizeof(buf))); + } else { + cJSON_AddNullToObject(r, "pointer_x"); + cJSON_AddNullToObject(r, "pointer_y"); + cJSON_AddNullToObject(r, "screen_width"); + cJSON_AddNullToObject(r, "screen_height"); + cJSON_AddNullToObject(r, "frontmost_screen"); + } + + if (active) { + cJSON_AddStringToObject(r, "active_screen", + sanitize_str((const char *)active->Title, buf, sizeof(buf))); + } else { + cJSON_AddNullToObject(r, "active_screen"); + } + + if (aw) { + cJSON_AddStringToObject(r, "active_window", + sanitize_str((const char *)aw->Title, buf, sizeof(buf))); + cJSON_AddNumberToObject(r, "active_window_left", (double)aw->LeftEdge); + cJSON_AddNumberToObject(r, "active_window_top", (double)aw->TopEdge); + cJSON_AddNumberToObject(r, "active_window_width", (double)aw->Width); + cJSON_AddNumberToObject(r, "active_window_height", (double)aw->Height); + } else { + cJSON_AddNullToObject(r, "active_window"); + } + + ii->UnlockIBase(lock); + _release_intuition(ii, ibase); + + /* Always true here -- INPUT_GATE() would have returned otherwise. + * Present so the field exists unconditionally in the schema and a + * client can read one shape regardless of how it got the object. */ + cJSON_AddBoolToObject(r, "enabled", 1); + + *out_result = r; + return 0; +} + + +/* ---- input.key ----------------------------------------------------- * + * + * Chord injection. params: + * keys : ["lamiga", "q"] -- all but the last are modifiers held + * down; the last is pressed+released + * delay_ms : optional pacing + * confirm : REQUIRED + * + * The host normalises the "lamiga+q" string shorthand into `keys` + * before it gets here, so the daemon implements exactly one form. + * + * Unlike input.type, this emits faithful modifier down/up events rather + * than just setting the qualifier -- chords are precisely where + * applications watch modifier keys directly (menu shortcuts, Amiga-Q), + * so fidelity matters more than event economy. + */ +int input_key(cJSON *params, cJSON **out_result, cJSON **out_err) { + INPUT_GATE(); + if (_require_confirm(params, "input.key", out_err) != 0) return 0; + + cJSON *keys = cJSON_GetObjectItemCaseSensitive(params, "keys"); + if (!cJSON_IsArray(keys)) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "keys must be an array of key names, e.g. [\"lamiga\",\"q\"]", + NULL); + return 0; + } + int n = cJSON_GetArraySize(keys); + if (n < 1 || n > INPUT_MAX_CHORD_KEYS) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "keys must contain 1..8 entries", NULL); + return 0; + } + + int delay; + if (_get_delay(params, INPUT_DEF_KEY_MS, &delay, out_err) != 0) return 0; + + /* Resolve every key BEFORE opening the device, so a typo'd key name + * fails cleanly without having injected half a chord. */ + const named_key *resolved[INPUT_MAX_CHORD_KEYS]; + uint8_t final_code = 0; + int final_shift = 0; + for (int i = 0; i < n; i++) { + cJSON *e = cJSON_GetArrayItem(keys, i); + if (!cJSON_IsString(e) || !e->valuestring) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "keys entries must be strings", NULL); + return 0; + } + const char *name = e->valuestring; + const named_key *k = _find_named_key(name); + + if (i < n - 1) { + /* Everything but the last must be a modifier -- otherwise + * "keys" would silently mean something different from what + * the caller drew in their head. */ + if (!k || k->qual == 0) { + char msg[160]; + snprintf(msg, sizeof(msg), + "keys[%d] (\"%s\") must be a modifier " + "(shift/ctrl/alt/lamiga/ramiga/capslock); only the " + "last entry may be a regular key", i, name); + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, msg, NULL); + return 0; + } + resolved[i] = k; + } else { + if (k) { + final_code = k->code; + final_shift = 0; + } else if (name[0] && name[1] == '\0' + && _ascii_to_raw((unsigned char)name[0], + &final_code, &final_shift)) { + /* Single printable character -- route through the same + * table input.type uses so ["lamiga","q"] works. */ + } else { + char msg[160]; + snprintf(msg, sizeof(msg), + "unknown key name \"%s\" (expected a named key such " + "as f1/esc/return/up, or a single character)", name); + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, msg, NULL); + return 0; + } + } + } + + /* Ctrl-Amiga-Amiga is the AmigaOS three-finger salute: it resets + * the machine. Reachable through this API by construction, so it + * gets its own explicit acknowledgement rather than riding on the + * generic confirm. */ + { + int has_ctrl = 0, has_lamiga = 0, has_ramiga = 0; + for (int i = 0; i < n - 1; i++) { + if (resolved[i]->code == RAW_CTRL) has_ctrl = 1; + if (resolved[i]->code == RAW_LAMIGA) has_lamiga = 1; + if (resolved[i]->code == RAW_RAMIGA) has_ramiga = 1; + } + if (final_code == RAW_CTRL) has_ctrl = 1; + if (final_code == RAW_LAMIGA) has_lamiga = 1; + if (final_code == RAW_RAMIGA) has_ramiga = 1; + + if (has_ctrl && has_lamiga && has_ramiga) { + cJSON *cr = cJSON_GetObjectItemCaseSensitive(params, + "confirm_reset"); + if (!cJSON_IsBool(cr) || !cJSON_IsTrue(cr)) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "ctrl+lamiga+ramiga REBOOTS the machine; " + "requires confirm_reset:true in addition to confirm", + NULL); + return 0; + } + } + } + + inject_ctx c; + if (_inject_open(&c, out_err) != 0) { _inject_close(&c); return 0; } + + for (int i = 0; i < n - 1; i++) { + if (_push_modifier(&c, resolved[i]) != 0) goto done; + _delay_ms(&c, delay); + } + + { + uint16_t q = c.held_quals; + if (final_shift) q |= IEQUALIFIER_LSHIFT; + + /* A shifted single character needs a real shift press too, for + * the same reason chords do. */ + if (final_shift) { + const named_key *sh = _find_named_key("lshift"); + if (sh && _push_modifier(&c, sh) != 0) goto done; + q = c.held_quals; + } + if (_key_event(&c, final_code, 0, q) != 0) goto done; + _delay_ms(&c, delay); + if (_key_event(&c, final_code, 1, q) != 0) goto done; + } + +done: + /* _inject_close releases every modifier we pushed, in reverse. */ + { + cJSON *r = cJSON_CreateObject(); + cJSON *echo = cJSON_AddArrayToObject(r, "keys"); + for (int i = 0; i < n; i++) { + cJSON *e = cJSON_GetArrayItem(keys, i); + if (cJSON_IsString(e) && e->valuestring) + cJSON_AddItemToArray(echo, + cJSON_CreateString(e->valuestring)); + } + cJSON_AddStringToObject(r, "op", "key"); + _inject_close(&c); + _add_common(r, &c); + *out_result = r; + } + return 0; +} + + +/* ---- input.type ---------------------------------------------------- * + * + * Bulk text entry. params: + * text : the string (US layout -- see the g_us_ascii comment) + * keymap : optional, "us" (default). "system" is rejected until + * the MapANSI layout is verified on hardware. + * delay_ms : optional pacing + * confirm : REQUIRED + * + * Unlike input.key this does NOT emit separate shift press/release + * events -- it sets IEQUALIFIER_LSHIFT on the character event itself. + * Intuition's RawKeyConvert path derives the character from code + + * qualifier, so this is correct for text entry and halves the event + * count, which matters against the 256-event cap. + */ +int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { + INPUT_GATE(); + if (_require_confirm(params, "input.type", out_err) != 0) return 0; + + cJSON *err = NULL; + const char *text = p_str(params, "text", &err); + if (!text) { *out_err = err; return 0; } + + size_t len = strlen(text); + if (len == 0) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "text must not be empty", NULL); + return 0; + } + if (len > INPUT_MAX_TEXT) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "text exceeds 512 characters", NULL); + return 0; + } + + /* keymap: "system" (default, layout-correct via keymap.library) + * or "us" to force the built-in table. */ + int force_us = 0; + cJSON *km = cJSON_GetObjectItemCaseSensitive(params, "keymap"); + if (cJSON_IsString(km) && km->valuestring) { + if (strcmp(km->valuestring, "us") == 0) force_us = 1; + else if (strcmp(km->valuestring, "system") != 0) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "keymap must be \"system\" (default) or \"us\"", NULL); + return 0; + } + } + + int delay; + if (_get_delay(params, INPUT_DEF_CHAR_MS, &delay, out_err) != 0) return 0; + + inject_ctx c; + if (_inject_open(&c, out_err) != 0) { _inject_close(&c); return 0; } + + int use_keymap = (!force_us && c.ikeymap != NULL); + + cJSON *unmapped = cJSON_CreateArray(); + int mapped = 0; + + for (size_t i = 0; i < len; i++) { + unsigned char ch = (unsigned char)text[i]; + + if (use_keymap) { + /* MapANSI yields code/qualifier PAIRS (2 bytes each) for + * the keystrokes that would produce this character on the + * system's configured keymap. Return value is the pair + * count: 1 for a plain key, 2 or 3 when the character + * needs a dead-key prefix (e.g. accents). 0 / -1 / -2 are + * "ungeneratable" / overflow / internal error. + * + * `length` is the buffer size in BYTES DIVIDED BY TWO -- + * i.e. the number of pairs it may write, not the byte + * count. Passing sizeof(rbuf) here would let MapANSI + * write twice the space we own. */ + uint8_t rbuf[6]; + memset(rbuf, 0, sizeof(rbuf)); + int32 pairs = c.ikeymap->MapANSI((STRPTR)&ch, 1, + (STRPTR)rbuf, 3, NULL); + if (pairs < 1 || pairs > 3) { + char one[8]; + snprintf(one, sizeof(one), "0x%02X", (unsigned)ch); + cJSON_AddItemToArray(unmapped, cJSON_CreateString(one)); + continue; + } + + /* Emit the pairs in order. For a dead-key sequence the + * earlier pairs are the dead keys, and each subsequent + * event carries the preceding down-codes as context so + * the keymap can compose the accent. Unused context slots + * must be 0x80, NOT 0 -- 0 is itself a valid deadkey code + * (autodoc keymap.library/MapANSI). */ + int aborted = 0; + for (int p = 0; p < pairs && !aborted; p++) { + uint8_t code = rbuf[p * 2]; + uint8_t qual = rbuf[p * 2 + 1]; + + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWKEY; + c.ev->ie_Code = (UWORD)code; + c.ev->ie_Qualifier = (UWORD)qual; + c.ev->ie_Prev2DownCode = (p >= 2) ? rbuf[(p - 2) * 2] : 0x80; + c.ev->ie_Prev2DownQual = (p >= 2) ? rbuf[(p - 2) * 2 + 1] : 0; + c.ev->ie_Prev1DownCode = (p >= 1) ? rbuf[(p - 1) * 2] : 0x80; + c.ev->ie_Prev1DownQual = (p >= 1) ? rbuf[(p - 1) * 2 + 1] : 0; + if (_write_event(&c) != 0) { aborted = 1; break; } + + _delay_ms(&c, delay > 2 ? delay / 2 : delay); + + c.ev->ie_Code = (UWORD)(code | IECODE_UP_PREFIX); + if (_write_event(&c) != 0) { aborted = 1; break; } + _delay_ms(&c, delay); + } + if (aborted) break; + mapped++; + continue; + } + + /* Fallback: built-in US table. */ + uint8_t code; int shift; + if (!_ascii_to_raw(ch, &code, &shift)) { + /* Report rather than drop silently -- a caller who typed a + * password containing a character we can't map needs to + * know the target got a DIFFERENT string than requested. */ + char one[8]; + snprintf(one, sizeof(one), "0x%02X", (unsigned)ch); + cJSON_AddItemToArray(unmapped, cJSON_CreateString(one)); + continue; + } + uint16_t q = shift ? IEQUALIFIER_LSHIFT : 0; + if (_key_event(&c, code, 0, q) != 0) break; + _delay_ms(&c, delay > 2 ? delay / 2 : delay); + if (_key_event(&c, code, 1, q) != 0) break; + _delay_ms(&c, delay); + mapped++; + } + + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "op", "type"); + cJSON_AddNumberToObject(r, "text_len", (double)len); + cJSON_AddNumberToObject(r, "chars_mapped", (double)mapped); + cJSON_AddItemToObject(r, "unmapped", unmapped); + cJSON_AddStringToObject(r, "keymap", + use_keymap ? "keymap.library" : "us-fallback"); + + _inject_close(&c); + _add_common(r, &c); + *out_result = r; + return 0; +} + + +/* ---- input.mouse_move ---------------------------------------------- * + * + * params (one of): + * dx, dy -- relative motion + * x, y -- absolute target position + * plus: + * absolute_mode -- "delta" (default) | "raw" + * steps -- split the motion into N events (default 1) + * delay_ms + * + * No confirm: pure motion commits nothing. + * + * ABSOLUTE POSITIONING. The default "delta" mode reads the current + * pointer position off the frontmost screen and emits a RELATIVE + * delta to get there, then re-reads to report where it actually + * landed. Omitting IEQUALIFIER_RELATIVEMOUSE is documented to make + * ie_X/ie_Y absolute, but its behaviour on AOS4 with modern pointer + * drivers is exactly the sort of thing that works on one machine and + * silently does nothing on another. The delta approach uses only + * Screen->MouseX/MouseY, which wb.c already proves is readable, and + * it is self-verifying. "raw" exposes the absolute form so a reviewer + * can A/B them on hardware without a rebuild. + */ +int input_mouse_move(cJSON *params, cJSON **out_result, cJSON **out_err) { + INPUT_GATE(); + + cJSON *jx = cJSON_GetObjectItemCaseSensitive(params, "x"); + cJSON *jy = cJSON_GetObjectItemCaseSensitive(params, "y"); + cJSON *jdx = cJSON_GetObjectItemCaseSensitive(params, "dx"); + cJSON *jdy = cJSON_GetObjectItemCaseSensitive(params, "dy"); + + int absolute = (cJSON_IsNumber(jx) && cJSON_IsNumber(jy)); + int relative = (cJSON_IsNumber(jdx) || cJSON_IsNumber(jdy)); + + if (absolute == relative) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "provide either x+y (absolute) or dx/dy (relative), not both", + NULL); + return 0; + } + + int raw_absolute = 0; + cJSON *am = cJSON_GetObjectItemCaseSensitive(params, "absolute_mode"); + if (cJSON_IsString(am) && am->valuestring) { + if (strcmp(am->valuestring, "raw") == 0) raw_absolute = 1; + else if (strcmp(am->valuestring, "delta") != 0) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "absolute_mode must be \"delta\" or \"raw\"", NULL); + return 0; + } + } + + long long steps = p_int(params, "steps", 1); + if (steps < 1 || steps > INPUT_MAX_DRAG_STEPS) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "steps out of range [1..64]", NULL); + return 0; + } + + int delay; + if (_get_delay(params, INPUT_DEF_MOVE_MS, &delay, out_err) != 0) return 0; + + int dx = 0, dy = 0; + int target_x = 0, target_y = 0; + + if (absolute) { + target_x = (int)jx->valuedouble; + target_y = (int)jy->valuedouble; + + if (!raw_absolute) { + int cx, cy, w, h; + if (_pointer_pos(&cx, &cy, &w, &h, out_err) != 0) return 0; + /* Clamp into the screen so a bad coordinate cannot fling + * the pointer somewhere unrecoverable. */ + if (target_x < 0) target_x = 0; + if (target_y < 0) target_y = 0; + if (target_x > w - 1) target_x = w - 1; + if (target_y > h - 1) target_y = h - 1; + dx = target_x - cx; + dy = target_y - cy; + } + } else { + dx = cJSON_IsNumber(jdx) ? (int)jdx->valuedouble : 0; + dy = cJSON_IsNumber(jdy) ? (int)jdy->valuedouble : 0; + if (dx > INPUT_MAX_MOVE || dx < -INPUT_MAX_MOVE || + dy > INPUT_MAX_MOVE || dy < -INPUT_MAX_MOVE) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "dx/dy out of range [-4096..4096]", NULL); + return 0; + } + } + + inject_ctx c; + if (_inject_open(&c, out_err) != 0) { _inject_close(&c); return 0; } + + if (absolute && raw_absolute) { + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = IECODE_NOBUTTON; + c.ev->ie_Qualifier = 0; /* no RELATIVEMOUSE => absolute */ + c.ev->ie_X = (WORD)target_x; + c.ev->ie_Y = (WORD)target_y; + _write_event(&c); + } else { + /* Split into `steps` events. Integer division leaves a + * remainder; give it all to the final step so the total is + * exact rather than landing a pixel or two short. */ + for (int i = 1; i <= (int)steps; i++) { + int sx = (dx * i) / (int)steps - (dx * (i - 1)) / (int)steps; + int sy = (dy * i) / (int)steps - (dy * (i - 1)) / (int)steps; + if (sx == 0 && sy == 0 && steps > 1) continue; + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = IECODE_NOBUTTON; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE | c.held_buttons; + c.ev->ie_X = (WORD)sx; + c.ev->ie_Y = (WORD)sy; + if (_write_event(&c) != 0) break; + if (i < (int)steps) _delay_ms(&c, delay); + } + } + + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "op", "mouse_move"); + cJSON_AddStringToObject(r, "mode", absolute ? "absolute" : "relative"); + + _inject_close(&c); + + /* Report where the pointer ACTUALLY ended up, not where we asked + * it to go. On the delta path this is what makes the operation + * self-verifying; a caller can compare and retry. */ + { + cJSON *pe = NULL; + int cx, cy, w, h; + if (_pointer_pos(&cx, &cy, &w, &h, &pe) == 0) { + cJSON_AddNumberToObject(r, "x", (double)cx); + cJSON_AddNumberToObject(r, "y", (double)cy); + } else { + cJSON_AddNullToObject(r, "x"); + cJSON_AddNullToObject(r, "y"); + if (pe) cJSON_Delete(pe); + } + } + + _add_common(r, &c); + *out_result = r; + return 0; +} + + +/* Map a button name to its IECODE_* and matching qualifier bit. */ +static int _button_code(const char *name, uint8_t *code, uint16_t *qual) { + if (!name || strcmp(name, "left") == 0) { + *code = IECODE_LBUTTON; *qual = IEQUALIFIER_LEFTBUTTON; return 1; + } + if (strcmp(name, "right") == 0) { + *code = IECODE_RBUTTON; *qual = IEQUALIFIER_RBUTTON; return 1; + } + if (strcmp(name, "middle") == 0) { + *code = IECODE_MBUTTON; *qual = IEQUALIFIER_MIDBUTTON; return 1; + } + return 0; +} + + +/* ---- input.click --------------------------------------------------- * + * + * params: button ("left"), count (1), x/y (optional pre-move), + * delay_ms, confirm (REQUIRED) + * + * Confirm is required because a click lands on whatever is under the + * pointer -- which the caller cannot know for certain without having + * called input.state first. That is the whole argument for gating it. + */ +int input_click(cJSON *params, cJSON **out_result, cJSON **out_err) { + INPUT_GATE(); + if (_require_confirm(params, "input.click", out_err) != 0) return 0; + + cJSON *jb = cJSON_GetObjectItemCaseSensitive(params, "button"); + const char *bname = (cJSON_IsString(jb) && jb->valuestring) + ? jb->valuestring : "left"; + uint8_t bcode; uint16_t bqual; + if (!_button_code(bname, &bcode, &bqual)) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "button must be \"left\", \"right\" or \"middle\"", NULL); + return 0; + } + + long long count = p_int(params, "count", 1); + if (count < 1 || count > 8) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "count out of range [1..8]", NULL); + return 0; + } + + int delay; + if (_get_delay(params, INPUT_DEF_BUTTON_MS, &delay, out_err) != 0) return 0; + + /* Optional pre-move. Done as a relative delta for the same reason + * input.mouse_move defaults to delta mode. */ + cJSON *jx = cJSON_GetObjectItemCaseSensitive(params, "x"); + cJSON *jy = cJSON_GetObjectItemCaseSensitive(params, "y"); + int want_move = (cJSON_IsNumber(jx) && cJSON_IsNumber(jy)); + int dx = 0, dy = 0; + if (want_move) { + int cx, cy, w, h; + if (_pointer_pos(&cx, &cy, &w, &h, out_err) != 0) return 0; + int tx = (int)jx->valuedouble; + int ty = (int)jy->valuedouble; + if (tx < 0) tx = 0; + if (ty < 0) ty = 0; + if (tx > w - 1) tx = w - 1; + if (ty > h - 1) ty = h - 1; + dx = tx - cx; + dy = ty - cy; + } + + inject_ctx c; + if (_inject_open(&c, out_err) != 0) { _inject_close(&c); return 0; } + + if (want_move && (dx || dy)) { + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = IECODE_NOBUTTON; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c.ev->ie_X = (WORD)dx; + c.ev->ie_Y = (WORD)dy; + _write_event(&c); + _delay_ms(&c, delay); + } + + for (int i = 0; i < (int)count; i++) { + /* Press. ie_X/ie_Y stay 0 so the click does not nudge the + * pointer off the thing we just aimed at. */ + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = bcode; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c.ev->ie_X = 0; c.ev->ie_Y = 0; + if (_write_event(&c) != 0) break; + c.held_button_code = bcode; + c.held_buttons = bqual; + + _delay_ms(&c, delay); + + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = (UWORD)(bcode | IECODE_UP_PREFIX); + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c.ev->ie_X = 0; c.ev->ie_Y = 0; + if (_write_event(&c) != 0) break; + c.held_button_code = 0; + c.held_buttons = 0; + + if (i + 1 < (int)count) _delay_ms(&c, delay); + } + + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "op", "click"); + cJSON_AddStringToObject(r, "mode", want_move ? "absolute" : "relative"); + cJSON_AddStringToObject(r, "button", bname); + cJSON_AddNumberToObject(r, "count", (double)count); + + _inject_close(&c); + _add_common(r, &c); + *out_result = r; + return 0; +} + + +/* ---- input.drag ---------------------------------------------------- * + * + * params: from_x, from_y, to_x, to_y, button ("left"), steps (16), + * delay_ms, confirm (REQUIRED) + * + * The riskiest method here: a drag can move, resize, or drag-to-trash. + * It is also the one where an early abort does the most damage, since + * bailing out mid-drag leaves the button down. _inject_close() releases + * it unconditionally -- see the comment there. + */ +int input_drag(cJSON *params, cJSON **out_result, cJSON **out_err) { + INPUT_GATE(); + if (_require_confirm(params, "input.drag", out_err) != 0) return 0; + + cJSON *jfx = cJSON_GetObjectItemCaseSensitive(params, "from_x"); + cJSON *jfy = cJSON_GetObjectItemCaseSensitive(params, "from_y"); + cJSON *jtx = cJSON_GetObjectItemCaseSensitive(params, "to_x"); + cJSON *jty = cJSON_GetObjectItemCaseSensitive(params, "to_y"); + if (!cJSON_IsNumber(jfx) || !cJSON_IsNumber(jfy) + || !cJSON_IsNumber(jtx) || !cJSON_IsNumber(jty)) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "from_x, from_y, to_x, to_y are all required", NULL); + return 0; + } + + cJSON *jb = cJSON_GetObjectItemCaseSensitive(params, "button"); + const char *bname = (cJSON_IsString(jb) && jb->valuestring) + ? jb->valuestring : "left"; + uint8_t bcode; uint16_t bqual; + if (!_button_code(bname, &bcode, &bqual)) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "button must be \"left\", \"right\" or \"middle\"", NULL); + return 0; + } + + long long steps = p_int(params, "steps", 16); + if (steps < 1 || steps > INPUT_MAX_DRAG_STEPS) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "steps out of range [1..64]", NULL); + return 0; + } + + int delay; + if (_get_delay(params, INPUT_DEF_MOVE_MS, &delay, out_err) != 0) return 0; + + int cx, cy, w, h; + if (_pointer_pos(&cx, &cy, &w, &h, out_err) != 0) return 0; + + int fx = (int)jfx->valuedouble; + int fy = (int)jfy->valuedouble; + int tx = (int)jtx->valuedouble; + int ty = (int)jty->valuedouble; + if (fx < 0) fx = 0; + if (fy < 0) fy = 0; + if (tx < 0) tx = 0; + if (ty < 0) ty = 0; + if (fx > w - 1) fx = w - 1; + if (fy > h - 1) fy = h - 1; + if (tx > w - 1) tx = w - 1; + if (ty > h - 1) ty = h - 1; + + inject_ctx c; + if (_inject_open(&c, out_err) != 0) { _inject_close(&c); return 0; } + + /* 1. move to the start point */ + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = IECODE_NOBUTTON; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c.ev->ie_X = (WORD)(fx - cx); + c.ev->ie_Y = (WORD)(fy - cy); + if (_write_event(&c) != 0) goto drag_done; + _delay_ms(&c, INPUT_DEF_BUTTON_MS); + + /* 2. button down -- recorded in ctx so _inject_close can undo it */ + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = bcode; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c.ev->ie_X = 0; c.ev->ie_Y = 0; + if (_write_event(&c) != 0) goto drag_done; + c.held_button_code = bcode; + c.held_buttons = bqual; + _delay_ms(&c, INPUT_DEF_BUTTON_MS); + + /* 3. interpolated motion, carrying the button qualifier throughout + * so the receiving app sees a genuine drag rather than a + * teleport between two unrelated positions */ + { + int ddx = tx - fx, ddy = ty - fy; + for (int i = 1; i <= (int)steps; i++) { + int sx = (ddx * i) / (int)steps - (ddx * (i - 1)) / (int)steps; + int sy = (ddy * i) / (int)steps - (ddy * (i - 1)) / (int)steps; + if (sx == 0 && sy == 0) continue; + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = IECODE_NOBUTTON; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE | c.held_buttons; + c.ev->ie_X = (WORD)sx; + c.ev->ie_Y = (WORD)sy; + if (_write_event(&c) != 0) goto drag_done; + _delay_ms(&c, delay); + } + } + + _delay_ms(&c, INPUT_DEF_BUTTON_MS); + /* 4. release happens in _inject_close via _release_buttons */ + +drag_done: + { + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "op", "drag"); + cJSON_AddStringToObject(r, "mode", "absolute"); + cJSON_AddStringToObject(r, "button", bname); + _inject_close(&c); + { + cJSON *pe = NULL; + int ex, ey, ew, eh; + if (_pointer_pos(&ex, &ey, &ew, &eh, &pe) == 0) { + cJSON_AddNumberToObject(r, "x", (double)ex); + cJSON_AddNumberToObject(r, "y", (double)ey); + } else { + cJSON_AddNullToObject(r, "x"); + cJSON_AddNullToObject(r, "y"); + if (pe) cJSON_Delete(pe); + } + } + _add_common(r, &c); + *out_result = r; + } + return 0; +} + + +/* ---- input.scroll -------------------------------------------------- * + * + * params: clicks (1..32), direction ("down"|"up"|"left"|"right"), + * delay_ms + * + * No confirm: scrolling commits nothing. + * + * Emits NewMouse wheel codes as IECLASS_RAWMOUSE events, which is what + * MUI and ReAction actually listen for. See the NM_WHEEL_* guards at + * the top -- those constants are not reliably in the SDK headers. + */ +int input_scroll(cJSON *params, cJSON **out_result, cJSON **out_err) { + INPUT_GATE(); + + long long clicks = p_int(params, "clicks", 1); + if (clicks < 1 || clicks > INPUT_MAX_SCROLL) { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "clicks out of range [1..32]", NULL); + return 0; + } + + cJSON *jd = cJSON_GetObjectItemCaseSensitive(params, "direction"); + const char *dir = (cJSON_IsString(jd) && jd->valuestring) + ? jd->valuestring : "down"; + uint8_t code; + if (strcmp(dir, "down") == 0) code = NM_WHEEL_DOWN; + else if (strcmp(dir, "up") == 0) code = NM_WHEEL_UP; + else if (strcmp(dir, "left") == 0) code = NM_WHEEL_LEFT; + else if (strcmp(dir, "right") == 0) code = NM_WHEEL_RIGHT; + else { + *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, + "direction must be \"up\", \"down\", \"left\" or \"right\"", + NULL); + return 0; + } + + int delay; + if (_get_delay(params, INPUT_DEF_MOVE_MS, &delay, out_err) != 0) return 0; + + inject_ctx c; + if (_inject_open(&c, out_err) != 0) { _inject_close(&c); return 0; } + + for (int i = 0; i < (int)clicks; i++) { + _ev_reset(&c); + c.ev->ie_Class = IECLASS_RAWMOUSE; + c.ev->ie_Code = code; + c.ev->ie_Qualifier = IEQUALIFIER_RELATIVEMOUSE; + c.ev->ie_X = 0; c.ev->ie_Y = 0; + if (_write_event(&c) != 0) break; + if (i + 1 < (int)clicks) _delay_ms(&c, delay); + } + + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "op", "scroll"); + cJSON_AddStringToObject(r, "mode", "relative"); + cJSON_AddStringToObject(r, "direction", dir); + cJSON_AddNumberToObject(r, "clicks", (double)clicks); + + _inject_close(&c); + _add_common(r, &c); + *out_result = r; + return 0; +} diff --git a/mcpd/src/methods/methods.h b/mcpd/src/methods/methods.h index f7ac3f5..e7f745f 100644 --- a/mcpd/src/methods/methods.h +++ b/mcpd/src/methods/methods.h @@ -81,6 +81,17 @@ MCPD_HANDLER(wb_publicscreens); MCPD_HANDLER(wb_frontmost); MCPD_HANDLER(wb_screenshot); +/* input.* -- DISABLED BY DEFAULT. Every one of these returns -32003 + * unless MCPd was started with --enable-input or the sentinel file + * SYS:System/MCPd/ENABLE-INPUT exists. See methods/input.c. */ +MCPD_HANDLER(input_state); +MCPD_HANDLER(input_type); +MCPD_HANDLER(input_key); +MCPD_HANDLER(input_mouse_move); +MCPD_HANDLER(input_click); +MCPD_HANDLER(input_drag); +MCPD_HANDLER(input_scroll); + MCPD_HANDLER(debug_task_snapshot); MCPD_HANDLER(debug_symbol); MCPD_HANDLER(debug_stacktrace); diff --git a/mcpd/src/methods/proto.c b/mcpd/src/methods/proto.c index 6279fb6..f24c85c 100644 --- a/mcpd/src/methods/proto.c +++ b/mcpd/src/methods/proto.c @@ -40,6 +40,9 @@ static int _add_unique_feature(cJSON *features, const char *name) { /* In sys.c. AttnFlags + GetCPUInfo + xena/fsldma resource probes. */ extern const char *sys_detect_board(void); +/* In input.c. The input-injection gate, resolved once at startup. */ +extern int input_is_enabled(void); + int proto_capabilities(cJSON *params, cJSON **out_result, cJSON **out_err) { (void)params; (void)out_err; @@ -94,6 +97,18 @@ int proto_capabilities(cJSON *params, cJSON **out_result, cJSON **out_err) { cJSON_AddNumberToObject(limits, "max_concurrent_clients", 1); cJSON_AddNumberToObject(limits, "max_in_flight_per_client", 1); + /* Input-injection gate state. + * + * Note the input.* methods stay listed in methods[] / method_names + * above even when the gate is off. Hiding them would be + * indistinguishable from talking to an older daemon that lacks the + * feature entirely, which would send clients chasing a phantom + * upgrade. Advertising them plus an explicit enabled:false lets a + * client say "this daemon supports input but it is switched off" + * and point the operator at the remedy. */ + cJSON *input = cJSON_AddObjectToObject(r, "input"); + cJSON_AddBoolToObject(input, "enabled", input_is_enabled() ? 1 : 0); + /* Best-effort board detection. */ cJSON *board = cJSON_AddObjectToObject(r, "board"); cJSON_AddStringToObject(board, "detected", sys_detect_board()); diff --git a/mcpd/src/rpc.c b/mcpd/src/rpc.c index 7fb6606..d5bb9ba 100644 --- a/mcpd/src/rpc.c +++ b/mcpd/src/rpc.c @@ -155,6 +155,20 @@ const method_entry mcpd_methods[] = { { "wb.publicscreens", wb_publicscreens, "Public screens registry (LockPubScreenList)" }, { "wb.frontmost", wb_frontmost, "Frontmost screen + active screen + active window" }, { "wb.screenshot", wb_screenshot, "Capture a screen (frontmost or by screen_index) to a PNG file on the target. params: screen_index (default 0), path (default T:mcpd-shot.png). Captures via graphics.library ReadPixelArray + encodes PNG via z.library; works on real hardware AND QEMU (unlike qemu.screenshot which is QMP-only). Returns path/format/width/height/depth/bytes." }, + { "input.state", input_state, + "DISABLED BY DEFAULT (-32003 unless MCPd was started with --enable-input or SYS:System/MCPd/ENABLE-INPUT exists). Read-only: pointer position, frontmost/active screen, active window + geometry. Call this before input.click to know what you are about to click on." }, + { "input.type", input_type, + "DISABLED BY DEFAULT (see input.state). Type a string as rawkey events via input.device. params: text (<=512 chars, REQUIRED), keymap (\"us\" only), delay_ms (0..1000), confirm:true (REQUIRED). US layout only -- non-US keymaps produce wrong characters; use input.key for those. Unmappable characters are reported in unmapped[], not silently dropped." }, + { "input.key", input_key, + "DISABLED BY DEFAULT (see input.state). Press a key or chord. params: keys (array, e.g. [\"lamiga\",\"q\"]; all but the last must be modifiers), delay_ms, confirm:true (REQUIRED). ctrl+lamiga+ramiga REBOOTS the machine and additionally requires confirm_reset:true." }, + { "input.mouse_move", input_mouse_move, + "DISABLED BY DEFAULT (see input.state). Move the pointer. params: x+y (absolute) OR dx/dy (relative, +-4096), absolute_mode (\"delta\" default | \"raw\"), steps (1..64), delay_ms. No confirm -- motion commits nothing. Absolute targets are clamped to the frontmost screen; the achieved position is returned." }, + { "input.click", input_click, + "DISABLED BY DEFAULT (see input.state). Click at the pointer, or at x+y if given. params: button (\"left\"|\"right\"|\"middle\"), count (1..8), x, y, delay_ms, confirm:true (REQUIRED -- lands on whatever is under the pointer; call input.state first)." }, + { "input.drag", input_drag, + "DISABLED BY DEFAULT (see input.state). Press at from_x/from_y, move to to_x/to_y, release. params: from_x, from_y, to_x, to_y (all REQUIRED), button, steps (1..64), delay_ms, confirm:true (REQUIRED). The button is always released, even if the call aborts on a budget overrun." }, + { "input.scroll", input_scroll, + "DISABLED BY DEFAULT (see input.state). Mouse wheel via NewMouse codes. params: clicks (1..32), direction (\"up\"|\"down\"|\"left\"|\"right\"), delay_ms. No confirm -- scrolling commits nothing." }, { "debug.task_snapshot", debug_task_snapshot, "Snapshot a named task: registers + backtrace via IDebug->ReadTaskContext" }, { "debug.symbol", debug_symbol, diff --git a/mcpd/src/rpc.h b/mcpd/src/rpc.h index d75d059..7447541 100644 --- a/mcpd/src/rpc.h +++ b/mcpd/src/rpc.h @@ -10,8 +10,22 @@ #include #include "cJSON.h" +/* SINGLE SOURCE OF TRUTH for the daemon version. + * + * Previously main.c's MCPD_VERSION and this file's MCPD_SERVER_VERSION + * were independent literals, so a release bump that touched one and + * not the other left `MCPd --version` and `proto.version` disagreeing + * (and discovery.c advertising a third answer). Derive them all from + * one define instead -- also keep mcpd/Makefile's VERSION in step, + * which is only used for packaging. + * + * The wire protocol version is deliberately separate: it tracks + * framing / error-code / notification semantics, not the daemon + * build, and only changes when those become incompatible. Adding + * methods is backward-compatible and does NOT bump it. */ +#define MCPD_VERSION_STR "1.3" #define MCPD_PROTOCOL_VERSION "1.0" -#define MCPD_SERVER_VERSION "mcpd/1.3" +#define MCPD_SERVER_VERSION "mcpd/" MCPD_VERSION_STR typedef int (*method_handler_fn)(cJSON *params, cJSON **out_result, From b4e7bef87bf404b09a8b288b13a70bd36a555cf7 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Thu, 10 Sep 2026 22:30:07 +0100 Subject: [PATCH 16/23] input.type: decode UTF-8 before mapping; correct the stale keymap docs The wire is UTF-8 -- the host sends json.dumps(..., ensure_ascii=False).encode("utf-8") (transports/mcpd.py) and cJSON expands \uXXXX escapes to UTF-8 as well -- but input_type() walked the string byte by byte and handed each byte to MapANSI, which wants one ANSI / ISO-8859-1 byte per character. An accented "cafe" was therefore typed as five keystrokes, or landed two entries in unmapped[], for four requested characters. The host layer explicitly permits U+0080..U+00FF, so this was reachable straight from the documented API; the on-target verification was ASCII-only, which exercises the layout mapping but never this path. Decode to a codepoint first (_utf8_next), map codepoints <= 0xFF, and report anything above as "U+XXXX" in unmapped[] rather than typing something else. The 512 cap now counts characters on both sides (_utf8_strlen), so a 512-character accented string is no longer rejected as 1024 bytes, and text_len reports characters to match what the caller counted. Also correct three descriptions that contradicted the shipped code: - the comment above g_us_ascii still said MapANSI was "deliberately NOT implemented" and that keymap="system" is rejected. It is the default and it is implemented; the table is the fallback. - rpc.c's input.type description -- which proto.capabilities serves to clients, so an agent reads it as the spec -- said keymap "us" only / US layout only. Same correction. - the rawkey table claimed F11/F12 codes were present but unverified. There are no F11/F12 entries at all; say they are deliberately absent, since an unknown key name fails cleanly while a wrong rawkey code silently presses something else. Cross-compiles clean with the CI toolchain (walkero/amigagccondocker:os4-gcc11). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- host/src/amiga_fleet_mcp/tools/input.py | 13 ++- host/tests/unit/test_input.py | 29 +++++ mcpd/src/methods/input.c | 148 ++++++++++++++++++------ mcpd/src/rpc.c | 2 +- 4 files changed, 152 insertions(+), 40 deletions(-) diff --git a/host/src/amiga_fleet_mcp/tools/input.py b/host/src/amiga_fleet_mcp/tools/input.py index 91faf61..d681129 100644 --- a/host/src/amiga_fleet_mcp/tools/input.py +++ b/host/src/amiga_fleet_mcp/tools/input.py @@ -154,10 +154,15 @@ def _check_delay(delay_ms: int | None) -> None: def _encode_text(text: str, cfg: InputConfig) -> str: """Validate a string is typeable and return it unchanged. - The daemon's keymap is byte-oriented (Latin-1), not UTF-8, so any - codepoint above 0xFF cannot be represented at all. Rejecting here — - naming the offending character — beats letting it reach the wire - and come back as a silent entry in `unmapped`. + The text crosses the wire as UTF-8 and the daemon decodes it back + to codepoints before mapping, but both mapping paths on the Amiga + side (`keymap.library` `MapANSI` and the built-in table) are ANSI / + ISO-8859-1 — one byte per character. Any codepoint above U+00FF has + no key on any Amiga keymap, so reject it here, naming the offending + character, rather than letting it reach the wire and come back as a + silent entry in `unmapped`. + + Length is counted in characters, matching the daemon's own cap. """ if not text: raise InvalidParams("text must not be empty") diff --git a/host/tests/unit/test_input.py b/host/tests/unit/test_input.py index ad90bc4..e91f845 100644 --- a/host/tests/unit/test_input.py +++ b/host/tests/unit/test_input.py @@ -303,6 +303,35 @@ async def test_latin1_text_accepted() -> None: assert len(fake.calls) == 1 +@pytest.mark.asyncio +async def test_latin1_text_reaches_the_wire_verbatim() -> None: + """Pin the host half of the UTF-8 contract. + + The daemon decodes `text` from UTF-8 back to codepoints before + mapping (input.c `_utf8_next`), because both of its mapping paths + are one-byte ANSI. That is only correct if the host passes the + string through untouched and the transport encodes it as UTF-8 -- + it does (`transports/mcpd.py`). Before the daemon-side decode + existed, "café" was typed as five keystrokes instead of four, and + the assertion below was the only thing a host test could see. + """ + fleet, fake = _enabled() + await input_tool.input_type(fleet, "x5000", text="café", confirm=True) + _method, params = fake.calls[0] + assert params is not None + assert params["text"] == "café" + assert params["text"].encode("utf-8") == b"caf\xc3\xa9" + + +@pytest.mark.asyncio +async def test_text_cap_counts_characters_not_bytes() -> None: + """512 accented characters is 1024 bytes of UTF-8 and must still + be accepted -- the daemon counts characters too (`_utf8_strlen`).""" + fleet, fake = _enabled() + await input_tool.input_type(fleet, "x5000", text="é" * 512, confirm=True) + assert len(fake.calls) == 1 + + @pytest.mark.asyncio async def test_non_latin1_text_rejected_naming_the_char() -> None: fleet, fake = _enabled() diff --git a/mcpd/src/methods/input.c b/mcpd/src/methods/input.c index ae44fc5..5b84f66 100644 --- a/mcpd/src/methods/input.c +++ b/mcpd/src/methods/input.c @@ -312,9 +312,11 @@ static const named_key g_named_keys[] = { { "left", 0x4F, 0 }, /* function keys. F1..F10 are the classic Amiga block and are - * solid. F11/F12 are a PC-keyboard extension; the codes below are - * the commonly-used AOS4 values but are NOT verified on hardware - * here -- see the FLAGGED list in the file header. */ + * solid. F11/F12 are DELIBERATELY ABSENT: they are a PC-keyboard + * extension whose AOS4 rawkey codes were never verified on + * hardware for this table, and an unknown key name fails cleanly + * with "unknown key name" while a wrong code would silently press + * some other key. Add them only with a hardware check behind it. */ { "f1", 0x50, 0 }, { "f2", 0x51, 0 }, { "f3", 0x52, 0 }, { "f4", 0x53, 0 }, { "f5", 0x54, 0 }, { "f6", 0x55, 0 }, { "f7", 0x56, 0 }, { "f8", 0x57, 0 }, { "f9", 0x58, 0 }, @@ -337,24 +339,20 @@ static const named_key g_named_keys[] = { /* ASCII -> (rawkey, needs-shift) for the US layout. * * ------------------------------------------------------------------ - * LIMITATION: this is a US keymap, hardcoded. On a machine configured - * for a different layout (German, French, Swedish, ...) the CHARACTERS - * produced by input.type will be wrong -- a German keymap will turn - * 'y' into 'z', and the symbol keys land in entirely different places. + * THIS IS THE FALLBACK PATH, NOT THE DEFAULT. * - * The correct fix is keymap.library's MapANSI() against the system - * default keymap, which is layout-aware. That is deliberately NOT - * implemented here: the exact MapANSI buffer layout (bytes per - * character, and whether the second byte is a qualifier or a qualifier - * index) must be read out of SDK:Documentation/Autodocs/keymap.doc on - * a real target first, and shipping a speculative, never-executed - * implementation as the default typing path would be worse than - * shipping a known-limited one. + * input.type maps characters through keymap.library's MapANSI() + * against the system's configured keymap, which is layout-aware -- + * see input_type() below. This table is used only when + * keymap.library cannot be opened, or when the caller explicitly + * passes keymap="us". * - * Until then: input.type is US-layout only, and input.key with - * explicit named keys is the layout-independent escape hatch. - * See the `keymap` parameter, which rejects "system" explicitly rather - * than silently doing the wrong thing. + * Being a US matrix, it produces the WRONG CHARACTERS on a machine + * configured for another layout: a German keymap turns 'y' into 'z' + * and moves every symbol key. The result's `keymap` field reports + * which of the two paths actually ran, so a caller that cares can + * check rather than assume. input.key with explicit named keys is + * the layout-independent escape hatch in either case. * ------------------------------------------------------------------ */ typedef struct { uint8_t code; uint8_t shift; } ascii_key; @@ -388,6 +386,63 @@ static const ascii_key g_us_ascii[96] = { /* |}~ */ {0x0D,1}, {0x1B,1}, {0x00,1}, {NOKEY,0}, }; +/* ---- UTF-8 -> codepoint ------------------------------------------- * + * + * The wire is UTF-8. The host sends + * `json.dumps(env, ensure_ascii=False).encode("utf-8")` + * (transports/mcpd.py), and cJSON decodes any \uXXXX escape into UTF-8 + * too, so a non-ASCII character reaches us as TWO OR MORE BYTES. + * + * MapANSI, by contrast, wants ONE ANSI (ISO-8859-1) byte per + * character. Walking the string byte-by-byte therefore typed 0xC3 + * followed by 0xA9 for a single "e-acute" -- two wrong keystrokes, or + * two entries in unmapped[], for one requested character. Decode + * first, then map the codepoint. + * + * Returns the number of bytes consumed (always >= 1) and stores the + * codepoint. A malformed or truncated sequence consumes exactly one + * byte and yields that raw byte, so bad input is reported through + * unmapped[] rather than resynchronising into something typeable. + */ +static int _utf8_next(const char *s, size_t len, size_t i, uint32_t *out_cp) { + unsigned char c0 = (unsigned char)s[i]; + int need; + uint32_t cp; + + if (c0 < 0x80) { *out_cp = c0; return 1; } + else if ((c0 & 0xE0) == 0xC0) { need = 1; cp = c0 & 0x1Fu; } + else if ((c0 & 0xF0) == 0xE0) { need = 2; cp = c0 & 0x0Fu; } + else if ((c0 & 0xF8) == 0xF0) { need = 3; cp = c0 & 0x07u; } + else { *out_cp = c0; return 1; } + + if (i + (size_t)need >= len) { /* truncated at end of buffer */ + *out_cp = c0; + return 1; + } + for (int k = 1; k <= need; k++) { + unsigned char cn = (unsigned char)s[i + (size_t)k]; + if ((cn & 0xC0) != 0x80) { *out_cp = c0; return 1; } + cp = (cp << 6) | (uint32_t)(cn & 0x3Fu); + } + *out_cp = cp; + return need + 1; +} + + +/* Count characters, not bytes. The caller's 512-character budget was + * counted in characters, so enforcing it on the byte length would + * reject a perfectly legal 300-character accented string. */ +static size_t _utf8_strlen(const char *s, size_t len) { + size_t n = 0, i = 0; + while (i < len) { + uint32_t cp; + i += (size_t)_utf8_next(s, len, i, &cp); + n++; + } + return n; +} + + /* Newline and tab are the two control characters worth honouring in a * typed string; everything else non-printable is rejected. */ static int _ascii_to_raw(unsigned char c, uint8_t *out_code, int *out_shift) { @@ -994,17 +1049,24 @@ int input_key(cJSON *params, cJSON **out_result, cJSON **out_err) { /* ---- input.type ---------------------------------------------------- * * * Bulk text entry. params: - * text : the string (US layout -- see the g_us_ascii comment) - * keymap : optional, "us" (default). "system" is rejected until - * the MapANSI layout is verified on hardware. + * text : the string, UTF-8 on the wire, <= 512 CHARACTERS + * keymap : optional, "system" (default, layout-correct via + * keymap.library MapANSI) or "us" (built-in table) * delay_ms : optional pacing * confirm : REQUIRED * - * Unlike input.key this does NOT emit separate shift press/release - * events -- it sets IEQUALIFIER_LSHIFT on the character event itself. - * Intuition's RawKeyConvert path derives the character from code + - * qualifier, so this is correct for text entry and halves the event - * count, which matters against the 256-event cap. + * Characters are decoded from UTF-8 to a codepoint first (see + * _utf8_next) because both mapping paths are ANSI / ISO-8859-1: one + * byte per character. Anything above U+00FF, and anything the keymap + * cannot generate, is reported in unmapped[] rather than typed as + * something else. + * + * In the "us" fallback path this does NOT emit separate shift + * press/release events -- it sets IEQUALIFIER_LSHIFT on the character + * event itself. Intuition's RawKeyConvert path derives the character + * from code + qualifier, so this is correct for text entry and halves + * the event count, which matters against the 256-event cap. The + * MapANSI path uses whatever qualifier the keymap returned. */ int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { INPUT_GATE(); @@ -1020,7 +1082,10 @@ int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { "text must not be empty", NULL); return 0; } - if (len > INPUT_MAX_TEXT) { + /* In CHARACTERS, not bytes -- the wire is UTF-8, so an accented + * string is longer in bytes than the caller counted. */ + size_t nchars = _utf8_strlen(text, len); + if (nchars > INPUT_MAX_TEXT) { *out_err = rpc_make_error(MCPD_ERR_INVPARAMS, "text exceeds 512 characters", NULL); return 0; @@ -1050,8 +1115,21 @@ int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { cJSON *unmapped = cJSON_CreateArray(); int mapped = 0; - for (size_t i = 0; i < len; i++) { - unsigned char ch = (unsigned char)text[i]; + for (size_t i = 0; i < len; ) { + uint32_t cp; + i += (size_t)_utf8_next(text, len, i, &cp); + + /* MapANSI and the US table are both ANSI (ISO-8859-1): one + * byte per character. A codepoint above 0xFF has no key on any + * Amiga keymap, so report it rather than truncating it into a + * different character. */ + if (cp > 0xFFu) { + char one[12]; + snprintf(one, sizeof(one), "U+%04X", (unsigned)cp); + cJSON_AddItemToArray(unmapped, cJSON_CreateString(one)); + continue; + } + unsigned char ch = (unsigned char)cp; if (use_keymap) { /* MapANSI yields code/qualifier PAIRS (2 bytes each) for @@ -1070,8 +1148,8 @@ int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { int32 pairs = c.ikeymap->MapANSI((STRPTR)&ch, 1, (STRPTR)rbuf, 3, NULL); if (pairs < 1 || pairs > 3) { - char one[8]; - snprintf(one, sizeof(one), "0x%02X", (unsigned)ch); + char one[12]; + snprintf(one, sizeof(one), "U+%04X", (unsigned)cp); cJSON_AddItemToArray(unmapped, cJSON_CreateString(one)); continue; } @@ -1114,8 +1192,8 @@ int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { /* Report rather than drop silently -- a caller who typed a * password containing a character we can't map needs to * know the target got a DIFFERENT string than requested. */ - char one[8]; - snprintf(one, sizeof(one), "0x%02X", (unsigned)ch); + char one[12]; + snprintf(one, sizeof(one), "U+%04X", (unsigned)cp); cJSON_AddItemToArray(unmapped, cJSON_CreateString(one)); continue; } @@ -1129,7 +1207,7 @@ int input_type(cJSON *params, cJSON **out_result, cJSON **out_err) { cJSON *r = cJSON_CreateObject(); cJSON_AddStringToObject(r, "op", "type"); - cJSON_AddNumberToObject(r, "text_len", (double)len); + cJSON_AddNumberToObject(r, "text_len", (double)nchars); cJSON_AddNumberToObject(r, "chars_mapped", (double)mapped); cJSON_AddItemToObject(r, "unmapped", unmapped); cJSON_AddStringToObject(r, "keymap", diff --git a/mcpd/src/rpc.c b/mcpd/src/rpc.c index d5bb9ba..37f25c6 100644 --- a/mcpd/src/rpc.c +++ b/mcpd/src/rpc.c @@ -158,7 +158,7 @@ const method_entry mcpd_methods[] = { { "input.state", input_state, "DISABLED BY DEFAULT (-32003 unless MCPd was started with --enable-input or SYS:System/MCPd/ENABLE-INPUT exists). Read-only: pointer position, frontmost/active screen, active window + geometry. Call this before input.click to know what you are about to click on." }, { "input.type", input_type, - "DISABLED BY DEFAULT (see input.state). Type a string as rawkey events via input.device. params: text (<=512 chars, REQUIRED), keymap (\"us\" only), delay_ms (0..1000), confirm:true (REQUIRED). US layout only -- non-US keymaps produce wrong characters; use input.key for those. Unmappable characters are reported in unmapped[], not silently dropped." }, + "DISABLED BY DEFAULT (see input.state). Type a string as rawkey events via input.device. params: text (UTF-8, <=512 characters, REQUIRED), keymap (\"system\" default | \"us\"), delay_ms (0..1000), confirm:true (REQUIRED). Layout-correct: characters map through the target's own keymap.library (MapANSI), including dead-key sequences; \"us\" forces the built-in table, which is also the fallback when keymap.library cannot be opened. The result's keymap field says which path ran. Characters the keymap cannot generate, and anything above U+00FF, are reported in unmapped[], not silently dropped." }, { "input.key", input_key, "DISABLED BY DEFAULT (see input.state). Press a key or chord. params: keys (array, e.g. [\"lamiga\",\"q\"]; all but the last must be modifiers), delay_ms, confirm:true (REQUIRED). ctrl+lamiga+ramiga REBOOTS the machine and additionally requires confirm_reset:true." }, { "input.mouse_move", input_mouse_move, From 7d3f15a2beb3bc37977b98da8f80f0a1a891fb32 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Thu, 10 Sep 2026 22:30:54 +0100 Subject: [PATCH 17/23] input gate: announce state in the debug ring, not just on stdout input_init_gate()'s "*** INPUT INJECTION ENABLED ***" banner was deliberately loud, but it goes to stdout -- which is NIL: on the only path that matters in production. S:Network-Startup launches `Run >NIL: Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- mcpd/src/main.c | 9 +++++++-- mcpd/src/methods/input.c | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/mcpd/src/main.c b/mcpd/src/main.c index 5a8cf48..1246789 100644 --- a/mcpd/src/main.c +++ b/mcpd/src/main.c @@ -32,6 +32,10 @@ extern void applib_shutdown(void); * any connection Process exists, and prints the startup banner. */ extern void input_init_gate(int cli_flag); +/* Also in methods/input.c. Reads the resolved gate, so the readiness + * beacon can say whether this daemon will accept injection. */ +extern int input_is_enabled(void); + /* Derived from rpc.h so there is exactly one place to bump. */ #define MCPD_VERSION MCPD_VERSION_STR #define DEFAULT_PORT 4322 @@ -511,9 +515,10 @@ int main(int argc, char **argv) { * Keep the "[MCPd] ready " prefix and the key=value shape stable: * they are parsed. */ IExec->DebugPrintF("[MCPd] ready name=MCPd version=%s " - "build_date=%s build_time=%s port=%lu\n", + "build_date=%s build_time=%s port=%lu input=%s\n", MCPD_VERSION, MCPD_DATE, MCPD_TIME, - (unsigned long)port); + (unsigned long)port, + input_is_enabled() ? "on" : "off"); /* Multi-client (§19.3 P1 #7): the parent task does nothing but * accept + spawn. Each child Process owns its own connection diff --git a/mcpd/src/methods/input.c b/mcpd/src/methods/input.c index 5b84f66..492bb25 100644 --- a/mcpd/src/methods/input.c +++ b/mcpd/src/methods/input.c @@ -168,6 +168,10 @@ void input_init_gate(int cli_flag) { int sentinel = _sentinel_present(); g_input_enabled = (cli_flag || sentinel) ? 1 : 0; + const char *how = cli_flag + ? (sentinel ? "--enable-input + sentinel file" : "--enable-input") + : (sentinel ? "sentinel file" : "off"); + if (g_input_enabled) { /* Deliberately loud. An operator who enabled this by accident * -- or an operator who inherited a machine someone else @@ -176,14 +180,25 @@ void input_init_gate(int cli_flag) { IDOS->Printf( "MCPd: *** INPUT INJECTION ENABLED *** (%s)\n" "MCPd: remote clients can type and click on this machine.\n", - cli_flag ? (sentinel ? "--enable-input + sentinel file" - : "--enable-input") - : "sentinel file"); + how); } else { IDOS->Printf( "MCPd: input injection disabled (input.* -> -32003).\n" "MCPd: to enable: %s\n", INPUT_ENABLE_HINT); } + + /* Printf goes to stdout, which is NIL: on the normal auto-start + * path (S:Network-Startup -> Run >NIL: DebugPrintF("[MCPd] input_gate state=%s source=%s\n", + g_input_enabled ? "ENABLED" : "disabled", how); } From 3b57998ed4a84072a03760f41b16a3d5e6bf0410 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Thu, 10 Sep 2026 22:35:07 +0100 Subject: [PATCH 18/23] installer: deploy the MCPd-Enable-Input / -Disable-Input scripts MCPd-Install copies these next to the binary, but nothing in the fleet is provisioned by running MCPd-Install on the target -- installer.stage + the install_mcpd sequence step mirror it, and scripts/ install_mcpd_autostart.py uploads MCPd and the watchdog directly. Neither knew about the two new scripts, so on every machine this project actually installs, the documented persistent way to open the input.* gate did not exist: the operator would have had to hand-create SYS:System/MCPd/ENABLE-INPUT. - installer.stage resolves them from sources_dir, the repo's mcpd/install/, or the install-support tree, and stages them to :tmp/ alongside MCPd. Optional: a missing script is reported in `skipped`, never fatal. - install_mcpd() copies whichever are staged into SYS:System/MCPd/ with +rwed and reports them as `input_helpers`. - install_mcpd_autostart.py uploads them after the watchdog. Copied, never run, in all three paths -- injection stays off until an operator executes MCPd-Enable-Input on the target and restarts MCPd. While here: _resolve_mcpd_install_script also searches parents[4] (the repo root). parents[3] is `host/`, so the existing binary resolver's repo-root guess only ever worked via the cwd entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- .../installer/sequences/_steps.py | 34 ++++++++++- host/src/amiga_fleet_mcp/tools/installer.py | 44 +++++++++++++ .../tests/unit/test_installer_stage_verify.py | 20 ++++-- host/tests/unit/test_installer_x5000_seq.py | 61 +++++++++++++++++++ scripts/install_mcpd_autostart.py | 20 +++++- 5 files changed, 173 insertions(+), 6 deletions(-) diff --git a/host/src/amiga_fleet_mcp/installer/sequences/_steps.py b/host/src/amiga_fleet_mcp/installer/sequences/_steps.py index 0d1e0c9..e223f4a 100644 --- a/host/src/amiga_fleet_mcp/installer/sequences/_steps.py +++ b/host/src/amiga_fleet_mcp/installer/sequences/_steps.py @@ -1385,6 +1385,34 @@ async def fn(ctx): "timeout_ms": 30000, }, timeout_s=35.0) + # 1b. Operator helper scripts, if staged. These are what + # `MCPd-Install` copies, and without them the ONLY documented + # way to turn input injection on persistently doesn't exist on + # an installer-provisioned machine -- which is how every + # machine in the fleet is built. Copied, never run: input + # injection stays off until an operator executes + # MCPd-Enable-Input on the target themselves. + helpers_installed: list[str] = [] + for helper in ("MCPd-Enable-Input", "MCPd-Disable-Input"): + hsrc = dest + "tmp/" + helper + hdst = dest + "System/MCPd/" + helper + try: + await mcpd.request("fs.stat", {"path": hsrc}) + except Exception: + continue # not staged; optional + try: + await mcpd.request("fs.copy", {"src": hsrc, "dst": hdst}) + except Exception: + await mcpd.request("exec.cmd", { + "command": f'Copy "{hsrc}" "{hdst}" CLONE', + "timeout_ms": 30000, + }, timeout_s=35.0) + await mcpd.request("exec.cmd", { + "command": f'Protect "{hdst}" +rwed', + "timeout_ms": 30000, + }, timeout_s=35.0) + helpers_installed.append(hdst) + # 2. Network-Startup: read, check for existing MCPd line, edit. # Capture mtime first so we can restore it after our append. orig_netstart_mtime = await _capture_mtime(mcpd, netstart) @@ -1400,6 +1428,7 @@ async def fn(ctx): if marker in existing: return {"action": "binary copied; Network-Startup unchanged", "binary": binary_dst, + "input_helpers": helpers_installed, "marker_already_present": True} # backup @@ -1439,6 +1468,7 @@ async def fn(ctx): return { "action": "installed", "binary": binary_dst, + "input_helpers": helpers_installed, "network_startup": netstart, "backed_up_to": backup if backed_up else None, } @@ -1446,7 +1476,9 @@ async def fn(ctx): name="install_mcpd", doc=("Copy MCPd binary to SYS:System/MCPd/, +rwed, append " "launch line to S:Network-Startup so it auto-starts on " - "first boot."), + "first boot. Also copies the MCPd-Enable-Input / " + "MCPd-Disable-Input operator scripts when staged - " + "copied only, never run: input injection stays off."), fn=fn, ) diff --git a/host/src/amiga_fleet_mcp/tools/installer.py b/host/src/amiga_fleet_mcp/tools/installer.py index e2a5d83..d64ae54 100644 --- a/host/src/amiga_fleet_mcp/tools/installer.py +++ b/host/src/amiga_fleet_mcp/tools/installer.py @@ -1100,6 +1100,31 @@ def _resolve_mcpd_binary(sources_dir: Path) -> Path | None: return None +def _resolve_mcpd_install_script(name: str, sources_dir: Path) -> Path | None: + """Resolve one of the AmigaDOS scripts shipped in `mcpd/install/`. + + Same search order as `_resolve_mcpd_binary`, plus the repo's + `mcpd/install/` directory, which is where these live in a checkout. + Returns None when nothing matches -- these are optional, and a + missing one must never fail an install. + """ + cand = sources_dir / name + if cand.is_file(): + return cand + # parents[3] is `host/`, parents[4] the repo root; try both so this + # resolves whether the package is used from a checkout or installed. + here = Path(__file__).resolve() + for repo_root in (Path.cwd(), here.parents[3], here.parents[4]): + cand = repo_root / "mcpd" / "install" / name + if cand.is_file(): + return cand + for d in _candidate_install_support_dirs(): + cand = d / name + if cand.is_file(): + return cand + return None + + # What the preflight + stage need to find before allowing an install. # Each entry has: name, friendly description, and a resolver function # returning a Path or None. @@ -1272,6 +1297,25 @@ async def installer_stage( assert mcpd_local is not None # _mandatory_binaries enforced this items.append(("mcpd_binary", mcpd_local, staging + "MCPd")) + # MCPd operator scripts (optional). `MCPd-Install` copies these + # next to the binary, but installer-provisioned machines never run + # MCPd-Install -- install_mcpd() mirrors it -- so stage them here + # or the documented persistent way to enable input injection + # doesn't exist on the resulting install. They are only ever + # copied, never executed: injection stays off. + for helper in ("MCPd-Enable-Input", "MCPd-Disable-Input"): + helper_local = _resolve_mcpd_install_script(helper, src) + if helper_local is not None: + items.append((f"mcpd_script_{helper}", helper_local, + staging + helper)) + else: + skipped.append( + f"mcpd_script_{helper}: {helper} not found; the " + "installed system will have no helper script for " + "input.* injection (create " + "SYS:System/MCPd/ENABLE-INPUT by hand instead)." + ) + # SerialShell binary (optional). Useful only for users who run # qemu-runner / AmigaQemuTests workflows on the resulting install; # MCPd itself doesn't need it. The install_serialshell sequence diff --git a/host/tests/unit/test_installer_stage_verify.py b/host/tests/unit/test_installer_stage_verify.py index 099f063..6d44d70 100644 --- a/host/tests/unit/test_installer_stage_verify.py +++ b/host/tests/unit/test_installer_stage_verify.py @@ -68,6 +68,11 @@ def _make_x5000_sources(d: Path, *, with_bootstrap: bool = True, (d / "NGFS.lha").write_bytes(b"ngfs") if with_mcpd: (d / "MCPd").write_bytes(b"\x7fELF" + b"mcpd-test-bin") + # Operator scripts for the input.* gate. Staged alongside the + # binary so install_mcpd() can copy them to SYS:System/MCPd/; + # nothing in the installer ever executes them. + (d / "MCPd-Enable-Input").write_bytes(b"; enable-input\n") + (d / "MCPd-Disable-Input").write_bytes(b"; disable-input\n") (d / "SerialShell").write_bytes(b"\x7fELF" + b"ss-test-bin") (d / "AmiDock.amiga.com.xml").write_bytes(b"") if with_bootstrap: @@ -123,10 +128,10 @@ async def fake_upload(fleet_, target, local, remote, **_): confirm=True, ) - # ISO + 2 updates + enhancer + 2 extras + MCPd + SerialShell + - # AmiDock XML = 9 (no bootstrap upload -- diskimage tools come - # from the running AmigaOS / install ISO). - assert len(uploaded) == 9 + # ISO + 2 updates + enhancer + 2 extras + MCPd + 2 MCPd input + # scripts + SerialShell + AmiDock XML = 11 (no bootstrap upload -- + # diskimage tools come from the running AmigaOS / install ISO). + assert len(uploaded) == 11 assert res.machine == "X5000" assert res.iso_filename == "AmigaOneX5000InstallCD-53.42.iso" assert res.skipped == [] @@ -137,6 +142,13 @@ async def fake_upload(fleet_, target, local, remote, **_): bootstrap_dsts = [dst for src, dst in uploaded if "diskimage-bootstrap" in dst] assert bootstrap_dsts == [] + # The input.* operator scripts ride along with the binary, so an + # installer-provisioned machine has the documented way to enable + # injection later. Staged only: install_mcpd() copies them into + # SYS:System/MCPd/ and nothing ever runs them. + staged_dsts = [dst for _src, dst in uploaded] + assert "BootTest:tmp/MCPd-Enable-Input" in staged_dsts + assert "BootTest:tmp/MCPd-Disable-Input" in staged_dsts # Make sure pyflakes doesn't complain about the unused fixture # output (kept so future tests can assert on it). assert bootstrap is not None diff --git a/host/tests/unit/test_installer_x5000_seq.py b/host/tests/unit/test_installer_x5000_seq.py index c4b5d67..db3199a 100644 --- a/host/tests/unit/test_installer_x5000_seq.py +++ b/host/tests/unit/test_installer_x5000_seq.py @@ -295,6 +295,67 @@ async def _request(method, params=None, timeout_s=30.0): for c in exec_cmds) +@pytest.mark.asyncio +@pytest.mark.parametrize("staged", [True, False]) +async def test_install_mcpd_copies_input_helper_scripts( + fleet_with_fake, staged +): + """The MCPd-Enable-Input / MCPd-Disable-Input operator scripts are + copied to SYS:System/MCPd/ when staged, and their absence is + tolerated. + + `MCPd-Install` copies them, but an installer-provisioned machine + never runs `MCPd-Install` -- this step mirrors it -- so without + this the only documented persistent way to open the input.* gate + wouldn't exist on any machine the fleet installs. They are copied, + never executed: injection stays off until an operator runs one. + """ + import base64 + + from amiga_fleet_mcp.installer.sequences import _steps + fleet, fake = fleet_with_fake + fake.existing_paths.add("BootTest:tmp/MCPd") + if staged: + fake.existing_paths.add("BootTest:tmp/MCPd-Enable-Input") + fake.existing_paths.add("BootTest:tmp/MCPd-Disable-Input") + + netstart_path = "BootTest:S/Network-Startup" + ops: list[tuple[str, dict]] = [] + original_request = fake.request + + async def _request(method, params=None, timeout_s=30.0): + ops.append((method, dict(params or {}))) + if method == "fs.read" and (params or {}).get("path") == netstart_path: + return {"content_b64": base64.b64encode(b"; empty\n").decode(), + "size": 8} + if method in ("fs.write", "fs.copy"): + return {"ok": True} + return await original_request(method, params, timeout_s) + fake.request = _request + + step = _steps.install_mcpd() + res = await step.fn({ + "fleet": fleet, "target": "x5000", + "mcpd": fleet.mcpd("x5000"), + "dest_volume": "BootTest:", + }) + + expected = [ + "BootTest:System/MCPd/MCPd-Enable-Input", + "BootTest:System/MCPd/MCPd-Disable-Input", + ] if staged else [] + assert res["input_helpers"] == expected + + copies = [p.get("dst") for m, p in ops if m == "fs.copy"] + protects = [p["command"] for m, p in ops if m == "exec.cmd"] + for dst in expected: + assert dst in copies + assert any(f'Protect "{dst}" +rwed' in c for c in protects) + # Never executed by the installer -- only copied. + assert not any("MCPd-Enable-Input" in c and "Protect" not in c + for c in protects) + + @pytest.mark.asyncio async def test_install_mcpd_idempotent_when_marker_present(fleet_with_fake): """If S:Network-Startup already references MCPd, don't append a diff --git a/scripts/install_mcpd_autostart.py b/scripts/install_mcpd_autostart.py index 6d9c8f0..b9d4b92 100644 --- a/scripts/install_mcpd_autostart.py +++ b/scripts/install_mcpd_autostart.py @@ -39,7 +39,8 @@ "; MCPd - Model Context Protocol daemon (auto-installed, watchdog'd)", "Run >NIL: Config: @@ -126,6 +127,23 @@ async def install( ) print(f" wrote {len(wd_blob)} bytes") + # The operator scripts for the input.* gate. Uploaded, never run: + # input injection stays off until someone executes + # MCPd-Enable-Input on the target and restarts MCPd. Without them + # the only persistent way to open the gate wouldn't be present on + # a machine provisioned by this script. + for helper in ("MCPd-Enable-Input", "MCPd-Disable-Input"): + local = INSTALL_DIR / helper + if not local.exists(): + print(f" skip {helper} (not in {INSTALL_DIR})") + continue + blob_h = local.read_bytes() + await fs_tool.fs_write( + fleet, target, f"SYS:System/MCPd/{helper}", + base64.b64encode(blob_h).decode(), + ) + print(f" wrote {helper} ({len(blob_h)} bytes, not executed)") + print("[4/6] Backup S:Network-Startup -> S:Network-Startup.before-mcpd " "(if missing)") if await _exists(fleet, target, "S:Network-Startup.before-mcpd"): From 9697a5324b272bd9e246e4f1a83aa05378c254ed Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Thu, 10 Sep 2026 22:38:04 +0100 Subject: [PATCH 19/23] docs: input.* tool counts, install-script table, agent guidance - README + AGENTS_SETUP: 129 -> 137 tools (the seven input.* tools plus their dispatcher), and input.* added to the namespace list. - COMMANDS: MCPd-Enable-Input / MCPd-Disable-Input rows in the on-Amiga scripts table; note that MCPd-Uninstall's recursive delete removes the sentinel with them. - INSTALL: the autostart helper now uploads both scripts, and says plainly that they are copied and never run. - AGENTS_SETUP: a NotCapable row naming both gates, and an explicit "this spec does not enable injection" entry. An agent generating a config must not open a gate whose entire purpose is to require a deliberate human action. - CHANGELOG: fold the input.* entries into Unreleased rather than a released 1.3 section, record the UTF-8 and debug-ring changes, add a tool-count block, and rewrite the limitations to match the code -- F11/F12 are absent rather than unverified, and the click/drag pointer-accuracy gap on real hardware is now stated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- AGENTS_SETUP.md | 11 ++++++++++- CHANGELOG.md | 45 ++++++++++++++++++++++++++++++++++++--------- COMMANDS.md | 6 ++++-- INSTALL.md | 11 ++++++++--- README.md | 5 +++-- 5 files changed, 61 insertions(+), 17 deletions(-) diff --git a/AGENTS_SETUP.md b/AGENTS_SETUP.md index 93eac25..3655cca 100644 --- a/AGENTS_SETUP.md +++ b/AGENTS_SETUP.md @@ -163,7 +163,7 @@ amiga-fleet-mcp --inspect # 2. List the live tool surface (no target reach required). amiga-fleet-mcp --list-tools | wc -l -# Expected: 121 tools as of v1.2. +# Expected: 137 tools on the current develop branch (121 at v1.2). # 3. Probe every target's MCPd channel (target reach required). amiga-fleet-mcp --health-check @@ -195,6 +195,7 @@ wiring. | `paths.amiga_qemu_tests not set in config` | `tests.*` invoked but no `[paths] amiga_qemu_tests`. Set it. | | `target.qemu_config is required for qemu.start` | Target's `qemu_config` path missing from `[targets.]`. Set it. | | `NotCapable: channel mcu` | `power.*` invoked but `[targets..channels.mcu]` not configured. Wire the FTDI cable, set the block. | +| `NotCapable: input injection is not enabled` | `input.*` invoked without both gates open. Host side: `[targets..input] enabled = true`. Daemon side (the real control): run `MCPd-Enable-Input` on the target and restart MCPd. Do not open either without the user asking. | | `FileNotFoundError: config not found` | `--config` points at a path that doesn't exist, or no config at the platform default. Run `--init`. | | `bad config: ...` | TOML doesn't validate against the schema. Re-run `--init`; the wizard pre-validates. | @@ -204,6 +205,14 @@ wiring. procedure (see [INSTALL.md § Installing MCPd on a target](INSTALL.md#installing-mcpd-on-a-target)) — it requires the target to already be reachable and is inherently more interactive. +- **Doesn't enable keyboard / mouse injection.** `input.*` is off by + default at both layers and this spec leaves it that way. Writing + `[targets..input] enabled = true` into a generated config is + out of scope — an agent must not open a gate whose whole purpose is + to require a deliberate human action, and the host-side flag alone + does nothing anyway (the daemon gate needs `MCPd-Enable-Input` plus a + restart, on the target). If the user asks for it, point them at + [SECURITY.md](SECURITY.md) first. - **Doesn't choose secrets or credentials.** MCPd has no authentication on its TCP listener; the security stance is network-level isolation (private LAN, host-only QEMU NAT, diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f17d8..374d93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,10 @@ `allow_drag`), defaulting to absent. Raises `NotCapable` naming both gates. The `--init` wizard asks about it, defaulting to no. - **New install scripts** `MCPd-Enable-Input` / `MCPd-Disable-Input`, - copied by `MCPd-Install` but never run by it. + copied but never run -- by `MCPd-Install`, by `installer.stage` + + the `install_mcpd` sequence step, and by + `scripts/install_mcpd_autostart.py`, so a machine provisioned any of + the three ways has the documented persistent way to open the gate. - `confirm: true` required on the committing operations (`type`, `key`, `click`, `drag`); not on `mouse_move`, `scroll`, `state`. `ctrl+lamiga+ramiga` additionally requires `confirm_reset: true` @@ -43,7 +46,18 @@ end-to-end on a German (QWERTZ) AmigaOS 4.1 FE guest: typing `Echo TYPED-OK >T:typed.txt` into a Shell produced exactly that file. `keymap="us"` forces the built-in table, which is also the - automatic fallback; the result reports which path ran. + automatic fallback; the result reports which path ran. Text is + decoded from UTF-8 to codepoints before mapping (both mapping paths + are one-byte ANSI), so an accented character is one keystroke rather + than one per UTF-8 byte; codepoints above U+00FF, which no Amiga + keymap can generate, are reported in `unmapped[]`. The 512 cap + counts characters on both sides. +- **The input gate is visible in the kernel debug ring.** MCPd emits + `[MCPd] input_gate state=... source=...` at startup and adds + `input=on|off` to the `[MCPd] ready` beacon, both readable through + `sys.debug_ring`. The startup banner alone goes to stdout, which is + `NIL:` on the watchdog auto-start path -- invisible exactly where it + matters most. - **`wb.screenshot`** — capture an AmigaOS screen to a PNG on the @@ -182,16 +196,29 @@ from its inherited Output() handle). The probe now treats any structured `exec.cmd` exit as proof the binary started. -### Known limitations - -- F11/F12 rawkey codes and the NewMouse wheel constants are still - flagged in `mcpd/src/methods/input.c` as unverified. - `input.mouse_move` defaults to a read-position-then-relative-delta - strategy (verified accurate); `absolute_mode="raw"` remains - available to A/B the true-absolute event form. +### Known limitations (`input.*`) + +- F11/F12 are deliberately absent from the rawkey table: their AOS4 + codes were never verified on hardware, and an unknown key name fails + cleanly where a wrong code would silently press something else. The + NewMouse wheel constants remain unverified (behind `#ifndef` guards + so a missing header cannot break the build). +- `input.mouse_move` defaults to a read-position-then-relative-delta + strategy and reports the position actually achieved, but does not + retry to converge. `input.click`'s pre-move and `input.drag`'s + move-to-start do not read back at all, so pointer acceleration on + real hardware could land them off-target; only QEMU pegasos2 has + been measured. `absolute_mode="raw"` remains available to A/B the + true-absolute event form. - `input.click` does not report the achieved pointer position the way `input.mouse_move` and `input.drag` do. +### Tool count + +- 121 → 137 tools (+6 `sandbox.*`, +`sys.debug_ring`, + +`wb.screenshot`, +8 `input.*` including its dispatcher). + 13 → 14 namespaces. + ## 1.2 — Guided setup and whole-file transfers ### Added diff --git a/COMMANDS.md b/COMMANDS.md index f5ddfb8..04f8f92 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -437,8 +437,10 @@ Located in `mcpd/install/`. Run from a Shell on the target with | Script | Description | |---|---| -| `MCPd-Install` | Install MCPd to `SYS:System/MCPd/MCPd`, set protection bits, back up `S:Network-Startup`, idempotently append the launch line. | -| `MCPd-Uninstall` | Restore `S:Network-Startup` from backup; remove `SYS:System/MCPd/`. | +| `MCPd-Install` | Install MCPd to `SYS:System/MCPd/MCPd`, set protection bits, back up `S:Network-Startup`, idempotently append the launch line. Also copies the two input scripts below — copied, never run. | +| `MCPd-Enable-Input` | Create `SYS:System/MCPd/ENABLE-INPUT`, the persistent gate for the `input.*` namespace. Prints a warning first; needs an MCPd restart to take effect. Also installed by `installer.stage` + the `install_mcpd` step, and by `scripts/install_mcpd_autostart.py`. | +| `MCPd-Disable-Input` | Remove the sentinel again. Needs an MCPd restart; does not undo a `--enable-input` flag on a running daemon's command line. | +| `MCPd-Uninstall` | Restore `S:Network-Startup` from backup; remove `SYS:System/MCPd/` (sentinel and both input scripts included). | | `MCPd-Watchdog` | Relaunch wrapper: runs MCPd in a loop with a five-second back-off between exits. Writes `T:MCPd-Watchdog.log`. | ## Build commands diff --git a/INSTALL.md b/INSTALL.md index 48665a0..06fddc7 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -403,7 +403,12 @@ The installer performs five steps: 2. Copies the binary to `SYS:System/MCPd/MCPd` and clears the `e`-protected bit (AmigaOS new-file default). 3. Uploads the watchdog script `SYS:System/MCPd/MCPd-Watchdog`, which - relaunches MCPd if it ever exits, with a 5 s back-off. + relaunches MCPd if it ever exits, with a 5 s back-off, plus the + `MCPd-Enable-Input` / `MCPd-Disable-Input` operator scripts. + Those two are only copied, never run: keyboard / mouse injection + (`input.*`) stays disabled until you execute `MCPd-Enable-Input` + on the target yourself and restart MCPd. See + [SECURITY.md](SECURITY.md). 4. Creates a one-time backup of `S:Network-Startup` at `S:Network-Startup.before-mcpd`. 5. Idempotently appends a launch line that runs the watchdog from the @@ -414,8 +419,8 @@ eleven seconds of cold boot. ### Manual install (no host helper) -Copy `mcpd/MCPd` and `mcpd/install/MCPd-Install` onto the target, then -from a Shell on the target: +Copy `mcpd/MCPd` and the scripts from `mcpd/install/` onto the +target, then from a Shell on the target: ``` CD diff --git a/README.md b/README.md index ccf0b11..cfc3e47 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ can: resident-init mode and chain a test program against them, all in a single tool call. -129 typed MCP tools, 8 live resources, validated end-to-end on QEMU +137 typed MCP tools, 8 live resources, validated end-to-end on QEMU pegasos2 and real AmigaOne X5000 hardware. If that sounds useful, the rest of this README and the docs below @@ -96,7 +96,8 @@ context in [USAGE.md](USAGE.md). - More than 120 typed MCP tools across `fs.*`, `exec.cmd`, `sys.*`, `wb.*`, `debug.*`, `qemu.*`, `fleet.*`, `tests.*`, `events.wait`, `app.notify`, `notify.*`, `installer.*`, `serial.*`, `power.*`, - and `sandbox.*`, plus a namespace dispatcher per group. + `sandbox.*`, and `input.*` (keyboard / mouse injection, disabled by + default at the daemon), plus a namespace dispatcher per group. (`events.subscribe`, `events.unsubscribe`, and `events.test_emit` are exposed only as daemon RPC methods, not MCP tools — clients call them directly through the transport.) From 70588e2cdd41424fda0d98683db9583931cd5c04 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Thu, 10 Sep 2026 23:14:56 +0100 Subject: [PATCH 20/23] scripts: validate_input.py -- end-to-end check of the input.* gate input.* is the only surface here that writes to a machine's UI, and its safety story is entirely "the gate is shut unless an operator opened it". That is worth nothing unverified, so this drives a live target through the whole cycle: refuse everything by default, open via the sentinel (and only after a restart), inject, close again. The section that earns its keep is the typing one. input.type takes UTF-8 off the wire while both daemon mapping paths are one-byte ANSI, so "cafe"-with-an-acute is the discriminator: 4 characters, 5 UTF-8 bytes. text_len == 4 and a 4-byte file mean the decode happened. The typed output goes to the guest's SHARED: volume so the bytes are read on the host rather than back through the daemon that typed them. Two things this cost to learn, both now encoded in the script: - QMP `quit` stops QEMU dead and AmigaOS writes back lazily, so a file written seconds earlier is not in the disk image after a restart -- and fs.upload's verify does NOT catch it, because the read-back comes from the same dirty cache. 45 s of idle is enough for a 300 KB write. A deploy that looked successful had silently not happened. - A QEMU left running from a previous run makes every later result a lie: the new instance can't bind the same hostfwd port, so the script talks to the OLD guest with whatever gate state it booted with. The script now refuses to start if the endpoint is already listening, and stops its guest on the way out even when a check raises. Run against qemu-pegasos2-sm501 (AOS 4.1 FE Update 3, Kickstart 54.57, sm501 RTG, 1280x800): 27/27. Highlights: gate shut by default with all seven methods returning -32003 and the ring showing `input_gate state=disabled` / `input=off`; sentinel ignored until a restart; `Echo >SHARED:...` produced exactly b'caf\xe9' in the Shell -- four bytes, correct ISO-8859-1, through keymap.library; U+65E5 refused host-side and reported as U+65E5 in unmapped[] daemon-side with zero events; a real window drag; both confirm guards rejecting; gate shut again after the sentinel was removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- COMMANDS.md | 1 + mcpd/README.md | 6 +- scripts/validate_input.py | 487 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 scripts/validate_input.py diff --git a/COMMANDS.md b/COMMANDS.md index 04f8f92..49cd0d2 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -402,6 +402,7 @@ Located in `scripts/`. Run with `python scripts/.py`. |---|---| | `validate.py --endpoint host:port [--rounds 1,2,3,4,5]` | Parametric runner for the validation suites against any endpoint. | | `validate_full.py --endpoint host:port [--skip-stress] [--skip-mcp]` | Comprehensive capability sweep covering connectivity, fs, exec, sys, wb, debug, events, application library, edge cases, stress, and the host MCP protocol layer. | +| `validate_input.py --target [--shared-dir DIR] [--no-restart-cycles]` | End-to-end check of the `input.*` gate and injection path on a live target: refuses everything by default, opens only after `MCPd-Enable-Input` + a restart, types ASCII and non-ASCII into a Shell (byte-checked through the guest's `SHARED:` volume), drags a window, verifies the `confirm` guards, then closes the gate again. Kills/relaunches QEMU for the restarts; use `--no-restart-cycles` on real hardware. | | `validate_durability.py --endpoint host:port [--skip-long]` | Out-of-the-box durability sweep: raw-socket frame fuzzing, JSON-RPC envelope abuse, error-code coverage, connection lifecycle, long-running stability, filesystem corner cases, exec.cmd corners, subscription state transitions, concurrency, numeric boundaries, cross-method workflows, MCP resources, fleet host tools, CLI flags. | | `round1_validation.py` … `round5_serverpush_validation.py` | The individual suites; standalone, hardcoded for the canonical test endpoint. | | `qemu_install_test.py` | End-to-end: launch QEMU, bootstrap, install, kill+relaunch, verify auto-start, run all rounds. | diff --git a/mcpd/README.md b/mcpd/README.md index dba98e9..c3b2f84 100644 --- a/mcpd/README.md +++ b/mcpd/README.md @@ -83,9 +83,13 @@ On a successful start MCPd writes one line to the kernel debug ring via `IExec->DebugPrintF`: ``` -[MCPd] ready name=MCPd version=1.3 build_date=02.08.2026 build_time=18:18:04 port=4322 +[MCPd] ready name=MCPd version=1.3 build_date=02.08.2026 build_time=18:18:04 port=4322 input=off ``` +`input=on|off` reports the keyboard / mouse injection gate, which is +resolved once at startup; a companion `[MCPd] input_gate state=... +source=...` line says where the setting came from. + The line is emitted **after** `bind` + `listen` succeed, so its presence means the port is accepting connections — not merely that the binary loaded. Two companion lines cover the other outcomes: diff --git a/scripts/validate_input.py b/scripts/validate_input.py new file mode 100644 index 0000000..42d87e2 --- /dev/null +++ b/scripts/validate_input.py @@ -0,0 +1,487 @@ +"""End-to-end validation of the `input.*` namespace against a live target. + +`input.*` is the only tool surface in this project that *writes* to a +machine's UI, and its whole safety story is a gate that must be off +unless an operator deliberately turned it on. That story is only worth +anything if it is checked on a real AmigaOS, so this script drives a +target through the full cycle: + + gate closed by default -> enable via the sentinel -> restart -> + gate open -> inject -> remove the sentinel -> restart -> gate closed + +Sections +-------- + + A. Gate closed by default all 7 methods -> -32003, caps + ring + B. Sentinel enable written, ignored until a restart + C. Read-only state pointer / focus / geometry + D. Pointer absolute move lands where asked + E. Typing ASCII, then non-ASCII end-to-end + F. Drag + scroll a real window move + G. Confirm guards daemon-side, bypassing the wrapper + H. Sentinel disable gate closed again after a restart + +Section E is the one that matters most for regressions. `input.type` +receives UTF-8 on the wire but both of the daemon's mapping paths are +one-byte ANSI, so the daemon decodes to codepoints first. The +discriminator is that "cafe"-with-an-acute is 4 characters and 5 UTF-8 +bytes: `text_len == 4` and a 4-byte file mean the decode happened, +`5` means something walked the bytes. + +The typed output is written to the guest's `SHARED:` volume (a 9p +share pointing at a host directory) so the bytes can be inspected on +the host rather than read back through the same daemon that typed +them. Pass `--shared-dir` to point at the host side of that share; the +typing checks are skipped when it isn't given. + +QEMU targets only for the restart cycles: the script kills and +relaunches the QEMU process, because neither QMP `system_reset` nor a +guest-side Reboot works reliably for AmigaOS 4 guests. On real +hardware the daemon has to be restarted out of band, so run this with +`--no-restart-cycles` there and drive the reboots yourself. + +Usage: + python scripts/validate_input.py --target qemu-pegasos2-sm501 \\ + --shared-dir S:/temp +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import socket +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _paths import repo_root + +sys.path.insert(0, str(repo_root() / "host" / "src")) + +from amiga_fleet_mcp.config import InputConfig, load_config +from amiga_fleet_mcp.errors import InvalidParams, NotCapable +from amiga_fleet_mcp.fleet import Fleet +from amiga_fleet_mcp.tools import exec as exec_tool +from amiga_fleet_mcp.tools import fs as fs_tool +from amiga_fleet_mcp.tools import input as input_tool +from amiga_fleet_mcp.tools import qemu as qemu_tool +from amiga_fleet_mcp.tools import sys as sys_tool + +SENTINEL = "SYS:System/MCPd/ENABLE-INPUT" +ACUTE = "caf\u00e9" # 4 characters, 5 bytes of UTF-8 +CJK = "\u65e5" # U+65E5 -- above anything an Amiga keymap has + +# QMP `quit` stops QEMU dead, and AmigaOS writes its filesystem cache +# back lazily. A file written seconds before the kill is simply not in +# the disk image afterwards -- and an `fs.upload` with verify=True does +# NOT catch it, because the read-back comes from the same dirty cache. +# 45 s of idle was measured as enough for a 300 KB write to land; both +# the sentinel writes and its deletion need it too. +WRITEBACK_SETTLE_S = 45.0 + +# This script prints non-ASCII on purpose -- that is the thing under +# test -- and a Windows console is cp1252. +sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace") + +results: list[tuple[str, bool, str]] = [] + + +def check(name: str, ok: bool, detail: str = "") -> bool: + results.append((name, ok, detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + + (f" -- {detail}" if detail else ""), flush=True) + return ok + + +def log(msg: str) -> None: + print(f"\n=== {msg}", flush=True) + + +def port_open(endpoint: str, timeout: float = 1.0) -> bool: + host, port = endpoint.split(":") + try: + with socket.create_connection((host, int(port)), timeout): + return True + except OSError: + return False + + +class Runner: + def __init__(self, fleet: Fleet, target: str, *, + shared_dir: Path | None, restart_cycles: bool) -> None: + self.fleet = fleet + self.target = target + self.shared = shared_dir + self.restart_cycles = restart_cycles + self.endpoint = fleet.target_config(target).channels.mcpd.endpoint + + # ---- plumbing -------------------------------------------------- + + async def wait_up(self, timeout_s: float = 300.0) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if port_open(self.endpoint): + # The listener answers before the rest of the machine + # is ready; the settle is the documented gotcha. + await asyncio.sleep(3.0) + try: + await sys_tool.sys_version(self.fleet, self.target) + return True + except Exception: + pass + await asyncio.sleep(2.0) + return False + + async def restart(self, why: str) -> bool: + print(f" idling {WRITEBACK_SETTLE_S:.0f}s for AmigaOS write-back", + flush=True) + await asyncio.sleep(WRITEBACK_SETTLE_S) + log(f"restarting target ({why})") + if not self.restart_cycles: + print(" --no-restart-cycles: restart MCPd on the target now," + " then press Enter", flush=True) + await asyncio.to_thread(input) + return await self.wait_up() + try: + await qemu_tool.qemu_stop(self.fleet, self.target) + except Exception as e: + print(f" (stop: {e})", flush=True) + await asyncio.sleep(5.0) + await qemu_tool.qemu_start(self.fleet, self.target) + ok = await self.wait_up() + print(f" target back: {ok}", flush=True) + return ok + + async def gate_open(self) -> bool: + caps = await self.fleet.mcpd(self.target).request( + "proto.capabilities", {}) + return bool(caps.get("input", {}).get("enabled", False)) + + async def ring(self) -> str: + r = await sys_tool.sys_debug_ring( + self.fleet, self.target, max_lines=400) + return "\n".join(r.lines) + + async def sentinel_present(self) -> bool: + try: + await self.fleet.mcpd(self.target).request( + "fs.stat", {"path": SENTINEL}) + return True + except Exception: + return False + + def read_shared(self, name: str) -> bytes: + assert self.shared is not None + p = self.shared / name + for _ in range(10): + if p.exists(): + try: + return p.read_bytes() + except OSError: + pass + time.sleep(1.0) + return b"" + + # ---- A: gate closed -------------------------------------------- + + async def all_refused(self) -> tuple[int, list[str]]: + f, t = self.fleet, self.target + calls = [ + ("state", lambda: input_tool.input_state(f, t)), + ("type", lambda: input_tool.input_type( + f, t, text="x", confirm=True)), + ("key", lambda: input_tool.input_key( + f, t, keys=["esc"], confirm=True)), + ("mouse_move", lambda: input_tool.input_mouse_move( + f, t, dx=1, dy=0)), + ("click", lambda: input_tool.input_click(f, t, confirm=True)), + ("drag", lambda: input_tool.input_drag( + f, t, from_x=10, from_y=10, to_x=20, to_y=20, confirm=True)), + ("scroll", lambda: input_tool.input_scroll(f, t)), + ] + refused, leaked = 0, [] + for name, fn in calls: + try: + await fn() + leaked.append(name) + except Exception as e: + if isinstance(e, NotCapable) or "32003" in str(e) \ + or "disabled" in str(e).lower(): + refused += 1 + else: + leaked.append(f"{name}({type(e).__name__}: {e})") + return refused, leaked + + async def section_gate_closed(self) -> None: + log("A. gate closed by default") + check("proto.capabilities reports input.enabled false", + await self.gate_open() is False) + refused, leaked = await self.all_refused() + check("all 7 methods refuse", refused == 7 and not leaked, + f"refused={refused} leaked={leaked}") + ring = await self.ring() + check("debug ring shows input_gate state=disabled", + "input_gate state=disabled" in ring) + check("ready beacon carries input=off", "input=off" in ring) + + # ---- B: enable -------------------------------------------------- + + async def section_enable(self) -> bool: + log("B. enable via the sentinel file") + await fs_tool.fs_write( + self.fleet, self.target, SENTINEL, + base64.b64encode(b"enabled by validate_input.py\n").decode()) + check("sentinel written", await self.sentinel_present()) + check("gate stays closed until a restart", + await self.gate_open() is False) + if not await self.restart("pick up the sentinel"): + return check("target returns after enabling", False) + check("proto.capabilities reports input.enabled true", + await self.gate_open() is True) + ring = await self.ring() + check("debug ring shows input_gate state=ENABLED", + "input_gate state=ENABLED" in ring) + check("ready beacon carries input=on", "input=on" in ring) + return True + + # ---- C-F: the actual injection ---------------------------------- + + async def section_inject(self) -> None: + f, t = self.fleet, self.target + + log("C. input.state") + st = await input_tool.input_state(f, t) + check("state returns screen geometry", + bool(st.screen_width and st.screen_height), + f"{st.screen_width}x{st.screen_height} " + f"pointer=({st.pointer_x},{st.pointer_y})") + + log("D. input.mouse_move") + mx = (st.screen_width or 640) // 2 + my = (st.screen_height or 480) // 2 + mv = await input_tool.input_mouse_move(f, t, x=mx, y=my) + check("absolute move lands where asked", + mv.x == mx and mv.y == my, + f"asked ({mx},{my}) got ({mv.x},{mv.y})") + + if self.shared is None: + print("\n (skipping typing checks: no --shared-dir)", + flush=True) + else: + await self._section_typing() + + log("F. input.drag + input.scroll") + before = await input_tool.input_state(f, t) + bx = before.active_window_left or 0 + by = before.active_window_top or 0 + d = await input_tool.input_drag( + f, t, from_x=bx + 60, from_y=by + 5, + to_x=bx + 120, to_y=by + 45, confirm=True) + await asyncio.sleep(1.5) + after = await input_tool.input_state(f, t) + # Intuition clamps a window to the screen, so a full-width + # window only moves vertically. Either axis moving is enough. + check("drag moved the active window", + (after.active_window_left, after.active_window_top) + != (bx, by), + f"({bx},{by}) -> ({after.active_window_left}," + f"{after.active_window_top}) events={d.events} " + f"truncated={d.truncated}") + sc = await input_tool.input_scroll(f, t, clicks=3) + check("scroll emits one event per click", sc.events == 3, + f"events={sc.events}") + + async def _section_typing(self) -> None: + f, t = self.fleet, self.target + assert self.shared is not None + + log("E. typing -- a Shell to receive the keystrokes") + await exec_tool.exec_cmd( + f, t, "Run >NIL: SHARED:mcpd-typed2.txt", confirm=True) + await input_tool.input_key(f, t, keys=["return"], confirm=True) + await asyncio.sleep(3.0) + raw = self.read_shared("mcpd-typed2.txt").strip() + check("the Shell received 4 characters, not 5", len(raw) == 4, + f"got {len(raw)} bytes: {raw!r}") + + log("E. typing -- codepoint above U+00FF") + try: + await input_tool.input_type(f, t, text=CJK, confirm=True) + check("host rejects a non-Latin-1 codepoint", False) + except InvalidParams as e: + check("host rejects a non-Latin-1 codepoint", + "U+65E5" in str(e), str(e)[:60]) + r3 = await f.mcpd(t).request( + "input.type", {"text": CJK, "confirm": True}) + check("daemon reports it unmapped and types nothing", + r3.get("unmapped") == ["U+65E5"] + and r3.get("chars_mapped") == 0 + and r3.get("events") == 0, f"{r3}") + + # ---- G: guards --------------------------------------------------- + + async def section_guards(self) -> None: + log("G. confirm guards (daemon-side, bypassing the wrapper)") + t = self.fleet.mcpd(self.target) + try: + await t.request("input.type", {"text": "x"}) + check("daemon rejects input.type without confirm", False) + except Exception as e: + check("daemon rejects input.type without confirm", + "confirm" in str(e).lower(), str(e)[:80]) + try: + await t.request("input.key", { + "keys": ["ctrl", "lamiga", "ramiga"], "confirm": True}) + check("daemon rejects the reset chord without confirm_reset", + False, "ACCEPTED -- the machine may be rebooting") + except Exception as e: + check("daemon rejects the reset chord without confirm_reset", + "confirm_reset" in str(e), str(e)[:80]) + try: + await input_tool.input_type( + self.fleet, self.target, text="x") + check("host rejects input.type without confirm", False) + except InvalidParams as e: + check("host rejects input.type without confirm", True, + str(e)[:50]) + + # ---- H: disable --------------------------------------------------- + + async def section_disable(self) -> None: + log("H. disable again") + await fs_tool.fs_delete(self.fleet, self.target, SENTINEL) + if not await self.restart("drop the sentinel"): + check("target returns after disabling", False) + return + check("gate closed after removing the sentinel", + await self.gate_open() is False) + refused, leaked = await self.all_refused() + check("all 7 methods refuse again", refused == 7 and not leaked, + f"refused={refused} leaked={leaked}") + + +async def main() -> int: + ap = argparse.ArgumentParser( + description="Validate the input.* namespace end to end.") + ap.add_argument("--target", required=True, + help="target name from the amiga-fleet-mcp config") + ap.add_argument("--shared-dir", + help="host side of the guest's SHARED: volume; " + "typing checks are skipped without it") + ap.add_argument("--no-restart-cycles", action="store_true", + help="don't kill/relaunch QEMU -- prompt for a manual " + "MCPd restart instead (use on real hardware)") + ap.add_argument("--keep-running", action="store_true", + help="leave the QEMU guest running at the end") + args = ap.parse_args() + + cfg = load_config() + if args.target not in cfg.targets: + print(f"unknown target {args.target!r}; configured: " + f"{sorted(cfg.targets)}") + return 2 + # Open the host-side gate in memory only. The daemon gate is what + # is under test, and writing this to the user's config file would + # leave a live target configured for injection afterwards. + cfg.targets[args.target].input = InputConfig(enabled=True) + fleet = Fleet(cfg) + + shared = Path(args.shared_dir).resolve() if args.shared_dir else None + if shared is not None and not shared.is_dir(): + print(f"--shared-dir {shared} is not a directory") + return 2 + + r = Runner(fleet, args.target, shared_dir=shared, + restart_cycles=not args.no_restart_cycles) + + if r.restart_cycles: + # A QEMU left behind by an earlier run invalidates everything: + # the second instance can't bind the same hostfwd port, so this + # script would end up talking to the OLD guest, with whatever + # gate state that one booted with. + if port_open(r.endpoint): + print("REFUSING: something already listens on " + f"{r.endpoint}. Kill the stale qemu-system-ppc first.") + return 2 + log(f"starting {args.target}") + await qemu_tool.qemu_start(fleet, args.target) + if not await r.wait_up(): + check("target reachable", False) + return 1 + check("target reachable", True) + + pv = await fleet.mcpd(args.target).request("proto.version", {}) + print(f" {pv}", flush=True) + + # A previous run may have left the sentinel behind, which would + # make section A meaningless. + if await r.sentinel_present(): + log("sentinel left over from an earlier run -- removing") + await fs_tool.fs_delete(fleet, args.target, SENTINEL) + if not await r.restart("start from a closed gate"): + return 1 + + try: + await r.section_gate_closed() + if await r.section_enable(): + await r.section_inject() + await r.section_guards() + await r.section_disable() + finally: + if r.restart_cycles and not args.keep_running: + log("stopping guest") + try: + await qemu_tool.qemu_stop(fleet, args.target) + except Exception as e: + print(f" (stop: {e})", flush=True) + + failed = [n for n, ok, _ in results if not ok] + print("\n" + "=" * 62, flush=True) + print(f"{len(results) - len(failed)}/{len(results)} checks passed", + flush=True) + for n in failed: + print(f" FAILED: {n}", flush=True) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) From 0c13cacb0f336d857c6dda88b47b2b378496b60a Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Thu, 10 Sep 2026 23:44:39 +0100 Subject: [PATCH 21/23] validate_input: real-hardware support, and the ring is not authoritative Verified on the real X5000 (P5020, Radeon RTG at 1600x900, AOS 4.1 FE): 23/27, with every functional check passing and the four failures all being debug-ring assertions that the machine cannot satisfy. - `--restart-mode cold` reboots via sys.cold_reboot with an MCU power-cycle fallback, so the gate cycle runs unattended on hardware instead of prompting. `--restart-mode manual` still prompts. - Typing checks no longer require a 9p share. With `--shared-dir` the output is read off the host side of SHARED:; without it -- which is every real machine -- the same files go to `T:` and come back via fs.read. Skipping them on hardware would have skipped the single most important check. - The debug-ring assertions now SKIP instead of FAIL when the ring holds no MCPd output at all, and read 2000 lines rather than 400. That last point is the finding. **The AmigaOS kernel debug buffer does not wrap** -- once full it stops accepting entries. On this X5000 the buffer held 711 lines / 80,366 bytes, essentially all of it amdgpu-os4 output beginning at `[gpu.chip] board resource init`, and it had not grown by a single byte after 60 s with two MCPd daemons running and the GPU actively repainting. It contains zero `[MCPd]` lines: the graphics driver exhausts the buffer during boot, before MCPd is started from S:Network-Startup, so the daemon's beacons cannot land there. A second MCPd launched by hand on a spare port confirmed it -- it bound the port and still logged nothing. So the beacons work (both appear on QEMU) but the ring is not a trustworthy annunciator on a machine like this. SECURITY.md now says `proto.capabilities` is the authoritative gate reading, and COMMANDS.md documents the non-wrapping buffer against sys.debug_ring with the raw_size-growth test for spotting it -- it equally blinds sandbox.last_trap and any post-boot forensics on that machine. Hardware results worth recording: `Echo >T:...` produced exactly b'caf\xe9' through keymap.library on the real board, so the UTF-8 decode is confirmed off-QEMU; absolute pointer moves landed exactly on target (800,450), so pointer acceleration did not distort them as feared; the drag moved a window; both confirm guards rejected; and the gate opened and closed correctly across two cold reboots. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- COMMANDS.md | 2 +- SECURITY.md | 9 ++ scripts/validate_input.py | 200 +++++++++++++++++++++++++++----------- 3 files changed, 155 insertions(+), 56 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index 49cd0d2..54d11c7 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -83,7 +83,7 @@ prerequisites](INSTALL.md#per-feature-prerequisites). | `sys.read_pa` | DANGEROUS supervisor-mode read at any 36-bit physical address. No range checking; misuse can crash the connection-handling task (the daemon listener stays up by design). | | `sys.tlb_dump` | Dump all 64 TLB1 entries from a P5020 / P1022 target via `tlbre` / MAS registers. | | `sys.mcu_cmd` | X5000 Cyrus MCU supervisor protocol over `serial.device` unit 1 (38400 8N1). Documented commands `t` / `v` / `f` / `s` plus build-info `b`. The canonical sensor path on X5000 (board / CPU temperatures, voltage rails, fan PWM/RPM, soft power-off). | -| `sys.debug_ring` | Read the kernel debug ring via `c:DumpDebugBuffer`. Returns the most recent `max_lines` lines, the truncation flag, raw byte size, and an ISO-8601 host capture timestamp. Useful for post-install forensics, hardware bring-up, and as the primitive that `sandbox.last_trap` filters against. `since_s` is reserved for a future timestamp-aware trim — accepted but not yet used (DumpDebugBuffer entries don't carry per-line timestamps). | +| `sys.debug_ring` | Read the kernel debug ring via `c:DumpDebugBuffer`. Returns the most recent `max_lines` lines, the truncation flag, raw byte size, and an ISO-8601 host capture timestamp. Useful for post-install forensics, hardware bring-up, and as the primitive that `sandbox.last_trap` filters against. `since_s` is reserved for a future timestamp-aware trim — accepted but not yet used (DumpDebugBuffer entries don't carry per-line timestamps). **The AmigaOS debug buffer does not wrap**: once full it stops accepting entries, so on a machine with a verbose driver (a real X5000 with amdgpu-os4 fills ~80 KB during boot) nothing logged after that point is visible here — including MCPd's own beacons and any later trap. Check `raw_size` against a second call: if it doesn't grow, the buffer is exhausted and this tool can't see anything current. | ### `fs.*` diff --git a/SECURITY.md b/SECURITY.md index b9a4622..6a339fb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -99,6 +99,15 @@ These are documented behaviours, not vulnerabilities: cannot be opened it silently falls back to a built-in US table; the result's `keymap` field reports which path ran, and callers who care should check it. +- MCPd announces the gate at startup in the kernel debug ring + (`[MCPd] input_gate state=... source=...`, plus `input=on|off` on + the readiness beacon), because its stdout banner goes to `NIL:` on + the watchdog auto-start path. **Do not treat the ring as the + authoritative reading**: AmigaOS's debug buffer does not wrap, so on + a machine whose buffer is already full when MCPd starts -- a real + X5000 with a verbose graphics driver fills ~80 KB during boot -- the + daemon's lines never land there at all. `proto.capabilities` + (`input.enabled`) is the reading that is always correct. - `input.*` calls are capped per call (256 events, 20 s wall clock, 512 characters of text) and the daemon always releases any held modifier or mouse button before returning, including on the abort diff --git a/scripts/validate_input.py b/scripts/validate_input.py index 42d87e2..b51d1fd 100644 --- a/scripts/validate_input.py +++ b/scripts/validate_input.py @@ -31,14 +31,17 @@ The typed output is written to the guest's `SHARED:` volume (a 9p share pointing at a host directory) so the bytes can be inspected on the host rather than read back through the same daemon that typed -them. Pass `--shared-dir` to point at the host side of that share; the -typing checks are skipped when it isn't given. +them. Pass `--shared-dir` to point at the host side of that share. +Without it -- real hardware has no such share -- the same files go to +`T:` and are read back with `fs.read`: less independent, but still +the Shell's own output rather than `input.type`'s self-report. QEMU targets only for the restart cycles: the script kills and relaunches the QEMU process, because neither QMP `system_reset` nor a guest-side Reboot works reliably for AmigaOS 4 guests. On real -hardware the daemon has to be restarted out of band, so run this with -`--no-restart-cycles` there and drive the reboots yourself. +hardware use `--restart-mode cold`, which reboots via +`sys.cold_reboot` and falls back to an MCU power cycle when the board +doesn't come back; `--restart-mode manual` prompts instead. Usage: python scripts/validate_input.py --target qemu-pegasos2-sm501 \\ @@ -66,6 +69,7 @@ from amiga_fleet_mcp.tools import exec as exec_tool from amiga_fleet_mcp.tools import fs as fs_tool from amiga_fleet_mcp.tools import input as input_tool +from amiga_fleet_mcp.tools import power as power_tool from amiga_fleet_mcp.tools import qemu as qemu_tool from amiga_fleet_mcp.tools import sys as sys_tool @@ -110,11 +114,14 @@ def port_open(endpoint: str, timeout: float = 1.0) -> bool: class Runner: def __init__(self, fleet: Fleet, target: str, *, - shared_dir: Path | None, restart_cycles: bool) -> None: + shared_dir: Path | None, restart_mode: str) -> None: self.fleet = fleet self.target = target self.shared = shared_dir - self.restart_cycles = restart_cycles + # "qemu" kill + relaunch the QEMU process + # "cold" sys.cold_reboot, with an MCU power-cycle fallback + # "manual" prompt the operator and wait + self.restart_mode = restart_mode self.endpoint = fleet.target_config(target).channels.mcpd.endpoint # ---- plumbing -------------------------------------------------- @@ -139,21 +146,49 @@ async def restart(self, why: str) -> bool: flush=True) await asyncio.sleep(WRITEBACK_SETTLE_S) log(f"restarting target ({why})") - if not self.restart_cycles: - print(" --no-restart-cycles: restart MCPd on the target now," - " then press Enter", flush=True) + + if self.restart_mode == "manual": + print(" restart MCPd on the target now, then press Enter", + flush=True) await asyncio.to_thread(input) - return await self.wait_up() - try: - await qemu_tool.qemu_stop(self.fleet, self.target) - except Exception as e: - print(f" (stop: {e})", flush=True) - await asyncio.sleep(5.0) - await qemu_tool.qemu_start(self.fleet, self.target) + elif self.restart_mode == "cold": + if not await self._cold_restart(): + return False + else: + try: + await qemu_tool.qemu_stop(self.fleet, self.target) + except Exception as e: + print(f" (stop: {e})", flush=True) + await asyncio.sleep(5.0) + await qemu_tool.qemu_start(self.fleet, self.target) + ok = await self.wait_up() print(f" target back: {ok}", flush=True) return ok + async def _cold_restart(self) -> bool: + """Reboot real hardware. ColdReboot doesn't always bring an + X5000 back on the first try, so fall back to an MCU power + cycle when the machine hasn't reappeared in time.""" + try: + await sys_tool.sys_cold_reboot( + self.fleet, self.target, confirm=True) + except Exception as e: + print(f" (cold_reboot: {e})", flush=True) + await asyncio.sleep(20.0) + if await self.wait_up(timeout_s=240.0): + return True + print(" cold reboot didn't come back -- MCU power cycle", + flush=True) + try: + await power_tool.power_off(self.fleet, self.target, confirm=True) + await asyncio.sleep(15.0) + await power_tool.power_on(self.fleet, self.target, confirm=True) + except Exception as e: + print(f" (power cycle failed: {e})", flush=True) + return False + return True + async def gate_open(self) -> bool: caps = await self.fleet.mcpd(self.target).request( "proto.capabilities", {}) @@ -161,9 +196,34 @@ async def gate_open(self) -> bool: async def ring(self) -> str: r = await sys_tool.sys_debug_ring( - self.fleet, self.target, max_lines=400) + self.fleet, self.target, max_lines=2000) return "\n".join(r.lines) + async def check_ring(self, name: str, needle: str) -> None: + """Assert a beacon is in the kernel debug ring -- unless this + machine's ring never captured MCPd at all. + + AmigaOS's debug buffer does not wrap: once full it stops + accepting entries. On a real X5000 with a chatty graphics + driver (amdgpu-os4 logs ~80 KB during boot) the buffer is + already exhausted before MCPd starts, so nothing the daemon + emits can land there -- measured: 711 lines, zero growth over + 60 s with two daemons running. That is a property of the + machine, not of the gate, so report it as SKIP rather than + failing a check the daemon cannot influence. The authoritative + gate reading is proto.capabilities, which is checked either + way. + """ + ring = await self.ring() + if needle in ring: + check(name, True) + elif "[MCPd]" not in ring: + print(f" [SKIP] {name} -- this machine's debug buffer holds " + "no MCPd output at all (full before the daemon " + "started; AmigaOS's ring does not wrap)", flush=True) + else: + check(name, False, "MCPd is in the ring but this line isn't") + async def sentinel_present(self) -> bool: try: await self.fleet.mcpd(self.target).request( @@ -172,16 +232,45 @@ async def sentinel_present(self) -> bool: except Exception: return False - def read_shared(self, name: str) -> bytes: - assert self.shared is not None - p = self.shared / name + def typed_path(self, name: str) -> str: + """Where the Shell should redirect its output to.""" + return f"SHARED:{name}" if self.shared is not None else f"T:{name}" + + async def clear_typed(self, name: str) -> None: + if self.shared is not None: + (self.shared / name).unlink(missing_ok=True) + return + try: + await fs_tool.fs_delete(self.fleet, self.target, f"T:{name}") + except Exception: + pass + + async def read_typed(self, name: str) -> bytes: + """Read back what the Shell actually received. + + Preferred route is the host side of the guest's 9p SHARED: + volume, so the bytes are inspected outside the daemon that + typed them. Real hardware has no such share, so fall back to + fs.read of a T: file -- less independent, but still the + Shell's own output rather than input.type's self-report. + """ + if self.shared is not None: + p = self.shared / name + for _ in range(10): + if p.exists(): + try: + return p.read_bytes() + except OSError: + pass + time.sleep(1.0) + return b"" for _ in range(10): - if p.exists(): - try: - return p.read_bytes() - except OSError: - pass - time.sleep(1.0) + try: + r = await fs_tool.fs_read( + self.fleet, self.target, f"T:{name}") + return base64.b64decode(r.content_b64) + except Exception: + await asyncio.sleep(1.0) return b"" # ---- A: gate closed -------------------------------------------- @@ -221,10 +310,9 @@ async def section_gate_closed(self) -> None: refused, leaked = await self.all_refused() check("all 7 methods refuse", refused == 7 and not leaked, f"refused={refused} leaked={leaked}") - ring = await self.ring() - check("debug ring shows input_gate state=disabled", - "input_gate state=disabled" in ring) - check("ready beacon carries input=off", "input=off" in ring) + await self.check_ring("debug ring shows input_gate state=disabled", + "input_gate state=disabled") + await self.check_ring("ready beacon carries input=off", "input=off") # ---- B: enable -------------------------------------------------- @@ -240,10 +328,9 @@ async def section_enable(self) -> bool: return check("target returns after enabling", False) check("proto.capabilities reports input.enabled true", await self.gate_open() is True) - ring = await self.ring() - check("debug ring shows input_gate state=ENABLED", - "input_gate state=ENABLED" in ring) - check("ready beacon carries input=on", "input=on" in ring) + await self.check_ring("debug ring shows input_gate state=ENABLED", + "input_gate state=ENABLED") + await self.check_ring("ready beacon carries input=on", "input=on") return True # ---- C-F: the actual injection ---------------------------------- @@ -266,11 +353,7 @@ async def section_inject(self) -> None: mv.x == mx and mv.y == my, f"asked ({mx},{my}) got ({mv.x},{mv.y})") - if self.shared is None: - print("\n (skipping typing checks: no --shared-dir)", - flush=True) - else: - await self._section_typing() + await self._section_typing() log("F. input.drag + input.scroll") before = await input_tool.input_state(f, t) @@ -295,7 +378,6 @@ async def section_inject(self) -> None: async def _section_typing(self) -> None: f, t = self.fleet, self.target - assert self.shared is not None log("E. typing -- a Shell to receive the keystrokes") await exec_tool.exec_cmd( @@ -307,14 +389,16 @@ async def _section_typing(self) -> None: f"active_window={st.active_window!r}"): return - (self.shared / "mcpd-typed.txt").unlink(missing_ok=True) + await self.clear_typed("mcpd-typed.txt") r = await input_tool.input_type( - f, t, text="Echo TYPED-OK >SHARED:mcpd-typed.txt", confirm=True) + f, t, + text=f"Echo TYPED-OK >{self.typed_path('mcpd-typed.txt')}", + confirm=True) await input_tool.input_key(f, t, keys=["return"], confirm=True) await asyncio.sleep(3.0) - check("ASCII string reaches the Shell", - self.read_shared("mcpd-typed.txt").strip() == b"TYPED-OK", - f"keymap={r.keymap} unmapped={r.unmapped}") + got = (await self.read_typed("mcpd-typed.txt")).strip() + check("ASCII string reaches the Shell", got == b"TYPED-OK", + f"file={got!r} keymap={r.keymap} unmapped={r.unmapped}") log("E. typing -- non-ASCII (UTF-8 decode)") r2 = await input_tool.input_type(f, t, text=ACUTE, confirm=True) @@ -334,12 +418,14 @@ async def _section_typing(self) -> None: await input_tool.input_key(f, t, keys=["ctrl", "x"], confirm=True) await asyncio.sleep(1.0) - (self.shared / "mcpd-typed2.txt").unlink(missing_ok=True) + await self.clear_typed("mcpd-typed2.txt") await input_tool.input_type( - f, t, text=f"Echo {ACUTE} >SHARED:mcpd-typed2.txt", confirm=True) + f, t, + text=f"Echo {ACUTE} >{self.typed_path('mcpd-typed2.txt')}", + confirm=True) await input_tool.input_key(f, t, keys=["return"], confirm=True) await asyncio.sleep(3.0) - raw = self.read_shared("mcpd-typed2.txt").strip() + raw = (await self.read_typed("mcpd-typed2.txt")).strip() check("the Shell received 4 characters, not 5", len(raw) == 4, f"got {len(raw)} bytes: {raw!r}") @@ -406,10 +492,14 @@ async def main() -> int: help="target name from the amiga-fleet-mcp config") ap.add_argument("--shared-dir", help="host side of the guest's SHARED: volume; " - "typing checks are skipped without it") - ap.add_argument("--no-restart-cycles", action="store_true", - help="don't kill/relaunch QEMU -- prompt for a manual " - "MCPd restart instead (use on real hardware)") + "without it the typed output goes to T: and is " + "read back with fs.read") + ap.add_argument("--restart-mode", choices=("qemu", "cold", "manual"), + default="qemu", + help="how to restart the target between gate changes: " + "kill+relaunch QEMU (default), sys.cold_reboot " + "with an MCU power-cycle fallback (real hardware), " + "or prompt and wait") ap.add_argument("--keep-running", action="store_true", help="leave the QEMU guest running at the end") args = ap.parse_args() @@ -431,9 +521,9 @@ async def main() -> int: return 2 r = Runner(fleet, args.target, shared_dir=shared, - restart_cycles=not args.no_restart_cycles) + restart_mode=args.restart_mode) - if r.restart_cycles: + if r.restart_mode == "qemu": # A QEMU left behind by an earlier run invalidates everything: # the second instance can't bind the same hostfwd port, so this # script would end up talking to the OLD guest, with whatever @@ -467,7 +557,7 @@ async def main() -> int: await r.section_guards() await r.section_disable() finally: - if r.restart_cycles and not args.keep_running: + if r.restart_mode == "qemu" and not args.keep_running: log("stopping guest") try: await qemu_tool.qemu_stop(fleet, args.target) From b711eef71ac5bd0bbea649a882e7aac1c1e499f3 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Fri, 11 Sep 2026 00:20:29 +0100 Subject: [PATCH 22/23] =?UTF-8?q?Release=201.3=20=E2=80=94=20sandboxed=20i?= =?UTF-8?q?teration,=20screen=20capture,=20and=20input=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the unreleased changelog block to 1.3 and bring the host package into line with the daemon, which develop had already bumped: - host/pyproject.toml: 1.2 -> 1.3 - host/src/amiga_fleet_mcp/__init__.py: 1.2 -> 1.3 - AGENTS_SETUP.md tool-count expectation: now "137 as of v1.3" - mcpd was already 1.3 (MCPD_VERSION_STR in rpc.h, which main.c and the Makefile now derive from rather than repeating) Headlines: the sandbox.* namespace over SandboxVM, wb.screenshot, sys.debug_ring, and the input.* keyboard/mouse injection surface -- the last of which is off by default at the daemon and needs an operator action on the target itself to open. 121 -> 137 tools, 12 -> 14 namespaces. Wire protocol stays at 1.0: every addition is additive and proto.capabilities derives its method list dynamically, so an older host talks to a 1.3 daemon fine and an older daemon simply reports no input capability. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- AGENTS_SETUP.md | 2 +- CHANGELOG.md | 2 +- host/pyproject.toml | 2 +- host/src/amiga_fleet_mcp/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS_SETUP.md b/AGENTS_SETUP.md index 3655cca..7448f8f 100644 --- a/AGENTS_SETUP.md +++ b/AGENTS_SETUP.md @@ -163,7 +163,7 @@ amiga-fleet-mcp --inspect # 2. List the live tool surface (no target reach required). amiga-fleet-mcp --list-tools | wc -l -# Expected: 137 tools on the current develop branch (121 at v1.2). +# Expected: 137 tools as of v1.3 (121 at v1.2). # 3. Probe every target's MCPd channel (target reach required). amiga-fleet-mcp --health-check diff --git a/CHANGELOG.md b/CHANGELOG.md index 374d93e..7e74b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change log -## Unreleased +## 1.3 — Sandboxed iteration, screen capture, and input injection ### Added diff --git a/host/pyproject.toml b/host/pyproject.toml index 6fe0d14..1f6a91e 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "amiga-fleet-mcp" -version = "1.2" +version = "1.3" description = "MCP server for driving AmigaOS 4 fleets (QEMU + real hardware)" readme = "README.md" requires-python = ">=3.11" diff --git a/host/src/amiga_fleet_mcp/__init__.py b/host/src/amiga_fleet_mcp/__init__.py index 0e2dfe4..ef5d223 100644 --- a/host/src/amiga_fleet_mcp/__init__.py +++ b/host/src/amiga_fleet_mcp/__init__.py @@ -1,4 +1,4 @@ """amiga-fleet-mcp — MCP server for AmigaOS 4 fleets.""" -__version__ = "1.2" +__version__ = "1.3" __all__ = ["__version__"] From cea8c7b226b9a8c615220ddfb8f86070db1c5f9a Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Fri, 11 Sep 2026 00:24:50 +0100 Subject: [PATCH 23/23] deps: cap mcp and pydantic majors so CI resolves something buildable `host/uv.lock` is gitignored, so CI resolves dependencies fresh on every run. `mcp[cli]>=1.0.0` therefore picked up mcp 2.2.0, which removed `from mcp.server.fastmcp import FastMCP` -- the entry point server.py is written against -- and every host job failed the type-check on a repo change that had nothing to do with it. The last green run was 2026-06-16, so this has been latent since mcp 2.0 shipped rather than being introduced here. Cap both majors: mcp[cli]>=1.0.0,<2 and pydantic>=2.0,<3. Porting to the 2.x API is real work with its own testing, and doing it blind to unblock a release would be the wrong trade. Worth considering separately: committing host/uv.lock. For an application rather than a library that is the usual answer, and it would have prevented this entirely -- but the ignore rule looks deliberate, so leaving that call to the maintainer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- CHANGELOG.md | 11 +++++++++++ host/pyproject.toml | 11 +++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e74b35..fc9a650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -196,6 +196,17 @@ from its inherited Output() handle). The probe now treats any structured `exec.cmd` exit as proof the binary started. +### Fixed + +- **Dependency majors are now capped** (`mcp[cli]>=1.0.0,<2`, + `pydantic>=2.0,<3`). `uv.lock` is not committed, so CI resolves + dependencies fresh on every run; the unbounded `mcp` requirement + picked up mcp 2.2.0, which removed the 1.x + `from mcp.server.fastmcp import FastMCP` entry point this server is + written against, and the type-check failed for reasons unrelated to + anything in the repo. Lift the cap in a change that ports to the 2.x + API and is tested against it. + ### Known limitations (`input.*`) - F11/F12 are deliberately absent from the rawkey table: their AOS4 diff --git a/host/pyproject.toml b/host/pyproject.toml index 1f6a91e..363fcdd 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -24,8 +24,15 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "mcp[cli]>=1.0.0", - "pydantic>=2.0", + # Upper-bounded on purpose. This server is written against the 1.x + # FastMCP API (`from mcp.server.fastmcp import FastMCP`), which mcp + # 2.0 removed -- CI resolves fresh on every run (uv.lock is not + # committed), so an unbounded requirement silently upgraded to + # mcp 2.2.0 and broke the type-check for reasons unrelated to any + # change in this repo. Lift the cap in a change that actually ports + # to the 2.x API and is tested against it. + "mcp[cli]>=1.0.0,<2", + "pydantic>=2.0,<3", "pyserial>=3.5", ]