A macOS runner installs each version as SakRunner.app, signed on the host with the identity its configuration names #142

Merged
agra merged 4 commits from task/3797-each-sak-runner-update-drops-the-macos-p into master 2026-10-25 14:50:35 +02:00
Owner

Tracker: #3797

macOS keys a privacy grant to a bare executable by its path and its code requirement. Each sak-runner version was its own file, ~/.sak-runner/bin/<version>/sak-runner, linker-signed ad hoc, so its designated requirement was its own cdhash, and every Update all dropped what Agra had granted the runner. A bundled app's grants are keyed by its bundle identifier, so this PR installs every macOS version as one app bundle, com.swipelab.sak-runner, which the host signs.

What it delivers

  • The bundle. On macOS a version is installed as ~/.sak-runner/bin/<version>/SakRunner.app: Contents/MacOS/sak-runner is the checked build, and Contents/Info.plist carries CFBundleIdentifier com.swipelab.sak-runner, CFBundleExecutable sak-runner and the version. The version file the link, the gates and the slots use is the bundle's executable, so ~/.local/bin/sak-runner links it and the launchd agent of either slot runs …/SakRunner.app/Contents/MacOS/sak-runner --slot <a|b>.
  • Signing on the host. A new configuration line, codesign-identity=<identity>, names the code-signing identity (on m5, Apple Development: Alexandru Agrapine (DC8VVHJ9W4)). The install and each update make the bundle beside its place (~/.sak-runner/bin/.SakRunner.app), sign it with codesign --force --sign <identity> --identifier com.swipelab.sak-runner, verify it with codesign --verify --strict, and only then move it into place. A signing or verification that fails reports the update failed with the reason, and the running version stays in place: no bundle of the update, no link moved, no next. A host whose configuration names no identity installs the bundle as it comes, unsigned. A join keeps the line, as it keeps slot and service.
  • The line is read at each install. The runner reads codesign-identity from the configuration file each time it installs a bundle (update.install takes the configuration file's path), not once when its process starts. So a line added or changed while a slot runs signs the next update, with no restart. A configuration file that cannot be read at that moment fails the update ("the configuration file cannot be read for the codesign-identity…") rather than installing the bundle unsigned.
  • One install path. sak-runner --install installs the executable it runs from as its version, the way an update installs a checked download, and moves the link to it. install.sh now runs it after the sha256 and --version checks, in place of its own mv and ln. So the install signs when the configuration already names an identity. On a fresh host the join writes the configuration only after the install, so the first install there is unsigned and the next update signs.
  • Unchanged. Linux installs, the systemd unit, the two-slot hand-over and what sak serves (/runner/<platform>/sak-runner, sha256, version) are unchanged. sak still serves the bare macOS build, and the bundle is made on the host around it, for two reasons. The deploy job cross-builds the macOS runner on Linux, where no bundle can be signed. And a 0.3.6 runner's Update all can only install a bare file, so it can still take the release this PR goes into.

Rollout

  1. A 0.3.6 runner takes the release this PR goes into (0.3.7) with its own 0.3.6 code, so 0.3.7 is installed bare, as before.
  2. 0.3.6 refuses a codesign-identity= line: its configuration parser rejects any key it does not know, so a 0.3.6 process that starts while the line is there fails. Add the line on m5 only once no 0.3.6 process can start, that is once the 0.3.7 hand-over has ended (the old slot unloaded and its plist gone): codesign-identity=Apple Development: Alexandru Agrapine (DC8VVHJ9W4) in ~/.config/sak-runner/config. No restart is needed.
  3. The next update (0.3.8) is installed by 0.3.7, which reads the line then and signs the bundle. Grants given to the runner from then on hold for every later update.

The first signing from the runner's launchd agent may show macOS's key-access prompt once on m5's screen. s32 has no Apple Development identity, so it installs unsigned bundles until one is added.

Evidence

  • Before, on m5's live install (read-only, 2026-10-24 22:09Z). codesign -d -r- gives each version file its own requirement: bin/0.3.5/sak-runner is cdhash H"3b6b44dd…" and bin/0.3.6/sak-runner is cdhash H"adf69948…". The agent's ProgramArguments is ["/Users/agra/.sak-runner/bin/0.3.6/sak-runner","--slot","a"].
  • Real identity, by hand on m5 (2026-10-24 21:54:14Z). A scratch SakRunner.app was signed with codesign --force --sign "Apple Development: Alexandru Agrapine (DC8VVHJ9W4)" --identifier com.swipelab.sak-runner. No keychain prompt appeared. codesign -d -r- printed designated => identifier "com.swipelab.sak-runner" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: Alexandru Agrapine (DC8VVHJ9W4)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */, and codesign --verify --strict passed. The tests sign ad hoc (codesign-identity=-), so they raise no keychain prompt and run on s32 too.
  • tests/runner_launchd.sx (real launchd, a scratch service label) pins:
    • each slot agent's ProgramArguments, as plutil reads them, is the bundle executable;
    • the update's bundle is signed with the identifier and verifies;
    • the identity is read at the install: check_unsigned changes the line from - to an identity no keychain holds while slot a runs, with no restart, and the next update fails with "cannot be signed", leaving slot a's version in place, with no bundle of the update and no next.
  • tests/runner_install.sx pins the macOS install as the bundle, and that install.sh run again signs it with the configuration's identity. tests/step9_release.sx pins the bundle executable for install.sh on this Mac.
  • Whether the bundle is made follows uname -s on the runner's PATH: on Darwin the slots run under launchd and each version is a bundle. That is the same answer that picks the service manager, so a test that stubs uname as Linux gets bare files.
  • Fail-before for reading the line at each install (m5, 2026-10-25, git archive arms with the gate cache's sx, under taskpolicy -b).
    • The runner at 6f3b4f5 (identity read once, at process start) with this head's tests/runner_launchd.sx: RUNNER LAUNCHD FAIL (2 checks), "the card shows the update failed: its bundle cannot be signed" and "slot a keeps its version: the link, its agent and its registration, with no next and no bundle of the update". The running process still signed with -, so the update went through.
    • The same test at 0422ff9: RUNNER LAUNCHD OK, 42 ok.
  • By hand at 0422ff9 (m5, same arms). runner_handover (75 ok), runner_install (65 ok) and runner_view (30 ok) pass. These are the tests whose shared install helper now writes the configuration before it installs, so update.install reads it.
  • An unreadable configuration (scratch probe at 0422ff9). update.install with bundle and a configuration file holding a line it cannot parse answers "the configuration file cannot be read for the codesign-identity that signs sak-runner 9.9.9's bundle" and installs no bundle. With the line fixed, the same call installs a bundle that codesign -dv shows as Identifier=com.swipelab.sak-runner.

Master merged in

5daee6b merges master 05a68a2 into the branch, with no conflict. Both sides touched runner/config.sx (master's min-free key beside this PR's codesign-identity) and runner/README.md, in different hunks. diff <(git diff 8cda22f 0422ff9) <(git diff origin/master 5daee6b) differs only in index lines, hunk offsets and the FILE_KEYS context line, and git diff-tree --cc shows no line that differs from both parents. The merge was needed for the gate: 0422ff9's full failed only two runner_core timing checks that #3629 (on master since ad26ff3) made signal-driven, "the waiting run's stream says it waits" and the gate idle-timeout check. This PR touches neither runner/run.sx, runner/gate.sx nor tests/runner_core.sx.

Gates (m5)

  • full at 0422ff9: tracker gate run 440 (this run's #1), failed only on tests/runner_core.sx, the two #3629 checks above. Every other tests/*.sx file passed.
  • full at 5daee6b: tracker gate run 443 (this run's #2), passed. That covers every tests/*.sx file, release with step9_release, the deploy contract, the main Playwright config (740 passed, 1 skipped, 3.5 min) and the runner Playwright config (10 passed).
  • At 6f3b4f5, before this round: fast gate run 380 and full gate run 382 passed.

Left for the rollout

The acceptance "a grant Agra gives the runner on m5 keeps working after an update" needs the rollout above on the live runner, so this PR cannot show it. Verify it there with codesign -d -r- ~/.sak-runner/bin/<version>/SakRunner.app after each update.

Tracker: [#3797](https://sak.swipelab.com/tasks/3797) macOS keys a privacy grant to a bare executable by its path and its code requirement. Each sak-runner version was its own file, `~/.sak-runner/bin/<version>/sak-runner`, linker-signed ad hoc, so its designated requirement was its own cdhash, and every Update all dropped what Agra had granted the runner. A bundled app's grants are keyed by its bundle identifier, so this PR installs every macOS version as one app bundle, `com.swipelab.sak-runner`, which the host signs. ## What it delivers - **The bundle.** On macOS a version is installed as `~/.sak-runner/bin/<version>/SakRunner.app`: `Contents/MacOS/sak-runner` is the checked build, and `Contents/Info.plist` carries `CFBundleIdentifier` `com.swipelab.sak-runner`, `CFBundleExecutable` `sak-runner` and the version. The version file the link, the gates and the slots use is the bundle's executable, so `~/.local/bin/sak-runner` links it and the launchd agent of either slot runs `…/SakRunner.app/Contents/MacOS/sak-runner --slot <a|b>`. - **Signing on the host.** A new configuration line, `codesign-identity=<identity>`, names the code-signing identity (on m5, `Apple Development: Alexandru Agrapine (DC8VVHJ9W4)`). The install and each update make the bundle beside its place (`~/.sak-runner/bin/.SakRunner.app`), sign it with `codesign --force --sign <identity> --identifier com.swipelab.sak-runner`, verify it with `codesign --verify --strict`, and only then move it into place. A signing or verification that fails reports the update failed with the reason, and the running version stays in place: no bundle of the update, no link moved, no `next`. A host whose configuration names no identity installs the bundle as it comes, unsigned. A join keeps the line, as it keeps `slot` and `service`. - **The line is read at each install.** The runner reads `codesign-identity` from the configuration file each time it installs a bundle (`update.install` takes the configuration file's path), not once when its process starts. So a line added or changed while a slot runs signs the next update, with no restart. A configuration file that cannot be read at that moment fails the update ("the configuration file cannot be read for the codesign-identity…") rather than installing the bundle unsigned. - **One install path.** `sak-runner --install` installs the executable it runs from as its version, the way an update installs a checked download, and moves the link to it. install.sh now runs it after the sha256 and `--version` checks, in place of its own `mv` and `ln`. So the install signs when the configuration already names an identity. On a fresh host the join writes the configuration only after the install, so the first install there is unsigned and the next update signs. - **Unchanged.** Linux installs, the systemd unit, the two-slot hand-over and what sak serves (`/runner/<platform>/sak-runner`, `sha256`, `version`) are unchanged. sak still serves the bare macOS build, and the bundle is made on the host around it, for two reasons. The deploy job cross-builds the macOS runner on Linux, where no bundle can be signed. And a 0.3.6 runner's Update all can only install a bare file, so it can still take the release this PR goes into. ## Rollout 1. A 0.3.6 runner takes the release this PR goes into (0.3.7) with its own 0.3.6 code, so 0.3.7 is installed bare, as before. 2. 0.3.6 refuses a `codesign-identity=` line: its configuration parser rejects any key it does not know, so a 0.3.6 process that starts while the line is there fails. Add the line on m5 only once no 0.3.6 process can start, that is once the 0.3.7 hand-over has ended (the old slot unloaded and its plist gone): `codesign-identity=Apple Development: Alexandru Agrapine (DC8VVHJ9W4)` in `~/.config/sak-runner/config`. No restart is needed. 3. The next update (0.3.8) is installed by 0.3.7, which reads the line then and signs the bundle. Grants given to the runner from then on hold for every later update. The first signing from the runner's launchd agent may show macOS's key-access prompt once on m5's screen. s32 has no Apple Development identity, so it installs unsigned bundles until one is added. ## Evidence - **Before, on m5's live install (read-only, 2026-10-24 22:09Z).** `codesign -d -r-` gives each version file its own requirement: `bin/0.3.5/sak-runner` is `cdhash H"3b6b44dd…"` and `bin/0.3.6/sak-runner` is `cdhash H"adf69948…"`. The agent's `ProgramArguments` is `["/Users/agra/.sak-runner/bin/0.3.6/sak-runner","--slot","a"]`. - **Real identity, by hand on m5 (2026-10-24 21:54:14Z).** A scratch `SakRunner.app` was signed with `codesign --force --sign "Apple Development: Alexandru Agrapine (DC8VVHJ9W4)" --identifier com.swipelab.sak-runner`. No keychain prompt appeared. `codesign -d -r-` printed `designated => identifier "com.swipelab.sak-runner" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: Alexandru Agrapine (DC8VVHJ9W4)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */`, and `codesign --verify --strict` passed. The tests sign ad hoc (`codesign-identity=-`), so they raise no keychain prompt and run on s32 too. - **`tests/runner_launchd.sx`** (real launchd, a scratch service label) pins: - each slot agent's `ProgramArguments`, as `plutil` reads them, is the bundle executable; - the update's bundle is signed with the identifier and verifies; - the identity is read at the install: `check_unsigned` changes the line from `-` to an identity no keychain holds while slot a runs, with no restart, and the next update fails with "cannot be signed", leaving slot a's version in place, with no bundle of the update and no `next`. - **`tests/runner_install.sx`** pins the macOS install as the bundle, and that install.sh run again signs it with the configuration's identity. **`tests/step9_release.sx`** pins the bundle executable for install.sh on this Mac. - **Whether the bundle is made** follows `uname -s` on the runner's PATH: on Darwin the slots run under launchd and each version is a bundle. That is the same answer that picks the service manager, so a test that stubs `uname` as Linux gets bare files. - **Fail-before for reading the line at each install (m5, 2026-10-25, `git archive` arms with the gate cache's sx, under `taskpolicy -b`).** - The runner at 6f3b4f5 (identity read once, at process start) with this head's `tests/runner_launchd.sx`: `RUNNER LAUNCHD FAIL (2 checks)`, "the card shows the update failed: its bundle cannot be signed" and "slot a keeps its version: the link, its agent and its registration, with no next and no bundle of the update". The running process still signed with `-`, so the update went through. - The same test at 0422ff9: `RUNNER LAUNCHD OK`, 42 ok. - **By hand at 0422ff9 (m5, same arms).** `runner_handover` (75 ok), `runner_install` (65 ok) and `runner_view` (30 ok) pass. These are the tests whose shared install helper now writes the configuration before it installs, so `update.install` reads it. - **An unreadable configuration (scratch probe at 0422ff9).** `update.install` with `bundle` and a configuration file holding a line it cannot parse answers "the configuration file cannot be read for the codesign-identity that signs sak-runner 9.9.9's bundle" and installs no bundle. With the line fixed, the same call installs a bundle that `codesign -dv` shows as `Identifier=com.swipelab.sak-runner`. ## Master merged in 5daee6b merges master 05a68a2 into the branch, with no conflict. Both sides touched `runner/config.sx` (master's `min-free` key beside this PR's `codesign-identity`) and `runner/README.md`, in different hunks. `diff <(git diff 8cda22f 0422ff9) <(git diff origin/master 5daee6b)` differs only in index lines, hunk offsets and the `FILE_KEYS` context line, and `git diff-tree --cc` shows no line that differs from both parents. The merge was needed for the gate: 0422ff9's `full` failed only two `runner_core` timing checks that #3629 (on master since ad26ff3) made signal-driven, "the waiting run's stream says it waits" and the gate idle-timeout check. This PR touches neither `runner/run.sx`, `runner/gate.sx` nor `tests/runner_core.sx`. ## Gates (m5) - `full` at 0422ff9: tracker gate run 440 (this run's #1), failed only on `tests/runner_core.sx`, the two #3629 checks above. Every other `tests/*.sx` file passed. - `full` at 5daee6b: tracker gate run 443 (this run's #2), passed. That covers every `tests/*.sx` file, release with `step9_release`, the deploy contract, the main Playwright config (740 passed, 1 skipped, 3.5 min) and the runner Playwright config (10 passed). - At 6f3b4f5, before this round: `fast` gate run 380 and `full` gate run 382 passed. ## Left for the rollout The acceptance "a grant Agra gives the runner on m5 keeps working after an update" needs the rollout above on the live runner, so this PR cannot show it. Verify it there with `codesign -d -r- ~/.sak-runner/bin/<version>/SakRunner.app` after each update.
macOS keys a privacy grant to a bare executable by its path and its cdhash,
so each update dropped what was granted to the runner. Each macOS version is
now ~/.sak-runner/bin/<version>/SakRunner.app, com.swipelab.sak-runner, made
beside its place, signed with the identity the configuration's
codesign-identity line names, verified with codesign --verify --strict, and
only then moved into place; a bundle that fails either leaves the update
failed and the running version in place. sak-runner --install installs the
executable it runs from the same way.
The script checks the download's sha256 and --version as before, then has
the download install itself, in place of its own mv and ln, so the install
makes the same bundle an update makes and signs it when the configuration
already names an identity.
agra merged commit 2a58fb926d into master 2026-10-25 14:50:35 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sx/sak!142
No description provided.