Repository navigation
Conversation
The diskimage tools (MountDiskImage / diskimage.device / CDFileSystem) ship with AOS 4.1, so the running AmigaOS already has them and the dest drive picks up fresh copies via copy_base_os from the install ISO. installer.preflight + installer.stage no longer require a host-side diskimage-bootstrap/ directory; stage_diskimage_tools is now a probe that fails loudly if the running system is missing any of the three tools. AmiDock.amiga.com.xml ships as a bundled package resource under installer/resources/ — the previous behaviour silently no-op'd patch_amidock_prefs when the user hadn't staged a copy by hand. New dismount_combi_device step runs after unmount_iso to clean up the COMBI: scaffolding the install creates: ejects the unit (belt- and-braces, even though unmount_iso already did), deletes DEVS:DOSDrivers/COMBI when it carries the installer's marker, and issues `c:Dismount COMBI: FORCE`. (Assign DEVICE: REMOVE doesn't work for Mount-style DOS devices on AOS4 — only true assigns. And Dismount with media still inserted has been observed to wedge.) Validated end-to-end on real X5000 → TEST: + TEST2: drives. Drops [defaults] bootstrap_dir field; --init wizard no longer prompts for it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps SandboxVM (the AOS4 in-process sandbox host) so an agent can
deploy a guest ELF and run it on a target without power-cycling on
every crash. Three host-side tools registered:
sandbox.probe — path resolution + executability + Pegasos2
refusal (raises NotCapable). Eager; bypasses
the 60 s in-process probe cache.
sandbox.deploy — fs.upload wrapper; resolves source from
[paths] sandboxvm, dest from
[targets.<name>.sandbox.path] (defaults to
SYS:Tools/sandboxvm). Verifies via SHA-256 and
re-probes the just-uploaded path so the cache
can't be shadowed by a legacy default-path
binary. confirm=True required.
sandbox.run_guest — runs one guest ELF, slurps T:sandboxvm-
<name>.{out,err}, decodes SandboxVM's exit-code
convention into trap_kind (DSI/ISI/alignment/
program/fp_unavailable), and matches the
documented kmod-libcall NULL+4 fingerprint
against captured stderr. Per-target asyncio
lock so concurrent calls serialise instead of
racing the T:capture files.
Every public sandbox.* tool routes through _ensure_available first,
which keeps a 60 s positive-result cache and raises typed errors
(InvalidParams for SANDBOXVM_MISSING / SANDBOXVM_BROKEN, NotCapable
for SANDBOXVM_INCOMPATIBLE_TARGET) so a misconfigured target fails
fast instead of running half a workflow.
Config additions:
[paths] sandboxvm -- host source
[targets.<name>.sandbox.path] -- AOS dest
[targets.<name>.sandbox.default_extmem_mb]
[targets.<name>.sandbox.default_window_mb]
[targets.<name>.sandbox.deny_libs] -- always-on -x
Validated end-to-end: 227 unit tests pass (208 prior + 19 new),
ruff + mypy clean, server smoke check shows the four sandbox tools
register correctly (count 121 -> 124).
Phase B (sys.debug_ring + sandbox.run_driver + sandbox.last_trap)
and Phase C (sandbox.run_batch) follow in later commits per
SANDBOX_PLAN.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real-X5000 hardware testing showed sandboxvm's printf-based usage
banner is captured as empty by MCPd's exec.cmd (truncated=true,
output=""). Best guess: clib4/newlib stdio buffering not flushed
before the process detaches from its inherited Output() handle.
Result: probe declared SANDBOXVM_BROKEN even though the binary
loaded, parsed args, and returned exit code 5 (RETURN_FAIL) cleanly.
Relaxed the gate. _execute_banner_probe now returns
(ran: bool, banner: str | None) — `ran=True` whenever exec.cmd
returned a structured exit code, regardless of captured stdout
content. Probe still reports the first line of any non-empty output
as `version_banner` (best-effort), but availability no longer hinges
on it.
Validated end-to-end on real X5000:
- probe (post-deploy) -> available=true, banner=null
- run_guest hello -> exit_code 0
- run_guest crashy -> exit_code -768, trap_kind DSI
- run_guest hello-survives -> exit_code 0, AFTER the crash
(daemon survived; key Phase A
win)
20/20 sandbox unit tests pass (added an explicit
test_probe_empty_banner_still_available case to prevent regression).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a target lacks sandboxvm, the SANDBOXVM_MISSING probe result now points the caller at https://github.com/derfsss/SandboxVM in its `hint` field. Saves a fresh user from grepping docs for "where do I get the binary". Constant lives at module scope (`SANDBOXVM_UPSTREAM_URL`) so the test asserts on the exact value rather than substring-matching the URL inside the hint sentence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SandboxVM ships pre-built PPC AOS4 binaries on its GitHub releases page (v1.0 currently). The MISSING hint now leads with the /releases/latest link as the fast path, mentioning the build-from- source flow as the alternative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a kernel-debug-ring primitive in sys.* and the SandboxVM-
specific tools that consume it. Three new MCP tools (127 total).
sys.debug_ring(target=None, since_s=60.0, max_lines=500)
Wraps `c:DumpDebugBuffer` via exec.cmd. Returns the most recent
`max_lines` lines (oldest-first), the truncated flag, raw byte
size, and a UTC ISO-8601 capture timestamp. since_s is accepted
today but reserved for a future timestamp-aware trim --
DumpDebugBuffer entries don't carry per-line timestamps. Useful
well outside sandbox: post-install forensics, hardware bring-up.
sandbox.run_driver(target, driver, test=None, ...)
Loads a driver via SandboxVM's resident-driver mode (`-r`). When
`test` is set, chains a follow-on guest in the same Guest
context so OpenLibrary(<driver-name>) resolves through the
resident-lib registry. Same return shape and lifecycle as
sandbox.run_guest; the result's `guest` field carries the
driver path. Internally _run_sandboxvm is the shared core for
both run_guest and run_driver -- just two flag knobs differ.
sandbox.last_trap(target=None, since_s=60.0, max_lines=500,
retry_count=3, retry_delay_s=0.2)
Filters sys.debug_ring for SandboxVM trap signatures. Anchor
lines must contain a `traptype=0x...` hex OR a hard trap
keyword (dsi/isi/alignment/illegal/privilege/fault/trap) -- a
bare [sandboxvm] prefix isn't enough since SandboxVM emits
routine status lines too. Block extends across adjacent
[sandboxvm]-prefixed lines so the caller gets the full dump
(registers, dsisr, dar, callsite). Maps 0x300/0x400/0x600/
0x700/0x800 -> DSI/ISI/alignment/program/fp_unavailable. The
documented kmod-libcall NULL+4 fingerprint surfaces as
`kmod_libcall_null4` when Traptype=0x300 + dar=0x4 +
dsisr=0x00800000 all appear in the trap block.
Built-in 3 attempts × 200 ms retry handles the ring-write race
when last_trap is called immediately after run_guest -- the
kernel ring write is asynchronous to SandboxVM exit.
Tests: 17 new (7 sys.debug_ring + 9 sandbox + 1 import collateral).
Total 244/244 unit tests pass; ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds sandbox.run_batch: up to 16 guest ELFs sequentially in a single
SandboxVM invocation (matches upstream SANDBOXVM_MAX_GUESTS).
sandbox.run_batch(target=None,
guests=[{guest, name?}, ...],
extmem_mb=None, window_mb=None,
deny_libs=None, name=None, timeout_s=600.0)
Probe-gated, lock-serialised, capture-slurped/cleaned just like
run_guest. New return shape: BatchRunResult { aggregate_exit_code,
all_clean, duration_s, entries: [BatchEntryResult, ...] }. Each
entry carries its own exit_code + trap classification + stdout/
stderr / capture_paths.
Per-guest exit codes are recovered from the kernel debug ring after
the batch completes (regex match on `[sandboxvm] guest_run_elf
<path> returned <rc>` -- the format SandboxVM emits via
DebugPrintF in src/main.c). Per-guest names default to
<batch>.<idx> to line up with SandboxVM's own
T:sandboxvm-<gname>.{out,err} convention. Deliberately doesn't
support per-guest argv (SandboxVM forbids `--` with multi-guest)
or per-guest deny-lists (SandboxVM `-x` is global) -- the
docstring points to sandbox.run_guest for those cases.
Edge case caught + fixed during testing: when the kernel ring
rotates past older entries before we read it, the K captured
"returned" lines align with the LAST K guests, not the first K
(ring drops oldest first). Implementation now computes a
ring_offset = len(specs) - len(per_guest_exits) and indexes
accordingly; aggregate_exit_code is unaffected because SandboxVM
itself returns the right last-non-zero rc regardless.
7 new tests added: 3-guest happy path; mixed clean+trap; explicit
per-guest names; empty-list rejection; >16-guest rejection;
probe-gated failure; ring-loss alignment. 251/251 unit tests pass;
ruff + mypy clean. Tool count 127 -> 128.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the SandboxVM integration (Phases A/B/C) and the
installer refactor that dropped the host-side bootstrap_dir
requirement, in the public-facing docs gating a release.
README.md
- "iterate without power-cycling" bullet under §At a glance.
- Tool count 118 -> 128.
- sandbox.* added to the namespace list.
- bootstrap_dir removed from the [defaults] example.
COMMANDS.md
- sys.debug_ring row in the sys.* table.
- New "Sandbox harness (sandbox.*)" section: probe / deploy /
run_guest / run_driver / run_batch / last_trap with the
typed error codes (SANDBOXVM_MISSING / _BROKEN /
_INCOMPATIBLE_TARGET) and the upstream link.
- sandbox row added to the namespace dispatcher table.
- installer_stage description updated (no diskimage-bootstrap
upload).
- bootstrap_dir removed from frequently-repeated-parameters
list under installer.*.
USAGE.md
- New "Driver / program iteration via SandboxVM" section
between Out-of-band power and Validation: when to use it,
prerequisites, typical inner loop, driver iteration flow,
batched test bundles, cross-references to sys.debug_ring
and tests.run_suite.
- bootstrap_dir removed from the [defaults] example + the
"Supported keys" table.
INSTALL.md
- Removed the "host-side diskimage-bootstrap directory
required" prerequisite. Replaced with a brief note
explaining the tools come from the running AmigaOS / install
ISO instead.
- bootstrap_dir removed from the [defaults] sample.
CHANGELOG.md
- New Unreleased section covering all sandbox.* tools,
sys.debug_ring, the bundled AmiDock prefs, the new
dismount_combi_device installer step, and the bootstrap_dir
removal as a behaviour change. v1.2 entry left untouched.
Phase D from SANDBOX_PLAN.md is deferred -- triggered only by an
actual workload need (long-running streaming or multi-target
parallel-guest patience regression). Not implemented this cycle.
251/251 unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The plan claimed multi-target fan-out across `sandbox.*` worked
"for free" via fleet.run_on_all, but fleet.run_on_all uses an
explicit allowlist (_FANOUT) and the new methods weren't in it --
calls returned "method 'sandbox.probe' cannot be fanned out".
Adds:
- sandbox.probe / .deploy / .run_guest / .run_driver /
.run_batch / .last_trap to the fan-out registry. Mutating
methods (deploy, run_*) make sense to fan out: same source +
dest path deployed across every target, or same guest run on
every target where it's reachable. Per-target probe gates +
per-target asyncio locks already isolate failures.
- sys.debug_ring -- read-only kernel-ring snapshot is naturally
fanout-friendly.
Also adds two test classes to lock the contract:
- test_fanout_registry_includes_sandbox_methods: pins the
registry against silent regressions (the plan's "free" claim
was originally wrong because of exactly this).
- test_classify_exit_known_traps + kmod fingerprint test:
pins the upstream trap-code mapping so a SandboxVM rename
surfaces in our test suite rather than at runtime.
- test_aos_parent_dir / test_derive_guest_name: covers the
edge cases (assign-root parent, no-extension guest names,
empty-after-strip default-to-"guest") that aren't otherwise
exercised.
256/256 unit tests pass; ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real-target fan-out smoke surfaced a misleading result: probing
an offline a1222 returned SANDBOXVM_MISSING (suggests "deploy the
binary"), but the underlying fs.stat actually failed with a
transport-level connection error -- the target was simply
powered off and no binary deployment was going to fix that.
Cause: a bare `except Exception` in the probe candidate-walk
treated transport errors and "path not found" identically.
Fix: catch only TargetError as "path not present, try next"; let
other exceptions propagate as a new probe code
SANDBOXVM_TARGET_UNREACHABLE with a hint that points at
`fleet.target_status` for triage. The new code raises NotCapable
through `_ensure_available`, so downstream tools (run_guest /
run_driver / run_batch / deploy / last_trap) all surface "target
unreachable" distinctly from "binary missing" -- the two cases
have different fixes (power on the target vs run sandbox.deploy).
Same fix applied to `_probe_specific_path`, the path-pinned
verifier used by `sandbox.deploy`.
Two new tests:
- probe with a transport-error fake: confirms code +
hint shape.
- run_guest with the same fake: confirms NotCapable is raised
(not InvalidParams) so the caller can branch on the
JsonRpcError code.
Probe cache deliberately left unchanged: unreachable results are
NOT cached (only available=true and INCOMPATIBLE_TARGET cache),
so the next probe will re-test once the target comes back.
258/258 unit tests pass; ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real-target follow-up smoke against an actually-off A1222 still
returned SANDBOXVM_MISSING after the previous fix. Cause:
`transports/mcpd.py:114` wraps connection failures as TargetError
(not OSError or another distinct class), so the probe's
`except TargetError: continue` swallowed transport errors as
"path not found" all over again.
The transport tags its connection-failure exceptions with a
specific data shape: `data={"endpoint": ..., "error": ...}`.
AOS-side path-not-found errors carry a different shape (typically
`data={"path": ...}`). Use that as the discriminator via a new
`_is_transport_error(exc)` helper -- TargetError with `endpoint`
in its data dict, or any non-TargetError, counts as transport-
level. Plain TargetError (no data, or no endpoint key) stays as
"AOS-side path not found".
Same logic in `_probe_specific_path` (the path-pinned verifier
used by sandbox.deploy).
Three new tests pin the contract:
- probe with TargetError(endpoint=...) -> SANDBOXVM_TARGET_UNREACHABLE
- probe with non-TargetError exception (belt-and-braces leak path)
- test_is_transport_error_disambiguates: the helper itself,
against all four cases (transport TargetError / AOS TargetError
with path / AOS TargetError with no data / non-TargetError).
A proper architectural fix would add a `TargetUnreachable(TargetError)`
subclass on the transport side; the data-shape sniff is the
minimal pragmatic patch. Other tools (installer.* etc.) have the
same blind spot but aren't fixed in this commit -- scope creep.
260/260 unit tests pass; ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…driver)
qemu_run deploys a local exe to a target, launches it detached via AmigaDOS
`run`, and (with --wait REGEX) polls the QEMU -serial log until the marker
appears, with --start/--stop for a self-contained bounded smoke. This is the
script-drivable equivalent of the qemu lifecycle + exec + serial tools, so a
headless CI smoke or a `cargo-amiga test` can drive the fleet without an MCP
client.
Verified against qemu-amigaone with HorizonWM's --smoke build:
qemu_run --start --stop --exe horizon --args=--smoke \
--wait "HZ-SMOKE: frames=[0-9]+ ok"
-> started -> upload -> run -> marker seen -> stopped (qmp), exit 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New daemon method (mcpd/src/methods/screen.c) captures a screen (frontmost or by screen_index) via graphics.library ReadPixelArray into a 24-bit buffer and PNG-encodes it with the already-linked z.library (deflate + CRC32). AOS4.1 ships no PNG *writer* datatype (picture.datatype DTM_WRITE = ILBM only; warppngdt is decode-only), so the encoding is hand-rolled rather than going through datatypes. Host tool wb.screenshot captures to a temp path on the Amiga, then fs.download brings it to the host (mirroring qemu.screenshot), with optional inline base64 and remote-temp cleanup; wired into the wb.* dispatcher and fleet fan-out. Unlike qemu.screenshot (QMP screendump, QEMU-only) this works on real X5000 / A1222 hardware too. Validated end-to-end on QEMU pegasos2 (sm501 RTG): captured the 1280x800 Workbench screen to a structurally-valid PNG that renders correctly. Fixed a filter-byte bug found in testing: AVT_Clear is AVT_ClearWithValue (tag data is the fill byte, not a bool), so AVT_Clear,TRUE filled with 0x01 and corrupted every scanline filter byte; now zeroed explicitly post-capture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The listener never set its own priority, so it inherited 0 from the launching Shell (Run >NIL: <NIL: Execute MCPd-Watchdog) and competed on equal terms with Workbench exactly when the accept loop most needs to be responsive. It now calls SetTaskPri(1) after argument parsing, so --version / --help still exit without touching scheduling. The per-connection workers stay at -1: accept+spawn above Workbench, heavy RPC work below it. MCPd also had no kernel-debug output at all. The only startup announcement was an IDOS->Printf banner to stdout, which the auto-start path sends to NIL:, so a boot-time start was not observable anywhere. It now emits via IExec->DebugPrintF: [MCPd] ready name=MCPd version=1.3 build_date=... build_time=... port=... [MCPd] startup_failed version=... reason=bsdsocket|listen [port=N] [MCPd] shutdown version=... The ready line is emitted after bind+listen succeed, so its presence means the port is accepting rather than merely that the binary loaded. startup_failed explains a daemon that never came up, and shutdown marks a clean exit so a reader can tell a normal stop from a crash. The "[MCPd] " prefix and key=value shape are a parsed interface. Build time is a new MCPD_TIME macro so two same-day builds are distinguishable. It is deliberately kept out of the $VER cookie, which AmigaDOS Version parses as (DD.MM.YYYY); verified that Version MCPd FULL still returns "MCPd 1.3 (02/08/2026)". Time is surfaced in the beacon, --version, proto.version.build_time and proto.capabilities.build.time instead. Version bumped to 1.3 across main.c, rpc.h and the Makefile. The Makefile knob had drifted to 1.1 while the shipped 1.2 binary reported 1.2; it only feeds the dist tarball name, so nothing broke. Validated on QEMU AmigaOne across two cold boots: beacon present in both the serial log and sys.debug_ring, Status FULL reports priority 1 for the listener with exec.cmd subprocesses still at -1, and the shutdown line is emitted on a clean Break. Caveat documented in mcpd/README.md: sys.debug_ring reaches DumpDebugBuffer through MCPd, so it cannot detect a daemon that failed to start. Reading the beacon in that case needs a serial capture, which does not depend on the daemon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add seven input.* methods driving input.device IND_WRITEEVENT: state, type, key, mouse_move, click, drag, scroll. Goes through MCPd rather than QMP, so it works on real hardware and QEMU alike. Injection is off at the daemon and returns -32003 until the operator passes --enable-input or creates SYS:System/MCPd/ENABLE-INPUT. The gate is read once at startup, so no RPC can switch it on. The sentinel file is the primary mechanism because MCPd-Watchdog and the S:Network-Startup line both relaunch without arguments, which would silently drop a flag-only gate on the first restart. The host-side [targets.<n>.input] block is a wrong-target guard, not access control. type/key/click/drag also require confirm:true; ctrl+lamiga+ramiga additionally requires confirm_reset:true. Typing maps characters through the target's keymap.library MapANSI, including dead-key sequences, so non-US layouts get the intended characters; a built-in US table is the fallback and the result reports which path ran. Caps enforced daemon-side: 256 events, 20s wall clock, 512 chars, 64 drag steps. Held modifiers and buttons are always released before returning, including on the abort path, so a truncated drag cannot leave a stuck mouse button. Also unify the daemon version behind MCPD_VERSION_STR in rpc.h -- main.c and rpc.h previously carried independent literals, so MCPd --version and proto.version could disagree. Bump to 1.3; the wire protocol stays at 1.0 since input.* is purely additive. Verified against a QEMU Pegasos2 guest (AmigaOS 4.1 FE, Kickstart 54.57): gate closed by default and after a cold boot, enable/disable lifecycle, pointer positioning, and typing end-to-end through a Shell on a German keymap.
The wire is UTF-8 -- the host sends
json.dumps(..., ensure_ascii=False).encode("utf-8")
(transports/mcpd.py) and cJSON expands \uXXXX escapes to UTF-8 as well
-- but input_type() walked the string byte by byte and handed each byte
to MapANSI, which wants one ANSI / ISO-8859-1 byte per character. An
accented "cafe" was therefore typed as five keystrokes, or landed two
entries in unmapped[], for four requested characters. The host layer
explicitly permits U+0080..U+00FF, so this was reachable straight from
the documented API; the on-target verification was ASCII-only, which
exercises the layout mapping but never this path.
Decode to a codepoint first (_utf8_next), map codepoints <= 0xFF, and
report anything above as "U+XXXX" in unmapped[] rather than typing
something else. The 512 cap now counts characters on both sides
(_utf8_strlen), so a 512-character accented string is no longer
rejected as 1024 bytes, and text_len reports characters to match what
the caller counted.
Also correct three descriptions that contradicted the shipped code:
- the comment above g_us_ascii still said MapANSI was "deliberately
NOT implemented" and that keymap="system" is rejected. It is the
default and it is implemented; the table is the fallback.
- rpc.c's input.type description -- which proto.capabilities serves to
clients, so an agent reads it as the spec -- said keymap "us" only /
US layout only. Same correction.
- the rawkey table claimed F11/F12 codes were present but unverified.
There are no F11/F12 entries at all; say they are deliberately
absent, since an unknown key name fails cleanly while a wrong rawkey
code silently presses something else.
Cross-compiles clean with the CI toolchain
(walkero/amigagccondocker:os4-gcc11).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
input_init_gate()'s "*** INPUT INJECTION ENABLED ***" banner was deliberately loud, but it goes to stdout -- which is NIL: on the only path that matters in production. S:Network-Startup launches `Run >NIL: <NIL: Execute SYS:System/MCPd/MCPd-Watchdog`, so on an auto-started machine nobody ever sees it. That is exactly the hole the readiness beacon (4541163) was added to close for "is MCPd up"; the same argument applies with more force to a security control. Mirror the gate state into the kernel debug ring as `[MCPd] input_gate state=... source=...`, and add `input=on|off` to the existing `[MCPd] ready` beacon. Both are readable remotely through sys.debug_ring, so an operator can confirm whether a machine will accept injection without opening a socket or walking to a serial console -- and without having to trust that the host-side config matches what the daemon actually resolved at startup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
MCPd-Install copies these next to the binary, but nothing in the fleet is provisioned by running MCPd-Install on the target -- installer.stage + the install_mcpd sequence step mirror it, and scripts/ install_mcpd_autostart.py uploads MCPd and the watchdog directly. Neither knew about the two new scripts, so on every machine this project actually installs, the documented persistent way to open the input.* gate did not exist: the operator would have had to hand-create SYS:System/MCPd/ENABLE-INPUT. - installer.stage resolves them from sources_dir, the repo's mcpd/install/, or the install-support tree, and stages them to <dest>:tmp/ alongside MCPd. Optional: a missing script is reported in `skipped`, never fatal. - install_mcpd() copies whichever are staged into SYS:System/MCPd/ with +rwed and reports them as `input_helpers`. - install_mcpd_autostart.py uploads them after the watchdog. Copied, never run, in all three paths -- injection stays off until an operator executes MCPd-Enable-Input on the target and restarts MCPd. While here: _resolve_mcpd_install_script also searches parents[4] (the repo root). parents[3] is `host/`, so the existing binary resolver's repo-root guess only ever worked via the cwd entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
- README + AGENTS_SETUP: 129 -> 137 tools (the seven input.* tools plus their dispatcher), and input.* added to the namespace list. - COMMANDS: MCPd-Enable-Input / MCPd-Disable-Input rows in the on-Amiga scripts table; note that MCPd-Uninstall's recursive delete removes the sentinel with them. - INSTALL: the autostart helper now uploads both scripts, and says plainly that they are copied and never run. - AGENTS_SETUP: a NotCapable row naming both gates, and an explicit "this spec does not enable injection" entry. An agent generating a config must not open a gate whose entire purpose is to require a deliberate human action. - CHANGELOG: fold the input.* entries into Unreleased rather than a released 1.3 section, record the UTF-8 and debug-ring changes, add a tool-count block, and rewrite the limitations to match the code -- F11/F12 are absent rather than unverified, and the click/drag pointer-accuracy gap on real hardware is now stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
input.* is the only surface here that writes to a machine's UI, and its safety story is entirely "the gate is shut unless an operator opened it". That is worth nothing unverified, so this drives a live target through the whole cycle: refuse everything by default, open via the sentinel (and only after a restart), inject, close again. The section that earns its keep is the typing one. input.type takes UTF-8 off the wire while both daemon mapping paths are one-byte ANSI, so "cafe"-with-an-acute is the discriminator: 4 characters, 5 UTF-8 bytes. text_len == 4 and a 4-byte file mean the decode happened. The typed output goes to the guest's SHARED: volume so the bytes are read on the host rather than back through the daemon that typed them. Two things this cost to learn, both now encoded in the script: - QMP `quit` stops QEMU dead and AmigaOS writes back lazily, so a file written seconds earlier is not in the disk image after a restart -- and fs.upload's verify does NOT catch it, because the read-back comes from the same dirty cache. 45 s of idle is enough for a 300 KB write. A deploy that looked successful had silently not happened. - A QEMU left running from a previous run makes every later result a lie: the new instance can't bind the same hostfwd port, so the script talks to the OLD guest with whatever gate state it booted with. The script now refuses to start if the endpoint is already listening, and stops its guest on the way out even when a check raises. Run against qemu-pegasos2-sm501 (AOS 4.1 FE Update 3, Kickstart 54.57, sm501 RTG, 1280x800): 27/27. Highlights: gate shut by default with all seven methods returning -32003 and the ring showing `input_gate state=disabled` / `input=off`; sentinel ignored until a restart; `Echo <acute> >SHARED:...` produced exactly b'caf\xe9' in the Shell -- four bytes, correct ISO-8859-1, through keymap.library; U+65E5 refused host-side and reported as U+65E5 in unmapped[] daemon-side with zero events; a real window drag; both confirm guards rejecting; gate shut again after the sentinel was removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
Verified on the real X5000 (P5020, Radeon RTG at 1600x900, AOS 4.1 FE): 23/27, with every functional check passing and the four failures all being debug-ring assertions that the machine cannot satisfy. - `--restart-mode cold` reboots via sys.cold_reboot with an MCU power-cycle fallback, so the gate cycle runs unattended on hardware instead of prompting. `--restart-mode manual` still prompts. - Typing checks no longer require a 9p share. With `--shared-dir` the output is read off the host side of SHARED:; without it -- which is every real machine -- the same files go to `T:` and come back via fs.read. Skipping them on hardware would have skipped the single most important check. - The debug-ring assertions now SKIP instead of FAIL when the ring holds no MCPd output at all, and read 2000 lines rather than 400. That last point is the finding. **The AmigaOS kernel debug buffer does not wrap** -- once full it stops accepting entries. On this X5000 the buffer held 711 lines / 80,366 bytes, essentially all of it amdgpu-os4 output beginning at `[gpu.chip] board resource init`, and it had not grown by a single byte after 60 s with two MCPd daemons running and the GPU actively repainting. It contains zero `[MCPd]` lines: the graphics driver exhausts the buffer during boot, before MCPd is started from S:Network-Startup, so the daemon's beacons cannot land there. A second MCPd launched by hand on a spare port confirmed it -- it bound the port and still logged nothing. So the beacons work (both appear on QEMU) but the ring is not a trustworthy annunciator on a machine like this. SECURITY.md now says `proto.capabilities` is the authoritative gate reading, and COMMANDS.md documents the non-wrapping buffer against sys.debug_ring with the raw_size-growth test for spotting it -- it equally blinds sandbox.last_trap and any post-boot forensics on that machine. Hardware results worth recording: `Echo <e-acute> >T:...` produced exactly b'caf\xe9' through keymap.library on the real board, so the UTF-8 decode is confirmed off-QEMU; absolute pointer moves landed exactly on target (800,450), so pointer acceleration did not distort them as feared; the drag moved a window; both confirm guards rejected; and the gate opened and closed correctly across two cold reboots. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
Promote the unreleased changelog block to 1.3 and bring the host package into line with the daemon, which develop had already bumped: - host/pyproject.toml: 1.2 -> 1.3 - host/src/amiga_fleet_mcp/__init__.py: 1.2 -> 1.3 - AGENTS_SETUP.md tool-count expectation: now "137 as of v1.3" - mcpd was already 1.3 (MCPD_VERSION_STR in rpc.h, which main.c and the Makefile now derive from rather than repeating) Headlines: the sandbox.* namespace over SandboxVM, wb.screenshot, sys.debug_ring, and the input.* keyboard/mouse injection surface -- the last of which is off by default at the daemon and needs an operator action on the target itself to open. 121 -> 137 tools, 12 -> 14 namespaces. Wire protocol stays at 1.0: every addition is additive and proto.capabilities derives its method list dynamically, so an older host talks to a 1.3 daemon fine and an older daemon simply reports no input capability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
`host/uv.lock` is gitignored, so CI resolves dependencies fresh on every run. `mcp[cli]>=1.0.0` therefore picked up mcp 2.2.0, which removed `from mcp.server.fastmcp import FastMCP` -- the entry point server.py is written against -- and every host job failed the type-check on a repo change that had nothing to do with it. The last green run was 2026-06-16, so this has been latent since mcp 2.0 shipped rather than being introduced here. Cap both majors: mcp[cli]>=1.0.0,<2 and pydantic>=2.0,<3. Porting to the 2.x API is real work with its own testing, and doing it blind to unblock a release would be the wrong trade. Worth considering separately: committing host/uv.lock. For an application rather than a library that is the usual answer, and it would have prevented this entirely -- but the ignore rule looks deliberate, so leaving that call to the maintainer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Release 1.3. Promotes everything that has accumulated on
developsince v1.2 to
main, with theinput.*namespace as the headline —keyboard and mouse injection on the target, off by default at the
daemon and openable only by an operator action on the machine itself.
Originally contributed by @cpm1 in #2 against
main. Rebased ontodevelophere with authorship preserved, plus fixes found in reviewand verification (below). #2 will be closed in favour of this.
Changes
input.*—state,type,key,mouse_move,click,drag,scrollviainput.deviceIND_WRITEEVENT, so it works onreal PowerPC hardware as well as QEMU. Gate resolved once at startup
into a read-only static, so no RPC can turn it on; the sentinel file
SYS:System/MCPd/ENABLE-INPUTis the supported mechanism becauseMCPd-Watchdogrelaunches argument-less.proto.capabilitiesreports
input.enabled, and the methods stay advertised when shut soclients can tell "off" from "old daemon".
sandbox.*+sys.debug_ring+wb.screenshot— already ondevelop, unchanged here.input.typedecodes UTF-8 before mapping. The wire is UTF-8 butboth daemon mapping paths are one-byte ANSI, so the original walked
the string byte by byte and typed five keystrokes for an accented
"café". The host layer explicitly permits U+0080..U+00FF, so this
was reachable from the documented API; the contributor's on-target
test was ASCII-only, which is why it survived. Codepoints above
U+00FF now land in
unmapped[]asU+XXXXinstead of being typed assomething else, and the 512 cap counts characters on both sides.
including
rpc.c'sinput.typetext, whichproto.capabilitiesserves to clients as the spec.
MCPd-Enable-Input/-Disable-Input.MCPd-Installcopied them, but nothing in this fleet is provisionedby running
MCPd-Install—installer.stage+install_mcpd+scripts/install_mcpd_autostart.pyare, and none knew about them, sothe documented persistent way to open the gate did not exist on any
machine the project installs. Copied, never run, in all three paths.
banner goes to
NIL:on the watchdog auto-start path, i.e. invisibleexactly where it matters.
scripts/validate_input.py— end-to-end check of the whole gatecycle, QEMU and real hardware.
Testing
pytest -q— 310 passedruff check src testscleanmypy srccleanmake docker-buildsucceeds (walkero/amigagccondocker:os4-gcc11)scripts/validate_input.pydrives: gate shut by default with all sevenmethods at
-32003→ sentinel written and correctly ignored until arestart → gate open → inject → sentinel removed → gate shut again,
across two real reboots each time.
The check that matters most:
Echo café >…typed into a Shell producedexactly
b'caf\xe9'— four bytes, correct ISO-8859-1, throughkeymap.library— on both QEMU and the real X5000. Before the fixthat string produced five keystrokes. Absolute pointer moves landed
exactly on target on real hardware (800,450), so the pointer
acceleration risk flagged in review did not materialise.
The X5000's four non-passes are all debug-ring assertions, and are a
property of that machine: the AmigaOS kernel debug buffer does not
wrap, and amdgpu-os4 fills all 80 KB of it during boot, before MCPd
starts. The ring held 711 lines with zero
[MCPd]entries and did notgrow one byte over 60 s with two daemons running; a second MCPd
launched by hand on a spare port bound the port and still logged
nothing.
proto.capabilitiesis therefore documented as theauthoritative gate reading, and
SECURITY.md/COMMANDS.mdnow carrythe caveat — it equally blinds
sandbox.last_trapon such a machine.validate_input.pyreports those as SKIP rather than FAIL.Breaking changes?
None. Wire protocol stays at 1.0: every addition is additive and
proto.capabilitiesderives its method list dynamically, so an olderhost talks to a 1.3 daemon fine and an older daemon simply reports no
inputcapability.input.*is inert until an operator opens the gateon the target.
Known limitations
verified on hardware; an unknown key name fails cleanly, a wrong code
silently presses something else). The NewMouse wheel constants remain
unverified, behind
#ifndefguards.input.click's pre-move andinput.drag's move-to-start don't readthe pointer back the way
mouse_movedoes.InputConfig.max_eventsis documented inconfig.example.tomlbutnot enforced host-side.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71