Packaging, install and release¶
The four executables ship in one signed, notarized bundle, installed by a Homebrew cask from a tap and released by a tag-triggered workflow. The one thing to know: every step that registers the agent or the extension has to be run from the app's own bundle, in a fixed order - assess, strip quarantine, unregister, open - or the install looks finished while something is dead.
Build¶
- An Xcode project with four targets (app/agent, extension, CLI, askpass) plus the
SSHDriveCorelocal package, and a Rust crate for the helper. - CI:
xcodebuild archive, Developer ID sign,notarytool, staple, DMG. - The app bundle embeds a Developer ID provisioning profile, because
keychain-access-groupson the agent is a restricted entitlement (components and identifiers). - The CLI and askpass are signed with the hardened runtime and no restricted entitlements, so their being bare executables reached through a symlink is fine.
Install: the Homebrew cask¶
The cask installs SSH Drive.app and links sshdrive from inside the bundle with the binary
stanza.
Launching the app is what registers both the extension (with PlugInKit) and the login item
(through SMAppService). Both must be done from the app's own bundle, which a symlinked CLI
cannot do. On launch the app registers its login item, notices the launchd-managed instance
already holds the mach service, and exits. macOS posts a notification that a background item was
added, already enabled, with a switch to turn it off under Login Items. That notification is the
only UI the user ever sees.
postflight¶
The postflight is a legacy Ruby postflight do block of system_command calls, not
postflight_steps. Homebrew runs postflight_steps in a sandbox that denies LaunchServices and
the Mach services behind it (Homebrew/brew#23907), which is every step below except the
quarantine strip: inside it spctl --assess printed internal error in Code Signing subsystem,
lsregister -dump printed failed to scan /Applications/SSH Drive.app: -10822 from spotlight,
each lsregister -f died Trace/BPT trap: 5, and open -g failed -10810 kLSUnknownErr with
Couldn't communicate with a helper application under it (Homebrew 7, macOS 27.0, 2026-09-23,
upgrading 0.1.9 to 0.1.10). The legacy block runs outside the sandbox. Homebrew rejects it in
official taps and keeps it for third-party taps for now, so the cask's caveats say what to run
when an install ran none of this: open -a "SSH Drive", whose launch sweeps the records, forces
the registration and repairs the login item itself, then sshdrive doctor.
The steps run in this order:
- Assess:
/usr/sbin/spctl --assess --type executeon the installed app. The verdict is logged; it does not fail the install. - Strip quarantine:
/usr/bin/xattr -dr com.apple.quarantineon the bundle (below). - Sweep and rebuild the LaunchServices records: one
/bin/shstep. It readslsregister -dump, runslsregister -uon every record path underorg.shirls.sshdrivethat is not the installed bundle, and thenlsregister -f -R -trustedon the installed bundle (below). - Unregister: run the new bundle once with
SSHDRIVE_AGENT_ROLE=unregister, which callsSMAppService.unregister(), waits, and exits (upgrades). - Open:
open -g -a "SSH Drive", which registers again.
sshdrive doctor runs the last step on its own. The unregister and open come after the
assess-and-strip pair because a quarantined bundle registers no plugin however many times it is
opened, and after the sweep because a registration answered from another copy's record registers
nothing new. A developer replacing the bundle by hand has to run steps 3 to 5 too.
Step 1 spells the verbosity flag -v: macOS 27's spctl rejects --verbose=4 and prints its
usage instead of a verdict.
Quarantine¶
Homebrew leaves com.apple.quarantine on the installed bundle, and LaunchServices registers no
plugin of a quarantined bundle that no person has launched; open -g is not an assessed launch
(MQ-061, gotcha 99).
Nothing else shows it. The agent runs, because launchd starts the main executable directly, and
the login item registers. Meanwhile pluginkit -m prints nothing of ours, doctor fails
"extension registered" and "file provider domains" ("The application cannot be used right now"),
and fileproviderd logs getDomainsForProviderIdentifier((null)) failed: FP -2001 Underlying FP
-2014 (provider not found, application extension not found). pluginkit -a <appex> registers it
until the next launch wipes that again; xattr -dr com.apple.quarantine followed by open -g
registers it durably.
Stripping the attribute relocates Gatekeeper rather than bypassing it: the DMG was assessed on
the download path, the notarization ticket is stapled to the app inside it, and the postflight
repeats the assessment on the installed copy first. What is given up is the one-time "downloaded
from the Internet" dialog, which open -g shows to nobody anyway.
doctorhas aquarantinecheck, ordered before "extension registered" because it is the ordinary cause of that one failing. Its remedy is the two commands above.- The agent logs the same thing once per launch when its own bundle is quarantined.
Stale LaunchServices records¶
LaunchServices keys its records on the bundle identifier, and it holds one per path. A record for
a second copy of SSH Drive.app answers every registration of the installed one. The second copy
is the DMG: the app is opened once from /Volumes/SSH Drive/SSH Drive.app, the volume is
detached, and the record stays behind it.
While that record is there, lsd answers every registration of /Applications/SSH Drive.app -
the postflight's own lsregister -f -R -trusted included - with
SecStaticCodeCreateWithPath(<private>) failed with error -67028, skipping registration of an
incomplete bundle and Registration succeeded, but did not actually register anything new;
returning existing bundle, and pkd says No plugins found to match query. PlugInKit discovers
no appex, so the symptoms are the ones quarantine produces - pluginkit -m
silent, doctor failing "extension registered" and "file provider domains" - on a bundle
carrying no quarantine attribute at all (MQ-081, gotcha 107).
lsregister -u on the stale path drops that record, and lsregister -f -R -trusted on the
installed bundle then rebuilds its own; the appex is registered on the next launch. Unlike
pluginkit -a, that survives the launch after it. The sweep has to come first: the force on its
own is one of the registrations the stale record answers. Three things do it:
- the cask's
postflight, before its unregister and open, so an install never leaves the appex hidden; - the app launch itself. After the login item answers, the launch asks PlugInKit whether it knows the extension, and when the answer is nothing it unregisters every record path that is not its own bundle, forces its own registration once, and waits up to 5 s, polling every 0.5 s, for the appex to appear. The outcome is logged either way.
doctor'slaunch services recordscheck, ordered before "extension registered" for the same reasonquarantineis, lists every other record path and gives thelsregister -ucommand for each. It readslsregister -dumponly when PlugInKit does not know the extension, the launch's own condition. The dump is slow (3.5 s from a terminal, and nearly all of a 23 sdoctorwhen the agent read it, on 27.0, 2026-09-23), and a record that is not stopping the appex registering is not a finding. With the extension registered the check passes as "not checked".
uninstall stanza¶
Homebrew runs a cask's uninstall directives on brew upgrade and brew reinstall as well as
on brew uninstall, so nothing destructive may live there. The stanza only stops the agent:
signal: ["TERM", "org.shirls.sshdrive.agent"]
- It names the launchd label, not the bundle id: Homebrew matches the string against
launchctl listoutput, where the bundle id never appears. - It is deliberately not
launchctl:. That directive boots the label out of launchd whileSMAppServiceand the background-task database still consider the login item enabled, so an app launch that registered "only if needed" would do nothing and the mach service would stay dead until the next login. A TERM leaves the launchd registration alone. - The agent handles TERM itself, runs the same shutdown as
sshdrive agent stop, and exits 0 (gotcha 94).
KeepAlive¶
The plist sets KeepAlive to SuccessfulExit false, not plain true.
| Exit | Plain true |
SuccessfulExit false |
|---|---|---|
Clean (TERM, agent stop) |
Relaunched at once, from whatever bundle sits at the path - mid-upgrade, the old one about to be deleted; agent stop could stop nothing |
Stays down until the next mach lookup |
| Crash or death by signal | Relaunched | Relaunched at once |
The lookup that brings the agent back after an upgrade normally finds the new bundle in place. If it lands during the swap and starts the old bundle, the agent's watch on its own executable (handover) catches the replacement, it exits cleanly again, and the next lookup starts the new one.
Upgrades¶
Locations, domains, the local replica and pending uploads all survive an upgrade untouched. A pending upload can be re-offered after the replacement, so the same write may arrive twice; the conflict check makes that safe (gotcha 97).
Unregister, then register¶
The app calls SMAppService.register() unconditionally on every launch, since it is idempotent,
rather than checking status first. But registering is not repairing.
Homebrew deletes the app and installs the new one, and a login item whose bundle was deleted and
put back keeps its enabled status while launchd can no longer resolve the program: every spawn
fails with Could not find and/or execute program specified by service and
copy_bundle_path(...) error 0x6f, on a 10 s retry, for good. register() keeps returning
success throughout, because to SMAppService the item is still enabled. Only unregister()
clears it (MQ-062). Hence postflight steps 3 and 4.
The unregister role waits, then waits a 5 s grace¶
SMAppService.unregister() returns, and status says notRegistered, while launchd is still
spawning the old record. A register() inside that window leaves the job carrying the previous
bundle's launch constraint: every spawn dies EXC_CRASH (SIGKILL (Code Signature Invalid)) with a
Launch Constraint Violation on a 10 s retry, for ever, while the mach service accepts
connections and answers no command (MQ-063, gotcha 93).
- The role polls
launchctl printuntil the service is gone. - That is not sufficient on its own: the poll can answer "gone" 7 ms after
unregister()returns and a register 110 ms later is constrained anyway. So the role then waitsAgentLifecycle.unregisterGraceSeconds(5 s), an interval that worked, not a measured boundary.
The two together are what make the cask's back-to-back postflight steps safe.
The launch verifies what it registered¶
Nothing in register(), SMAppService.status or the mach lookup distinguishes a job holding a
constraint it cannot satisfy; only asking the agent a question and getting no answer does.
AgentLifecycle.registerAndVerify:
- registers;
- pings the mach service for up to 10 s;
- on silence, logs the job as stuck, runs the unregister-and-wait above, registers again and asks again;
- repairs up to three times before reporting failure.
One repair is not always enough: on macOS 26.4.1 a register made 100 ms after the grace died the same way, and the second repair started the agent (2026-09-23).
The constraint comes from the bundle's signature, so an upgrade that changes signing certificate is the one that hits this; an ordinary version bump does not.
Handover¶
Upgrades replace the bundle under a running agent and extension. The system kills the extension and relaunches it from the new bundle.
The agent watches its own executable with a vnode dispatch source. When the executable is deleted or replaced, the agent waits until:
- the bundle at its path is readable,
- its
Info.plistparses, and - its main executable is a different inode from the one the agent is running.
Then it exits cleanly, and the next mach lookup starts the new build (see
KeepAlive). The checks mean it never hands over to a half-copied bundle and never
waits forever on a brew reinstall of the same version. Pending uploads are held by the system and survive.
Removal¶
Homebrew runs the uninstall stanza, then deletes the app, then runs zap. By the time zap
runs there is no sshdrive and no provider left to call NSFileProviderManager.remove(domain),
so domain removal cannot be automated from the cask.
What zap does and cannot do:
- It deletes the group container and the launch-agent registration.
launchctl:is right here, since nothing is coming back. - It cannot delete the keychain items. They live in the data-protection keychain, which no file
removal reaches and no cask directive addresses, and the only executable that could delete them
is gone.
sshdrive remove --alldeletes them; without it they are orphaned until a later install'saddoverwrites them. - It does not clear the entry under Login Items. That is the system's own record and goes away once the system finds the bundle missing, sometimes not before the next login.
Run sshdrive remove --all before brew uninstall
The cask's caveats, the docs and sshdrive doctor all say this. A user who skips it is left
with sidebar entries for a provider that no longer exists, shown as unavailable, until a
reinstall. On its first launch the reinstalled app removes every domain whose identifier is
not in config.json, or every domain of ours when the container is gone too.
The Local Network prompt¶
The agent dials the server, and when the server's address is on the user's own network macOS asks "Allow “SSH Drive” to find devices on local networks?" in the app's name, once, on first connect (MQ-069, gotcha 57). For this app that is the ordinary case: a NAS is exactly a device on the local network.
Nothing can pre-arm it: no entitlement suppresses it, and a launchd agent has no window to put it
over. So add mentions it, sshdrive doctor names it when a location is offline, and the cask's
caveats say it once. A refusal is harmless for a server reached off the LAN and fatal for one on
it; the fix is System Settings > Privacy & Security > Local Network.
Repository and hosting¶
| What | Where | Notes |
|---|---|---|
| Source, design docs, issues, CI | github.com/alecdwm/sshdrive |
Design pages and user docs under docs/. |
| Release binaries | GitHub Releases on the same repo | The workflow attaches the notarized, stapled SSH-Drive-<version>.dmg plus a .sha256. Tags v1.2.3. |
| Website / user docs | GitHub Pages from docs/ on main |
MkDocs, built from docs/ and deployed by .github/workflows/pages.yml. Served at sshdrive.shirls.org, the custom domain set in the repository's Pages settings; an Actions deployment needs no CNAME file. |
| Homebrew tap | github.com/alecdwm/homebrew-tap |
Must be a separate repo: brew tap alecdwm/tap resolves to alecdwm/homebrew-tap by naming convention, so the cask cannot sit inside the main repo without users typing a full URL. Contains Casks/sshdrive.rb. |
| Support links baked into the app | sshdrive --help, sshdrive doctor |
Point at the Pages site and the issues tracker. |
- The cask token is
sshdrive, matching the command and the repository. Homebrew resolves a token to the cask file's basename, so the file must beCasks/sshdrive.rb. Thessh-drivetoken Homebrew would derive from the app name is enforced only in Homebrew's own tap. - The tap is named
homebrew-tap, nothomebrew-sshdrive, so it can hold future casks or formulae. If the project gains enough users the cask can be submitted tohomebrew-cask, andbrew install --cask sshdrivethen works without the tap.
User install:
brew tap alecdwm/tap
brew install --cask sshdrive
sshdrive add nas
Versioning¶
The root VERSION file holds one version for the whole product. scripts/set-version.sh writes it
into every component - the Xcode targets' marketing version, the helper crate, the cask template -
so a bump is one edit and the agent, extension, CLI and helper always report the same string. A
release is that file bumped and a v<version> tag pushed.
Release flow¶
.github/workflows/release.yml, triggered by a v* tag, is a Linux job feeding a macOS job.
- Build the helper.
- The Linux and FreeBSD targets are cross-compiled in a Linux job (the one
.github/workflows/helper.ymlruns on ordinary pushes), sincecrossneeds Docker and GitHub's macOS runners do not provide it. - Only
freebsd/x86_64needscross. The three musl targets link with rustc's ownrust-lldand its bundled self-contained objects (helper/.cargo/config.toml), so they build on a plain runner withrustup target addand no cross C toolchain. freebsd/aarch64is not built: it is a tier 3 Rust target with no prebuiltrust-std, so it cannot be built or evencargo checked without-Z build-stdon nightly (SQ-065, gotcha 90).- The macOS job builds
darwin/arm64natively withcargoand ad-hoc signs it withcodesign, because arm64 macOS refuses to run unsigned code even overssh. It collects the Linux job's artifacts and records every hash into the app's manifest.
- The Linux and FreeBSD targets are cross-compiled in a Linux job (the one
xcodebuild archive→ Developer ID sign (certificate and App Store Connect API key stored as repository secrets) →notarytool submit --wait→ staple → DMG.- Upload DMG + sha256 to the GitHub Release.
- Render
Casks/sshdrive.rbfrom a template with the new version, URL and sha256, and push it toalecdwm/homebrew-tap(a deploy key or fine-grained PAT for that one repo).brew upgradethen picks it up. - Publish the docs site (Pages deploys automatically from
main).
Signing and notarization¶
Three facts about the Apple material, each capable of costing an afternoon (measured 2026-09-05).
The profile must name the signing certificate¶
A profile carries the DeveloperCertificates it was issued for, and AMFI matches on them as well
as on the entitlements. A Developer ID profile created against a different Developer ID
Application certificate than the signing one makes every restricted entitlement unsatisfied, and
the agent is SIGKILLed at exec; open -g returns Launchd job spawn failed (MQ-064, gotcha 91).
Such a build signs, verifies, notarizes and staples, because notarization never looks at
provisioning profiles. Adding com.apple.application-identifier to match the profile does not
help and cannot be done anyway.
So scripts/release.sh compares the profile's certificate hashes against the signing identity
before signing, and drops keychain-access-groups with a loud warning rather than embed a
profile that would kill the agent.
The DMG is signed as well as stapled¶
A DMG that carries a notarization ticket but no signature of its own is refused on the download
path: spctl --assess --type open --context context:primary-signature answers rejected /
source=no usable signature however good the ticket is. Homebrew never sees this, since it reads
the app out of the image, but a person who double-clicks the download does. release.sh
codesigns the DMG with the same Developer ID identity before submitting it.
Notarization uses an API key¶
notarytool store-credentials cannot run over ssh: it writes a login-keychain item through an
interactive authorisation and fails with "User interaction is not allowed" even with the keychain
unlocked (gotcha 92). A headless release authenticates with an App Store Connect API key
(--key, --key-id, --issuer). The .p8 stays on the build machine and is never copied into
the repository, the bundle or the DMG. The --keychain-profile form is the fallback for a profile
someone created at the console.
A read-only API key is enough to notarize. It cannot create a provisioning profile
(403 FORBIDDEN_ERROR); that stays a web-UI job.