pi-session-mcp 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.pi-session-mcp-cli/config.js +11 -1
  2. package/.pi-session-mcp-cli/doctor.js +22 -1
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/extension-spec.js +26 -0
  5. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  6. package/CHANGELOG.md +62 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +2 -2
  9. package/SECURITY.md +1 -1
  10. package/dist/capabilities.d.ts +1 -0
  11. package/dist/capabilities.js +2 -1
  12. package/dist/client-adapters/mcp-verifier.js +5 -2
  13. package/dist/config.d.ts +1 -0
  14. package/dist/config.js +11 -1
  15. package/dist/doctor.d.ts +4 -1
  16. package/dist/doctor.js +22 -1
  17. package/dist/execution-profile.d.ts +3 -0
  18. package/dist/execution-profile.js +1 -0
  19. package/dist/extension-spec.d.ts +15 -0
  20. package/dist/extension-spec.js +26 -0
  21. package/dist/main.js +6 -2
  22. package/dist/pi-adapter.d.ts +1 -1
  23. package/dist/pi-adapter.js +4 -0
  24. package/dist/pi-extensions.d.ts +17 -0
  25. package/dist/pi-extensions.js +67 -0
  26. package/dist/sdk-pi-adapter.d.ts +24 -6
  27. package/dist/sdk-pi-adapter.js +256 -86
  28. package/dist/server.js +1 -0
  29. package/dist/session-tools.d.ts +9 -0
  30. package/dist/session-tools.js +41 -0
  31. package/dist/setup-command-guardian.js +29 -73
  32. package/dist/setup-main.js +2 -1
  33. package/dist/setup-ownership.js +18 -28
  34. package/dist/setup-platform.d.ts +57 -0
  35. package/dist/setup-platform.js +195 -0
  36. package/dist/setup-process.d.ts +6 -3
  37. package/dist/setup-process.js +85 -93
  38. package/dist/setup.js +2 -1
  39. package/dist/stdout-guard-install.d.ts +5 -0
  40. package/dist/stdout-guard-install.js +6 -0
  41. package/dist/stdout-guard.d.ts +20 -0
  42. package/dist/stdout-guard.js +72 -0
  43. package/docs/acceptance.md +343 -0
  44. package/docs/adr/0005-macos-setup-platform.md +84 -0
  45. package/docs/adr/0006-pi-extension-allowlist.md +58 -0
  46. package/docs/architecture.md +1 -1
  47. package/docs/client-setup-design.md +21 -9
  48. package/docs/client-setup.md +25 -10
  49. package/docs/doctor-design.md +7 -2
  50. package/docs/installation.md +125 -4
  51. package/docs/macos-setup-design.md +386 -0
  52. package/docs/pi-extensions-design.md +437 -0
  53. package/docs/releasing.md +16 -7
  54. package/docs/threat-model.md +54 -18
  55. package/docs/tool-contracts.md +8 -3
  56. package/package.json +2 -1
@@ -9,8 +9,9 @@ directory: an npm executable can run from any directory.
9
9
  `runDoctor` returns one frozen result model (`schemaVersion: 1`, `ok`, a
10
10
  sanitized semver `packageVersion` when metadata is available, ordered `checks`,
11
11
  and `exitCode`). Human and JSON output are projections of that same model.
12
- Subjects are only fixed component names or validated workspace aliases; paths,
13
- configuration values, errors, credentials, and provider data are never output.
12
+ Subjects are only fixed component names, validated workspace aliases, or
13
+ validated `<profile>:<package>` pairs; paths, configuration values, errors,
14
+ credentials, and provider data are never output.
14
15
 
15
16
  Each check is `{ id, severity, subject, remediation? }`. Severity is `ok`,
16
17
  `warning`, or `error`; remediation is a fixed safe sentence. The initial IDs
@@ -31,6 +32,10 @@ and owners are:
31
32
  | `workspace_not_directory` | A resolved workspace is not a directory | validated workspace alias |
32
33
  | `workspace_unreadable` | A resolved workspace cannot be read | validated workspace alias |
33
34
  | `workspace_not_writable` | A workspace is not writable while a coding profile can target it | validated workspace alias |
35
+ | `extension_missing` | Pi has no user install of a package that a profile's `extensions` names | `<profile>:<package>` |
36
+ | `extension_version_mismatch` | The installed manifest's version differs from the pinned version, or the manifest or its `pi.extensions` entries are invalid | `<profile>:<package>` |
37
+
38
+ Doctor reads extension manifests from Pi's user install location only; it never installs packages and never imports extension code.
34
39
 
35
40
  The Doctor reuses `loadConfig`; it does not duplicate alias, profile, thinking
36
41
  level, or read-only-default rules. Workspace values are used only for local
@@ -35,19 +35,25 @@ the same arguments.
35
35
 
36
36
  ## Requirements
37
37
 
38
- - Linux. `setup` fails closed on every other platform, and the server has only
39
- been accepted on Linux.
38
+ - Linux or macOS. `setup` fails closed on every other platform. macOS is tested
39
+ in CI on macOS 15 and 26 on Apple silicon. Setup acceptance on a real Mac is
40
+ recorded for Claude Code only; Codex is still pending. See
41
+ [Acceptance](acceptance.md#status).
40
42
  - Node.js **22.19.0 or newer**. `setup` registers the absolute path of the Node
41
43
  executable that runs it, so run it with the Node installation that clients
42
44
  should use.
43
- - For `setup`: a root-owned util-linux `flock` at `/usr/bin/flock` or
45
+ - For `setup` on Linux: a root-owned util-linux `flock` at `/usr/bin/flock` or
44
46
  `/bin/flock`, readable `/proc`, and the ownership state directory
45
47
  `~/.local/state/pi-session-mcp` on ext2/3/4, XFS, Btrfs, tmpfs, overlayfs, ZFS,
46
48
  F2FS, UBIFS or bcachefs. Network and FUSE filesystems fail closed.
49
+ - For `setup` on macOS: the system `/bin/ps`, and the ownership state directory
50
+ `~/.local/state/pi-session-mcp` on the same filesystem type as `/`, which is
51
+ APFS. A home directory on HFS+, exFAT, a network volume or any other
52
+ filesystem fails closed.
47
53
  - Codex CLI or Claude Code. `setup` does not pin client versions. It accepts a
48
54
  client whose public `mcp` command output matches the expected contract and
49
55
  reports `unsupported` otherwise. The contract was last recorded with Codex CLI
50
- `0.162.0` and Claude Code `2.1.296`. Manual registration, described in the
56
+ `0.162.1` and Claude Code `2.1.296`. Manual registration, described in the
51
57
  [client setup reference](client-setup.md), works independently of `setup`.
52
58
  - For real prompts only: local Pi authentication and a provider and model pair
53
59
  that the local Pi configuration knows. Installation, Doctor and `setup` need
@@ -146,6 +152,10 @@ Rules:
146
152
  `azure`; a profile that names the old key must be changed.
147
153
  - A profile may grant tools from local stdio MCP servers through `mcpServers`.
148
154
  See [External MCP tools in embedded sessions](session-mcp.md).
155
+ - A profile may load Pi extensions that Pi has installed, through
156
+ `extensions`: a list of at most 8 entries of the form
157
+ `npm:<name>@<exact version>`, each package once. Ranges, tags, `git:`
158
+ sources, paths and build metadata (`+build`) are rejected. See [Pi extensions](#pi-extensions).
149
159
  - Never put keys, tokens, credential objects, headers, base URLs or
150
160
  authentication paths into this file.
151
161
 
@@ -245,6 +255,67 @@ If a registration already exists, `setup` never replaces it:
245
255
  Start a new client session after registering. The client launches the server on
246
256
  demand; there is nothing to start by hand.
247
257
 
258
+ ### Register without setup
259
+
260
+ `setup` runs on Linux and macOS. On every other platform, Windows included, it
261
+ refuses every operation, `--verify` included, with `platform_unsupported`
262
+ before it starts a client command. On Linux and macOS, `--apply`, `--verify`,
263
+ `--rollback` and `--remove` also fail closed when the platform prerequisites in
264
+ [Requirements](#requirements) are not met. A manual registration needs none of
265
+ this: it uses the client's own commands and writes no ownership state. Use it
266
+ when `setup` cannot run, or when you manage client configuration yourself. No
267
+ acceptance level covers this path.
268
+
269
+ 1. Install as in section 1, configure as in section 2, and run Doctor as in
270
+ section 3. Doctor has no platform-specific check and needs neither the lock
271
+ nor the process-table prerequisites of `setup`.
272
+ 2. Register the server with the client's own command, as the
273
+ [Codex](client-setup.md#register-with-codex) and
274
+ [Claude Code](client-setup.md#register-with-claude-code) sections of the
275
+ client setup reference show. Use absolute paths and the Node executable that
276
+ clients should use. The entry point is `dist/main.js` inside the package
277
+ directory: in an npm installation `echo "$(npm root -g)/pi-session-mcp"`
278
+ prints that directory, in a release checkout it is the checkout. Write the
279
+ resulting absolute path into the command; a substitution inside the single
280
+ quotes of the Codex example would not be expanded.
281
+ 3. Check the registration. These checks are not a replacement for
282
+ `setup --verify`: it also performs a bounded direct MCP handshake, and
283
+ outside `setup` nothing does that yet. The last check below depends on the
284
+ client, which can hang or hide a handshake problem, so treat the result as
285
+ weaker evidence than `--verify`.
286
+ - Doctor passed in item 1.
287
+ - Compare the stored registration with what you meant to register.
288
+ `claude mcp get pi-session-mcp` and `codex mcp get pi-session-mcp` print
289
+ the Node executable, the entry point and the configuration path that the
290
+ client stored, and Claude Code also prints the scope. Each must equal the
291
+ value you passed. `--verify` makes this comparison for `setup`. Without
292
+ it, a registration that still points at an old release directory passes
293
+ every other check.
294
+ - `claude mcp list` and `claude mcp get pi-session-mcp` report `Connected`
295
+ when the client could start the server and complete the MCP connection.
296
+ A registration with `-s project` reports `Pending approval` instead,
297
+ because Claude Code connects to a project server only after you approve it
298
+ in a session in that project. Approve it, then expect `Connected`.
299
+ - Start a new client session and call `pi_capabilities_get` with `{}`. The
300
+ client lists the eight tools, and the call returns the configured aliases.
301
+
302
+ Like `--verify`, none of these starts a Pi session or contacts a provider.
303
+ 4. Upgrade and remove with the client's own commands. Nothing owns the
304
+ registration and it is not bound to the build.
305
+ - An npm upgrade replaces the package in place and keeps the entry point.
306
+ Close the client sessions, install the new version as in section 1, run
307
+ Doctor as in section 3, and repeat the checks of item 3. Do not run the
308
+ `cp` of section 2 again: it replaces your configuration with the packaged
309
+ example.
310
+ - A new release directory needs a new registration. Run
311
+ `codex mcp remove pi-session-mcp` or
312
+ `claude mcp remove pi-session-mcp -s <scope>`, add the server again with
313
+ the new paths, run Doctor, and repeat the checks of item 3.
314
+ - To remove it entirely, run the client's remove command, then uninstall the
315
+ package or delete the release directory and the configuration file. A
316
+ manual registration leaves no ownership state, so the `--remove` of the
317
+ Remove section does not apply.
318
+
248
319
  ## 5. Run a first session
249
320
 
250
321
  From the client, call the tools in this order:
@@ -273,6 +344,56 @@ configuration or credentials.
273
344
  The configuration path is part of the registration. Editing the file in place
274
345
  needs no new registration; moving it does.
275
346
 
347
+ ## Pi extensions
348
+
349
+ An execution profile can load Pi extensions that Pi has installed. Install them
350
+ with Pi (`pi install npm:<name>`; update with `pi update --extensions`), then
351
+ pin the installed version in the profile:
352
+
353
+ ```json
354
+ "code-cursor": {
355
+ "default": false,
356
+ "permissionProfile": "coding",
357
+ "provider": "cursor",
358
+ "model": "<model id from Pi's cursor catalog>",
359
+ "thinkingLevel": "high",
360
+ "extensions": ["npm:pi-cursor-sdk@0.5.3"]
361
+ }
362
+ ```
363
+
364
+ - **Install location.** Only Pi's own user install counts:
365
+ `<Pi agent dir>/npm/node_modules/<name>`, normally `~/.pi/agent/npm/…`,
366
+ created by `pi install npm:<name>`. A package installed with `npm i -g` is
367
+ reported as not installed: sessions report `extension_not_installed`, Doctor
368
+ reports `extension_missing`.
369
+ - **Version pin.** The server never installs or updates extensions. If Pi has
370
+ another version installed, sessions of that profile fail with
371
+ `extension_version_mismatch` and Doctor reports it. After `pi update
372
+ --extensions`, update the pinned version and restart the client.
373
+ - **Full capabilities.** An extension loads with its providers, tools, hooks
374
+ and system-prompt changes. In `read-only` profiles the model still gets only
375
+ `read`, `grep`, `find` and `ls`; in `coding` profiles every extension tool
376
+ may be active. Extension commands never run from client prompts, but
377
+ extension input hooks can still see, rewrite or consume a client prompt;
378
+ they receive it with `source: "rpc"`, never `interactive`.
379
+ - **Fail-closed start.** A session start fails with `extension_load_failed` if
380
+ an extension reports a hook error while binding. It fails the same way if an
381
+ extension registers an MCP server while loading or starting, because Pi's
382
+ built-in MCP extension is not loaded. An extension tool whose name collides
383
+ with another tool, or whose name is not 1 to 64 letters, digits, `_` or `-`
384
+ (not starting with `-`), or whose name Pi treats as an MCP tool (starting
385
+ with `mcp__`, or `list_mcp_resources`, `list_mcp_resource_templates`,
386
+ `read_mcp_resource`), fails with `extension_tool_conflict`.
387
+ - **Cursor runtime.** `pi-cursor-sdk` runs Cursor's local agent runtime. That
388
+ runtime starts its own processes (for example git and the MCP servers
389
+ configured for Cursor) and may act through Cursor's own tooling, so a
390
+ `read-only` profile limits only Pi's tools, not what the Cursor runtime does.
391
+ - **Trust.** Only list extensions you trust like any other program you run:
392
+ they execute inside the server process with your rights.
393
+ - **Cursor sign-in limit.** Without a stored Cursor key, `pi-cursor-sdk`
394
+ reports a placeholder key. Session start succeeds and the first prompt fails
395
+ with `turn_failed`. Store the key with Pi first.
396
+
276
397
  ## Upgrade
277
398
 
278
399
  `setup` binds each owned registration to the package manifests and to every
@@ -0,0 +1,386 @@
1
+ # macOS setup platform design
2
+
3
+ **Status:** Implemented (2026-10-10); Level-2 acceptance on real hardware is
4
+ recorded for Claude Code (2026-10-11) and pending for Codex. Tracking issue: #32. Follows #28 and PR #30. The decision record is
5
+ [ADR 0005](adr/0005-macos-setup-platform.md).
6
+
7
+ ## Goal
8
+
9
+ The complete setup lifecycle (`--dry-run`, `--apply`, `--verify`,
10
+ `--rollback`, `--remove`) works on macOS with the same guarantees as on Linux:
11
+
12
+ - a kernel lock fences the whole inspect → pending → client mutation → owned
13
+ transaction;
14
+ - the fence is released only after a determinate process-table scan confirms
15
+ that no non-zombie member of a client command's process group remains;
16
+ - the ownership directory sits on a filesystem whose lock and fsync semantics
17
+ are accepted.
18
+
19
+ macOS counts as accepted only after Level-2 setup acceptance on real hardware
20
+ (see [Acceptance on real hardware](#acceptance-on-real-hardware)). Until then,
21
+ the documentation calls it CI-tested.
22
+
23
+ ## Non-goals
24
+
25
+ - Windows and every platform other than `linux` and `darwin` keep failing
26
+ closed with `platform_unsupported` before any client command is spawned.
27
+ - Intel Macs are not part of the CI matrix. The code path is identical, so
28
+ they are expected to work but are documented as untested.
29
+ - The ownership location stays `~/.local/state/pi-session-mcp/` on both
30
+ platforms.
31
+ - No new public result codes, statuses, exit codes or result schema changes.
32
+ - No release or version change; that is a separate release change.
33
+
34
+ ## Current Linux dependencies
35
+
36
+ | Concern | Linux mechanism | Location |
37
+ | --- | --- | --- |
38
+ | Kernel lock | util-linux `flock -E 75 -w N 3` locks the open file description that the parent opened; the guardian inherits it as fd 3 | `src/setup-process.ts` `acquireSetupGuardian` |
39
+ | Process-group proof | `/proc/<pid>/stat` scan; `Z` entries do not count as live | `src/setup-process.ts` `processGroupState`, `src/setup-command-guardian.ts` `groupState`, `processTableReadable` |
40
+ | Filesystem | `statfs().type` allowlist of Linux magic numbers | `src/setup-ownership.ts` `SUPPORTED_LOCAL_FILESYSTEMS` |
41
+ | Platform gates | `process.platform !== "linux"` | `src/setup-main.ts`, `src/setup-process.ts` (runner, guardian), `src/setup-ownership.ts` |
42
+
43
+ Facts that rule out a direct port, with sources from the research on
44
+ 2026-10-10:
45
+
46
+ - macOS ships no `flock(1)`.
47
+ - macOS has no `/proc`.
48
+ - On macOS, `statfs` `f_type` is a dynamically assigned VFS type index, not a
49
+ stable magic number (Apple Developer Forums thread 87745). Node does not
50
+ expose `f_fstypename`.
51
+
52
+ ## Design
53
+
54
+ ### Platform module
55
+
56
+ A new module `src/setup-platform.ts` owns every operating-system fact that the
57
+ fence relies on. It has no relative imports, so the guardian can load it in
58
+ both source and built form.
59
+
60
+ ```ts
61
+ export type SetupPlatform = "linux" | "darwin";
62
+ export function currentSetupPlatform(platform?: NodeJS.Platform): SetupPlatform | undefined;
63
+
64
+ export type ProcessGroupState = "alive" | "gone" | "unknown";
65
+ export interface ProcessTable {
66
+ /** "alive" if any non-zombie process has this process group ID. */
67
+ groupState(group: number): Promise<ProcessGroupState>;
68
+ /** True only if a scan is trustworthy and contains the calling process. */
69
+ readable(): Promise<boolean>;
70
+ /** Poll interval for cleanup loops: 10 ms on Linux, 50 ms on macOS. */
71
+ readonly pollMs: number;
72
+ }
73
+ export function createProcessTable(platform: SetupPlatform): ProcessTable;
74
+
75
+ export function parsePsTable(output: string): readonly ProcessEntry[] | undefined;
76
+ export function isTrustedSystemBinary(path: string): boolean;
77
+ export function assertSupportedFilesystem(path: string, platform: SetupPlatform): Promise<void>;
78
+ export function openLockedDarwin(path: string, options: DarwinLockOptions): Promise<FileHandle>;
79
+ ```
80
+
81
+ The exact signatures may gain injected dependencies for tests, such as `stat`,
82
+ `statfs`, `open` and the `ps` executor. The behavior below is the contract.
83
+
84
+ `isTrustedSystemBinary` is the existing `flock` check, moved and shared: a
85
+ regular file owned by uid 0, not group- or world-writable, and executable.
86
+
87
+ ### Kernel lock
88
+
89
+ **Linux (unchanged):**
90
+
91
+ 1. The parent opens the lock file with `O_CREAT | O_RDWR | O_NOFOLLOW`.
92
+ 2. It validates the file.
93
+ 3. A trusted `flock -E 75 -w N 3` locks the shared open file description.
94
+ 4. The guardian inherits that description as fd 3.
95
+
96
+ **macOS:**
97
+
98
+ 1. The parent opens the lock file with
99
+ `O_CREAT | O_RDWR | O_NOFOLLOW | O_EXLOCK | O_NONBLOCK`.
100
+ - `O_EXLOCK` (`0x20`) and `O_NONBLOCK` (`0x4`) come from XNU
101
+ `bsd/sys/fcntl.h`. `fs.constants` does not export `O_EXLOCK`, so it is a
102
+ named constant in the module with that citation.
103
+ - XNU takes an `F_FLOCK` write lock on the open file description at open
104
+ time. libuv passes the flags through unchanged, adding only
105
+ `O_CLOEXEC`.
106
+ 2. `EAGAIN` or `EWOULDBLOCK` means the lock is held elsewhere. The open is
107
+ retried every 50 ms until the existing acquisition timeout
108
+ (`acquireTimeoutMs`, default 30 s, at most 120 s); then it fails with
109
+ `ownership_lock_busy`.
110
+ - An abort signal ends the loop with `operation_aborted`.
111
+ - Every other error, including `EOPNOTSUPP` or `ENOTSUP` on a filesystem
112
+ without locking, is `ownership_lock_unavailable`.
113
+ - A failed attempt returns no descriptor and cannot release another
114
+ holder's lock, because these are flock semantics on the open file
115
+ description.
116
+ 3. The rest is the Linux path, unchanged:
117
+ - `chmod 0600`;
118
+ - fsync of the file and its directory;
119
+ - the inode comparison between descriptor and path;
120
+ - the `afterLockOpen` test hook;
121
+ - `validateFence`;
122
+ - starting the guardian with the locked descriptor as fd 3;
123
+ - the second validation.
124
+ 4. `acquireSetupGuardian` derives the lock step from the platform:
125
+ - on Linux it runs the util-linux step;
126
+ - on macOS it skips it, because the caller passes a descriptor from
127
+ `openLockedDarwin` that is already locked.
128
+
129
+ There is no lock-mode parameter, so a caller cannot select the
130
+ already-locked mode for an unlocked descriptor by mistake. The guardian
131
+ start, IPC protocol and release are shared.
132
+
133
+ ### Process-group proof
134
+
135
+ **Linux:** the existing `/proc` scan moves unchanged into the platform module.
136
+ The two copies in `setup-process.ts` and `setup-command-guardian.ts` become
137
+ one.
138
+
139
+ **macOS:** one scan runs `/bin/ps -A -o pid=,pgid=,stat=`. The call:
140
+
141
+ - uses the absolute path;
142
+ - passes `isTrustedSystemBinary` before every call;
143
+ - runs with fixed arguments, an environment of only `LC_ALL=C`, a 2 s timeout
144
+ and a 1 MiB output cap;
145
+ - is not detached, so `ps` is never a member of the group under test.
146
+
147
+ `parsePsTable` accepts only lines that match `^\s*(\d+)\s+(\d+)\s+(\S+)\s*$`.
148
+ Any other line rejects the whole output.
149
+
150
+ The scan result is `unknown` if any of these holds:
151
+
152
+ - `ps` is untrusted, fails to start, times out or exits non-zero;
153
+ - the output exceeds the cap or fails to parse;
154
+ - the output does not contain the calling process's own PID.
155
+
156
+ Otherwise the result is `alive` if any entry has the requested process group
157
+ ID and a `stat` that does not start with `Z`, and `gone` if none does.
158
+
159
+ `readable()` is true only for a scan that is not `unknown`. The guardian sends
160
+ `ready` only after `readable()` succeeds, as it does today with `/proc`.
161
+
162
+ `kill(-pgid, 0)` is deliberately not used as proof. The XNU source treats
163
+ zombie-only groups inconsistently.
164
+
165
+ **Callers become asynchronous:**
166
+
167
+ - the process runner's `finishAfterGroup`;
168
+ - the MCP launcher's `kill`;
169
+ - the guardian's `groupState` loops.
170
+
171
+ Deadlines stay as they are (2 s report deadline, 1 s launcher cleanup). Only
172
+ the poll interval comes from `ProcessTable.pollMs`.
173
+
174
+ **Guardian loading.** The guardian is started as `.ts` in source-mode tests
175
+ (Node type stripping) and as `.js` in production. A static relative import
176
+ would fail in source mode. The guardian therefore loads the platform module
177
+ with a dynamic import that picks `./setup-platform.ts` or
178
+ `./setup-platform.js` next to itself. This is the same selection that
179
+ `setup-process.ts` already uses to locate the guardian. `setup-platform.js`
180
+ becomes a mandatory regular, non-symlink release artifact next to `main.js`
181
+ and `setup-command-guardian.js` in `src/setup.ts`. The ownership fingerprint
182
+ already binds every built runtime module.
183
+
184
+ ### Filesystem rule
185
+
186
+ **Linux:** unchanged magic-number allowlist.
187
+
188
+ **macOS:** the ownership directory, and the nearest existing ancestor that is
189
+ checked before creation, must have the same `statfs().type` as `/`. The
190
+ reasoning:
191
+
192
+ - The minimum macOS that Node.js 22 supports is macOS 11.
193
+ - From macOS 11 on, `/` is the sealed APFS system volume, so its type is
194
+ APFS in every case.
195
+ - `f_type` identifies a filesystem type for the running kernel, so equality
196
+ with `/` means APFS.
197
+ - APFS volumes are local.
198
+ - HFS+, exFAT, MS-DOS, SMB, NFS, AFP and every other type fail closed with
199
+ `ownership_filesystem_unsupported`.
200
+
201
+ Directory fsync uses the existing `FileHandle.sync()`. On macOS, libuv already
202
+ tries `F_FULLFSYNC`, then `F_BARRIERFSYNC`, then `fsync`. Whether this
203
+ succeeds on a directory descriptor on APFS is unverified, so a macOS CI test
204
+ asserts it. A failure fails closed.
205
+
206
+ ### Platform gates
207
+
208
+ `process.platform !== "linux"` becomes `currentSetupPlatform() === undefined`
209
+ in these places:
210
+
211
+ - `setup-main.ts`, before any client command;
212
+ - the process runner, which returns exit code 126;
213
+ - `acquireSetupGuardian`;
214
+ - `DurableOwnershipStore.withLock`.
215
+
216
+ ## Failure semantics
217
+
218
+ Public behavior is identical on both platforms:
219
+
220
+ - The stable codes are unchanged. The internal lock and filesystem errors are
221
+ `ownership_lock_busy`, `ownership_lock_unavailable` and
222
+ `ownership_filesystem_unsupported`. They never reach a report. When one
223
+ ends an ownership transaction, the CLI reports `operation_failed` with
224
+ exit 1, or `operation_interrupted` with exit 130 or 143 after a signal. This
225
+ is the same as on Linux. Failures inside a transaction keep their existing
226
+ public codes, such as `ownership_unavailable` and `cleanup_failed`.
227
+ - An `unknown` process-group state while the guardian holds the fence keeps
228
+ the fence and reports exit code 126 to the caller. Cleanup continues in the
229
+ detached guardian.
230
+ - Exit codes stay `0`, `1`, `64`, `130` and `143`.
231
+ - Results never contain paths, command arguments, command output or raw
232
+ errors. `ps` output never leaves the platform module.
233
+
234
+ ## Trust boundary and limits
235
+
236
+ These are recorded in `threat-model.md`; `client-setup-design.md` names the platform requirements:
237
+
238
+ - On macOS, the proof that no live group member remains rests on `/bin/ps`,
239
+ which sits on the sealed, SIP-protected system volume, together with the
240
+ uid and mode check.
241
+ - Tampering by the same UID with the stable lock path stays outside the
242
+ fence's trust boundary on both platforms.
243
+ - On both platforms, the fence does not cover descendants that escape the
244
+ process group, and it does not promise PID reaping.
245
+ - The macOS lock depends on the documented `O_EXLOCK` behavior of open(2):
246
+ flock(2) semantics, with `EOPNOTSUPP` on filesystems without locking.
247
+
248
+ ## Testing
249
+
250
+ **Unit tests, run on Linux and macOS with injected dependencies:**
251
+
252
+ - `parsePsTable`:
253
+ - a live group, a zombie-only group and a foreign group;
254
+ - a malformed line, an extra header line, empty output;
255
+ - a missing own PID, a non-zero exit, output over the cap.
256
+ - `isTrustedSystemBinary`: not root-owned, group-writable, world-writable,
257
+ not executable, not a regular file, missing. Like the existing `flock`
258
+ check, it follows symlinks, so Linux behavior is unchanged.
259
+ - macOS filesystem rule: same type, different type, and `statfs` failure.
260
+ - macOS lock loop:
261
+ - `EAGAIN` then success; persistent `EAGAIN` until the timeout gives
262
+ `ownership_lock_busy`;
263
+ - `EOPNOTSUPP` gives `ownership_lock_unavailable`;
264
+ - an abort during the wait gives `operation_aborted`;
265
+ - no descriptor leaks on any of these paths.
266
+ - `currentSetupPlatform`: `linux`, `darwin`, `win32`, `freebsd`.
267
+
268
+ **Integration tests:** their gate changes from `process.platform !== "linux"`
269
+ to "setup platform supported", so they also run on macOS:
270
+
271
+ - `test/setup-ownership.test.ts`: the durable store, including real lock
272
+ contention between processes;
273
+ - `test/setup-process.test.ts`: the runner terminates a real process group,
274
+ and fence retention on an indeterminate scan;
275
+ - `test/setup-main.test.ts`: the `SIGINT` and `SIGTERM` cleanup with exit
276
+ codes 130 and 143, and the stubborn process group;
277
+ - `test/setup-lifecycle.test.ts`: the corrupt durable ownership store.
278
+
279
+ **macOS-only tests:**
280
+
281
+ - `O_EXLOCK` semantics:
282
+ - a second non-blocking open is busy while the first descriptor is open;
283
+ - a child process that inherited the descriptor keeps the lock after the
284
+ parent closes its copy;
285
+ - the lock is free after the last close.
286
+ - Directory fsync on the test's temporary APFS directory succeeds.
287
+ - `os.tmpdir()` passes the macOS filesystem rule.
288
+
289
+ **Server lifecycle:** `test/process-fixture.ts` reads `/proc/<pid>/stat` and
290
+ `/proc/<pid>/cmdline`. It moves to a platform helper that uses `/proc` on
291
+ Linux and `/bin/ps -p <pid> -o stat=,command=` on macOS. Then
292
+ `test/process-stdin-eof.test.ts` also runs on macOS and proves that the server
293
+ exits on stdin EOF there. This is a separate commit.
294
+
295
+ **Load sensitivity:** no timeout is raised in advance. Flaky tests observed on
296
+ macOS runners are treated individually, with the cause recorded.
297
+
298
+ ## CI
299
+
300
+ The ruleset "Protect main" requires a status check named `verify`. A plain
301
+ matrix would rename it to `verify (macos-15)` and so on, and block every merge.
302
+ The workflow therefore has two jobs:
303
+
304
+ - **`test`:** a matrix over `os: [ubuntu-latest, macos-15, macos-26]` with
305
+ `fail-fast: false`, the same steps as today, and Node pinned to `22.19.0`.
306
+ - **`verify`:** depends on `test`, always runs, and passes only if every
307
+ platform passed. It keeps the required check name, so the ruleset does
308
+ not change.
309
+
310
+ Standard GitHub-hosted runners are free for this public repository. The Pi SDK
311
+ canary stays on Linux.
312
+
313
+ ## Documentation and governance
314
+
315
+ - **ADR 0005, "macOS as a supported setup platform":**
316
+ - It records that the B-type revisit trigger of ADR 0003 (repeated setup
317
+ friction) fired with #28.
318
+ - It records the chosen approach and the rejected alternatives:
319
+ - a `lockf(1)` helper with `/sbin/mount` parsing, rejected because the fd
320
+ mode of `lockf` on macOS 15 and the `mount` output format are
321
+ unverified;
322
+ - a weaker macOS profile without the kernel fence or the process-group
323
+ proof.
324
+ - It sets the condition that macOS counts as accepted only after Level-2
325
+ acceptance on real hardware.
326
+ - Its revisit triggers: a change in the `ps` output format, in `O_EXLOCK`
327
+ semantics, or in the minimum macOS version that Node supports.
328
+ - **`AGENTS.md`:** the rule "Mutating setup is Linux-only and requires …"
329
+ becomes a rule for Linux or macOS, each with its platform's requirements.
330
+ This change to an agent rule is called out in the PR.
331
+ - **`docs/client-setup-design.md` and `docs/threat-model.md`:** platform
332
+ requirements and the trust boundary for both platforms.
333
+ - **`docs/installation.md`:**
334
+ - `setup` on macOS;
335
+ - "Register without setup" stays as a general alternative, without a macOS
336
+ special case.
337
+ - **`README.md`:** the Status line names Linux and macOS, the macOS CI
338
+ versions, Intel as untested, and the acceptance status.
339
+ - **`docs/acceptance.md`:** the macOS Level-2 checklist and its evidence
340
+ format.
341
+ - **`CHANGELOG.md`:** an Unreleased entry.
342
+
343
+ ## Acceptance on real hardware
344
+
345
+ A Level-2 run on the owner's Mac, from the PR branch build, records:
346
+
347
+ - **Environment:** macOS version, architecture, Node version, Claude Code and
348
+ Codex versions.
349
+ - **Before mutating:** `doctor`, and `setup --dry-run` for
350
+ `claude-code:user:pi-session-mcp` and `codex:user:pi-session-mcp`.
351
+ - **The lifecycle, for each target:** `--apply`, `--verify`, `--rollback`,
352
+ then `--apply` and `--remove`.
353
+ - **Lock contention:** two `--apply` runs for the same target, started in
354
+ parallel.
355
+ - The second waits for the first, because the acquisition timeout is 30 s.
356
+ - It then reports the registration as already equivalent.
357
+ - Both exit with 0, and the ownership record stays readable.
358
+ - A waiting run that reports `operation_failed` with exit 1 did not get the
359
+ lock within the timeout.
360
+ - **Interruption:** Ctrl-C during `--apply` gives exit code 130, leaves no
361
+ process from the client command's group behind, and a following run
362
+ reconciles.
363
+ - **Evidence:** exit codes and the sanitized human and JSON results. No paths,
364
+ environment values or secrets are recorded.
365
+
366
+ The evidence is committed to the same PR before merge.
367
+
368
+ ## Delivery
369
+
370
+ One branch, `feat/macos-setup`, and one PR referencing #32. Separate commits,
371
+ in this order:
372
+
373
+ 1. platform module;
374
+ 2. macOS lock;
375
+ 3. process table and guardian;
376
+ 4. filesystem rule;
377
+ 5. server lifecycle fixture;
378
+ 6. CI matrix;
379
+ 7. documentation, ADR 0005 and `AGENTS.md`;
380
+ 8. acceptance evidence.
381
+
382
+ The merge requires all of the following:
383
+
384
+ - green CI on all three runners;
385
+ - reviews without open findings;
386
+ - the owner's evidence from the Mac.