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/AGENTS_SETUP.md b/AGENTS_SETUP.md index 93eac25..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: 121 tools as of 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 @@ -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 57ef041..fc9a650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,235 @@ # Change log +## 1.3 — Sandboxed iteration, screen capture, and input injection + +### 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 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` + 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. 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 + 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 / + 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. + +- **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: :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. + +### 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 + 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 cd174e8..54d11c7 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) @@ -82,6 +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). **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.*` @@ -123,6 +125,40 @@ 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). | + +### `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.*` @@ -165,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` | @@ -217,9 +254,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 +265,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 +315,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 @@ -290,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.*` | @@ -299,6 +369,7 @@ to script an operation by method name rather than by tool name. | `installer` | `installer.*` | | `serial` | `serial.*` | | `power` | `power.*` | +| `sandbox` | `sandbox.*` | ## MCP resources @@ -331,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. | @@ -343,7 +415,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 @@ -366,8 +438,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/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/INSTALL.md b/INSTALL.md index 4b64ebc..06fddc7 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. @@ -402,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 @@ -413,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 8c75626..cfc3e47 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. + +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 @@ -86,13 +93,14 @@ 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.*`, + `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.) - Eight live MCP resources for fleet status, per-target system snapshots, capability reports, and serial logs. - Parallel multi-target operations (`fleet.run_on_all`, @@ -110,10 +118,15 @@ context in [USAGE.md](USAGE.md). - Auto-start install integration: a single command deploys MCPd to `SYS:System/MCPd/`, registers a watchdog wrapper, and patches `S:Network-Startup`. Boot-to-bind is approximately eleven seconds on - an AmigaOne X5000. + an AmigaOne X5000. On start the daemon writes a machine-readable + `[MCPd] ready name=… version=… build_date=… build_time=… port=…` + line to the kernel debug ring — readable via `sys.debug_ring` or a + `serial.*` capture — so a boot-time start is observable even though + the auto-start path sends stdout to `NIL:`. See + [mcpd/README.md](mcpd/README.md#startup-beacon). - 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/SECURITY.md b/SECURITY.md index 76f317f..6a339fb 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,25 @@ 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. +- 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 + 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 298ee66..ef3a74a 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) | @@ -277,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 @@ -333,6 +425,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 diff --git a/host/config.example.toml b/host/config.example.toml index f6b5709..c9b8fda 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 @@ -49,8 +53,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 +60,6 @@ mcp_transport = "stdio" # stdio | sse | streamable-http # # dest_volume = "BootTest:" # sources_dir = "" -# bootstrap_dir = "" # machine = "X5000" # ---- Example: QEMU Pegasos2 target ------------------------------- @@ -102,3 +103,37 @@ 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 + + # 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/pyproject.toml b/host/pyproject.toml index 6fe0d14..363fcdd 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" @@ -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", ] 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__"] 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 diff --git a/host/src/amiga_fleet_mcp/config.py b/host/src/amiga_fleet_mcp/config.py index 1dc70e3..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.""" @@ -165,6 +189,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 +226,13 @@ class TargetConfig(BaseModel): headless: bool = False tags: list[str] = Field(default_factory=list) 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): @@ -188,6 +244,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): @@ -203,7 +263,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..e223f4a 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/`. @@ -1300,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) @@ -1315,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 @@ -1354,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, } @@ -1361,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/server.py b/host/src/amiga_fleet_mcp/server.py index 2fa09b3..3b739af 100644 --- a/host/src/amiga_fleet_mcp/server.py +++ b/host/src/amiga_fleet_mcp/server.py @@ -28,9 +28,11 @@ 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 +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 @@ -364,6 +366,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: @@ -744,6 +768,176 @@ 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, + ) + + # ---------- 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") @@ -1369,6 +1563,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, @@ -1409,13 +1604,38 @@ 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="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") @@ -1750,10 +1970,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 +2024,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 +2056,6 @@ async def installer_stage( machine=machine, iso_filename=iso_filename, iso_lha_path=iso_lha_path, - bootstrap_dir=bootstrap_dir, confirm=confirm, ) @@ -2085,6 +2305,232 @@ 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_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_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( + *, 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) + 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) + + 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, + ) + 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 == "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( + fleet, fleet.resolve_target(p.get("target")), **kwargs, + ) + raise MethodNotFound( + f"unknown method: {method!r}", + data={"namespace_methods": [ + "probe", "deploy", "run_guest", + "run_driver", "run_batch", "last_trap", + ]}, + ) + def _build_runtime(config: Config) -> tuple[Fleet, Archive]: fleet = Fleet(config) diff --git a/host/src/amiga_fleet_mcp/setup_wizard.py b/host/src/amiga_fleet_mcp/setup_wizard.py index 4b29893..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 @@ -294,7 +312,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/fleet.py b/host/src/amiga_fleet_mcp/tools/fleet.py index 3739382..21bd680 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,23 @@ 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, + # 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 + # 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/src/amiga_fleet_mcp/tools/input.py b/host/src/amiga_fleet_mcp/tools/input.py new file mode 100644 index 0000000..d681129 --- /dev/null +++ b/host/src/amiga_fleet_mcp/tools/input.py @@ -0,0 +1,462 @@ +"""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 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") + 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/src/amiga_fleet_mcp/tools/installer.py b/host/src/amiga_fleet_mcp/tools/installer.py index e9b789a..d64ae54 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 @@ -1124,38 +1100,52 @@ 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. -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 +1155,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 +1225,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,17 +1292,30 @@ 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 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 @@ -1352,10 +1346,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/src/amiga_fleet_mcp/tools/sandbox.py b/host/src/amiga_fleet_mcp/tools/sandbox.py new file mode 100644 index 0000000..60b93df --- /dev/null +++ b/host/src/amiga_fleet_mcp/tools/sandbox.py @@ -0,0 +1,1444 @@ +"""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 re +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 +from . import sys as sys_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.""" + +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.""" + +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. +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``, ``SANDBOXVM_TARGET_UNREACHABLE``. + ``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 _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] = [] + 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) -> 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( + "exec.cmd", + { + "command": f'"{path}"', + "timeout_ms": 5000, + }, + timeout_s=10.0, + ) + except Exception: + return False, None + output = str(raw.get("output", "")).strip() + banner = output.splitlines()[0] if output else None + return True, banner + + +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) + transport_error: Exception | None = None + for path in candidates: + try: + await mcpd.request("fs.stat", {"path": path}) + found = path + break + 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: + # Non-TargetError exception (asyncio.TimeoutError, + # OSError if it ever leaks past the transport, etc.). + 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( + target=target, available=False, + code="SANDBOXVM_MISSING", + searched=candidates, + machine=machine, machine_ok=machine_ok, + 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 + # `sandbox.deploy` would otherwise have to explicitly + # invalidate before subsequent calls work. + return result + + ran, banner = await _execute_banner_probe(fleet, target, found) + if not ran: + 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 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_MISSING", + searched=[path], + machine=machine, machine_ok=machine_ok, + ) + + ran, banner = await _execute_banner_probe(fleet, target, path) + if not ran: + 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}, + ) + 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, + "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, + resident_driver: bool = False, + test_elf: str | None = None) -> str: + """Construct the AmigaDOS command line for one guest run. + + 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 + 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)]) + if resident_driver: + parts.append("-r") + if test_elf: + parts.extend(["-t", f'"{test_elf}"']) + 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 _run_sandboxvm( + fleet: Fleet, target: str, *, + guest: str, + 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: + """Shared core for `sandbox_run_guest` and `sandbox_run_driver`. + + 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 + + # 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, + resident_driver=resident_driver, + test_elf=test_elf, + ) + + # 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, + ) + + +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, + ) + + +# --------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------- + + +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/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/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_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_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_input.py b/host/tests/unit/test_input.py new file mode 100644 index 0000000..e91f845 --- /dev/null +++ b/host/tests/unit/test_input.py @@ -0,0 +1,447 @@ +"""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_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() + 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_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..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: @@ -120,23 +125,33 @@ 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 + 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 == [] - # 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 == [] + # 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 @pytest.mark.asyncio @@ -169,7 +184,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..db3199a 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", ] @@ -294,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/host/tests/unit/test_sandbox.py b/host/tests/unit/test_sandbox.py new file mode 100644 index 0000000..edb883a --- /dev/null +++ b/host/tests/unit/test_sandbox.py @@ -0,0 +1,1223 @@ +"""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 +from amiga_fleet_mcp.tools import sys as sys_tool + +# ---- 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) -> None: + """Add a one-shot canned response for the next exec.cmd call + whose params satisfy `matcher` (callable taking the params + 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): + 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) + if isinstance(resp, Exception): + raise resp + 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 "") + # 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. + 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") + # 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"), + ) + + 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_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_unreachable_target_returns_typed_code(fleet_with_fake): + """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 + + # 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 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 "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 + 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 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: + await sb.sandbox_run_guest( + fleet, "tgt", guest="Tools:hello", + ) + 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")) + 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 + + +# ---- 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 + + +# ---- 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 + + +# ---- 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" 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/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 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..6fe84b0 100644 --- a/mcpd/Makefile +++ b/mcpd/Makefile @@ -17,6 +17,14 @@ LDFLAGS ?= -lauto BUILD_DATE := $(shell date +%d.%m.%Y) MCPD_CFLAGS = -DMCPD_DATE=\"$(BUILD_DATE)\" +# Build time (HH:MM:SS, 24h). Deliberately NOT part of the $VER cookie - +# AmigaDOS `Version` parses "(DD.MM.YYYY)" and a time component would +# break it. Surfaced via the startup debug line, `MCPd --version`, +# proto.version and proto.capabilities instead, so two same-day builds +# are still distinguishable. +BUILD_TIME := $(shell date +%H:%M:%S) +MCPD_CFLAGS += -DMCPD_TIME=\"$(BUILD_TIME)\" + # AmigaOS 4 SDK + toolchain identifier embedded into MCPd. Surfaced # via `MCPd --version`, `proto.version`, and `proto.capabilities` # build.sdk so users / CI can see exactly which SDK produced a given @@ -30,7 +38,7 @@ SDK_VERSION ?= AmigaOS 4.1 FE SDK 54.16 + GCC 11.5 (walkero/amigagccondocker:os4 MCPD_CFLAGS += -DMCPD_SDK_VERSION="\"$(SDK_VERSION)\"" # Distribution -VERSION = 1.1 +VERSION = 1.3 SRC_DIR = src METHODS_DIR = $(SRC_DIR)/methods @@ -49,6 +57,8 @@ SRCS = \ $(METHODS_DIR)/exec.c \ $(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/README.md b/mcpd/README.md index 44dad35..c3b2f84 100644 --- a/mcpd/README.md +++ b/mcpd/README.md @@ -76,3 +76,70 @@ MCPd --version In production deployments the daemon is launched by the watchdog script (`MCPd-Watchdog`) from `S:Network-Startup`. See [INSTALL.md](../INSTALL.md) for the auto-start install procedure. + +## Startup beacon + +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 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: + +``` +[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: 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 62496a5..1246789 100644 --- a/mcpd/src/main.c +++ b/mcpd/src/main.c @@ -27,14 +27,35 @@ extern void applib_register(void); extern void applib_shutdown(void); -#define MCPD_VERSION "1.2" +/* 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); + +/* 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 #define BACKLOG 4 +/* Priority of the listener process itself. The accept+spawn loop burns + * almost no CPU, so running it just above Workbench (0) keeps MCPd + * responsive to new connections even while the machine is loaded. + * The per-connection workers stay at -1 (see spawn_client_worker) so + * the actual heavy RPC work still yields to the user. */ +#define MCPD_LISTENER_PRI 1 + #ifndef MCPD_DATE #define MCPD_DATE "00.00.0000" #endif +#ifndef MCPD_TIME +#define MCPD_TIME "00:00:00" +#endif + /* AmigaOS 4 SDK identifier this build was compiled against. Set * from the Makefile (-DMCPD_SDK_VERSION=...) so the daemon can * report exactly which SDK + cross-compile toolchain produced it. @@ -363,14 +384,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 @@ -381,7 +411,8 @@ int main(int argc, char **argv) { for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--version") == 0) { - IDOS->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; } @@ -398,12 +429,25 @@ 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; } - 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 +456,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; @@ -443,9 +490,36 @@ 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); + /* 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 input=%s\n", + MCPD_VERSION, MCPD_DATE, MCPD_TIME, + (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 * lifecycle. The single-client wedge that bit us when an old @@ -480,5 +554,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/input.c b/mcpd/src/methods/input.c new file mode 100644 index 0000000..492bb25 --- /dev/null +++ b/mcpd/src/methods/input.c @@ -0,0 +1,1704 @@ +/* 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; + + 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 + * 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", + 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); +} + + +/* 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 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 }, + { "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. + * + * ------------------------------------------------------------------ + * THIS IS THE FALLBACK PATH, NOT THE DEFAULT. + * + * 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". + * + * 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; + +#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}, +}; + +/* ---- 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) { + 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, 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 + * + * 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(); + 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; + } + /* 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; + } + + /* 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; ) { + 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 + * 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[12]; + snprintf(one, sizeof(one), "U+%04X", (unsigned)cp); + 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[12]; + snprintf(one, sizeof(one), "U+%04X", (unsigned)cp); + 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)nchars); + 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 8ab7d48..e7f745f 100644 --- a/mcpd/src/methods/methods.h +++ b/mcpd/src/methods/methods.h @@ -79,6 +79,18 @@ MCPD_HANDLER(wb_screens); MCPD_HANDLER(wb_windows); 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); diff --git a/mcpd/src/methods/proto.c b/mcpd/src/methods/proto.c index 13713ac..f24c85c 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 @@ -36,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; @@ -48,6 +55,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); @@ -89,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()); @@ -106,6 +126,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/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..37f25c6 100644 --- a/mcpd/src/rpc.c +++ b/mcpd/src/rpc.c @@ -154,6 +154,21 @@ 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." }, + { "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 (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, + "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 80123c0..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.2" +#define MCPD_SERVER_VERSION "mcpd/" MCPD_VERSION_STR typedef int (*method_handler_fn)(cJSON *params, cJSON **out_result, 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"): diff --git a/scripts/validate_input.py b/scripts/validate_input.py new file mode 100644 index 0000000..b51d1fd --- /dev/null +++ b/scripts/validate_input.py @@ -0,0 +1,577 @@ +"""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. +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 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 \\ + --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 power as power_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_mode: str) -> None: + self.fleet = fleet + self.target = target + self.shared = shared_dir + # "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 -------------------------------------------------- + + 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 self.restart_mode == "manual": + print(" restart MCPd on the target now, then press Enter", + flush=True) + await asyncio.to_thread(input) + 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", {}) + 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=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( + "fs.stat", {"path": SENTINEL}) + return True + except Exception: + return False + + 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): + 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 -------------------------------------------- + + 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}") + 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 -------------------------------------------------- + + 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) + 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 ---------------------------------- + + 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})") + + 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 + + log("E. typing -- a Shell to receive the keystrokes") + await exec_tool.exec_cmd( + f, t, "Run >NIL: {self.typed_path('mcpd-typed.txt')}", + confirm=True) + await input_tool.input_key(f, t, keys=["return"], confirm=True) + await asyncio.sleep(3.0) + 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) + check("text_len counts characters, not UTF-8 bytes", + r2.text_len == 4, + f"text_len={r2.text_len} (4 = decoded, 5 = a byte walk)") + check("one accented character is at most one unmapped entry", + len(r2.unmapped) <= 1, + f"unmapped={r2.unmapped} chars_mapped={r2.chars_mapped} " + f"keymap={r2.keymap}") + check("no unmapped entry is a bare UTF-8 continuation byte", + not any(u in ("U+00C3", "U+00A9", "0xC3", "0xA9") + for u in r2.unmapped), + f"unmapped={r2.unmapped}") + # That typed onto the command line without a Return; clear it + # so the next command doesn't land behind it. + await input_tool.input_key(f, t, keys=["ctrl", "x"], confirm=True) + await asyncio.sleep(1.0) + + await self.clear_typed("mcpd-typed2.txt") + await input_tool.input_type( + 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 = (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}") + + 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; " + "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() + + 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_mode=args.restart_mode) + + 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 + # 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_mode == "qemu" 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()))