From b2be49a83eedcb06857d58a39085d7fe51914f7e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:41:34 +0000 Subject: [PATCH 1/5] ci: verify signed Computer Use hosts against nightlies Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-72710c32bbeb8f787f6a --- .../nightly-next-cua-signed-host.yml | 122 +++++++ scripts/ci/cua_signed_host_check.py | 335 ++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 .github/workflows/nightly-next-cua-signed-host.yml create mode 100644 scripts/ci/cua_signed_host_check.py diff --git a/.github/workflows/nightly-next-cua-signed-host.yml b/.github/workflows/nightly-next-cua-signed-host.yml new file mode 100644 index 000000000000..6d6fe65b0c16 --- /dev/null +++ b/.github/workflows/nightly-next-cua-signed-host.yml @@ -0,0 +1,122 @@ +name: nightly-next Computer Use signed host + +# A real-artifact contract for Computer Use consent. The appcast identities +# are downloaded on a fleet Mac and exercised through both helper drivers. A +# schedule is registered from the default branch; tooling comes from +# feat-cmux-next so the release branch and this check use the same code. +on: + schedule: + - cron: "23 * * * *" + workflow_dispatch: + pull_request: + paths: + - .github/workflows/nightly-next-cua-signed-host.yml + - scripts/ci/cua_signed_host_check.py + +permissions: {} + +concurrency: + group: nightly-next-cua-signed-host-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + TOOLING_REF: ${{ github.event_name == 'pull_request' && github.base_ref == 'feat-cmux-next' && github.sha || 'feat-cmux-next' }} + +jobs: + pick: + if: github.repository == 'manaflow-ai/cmux' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-16vcpu-ubuntu-2404' || !startsWith(vars.LINUX_RUNNER, 'blacksmith-') && vars.LINUX_RUNNER || vars.CI_CLOUD_OVERFLOW_SAVED && 'blacksmith-32vcpu-ubuntu-2404' || 'blacksmith-16vcpu-ubuntu-2404' }} + timeout-minutes: 8 + environment: ${{ github.event_name != 'pull_request' && 'ci-routing-agent' || '' }} + permissions: {} + outputs: + runner: ${{ steps.place.outputs.runner }} + owned_jobs: ${{ steps.place.outputs.owned_jobs }} + blacksmith_12_jobs: ${{ steps.place.outputs.blacksmith_12_jobs }} + blacksmith_6_jobs: ${{ steps.place.outputs.blacksmith_6_jobs }} + host_available: ${{ steps.host.outputs.host_available }} + placed_attempt: ${{ steps.place.outcome == 'success' && format('{0}', github.run_attempt) || '' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ env.TOOLING_REF }} + persist-credentials: false + sparse-checkout: scripts/ci + sparse-checkout-cone-mode: false + + - name: Mint the owned-pool routing token + id: route-token + if: github.event_name != 'pull_request' && vars.GLAEDA_ROUTE_APP_ID != '' + continue-on-error: true + timeout-minutes: 1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.GLAEDA_ROUTE_APP_ID }} + private-key: ${{ secrets.GLAEDA_ROUTE_APP_KEY }} + permission-administration: read + permission-organization-self-hosted-runners: read + permission-actions: read + + - name: Pick an idle owned Mac, else a moving Blacksmith label + id: place + continue-on-error: true + timeout-minutes: 3 + env: + ROUTE_TOKEN: ${{ steps.route-token.outputs.token }} + SNAPSHOT_TOKEN: ${{ github.token }} + SIDE_LABEL: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && vars.CI_SIDE_LANE_RUNNER || '' }} + JOBS: cua-signed-host + CASCADE_JOBS: cua-signed-host + CASCADE_RUNS: ' cua-signed-host ' + FRESH_JOBS: cua-signed-host + run: python3 scripts/ci/side_lane_placement.py + + - name: Decide whether a Mac route was placed + id: host + env: + OWNED_JOBS: ${{ steps.place.outputs.owned_jobs }} + BLACKSMITH_12_JOBS: ${{ steps.place.outputs.blacksmith_12_jobs }} + BLACKSMITH_6_JOBS: ${{ steps.place.outputs.blacksmith_6_jobs }} + run: | + if [[ -n "$OWNED_JOBS" || "$BLACKSMITH_12_JOBS" == *" cua-signed-host "* || "$BLACKSMITH_6_JOBS" == *" cua-signed-host "* ]]; then + echo "host_available=true" >> "$GITHUB_OUTPUT" + else + echo "host_available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Report unavailable fleet Mac + if: steps.host.outputs.host_available != 'true' + run: | + echo "skipped: no host" + echo "skipped: no host" >> "$GITHUB_STEP_SUMMARY" + + signed-host: + name: signed host admission + needs: pick + if: needs.pick.result == 'success' && needs.pick.outputs.host_available == 'true' + # The picker offers an idle owned Mac first, then a moving Blacksmith slot. + # If neither route has room, the job is skipped in pick; it never queues on + # the saturated side-std label. The final label is unreachable on a normal + # skip, but remains a non-hosted Mac fallback for a placement race. + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || needs.pick.outputs.placed_attempt == format('{0}', github.run_attempt) && contains(needs.pick.outputs.blacksmith_12_jobs, ' cua-signed-host ') && 'blacksmith-12vcpu-macos-26' || needs.pick.outputs.placed_attempt == format('{0}', github.run_attempt) && contains(needs.pick.outputs.blacksmith_6_jobs, ' cua-signed-host ') && 'blacksmith-6vcpu-macos-26' || needs.pick.outputs.owned_jobs != '' && needs.pick.outputs.runner || 'blacksmith-6vcpu-macos-26' }} + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ env.TOOLING_REF }} + persist-credentials: false + sparse-checkout: | + scripts/ci/cua_signed_host_check.py + scripts/select-ci-xcode.sh + sparse-checkout-cone-mode: false + + - name: Select Xcode + env: + CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} + CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" + run: ./scripts/select-ci-xcode.sh + + - name: Check current signed NIGHTLY host admission + run: python3 scripts/ci/cua_signed_host_check.py diff --git a/scripts/ci/cua_signed_host_check.py b/scripts/ci/cua_signed_host_check.py new file mode 100644 index 000000000000..8e205b751366 --- /dev/null +++ b/scripts/ci/cua_signed_host_check.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Exercise Computer Use host admission with the signed NIGHTLY apps. + +The unit tests inject the code check. This test keeps the real signed app in +the loop: it downloads the two NIGHTLY identities, starts each app with each +Computer Use driver enabled, and verifies that the app's authenticated host +connection remains open. A host connection that is refused closes before it +can remain in the app's descriptor table. + +The helper's own signed bundle is checked as a negative identity as well. An +ad-hoc tagged app and an ordinary unsigned process are connected to each host +socket and must receive the stable ``host_unverified`` refusal. The detailed +``not Apple-signed (ad hoc or unsigned)`` reason is emitted by the helper's +real code check (and is covered by the HostVerifier unit test); the wire reply +deliberately exposes only the stable refusal reason. + +The NEXT appcast is the blocking contract. The legacy identity is retained as +a best-effort diagnostic while that feed is still published; a retired or +temporarily unavailable classic feed must not turn this scheduled check red. +""" + +from __future__ import annotations + +import argparse +import os +import plistlib +import signal +import shutil +import socket +import subprocess +import tempfile +import time +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + + +APPLE_NS = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"} +MAIN_IDS = { + "com.cmuxterm.app.nightly", + "com.cmuxterm.app.nightly.cmux-next", +} +HELPER_ID = "com.cmuxterm.cua" +HOST_REFUSAL = "host_unverified" + + +class Failure(RuntimeError): + pass + + +def log(message: str) -> None: + print(message, flush=True) + + +def run(args: list[str], *, timeout: float = 60, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run(args, text=True, capture_output=True, timeout=timeout) + if check and result.returncode != 0: + raise Failure(f"{' '.join(args)} failed ({result.returncode}): {result.stderr.strip()}") + return result + + +def feed_url(url: str) -> tuple[str, int, str]: + request = urllib.request.Request(url, headers={"User-Agent": "cmux-cua-signed-host/1"}) + try: + body = urllib.request.urlopen(request, timeout=30).read() + root = ET.fromstring(body) + except Exception as error: + raise Failure(f"could not read appcast {url}: {error}") from error + item = root.find("./channel/item") + enclosure = item.find("enclosure") if item is not None else None + if enclosure is None: + raise Failure(f"appcast {url} has no enclosure") + artifact = enclosure.attrib.get("url", "") + length = enclosure.attrib.get("length", "") + version = item.findtext("sparkle:version", default="", namespaces=APPLE_NS) if item is not None else "" + if not artifact or not length.isdigit(): + raise Failure(f"appcast {url} has an invalid enclosure") + return artifact, int(length), version + + +def download(url: str, length: int, destination: Path) -> None: + log(f"download {url}") + request = urllib.request.Request(url, headers={"User-Agent": "cmux-cua-signed-host/1"}) + try: + with urllib.request.urlopen(request, timeout=180) as source, destination.open("wb") as target: + shutil.copyfileobj(source, target, 1 << 20) + except Exception as error: + raise Failure(f"could not download {url}: {error}") from error + actual = destination.stat().st_size + if actual != length: + raise Failure(f"{url}: expected {length} bytes, got {actual}") + + +def plist(bundle: Path) -> dict[str, object]: + try: + with (bundle / "Contents/Info.plist").open("rb") as source: + return plistlib.load(source) + except Exception as error: + raise Failure(f"could not read {bundle}/Contents/Info.plist: {error}") from error + + +def bundle_id(bundle: Path) -> str: + identifier = plist(bundle).get("CFBundleIdentifier") + if not isinstance(identifier, str) or not identifier: + raise Failure(f"{bundle} has no CFBundleIdentifier") + return identifier + + +def executable(bundle: Path) -> Path: + name = plist(bundle).get("CFBundleExecutable") + if not isinstance(name, str): + raise Failure(f"{bundle} has no CFBundleExecutable") + path = bundle / "Contents/MacOS" / name + if not path.is_file() or not os.access(path, os.X_OK): + raise Failure(f"{bundle}: executable is missing ({path})") + return path + + +def verify_signature(bundle: Path, expected: str) -> None: + identifier = bundle_id(bundle) + if identifier != expected: + raise Failure(f"{bundle}: expected bundle id {expected}, got {identifier}") + run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(bundle)], timeout=120) + details = run(["/usr/bin/codesign", "-d", "--verbose=4", str(bundle)], check=False) + if "TeamIdentifier=7WLXT3NR37" not in details.stderr: + raise Failure(f"{bundle}: signed by an unexpected team: {details.stderr}") + + +def nested_helper(app: Path) -> Path: + for candidate in sorted(app.rglob("*.app")): + if candidate == app: + continue + if bundle_id(candidate) == HELPER_ID: + return candidate + raise Failure(f"{app}: no signed {HELPER_ID} helper bundle") + + +def fnv_scope(identifier: str) -> str: + value = 0xCBF29CE484222325 + for byte in identifier.encode(): + value = ((value ^ byte) * 0x00000100000001B3) & 0xFFFFFFFFFFFFFFFF + return f"{value:016x}" + + +def user_temp() -> Path: + return Path(run(["/usr/bin/getconf", "DARWIN_USER_TEMP_DIR"]).stdout.strip()) + + +def host_socket(identifier: str, driver: str) -> Path: + root = user_temp() + scope = fnv_scope(identifier) + if driver == "upstream": + return root / "cmux-cua-v2" / scope / "c.sock" + return root / "cmux-cua" / scope / "cua.sock" + + +def process_has_path(pid: int, path: Path) -> bool: + result = run(["/usr/sbin/lsof", "-nP", "-a", "-p", str(pid), "-U"], timeout=15, check=False) + return str(path) in result.stdout + + +def wait_for_host(process: subprocess.Popen[bytes], path: Path, timeout: float = 45) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise Failure(f"cmux exited with status {process.returncode} before attaching to {path}") + if path.exists() and process_has_path(process.pid, path): + return + time.sleep(0.25) + raise Failure(f"signed app pid {process.pid} never kept an accepted host connection to {path}") + + +def stop(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=15) + + +def connect_refusal(path: Path) -> str: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(10) + try: + client.connect(str(path)) + return client.makefile("rb").readline().decode("utf-8", "replace") + finally: + client.close() + + +def ad_hoc_peer(root: Path) -> Path: + """Make a tagged DEV-shaped, ad-hoc app whose process can be a socket peer.""" + app = root / "cmux DEV signed-host-probe.app" + binary = app / "Contents/MacOS/cmux" + binary.parent.mkdir(parents=True) + shutil.copyfile("/usr/bin/python3", binary) + binary.chmod(0o755) + (app / "Contents/Info.plist").write_bytes(plistlib.dumps({ + "CFBundleIdentifier": "com.cmuxterm.app.debug.signed-host-probe", + "CFBundleExecutable": "cmux", + "CFBundlePackageType": "APPL", + "CFBundleVersion": "1", + })) + run(["/usr/bin/codesign", "--force", "--sign", "-", str(app)], timeout=60) + return binary + + +def ad_hoc_refusal(path: Path, binary: Path) -> None: + script = ( + "import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); " + "print(s.makefile('rb').readline().decode(), end=''); s.close()" + ) + result = subprocess.run([str(binary), "-c", script, str(path)], capture_output=True, text=True, timeout=20) + if HOST_REFUSAL not in result.stdout: + raise Failure(f"ad hoc tagged DEV peer was not refused by {path}: {result.stdout!r}") + + +def exec_chain_refusal(path: Path, binary: Path) -> None: + """Connect, exec, then speak: the connection's audit token must not survive exec.""" + script = ( + "import os,socket,sys,time; s=socket.socket(socket.AF_UNIX); " + "s.set_inheritable(True); s.connect(sys.argv[1]); time.sleep(.2); " + "os.execv(sys.executable,[sys.executable,'-c'," + "\"import os,socket,sys; s=socket.socket(fileno=3); " + "print(s.makefile('rb').readline().decode(), end='')\",sys.argv[1]])" + ) + result = subprocess.run([str(binary), "-c", script, str(path)], capture_output=True, text=True, timeout=20) + if HOST_REFUSAL not in result.stdout: + raise Failure(f"exec-chain peer was not refused by {path}: {result.stdout!r}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--legacy-feed", default="https://files-next.cmux.com/nightly-next/appcast-arm64.xml") + parser.add_argument("--next-feed", default="https://files-next.cmux.com/nightly-next/next/appcast-arm64.xml") + parser.add_argument("--out", type=Path, default=None) + args = parser.parse_args() + root = Path(tempfile.mkdtemp(prefix="cmux-cua-signed-host-")) + if args.out: + args.out.mkdir(parents=True, exist_ok=True) + try: + artifacts = [ + # NEXT is the supported nightly and the release-facing contract. + ("next", "com.cmuxterm.app.nightly.cmux-next", args.next_feed, True), + # Keep the old identity as a diagnostic while it is available; a + # classic feed outage must not red the supported nightly check. + ("classic", "com.cmuxterm.app.nightly", args.legacy_feed, False), + ] + apps: list[tuple[str, Path, str]] = [] + for name, expected, feed, required in artifacts: + try: + url, length, version = feed_url(feed) + dmg = root / f"{name}.dmg" + download(url, length, dmg) + mount = root / f"mount-{name}" + mount.mkdir() + run(["/usr/bin/hdiutil", "attach", str(dmg), "-nobrowse", "-readonly", "-mountpoint", str(mount)], timeout=300) + try: + candidates = sorted(mount.glob("*.app")) + if len(candidates) != 1: + raise Failure(f"{dmg}: expected one app, found {candidates}") + app = root / f"{name}.app" + run(["/usr/bin/ditto", str(candidates[0]), str(app)], timeout=300) + finally: + run(["/usr/bin/hdiutil", "detach", str(mount)], timeout=120, check=False) + verify_signature(app, expected) + helper = nested_helper(app) + verify_signature(helper, HELPER_ID) + log(f"{name} {version}: signed app and helper identities are correct") + apps.append((name, app, expected)) + except Failure as error: + if required: + raise + log(f"INFO: classic NIGHTLY diagnostic skipped: {error}") + + adhoc = ad_hoc_peer(root) + for name, app, identifier in apps: + try: + for driver in ("upstream", "legacy"): + run_root = root / f"run-{name}-{driver}" + home = run_root / "home" + (home / "Library/Application Support").mkdir(parents=True) + run_root.mkdir(exist_ok=True) + config = run_root / "cmux-next.json" + config.write_text('{"computerUse":{"enabled":true,"driver":"' + driver + '"}}\n') + env = { + "HOME": str(home), + "CFFIXED_USER_HOME": str(home), + "TMPDIR": str(run_root / "tmp") + "/", + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "USER": os.environ.get("USER", ""), + "LOGNAME": os.environ.get("LOGNAME", os.environ.get("USER", "")), + "LANG": "en_US.UTF-8", + "CMUX_NEXT_CONFIG_FILE": str(config), + "CMUX_NEXT_NO_ACTIVATE": "1", + "CMUX_NEXT_SOCKET_MODE": "allowAll", + } + (run_root / "tmp").mkdir() + output = (run_root / "app.log").open("wb") + process = subprocess.Popen([str(executable(app)), "-ApplePersistenceIgnoreState", "YES"], + env=env, cwd=str(home), stdin=subprocess.DEVNULL, + stdout=output, stderr=subprocess.STDOUT, start_new_session=True) + try: + path = host_socket(identifier, driver) + wait_for_host(process, path) + log(f"ok: {name} {driver} accepted the signed app host on {path}") + refusal = connect_refusal(path) + if HOST_REFUSAL not in refusal: + raise Failure(f"unsigned peer was not refused by {path}: {refusal!r}") + ad_hoc_refusal(path, adhoc) + exec_chain_refusal(path, adhoc) + log(f"ok: {name} {driver} refused unsigned, ad hoc, and exec-chain peers") + finally: + stop(process) + output.close() + except Failure as error: + if name == "next": + raise + log(f"INFO: classic NIGHTLY diagnostic failed: {error}") + if not any(name == "next" for name, _, _ in apps): + raise Failure("NEXT NIGHTLY artifact was not checked") + log("PASS: signed NEXT NIGHTLY host admission and refusal checks (classic is informational)") + return 0 + except Failure as error: + log(f"FAIL: {error}") + return 1 + finally: + shutil.rmtree(root, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) From 564325c146acde702cb2d8b5d6d498f7b53bc232 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 02:13:14 +0000 Subject: [PATCH 2/5] ci: include Xcode requirements in signed-host checkout Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-72710c32bbeb8f787f6a --- .github/workflows/nightly-next-cua-signed-host.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nightly-next-cua-signed-host.yml b/.github/workflows/nightly-next-cua-signed-host.yml index 6d6fe65b0c16..c6dec19497f8 100644 --- a/.github/workflows/nightly-next-cua-signed-host.yml +++ b/.github/workflows/nightly-next-cua-signed-host.yml @@ -108,7 +108,9 @@ jobs: ref: ${{ env.TOOLING_REF }} persist-credentials: false sparse-checkout: | + .xcode-version scripts/ci/cua_signed_host_check.py + scripts/ci/xcode-pins.txt scripts/select-ci-xcode.sh sparse-checkout-cone-mode: false From d9b8abb7b64502b0719f990c35cec3894758be6d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 09:45:36 +0000 Subject: [PATCH 3/5] Check the driver NIGHTLY runs and speak the legacy helper's protocol The signed-host check waited for the helper v2 socket, which NIGHTLY never creates (it has no helper v2, so upstream runs legacy), and its legacy probes only connected and read, which the legacy helper never answers. It now checks the effective driver, sends legacy peers a machine_consent_host request and requires a refusal, and requires the app's host connection to stay open for 3 seconds. Declare the signed-host job in warm-sources.json (it compiles nothing). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63f0bcf9b78ecec06a14 --- .github/warm-sources.json | 1 + scripts/ci/cua_signed_host_check.py | 86 ++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/.github/warm-sources.json b/.github/warm-sources.json index 6ea28430c01c..4476b4f71865 100644 --- a/.github/warm-sources.json +++ b/.github/warm-sources.json @@ -158,6 +158,7 @@ "main-compile-probe.yml:compile": {"class": "mac-build", "warm": ["deriveddata-reuse", "ssd-cas", "actions-cache"]}, "mux.yml:test": {"class": "linux-js", "warm": [], "exempt": "cold: bun or npm install and TypeScript checks or tests with no dependency cache; no warm source wired yet"}, "nightly-next-canary.yml:canary": {"class": "mac-sign", "warm": [], "exempt": "verification only: checks the feed's signed DMG with Gatekeeper and the notary tickets, compiles nothing"}, + "nightly-next-cua-signed-host.yml:signed-host": {"class": "mac-run-product", "warm": [], "exempt": "runs the computer use helpers of the published signed NIGHTLY; compiles nothing"}, "nightly-next-launch-smoke.yml:smoke": {"class": "nightly-release-compile", "warm": [], "exempt": "launches the feed's published DMG from a fresh home; compiles nothing"}, "nightly-next-train.yml:build": {"class": "nightly-release-compile", "warm": ["deriveddata-reuse"]}, "nightly.yml:build-nightly-app": {"class": "nightly-release-compile", "warm": ["actions-cache", "product-reuse"]}, diff --git a/scripts/ci/cua_signed_host_check.py b/scripts/ci/cua_signed_host_check.py index 8e205b751366..42c3f104bbfd 100644 --- a/scripts/ci/cua_signed_host_check.py +++ b/scripts/ci/cua_signed_host_check.py @@ -7,9 +7,13 @@ connection remains open. A host connection that is refused closes before it can remain in the app's descriptor table. -The helper's own signed bundle is checked as a negative identity as well. An -ad-hoc tagged app and an ordinary unsigned process are connected to each host -socket and must receive the stable ``host_unverified`` refusal. The detailed +Each driver is checked as the build runs it: NIGHTLY carries no helper v2, so +"upstream" runs the legacy helper there. An ad-hoc tagged app and an ordinary +unsigned process are connected to each host socket and must be refused. The +helper v2 refuses them before reading, with the stable ``host_unverified`` +refusal. The legacy helper answers their ``machine_consent_host`` request +without the host token, which it checks before the code signature, so its +refusal is a host-only error that never says attached. The detailed ``not Apple-signed (ad hoc or unsigned)`` reason is emitted by the helper's real code check (and is covered by the HostVerifier unit test); the wire reply deliberately exposes only the stable refusal reason. @@ -22,6 +26,7 @@ from __future__ import annotations import argparse +import json import os import plistlib import signal @@ -42,6 +47,11 @@ } HELPER_ID = "com.cmuxterm.cua" HOST_REFUSAL = "host_unverified" +# DEV builds embed the helper v2 here (ComputerUseHelperV2.embeddedHelperApp); +# without it "upstream" runs the legacy helper (ComputerUseHelperDaemon.effectiveDriver). +UPSTREAM_HELPER = "Contents/Library/cmux Computer Use (dev).app/Contents/MacOS/cmux-cua-helper" +# The legacy helper (cmux-cua serve) answers a request; this is the app's attach request. +LEGACY_ATTACH = b'{"request":{"args":{},"method":"machine_consent_host"}}\n' class Failure(RuntimeError): @@ -154,18 +164,29 @@ def host_socket(identifier: str, driver: str) -> Path: return root / "cmux-cua" / scope / "cua.sock" +def effective_driver(app: Path, driver: str) -> str: + """The helper the app runs for `driver`: NIGHTLY has no helper v2, so upstream is legacy.""" + return "upstream" if driver == "upstream" and (app / UPSTREAM_HELPER).is_file() else "legacy" + + def process_has_path(pid: int, path: Path) -> bool: result = run(["/usr/sbin/lsof", "-nP", "-a", "-p", str(pid), "-U"], timeout=15, check=False) return str(path) in result.stdout -def wait_for_host(process: subprocess.Popen[bytes], path: Path, timeout: float = 45) -> None: +def wait_for_host(process: subprocess.Popen[bytes], path: Path, timeout: float = 45, hold: float = 3) -> None: + """The app's host connection must stay open for `hold` seconds: a refused one closes at once.""" deadline = time.monotonic() + timeout + since: float | None = None while time.monotonic() < deadline: if process.poll() is not None: raise Failure(f"cmux exited with status {process.returncode} before attaching to {path}") if path.exists() and process_has_path(process.pid, path): - return + since = since if since is not None else time.monotonic() + if time.monotonic() - since >= hold: + return + else: + since = None time.sleep(0.25) raise Failure(f"signed app pid {process.pid} never kept an accepted host connection to {path}") @@ -181,16 +202,34 @@ def stop(process: subprocess.Popen[bytes]) -> None: process.wait(timeout=15) -def connect_refusal(path: Path) -> str: +def connect_refusal(path: Path, driver: str) -> str: + """Helper v2 refuses an unverified peer before reading; legacy answers the attach request.""" client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) client.settimeout(10) try: client.connect(str(path)) + if driver == "legacy": + client.sendall(LEGACY_ATTACH) return client.makefile("rb").readline().decode("utf-8", "replace") finally: client.close() +def refused(reply: str, driver: str) -> bool: + """Helper v2 names host_unverified. Legacy checks the host token before the code + signature, so a peer without it gets a refusal that never says attached.""" + if driver == "upstream": + return HOST_REFUSAL in reply + try: + line = json.loads(reply) + except ValueError: + return False + if not isinstance(line, dict): + return False + attached = isinstance(line.get("result"), dict) and line["result"].get("attached") is True + return line.get("ok") is False and not attached + + def ad_hoc_peer(root: Path) -> Path: """Make a tagged DEV-shaped, ad-hoc app whose process can be a socket peer.""" app = root / "cmux DEV signed-host-probe.app" @@ -208,27 +247,33 @@ def ad_hoc_peer(root: Path) -> Path: return binary -def ad_hoc_refusal(path: Path, binary: Path) -> None: +def ad_hoc_refusal(path: Path, binary: Path, driver: str) -> None: script = ( "import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); " + "sys.argv[2] and s.sendall(sys.argv[2].encode()); " "print(s.makefile('rb').readline().decode(), end=''); s.close()" ) - result = subprocess.run([str(binary), "-c", script, str(path)], capture_output=True, text=True, timeout=20) - if HOST_REFUSAL not in result.stdout: + request = LEGACY_ATTACH.decode() if driver == "legacy" else "" + result = subprocess.run([str(binary), "-c", script, str(path), request], + capture_output=True, text=True, timeout=20) + if not refused(result.stdout, driver): raise Failure(f"ad hoc tagged DEV peer was not refused by {path}: {result.stdout!r}") -def exec_chain_refusal(path: Path, binary: Path) -> None: +def exec_chain_refusal(path: Path, binary: Path, driver: str) -> None: """Connect, exec, then speak: the connection's audit token must not survive exec.""" script = ( "import os,socket,sys,time; s=socket.socket(socket.AF_UNIX); " "s.set_inheritable(True); s.connect(sys.argv[1]); time.sleep(.2); " "os.execv(sys.executable,[sys.executable,'-c'," - "\"import os,socket,sys; s=socket.socket(fileno=3); " - "print(s.makefile('rb').readline().decode(), end='')\",sys.argv[1]])" + "\"import os,socket,sys; s=socket.socket(fileno=int(sys.argv[2])); " + "sys.argv[1] and s.sendall(sys.argv[1].encode()); " + "print(s.makefile('rb').readline().decode(), end='')\",sys.argv[2],str(s.fileno())])" ) - result = subprocess.run([str(binary), "-c", script, str(path)], capture_output=True, text=True, timeout=20) - if HOST_REFUSAL not in result.stdout: + request = LEGACY_ATTACH.decode() if driver == "legacy" else "" + result = subprocess.run([str(binary), "-c", script, str(path), request], + capture_output=True, text=True, timeout=20) + if not refused(result.stdout, driver): raise Failure(f"exec-chain peer was not refused by {path}: {result.stdout!r}") @@ -280,6 +325,9 @@ def main() -> int: for name, app, identifier in apps: try: for driver in ("upstream", "legacy"): + runs = effective_driver(app, driver) + if runs != driver: + log(f"info: {name} has no helper v2; driver {driver} runs the {runs} helper") run_root = root / f"run-{name}-{driver}" home = run_root / "home" (home / "Library/Application Support").mkdir(parents=True) @@ -304,14 +352,14 @@ def main() -> int: env=env, cwd=str(home), stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) try: - path = host_socket(identifier, driver) + path = host_socket(identifier, runs) wait_for_host(process, path) log(f"ok: {name} {driver} accepted the signed app host on {path}") - refusal = connect_refusal(path) - if HOST_REFUSAL not in refusal: + refusal = connect_refusal(path, runs) + if not refused(refusal, runs): raise Failure(f"unsigned peer was not refused by {path}: {refusal!r}") - ad_hoc_refusal(path, adhoc) - exec_chain_refusal(path, adhoc) + ad_hoc_refusal(path, adhoc, runs) + exec_chain_refusal(path, adhoc, runs) log(f"ok: {name} {driver} refused unsigned, ad hoc, and exec-chain peers") finally: stop(process) From cbbbe02721ef3fa0748d546cdbefdba85134255f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 09:49:25 +0000 Subject: [PATCH 4/5] Use a compiled socket probe for the ad hoc peer /usr/bin/python3 on macOS is the xcrun shim, which picks its tool by its own name: a copy named cmux ran the cmux CLI, printed nothing, and failed the ad hoc and exec-chain probes on every healthy NIGHTLY. Build a small C client with xcrun clang instead and ad hoc sign it. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63f0bcf9b78ecec06a14 --- scripts/ci/cua_signed_host_check.py | 95 ++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/scripts/ci/cua_signed_host_check.py b/scripts/ci/cua_signed_host_check.py index 42c3f104bbfd..4ce680ad7b5f 100644 --- a/scripts/ci/cua_signed_host_check.py +++ b/scripts/ci/cua_signed_host_check.py @@ -12,8 +12,9 @@ unsigned process are connected to each host socket and must be refused. The helper v2 refuses them before reading, with the stable ``host_unverified`` refusal. The legacy helper answers their ``machine_consent_host`` request -without the host token, which it checks before the code signature, so its -refusal is a host-only error that never says attached. The detailed +with the agent-token error, which it checks before the host token and the +code signature, so on legacy these probes prove token gating and that the +refusal never says attached. The detailed ``not Apple-signed (ad hoc or unsigned)`` reason is emitted by the helper's real code check (and is covered by the HostVerifier unit test); the wire reply deliberately exposes only the stable refusal reason. @@ -216,8 +217,9 @@ def connect_refusal(path: Path, driver: str) -> str: def refused(reply: str, driver: str) -> bool: - """Helper v2 names host_unverified. Legacy checks the host token before the code - signature, so a peer without it gets a refusal that never says attached.""" + """Helper v2 names host_unverified. Legacy checks the agent token before the host + token and the code signature, so a peer without them gets a refusal that never + says attached.""" if driver == "upstream": return HOST_REFUSAL in reply try: @@ -230,13 +232,59 @@ def refused(reply: str, driver: str) -> bool: return line.get("ok") is False and not attached +# A socket peer for the ad hoc app. /usr/bin/python3 is the xcrun shim, which +# picks its tool by its own name, so a copy named cmux would run the cmux CLI. +# probe connect PATH REQUEST connect, send REQUEST (if any), print one line +# probe exec PATH REQUEST connect, then exec itself to speak on the socket +PROBE_SOURCE = r""" +#include +#include +#include +#include +#include +#include + +static int speak(int fd, const char *request) { + char line[4096]; + size_t used = 0; + if (*request && write(fd, request, strlen(request)) < 0) return 1; + while (used < sizeof line - 1) { + ssize_t got = read(fd, line + used, 1); + if (got <= 0 || line[used] == '\n') break; + used += 1; + } + line[used] = 0; + printf("%s\n", line); + return 0; +} + +int main(int argc, char **argv) { + if (argc != 4) return 2; + if (strcmp(argv[1], "fd") == 0) return speak(atoi(argv[2]), argv[3]); + struct sockaddr_un address = {0}; + address.sun_family = AF_UNIX; + strncpy(address.sun_path, argv[2], sizeof address.sun_path - 1); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0 || connect(fd, (struct sockaddr *)&address, sizeof address) != 0) return 1; + if (strcmp(argv[1], "connect") == 0) return speak(fd, argv[3]); + usleep(200000); + char number[16]; + snprintf(number, sizeof number, "%d", fd); + char *next[] = {argv[0], "fd", number, argv[3], NULL}; + execv(argv[0], next); + return 1; +} +""" + + def ad_hoc_peer(root: Path) -> Path: """Make a tagged DEV-shaped, ad-hoc app whose process can be a socket peer.""" app = root / "cmux DEV signed-host-probe.app" binary = app / "Contents/MacOS/cmux" binary.parent.mkdir(parents=True) - shutil.copyfile("/usr/bin/python3", binary) - binary.chmod(0o755) + source = root / "probe.c" + source.write_text(PROBE_SOURCE) + run(["/usr/bin/xcrun", "clang", "-O1", "-o", str(binary), str(source)], timeout=120) (app / "Contents/Info.plist").write_bytes(plistlib.dumps({ "CFBundleIdentifier": "com.cmuxterm.app.debug.signed-host-probe", "CFBundleExecutable": "cmux", @@ -247,34 +295,23 @@ def ad_hoc_peer(root: Path) -> Path: return binary -def ad_hoc_refusal(path: Path, binary: Path, driver: str) -> None: - script = ( - "import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); " - "sys.argv[2] and s.sendall(sys.argv[2].encode()); " - "print(s.makefile('rb').readline().decode(), end=''); s.close()" - ) +def probe(binary: Path, mode: str, path: Path, driver: str) -> str: request = LEGACY_ATTACH.decode() if driver == "legacy" else "" - result = subprocess.run([str(binary), "-c", script, str(path), request], - capture_output=True, text=True, timeout=20) - if not refused(result.stdout, driver): - raise Failure(f"ad hoc tagged DEV peer was not refused by {path}: {result.stdout!r}") + result = subprocess.run([str(binary), mode, str(path), request], capture_output=True, text=True, timeout=20) + return result.stdout + + +def ad_hoc_refusal(path: Path, binary: Path, driver: str) -> None: + reply = probe(binary, "connect", path, driver) + if not refused(reply, driver): + raise Failure(f"ad hoc tagged DEV peer was not refused by {path}: {reply!r}") def exec_chain_refusal(path: Path, binary: Path, driver: str) -> None: """Connect, exec, then speak: the connection's audit token must not survive exec.""" - script = ( - "import os,socket,sys,time; s=socket.socket(socket.AF_UNIX); " - "s.set_inheritable(True); s.connect(sys.argv[1]); time.sleep(.2); " - "os.execv(sys.executable,[sys.executable,'-c'," - "\"import os,socket,sys; s=socket.socket(fileno=int(sys.argv[2])); " - "sys.argv[1] and s.sendall(sys.argv[1].encode()); " - "print(s.makefile('rb').readline().decode(), end='')\",sys.argv[2],str(s.fileno())])" - ) - request = LEGACY_ATTACH.decode() if driver == "legacy" else "" - result = subprocess.run([str(binary), "-c", script, str(path), request], - capture_output=True, text=True, timeout=20) - if not refused(result.stdout, driver): - raise Failure(f"exec-chain peer was not refused by {path}: {result.stdout!r}") + reply = probe(binary, "exec", path, driver) + if not refused(reply, driver): + raise Failure(f"exec-chain peer was not refused by {path}: {reply!r}") def main() -> int: From 327706963d46bc0ead69289bbe9da9ab335d4ad5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 10:02:24 +0000 Subject: [PATCH 5/5] Route the signed-host check through the Linux pool and classify its Mac job pick read LINUX_RUNNER with a fixed Blacksmith pair, which the Linux size guard refuses; read fromJSON(vars.RUNNER_POOLS).linux after the fork branches. signed-host launches the signed app on a Mac: class apple-gui in runners.json, like the launch smoke. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63f0bcf9b78ecec06a14 --- .github/runners.json | 1 + .github/workflows/nightly-next-cua-signed-host.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/runners.json b/.github/runners.json index c06d21e06ea5..9ece021d666b 100644 --- a/.github/runners.json +++ b/.github/runners.json @@ -319,6 +319,7 @@ "class": "macos-runtime-tests", "placement_followup": "runs-on reads vars.CI_SIDE_LANE_RUNNER directly on attempt 1 (#26688); take the label from a placement job like pick-train-runner" }, + "nightly-next-cua-signed-host.yml#signed-host": "apple-gui", "nightly-next-launch-smoke.yml#smoke": "apple-gui", "nightly-next-train.yml#build": "apple-app-build", "nightly.yml#build-nightly-app": "apple-app-build", diff --git a/.github/workflows/nightly-next-cua-signed-host.yml b/.github/workflows/nightly-next-cua-signed-host.yml index c6dec19497f8..ce5a97bfa745 100644 --- a/.github/workflows/nightly-next-cua-signed-host.yml +++ b/.github/workflows/nightly-next-cua-signed-host.yml @@ -25,7 +25,7 @@ env: jobs: pick: if: github.repository == 'manaflow-ai/cmux' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-16vcpu-ubuntu-2404' || !startsWith(vars.LINUX_RUNNER, 'blacksmith-') && vars.LINUX_RUNNER || vars.CI_CLOUD_OVERFLOW_SAVED && 'blacksmith-32vcpu-ubuntu-2404' || 'blacksmith-16vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-16vcpu-ubuntu-2404' || fromJSON(vars.RUNNER_POOLS).linux }} timeout-minutes: 8 environment: ${{ github.event_name != 'pull_request' && 'ci-routing-agent' || '' }} permissions: {}