Skip to content

input.*: keyboard and mouse injection, disabled by default - #2

Closed
cpm1 wants to merge 1 commit into
derfsss:mainfrom
cpm1:feature/input-injection
Closed

cpm1 wants to merge 1 commit into
derfsss:mainfrom
cpm1:feature/input-injection

Conversation

@cpm1

@cpm1 cpm1 commented Jul 20, 2026

Copy link
Copy Markdown

Summary

Adds an input.* namespace — keyboard and mouse injection on the target — driving input.device IND_WRITEEVENT from MCPd. Seven methods: state, type, key, mouse_move, click, drag, scroll.

It goes through MCPd rather than QMP, so it works on real PowerPC hardware and QEMU guests alike, and composes with the existing read-only wb.* tools for coordinates.

This closes a real gap: an agent could previously see that a requester was on screen but not dismiss it, or launch a GUI installer but not click Proceed.

Security model

Input injection is categorically more dangerous than the rest of the surface — it types and clicks as the logged-in user, into whatever window is focused. So it is off by default at the daemon, and that switch, not the host config, is the real boundary.

input.* returns -32003 until the operator either starts MCPd with --enable-input or creates SYS:System/MCPd/ENABLE-INPUT (MCPd-Enable-Input), then restarts. The gate is read once at startup into a read-only static, so:

  • no RPC method can switch it on — enabling needs filesystem access to the target
  • the per-connection child Processes see it without locking

The sentinel file is the primary mechanism, and that's deliberate. mcpd/install/MCPd-Watchdog:24 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 — the worst failure mode for a security control, since it fails open in the operator's mental model. The sentinel survives restarts, and MCPd-Uninstall's recursive delete already removes it.

proto.capabilities reports input.enabled, and the methods stay advertised when the gate is closed so clients can distinguish "switched off" from "old daemon".

Secondary gates:

  • Host-side [targets.<n>.input] enabled — a wrong-target guard, not access control, since anything reaching TCP 4322 bypasses the host. Documented as such.
  • confirm: true on the operations that can commit an action (type, key, click, drag); not on mouse_move, scroll, state. ctrl+lamiga+ramiga additionally requires confirm_reset: true because it reboots the machine.

Typing is layout-correct

Characters are mapped through the target's own keymap.library MapANSI, including dead-key sequences. A built-in US table is the fallback if keymap.library can't be opened, and the result's keymap field reports which path ran. Unmappable characters come back in unmapped[] rather than being silently dropped.

Safety

Per-call caps enforced daemon-side (host-side ones are advisory): 256 events, 20 s wall clock, 512 characters, 64 drag steps, delay_ms 0–1000. A call that hits a cap stops cleanly and returns truncated: true.

Every handler releases held modifiers and mouse buttons before returning, including on the abort path — a truncated input.drag cannot leave the target with a stuck mouse button.

input.state is the "look before you click" method: pointer position, focused window, screen geometry. Deliberately not included is "seconds since last user input" — there's no supported AOS4 API for it short of installing a commodities input handler, so it's omitted rather than approximated.

Incidental fix

main.c and rpc.h carried independent version literals, so MCPd --version and proto.version could disagree (and mcpd/Makefile had already drifted to 1.1). Unified behind MCPD_VERSION_STR in rpc.h. Bumped to 1.3; the wire protocol stays at 1.0 since input.* is purely additive and proto.capabilities derives its method list dynamically.

Verification

Host: 251 tests pass, ruff and mypy clean. New tests/unit/test_input.py focuses on the disabled-by-default path — asserting both that the error is raised and that nothing reached the wire — plus a test pinning TargetConfig(...).input is None so a refactor can't silently make injection opt-out.

Exercised against a QEMU Pegasos2 guest (AmigaOS 4.1 FE Update 3, Kickstart 54.57):

  • gate closed by default — all 7 methods -32003, input.enabled: false
  • MCPd-Enable-Input → restart → open; MCPd-Disable-Input → restart → closed
  • gate still closed after a cold boot via the normal S:Network-Startup auto-start
  • absolute pointer moves land exactly on target; relative moves and wheel scroll confirmed
  • typing verified end-to-end: Echo TYPED-OK >T:typed.txt into a Shell produced exactly that file — on a German (QWERTZ) keymap, which is what forced the keymap.library path
  • daemon-side confirm gates and the confirm_reset reset-chord guard all reject as designed

Known limitations

  • F11/F12 rawkey codes and the NM_WHEEL_* NewMouse constants are still unverified — flagged in mcpd/src/methods/input.c. The wheel constants are behind #ifndef guards so a missing header can't break the build.
  • input.mouse_move defaults to read-position-then-relative-delta (verified accurate). absolute_mode="raw" exposes the true-absolute event form for A/B testing on other hardware.
  • input.click doesn't report the achieved pointer position the way mouse_move and drag do.
  • Anything typed via input.type is recorded in cleartext in the run archive. That's intended — it's an audit log, and silently redacting one is worse than not keeping it — but it's called out in SECURITY.md, and text_sha256 lets a reviewer confirm what was typed without re-reading the plaintext.

Note on the build

The binary used for the on-target verification was cross-compiled with a local adtools GCC 8.4.0 toolchain, not the walkero/amigagccondocker:os4-gcc11 image CI uses. The daemon reports its toolchain in proto.version/--version, so any target is identifiable. CI's cross-compile job should be treated as the authority for the shipped artifact.

Files

  • mcpd/src/methods/input.c (new), plus wiring in rpc.c, methods.h, main.c, proto.c, Makefile
  • mcpd/install/MCPd-Enable-Input / MCPd-Disable-Input (new), copied by MCPd-Install but never run by it
  • host/src/amiga_fleet_mcp/tools/input.py (new), config.py InputConfig, 7 tools + input dispatcher in server.py, --init wizard question
  • Docs: SECURITY.md, COMMANDS.md, USAGE.md, CHANGELOG.md, CONTRIBUTING.md

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.
@derfsss

derfsss commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Merged — this landed in v1.3 via #3, with your commit cherry-picked onto develop so the authorship is preserved (f8b0489).

Closing this one rather than merging it directly because it was based on v1.2 and targeted main, which is the release branch here; develop is where work lands, and it had ~14 commits (sandbox.*, wb.screenshot, sys.debug_ring) that this branch predated.

Four things changed on the way in, all documented in #3:

  1. input.type now decodes UTF-8 before mapping. The wire is UTF-8 (transports/mcpd.py sends ensure_ascii=False) but both daemon mapping paths are one-byte ANSI, so the original walked the string byte by byte — an accented "café" typed five keystrokes for four characters, or landed two unmapped[] entries for one. The host layer explicitly permits U+0080..U+00FF, so it was reachable from the documented API; the ASCII-only on-target test is why it survived. Codepoints above U+00FF are now reported as U+XXXX rather than typed as something else, and the 512 cap counts characters on both sides.
  2. Three descriptions corrected that contradicted the shipped code — including the rpc.c input.type text, which proto.capabilities serves to clients as the spec, and the comment above g_us_ascii still saying MapANSI was deliberately not implemented.
  3. 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 didn't exist on any machine the project installs.
  4. The gate is announced in the kernel debug ring, since the startup banner goes to NIL: on the watchdog auto-start path.

Verified end-to-end by a new scripts/validate_input.py: 27/27 on QEMU pegasos2 and 23/27 on a real X5000, where Echo café >… produced exactly b'caf\xe9' in a Shell through keymap.library. The four X5000 non-passes are debug-ring assertions that machine can't satisfy — the AmigaOS debug buffer doesn't wrap and its graphics driver fills all 80 KB during boot, before MCPd starts.

The security design was the part worth keeping as-is: a gate resolved once at startup into a read-only static so no RPC can escalate, and the sentinel file rather than a CLI flag precisely because the watchdog relaunches argument-less. Thanks for that reasoning — it's what made this straightforward to accept.

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.

2 participants