diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 59202542..2624dd88 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -121,6 +121,17 @@ jobs: for i in $(seq 1 100); do [ -f "$DATA2/port.json" ] && break; sleep 0.1; done kill $PID2 || true test -f "$DATA2/port.json" + # TD-5228: the pinned agent-desktop CLI and its helper sit beside + # tstd, signed along with the app (ad-hoc here, run-tauri.mjs), and + # the CLI runs from there. Signing changes the bytes, so the release + # digest is not checked here; the daemon trusts the packaged copy + # (TD-5222). + AD="$APP/Contents/MacOS/agent-desktop" + test -x "$APP/Contents/MacOS/agent-desktop-macos-helper" + codesign -v "$AD" + codesign -v "$APP/Contents/MacOS/agent-desktop-macos-helper" + "$AD" version | grep -q '"version":"1.0.2"' + ls "$APP/Contents/Resources/licenses/LICENSE" "$APP/Contents/Resources/licenses/NOTICE" - name: Smoke the bundled sidecar (Linux) if: runner.os == 'Linux' diff --git a/DECISIONS.md b/DECISIONS.md index e943a9d2..7a415319 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -19663,3 +19663,57 @@ as two agents); hiding the ring after a fixed time inside an episode (the TD-3407 reason stands: a long think between actions is still the agent driving); a stop-file watch inside the AppKit helper (it would need the config path and the mode as well; the sidecar already has both). + +## 2026-11-03 — TD-5228: the Mac app carries agent-desktop (Class B) + +**Decision:** The macOS app (Apple silicon and Intel) ships the pinned +`agent-desktop` 1.0.2 CLI and its `agent-desktop-macos-helper` beside +`tstd`, the same way TD-5221 step 1 does on Linux, so the TD-5220 tools +work with `binary` empty. Windows stays unbundled. The tools stay off by +default. + +1. **The same fetch, two more targets.** `fetch_agent_desktop.py` pins + both macOS archives (digests copied from the release's `checksums.txt`) + and both helpers (hashed from the checked archives). The CLI digests + were already in `ax_cli.PINNED_SHA256`. The helper's name now depends + on the platform. `build_sidecar.py` fetches on macOS hosts too, in both + modes. +2. **A macOS platform config.** `shell/tauri.macos.conf.json` repeats + `binaries/tstd` (the merge patch replaces the list) and adds the two + files and the licence resources. A test checks that each platform config + keeps every shared entry and never names the other platform's helper, + which is not fetched there and would fail the build. +3. **Signed by the bundler, under their own names.** Tauri's macOS bundler + (CLI 2.11.4) copies every `externalBin` into `Contents/MacOS` with the + triple dropped, then signs each one before the app, with the app's + identity, the hardened runtime and the app's entitlements. So the CLI + and helper carry the same `tst-desk-dev` certificate signature as the + rest of the bundle, with identifiers `agent-desktop` and + `agent-desktop-macos-helper`. Unlike `tstd`, they are not re-signed + with the bundle id. TCC charges a command-line tool's Accessibility use + to the app that started its process tree, here the `tst-desk` host: + the 2026-09-02 entry saw exactly that for `tstd`'s own input events, + and the CLI's own permission guide relies on the same rule. + `install-macos.sh` refuses a bundle where either file is not + certificate-signed. +4. **The packaged copy is not the release's bytes.** Signing rewrites the + binary, so the bundled CLI never matches the release digest. TD-5222's + rule (the copy beside the packaged daemon is trusted as part of the + package) already covers it. The files in `shell/binaries/` stay as + released, so the build's pin check still holds and a rebuild needs no + download. +5. **The helper is for the clipboard only.** In 1.0.2 the CLI starts + `agent-desktop-macos-helper` only for its clipboard commands, which none + of the four tools use. It ships so the bundle matches the upstream + package. The CLI accepts it only beside itself under that exact name, + owned by the user running the CLI, with no folder above it writable by + others. `/Applications` is group-writable (admin), so from there the + clipboard commands would refuse; nothing in TST Desk calls them. + +**Rejected:** re-signing the two files with the bundle id, as +`install-macos.sh` does for `tstd` (attribution follows the responsible +process, and another identity rewrite is one more step that can drift); +signing them in `build_sidecar.py` before bundling (the bundler re-signs +every `externalBin` anyway, and the files in `shell/binaries/` would stop +matching their pins); bumping to agent-desktop 1.1.0, released the same +day (the tools were built and tested against 1.0.2). diff --git a/NOTICE b/NOTICE index e66779fe..a21fab8e 100644 --- a/NOTICE +++ b/NOTICE @@ -26,7 +26,9 @@ The accessibility desktop tools (computer_use.accessibility, TD-5220) run the unmodified agent-desktop command-line tool, release 1.0.2, as a separate process. This repository does not include its source or binaries. The Linux packages (AppImage, deb) ship the unmodified release binaries agent-desktop -and agent-desktop-linux-clipboard-owner, downloaded at build time and -checked against pinned SHA-256 digests (TD-5221), together with this notice -and a copy of the Apache License, Version 2.0 (licenses/LICENSE and +and agent-desktop-linux-clipboard-owner, and the macOS app ships +agent-desktop and agent-desktop-macos-helper, re-signed with the app's own +code signature. All are downloaded at build time and checked against pinned +SHA-256 digests (TD-5221, TD-5228), and ship together with this notice and a +copy of the Apache License, Version 2.0 (licenses/LICENSE and licenses/NOTICE in the installed app's resources). diff --git a/core/scripts/build_sidecar.py b/core/scripts/build_sidecar.py index 3c2c6a08..92046ed7 100644 --- a/core/scripts/build_sidecar.py +++ b/core/scripts/build_sidecar.py @@ -11,9 +11,10 @@ tauri.conf.json's beforeDevCommand uses it so `tauri dev` pays the build once instead of on every start. -On Linux it also puts the pinned agent-desktop CLI and its helper beside -the sidecar (TD-5221, ``fetch_agent_desktop.py``); tauri.linux.conf.json -bundles them. Both modes do this, and a checksum mismatch fails the build. +On Linux and macOS it also puts the pinned agent-desktop CLI and its helper +beside the sidecar (TD-5221, TD-5228, ``fetch_agent_desktop.py``); +tauri.linux.conf.json and tauri.macos.conf.json bundle them. Both modes do +this, and a checksum mismatch fails the build. Run from anywhere; paths resolve from this file's location. """ @@ -58,6 +59,11 @@ def host_triple() -> str: raise RuntimeError("could not parse host triple from rustc -vV") +def bundles_agent_desktop() -> bool: + """True on the hosts whose package carries the agent-desktop CLI.""" + return sys.platform.startswith("linux") or sys.platform == "darwin" + + def target_path(triple: str) -> Path: """Where this host's sidecar lands; Tauri's externalBin convention.""" # PyInstaller appends .exe on Windows, and Tauri's externalBin @@ -347,7 +353,7 @@ def main() -> int: ) args = parser.parse_args() triple = host_triple() - if sys.platform.startswith("linux"): + if bundles_agent_desktop(): try: for path in fetch_agent_desktop.ensure(triple): print(f"agent-desktop {fetch_agent_desktop.PINNED_RELEASE}: {path}") diff --git a/core/scripts/fetch_agent_desktop.py b/core/scripts/fetch_agent_desktop.py index 1e906c55..ee55eba6 100644 --- a/core/scripts/fetch_agent_desktop.py +++ b/core/scripts/fetch_agent_desktop.py @@ -1,25 +1,28 @@ -"""Fetch the pinned agent-desktop CLI for the Linux package (TD-5221). +"""Fetch the pinned agent-desktop CLI for the Linux and macOS packages (TD-5221, TD-5228). uv run python scripts/fetch_agent_desktop.py [--triple T] [--archive FILE] -Downloads the ``agent-desktop`` release archive for one Linux target, checks -the archive and both files inside it against pinned SHA-256 digests, and -writes them as Tauri ``externalBin`` sidecars: +Downloads the ``agent-desktop`` release archive for one Linux or macOS +target, checks the archive and both files inside it against pinned SHA-256 +digests, and writes them as Tauri ``externalBin`` sidecars: shell/binaries/agent-desktop- - shell/binaries/agent-desktop-linux-clipboard-owner- + shell/binaries/- -The bundler drops the triple, so both land beside ``tstd`` in the package, -which is where the CLI looks for its helper and where the daemon looks for -the CLI. Any digest mismatch fails the build. Nothing here runs in the app: -the packaged app never downloads. +where the helper is ``agent-desktop-linux-clipboard-owner`` on Linux and +``agent-desktop-macos-helper`` on macOS. The bundler drops the triple, so +both land beside ``tstd`` in the package, which is where the CLI looks for +its helper and where the daemon looks for the CLI. On macOS the bundler +signs both with the app's identity, and they run as children of TST Desk, +so their Accessibility use is TST Desk's own; the files written here stay +as released. Any digest mismatch fails the build. Nothing here runs in the +app: the packaged app never downloads. Files already in place with the pinned digests are kept, so a rebuild needs no network. ``--archive`` uses a local copy of the archive instead of downloading it; it is checked the same way. -Only Linux is bundled for now. macOS needs the helper signed with the app -and its Accessibility grant (TD-4823); Windows has no live test yet. +Windows is not bundled yet: it has no live test. """ from __future__ import annotations @@ -41,7 +44,9 @@ BINARIES = CORE.parent / "shell" / "binaries" CLI = "agent-desktop" -HELPER = "agent-desktop-linux-clipboard-owner" +LINUX_HELPER = "agent-desktop-linux-clipboard-owner" +#: The CLI accepts this helper only under exactly this name, beside itself. +MACOS_HELPER = "agent-desktop-macos-helper" URL = ( "https://github.com/vercel-labs/agent-desktop/releases/download/" @@ -52,6 +57,8 @@ ARCHIVE_SHA256: Mapping[str, str] = { "x86_64-unknown-linux-gnu": "ad661b7a3fd8f990a769243f4889a240ca0e2c8dab3ef5bddd6019086c71e0b4", "aarch64-unknown-linux-gnu": "864cb782e0afc8bf0df19622016b24bdbd1a83fc7749f784ee82aa49a7f845eb", + "aarch64-apple-darwin": "7bee385196d7b478de80cf05ca4304aaa38f0eba593b3406ad3c247c1773be19", + "x86_64-apple-darwin": "8edda6df17eafc042395502a823dbb8415bc8ac14835a6731e4d8becc9cb4121", } #: SHA-256 of the clipboard helper inside each archive above. The CLI's own @@ -59,6 +66,8 @@ HELPER_SHA256: Mapping[str, str] = { "x86_64-unknown-linux-gnu": "bf17de139902724e86b3616970b7b7f16f5f1fb4143910b532a38dd2182720fa", "aarch64-unknown-linux-gnu": "dcd6f2ce9a0e92e42f4d2ee9161ea1b9f2b11c0e4b1878119a19c0a2db3630a6", + "aarch64-apple-darwin": "522829116cb006b5770c6a82b361f466a4775f794d6af4083e75231a2d6bb551", + "x86_64-apple-darwin": "ce52467818ac601ae1145a0b9ecaa14aaf04d2e72d24cf6d29d26757340f9c6b", } #: The archives are about 3.4 MB; anything far larger is not the release. @@ -77,16 +86,21 @@ def bundled(triple: str) -> bool: return triple in ARCHIVE_SHA256 +def helper_name(triple: str) -> str: + """The helper the CLI starts on *triple*'s platform.""" + return MACOS_HELPER if triple.endswith("-apple-darwin") else LINUX_HELPER + + def expected(triple: str) -> dict[str, str]: """File name inside the archive → its pinned SHA-256.""" if not bundled(triple): raise FetchError(f"no pinned agent-desktop {PINNED_RELEASE} archive for {triple}") - return {CLI: PINNED_SHA256[triple], HELPER: HELPER_SHA256[triple]} + return {CLI: PINNED_SHA256[triple], helper_name(triple): HELPER_SHA256[triple]} def sidecar_paths(triple: str, binaries: Path = BINARIES) -> dict[str, Path]: """Where each file lands: Tauri's ``externalBin`` name-triple convention.""" - return {name: binaries / f"{name}-{triple}" for name in (CLI, HELPER)} + return {name: binaries / f"{name}-{triple}" for name in (CLI, helper_name(triple))} def _sha256(data: bytes) -> str: diff --git a/core/scripts/smoke_macos_bundle.sh b/core/scripts/smoke_macos_bundle.sh index ff0bfa84..b58956f4 100755 --- a/core/scripts/smoke_macos_bundle.sh +++ b/core/scripts/smoke_macos_bundle.sh @@ -2,7 +2,8 @@ # Packaged-sidecar smoke on a clean macOS guest (TD-4906). # # Run on macOS after `npm run tauri:build` in ui/. Proves the bundled sidecar -# serves with no Python on PATH, then runs the protocol stranger loop +# serves with no Python on PATH, that agent-desktop and its helper ship signed +# beside it (TD-5228), then runs the protocol stranger loop # (mock OpenAI → local preset → fs_write → reply) and a keychain probe. # # Usage: smoke_macos_bundle.sh [path-to-.app] @@ -46,6 +47,18 @@ kill "$PID" 2>/dev/null || true wait "$PID" 2>/dev/null || true echo "sidecar ok: $(cat "$DATA/d/port.json")" +echo "== agent-desktop beside tstd (TD-5228) ==" +AD="$APP/Contents/MacOS/agent-desktop" +AD_HELPER="$APP/Contents/MacOS/agent-desktop-macos-helper" +test -x "$AD" +test -x "$AD_HELPER" +codesign -v "$AD" +codesign -v "$AD_HELPER" +"$AD" version | grep -q '"version":"1.0.2"' +test -f "$APP/Contents/Resources/licenses/LICENSE" +test -f "$APP/Contents/Resources/licenses/NOTICE" +echo "agent-desktop ok: $("$AD" version)" + echo "== protocol E2E (bundled tstd + loopback mock) ==" WS=$(mktemp -d) export HOME="$DATA/home" @@ -79,4 +92,4 @@ kill "$DAEMON" 2>/dev/null || true wait "$DAEMON" 2>/dev/null || true kill "$MOCK" 2>/dev/null || true wait "$MOCK" 2>/dev/null || true -echo "clean macOS guest: sidecar + protocol E2E + keychain probe ok" +echo "clean macOS guest: sidecar + agent-desktop + protocol E2E + keychain probe ok" diff --git a/core/tests/test_fetch_agent_desktop.py b/core/tests/test_fetch_agent_desktop.py index 8ce671b4..584a106d 100644 --- a/core/tests/test_fetch_agent_desktop.py +++ b/core/tests/test_fetch_agent_desktop.py @@ -1,4 +1,4 @@ -"""Build-time fetch of the pinned agent-desktop CLI for Linux packages (TD-5221). +"""Build-time fetch of the pinned agent-desktop CLI (TD-5221 Linux, TD-5228 macOS). A build gets exactly the pinned files or fails: the archive and both files in it are checked, only those two members are read, and nothing is written @@ -13,6 +13,7 @@ import os import sys import tarfile +from dataclasses import dataclass from pathlib import Path import pytest @@ -24,9 +25,10 @@ from tstd.desktop.ax_cli import PINNED_RELEASE, PINNED_SHA256 REPO = Path(__file__).resolve().parents[2] -TRIPLE = "x86_64-unknown-linux-gnu" -CLI_BYTES = b"\x7fELF pretend cli" -HELPER_BYTES = b"\x7fELF pretend helper" +LINUX = "x86_64-unknown-linux-gnu" +MACOS = "aarch64-apple-darwin" +CLI_BYTES = b"pretend cli" +HELPER_BYTES = b"pretend helper" def _sha(data: bytes) -> str: @@ -49,15 +51,27 @@ def _archive(members: dict[str, bytes], *, link: str | None = None) -> bytes: return buf.getvalue() -GOOD = _archive({fad.CLI: CLI_BYTES, fad.HELPER: HELPER_BYTES}) +@dataclass(frozen=True) +class Target: + """One bundled platform: its triple, the helper its CLI starts, a good archive.""" + triple: str + helper: str + good: bytes -@pytest.fixture -def pins(monkeypatch: pytest.MonkeyPatch) -> None: - """Pin the fake archive in place of the real release.""" - monkeypatch.setattr(fad, "ARCHIVE_SHA256", {TRIPLE: _sha(GOOD)}) - monkeypatch.setattr(fad, "HELPER_SHA256", {TRIPLE: _sha(HELPER_BYTES)}) - monkeypatch.setattr(fad, "PINNED_SHA256", {TRIPLE: _sha(CLI_BYTES)}) + +@pytest.fixture( + params=[(LINUX, "agent-desktop-linux-clipboard-owner"), (MACOS, "agent-desktop-macos-helper")], + ids=["linux", "macos"], +) +def target(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Target: + """Pin a fake archive for one platform in place of the real release.""" + triple, helper = request.param + good = _archive({fad.CLI: CLI_BYTES, helper: HELPER_BYTES}) + monkeypatch.setattr(fad, "ARCHIVE_SHA256", {triple: _sha(good)}) + monkeypatch.setattr(fad, "HELPER_SHA256", {triple: _sha(HELPER_BYTES)}) + monkeypatch.setattr(fad, "PINNED_SHA256", {triple: _sha(CLI_BYTES)}) + return Target(triple, helper, good) class FakeDownload: @@ -70,18 +84,17 @@ def __call__(self, url: str) -> bytes: return self.data -@pytest.mark.usefixtures("pins") class TestEnsure: - def test_writes_both_sidecars_from_the_pinned_url(self, tmp_path: Path) -> None: - fetch = FakeDownload(GOOD) - paths = fad.ensure(TRIPLE, binaries=tmp_path, fetch=fetch) + def test_writes_both_sidecars_from_the_pinned_url(self, target: Target, tmp_path: Path) -> None: + fetch = FakeDownload(target.good) + paths = fad.ensure(target.triple, binaries=tmp_path, fetch=fetch) assert fetch.urls == [ "https://github.com/vercel-labs/agent-desktop/releases/download/" - f"v{PINNED_RELEASE}/agent-desktop-v{PINNED_RELEASE}-{TRIPLE}.tar.gz" + f"v{PINNED_RELEASE}/agent-desktop-v{PINNED_RELEASE}-{target.triple}.tar.gz" ] assert paths == [ - tmp_path / f"agent-desktop-{TRIPLE}", - tmp_path / f"agent-desktop-linux-clipboard-owner-{TRIPLE}", + tmp_path / f"agent-desktop-{target.triple}", + tmp_path / f"{target.helper}-{target.triple}", ] assert paths[0].read_bytes() == CLI_BYTES assert paths[1].read_bytes() == HELPER_BYTES @@ -89,75 +102,93 @@ def test_writes_both_sidecars_from_the_pinned_url(self, tmp_path: Path) -> None: assert all(os.access(p, os.X_OK) for p in paths) assert sorted(p.name for p in tmp_path.iterdir()) == sorted(p.name for p in paths) - def test_archive_mismatch_writes_nothing(self, tmp_path: Path) -> None: - tampered = _archive({fad.CLI: CLI_BYTES + b"!", fad.HELPER: HELPER_BYTES}) + def test_archive_mismatch_writes_nothing(self, target: Target, tmp_path: Path) -> None: + tampered = _archive({fad.CLI: CLI_BYTES + b"!", target.helper: HELPER_BYTES}) with pytest.raises(fad.FetchError, match="archive"): - fad.ensure(TRIPLE, binaries=tmp_path, fetch=FakeDownload(tampered)) + fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(tampered)) assert list(tmp_path.iterdir()) == [] def test_cli_mismatch_inside_a_pinned_archive_writes_nothing( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + self, target: Target, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The archive pin alone is not enough: the CLI must match the daemon's pin.""" - swapped = _archive({fad.CLI: b"other cli", fad.HELPER: HELPER_BYTES}) - monkeypatch.setattr(fad, "ARCHIVE_SHA256", {TRIPLE: _sha(swapped)}) + swapped = _archive({fad.CLI: b"other cli", target.helper: HELPER_BYTES}) + monkeypatch.setattr(fad, "ARCHIVE_SHA256", {target.triple: _sha(swapped)}) with pytest.raises(fad.FetchError, match="agent-desktop for"): - fad.ensure(TRIPLE, binaries=tmp_path, fetch=FakeDownload(swapped)) + fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(swapped)) + assert list(tmp_path.iterdir()) == [] + + def test_helper_mismatch_inside_a_pinned_archive_writes_nothing( + self, target: Target, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The helper has its own pin, checked even when the archive's matches.""" + swapped = _archive({fad.CLI: CLI_BYTES, target.helper: b"other helper"}) + monkeypatch.setattr(fad, "ARCHIVE_SHA256", {target.triple: _sha(swapped)}) + with pytest.raises(fad.FetchError, match=f"{target.helper} for"): + fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(swapped)) assert list(tmp_path.iterdir()) == [] - def test_missing_helper_fails(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_missing_helper_fails( + self, target: Target, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: lone = _archive({fad.CLI: CLI_BYTES}) - monkeypatch.setattr(fad, "ARCHIVE_SHA256", {TRIPLE: _sha(lone)}) + monkeypatch.setattr(fad, "ARCHIVE_SHA256", {target.triple: _sha(lone)}) with pytest.raises(fad.FetchError, match="not in the"): - fad.ensure(TRIPLE, binaries=tmp_path, fetch=FakeDownload(lone)) + fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(lone)) assert list(tmp_path.iterdir()) == [] - def test_link_member_is_refused(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - linked = _archive({fad.CLI: CLI_BYTES}, link=fad.HELPER) - monkeypatch.setattr(fad, "ARCHIVE_SHA256", {TRIPLE: _sha(linked)}) + def test_link_member_is_refused( + self, target: Target, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + linked = _archive({fad.CLI: CLI_BYTES}, link=target.helper) + monkeypatch.setattr(fad, "ARCHIVE_SHA256", {target.triple: _sha(linked)}) with pytest.raises(fad.FetchError, match="not a regular file"): - fad.ensure(TRIPLE, binaries=tmp_path, fetch=FakeDownload(linked)) + fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(linked)) assert list(tmp_path.iterdir()) == [] - def test_pinned_files_in_place_need_no_download(self, tmp_path: Path) -> None: - fad.ensure(TRIPLE, binaries=tmp_path, fetch=FakeDownload(GOOD)) + def test_pinned_files_in_place_need_no_download(self, target: Target, tmp_path: Path) -> None: + fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(target.good)) again = FakeDownload(b"never read") - fad.ensure(TRIPLE, binaries=tmp_path, fetch=again) + fad.ensure(target.triple, binaries=tmp_path, fetch=again) assert again.urls == [] - def test_tampered_file_in_place_is_replaced(self, tmp_path: Path) -> None: - paths = fad.ensure(TRIPLE, binaries=tmp_path, fetch=FakeDownload(GOOD)) + def test_tampered_file_in_place_is_replaced(self, target: Target, tmp_path: Path) -> None: + paths = fad.ensure(target.triple, binaries=tmp_path, fetch=FakeDownload(target.good)) paths[0].write_bytes(b"tampered") - fetch = FakeDownload(GOOD) - fad.ensure(TRIPLE, binaries=tmp_path, fetch=fetch) + fetch = FakeDownload(target.good) + fad.ensure(target.triple, binaries=tmp_path, fetch=fetch) assert len(fetch.urls) == 1 assert paths[0].read_bytes() == CLI_BYTES - def test_local_archive_is_checked_the_same_way(self, tmp_path: Path) -> None: + def test_local_archive_is_checked_the_same_way(self, target: Target, tmp_path: Path) -> None: local = tmp_path / "release.tar.gz" - local.write_bytes(GOOD) + local.write_bytes(target.good) out = tmp_path / "bin" fetch = FakeDownload(b"never read") - fad.ensure(TRIPLE, binaries=out, archive=local, fetch=fetch) + fad.ensure(target.triple, binaries=out, archive=local, fetch=fetch) assert fetch.urls == [] - assert (out / f"agent-desktop-{TRIPLE}").read_bytes() == CLI_BYTES - local.write_bytes(GOOD + b"x") - (out / f"agent-desktop-{TRIPLE}").unlink() + assert (out / f"agent-desktop-{target.triple}").read_bytes() == CLI_BYTES + local.write_bytes(target.good + b"x") + (out / f"agent-desktop-{target.triple}").unlink() with pytest.raises(fad.FetchError): - fad.ensure(TRIPLE, binaries=out, archive=local) + fad.ensure(target.triple, binaries=out, archive=local) - def test_unbundled_target_is_refused_before_any_download(self, tmp_path: Path) -> None: - fetch = FakeDownload(GOOD) - with pytest.raises(fad.FetchError, match="no pinned"): - fad.ensure("aarch64-apple-darwin", binaries=tmp_path, fetch=fetch) - assert fetch.urls == [] + +def test_unbundled_target_is_refused_before_any_download(tmp_path: Path) -> None: + fetch = FakeDownload(b"never read") + with pytest.raises(fad.FetchError, match="no pinned"): + fad.ensure("x86_64-pc-windows-msvc", binaries=tmp_path, fetch=fetch) + assert fetch.urls == [] + assert list(tmp_path.iterdir()) == [] class TestRealPins: - def test_linux_targets_only(self) -> None: + def test_linux_and_macos_targets(self) -> None: assert set(fad.ARCHIVE_SHA256) == { "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-apple-darwin", } assert set(fad.HELPER_SHA256) == set(fad.ARCHIVE_SHA256) @@ -165,40 +196,78 @@ def test_cli_pin_is_the_daemons_pin(self) -> None: for triple in fad.ARCHIVE_SHA256: assert fad.expected(triple)[fad.CLI] == PINNED_SHA256[triple] + def test_each_platform_reads_its_own_helper(self) -> None: + for triple in ("aarch64-apple-darwin", "x86_64-apple-darwin"): + assert set(fad.expected(triple)) == {"agent-desktop", "agent-desktop-macos-helper"} + for triple in ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"): + assert set(fad.expected(triple)) == { + "agent-desktop", + "agent-desktop-linux-clipboard-owner", + } + + def test_packaged_names_are_the_ones_the_cli_looks_for(self) -> None: + """Tauri drops ``-``; the macOS CLI accepts its helper only by exact name.""" + for triple in ("aarch64-apple-darwin", "x86_64-apple-darwin"): + packaged = { + path.name.replace(f"-{triple}", "") + for path in fad.sidecar_paths(triple, Path("binaries")).values() + } + assert packaged == {"agent-desktop", "agent-desktop-macos-helper"} + def test_digests_are_sha256_hex(self) -> None: for table in (fad.ARCHIVE_SHA256, fad.HELPER_SHA256): for digest in table.values(): assert len(digest) == 64 and int(digest, 16) >= 0 + assert len({*fad.ARCHIVE_SHA256.values(), *fad.HELPER_SHA256.values()}) == 8 + + +PLATFORM_CONFIGS = [ + ("tauri.linux.conf.json", fad.LINUX_HELPER, fad.MACOS_HELPER), + ("tauri.macos.conf.json", fad.MACOS_HELPER, fad.LINUX_HELPER), +] -class TestTauriLinuxConfig: - """tauri.linux.conf.json replaces externalBin wholesale (JSON merge patch).""" +@pytest.mark.parametrize( + ("name", "helper", "other_helper"), PLATFORM_CONFIGS, ids=["linux", "macos"] +) +class TestTauriPlatformConfig: + """A platform config replaces externalBin wholesale (JSON merge patch).""" def _load(self, name: str) -> dict[str, object]: data: dict[str, object] = json.loads((REPO / "shell" / name).read_text(encoding="utf-8")) return data - def test_keeps_every_shared_sidecar_and_adds_both_files(self) -> None: + def test_keeps_every_shared_sidecar_and_adds_both_files( + self, name: str, helper: str, other_helper: str + ) -> None: shared = self._load("tauri.conf.json")["bundle"] - linux = self._load("tauri.linux.conf.json")["bundle"] - assert isinstance(shared, dict) and isinstance(linux, dict) - assert set(shared["externalBin"]) <= set(linux["externalBin"]) - assert {f"binaries/{fad.CLI}", f"binaries/{fad.HELPER}"} <= set(linux["externalBin"]) - - def test_bundles_licence_and_notice(self) -> None: - linux = self._load("tauri.linux.conf.json")["bundle"] - assert isinstance(linux, dict) - resources = linux["resources"] + platform = self._load(name)["bundle"] + assert isinstance(shared, dict) and isinstance(platform, dict) + assert set(shared["externalBin"]) <= set(platform["externalBin"]) + assert {f"binaries/{fad.CLI}", f"binaries/{helper}"} <= set(platform["externalBin"]) + # The other platform's helper is never fetched here, so listing it fails the build. + assert f"binaries/{other_helper}" not in platform["externalBin"] + + def test_bundles_licence_and_notice(self, name: str, helper: str, other_helper: str) -> None: + platform = self._load(name)["bundle"] + assert isinstance(platform, dict) + resources = platform["resources"] assert isinstance(resources, dict) for source in resources: assert (REPO / "shell" / source).resolve().is_file(), source assert {Path(s).name for s in resources} == {"LICENSE", "NOTICE"} -def test_build_fails_on_linux_without_a_pin(monkeypatch: pytest.MonkeyPatch) -> None: - """build_sidecar stops before building when the Linux target has no pinned CLI.""" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(sidecar, "host_triple", lambda: "riscv64gc-unknown-linux-gnu") +@pytest.mark.parametrize( + ("platform", "triple"), + [("linux", "riscv64gc-unknown-linux-gnu"), ("darwin", "arm64e-apple-darwin")], +) +def test_build_fails_without_a_pin( + monkeypatch: pytest.MonkeyPatch, platform: str, triple: str +) -> None: + """build_sidecar stops before building when the host's target has no pinned CLI.""" + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(sidecar, "host_triple", lambda: triple) monkeypatch.setattr(sys, "argv", ["build_sidecar.py"]) def no_build(triple: str) -> Path: @@ -206,3 +275,28 @@ def no_build(triple: str) -> Path: monkeypatch.setattr(sidecar, "build", no_build) assert sidecar.main() == 1 + + +@pytest.mark.parametrize( + ("platform", "triple", "fetched"), + [("linux", LINUX, True), ("darwin", MACOS, True), ("win32", "x86_64-pc-windows-msvc", False)], +) +def test_if_missing_still_fetches_where_the_package_carries_it( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, platform: str, triple: str, fetched: bool +) -> None: + """``tauri dev`` (``--if-missing``) gets the CLI the same way a package build does.""" + built = tmp_path / f"tstd-{triple}" + built.write_bytes(b"sidecar") + calls: list[str] = [] + + def ensure(triple: str) -> list[Path]: + calls.append(triple) + return [] + + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(sidecar, "host_triple", lambda: triple) + monkeypatch.setattr(sidecar, "target_path", lambda triple: built) + monkeypatch.setattr(fad, "ensure", ensure) + monkeypatch.setattr(sys, "argv", ["build_sidecar.py", "--if-missing"]) + assert sidecar.main() == 0 + assert calls == ([triple] if fetched else []) diff --git a/docs/configuration.md b/docs/configuration.md index 57a8c316..173a5a9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -343,11 +343,13 @@ Vercel Labs' [`agent-desktop`](https://github.com/vercel-labs/agent-desktop) CLI (Apache-2.0), release 1.0.2, which TST Desk runs as a short-lived child per command. Nothing listens on a socket. -The Linux packages (AppImage and deb, x86_64 and aarch64) carry that -binary and its `agent-desktop-linux-clipboard-owner` helper beside the -daemon, so there `binary` can stay empty. On macOS and Windows nothing is -bundled yet: install the 1.0.2 release yourself and set `binary`, or put -it on `PATH`. The tools stay off until `enabled` is on. +The Linux packages (AppImage and deb, x86_64 and aarch64) and the macOS +app (Apple silicon and Intel) carry that binary and its helper +(`agent-desktop-linux-clipboard-owner` on Linux, +`agent-desktop-macos-helper` on macOS) beside the daemon, so there +`binary` can stay empty. On Windows nothing is bundled yet: install the +1.0.2 release yourself and set `binary`, or put it on `PATH`. The tools +stay off until `enabled` is on. The coordinate tools stay. Browsers, canvases, remote desktops and TST Desk's own window expose little or nothing to the accessibility tree. @@ -370,8 +372,8 @@ effect when the daemon restarts. | Key | Type | Default | Effect | |---|---|---|---| | `enabled` | bool | `false` | Lists the four tools. Off, they are not offered to the model. | -| `binary` | string | *empty* | Path to the `agent-desktop` binary. Empty uses the copy beside the packaged daemon (bundled in the Linux packages), then `PATH`. | -| `sha256` | string | *empty* | The binary's SHA-256 (64 hex characters). Empty accepts only the pinned 1.0.2 release binaries, except the copy shipped inside the app package, which runs as is (the AppImage tool rewrites the binaries it packs). A binary that does not match is never run. | +| `binary` | string | *empty* | Path to the `agent-desktop` binary. Empty uses the copy beside the packaged daemon (bundled in the Linux packages and the macOS app), then `PATH`. | +| `sha256` | string | *empty* | The binary's SHA-256 (64 hex characters). Empty accepts only the pinned 1.0.2 release binaries, except the copy shipped inside the app package, which runs as is (the AppImage tool rewrites the binaries it packs, and the macOS app signs them with its own signature). A binary that does not match is never run. | | `timeout_seconds` | float > 0 | `20` | How long one CLI command may run. | | `max_snapshot_chars` | int ≥ 1000 | `12000` | Cap on one snapshot's text. A larger window comes back as a shallow skeleton, and `root=` opens one branch. | @@ -383,6 +385,10 @@ computer_use: binary: /opt/agent-desktop/agent-desktop ``` +On macOS the bundled CLI runs as part of TST Desk, so it uses TST Desk's +own Accessibility permission (System Settings → Privacy & Security → +Accessibility); it has no entry of its own. + On Linux the apps must be on the AT-SPI accessibility bus. A desktop host that sets `NO_AT_BRIDGE=1` keeps apps off it; TST Desk removes that variable for the CLI and for every app `desktop_launch` starts. An app diff --git a/docs/tst-desk-backlog.md b/docs/tst-desk-backlog.md index dc97b2e2..5fabc8a9 100644 --- a/docs/tst-desk-backlog.md +++ b/docs/tst-desk-backlog.md @@ -13037,7 +13037,39 @@ and headless type, toggle and click with no pointer movement. **Not in this story:** shipping the binary as a per-platform sidecar in the packaged app (TD-5221, below). The Linux packages carry it since TD-5221 -step 1; elsewhere the binary comes from `binary` or `PATH`. +step 1 and the Mac app since TD-5228; on Windows the binary comes from +`binary` or `PATH`. + +--- + +### TD-5228 — The Mac app ships agent-desktop +**Size:** 3 · **Depends on:** TD-5221 +**Status:** In review 2026-11-03. The live check runs separately against a signed build. + +This is TD-5221 step 2. The TD-5220 tools only worked on a Mac when the +user installed the `agent-desktop` CLI and set `binary`. The macOS app +(Apple silicon and Intel) now carries the pinned 1.0.2 CLI and +`agent-desktop-macos-helper` beside `tstd`, fetched at build time by URL +and pinned digest, the same way the Linux packages do. Tauri's bundler +signs both with the app's identity. The tools stay off by default. + +**Acceptance criteria:** +- [x] The build downloads the 1.0.2 archive for `aarch64-apple-darwin` and `x86_64-apple-darwin`, checks it against digests copied from the release's `checksums.txt`, checks the CLI against `ax_cli.PINNED_SHA256` and the helper against its own pin, and reads only those two members; any mismatch fails the build and writes nothing (`test_fetch_agent_desktop.py`; a real fetch on an Apple silicon Mac wrote both files with their pinned digests, and the Intel pins match the real Intel archive) +- [x] `build_sidecar.py` fetches them on macOS hosts in both normal and `--if-missing` modes (`test_build_fails_without_a_pin`, `test_if_missing_still_fetches_where_the_package_carries_it`; also run for real in `--if-missing` mode on a Mac) +- [x] `shell/tauri.macos.conf.json` bundles both beside `tstd` and ships `licenses/LICENSE` and `licenses/NOTICE`; a test checks it keeps every shared `externalBin` entry and names only the macOS helper. Tauri drops the triple, so the helper lands under the exact name the CLI looks for beside itself +- [x] The package workflow and `smoke_macos_bundle.sh` check that both files are present and signed, that the CLI's `version` is 1.0.2, and that the licence files ship; `install-macos.sh` refuses a bundle where either file is not certificate-signed (each check run against a hand-made bundle) +- [ ] A signed build carries both files with the app's certificate signature (Tauri 2.11.4's bundler signs every `externalBin` with the app's identity before the app) +- [ ] Live on macOS: desktop_windows, desktop_snapshot, desktop_act and desktop_launch work from the packaged app with `binary` empty, and Accessibility is attributed to TST Desk with no separate prompt + +**Notes:** In 1.0.2 the CLI starts `agent-desktop-macos-helper` only for +its clipboard commands, which the four tools do not use; it accepts the +helper only beside itself, owned by the user, with no folder above it +writable by others, so under the group-writable `/Applications` those +commands would refuse. Seen on a managed Mac: 1.0.2's `list-windows` with +no app filter refuses with `PERM_DENIED` (`process_identity_permission`) +when a root-owned process has a window on screen, while `--app ` +works. That is upstream, and it affects `desktop_windows` without `app` +on such a Mac whether or not the CLI is bundled. --- @@ -13162,7 +13194,7 @@ deb-only smoke passed. ### TD-5221 — Ship agent-desktop as a per-platform sidecar -**Size:** 3 · **Depends on:** TD-5220, TD-1725 · **Status:** In progress (step 1, Linux, done) +**Size:** 3 · **Depends on:** TD-5220, TD-1725 · **Status:** In progress (step 1, Linux, done; step 2, macOS, is TD-5228) TD-5220 runs a user-supplied binary. The packaged app should carry the pinned release beside `tstd` for each of the five package targets (the @@ -13192,13 +13224,13 @@ files' digests (the CLI's is `ax_cli.PINNED_SHA256`), and writes them as workflow runs `agent-desktop version` from the extracted deb. - [x] Linux: the licence and NOTICE ship in the bundle. -**Step 2 — macOS (follow-up).** Needs a live test on a real Mac first, -including the Accessibility permission prompt. +**Step 2 — macOS.** Built as TD-5228 (above), which carries the live +check on a real Mac, including the Accessibility permission. - [ ] macOS: the CLI and `agent-desktop-macos-helper` are signed with the app, and Accessibility permission is attributed to the process TCC sees through TST Desk's existing host (TD-4823), with no separate - prompt. + prompt. Ticks with TD-5228's live criterion. **Step 3 — Windows (later, on demand).** diff --git a/shell/scripts/install-macos.sh b/shell/scripts/install-macos.sh index aef1b1e9..794da296 100755 --- a/shell/scripts/install-macos.sh +++ b/shell/scripts/install-macos.sh @@ -127,6 +127,17 @@ for bin in "$HOST" "$SIDECAR"; do exit 1 fi done +# The agent-desktop CLI and its helper (TD-5228) run under TST Desk, so TCC +# charges their Accessibility use to the app. They keep their own +# identifiers, but the bundler must have signed them with the same identity. +for bin in "$BUILT/Contents/MacOS/agent-desktop" "$BUILT/Contents/MacOS/agent-desktop-macos-helper"; do + [[ -f "$bin" ]] || continue + req="$(requirement "$bin")" + if [[ "$req" != *"certificate leaf"* ]]; then + echo "install-macos: $bin is not signed with a certificate (requirement: $req)" >&2 + exit 1 + fi +done # 5. Never replace a running app. if pgrep -x tst-desk >/dev/null 2>&1; then diff --git a/shell/tauri.macos.conf.json b/shell/tauri.macos.conf.json new file mode 100644 index 00000000..3ab13c43 --- /dev/null +++ b/shell/tauri.macos.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "externalBin": [ + "binaries/tstd", + "binaries/agent-desktop", + "binaries/agent-desktop-macos-helper" + ], + "resources": { + "../LICENSE": "licenses/LICENSE", + "../NOTICE": "licenses/NOTICE" + } + } +}