pi-session-mcp 0.6.4 → 0.7.0

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 (58) 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 +21 -0
  5. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  6. package/CHANGELOG.md +37 -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 +21 -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 +253 -86
  28. package/dist/server.js +1 -0
  29. package/dist/session-tools.d.ts +9 -0
  30. package/dist/session-tools.js +25 -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 +216 -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 +122 -4
  51. package/docs/macos-setup-design.md +386 -0
  52. package/docs/pi-extensions-design.md +421 -0
  53. package/docs/releasing.md +1 -1
  54. package/docs/superpowers/plans/2026-10-10-macos-setup.md +1725 -0
  55. package/docs/superpowers/plans/2026-10-11-pi-extensions.md +2450 -0
  56. package/docs/threat-model.md +54 -18
  57. package/docs/tool-contracts.md +8 -3
  58. package/package.json +1 -1
@@ -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.