From 612cfec7764bca806187603a06b57351cf44ad5f Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Fri, 11 Sep 2026 08:31:25 +0100 Subject: [PATCH 1/2] release: ship MCPd as an LhA archive built on AmigaOS AmigaOS protection bits belong to the file, not to its contents, and no cross-platform container carries them. A bare `MCPd` ELF downloaded from the release page and moved onto an Amiga over SMB, a USB stick, or a browser arrives with the `e` bit protected; the daemon then refuses to run, and the error looks nothing like the cause. The two installers set the bits themselves, so this only ever bit people doing it by hand -- which is exactly the path a first-time user takes. `scripts/build_release_lha.py` fixes it at the source by assembling the archive *on an AmigaOS filesystem* (a QEMU guest will do; it needs no hardware and leaves nothing behind). It stages the daemon, the five install scripts and a README, sets the executable bit on the binary and the script bit on the scripts, runs `LhA`, and then -- rather than assuming -- extracts the archive again into a clean directory and checks the flags came back before it will hand over the file. The `s` bit is the proof: it is never a filesystem default, so `-s--rwed` on extraction can only have come out of the archive. `MCPd-1.3.lha` (126,535 bytes) is now attached to the v1.3 release alongside the bare ELF, and is what the README and INSTALL.md point a new user at. The release needed nothing else: everything committed after the tag is documentation, and `mcpd/` + `host/` at `v1.3` still match `main`, so the attached binary remains current. Also adds `.gitattributes`. The install scripts are uploaded to targets byte-for-byte from the working tree, and on a Windows checkout autocrlf had been giving every one of them CRLF endings -- a stray carriage return on each AmigaDOS script line, and visible ^M in Amiga text viewers. `eol=lf` pins them, along with the daemon sources the Docker toolchain compiles from the same tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- .gitattributes | 15 +++ CHANGELOG.md | 14 +++ COMMANDS.md | 1 + INSTALL.md | 33 +++++- README.md | 18 +++- mcpd/install/Archive-README | 73 +++++++++++++ scripts/build_release_lha.py | 194 +++++++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 .gitattributes create mode 100644 mcpd/install/Archive-README create mode 100644 scripts/build_release_lha.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a509ed0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Files that get shipped to an AmigaOS machine byte-for-byte must keep +# LF line endings, whatever the checkout platform does by default. +# +# The install scripts and the archive README are uploaded to targets +# exactly as they sit in the working tree. Checked out on Windows with +# the usual autocrlf, they gain CRLF, which puts a stray carriage +# return on the end of every AmigaDOS script line and shows up as ^M +# in Amiga text viewers. +mcpd/install/* text eol=lf + +# The daemon sources are cross-compiled from the working tree by the +# Docker toolchain; keep them LF for the same reason a Unix-side build +# would expect. +mcpd/src/** text eol=lf +mcpd/Makefile text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0f6c0..38907e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Added + +- **`scripts/build_release_lha.py`** — assembles the MCPd release + archive on an AmigaOS target (a QEMU guest will do), so the AmigaOS + protection bits are already correct in the file users download. + Protection bits belong to the file rather than the byte stream, and + no cross-platform container preserves them: a bare ELF moved onto an + Amiga over SMB, a USB stick, or a browser download arrives with the + executable bit protected and refuses to run. The script stages the + daemon, the install scripts and a README, sets the executable and + script bits, runs `LhA`, then extracts the archive again and checks + the flags came back before accepting it. `MCPd-1.3.lha` is attached + to the v1.3 release. + ### Changed - Documentation pass across the README and everything it links to, diff --git a/COMMANDS.md b/COMMANDS.md index e119abb..5750a80 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -417,6 +417,7 @@ Located in `scripts/`. Run with `python scripts/.py`. |---|---| | `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 the ISO + Update LHAs + Enhancer + extras + MCPd to a target. | +| `build_release_lha.py --target [--version V] [--out DIR]` | Assemble the release archive on an AmigaOS target (a QEMU guest is fine) so the AmigaOS protection bits are baked into the file users download. Stages the daemon, the install scripts and a README, sets the executable and script bits, runs `LhA`, extracts the result again to prove the flags survived, then downloads the archive and tidies up after itself. | | `deploy_mcpd_x5000.py` | One-shot deploy of a freshly built MCPd to a running X5000 (auto-start install + watchdog). | ### Diagnostics and probes diff --git a/INSTALL.md b/INSTALL.md index f63d110..c3f585c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -472,8 +472,37 @@ eleven seconds of cold boot. ### Manual install (no host helper) -Copy `mcpd/MCPd` and the scripts from `mcpd/install/` onto the -target, then from a Shell on the target: +The simplest route is the release archive. Download +`MCPd-.lha` from the [latest +release](https://github.com/derfsss/MCP-AmigaOS4/releases/latest) onto +the Amiga and, from a Shell: + +``` +LhA x MCPd-1.3.lha +Execute MCPd/MCPd-Install +``` + +The archive is assembled on AmigaOS, so every file already carries the +right protection bits — the daemon is runnable the moment it is +extracted. + +**Why that matters.** AmigaOS protection bits are a property of the +file, not of its contents, and no cross-platform container preserves +them. A bare `MCPd` ELF fetched from the release page and moved onto +the Amiga over SMB, a USB stick, or a browser download arrives with +the `e` (executable) bit protected, and the daemon then refuses to run +for a reason that looks nothing like the cause. If you do transfer the +bare binary that way, fix it by hand: + +``` +Protect SYS:System/MCPd/MCPd +rwed +``` + +`MCPd-Install` and `scripts/install_mcpd_autostart.py` both set the +bits themselves, so only manual copies need this. + +Alternatively, copy `mcpd/MCPd` and the scripts from `mcpd/install/` +onto the target yourself, then: ``` CD diff --git a/README.md b/README.md index 79b02eb..eb4d43a 100644 --- a/README.md +++ b/README.md @@ -73,16 +73,26 @@ uv run amiga-fleet-mcp --init uv run amiga-fleet-mcp --health-check ``` -The Amiga side needs `MCPd` running on each target. Download the -binary from the [latest -release](https://github.com/derfsss/MCP-AmigaOS4/releases/latest) and -either run `MCPd-Install` from a Shell on the machine, or deploy it +The Amiga side needs `MCPd` running on each target. Either deploy it from the host: ```sh python scripts/install_mcpd_autostart.py :4322 ``` +or, on the Amiga itself, grab `MCPd-.lha` from the [latest +release](https://github.com/derfsss/MCP-AmigaOS4/releases/latest): + +``` +LhA x MCPd-1.3.lha +Execute MCPd/MCPd-Install +``` + +The archive is assembled on AmigaOS, so the protection bits are +already correct — extract and the daemon runs. (A raw ELF copied over +SMB or a USB stick arrives with the executable bit protected and has +to be `Protect +rwed`-ed by hand.) + Either way MCPd lands in `SYS:System/MCPd/`, gains a watchdog, and auto-starts on boot. Then register the server with your client — for Claude Code: diff --git a/mcpd/install/Archive-README b/mcpd/install/Archive-README new file mode 100644 index 0000000..9945ef7 --- /dev/null +++ b/mcpd/install/Archive-README @@ -0,0 +1,73 @@ +MCPd 1.3 - the AmigaOS 4 daemon for MCP-AmigaOS4 +================================================ + +MCPd lets an MCP-aware client on another machine drive this Amiga: +browse and edit files, run AmigaDOS commands, read live system state, +capture the screen, and more. It listens on TCP port 4322, with a UDP +discovery responder on 4323. + +This archive was assembled on AmigaOS, so every file already carries +the right protection bits. Unpack it with LhA and the binary is +runnable as-is - no Protect needed: + + LhA x MCPd-1.3.lha + + +Installing +---------- + +From a Shell, in the directory this archive unpacked into: + + Execute MCPd/MCPd-Install + +That copies MCPd to SYS:System/MCPd/, installs the watchdog wrapper, +backs up S:Network-Startup, and adds a launch line so the daemon +starts on boot. Reboot, or start it straight away with: + + Run >NIL: str: + """Read the version out of rpc.h -- the single source of truth.""" + text = (root / "mcpd" / "src" / "rpc.h").read_text(encoding="utf-8") + m = re.search(r'#define\s+MCPD_VERSION_STR\s+"([^"]+)"', text) + if not m: + raise SystemExit("could not find MCPD_VERSION_STR in mcpd/src/rpc.h") + return m.group(1) + + +async def sh(fleet: Fleet, target: str, cmd: str, *, + cwd: str | None = None, timeout: float = 120.0, + allow_fail: bool = False) -> str: + r = await exec_tool.exec_cmd(fleet, target, cmd, cwd=cwd, + timeout_s=timeout) + if r.exit_code != 0 and not allow_fail: + raise SystemExit(f"{cmd!r} failed rc={r.exit_code}: {r.output[:400]}") + return r.output + + +async def rm(fleet: Fleet, target: str, path: str, *, + all_: bool = False) -> None: + """Delete something that may or may not be there. + + AmigaDOS `Delete` returns a warning (5) for a missing object even + with QUIET, so the tidy-up calls must not treat that as failure. + """ + await sh(fleet, target, + f'Delete >NIL: {path}{" ALL" if all_ else ""} QUIET', + allow_fail=True) + + +async def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--target", required=True, + help="a reachable AmigaOS target to build on") + ap.add_argument("--version", help="override the version string " + "(default: read from rpc.h)") + ap.add_argument("--out", default="dist", + help="host directory for the finished archive") + ap.add_argument("--readme", + help="README to include in the archive (default: " + "mcpd/install/Archive-README)") + args = ap.parse_args() + + root = repo_root() + version = args.version or daemon_version(root) + archive = f"MCPd-{version}.lha" + binary = root / "mcpd" / "MCPd" + if not binary.is_file(): + raise SystemExit(f"{binary} not built -- run `make docker-build` " + "in mcpd/ first") + + fleet = Fleet(load_config()) + t = args.target + print(f"building {archive} on {t}", flush=True) + + # Fresh staging tree every run: a leftover file from a previous + # build would be archived silently. + await rm(fleet, t, STAGE, all_=True) + await fs_tool.fs_makedir(fleet, t, STAGE) + await fs_tool.fs_makedir(fleet, t, f"{STAGE}/{DRAWER}") + + up = await fs_tool.fs_upload( + fleet, t, local_path=str(binary), + remote_path=f"{STAGE}/{DRAWER}/MCPd", verify=True) + print(f" MCPd {up.bytes_total} bytes, sha256 verified", flush=True) + + for name in SCRIPTS: + await fs_tool.fs_upload( + fleet, t, local_path=str(root / "mcpd" / "install" / name), + remote_path=f"{STAGE}/{DRAWER}/{name}", verify=True) + print(f" {len(SCRIPTS)} install scripts", flush=True) + + readme = Path(args.readme) if args.readme else ( + root / "mcpd" / "install" / "Archive-README") + if readme.is_file(): + await fs_tool.fs_upload( + fleet, t, local_path=str(readme), + remote_path=f"{STAGE}/{DRAWER}/README", verify=True) + print(f" README (from {readme.name})", flush=True) + else: + print(f" no README at {readme} -- archive will omit it", + flush=True) + + # The bits that have to survive the trip. The uploads already land + # as ----rwed; `s` marks the AmigaDOS scripts executable by name. + await sh(fleet, t, f'Protect {STAGE}/{DRAWER}/MCPd +rwed') + await sh(fleet, t, f'Protect {STAGE}/{DRAWER}/#?-#? +rwed') + await sh(fleet, t, f'Protect {STAGE}/{DRAWER}/#?-#? +s') + + await rm(fleet, t, f"RAM:{archive}") + await sh(fleet, t, f'C:LhA -r -e a RAM:{archive} {DRAWER}', cwd=STAGE) + + # Prove the flags round-trip rather than assuming it: extract into + # a clean directory and look at what comes out. `s` is never a + # filesystem default, so it can only have come from the archive. + await rm(fleet, t, "RAM:mcpd-verify", all_=True) + await fs_tool.fs_makedir(fleet, t, "RAM:mcpd-verify") + await sh(fleet, t, f'C:LhA x RAM:{archive} RAM:mcpd-verify/') + listing = await sh(fleet, t, f'List RAM:mcpd-verify/{DRAWER}') + print("\n extracted flags:", flush=True) + ok = True + for line in listing.splitlines(): + parts = line.split() + if len(parts) < 3 or not parts[1].isdigit(): + continue + name, _size, flags = parts[0], parts[1], parts[2] + print(f" {name:22s} {flags}", flush=True) + if "e" not in flags.split("-")[-1]: + ok = False + print(" ^ NOT executable", flush=True) + if name.startswith("MCPd-") and "s" not in flags: + ok = False + print(" ^ script bit missing", flush=True) + if not ok: + raise SystemExit("protection bits did not survive the archive") + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + local = out_dir / archive + dl = await fs_tool.fs_download( + fleet, t, remote_path=f"RAM:{archive}", + local_path=str(local), verify=True) + + # Leave the target as we found it. + await rm(fleet, t, STAGE, all_=True) + await rm(fleet, t, "RAM:mcpd-verify", all_=True) + await rm(fleet, t, f"RAM:{archive}") + + print(f"\n {local} ({dl.bytes_total} bytes)", flush=True) + print(f" sha256 {dl.sha256}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) From 37fb38a908554da7b89f8e335322a8ea28573bc2 Mon Sep 17 00:00:00 2001 From: Richard Gibbs Date: Fri, 11 Sep 2026 08:33:49 +0100 Subject: [PATCH 2/2] MCPd-Install: '*' is Echo's escape character, so the note printed wrong Running the installer out of the release archive showed the last line as "keyboard/mouse injection (input.) is DISABLED" -- the asterisk had vanished. In AmigaDOS Echo, '*' escapes the next character, so a literal one has to be written '**'. MCPd-Enable-Input already carried a comment about this; MCPd-Install did not. Found by actually running the shipped artifact rather than reading it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VF4wwBXaKNK2Y2N5sYtt71 --- mcpd/install/MCPd-Install | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcpd/install/MCPd-Install b/mcpd/install/MCPd-Install index 9ef6e3c..8439fc7 100644 --- a/mcpd/install/MCPd-Install +++ b/mcpd/install/MCPd-Install @@ -56,7 +56,9 @@ Echo "" Echo "MCPd-Install: done. Reboot to auto-launch MCPd on :4322." Echo " Or launch immediately: Run >NIL: