macOS quirks¶
Measured behaviour of macOS that SSH Drive depends on: File Provider, Finder, fileproviderd, launchd, LaunchServices, TCC and APFS. Each row is the record of its own measurement: the statement carries the evidence, the measured on cell the version and the date. Format and rules are in the catalog's README.
Nothing here has been measured on macOS 14 or 15
docs/design/platform.md names 14 as the minimum, so every row's
14/15 cell is empty and each one is an open question. MQ-023 (atime) and MQ-060 (TCC)
are the two whose measurements explicitly ask for a re-run on 14 or 15.
Enumeration, the working set and anchors¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-001 | A folder is enumerated once, ever. Revisiting it in Finder, closing and reopening the window, and a remote change landing while it is open produce no container-enumerator call at all — not enumerateChanges, not a second enumerateItems. Opening a Finder window on Documents/Reports produced exactly two calls, both enumerateItems, one per folder on the way down; a file created on the server while that window was open still appeared in it within ~20 s, through the working set. |
26.4 (2026-09-04) | root-set.md; gotcha 29 | A6, A7, G6, H8, H9, F5 |
| MQ-002 | The working set is only a change stream: enumerateItems on it returns nothing and the system ingests nothing from it. |
26.4 (2026-09-04) | item-index.md | A9, G6 |
| MQ-003 | The system launches a fresh extension instance for every working-set signal, and for every retry of a queued write; an indexReady lands in the same millisecond as each call. |
26.4 (2026-09-04) | item-index.md | A2, A3, F4 |
| MQ-073 | The system kills an idle extension instance, and the extension's XPC connection to the agent invalidates as part of that teardown - so an invalidation handler cannot be read as "the agent has gone". Reading it that way and calling disconnect(reason:) from the invalidation handler left the domain disconnected after its first listing: every later request failed FP -1004 with NSFileProviderErrorDomainDisconnectionStateKey=4, the system refusing rather than us. |
26.4 (2026-09-04) | extension.md | C1, C7 |
| MQ-004 | An empty change set at the anchor the system already holds tells the system it is up to date; the change is dropped until something else signals. Measured symptom: a file deleted on the server and removed from the index stayed in Finder for ten minutes, until an unrelated change signalled and the system caught up on both at once. | 26.4 (2026-09-04) | item-index.md; gotcha 78 | A1 |
| MQ-005 | fileproviderd throttles a change enumeration that keeps failing, and the throttle stops server-side changes reaching Finder entirely. Measured at two points on the same backoff: 27 consecutive errors took one domain's fetch-event stream to a 47-minute retry (sshdrive status green throughout), and the VM reproduction stood at 1 min 34 s after 7 errors. SystemModel reads the three values as MQ-005 (the 27-error threshold), MQ-005.ceiling (2820 s) and MQ-005.at7 (94 s), and fits 30 s x 1.18^n through both points. |
26.6 (2026-09-08); 26.4 (2026-09-08) | item-index.md | A2, A3 |
| MQ-006 | .syncAnchorExpired makes the system re-ask from a fresh anchor rather than giving up; a container enumerator's changes are never expired, only the working set's. |
26.4 (2026-09-04) | item-index.md | A5 |
| MQ-007 | The system does not time out an enumerateItems held the full 60 s, takes the answer, and leaves the extension process running. Measured at 60.19 s, with ls exiting 0 after 61 s. |
26.4 (2026-09-04) | offline.md; gotcha 60 | A8, F5 |
| MQ-008 | NSFileProviderDomain.supportsSyncingTrash defaults to YES ("This property defaults to YES", in the header's own words); not setting allowsTrashing on items does not stop the system giving the domain a trash. |
26.4 (2026-09-04) | names-and-attributes.md | B1, B2 |
| MQ-075 | The system creates the trash node itself at add(domain) time, as a system-owned item (cp:system nsp:system), and then asks the extension for its children. It does both even with supportsSyncingTrash = false: the node is still created and fetch-children-metadata(.trash) is still called, twice. |
26.4 (2026-09-04) | names-and-attributes.md | B1, B2 |
| MQ-009 | Answering enumerator(for: .trashContainer) with .noSuchItem makes the system delete the trash from disk, fail, re-materialize it and ask again — about once a second, for ever: fetch-children-metadata(.trash) failing itemNotFound (FP -1005), then materialize(trash) failing libfssync.MaterializationError 3 "materializationFailed". ls -la of the mount then never returns, sitting in the stat of .Trash; plain ls and ls -R never touch it and are unaffected. |
26.4 (2026-09-04) | names-and-attributes.md | B1 |
| MQ-010 | Answering NSCocoaErrorDomain / NSFeatureUnsupportedError (3328) makes the system throttle, give up after two attempts (fileproviderctl dump reports count:2 for the job) and remove .Trash from the mount altogether. Removal is not guaranteed: on a domain the system never asked the extension about, the trash node it created stayed in ls -la indefinitely, with no loop and no hang. |
26.4 (2026-09-04) | names-and-attributes.md | B1, B2, B3 |
Errors: what each one means to the system¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-011 | .noSuchItem from item(for:) makes the system consider the item deleted and delete it from disk. NSFileProviderReplicatedExtension.h says so in as many words: "the system will consider the item has been deleted and attempt to delete the item from disk". |
26.4 (documented + observed) | extension.md; gotcha 11 | C2, D12, H11 |
| MQ-012 | From fetchContents, .noSuchItem and .cannotSynchronize both leave the item in place and differ only in what the reader is told: Stale NFS file handle (ESTALE) against Operation timed out (ETIMEDOUT). Both are reversible: the item stayed listed after 60 s and after a working-set signal, and clearing the fault and re-reading returned the content. |
26.4 (2026-09-04) | change-detection.md; gotcha 62 | D5, D12, H11 |
| MQ-013 | The system believes whatever version a modifyItem reply carries: it records it, never re-fetches and never re-offers, and sets no conflict flag. A reply carrying an invented version left fileproviderctl evaluate reporting isDownloaded 1, isMostRecentVersionDownloaded 1, hasUnresolvedConflicts 0 and zero further transfers, so the replica holds local bytes under a remote version for ever. |
26.4 (2026-09-04) | writes.md; gotcha 30 | D1, D2 |
| MQ-014 | .filenameCollision from createItem is retried for ever with no alert, on a doubling backoff measured at 0 s, 0.04 s, 5 s, 15 s …; the caller is told it succeeded, the item stays in the mount, and enumeratorForPendingItems stays empty. fileproviderd logs each attempt as FP -1001 "The file already exists in this location." Clearing the collision makes the create land on the next retry. |
26.4 (2026-09-04) | writes.md; gotcha 30 | D3, D4 |
| MQ-080 | A pending edit on an item the provider reports deleted is not lost: the system keeps the local content and re-offers it as a createItem of the same name, which then collides with the path that is still there and is retried for ever (MQ-014), invisibly — observed still retrying four hundred seconds later. The mount shows the new content, the server keeps the old, and the re-created item carries a new identifier, so a pin or a tag on it is lost even if the create eventually succeeds. This is why the mass-deletion guard holds pending items and their ancestors rather than reporting them deleted. |
26.4 (2026-09-04) | change-detection.md; gotcha 63 | D4 |
| MQ-015 | A real name collision inside Finder never reaches the provider: Finder resolves it itself (run.sh → run copy.sh). |
26.4 (2026-09-04) | writes.md | D3 |
| MQ-016 | A case collision arriving from the server is renamed by the system on its replica only, silently, with no modifyItem back to us; the user sees a name the server does not have. With README-renamed.txt present, creating README-RENAMED.TXT on the server left the server and the index holding both names and the mount showing README-RENAMED.TXT beside README-renamed 2.txt: the older item is the one renamed. |
26.4 (2026-09-04) | names-and-attributes.md | L7 |
| MQ-052 | add(domain) can report NSCocoaErrorDomain 4099 ("connection to com.apple.FileProvider was invalidated") after the call has landed. Seen on set nickname and on the first location start after an upgrade. |
26.4 (2026-09-05) | extension.md, packaging.md; gotcha 95 | P5 |
Eviction, content policy and pinning¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-020 | evictItem evicts a directory recursively and works on .rootContainer: 11 materialized items to 0 from one call, directories included, each file left dataless with its size, xattrs and atime. NSFileProviderErrorNonEvictableChildren is reserved for a child that refuses. |
26.4 (2026-09-04) | eviction.md | G9 |
| MQ-017 | An evictItem issued straight after a modifyItem reply is refused -2008 NSFileProviderErrorNonEvictable — the system is still finishing the modification. The same call seconds later succeeds; the first retry has always been enough. confidence: the length of that window was never measured, only its effect, so SystemModel refuses exactly the first call after a reply. |
26.4 (2026-09-04) | writes.md; gotcha 49 | D2 |
| MQ-018 | -2008 is returned for both a pending upload and a kept item, so the eviction loop cannot tell a pin from a pending write by error code. NSFileProviderErrorUnsyncedEdits (-2007) never appeared. |
26.4 (2026-09-04) | eviction.md; gotcha 82 | G3 |
| MQ-019 | Evicting the parent directory of a pending item fails differently and opaquely: NSCocoaErrorDomain 4101 "Couldn't communicate with a helper application", underlying libfssync.VFSFileTree.ItemNotFoundReason 5 contentVersionMismatch. Not -2006. |
26.4 (2026-09-04) | eviction.md | G2, G3 |
| MQ-024 | The item's effective contentPolicy, inherited from an eager ancestor, is what refuses an eviction — not allowsEvicting. A file still serving allowsEvicting with kept = false was refused because its ancestor was eager. |
26.4 (2026-09-04) | pinning.md; gotcha 23 | G4 |
| MQ-025 | The system puts allowsEvicting back: the capability we serve is ignored and the bit reported follows isDownloaded. A pinned, downloaded file whose row served capabilities 47 (the evict bit cleared) was reported by fileproviderctl evaluate as 0x2000006F / rwdpfet-----, read back four times over forty seconds and never changing, with userInfo.kept = 1 in the same snapshot. allowsEvicting is API_DEPRECATED since macOS 13; the only lever is the per-provider NSExtensionFileProviderAllowsUserControlledEviction (and NSExtensionFileProviderAllowsContextualMenuDownloadEntry for the other entry). |
26.4 (2026-09-04) | platform.md, pinning.md | G4 |
| MQ-026 | contentPolicy = .inherited is the neutral value: an item served it forces nothing, and a freshly added domain fetches 0 items indefinitely. |
26.4 (2026-09-04) | pinning.md | G5 |
| MQ-027 | An explicit .downloadLazily on a child overrides an eager ancestor: only the direct sibling under the eager folder was fetched; nine files inside the excluded subtree stayed dataless. |
26.4 (2026-09-04) | pinning.md | G5 |
| MQ-028 | An eager policy downloads a whole subtree including subfolders nothing has ever listed: a folder with no index row at all was asked for and its eight files came down in the same pass. | 26.4 (2026-09-04) | pinning.md | G6 |
| MQ-029 | Ancestors reported through the working set are not ingested. Neither the working-set signal nor signalEnumerator on each new ancestor's container starts anything: both arms sat at 0 fetches for 90 s. A lookup of the path in the replica (getUserVisibleURL plus one lstat, 0.46 s, the file still dataless) is what does, and the files came down within 90 s of it. Reproduced three times. confidence: the outcome is measured; that the dividing line is a new container rather than a new item is SystemModel's reading of it and not a measurement of its own. |
26.4 (2026-09-04) | pinning.md; gotcha 25 | G6 |
| MQ-030 | An eager policy on .rootContainer downloads the whole location — 20 items, directories included. The root is not a special case. |
26.4 (2026-09-04) | pinning.md | G9 |
| MQ-031 | The system holds at most six fetchContents calls open at once for an eager subtree: strict batches of six, never seven, over 38 transfers with each held 5 s. Undelayed runs of the same subtree peaked at 5. |
26.4 (2026-09-04) | sftp.md | G8 |
| MQ-032 | Eight files opened at once from a shell arrive as eight simultaneous foreground fetchContents calls, so the six-fetch ceiling bounds an eager subtree and not the agent's queue. |
26.4 (2026-09-04) | sftp.md; gotcha 42 | G8 |
| MQ-033 | evictItem on the root container fails as a whole while anything under it is kept: the call meets a kept child and returns an error rather than evicting the rest. |
26.4 (2026-09-05) | eviction.md, pinning.md; gotcha 82 | G2 |
| MQ-034 | For 5-10 s after an unpin the system has not re-read the rows whose policy changed, and an eviction fails as NSCocoaErrorDomain "The file couldn't be opened", naming no reason. A single file becomes evictable in 5-10 s; the root container did not within a minute. confidence: a range and a lower bound rather than two numbers, so SystemModel takes 10 s and 60 s, the pessimistic ends of both. |
26.4 (2026-09-05) | eviction.md, pinning.md; gotcha 82 | G2 |
| MQ-021 | An eviction moves atime, so a TTL loop must read atime before it evicts. | 26.4 (2026-09-04) | eviction.md | G1 |
| MQ-022 | Something in the system advances a materialized file's atime minutes after the fetch, deferred, with no read of ours near it — not our lstat, not the materialized enumerator (two enumerations in a row moved nothing); on the evidence, the domain's own indexer. A file fetched 280 s earlier had an atime 23 s old, which under a 60 s TTL evicted nothing. confidence: one file, one reading, so SystemModel schedules a single deferred advance at that gap and claims nothing about the schedule behind it. |
26.4 (2026-09-05) | eviction.md; gotcha 80 | G1 |
| MQ-023 | atime follows the relatime rule on APFS: it advances only when it is older than mtime — ten cats over forty seconds moved nothing, and forcing atime one second below mtime made the very next read advance it to now. A plain file on /tmp behaves identically, so this is the filesystem and not the replica. The atime write is also deferred: a stat right after materialization still shows the old value, and the new one appears a minute or so later. |
26.4 (2026-09-04, 2026-09-05); 14/15 — explicitly wants a re-run | eviction.md | G1 |
| MQ-045 | Extended attributes survive an eviction: all three written through the mount were still on the file after evictItem made it dataless. NSFileProviderItem.extendedAttributes promises exactly that — the system sets them on dataless files and preserves them, treating them as metadata rather than content. |
26.4 (2026-09-04) | names-and-attributes.md, eviction.md | L5 |
Writes, saves and offline¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-035 | The system re-offers a queued write for ever on a doubling backoff with no ceiling in sight: 5.50, 10.56, 20.30, 43.03, 79.36, 153.23, 331.30 s, still climbing ten minutes into an outage. | 26.4 (2026-09-04) | offline.md; gotcha 59 | F4 |
| MQ-036 | The system never re-issues a failed fetchContents. One call, one error, Operation timed out at once, and nothing for the next seven minutes. A second read produces a second call, so the item is not poisoned; there is simply no retry. |
26.4 (2026-09-04) | offline.md; gotcha 59 | F2, F3 |
| MQ-037 | signalErrorResolved(.serverUnreachable) is the only thing that flushes a queued write — the modifyItem arrived 20 ms after it. signalEnumerator(.workingSet) alone did nothing in 60 s, and a plain reconnect nothing in 75 s. |
26.4 (2026-09-04) | offline.md; gotcha 58 | F1, P8 |
| MQ-038 | Requests keep reaching the extension while the domain is connected and every call fails fast, including through an authentication-deadline stop: 6 fetchContents and 4 createItem arrived with the breaker refusing everything, and fileproviderctl dump showed the domain not disconnected. |
26.4 (2026-09-04) | secrets.md, offline.md | F5 |
| MQ-039 | A readdir/lstat walk of the mount is answered from the system's replica and reaches the extension not at all: ls -R returns the whole tree with exit 0 while every provider call is failing, and a test -e inside the mount produces no provider traffic either. |
26.4 (2026-09-04) | item-index.md, root-set.md | H9, C6 |
| MQ-040 | disconnect(reason:) works from inside the extension: the domain goes permanently disconnected (state 4), fileproviderctl dump cannot dump the extension (FP -1004, NSFileProviderErrorDomainDisconnectionStateKey=4) and item-level state carries domain:serverUnreachable. |
26.4 (2026-09-04) | extension.md; gotcha 61 | C1 |
| MQ-041 | Through a permanent disconnect the replica listing and a queued write survive: a write into the mount succeeded locally and was queued with no agent at all; only fetches failed, with ETIMEDOUT. |
26.4 (2026-09-04) | extension.md | C1 |
| MQ-074 | Re-launching the app (open -g) lifts a permanent disconnect and the queued write then reaches the server with its contents. |
26.4 (2026-09-04) | extension.md | C1 |
| MQ-049 | An atomic save keeps the identifier: TextEdit's save and the shell's write-a-temp-and-mv both arrive as one modifyItem on the original item (0x289 and 0xc1), with no createItem and no deleteItem. Both are preceded by a modifyItem on the parent directory carrying 0x80, its mtime. |
26.4 (2026-09-04) | item-index.md, writes.md | D9 |
| MQ-047 | chmod inside the mount arrives as modifyItem with changedFields = 0x100 (.fileSystemFlags); a chmod +x on a file that is already 755 produces no call at all, because the only bit the replica carries is owner-execute. |
26.4 (2026-09-04) | names-and-attributes.md | L8 |
| MQ-048 | A Finder rename is one modifyItem with changedFields = 0x2 (.filename). |
26.4 (2026-09-04) | writes.md | D9 |
Attributes, tags and names¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-042 | Finder tags arrive as the item's tagData and nothing else: one modifyItem with changedFields = 0x10 (NSFileProviderItemTagData), an empty extendedAttributes dictionary, and a 453-byte NSKeyedArchiver archive — not the _kMDItemUserTags property list. Nothing about them reaches the server. |
26.4 (2026-09-04) | names-and-attributes.md; gotchas 26, 51 | L4 |
| MQ-043 | Tags are wiped on the next re-download unless the item returns them: after re-materializing, ordinary xattrs survived and the tags xattr was gone, because our item carried no tagData for the system to rebuild it from. An item that does return tagData gets the xattr rebuilt from it. |
26.4 (2026-09-04) | names-and-attributes.md | L4 |
| MQ-079 | The system asks once for a tag change, and it asks once even when the reply carries the metadata version the item already had (debug fault --frozen-metadata on: the same single modifyItem and no more over sixty seconds). So the xattr hash is not what stops a retry loop - there is no retry loop, for the same reason a returned version is believed at all (MQ-013). What the hash is for is the other direction: it is the only thing that moves an item's version when the agent changes the stored blob. |
26.4 (2026-09-04) | item-index.md, names-and-attributes.md; gotcha 50 | L4 |
| MQ-044 | The system decides which xattrs the extension is ever told about. Of three written through the mount only the one whose name carried XATTR_FLAG_SYNCABLE reached changedFields.extendedAttributes; com.apple.metadata:_kMDItemUserTags and com.apple.FinderInfo are excluded deliberately. NSExtensionFileProviderAdditionalSyncableExtendedAttributes is the way to widen the set. |
26.4 (2026-09-04) | names-and-attributes.md | L5 |
| MQ-046 | A .DS_Store written into the mount never reaches the extension: no createItem, no modifyItem, no row. The system keeps it in the replica, reports it as an item with isUploaded = 0 and isUploading = 0, and never asks anyone to upload it. |
26.4 (2026-09-04) | names-and-attributes.md; gotcha 52 | L6 |
| MQ-076 | The system creates a real symlink under ~/Library/CloudStorage for an item served as one: lrwx------, and readlink returns the row's target. A relative in-root target resolves inside the mount, an absolute in-root one is served rewritten relative (/home/alec/m4/Other as ../Other), and ln -s reaches createItem with the target intact. Finder gives it Kind "Alias" and an arrow badge, and a badged folder icon for a link to a directory. |
26.4 (2026-09-04) | symlinks.md; gotcha 54 | M3, M4 |
| MQ-077 | A dangling symlink presents identically to a live one: same badge, same Kind, size = the target string's length, and no broken-link marker anywhere. Nothing in the mount distinguishes the two. | 26.4 (2026-09-04) | symlinks.md; gotcha 54 | M4 |
| MQ-078 | A refused create is not a message the user sees: ln -s exits 0, the system keeps the item locally, and the refusal comes back as the item's uploadingError (NSFileProviderErrorCannotSynchronize, -2005) with the system's own wording. The sentence about what a link's target has to be can only reach the user through sshdrive status's sync-error list. |
26.4 (2026-09-04) | symlinks.md, cli.md; gotcha 55 | M3 |
| MQ-050 | displayName is the bare nickname. The mount directory is <app name, spaces removed>-<displayName, spaces removed> and Finder's label is <app display name> - <displayName>; a displayName of SSH Drive - nas2 gives SSHDrive-SSHDrive-nas2 and SSH Drive - SSH Drive - nas2. mdls reports the raw directory name instead; Finder's displayed name is what the user reads. |
26.4 (2026-09-04) | platform.md, locations.md; gotcha 28 | L3 |
| MQ-051 | add(domain) with an identifier the system already holds and a new displayName renames the domain in place: the mount directory is renamed, the materialized set (4 items) and the pending upload are unchanged, nothing is re-fetched and nothing is lost. |
26.4 (2026-09-05) | cli.md, locations.md | P8 |
Menus, decorations and drawing¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-053 | Finder's own File Provider entries are exactly two, in the third slot of the contextual menu: Download Now when the item is dataless and Remove Download when it is materialized, chosen by isDownloaded and nothing else. It draws no built-in "Keep Downloaded" for a third-party provider, and Remove Download is still offered on a kept item (where it fails). |
26.4 (2026-09-04, screenshots) | pinning.md; gotcha 32 | G13 |
| MQ-054 | Our custom actions are drawn at the very bottom of the contextual menu, below Quick Actions, at the top level, each with a leading empty checkbox-style glyph, exactly one of a pair at a time, and on the window background (evaluated against the folder being shown) but never on the sidebar row, whose menu offers Finder's own Download Now and neither of ours — so a whole location is pinned only from the CLI. |
26.4 (2026-09-04, screenshots) | pinning.md; gotcha 32 | G14 |
| MQ-055 | A custom action's activation rule binds fileproviderItems, lower-case p, as a key path — not fileProviderItems, and not a $ substitution variable. The capital-P spelling occurs nowhere in the dyld shared cache. Either mistake drops the menu entry silently with nothing in any log; $fileProviderItems raises out of NSVariableExpression because the bindings dictionary is empty. |
26.4 (2026-09-04) | pinning.md; gotcha 31 | G14 |
| MQ-056 | A decoration's Info.plist keys are the bare Identifier, BadgeImageType, Label, Category — not NSFileProviderDecoration-prefixed spellings, not the NSExtensionFileProviderAction* shape beside them — and BadgeImageType is a UTI conforming to com.apple.icon-decoration.badge (the system ships .badge.pinned, .checkmark, .locked, .syncing and a dozen more in CoreTypes.bundle), never an asset name. Every mistake is silent. |
26.4 (2026-09-05) | pinning.md; gotcha 81 | — (VM; Info.plist asserted) |
| MQ-057 | Finder draws a Category: Badge decoration as an orange disc at the trailing edge of the Name column, not on the icon, on every kept row including rows that merely inherit the pin. An excluded folder inside a kept one carries none. |
26.4 (2026-09-05, screenshots) | pinning.md | — (VM) |
| MQ-058 | Move to Bin is offered in the contextual menu even though allowsTrashing is never set; Delete Immediately… lives in the File menu. The delete alert's wording is fixed in Finder's own strings ("Are you sure you want to delete “^1”?" / "This item will be deleted immediately. You can't undo this action."). An AppleScript delete takes the FPDeleteOperation path instead and draws no dialog at all. |
26.4 (2026-09-04) | names-and-attributes.md | — (VM) |
| MQ-059 | Finder gives a third-party download no cancel control: with one fetchContents held open 40 s, the list-row progress ring is not a stop button, hovering for 1.5 s does not make it one, a click on it does nothing (the transfer stays in flight and the agent logs no cancellation), and the contextual menu on a downloading item offers no Stop/Cancel. |
26.4 (2026-09-04) | extension.md | — (VM; Progress.cancel() tested from code instead) |
| MQ-071 | On an idle headless Mac the com.apple.fileproviderd.background-download scheduler takes 8-90 s to start an eager fetch, and fileproviderd throttles the working-set fetch (a server-side change was still absent 40 s later and landed ~45 s after a second signal), so no latency claim can be made from such a machine. |
26.4 (2026-09-04) | testing.md | — (VM only) |
| MQ-072 | waitForStabilization is not a barrier for downloads: it returns in well under a second and only says the two sides have exchanged what they know. |
26.4 (2026-09-04) | testing.md | — (VM only) |
launchd, LaunchServices, signing and install¶
| id | statement | measured on | source | scenarios |
|---|---|---|---|---|
| MQ-061 | LaunchServices registers no plugin of a quarantined bundle that no person has launched. With com.apple.quarantine on the installed app, pluginkit -m prints nothing, doctor fails "extension registered", and fileproviderd answers FP -2001 / Underlying FP -2014 ("provider not found" / "application extension not found"). pluginkit -a registers it and the next launch wipes that again; xattr -dr then open -g is durable. open -g is not an assessed launch. |
26.6 (2026-09-05). A quarantined fresh-user install on 26.4 (2026-09-05) passed — which half of that difference matters is not claimed | packaging.md; gotcha 99 | P3 |
| MQ-081 | A LaunchServices record for a second copy of the app answers every registration of the installed one, and PlugInKit then discovers no appex. After brew upgrade --cask sshdrive from 0.1.7 to 0.1.8, lsregister -dump held two records with identifier org.shirls.sshdrive: /Applications/SSH Drive.app (version 108, registered 15:57) and /Volumes/SSH Drive/SSH Drive.app (version 105, registered 12:59) - the DMG mount of an earlier install, whose volume had long been detached and which ls /Volumes did not list. While that second record existed, every registration attempt, the postflight's own lsregister -f -R -trusted on /Applications/SSH Drive.app included, made lsd log SecStaticCodeCreateWithPath(<private>) failed with error -67028 (errSecCSBadBundleFormat), skipping registration of an incomplete bundle and Registration succeeded, but did not actually register anything new; returning existing bundle, and pkd log No plugins found to match query - for twenty minutes, so it is the record and not a window after the copy. pluginkit -m printed nothing of ours, doctor failed "extension registered" and "file provider domains: The application cannot be used right now", and the bundle carried no quarantine attribute at all (so this is not MQ-061). The agent itself was fine, launchd starting it directly. lsregister -u "/Volumes/SSH Drive/SSH Drive.app" exited 0 and removed the record; lsregister -f -R -trusted "/Applications/SSH Drive.app" and then open -g registered the appex at once, the launch logging extension registered with PlugInKit: org.shirls.sshdrive.fileprovider(0.1.8) and doctor going green. |
27.0 (2026-09-23), upgrading 0.1.7 to 0.1.8. The same upgrade on 26.4.1 produces no /Volumes record and registers the appex through the plain open -g |
packaging.md; gotcha 107 | P11, P12 |
| MQ-062 | SMAppService.register() does not repair a registration whose bundle was deleted and replaced: it keeps returning success while every spawn logs Could not find and/or execute program specified by service and copy_bundle_path(<uuid>, 501, 0), error 0x6f, retrying on a 10 s throttle for ever. Only unregister() followed by a launch clears it. |
26.4 (2026-09-04) | packaging.md | P1 |
| MQ-063 | SMAppService.unregister() returns before launchd has dropped the job, 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 and every spawn dies EXC_CRASH (SIGKILL (Code Signature Invalid)) on a 10 s retry, for ever, with the mach service accepting connections and answering nothing; launchctl print shows job state = spawn failed, last exit code = 78: EX_CONFIG and properties = … needs LWCR update | has LWCR, and launchd logs service inactive: org.shirls.sshdrive.agent every 10 s. The job being gone from launchctl print is not a sufficient condition. Installing 0.1.5 the way the cask's postflight does, the unregister role's poll said the job was gone 7 ms after unregister() returned ("login item unregistered" 12:59:50.604, "teardown finished" 12:59:50.611); the open -g 110 ms later registered it again and the spawn at 12:59:50.83 died namespace CODESIGNING, indicator "Launch Constraint Violation", runs = 6. sshdrive doctor then said "the agent answered the connection but not the command in time" and add said "The application cannot be used right now". The same bundle, with about 5 s of waiting between the unregister role and the open -g, started first time and doctor was green. The constraint is captured from the signature, so a build signed with a different certificate from the installed one is what exposes it: the 0.1.3 to 0.1.4 upgrade did not hit this and the 0.1.4 to 0.1.5 one, which changed certificate, did. confidence: 5 s is an interval that worked, not a measured boundary; the 7 ms and 110 ms are from the install that failed. SystemModel gives the window the 10 s throttle interval. |
26.4 (2026-09-05), 26.4.1 (2026-09-23) | packaging.md; gotcha 93 | P2, P10 |
| MQ-064 | A restricted entitlement is satisfied only by a profile that names the certificate the bundle is signed with: a profile issued for the account's other Developer ID certificate makes every restricted entitlement unsatisfied (taskgated-helper: Unsatisfied entitlements: keychain-access-groups / Disallowing, amfid: -413 "No matching profile found") and the agent is SIGKILLed at exec with a Launch Constraint Violation — after notarizing and stapling perfectly, because notarization never looks at profiles. The control is the same Release binary and entitlements signed with an identity the embedded profile does name, which launches and reaches the keychain. Adding com.apple.application-identifier to "match properly" makes it strictly worse: the team-identifier entitlement goes unsatisfied too. |
26.4 (2026-09-05) | packaging.md, components.md; gotcha 91 | P7 |
| MQ-065 | An ad-hoc signature carrying keychain-access-groups is killed at exec: AppleMobileFileIntegrityError Code=-424 "adhoc signed but contains restricted entitlements", with open -g answering Launchd job spawn failed (RBSRequestErrorDomain 5, POSIX 163) and a direct run Killed: 9. Removing that one key makes the same binary launch. com.apple.security.application-groups is not rejected ad-hoc, and the group container is reachable under one. |
26.4 (2026-09-04) | packaging.md | P7 |
| MQ-066 | com.apple.application-identifier in the agent's signed entitlements makes AMFI refuse to let launchd start it (AMFI: Launch Constraint Violation (enforcing), error info: c[5]p[1]m[1]e[0], xpcproxy exited due to OS_REASON_CODESIGNING), after which launchd removes the service "since it exited with consistent failure", while a direct exec of the same binary works and codesign --verify --deep --strict is happy. The appex keeps the key, being launched by fileproviderd. |
26.4 (2026-09-04) | components.md | P7 |
| MQ-067 | launchctl setenv does not reach a launchd agent on macOS 26, even across an agent restart; the job's inherited environment carries only SSH_AUTH_SOCK, and launchctl getenv shows the variable the job never sees. |
26.4 (2026-09-04) | secrets.md; gotcha 65 | — (design constraint; the override is a file) |
| MQ-068 | On a fresh user who has never opened System Settings, one open -g registers the login item already enabled (SMAppService reports .enabled, not requiresApproval) and registers the extension, and a domain added afterwards comes up not user-disabled: no user-disabled anywhere in fileproviderctl dump, no FP -2011, the appex launched and the mount listing. The user-disabled state seen earlier came from an ad-hoc-signed build. The app-group container is not evidence the app has ever run there: containermanagerd creates the skeleton for every installed app's group at first login. |
26.4 (2026-09-04, uid 502) | packaging.md | P6 |
| MQ-069 | macOS asks for Local Network access in the app's name on first connect to a LAN address — which every NAS is: "Allow “SSH Drive” to find devices on local networks?". No entitlement suppresses it and a launchd agent has no window to put it over. | 26.4 (2026-09-04) | platform.md, packaging.md; gotcha 57 | — (VM only) |
| MQ-070 | Neither kIOMessageSystemWillSleep and friends nor kCGAnyInputEventType import into Swift: they are iokit_common_msg() and C macros, spelled out as 0xE0000280 / 0xE0000270 / 0xE0000300 and CGEventType(rawValue: ~0). The will-sleep message must be acknowledged with IOAllowPowerChange. |
26.4 (2026-09-04) | ssh.md; gotcha 64 | F6 |
| MQ-060 | A launchd agent's stat and open under its own domain's mount draw no TCC prompt and no EPERM: the access is evaluated as kTCCServiceFileProviderDomain with our domain as the indirect object and allowed, with no Prompting line anywhere in the log. kTCCServiceSystemPolicyAllFiles is denied (authValue=0 authReason=5) and not needed. |
26.4 (2026-09-04); 14/15 — explicitly wants a re-run | eviction.md | G12 (the no-op rule: our code adds no guard, prompt path or fallback; TCC itself stays VM-only, docs/design/testing.md) |