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.
- package/.pi-session-mcp-cli/config.js +11 -1
- package/.pi-session-mcp-cli/doctor.js +22 -1
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/extension-spec.js +26 -0
- package/.pi-session-mcp-cli/pi-extensions.js +67 -0
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +5 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -1
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +22 -1
- package/dist/execution-profile.d.ts +3 -0
- package/dist/execution-profile.js +1 -0
- package/dist/extension-spec.d.ts +15 -0
- package/dist/extension-spec.js +26 -0
- package/dist/main.js +6 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +4 -0
- package/dist/pi-extensions.d.ts +17 -0
- package/dist/pi-extensions.js +67 -0
- package/dist/sdk-pi-adapter.d.ts +24 -6
- package/dist/sdk-pi-adapter.js +256 -86
- package/dist/server.js +1 -0
- package/dist/session-tools.d.ts +9 -0
- package/dist/session-tools.js +41 -0
- package/dist/setup-command-guardian.js +29 -73
- package/dist/setup-main.js +2 -1
- package/dist/setup-ownership.js +18 -28
- package/dist/setup-platform.d.ts +57 -0
- package/dist/setup-platform.js +195 -0
- package/dist/setup-process.d.ts +6 -3
- package/dist/setup-process.js +85 -93
- package/dist/setup.js +2 -1
- package/dist/stdout-guard-install.d.ts +5 -0
- package/dist/stdout-guard-install.js +6 -0
- package/dist/stdout-guard.d.ts +20 -0
- package/dist/stdout-guard.js +72 -0
- package/docs/acceptance.md +343 -0
- package/docs/adr/0005-macos-setup-platform.md +84 -0
- package/docs/adr/0006-pi-extension-allowlist.md +58 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +21 -9
- package/docs/client-setup.md +25 -10
- package/docs/doctor-design.md +7 -2
- package/docs/installation.md +125 -4
- package/docs/macos-setup-design.md +386 -0
- package/docs/pi-extensions-design.md +437 -0
- package/docs/releasing.md +16 -7
- package/docs/threat-model.md +54 -18
- package/docs/tool-contracts.md +8 -3
- package/package.json +2 -1
package/docs/doctor-design.md
CHANGED
|
@@ -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
|
|
13
|
-
configuration values, errors,
|
|
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
|
package/docs/installation.md
CHANGED
|
@@ -35,19 +35,25 @@ the same arguments.
|
|
|
35
35
|
|
|
36
36
|
## Requirements
|
|
37
37
|
|
|
38
|
-
- Linux. `setup` fails closed on every other platform
|
|
39
|
-
|
|
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
|
|
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.
|
|
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.
|