Skip to content

Release 1.3 — sandboxed iteration, screen capture, and input injection - #3

Merged
derfsss merged 23 commits into
mainfrom
develop
Sep 10, 2026
Merged

derfsss merged 23 commits into
mainfrom
develop

Conversation

@derfsss

@derfsss derfsss commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

Release 1.3. Promotes everything that has accumulated on develop
since v1.2 to main, with the input.* 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 onto
develop here with authorship preserved, plus fixes found in review
and verification (below). #2 will be closed in favour of this.

Changes

  • input.* — state, type, key, mouse_move, click,
    drag, scroll via input.device IND_WRITEEVENT, so it works on
    real 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-INPUT is the supported mechanism because
    MCPd-Watchdog relaunches argument-less. proto.capabilities
    reports input.enabled, and the methods stay advertised when shut so
    clients can tell "off" from "old daemon".
  • sandbox.* + sys.debug_ring + wb.screenshot — already on
    develop, unchanged here.
  • input.type decodes UTF-8 before mapping. The wire is UTF-8 but
    both 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[] as U+XXXX instead of being typed as
    something else, and the 512 cap counts characters on both sides.
  • Three descriptions corrected that contradicted the shipped code,
    including rpc.c's input.type text, which proto.capabilities
    serves to clients as the spec.
  • The installer now deploys MCPd-Enable-Input / -Disable-Input.
    MCPd-Install copied them, but nothing in this fleet is provisioned
    by running MCPd-Install — installer.stage + install_mcpd +
    scripts/install_mcpd_autostart.py are, and none knew about them, so
    the documented persistent way to open the gate did not exist on any
    machine the project installs. Copied, never run, in all three paths.
  • The gate is announced in the kernel debug ring — its stdout
    banner goes to NIL: on the watchdog auto-start path, i.e. invisible
    exactly where it matters.
  • scripts/validate_input.py — end-to-end check of the whole gate
    cycle, QEMU and real hardware.
  • Versions in lockstep at 1.3; 121 → 137 tools, 12 → 14 namespaces.

Testing

  • pytest -q — 310 passed
  • ruff check src tests clean
  • mypy src clean
  • make docker-build succeeds (walkero/amigagccondocker:os4-gcc11)
  • Verified live: QEMU pegasos2 sm501 27/27, real X5000 23/27

scripts/validate_input.py drives: gate shut by default with all seven
methods at -32003 → sentinel written and correctly ignored until a
restart → 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 produced
exactly b'caf\xe9' — four bytes, correct ISO-8859-1, through
keymap.library — on both QEMU and the real X5000. Before the fix
that 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 not
grow 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.capabilities is therefore documented as the
authoritative gate reading, and SECURITY.md / COMMANDS.md now carry
the caveat — it equally blinds sandbox.last_trap on such a machine.
validate_input.py reports those as SKIP rather than FAIL.

Breaking changes?

None. 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. input.* is inert until an operator opens the gate
on the target.

Known limitations

  • F11/F12 are deliberately absent from the rawkey table (codes never
    verified on hardware; an unknown key name fails cleanly, a wrong code
    silently presses something else). The NewMouse wheel constants remain
    unverified, behind #ifndef guards.
  • input.click's pre-move and input.drag's move-to-start don't read
    the pointer back the way mouse_move does.
  • InputConfig.max_events is documented in config.example.toml but
    not enforced host-side.
  • Not exercised on the A1222.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71

derfsss and others added 23 commits May 9, 2026 01:38
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
@derfsss
derfsss merged commit ed6887b into main Sep 10, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant