Package moshi-hook and run its daemon from home-manager on macOS and Linux - #2773
Open
cameronraysmith wants to merge 4 commits into
Open
Package moshi-hook and run its daemon from home-manager on macOS and Linux#2773cameronraysmith wants to merge 4 commits into
cameronraysmith wants to merge 4 commits into
Conversation
moshi-hook is the companion daemon for the Moshi mobile app: it installs hook configuration into supported coding agents, serves the local Unix socket those hooks post events to, and holds a WebSocket to Moshi for approval round-trips. The vendor publishes no source repository. Both documented install paths, the rjyo/moshi Homebrew tap and getmoshi.app/install.sh, resolve to the same prebuilt tarballs under cdn.getmoshi.app, and the tap's formula is generated by the vendor's release script from those assets. Pinning the four tarballs directly therefore reproduces what Homebrew would install while leaving the daemon inside the nix closure. Hashes come from the vendor's per-release checksums.txt, which update.sh reads rather than re-hashing downloads, so the pinned digests stay equal to the published ones. Darwin assets carry a Developer ID signature with the hardened runtime, so stripping is suppressed there; stripping rewrites the Mach-O and macOS then refuses to exec it. Verified with `nix build .#moshi-hook`, whose versionCheckHook execs the installed binary.
One option surface, two service backends: `services.moshi-hook` describes the daemon once and the platform decides whether it becomes a launchd user agent or a `moshi-hook.service` systemd user unit, the name upstream's own documentation tells operators to restart. Both run the same launcher, so the pairing gate, state layout, and failure behaviour are identical and only the supervisor differs. home-manager is the layer because the daemon is per-user: it owns a socket that this user's agent processes post hook events to and a WebSocket carrying this user's pairing. Nothing about it belongs to a system-wide service manager. Upstream defaults the macOS secret store to Keychain, which is unreachable from an SSH session against a locked login keychain and therefore unusable for a daemon meant to be driven from a phone. Setting MOSHI_STATE_DIR switches the store to <state>/secrets.json on macOS as well as Linux, so one launcher serves both platforms. That environment is bound to the package rather than to the service because the service is not the only caller: the installed hook entries are executed by agent processes, `moshi-hook status` is run by hand, and the iOS app probes over SSH. The pairing token is read only when the daemon reports itself unpaired, since `pair` rotates the host secret, and is passed through MOSHI_PAIRING_TOKEN so it never reaches the process listing. Agent-hook reconciliation orders the two writers rather than merging them. Our generators reinstall ~/.claude/settings.json and ~/.codex/config.toml wholesale on every activation, so an activation entry sequenced after them re-runs `moshi-hook install`. Emitting moshi's entries from our own generators was rejected: they embed moshi's binary path and a per-agent event schema upstream versions independently, and `install` also writes TypeScript extension modules and a hermes plugin directory that no settings generator models. Checked against moshi-hook 0.2.86 rather than assumed: `install` appended its hooks beside nix-declared PreToolUse and SessionStart entries without touching them, a second run left the file byte-identical, and `uninstall` restored the file to its pre-install content.
The token registers a host with Moshi, so the daemon is handed the decrypted path rather than the value, at mode 0400. Seeded blank: sops-nix validates every declared key when the generation is built, so the key has to exist before the token does. A blank value is the not-yet-paired state, and the daemon starts and serves its local socket regardless. Replace it with the token from the app's Settings -> Hooks screen via `just edit-secret secrets/home-manager/users/crs58/secrets.yaml`, then switch; the launcher pairs on the next start.
|
Tick the box to add this pull request to the merge queue (same as
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the ad-hoc Homebrew install of the Moshi companion daemon with a
declarative module that works the same on every host where agent sessions run.
What moshi-hook is
It installs hook configuration into supported coding agents, serves the local
Unix socket those hooks post events to, exposes a localhost gateway the iOS app
forwards to for the diff and browser-preview views, and holds a WebSocket to
Moshi for approval round-trips and status.
Packaging
pkgs/by-name/moshi-hookpins the vendor's four release tarballs. There is nosource build to do: the vendor publishes no source repository, and both
documented install paths — the
rjyo/moshiHomebrew tap andgetmoshi.app/install.sh— resolve to exactly these assets undercdn.getmoshi.app. The tap's formula is generated by the vendor's own releasescript from the same URLs and digests, so pinning them directly reproduces what
Homebrew would have installed while keeping the daemon inside the nix closure
and off the Homebrew upgrade path.
update.shreads the vendor's per-releasechecksums.txtrather than re-hashing downloads, so the pinned digests stayequal to the published ones.
Two platform details are load-bearing. The Linux assets are statically linked
Go, so nothing needs patchelf. The darwin assets carry a Developer ID signature
with the hardened runtime; stripping rewrites the Mach-O and macOS then refuses
to exec the binary, so stripping is suppressed there.
One option surface, two backends
services.moshi-hookis a home-manager module in theaiaggregate, becausethe daemon is per-user rather than per-machine: it owns a socket that this
user's agent processes post to and a WebSocket carrying this user's pairing.
Options cover only what the daemon needs —
enable,package,pairingTokenFile,stateDir,socketPath. macOS realizes it as a launchduser agent, Linux as a
moshi-hook.serviceuser unit named to match what thevendor's own documentation tells you to restart. Both run the same launcher
script, so the pairing gate, state layout, and failure behaviour are identical
and only the supervisor differs.
The macOS secret store defaults to Keychain, which is unreachable from an SSH
session against a locked login keychain — unusable for a daemon whose whole
point is being driven from a phone. Setting
MOSHI_STATE_DIRswitches the storeto
<state>/secrets.jsonon macOS as well as Linux; verified againstmoshi-hook 0.2.86, where
status --jsonthen reports"secretStore":"file".That single override is what lets one launcher serve both platforms.
The environment is bound to the package via a wrapper rather than to the
service, because the service is not the only caller: the hook entries are
executed by the agent processes,
moshi-hook statusis run by hand, and the iOSapp probes the host over SSH. A caller that missed the override would look for a
different socket and, on macOS, a different secret store.
Pairing token
The token arrives as a sops-nix secret at mode 0400 and is handed to the daemon
as a path. The launcher reads it only when the daemon reports itself unpaired —
pairrotates the host secret, so calling it every start would invalidate thecredential the app already holds — and passes it through
MOSHI_PAIRING_TOKENso it never appears in the host's process listing. Nothing writes it into a nix
store path and Keychain is never involved.
sops-nix validates every declared key when the generation is built, so the key
has to exist before the token does. It is seeded blank; a blank value is the
not-yet-paired state, and the daemon starts and serves its local socket
regardless.
Agent-config reconciliation
moshi-hook installwrites hook entries into agent config files this repositoryalso generates —
~/.claude/settings.jsonand~/.codex/config.tomlarereinstalled wholesale on every activation, so anything moshi added is gone by
the time the daemon next starts.
The choice here is to let moshi-hook remain the sole writer of its own entries
and to order the two writers rather than merge them: nix installs its declared
configuration first, then an activation entry sequenced after every generator
runs
moshi-hook install, which re-adds moshi's entries on top. Everyactivation converges to the same state.
The alternative — teaching our generators to emit moshi's entries — was
rejected because it pins two things we do not own. The entries embed
moshi-hook's own binary path and a per-agent event schema the vendor versions
independently, so every upstream bump would silently desynchronize them. And
installwrites more than settings keys: TypeScript extension modules forpi, omp, and opencode, and a plugin directory plus a config entry for hermes.
Reproducing that in nix would be reimplementing the vendor's installer.
Ordering composes because
installis additive and idempotent, which waschecked rather than assumed against moshi-hook 0.2.86: it appended its hooks
beside nix-declared
PreToolUseandSessionStartentries without touchingthem, a second run left the file byte-identical, and
uninstallremoved exactlyits own entries and restored the file to its pre-install content. The
reconciliation step reports failure without aborting activation, so a malformed
config file belonging to an agent this repository does not manage cannot fail an
otherwise valid generation switch.
Verification
Built green:
.#packages.aarch64-darwin.moshi-hookand.#packages.x86_64-linux.moshi-hook—
versionCheckHookexecs the installed binary on each, which is also whatproves the darwin signature survived packaging.
.#checks.aarch64-darwin.home-manager-crs58,.#checks.aarch64-darwin.home-manager-cameron,and
.#checks.x86_64-linux.home-manager-cameron..#checks.aarch64-darwin.darwin-blackphosand.#checks.x86_64-linux.nixos-cinnabar— one host configuration per platform.
That selection is the narrowest thing that would fail if this change were
wrong. The module is consumed by exactly one home-manager aggregate, and the
activationPackage checks are enumerated per user automatically, so no new check
file is needed; the two host builds add the platform-specific service
realization on top of the same home closure.
Inspected in the built outputs rather than inferred:
platform, and are absent from the other.
moshiHookReconcileafterlinkGeneration,claudeCodeMutableSettings,codexMutableSettings,ompMergeConfig, andpiCodingAgentMutableSettings, and beforesetupLaunchAgents.MOSHI_STATE_DIRandMOSHI_SOCKET_PATHas overridabledefaults, and the
moshialias resolves to the wrapper rather than past it.Ran the generated launcher directly: it reports the blank token, starts the
daemon, and the socket appears at 0600 with the gateway listening. Also
confirmed against 0.2.86 that a full
installskips absent agents and exitszero, so the activation step is quiet in the normal case.
Not run: the full
nix flake check, which is dominated by work unrelated tothis change, and
.#packages.aarch64-linux.moshi-hook, because no builder forthat platform was reachable and no machine here targets it. The platform is
still declared, since the vendor publishes the asset.
Pairing itself is not covered here — it needs the token from the app.