pi-session-mcp 0.6.3 → 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.
- package/.pi-session-mcp-cli/config.js +11 -1
- package/.pi-session-mcp-cli/doctor-cli.js +5 -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 +21 -0
- package/.pi-session-mcp-cli/pi-extensions.js +67 -0
- package/CHANGELOG.md +54 -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-cli.js +5 -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 +21 -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 +253 -86
- package/dist/server.js +1 -0
- package/dist/session-tools.d.ts +9 -0
- package/dist/session-tools.js +25 -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 +245 -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 +56 -10
- package/docs/doctor-design.md +9 -2
- package/docs/installation.md +129 -4
- package/docs/macos-setup-design.md +386 -0
- package/docs/pi-extensions-design.md +421 -0
- package/docs/releasing.md +1 -1
- package/docs/superpowers/plans/2026-10-10-macos-setup.md +1725 -0
- package/docs/superpowers/plans/2026-10-11-pi-extensions.md +2450 -0
- package/docs/threat-model.md +54 -18
- package/docs/tool-contracts.md +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,1725 @@
|
|
|
1
|
+
# macOS Setup Platform Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** The `pi-session-mcp-setup` lifecycle (`--dry-run`, `--apply`,
|
|
6
|
+
`--verify`, `--rollback`, `--remove`) works on macOS with the same fence
|
|
7
|
+
guarantees as on Linux.
|
|
8
|
+
|
|
9
|
+
**Architecture:** A new module, `src/setup-platform.ts`, owns every
|
|
10
|
+
operating-system fact behind the setup fence:
|
|
11
|
+
|
|
12
|
+
- platform detection;
|
|
13
|
+
- a process table, from `/proc` on Linux and the trusted `/bin/ps` on macOS;
|
|
14
|
+
- the filesystem rule;
|
|
15
|
+
- the macOS lock, taken with `O_EXLOCK` at open time.
|
|
16
|
+
|
|
17
|
+
The runner, the MCP launcher, the guardian and the ownership store call this
|
|
18
|
+
module instead of reading `/proc` or `statfs` magic numbers themselves. Linux
|
|
19
|
+
behavior is unchanged.
|
|
20
|
+
|
|
21
|
+
**Tech Stack:** Node.js >= 22.19.0, TypeScript (strict, ESM, NodeNext),
|
|
22
|
+
Vitest 5, GitHub Actions.
|
|
23
|
+
|
|
24
|
+
**Spec:** `docs/macos-setup-design.md`. Tracking issue: #32.
|
|
25
|
+
|
|
26
|
+
## Global Constraints
|
|
27
|
+
|
|
28
|
+
- Node.js 22.19.0 or newer, TypeScript strict mode, ESM, and `.js` suffixes in
|
|
29
|
+
every static relative import.
|
|
30
|
+
- Do not add, remove or change any dependency.
|
|
31
|
+
- `src/setup-platform.ts`, `src/setup-process.ts` and
|
|
32
|
+
`src/setup-command-guardian.ts` are loaded by Node type stripping in
|
|
33
|
+
source-mode tests. They may use only erasable TypeScript syntax:
|
|
34
|
+
- no `enum`, `namespace` or parameter properties;
|
|
35
|
+
- type-only imports written as `import type`;
|
|
36
|
+
- no static relative runtime imports in `setup-process.ts` or the guardian
|
|
37
|
+
(only `import type`).
|
|
38
|
+
- No new public result codes, statuses or exit codes. Exit codes stay 0, 1,
|
|
39
|
+
64, 130 and 143. Results never contain paths, command arguments, command
|
|
40
|
+
output or raw errors.
|
|
41
|
+
- Every platform other than `linux` and `darwin` fails closed with
|
|
42
|
+
`platform_unsupported` before any client command is spawned.
|
|
43
|
+
- Linux behavior stays byte-for-byte equivalent:
|
|
44
|
+
- the util-linux `flock -E 75 -w N 3` helper;
|
|
45
|
+
- the `/proc` scan semantics;
|
|
46
|
+
- the filesystem allowlist;
|
|
47
|
+
- the poll intervals (10 ms in the runner, 25 ms in the guardian).
|
|
48
|
+
- macOS constants, from XNU `bsd/sys/fcntl.h`: `O_NONBLOCK = 0x4`,
|
|
49
|
+
`O_EXLOCK = 0x20`.
|
|
50
|
+
- The macOS process scan is `/bin/ps -A -o pid=,pgid=,stat=`. It runs with:
|
|
51
|
+
- an environment of only `{ LC_ALL: "C" }`;
|
|
52
|
+
- a 2 000 ms timeout;
|
|
53
|
+
- a 1 048 576-byte output cap;
|
|
54
|
+
- a poll interval of 50 ms.
|
|
55
|
+
- The macOS lock retries every 50 ms. The acquisition bound is clamped to
|
|
56
|
+
1 000–120 000 ms, the same clamp as the Linux `flock -w`.
|
|
57
|
+
- Tracked documentation is written in English.
|
|
58
|
+
- One branch (`feat/macos-setup`), one PR, separate commits per task. Every
|
|
59
|
+
commit ends with:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Refs #32
|
|
63
|
+
|
|
64
|
+
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
65
|
+
Claude-Session: https://claude.ai/code/session_01DZuLc1Pue1cyQxuzpxdGK1
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- Quality gate before every commit: `npm run typecheck`, `npm test`,
|
|
69
|
+
`npm run build`. All must pass on Linux.
|
|
70
|
+
|
|
71
|
+
## Review Focus
|
|
72
|
+
|
|
73
|
+
1. **`ps` reports a zombie with suffix flags** (`Z+`, `Zs`) in the group under
|
|
74
|
+
test. It must count as gone, not alive; otherwise the fence is held
|
|
75
|
+
forever. Pinned in Task 1 (`parsePsTable` and macOS table tests).
|
|
76
|
+
2. **The ownership lock path is a symlink.** Setup must fail closed with
|
|
77
|
+
`ownership_lock_unavailable` on both platforms (`O_NOFOLLOW`). Pinned in
|
|
78
|
+
Task 3 ("refuses a symlinked lock file").
|
|
79
|
+
3. **Ctrl-C while another setup holds the lock.** The waiting run must stop
|
|
80
|
+
promptly with `operation_aborted` instead of waiting out the 30 s bound.
|
|
81
|
+
Pinned in Task 3 ("stops waiting for a held lock when aborted") and Task 1
|
|
82
|
+
(abort unit tests).
|
|
83
|
+
4. **The lock file is replaced after it was opened.** The fence must reject
|
|
84
|
+
it on macOS too, because macOS has no waiting window with an open
|
|
85
|
+
descriptor. Pinned in Task 3 ("rejects a lock inode replaced after the
|
|
86
|
+
lock file was opened").
|
|
87
|
+
5. **`/bin/ps` is missing, untrusted, failing, malformed or does not list the
|
|
88
|
+
caller.** The scan must be `unknown`, the guardian must not report ready,
|
|
89
|
+
and an active fence must be retained. Pinned in Task 1 (macOS table
|
|
90
|
+
tests); Task 2 keeps the guardian's ready path behind `readable()`.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### Task 1: Platform module
|
|
95
|
+
|
|
96
|
+
**Files:**
|
|
97
|
+
- Create: `src/setup-platform.ts`
|
|
98
|
+
- Test: `test/setup-platform.test.ts`
|
|
99
|
+
|
|
100
|
+
**Interfaces:**
|
|
101
|
+
- Consumes: nothing from other tasks.
|
|
102
|
+
- Produces (exact exports):
|
|
103
|
+
- `type SetupPlatform = "linux" | "darwin"`
|
|
104
|
+
- `type ProcessGroupState = "alive" | "gone" | "unknown"`
|
|
105
|
+
- `interface ProcessEntry { readonly pid: number; readonly group: number; readonly state: string }`
|
|
106
|
+
- `interface ProcessTable { groupState(group: number): Promise<ProcessGroupState>; readable(): Promise<boolean>; readonly pollMs: number }`
|
|
107
|
+
- `interface PsOutput { readonly exitCode: number; readonly stdout: string }`
|
|
108
|
+
- `interface ProcessTableDependencies { readonly runPs?: () => Promise<PsOutput | undefined>; readonly selfPid?: number }`
|
|
109
|
+
- `currentSetupPlatform(platform?: NodeJS.Platform): SetupPlatform | undefined`
|
|
110
|
+
- `isTrustedSystemBinary(path: string, statFile?: (path: string) => Pick<Stats, "isFile" | "uid" | "mode">): boolean`
|
|
111
|
+
- `parsePsTable(output: string): readonly ProcessEntry[] | undefined`
|
|
112
|
+
- `createProcessTable(platform: SetupPlatform, dependencies?: ProcessTableDependencies): ProcessTable`
|
|
113
|
+
- `type StatfsReader = (path: string) => Promise<{ readonly type: number }>`
|
|
114
|
+
- `assertSupportedFilesystem(path: string, platform: SetupPlatform, readStatfs?: StatfsReader): Promise<void>`
|
|
115
|
+
- `DARWIN_O_NONBLOCK = 0x4`, `DARWIN_O_EXLOCK = 0x20`, `DARWIN_PS_PATH = "/bin/ps"`
|
|
116
|
+
- `interface DarwinLockOptions { readonly timeoutMs: number; readonly signal?: AbortSignal; readonly retryMs?: number; readonly openFile?: (path: string, flags: number, mode: number) => Promise<FileHandle> }`
|
|
117
|
+
- `openLockedDarwin(path: string, options: DarwinLockOptions): Promise<FileHandle>`
|
|
118
|
+
|
|
119
|
+
- [ ] **Step 1: Write the failing tests**
|
|
120
|
+
|
|
121
|
+
Create `test/setup-platform.test.ts`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { spawn } from "node:child_process";
|
|
125
|
+
import { once } from "node:events";
|
|
126
|
+
import { constants } from "node:fs";
|
|
127
|
+
import { open, type FileHandle } from "node:fs/promises";
|
|
128
|
+
import { join } from "node:path";
|
|
129
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
130
|
+
import { removeTemporaryRoots, temporaryRoot } from "./temporary-roots.js";
|
|
131
|
+
import {
|
|
132
|
+
assertSupportedFilesystem, createProcessTable, currentSetupPlatform, DARWIN_O_EXLOCK, DARWIN_O_NONBLOCK,
|
|
133
|
+
isTrustedSystemBinary, openLockedDarwin, parsePsTable,
|
|
134
|
+
} from "../src/setup-platform.js";
|
|
135
|
+
|
|
136
|
+
afterAll(removeTemporaryRoots);
|
|
137
|
+
|
|
138
|
+
describe("setup platform detection", () => {
|
|
139
|
+
it.each([["linux", "linux"], ["darwin", "darwin"], ["win32", undefined], ["freebsd", undefined]] as const)("maps %s to %s", (input, expected) => {
|
|
140
|
+
expect(currentSetupPlatform(input)).toBe(expected);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("trusted system binary", () => {
|
|
145
|
+
const stats = (overrides: Partial<{ isFile: () => boolean; uid: number; mode: number }>) => ({ isFile: () => true, uid: 0, mode: 0o100755, ...overrides });
|
|
146
|
+
it("accepts a root-owned executable that only root can write", () => {
|
|
147
|
+
expect(isTrustedSystemBinary("/bin/ps", () => stats({}))).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
it.each([
|
|
150
|
+
["not root-owned", { uid: 501 }],
|
|
151
|
+
["group-writable", { mode: 0o100775 }],
|
|
152
|
+
["world-writable", { mode: 0o100757 }],
|
|
153
|
+
["not executable", { mode: 0o100644 }],
|
|
154
|
+
["not a regular file", { isFile: () => false }],
|
|
155
|
+
] as const)("rejects a binary that is %s", (_name, overrides) => {
|
|
156
|
+
expect(isTrustedSystemBinary("/bin/ps", () => stats(overrides))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
it("rejects a missing binary", () => {
|
|
159
|
+
expect(isTrustedSystemBinary("/bin/ps", () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); })).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("ps table parser", () => {
|
|
164
|
+
it("parses pid, process group and state, including zombie suffixes", () => {
|
|
165
|
+
expect(parsePsTable(" 1 1 Ss\n 4242 4242 S+\n 4243 4242 Z+\n 4244 4242 Zs\n")).toEqual([
|
|
166
|
+
{ pid: 1, group: 1, state: "Ss" }, { pid: 4242, group: 4242, state: "S+" },
|
|
167
|
+
{ pid: 4243, group: 4242, state: "Z+" }, { pid: 4244, group: 4242, state: "Zs" },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
it.each([
|
|
171
|
+
["empty output", ""],
|
|
172
|
+
["a header line", " PID PGID STAT\n 1 1 Ss\n"],
|
|
173
|
+
["a missing column", " 1 1\n"],
|
|
174
|
+
["an extra column", " 1 1 Ss extra\n"],
|
|
175
|
+
["a blank line inside", " 1 1 Ss\n\n 2 2 S\n"],
|
|
176
|
+
["a negative pid", " -1 1 S\n"],
|
|
177
|
+
])("rejects %s", (_name, text) => {
|
|
178
|
+
expect(parsePsTable(text)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("macOS process table", () => {
|
|
183
|
+
const table = (stdout: string | undefined, exitCode = 0) => createProcessTable("darwin", {
|
|
184
|
+
selfPid: 100,
|
|
185
|
+
runPs: async () => stdout === undefined ? undefined : { exitCode, stdout },
|
|
186
|
+
});
|
|
187
|
+
it("reports a group with a live member as alive", async () => {
|
|
188
|
+
expect(await table(" 100 100 S\n 200 200 S\n 201 200 Z\n").groupState(200)).toBe("alive");
|
|
189
|
+
});
|
|
190
|
+
it("reports a group whose members are all zombies as gone", async () => {
|
|
191
|
+
expect(await table(" 100 100 S\n 201 200 Z\n 202 200 Z+\n 203 200 Zs\n").groupState(200)).toBe("gone");
|
|
192
|
+
});
|
|
193
|
+
it("reports a group without members as gone", async () => {
|
|
194
|
+
expect(await table(" 100 100 S\n 300 300 S\n").groupState(200)).toBe("gone");
|
|
195
|
+
});
|
|
196
|
+
it.each([
|
|
197
|
+
["ps could not run", undefined, 0],
|
|
198
|
+
["ps exited non-zero", " 100 100 S\n", 1],
|
|
199
|
+
["the output is malformed", " 100 100 S\ngarbage\n", 0],
|
|
200
|
+
["the caller is missing", " 300 300 S\n", 0],
|
|
201
|
+
] as const)("is unknown and unreadable when %s", async (_name, stdout, exitCode) => {
|
|
202
|
+
const subject = table(stdout, exitCode);
|
|
203
|
+
expect(await subject.groupState(200)).toBe("unknown");
|
|
204
|
+
expect(await subject.readable()).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
it("is readable when the caller appears", async () => {
|
|
207
|
+
expect(await table(" 100 100 S\n").readable()).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
it("polls at 50 ms on macOS and 10 ms on Linux", () => {
|
|
210
|
+
expect(table("").pollMs).toBe(50);
|
|
211
|
+
expect(createProcessTable("linux").pollMs).toBe(10);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe.skipIf(!currentSetupPlatform())("live process table", () => {
|
|
216
|
+
it("is readable and tracks a detached child's process group", async () => {
|
|
217
|
+
const subject = createProcessTable(currentSetupPlatform()!);
|
|
218
|
+
expect(await subject.readable()).toBe(true);
|
|
219
|
+
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { detached: true, stdio: "ignore" });
|
|
220
|
+
const closed = once(child, "close");
|
|
221
|
+
try {
|
|
222
|
+
await once(child, "spawn");
|
|
223
|
+
expect(await subject.groupState(child.pid!)).toBe("alive");
|
|
224
|
+
} finally {
|
|
225
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
226
|
+
await closed;
|
|
227
|
+
}
|
|
228
|
+
expect(await subject.groupState(child.pid!)).toBe("gone");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("filesystem rule", () => {
|
|
233
|
+
const reader = (types: Record<string, number>) => async (path: string) => ({ type: types[path]! });
|
|
234
|
+
it("accepts an ext4 directory on Linux", async () => {
|
|
235
|
+
await expect(assertSupportedFilesystem("/state", "linux", reader({ "/state": 0xef53 }))).resolves.toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
it("rejects NFS on Linux", async () => {
|
|
238
|
+
await expect(assertSupportedFilesystem("/state", "linux", reader({ "/state": 0x6969 }))).rejects.toThrow("ownership_filesystem_unsupported");
|
|
239
|
+
});
|
|
240
|
+
it("accepts a macOS directory with the filesystem type of the system volume", async () => {
|
|
241
|
+
await expect(assertSupportedFilesystem("/state", "darwin", reader({ "/": 26, "/state": 26 }))).resolves.toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
it("rejects a macOS directory on another filesystem type", async () => {
|
|
244
|
+
await expect(assertSupportedFilesystem("/state", "darwin", reader({ "/": 26, "/state": 28 }))).rejects.toThrow("ownership_filesystem_unsupported");
|
|
245
|
+
});
|
|
246
|
+
it("propagates a statfs failure", async () => {
|
|
247
|
+
const failure = Object.assign(new Error("statfs failed"), { code: "EIO" });
|
|
248
|
+
await expect(assertSupportedFilesystem("/state", "darwin", async () => { throw failure; })).rejects.toBe(failure);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("macOS lock acquisition", () => {
|
|
253
|
+
const handle = { close: async () => undefined } as unknown as FileHandle;
|
|
254
|
+
const failure = (code: string) => Object.assign(new Error(code), { code });
|
|
255
|
+
it("opens once with an exclusive, non-blocking, no-follow lock", async () => {
|
|
256
|
+
let seen = 0;
|
|
257
|
+
const result = await openLockedDarwin("/lock", { timeoutMs: 1_000, openFile: async (_path, flags, mode) => { seen = flags; expect(mode).toBe(0o600); return handle; } });
|
|
258
|
+
expect(result).toBe(handle);
|
|
259
|
+
for (const flag of [DARWIN_O_EXLOCK, DARWIN_O_NONBLOCK, constants.O_CREAT, constants.O_RDWR, constants.O_NOFOLLOW]) expect(seen & flag).toBe(flag);
|
|
260
|
+
});
|
|
261
|
+
it("retries a busy lock until it is free", async () => {
|
|
262
|
+
let calls = 0;
|
|
263
|
+
const result = await openLockedDarwin("/lock", { timeoutMs: 1_000, retryMs: 1, openFile: async () => { calls += 1; if (calls < 3) throw failure("EAGAIN"); return handle; } });
|
|
264
|
+
expect(result).toBe(handle);
|
|
265
|
+
expect(calls).toBe(3);
|
|
266
|
+
});
|
|
267
|
+
it.each(["EAGAIN", "EWOULDBLOCK"])("reports %s past the bound as busy", async (code) => {
|
|
268
|
+
const started = Date.now();
|
|
269
|
+
await expect(openLockedDarwin("/lock", { timeoutMs: 1_000, retryMs: 5, openFile: async () => { throw failure(code); } })).rejects.toThrow("ownership_lock_busy");
|
|
270
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(1_000);
|
|
271
|
+
});
|
|
272
|
+
it.each(["EOPNOTSUPP", "ENOTSUP", "EACCES", "ELOOP"])("reports %s as unavailable without retrying", async (code) => {
|
|
273
|
+
let calls = 0;
|
|
274
|
+
await expect(openLockedDarwin("/lock", { timeoutMs: 1_000, openFile: async () => { calls += 1; throw failure(code); } })).rejects.toThrow("ownership_lock_unavailable");
|
|
275
|
+
expect(calls).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
it("stops waiting when aborted", async () => {
|
|
278
|
+
const controller = new AbortController();
|
|
279
|
+
const started = Date.now();
|
|
280
|
+
const pending = openLockedDarwin("/lock", { timeoutMs: 10_000, retryMs: 1_000, signal: controller.signal, openFile: async () => { throw failure("EAGAIN"); } });
|
|
281
|
+
setTimeout(() => controller.abort(), 20);
|
|
282
|
+
await expect(pending).rejects.toThrow("operation_aborted");
|
|
283
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
284
|
+
});
|
|
285
|
+
it("does not open after an abort", async () => {
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
controller.abort();
|
|
288
|
+
let calls = 0;
|
|
289
|
+
await expect(openLockedDarwin("/lock", { timeoutMs: 1_000, signal: controller.signal, openFile: async () => { calls += 1; return handle; } })).rejects.toThrow("operation_aborted");
|
|
290
|
+
expect(calls).toBe(0);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe.skipIf(process.platform !== "darwin")("macOS kernel lock semantics", () => {
|
|
295
|
+
it("excludes a second open file description until the first closes", async () => {
|
|
296
|
+
const path = join(await temporaryRoot("pi-session-mcp-exlock-"), "ownership.json.flock");
|
|
297
|
+
const first = await openLockedDarwin(path, { timeoutMs: 1_000 });
|
|
298
|
+
try {
|
|
299
|
+
await expect(openLockedDarwin(path, { timeoutMs: 1_000 })).rejects.toThrow("ownership_lock_busy");
|
|
300
|
+
} finally { await first.close(); }
|
|
301
|
+
const again = await openLockedDarwin(path, { timeoutMs: 1_000 });
|
|
302
|
+
await again.close();
|
|
303
|
+
});
|
|
304
|
+
it("keeps the lock while a child holds the inherited descriptor", async () => {
|
|
305
|
+
const path = join(await temporaryRoot("pi-session-mcp-exlock-child-"), "ownership.json.flock");
|
|
306
|
+
const first = await openLockedDarwin(path, { timeoutMs: 1_000 });
|
|
307
|
+
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { stdio: ["ignore", "ignore", "ignore", first.fd] });
|
|
308
|
+
const closed = once(child, "close");
|
|
309
|
+
try {
|
|
310
|
+
await once(child, "spawn");
|
|
311
|
+
await first.close();
|
|
312
|
+
await expect(openLockedDarwin(path, { timeoutMs: 1_000 })).rejects.toThrow("ownership_lock_busy");
|
|
313
|
+
} finally {
|
|
314
|
+
child.kill("SIGKILL");
|
|
315
|
+
await closed;
|
|
316
|
+
}
|
|
317
|
+
const again = await openLockedDarwin(path, { timeoutMs: 1_000 });
|
|
318
|
+
await again.close();
|
|
319
|
+
});
|
|
320
|
+
it("fsyncs a directory on the temporary volume", async () => {
|
|
321
|
+
const handle = await open(await temporaryRoot("pi-session-mcp-dirsync-"), "r");
|
|
322
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
323
|
+
});
|
|
324
|
+
it("accepts the temporary directory under the filesystem rule", async () => {
|
|
325
|
+
await expect(assertSupportedFilesystem(await temporaryRoot("pi-session-mcp-fsrule-"), "darwin")).resolves.toBeUndefined();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
331
|
+
|
|
332
|
+
Run: `npx vitest run test/setup-platform.test.ts`
|
|
333
|
+
Expected: FAIL. The module `../src/setup-platform.js` cannot be resolved.
|
|
334
|
+
|
|
335
|
+
- [ ] **Step 3: Implement `src/setup-platform.ts`**
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import { execFile } from "node:child_process";
|
|
339
|
+
import { constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
340
|
+
import type { Stats } from "node:fs";
|
|
341
|
+
import { open, statfs } from "node:fs/promises";
|
|
342
|
+
import type { FileHandle } from "node:fs/promises";
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Operating-system facts behind the setup fence. This module has no relative
|
|
346
|
+
* imports: the transaction guardian and source-mode harnesses load it through
|
|
347
|
+
* Node type stripping, which cannot map a `.js` specifier to its `.ts` source.
|
|
348
|
+
*/
|
|
349
|
+
export type SetupPlatform = "linux" | "darwin";
|
|
350
|
+
export type ProcessGroupState = "alive" | "gone" | "unknown";
|
|
351
|
+
export interface ProcessEntry { readonly pid: number; readonly group: number; readonly state: string; }
|
|
352
|
+
export interface ProcessTable {
|
|
353
|
+
/** "alive" if any non-zombie process has this process group ID. */
|
|
354
|
+
groupState(group: number): Promise<ProcessGroupState>;
|
|
355
|
+
/** True only if a scan is determinate and contains the calling process. */
|
|
356
|
+
readable(): Promise<boolean>;
|
|
357
|
+
/** Poll interval for cleanup loops. */
|
|
358
|
+
readonly pollMs: number;
|
|
359
|
+
}
|
|
360
|
+
export interface PsOutput { readonly exitCode: number; readonly stdout: string; }
|
|
361
|
+
export interface ProcessTableDependencies {
|
|
362
|
+
/** Test seam for the macOS `ps` call; undefined means it could not run. */
|
|
363
|
+
readonly runPs?: () => Promise<PsOutput | undefined>;
|
|
364
|
+
readonly selfPid?: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function currentSetupPlatform(platform: NodeJS.Platform = process.platform): SetupPlatform | undefined {
|
|
368
|
+
return platform === "linux" || platform === "darwin" ? platform : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A regular, root-owned executable that no group or other user can replace. */
|
|
372
|
+
export function isTrustedSystemBinary(path: string, statFile: (path: string) => Pick<Stats, "isFile" | "uid" | "mode"> = statSync): boolean {
|
|
373
|
+
try {
|
|
374
|
+
const file = statFile(path);
|
|
375
|
+
return file.isFile() && file.uid === 0 && (file.mode & 0o022) === 0 && (file.mode & 0o111) !== 0;
|
|
376
|
+
} catch { return false; }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function readLinuxProcessGroup(pid: number): { readonly group: number; readonly state: string } {
|
|
380
|
+
const line = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
|
381
|
+
const close = line.lastIndexOf(") ");
|
|
382
|
+
const state = line[close + 2];
|
|
383
|
+
if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ") throw new Error("process_stat_invalid");
|
|
384
|
+
const fields = line.slice(close + 4).trim().split(/\s+/);
|
|
385
|
+
const group = Number(fields[1]);
|
|
386
|
+
if (!Number.isSafeInteger(group) || group < 0) throw new Error("process_group_invalid");
|
|
387
|
+
return { group, state };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function linuxGroupState(group: number): ProcessGroupState {
|
|
391
|
+
let entries;
|
|
392
|
+
try { entries = readdirSync("/proc", { withFileTypes: true }); }
|
|
393
|
+
catch { return "unknown"; }
|
|
394
|
+
for (const entry of entries) {
|
|
395
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
396
|
+
try {
|
|
397
|
+
const sample = readLinuxProcessGroup(Number(entry.name));
|
|
398
|
+
if (sample.group === group && sample.state !== "Z") return "alive";
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return "unknown";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return "gone";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function linuxTableReadable(selfPid: number): boolean {
|
|
407
|
+
let sawSelf = false;
|
|
408
|
+
try {
|
|
409
|
+
for (const entry of readdirSync("/proc", { withFileTypes: true })) {
|
|
410
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
411
|
+
try {
|
|
412
|
+
const pid = Number(entry.name);
|
|
413
|
+
readLinuxProcessGroup(pid);
|
|
414
|
+
if (pid === selfPid) sawSelf = true;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch { return false; }
|
|
420
|
+
return sawSelf;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export const DARWIN_PS_PATH = "/bin/ps";
|
|
424
|
+
const PS_ARGUMENTS = ["-A", "-o", "pid=,pgid=,stat="] as const;
|
|
425
|
+
const PS_TIMEOUT_MS = 2_000;
|
|
426
|
+
const PS_MAX_OUTPUT_BYTES = 1_048_576;
|
|
427
|
+
|
|
428
|
+
/** Parses `ps -A -o pid=,pgid=,stat=`; any unexpected line rejects the whole sample. */
|
|
429
|
+
export function parsePsTable(output: string): readonly ProcessEntry[] | undefined {
|
|
430
|
+
const body = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
431
|
+
if (body.length === 0) return undefined;
|
|
432
|
+
const entries: ProcessEntry[] = [];
|
|
433
|
+
for (const line of body.split("\n")) {
|
|
434
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
|
|
435
|
+
if (!match) return undefined;
|
|
436
|
+
const pid = Number(match[1]);
|
|
437
|
+
const group = Number(match[2]);
|
|
438
|
+
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(group)) return undefined;
|
|
439
|
+
entries.push({ pid, group, state: match[3]! });
|
|
440
|
+
}
|
|
441
|
+
return entries;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function runDarwinPs(): Promise<PsOutput | undefined> {
|
|
445
|
+
if (!isTrustedSystemBinary(DARWIN_PS_PATH)) return Promise.resolve(undefined);
|
|
446
|
+
return new Promise((resolve) => {
|
|
447
|
+
try {
|
|
448
|
+
execFile(DARWIN_PS_PATH, [...PS_ARGUMENTS], { env: { LC_ALL: "C" }, timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_OUTPUT_BYTES, encoding: "utf8" }, (error, stdout) => {
|
|
449
|
+
// A non-zero exit, timeout, output overflow or spawn failure is indeterminate.
|
|
450
|
+
resolve(error ? undefined : { exitCode: 0, stdout });
|
|
451
|
+
});
|
|
452
|
+
} catch { resolve(undefined); }
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function createProcessTable(platform: SetupPlatform, dependencies: ProcessTableDependencies = {}): ProcessTable {
|
|
457
|
+
const selfPid = dependencies.selfPid ?? process.pid;
|
|
458
|
+
if (platform === "linux") {
|
|
459
|
+
return { pollMs: 10, groupState: async (group) => linuxGroupState(group), readable: async () => linuxTableReadable(selfPid) };
|
|
460
|
+
}
|
|
461
|
+
const runPs = dependencies.runPs ?? runDarwinPs;
|
|
462
|
+
const scan = async (): Promise<readonly ProcessEntry[] | undefined> => {
|
|
463
|
+
const output = await runPs();
|
|
464
|
+
if (!output || output.exitCode !== 0) return undefined;
|
|
465
|
+
const entries = parsePsTable(output.stdout);
|
|
466
|
+
// A sample without the caller cannot be a complete process table.
|
|
467
|
+
return entries?.some((entry) => entry.pid === selfPid) ? entries : undefined;
|
|
468
|
+
};
|
|
469
|
+
return {
|
|
470
|
+
pollMs: 50,
|
|
471
|
+
async groupState(group) {
|
|
472
|
+
const entries = await scan();
|
|
473
|
+
if (!entries) return "unknown";
|
|
474
|
+
return entries.some((entry) => entry.group === group && !entry.state.startsWith("Z")) ? "alive" : "gone";
|
|
475
|
+
},
|
|
476
|
+
async readable() { return (await scan()) !== undefined; },
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Kernel flock and directory fsync semantics are accepted only on the local
|
|
481
|
+
// Linux filesystems covered by the setup test matrix.
|
|
482
|
+
const SUPPORTED_LINUX_FILESYSTEMS = new Set([
|
|
483
|
+
0xef53, // ext2/3/4
|
|
484
|
+
0x58465342, // XFS
|
|
485
|
+
0x9123683e, // Btrfs
|
|
486
|
+
0x01021994, // tmpfs
|
|
487
|
+
0x794c7630, // overlayfs
|
|
488
|
+
0x2fc12fc1, // ZFS
|
|
489
|
+
0xf2f52010, // F2FS
|
|
490
|
+
0x24051905, // UBIFS
|
|
491
|
+
0xca451a4e, // bcachefs
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
export type StatfsReader = (path: string) => Promise<{ readonly type: number }>;
|
|
495
|
+
|
|
496
|
+
export async function assertSupportedFilesystem(path: string, platform: SetupPlatform, readStatfs: StatfsReader = (target) => statfs(target)): Promise<void> {
|
|
497
|
+
const filesystem = await readStatfs(path);
|
|
498
|
+
if (platform === "linux") {
|
|
499
|
+
if (!SUPPORTED_LINUX_FILESYSTEMS.has(filesystem.type)) throw new Error("ownership_filesystem_unsupported");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// macOS assigns f_type dynamically, so the directory must share the type of
|
|
503
|
+
// `/`, the sealed APFS system volume on every macOS that Node.js 22 supports.
|
|
504
|
+
if (filesystem.type !== (await readStatfs("/")).type) throw new Error("ownership_filesystem_unsupported");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** From XNU `bsd/sys/fcntl.h`; Node's `fs.constants` does not export these. */
|
|
508
|
+
export const DARWIN_O_NONBLOCK = 0x4;
|
|
509
|
+
export const DARWIN_O_EXLOCK = 0x20;
|
|
510
|
+
|
|
511
|
+
export interface DarwinLockOptions {
|
|
512
|
+
readonly timeoutMs: number;
|
|
513
|
+
readonly signal?: AbortSignal;
|
|
514
|
+
readonly retryMs?: number;
|
|
515
|
+
readonly openFile?: (path: string, flags: number, mode: number) => Promise<FileHandle>;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Opens the lock file with an exclusive lock on its open file description,
|
|
520
|
+
* which has flock(2) semantics on macOS. A busy lock is retried until the
|
|
521
|
+
* acquisition bound; a failed attempt holds no descriptor.
|
|
522
|
+
*/
|
|
523
|
+
export async function openLockedDarwin(path: string, options: DarwinLockOptions): Promise<FileHandle> {
|
|
524
|
+
const openFile = options.openFile ?? ((target: string, flags: number, mode: number) => open(target, flags, mode));
|
|
525
|
+
const flags = constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | DARWIN_O_EXLOCK | DARWIN_O_NONBLOCK;
|
|
526
|
+
const deadline = Date.now() + Math.max(1_000, Math.min(options.timeoutMs, 120_000));
|
|
527
|
+
const retryMs = options.retryMs ?? 50;
|
|
528
|
+
while (true) {
|
|
529
|
+
if (options.signal?.aborted) throw new Error("operation_aborted");
|
|
530
|
+
try { return await openFile(path, flags, 0o600); }
|
|
531
|
+
catch (error) {
|
|
532
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
533
|
+
if (code !== "EAGAIN" && code !== "EWOULDBLOCK") throw new Error("ownership_lock_unavailable");
|
|
534
|
+
}
|
|
535
|
+
if (Date.now() >= deadline) throw new Error("ownership_lock_busy");
|
|
536
|
+
await new Promise<void>((resolve) => {
|
|
537
|
+
const done = (): void => { clearTimeout(timer); options.signal?.removeEventListener("abort", done); resolve(); };
|
|
538
|
+
const timer = setTimeout(done, retryMs);
|
|
539
|
+
options.signal?.addEventListener("abort", done, { once: true });
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- [ ] **Step 4: Run the tests to verify they pass**
|
|
546
|
+
|
|
547
|
+
Run: `npx vitest run test/setup-platform.test.ts`
|
|
548
|
+
Expected: PASS on Linux. The two `macOS kernel lock semantics` and other
|
|
549
|
+
darwin-only tests are skipped; the `live process table` test runs.
|
|
550
|
+
|
|
551
|
+
- [ ] **Step 5: Run the quality gate**
|
|
552
|
+
|
|
553
|
+
Run: `npm run typecheck && npm test && npm run build`
|
|
554
|
+
Expected: all exit 0. `dist/setup-platform.js` exists after the build.
|
|
555
|
+
|
|
556
|
+
- [ ] **Step 6: Commit**
|
|
557
|
+
|
|
558
|
+
```bash
|
|
559
|
+
git add src/setup-platform.ts test/setup-platform.test.ts
|
|
560
|
+
git commit # subject: "feat: add the setup platform module for Linux and macOS", body per Global Constraints
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
### Task 2: Runner, launcher, guardian and release binding on the platform module
|
|
566
|
+
|
|
567
|
+
**Files:**
|
|
568
|
+
- Modify: `src/setup-process.ts` (whole file; the `/proc` code moves out)
|
|
569
|
+
- Modify: `src/setup-command-guardian.ts` (lines 1–4, 26–30, 70–113, 147–183, 223–228)
|
|
570
|
+
- Modify: `src/setup.ts:168` (the mandatory release artifacts)
|
|
571
|
+
- Test: `test/setup-release.test.ts`, `test/setup-process.test.ts`
|
|
572
|
+
|
|
573
|
+
**Interfaces:**
|
|
574
|
+
- Consumes from Task 1: `currentSetupPlatform`, `createProcessTable`,
|
|
575
|
+
`isTrustedSystemBinary`, `ProcessTable`, `ProcessGroupState`.
|
|
576
|
+
- Produces: the unchanged exports `createProcessRunner`,
|
|
577
|
+
`createMcpStdioLauncher` and `acquireSetupGuardian(lockFd: number,
|
|
578
|
+
timeoutMs: number, signal?: AbortSignal, validateFence?: () => Promise<void>):
|
|
579
|
+
Promise<SetupGuardian>`.
|
|
580
|
+
- On Linux, `acquireSetupGuardian` runs the util-linux `flock` step on
|
|
581
|
+
`lockFd`.
|
|
582
|
+
- On macOS it skips that step. The caller must pass a descriptor from
|
|
583
|
+
`openLockedDarwin`, which is already locked.
|
|
584
|
+
- The lock step is derived from the platform; there is no lock-mode
|
|
585
|
+
parameter, so a caller cannot select an unlocked mode by mistake.
|
|
586
|
+
|
|
587
|
+
- [ ] **Step 1: Write the failing tests**
|
|
588
|
+
|
|
589
|
+
In `test/setup-release.test.ts`, add this line to `releaseFixture()` after the
|
|
590
|
+
guardian line:
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
await writeFile(join(root, "dist", "setup-platform.js"), "export const platform = true;\n");
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Then add this test after "requires a regular non-symlink guardian in the
|
|
597
|
+
immutable runtime":
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
it("requires a regular non-symlink platform module in the immutable runtime", async () => {
|
|
601
|
+
const missing = await releaseFixture();
|
|
602
|
+
await rm(join(missing, "dist", "setup-platform.js"));
|
|
603
|
+
await expect(immutableReleaseBinding(missing)).rejects.toThrow("release_entry_missing");
|
|
604
|
+
const linked = await releaseFixture();
|
|
605
|
+
await rm(join(linked, "dist", "setup-platform.js"));
|
|
606
|
+
await symlink("main.js", join(linked, "dist", "setup-platform.js"));
|
|
607
|
+
await expect(immutableReleaseBinding(linked)).rejects.toThrow("release_symlink_invalid");
|
|
608
|
+
});
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
In `test/setup-process.test.ts`, add `vi` to the vitest import if it is not
|
|
612
|
+
already imported. Then add this block after the existing `describe`:
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
describe("setup process runner platform gate", () => {
|
|
616
|
+
it("returns 126 without spawning on an unsupported platform", async () => {
|
|
617
|
+
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
|
618
|
+
try {
|
|
619
|
+
const result = await createProcessRunner({ timeoutMs: 1_000 }).run(execPath, ["-e", "require('node:fs').writeFileSync('must-not-exist','')"]);
|
|
620
|
+
expect(result).toEqual({ exitCode: 126, stdout: "", stderr: "" });
|
|
621
|
+
} finally { vi.restoreAllMocks(); }
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
- [ ] **Step 2: Run the tests to verify the release test fails**
|
|
627
|
+
|
|
628
|
+
Run: `npx vitest run test/setup-release.test.ts test/setup-process.test.ts`
|
|
629
|
+
Expected:
|
|
630
|
+
- The new release test FAILS: `release_entry_missing` is not thrown for a
|
|
631
|
+
missing `setup-platform.js`.
|
|
632
|
+
- The platform-gate test PASSES already. It is a regression pin.
|
|
633
|
+
|
|
634
|
+
- [ ] **Step 3: Require the platform module in the release binding**
|
|
635
|
+
|
|
636
|
+
In `src/setup.ts`, replace
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
for (const required of ["main.js", "setup-command-guardian.js"] as const) {
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
with
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
// The guardian loads setup-platform.js at runtime, so it is mandatory too.
|
|
646
|
+
for (const required of ["main.js", "setup-command-guardian.js", "setup-platform.js"] as const) {
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- [ ] **Step 4: Rewrite `src/setup-process.ts` on the platform module**
|
|
650
|
+
|
|
651
|
+
Replace lines 1–49 (the imports through the `delay` helper) with:
|
|
652
|
+
|
|
653
|
+
```ts
|
|
654
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
655
|
+
import { lstatSync } from "node:fs";
|
|
656
|
+
import { fileURLToPath } from "node:url";
|
|
657
|
+
import type { CommandResult, CommandRunner } from "./client-adapters/types.js";
|
|
658
|
+
import type { McpStdioProcess } from "./client-adapters/mcp-verifier.js";
|
|
659
|
+
import type { ProcessGroupState, ProcessTable } from "./setup-platform.js";
|
|
660
|
+
|
|
661
|
+
export interface ProcessRunnerOptions { readonly timeoutMs?: number; readonly maxOutputBytes?: number; readonly env?: NodeJS.ProcessEnv; readonly signal?: AbortSignal; }
|
|
662
|
+
export interface SetupGuardian {
|
|
663
|
+
readonly runner: CommandRunner;
|
|
664
|
+
readonly signal: AbortSignal;
|
|
665
|
+
release(): Promise<void>;
|
|
666
|
+
}
|
|
667
|
+
const DEFAULT_TIMEOUT = 10_000;
|
|
668
|
+
const DEFAULT_MAX = 65_536;
|
|
669
|
+
const ABORT_GROUP_CLEANUP_TIMEOUT = 2_000;
|
|
670
|
+
const GUARDIAN_START_TIMEOUT = 5_000;
|
|
671
|
+
|
|
672
|
+
type SetupPlatformModule = typeof import("./setup-platform.js");
|
|
673
|
+
let platformModule: Promise<SetupPlatformModule> | undefined;
|
|
674
|
+
/**
|
|
675
|
+
* Source-mode harnesses import this file through Node type stripping, which
|
|
676
|
+
* cannot map a static `./setup-platform.js` import to its `.ts` source.
|
|
677
|
+
*/
|
|
678
|
+
function loadSetupPlatform(): Promise<SetupPlatformModule> {
|
|
679
|
+
platformModule ??= import(import.meta.url.endsWith(".ts") ? "./setup-platform.ts" : "./setup-platform.js") as Promise<SetupPlatformModule>;
|
|
680
|
+
return platformModule;
|
|
681
|
+
}
|
|
682
|
+
async function currentProcessTable(): Promise<ProcessTable | undefined> {
|
|
683
|
+
const platform = await loadSetupPlatform();
|
|
684
|
+
const name = platform.currentSetupPlatform();
|
|
685
|
+
return name ? platform.createProcessTable(name) : undefined;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Kill a detached process and every descendant in its process group. */
|
|
689
|
+
function terminateProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
|
690
|
+
if (child.pid === undefined) return;
|
|
691
|
+
if (process.platform !== "win32") {
|
|
692
|
+
try { process.kill(-child.pid, signal); return; } catch { /* already exited or no group */ }
|
|
693
|
+
}
|
|
694
|
+
try { child.kill(signal); } catch { /* already exited */ }
|
|
695
|
+
}
|
|
696
|
+
const delay = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
In `createProcessRunner`, change `run(command, args, signal): Promise<CommandResult> {`
|
|
700
|
+
into `async run(command, args, signal): Promise<CommandResult> {`. Then
|
|
701
|
+
replace
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
if (process.platform !== "linux") return Promise.resolve({ exitCode: 126, stdout: "", stderr: "" }); return new Promise((resolve) => {
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
with
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
const table = await currentProcessTable(); if (!table) return { exitCode: 126, stdout: "", stderr: "" }; return new Promise((resolve) => {
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
In the same function, replace the `finishAfterGroup` arrow (the current lines
|
|
714
|
+
59–64) with:
|
|
715
|
+
|
|
716
|
+
```ts
|
|
717
|
+
let groupCheck = false;
|
|
718
|
+
const finishAfterGroup = (successCode: number): void => {
|
|
719
|
+
if (done || groupCheck) return;
|
|
720
|
+
groupCheck = true;
|
|
721
|
+
const group = child.pid;
|
|
722
|
+
void (group === undefined ? Promise.resolve<ProcessGroupState>("unknown") : table.groupState(group)).then((state) => {
|
|
723
|
+
groupCheck = false;
|
|
724
|
+
if (done) return;
|
|
725
|
+
if (state === "gone") { finish(successCode); return; }
|
|
726
|
+
if (Date.now() >= groupDeadline) { finish(126); return; }
|
|
727
|
+
groupPoll = setTimeout(() => finishAfterGroup(successCode), table.pollMs);
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
In `createMcpStdioLauncher`, make the first line of the returned async
|
|
733
|
+
function
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
const table = await currentProcessTable();
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
and replace the last three lines of `kill()` (the `groupDeadline` line, the
|
|
740
|
+
`while` loop and the `if (!childClosed …)` line) with:
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
const group = child.pid;
|
|
744
|
+
const groupState = async (): Promise<ProcessGroupState> => table && group !== undefined ? table.groupState(group) : "unknown";
|
|
745
|
+
const groupDeadline = Date.now() + 1_000;
|
|
746
|
+
let state = await groupState();
|
|
747
|
+
while (state === "alive" && Date.now() < groupDeadline) { await delay(table?.pollMs ?? 10); state = await groupState(); }
|
|
748
|
+
if (!childClosed || state !== "gone") throw new Error("mcp_process_cleanup_failed");
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Replace the JSDoc and opening of `acquireSetupGuardian` through the end of the
|
|
752
|
+
locker wait (the current lines 129–163) with:
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
/**
|
|
756
|
+
* Starts the transaction guardian with the kernel fence held. On Linux the
|
|
757
|
+
* trusted util-linux `flock` locks the caller's open file description first;
|
|
758
|
+
* on macOS the caller passes a descriptor from `openLockedDarwin`, which is
|
|
759
|
+
* locked at open time. The guardian inherits the locked file description, so
|
|
760
|
+
* parent death cannot release the fence while a client mutation or
|
|
761
|
+
* descendant is still live.
|
|
762
|
+
*/
|
|
763
|
+
export async function acquireSetupGuardian(lockFd: number, timeoutMs: number, signal?: AbortSignal, validateFence?: () => Promise<void>): Promise<SetupGuardian> {
|
|
764
|
+
const platform = await loadSetupPlatform();
|
|
765
|
+
const platformName = platform.currentSetupPlatform();
|
|
766
|
+
if (!platformName) throw new Error("platform_unsupported");
|
|
767
|
+
const flockPath = platformName === "linux" ? ["/usr/bin/flock", "/bin/flock"].find((candidate) => platform.isTrustedSystemBinary(candidate)) : undefined;
|
|
768
|
+
if (platformName === "linux" && !flockPath) throw new Error("ownership_lock_unavailable");
|
|
769
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
770
|
+
const guardianPath = fileURLToPath(new URL(modulePath.endsWith(".ts") ? "./setup-command-guardian.ts" : "./setup-command-guardian.js", import.meta.url));
|
|
771
|
+
try { if (!lstatSync(guardianPath).isFile()) throw new Error("guardian_invalid"); }
|
|
772
|
+
catch { throw new Error("ownership_lock_unavailable"); }
|
|
773
|
+
if (flockPath) {
|
|
774
|
+
const waitSeconds = String(Math.ceil(Math.max(1_000, Math.min(timeoutMs, 120_000)) / 1_000));
|
|
775
|
+
let locker: ChildProcess;
|
|
776
|
+
try {
|
|
777
|
+
locker = spawn(flockPath, ["-E", "75", "-w", waitSeconds, "3"], {
|
|
778
|
+
stdio: ["ignore", "ignore", "ignore", lockFd],
|
|
779
|
+
detached: true,
|
|
780
|
+
});
|
|
781
|
+
} catch { throw new Error("ownership_lock_unavailable"); }
|
|
782
|
+
const stopAcquisition = (): void => { if (locker.pid !== undefined) { try { process.kill(-locker.pid, "SIGKILL"); } catch { /* already gone */ } } };
|
|
783
|
+
if (signal?.aborted) stopAcquisition();
|
|
784
|
+
signal?.addEventListener("abort", stopAcquisition, { once: true });
|
|
785
|
+
try {
|
|
786
|
+
await new Promise<void>((resolve, reject) => {
|
|
787
|
+
locker.once("error", () => reject(new Error("ownership_lock_unavailable")));
|
|
788
|
+
locker.once("close", (code) => code === 0 ? resolve() : reject(new Error(code === 75 ? "ownership_lock_busy" : signal?.aborted ? "operation_aborted" : "ownership_lock_unavailable")));
|
|
789
|
+
});
|
|
790
|
+
} finally { signal?.removeEventListener("abort", stopAcquisition); }
|
|
791
|
+
}
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Keep everything from `if (signal?.aborted) throw new Error("operation_aborted");`
|
|
795
|
+
(the current line 164) to the end of the file unchanged.
|
|
796
|
+
|
|
797
|
+
- [ ] **Step 5: Move the guardian onto the platform module**
|
|
798
|
+
|
|
799
|
+
In `src/setup-command-guardian.ts`:
|
|
800
|
+
|
|
801
|
+
- Replace line 3, `import { readdirSync, readFileSync } from "node:fs";`, with:
|
|
802
|
+
|
|
803
|
+
```ts
|
|
804
|
+
import type { ProcessTable } from "./setup-platform.js";
|
|
805
|
+
|
|
806
|
+
type SetupPlatformModule = typeof import("./setup-platform.js");
|
|
807
|
+
// Source-mode tests run this guardian through Node type stripping, which cannot
|
|
808
|
+
// map a static `./setup-platform.js` import to its `.ts` source.
|
|
809
|
+
const platform = await (import(import.meta.url.endsWith(".ts") ? "./setup-platform.ts" : "./setup-platform.js") as Promise<SetupPlatformModule>);
|
|
810
|
+
const platformName = platform.currentSetupPlatform();
|
|
811
|
+
const table: ProcessTable | undefined = platformName ? platform.createProcessTable(platformName) : undefined;
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
- After `const RETRY_MS = 25;`, add:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
// Each macOS scan starts `ps`, so cleanup loops poll no faster than the table allows.
|
|
818
|
+
const CLEANUP_RETRY_MS = Math.max(RETRY_MS, table?.pollMs ?? RETRY_MS);
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
- Delete `readProcessGroup`, `processTableReadable` and the synchronous
|
|
822
|
+
`groupState` (the current lines 70–113), and add:
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
async function groupState(group: number): Promise<GroupState> {
|
|
826
|
+
if (forceUnknownForSourceTest || !table) return "unknown";
|
|
827
|
+
return table.groupState(group);
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
- In `cleanup` and `terminateActive`, change `const state = groupState(execution.group);`
|
|
832
|
+
to `const state = await groupState(execution.group);`. Change both
|
|
833
|
+
`setTimeout(resolve, RETRY_MS)` calls inside those loops to
|
|
834
|
+
`setTimeout(resolve, CLEANUP_RETRY_MS)`. `parentWatch` keeps `RETRY_MS`.
|
|
835
|
+
- Replace the `child.once("close", …)` handler in `start` with:
|
|
836
|
+
|
|
837
|
+
```ts
|
|
838
|
+
child.once("close", (code) => {
|
|
839
|
+
if (execution.cleanupStarted) return;
|
|
840
|
+
void groupState(execution.group).then((state) => {
|
|
841
|
+
if (execution.cleanupStarted) return;
|
|
842
|
+
if (state === "gone") {
|
|
843
|
+
finish(code ?? 1);
|
|
844
|
+
active = undefined;
|
|
845
|
+
if (shuttingDown || !process.connected) exitWhenSafe();
|
|
846
|
+
} else void cleanup(code ?? 1);
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
- Replace the final readiness block (the current lines 223–228) with:
|
|
852
|
+
|
|
853
|
+
```ts
|
|
854
|
+
if (table && await table.readable()) send({ type: "ready" });
|
|
855
|
+
else {
|
|
856
|
+
clearInterval(parentWatch);
|
|
857
|
+
if (process.connected) process.disconnect();
|
|
858
|
+
process.exitCode = 69;
|
|
859
|
+
}
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
- [ ] **Step 6: Run the tests to verify they pass**
|
|
863
|
+
|
|
864
|
+
Run: `npx vitest run test/setup-release.test.ts test/setup-process.test.ts test/setup-ownership.test.ts test/setup-main.test.ts`
|
|
865
|
+
Expected: PASS on Linux, including the real guardian tests in
|
|
866
|
+
`setup-ownership.test.ts` and the stubborn-group signal tests in
|
|
867
|
+
`setup-main.test.ts`.
|
|
868
|
+
|
|
869
|
+
If a dynamic import of `./setup-platform.ts` fails under Vitest:
|
|
870
|
+
- Report the exact error.
|
|
871
|
+
- Do not switch to a static import.
|
|
872
|
+
- Do not change the specifier to a file URL without recording why.
|
|
873
|
+
|
|
874
|
+
- [ ] **Step 7: Run the quality gate and verify the built guardian**
|
|
875
|
+
|
|
876
|
+
Run: `npm run typecheck && npm test && npm run build && node -e "import('./dist/setup-platform.js').then(m=>console.log(typeof m.createProcessTable))"`
|
|
877
|
+
Expected: all exit 0, and the last command prints `function`.
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 8: Commit**
|
|
880
|
+
|
|
881
|
+
```bash
|
|
882
|
+
git add src/setup-process.ts src/setup-command-guardian.ts src/setup.ts test/setup-release.test.ts test/setup-process.test.ts
|
|
883
|
+
git commit # subject: "refactor: read the process table through the platform module", body per Global Constraints
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
888
|
+
### Task 3: Ownership store and setup entry on macOS
|
|
889
|
+
|
|
890
|
+
**Files:**
|
|
891
|
+
- Modify: `src/setup-ownership.ts` (lines 1–7 imports, `withLock` lines 62–96, lines 128–175)
|
|
892
|
+
- Modify: `src/setup-main.ts:20`
|
|
893
|
+
- Test: `test/setup-ownership.test.ts`, `test/setup-main.test.ts`
|
|
894
|
+
|
|
895
|
+
**Interfaces:**
|
|
896
|
+
- Consumes from Task 1: `currentSetupPlatform`, `assertSupportedFilesystem`,
|
|
897
|
+
`openLockedDarwin`, `SetupPlatform`.
|
|
898
|
+
- Consumes from Task 2: `acquireSetupGuardian`. On macOS it requires a
|
|
899
|
+
descriptor that is already locked.
|
|
900
|
+
- Produces: the unchanged `DurableOwnershipStore` API. On macOS it locks at
|
|
901
|
+
open time and maps the outcomes as follows:
|
|
902
|
+
- `ownership_lock_busy` and `operation_aborted` propagate unchanged;
|
|
903
|
+
- every other lock failure becomes `ownership_lock_unavailable`.
|
|
904
|
+
|
|
905
|
+
- [ ] **Step 1: Write the failing tests**
|
|
906
|
+
|
|
907
|
+
In `test/setup-main.test.ts`, replace the test `it.each(["win32", "darwin"] as const)("fails closed on %s …`
|
|
908
|
+
so that it reads `it.each(["win32", "freebsd"] as const)`; keep its body
|
|
909
|
+
unchanged. Then add after it:
|
|
910
|
+
|
|
911
|
+
```ts
|
|
912
|
+
it("accepts darwin as a setup platform", async () => {
|
|
913
|
+
process.env.PI_SESSION_MCP_CONFIG = "/SECRET/config.json";
|
|
914
|
+
const writes: string[] = [];
|
|
915
|
+
vi.spyOn(process.stdout, "write").mockImplementation(((value: string | Uint8Array) => { writes.push(String(value)); return true; }) as typeof process.stdout.write);
|
|
916
|
+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
|
917
|
+
const createSetup = vi.fn(async () => ({ run: async () => result("dry-run", []) }) as unknown as SetupOrchestrator);
|
|
918
|
+
expect(await main(["--json", "--dry-run", "--target", "codex:user:pi-session-mcp"], { createSetup })).toBe(0);
|
|
919
|
+
expect(createSetup).toHaveBeenCalledOnce();
|
|
920
|
+
expect(JSON.parse(writes.join(""))).toMatchObject({ operation: "dry-run", exitCode: 0 });
|
|
921
|
+
});
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
In `test/setup-ownership.test.ts`:
|
|
925
|
+
- Add `symlink` to the `node:fs/promises` import.
|
|
926
|
+
- Add `vi` to the vitest import.
|
|
927
|
+
- Add these tests inside the existing `describe` block, after "rejects a lock
|
|
928
|
+
inode replaced while acquisition is waiting":
|
|
929
|
+
|
|
930
|
+
```ts
|
|
931
|
+
it("rejects a lock inode replaced after the lock file was opened", async () => {
|
|
932
|
+
const directory = await temporaryRoot("pi-session-mcp-owned-replaced-");
|
|
933
|
+
const path = join(directory, "ownership.json");
|
|
934
|
+
const store = new DurableOwnershipStore(path, { afterLockOpen: async () => { await unlink(`${path}.flock`); await writeFile(`${path}.flock`, "", { mode: 0o600 }); } });
|
|
935
|
+
await expect(store.transaction(async () => undefined)).rejects.toThrow("ownership_lock_unavailable");
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("refuses a symlinked lock file", async () => {
|
|
939
|
+
const directory = await temporaryRoot("pi-session-mcp-owned-symlink-");
|
|
940
|
+
const path = join(directory, "ownership.json");
|
|
941
|
+
await writeFile(join(directory, "elsewhere"), "", { mode: 0o600 });
|
|
942
|
+
await symlink(join(directory, "elsewhere"), `${path}.flock`);
|
|
943
|
+
await expect(new DurableOwnershipStore(path).transaction(async () => undefined)).rejects.toThrow("ownership_lock_unavailable");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("stops waiting for a held lock when aborted", async () => {
|
|
947
|
+
const directory = await temporaryRoot("pi-session-mcp-owned-abort-");
|
|
948
|
+
const path = join(directory, "ownership.json");
|
|
949
|
+
let release!: () => void;
|
|
950
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
951
|
+
let entered!: () => void;
|
|
952
|
+
const holding = new Promise<void>((resolve) => { entered = resolve; });
|
|
953
|
+
const first = new DurableOwnershipStore(path).transaction(async () => { entered(); await gate; });
|
|
954
|
+
try {
|
|
955
|
+
await holding;
|
|
956
|
+
const controller = new AbortController();
|
|
957
|
+
const started = Date.now();
|
|
958
|
+
const second = new DurableOwnershipStore(path, { signal: controller.signal, acquireTimeoutMs: 10_000 }).transaction(async () => undefined);
|
|
959
|
+
setTimeout(() => controller.abort(), 100);
|
|
960
|
+
await expect(second).rejects.toThrow("operation_aborted");
|
|
961
|
+
expect(Date.now() - started).toBeLessThan(5_000);
|
|
962
|
+
} finally {
|
|
963
|
+
release();
|
|
964
|
+
await first;
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Add a second `describe` at the end of the file. It is not skipped on any
|
|
970
|
+
platform:
|
|
971
|
+
|
|
972
|
+
```ts
|
|
973
|
+
describe("durable setup ownership platform gate", () => {
|
|
974
|
+
it("fails closed on an unsupported platform before touching the filesystem", async () => {
|
|
975
|
+
const directory = await temporaryRoot("pi-session-mcp-owned-platform-");
|
|
976
|
+
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
|
977
|
+
try {
|
|
978
|
+
await expect(new DurableOwnershipStore(join(directory, "state", "ownership.json")).transaction(async () => undefined)).rejects.toThrow("platform_unsupported");
|
|
979
|
+
expect(existsSync(join(directory, "state"))).toBe(false);
|
|
980
|
+
} finally { vi.restoreAllMocks(); }
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
986
|
+
|
|
987
|
+
Run: `npx vitest run test/setup-main.test.ts test/setup-ownership.test.ts`
|
|
988
|
+
Expected:
|
|
989
|
+
- "accepts darwin as a setup platform" FAILS: the result is `platform_unsupported`
|
|
990
|
+
with exit code 1.
|
|
991
|
+
- The three new ownership tests PASS on Linux already. They are regression
|
|
992
|
+
pins that must keep passing, and they run on macOS after Task 4.
|
|
993
|
+
- The platform-gate test PASSES.
|
|
994
|
+
|
|
995
|
+
- [ ] **Step 3: Accept darwin in the setup entry point**
|
|
996
|
+
|
|
997
|
+
In `src/setup-main.ts`, add `import { currentSetupPlatform } from "./setup-platform.js";`
|
|
998
|
+
after the `./setup-process.js` import. Replace
|
|
999
|
+
|
|
1000
|
+
```ts
|
|
1001
|
+
if (process.platform !== "linux") return write(cliFailure(operation, request.targets, "platform_unsupported", 1), request.json);
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
with
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
if (!currentSetupPlatform()) return write(cliFailure(operation, request.targets, "platform_unsupported", 1), request.json);
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
- [ ] **Step 4: Lock and check the filesystem through the platform module**
|
|
1011
|
+
|
|
1012
|
+
In `src/setup-ownership.ts`:
|
|
1013
|
+
|
|
1014
|
+
- Change the second import line to
|
|
1015
|
+
`import { chmod, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";`
|
|
1016
|
+
(`statfs` is no longer used here).
|
|
1017
|
+
- Add after the `./setup-process.js` import:
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin, type SetupPlatform } from "./setup-platform.js";
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
- In `withLock`, replace the first three lines:
|
|
1024
|
+
|
|
1025
|
+
```ts
|
|
1026
|
+
if (process.platform !== "linux") throw new Error("platform_unsupported");
|
|
1027
|
+
const directory = dirname(this.path);
|
|
1028
|
+
await ensureOwnershipDirectory(directory);
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
with
|
|
1032
|
+
|
|
1033
|
+
```ts
|
|
1034
|
+
const platform = currentSetupPlatform();
|
|
1035
|
+
if (!platform) throw new Error("platform_unsupported");
|
|
1036
|
+
const directory = dirname(this.path);
|
|
1037
|
+
await ensureOwnershipDirectory(directory, platform);
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
- Replace the block from `const lockPath = \`${this.path}.flock\`;` through
|
|
1041
|
+
the `catch` that throws `ownership_lock_unavailable` (the current lines
|
|
1042
|
+
73–84) with:
|
|
1043
|
+
|
|
1044
|
+
```ts
|
|
1045
|
+
const lockPath = `${this.path}.flock`;
|
|
1046
|
+
const acquireTimeoutMs = this.options.acquireTimeoutMs ?? 30_000;
|
|
1047
|
+
let lockHandle;
|
|
1048
|
+
try {
|
|
1049
|
+
// macOS takes the kernel lock at open time; on Linux the trusted flock
|
|
1050
|
+
// locks this same open file description in acquireSetupGuardian.
|
|
1051
|
+
lockHandle = platform === "darwin"
|
|
1052
|
+
? await openLockedDarwin(lockPath, { timeoutMs: acquireTimeoutMs, signal: controller.signal })
|
|
1053
|
+
: await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
1054
|
+
await lockHandle.chmod(0o600);
|
|
1055
|
+
await lockHandle.sync();
|
|
1056
|
+
await syncDirectory(directory);
|
|
1057
|
+
const [descriptor, linked] = await Promise.all([lockHandle.stat(), stat(lockPath)]);
|
|
1058
|
+
if (!descriptor.isFile() || !linked.isFile() || descriptor.dev !== linked.dev || descriptor.ino !== linked.ino) throw new Error("ownership_lock_invalid");
|
|
1059
|
+
await this.options.afterLockOpen?.();
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
this.options.signal?.removeEventListener("abort", abort);
|
|
1063
|
+
await lockHandle?.close().catch(() => undefined);
|
|
1064
|
+
const code = (error as Error).message;
|
|
1065
|
+
throw new Error(["ownership_lock_busy", "operation_aborted"].includes(code) ? code : "ownership_lock_unavailable");
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
- In the following `acquireSetupGuardian(...)` call, replace
|
|
1070
|
+
`this.options.acquireTimeoutMs ?? 30_000` with `acquireTimeoutMs`.
|
|
1071
|
+
- Delete `SUPPORTED_LOCAL_FILESYSTEMS`, its comment, and the local
|
|
1072
|
+
`assertSupportedFilesystem` function (the current lines 128–140 and
|
|
1073
|
+
172–175).
|
|
1074
|
+
- Change `async function ensureOwnershipDirectory(directory: string): Promise<void> {`
|
|
1075
|
+
to `async function ensureOwnershipDirectory(directory: string, platform: SetupPlatform): Promise<void> {`.
|
|
1076
|
+
Change both calls inside it, `await assertSupportedFilesystem(cursor);` and
|
|
1077
|
+
`await assertSupportedFilesystem(directory);`, to pass `platform` as the
|
|
1078
|
+
second argument.
|
|
1079
|
+
|
|
1080
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
1081
|
+
|
|
1082
|
+
Run: `npx vitest run test/setup-main.test.ts test/setup-ownership.test.ts test/setup-lifecycle.test.ts`
|
|
1083
|
+
Expected: PASS.
|
|
1084
|
+
|
|
1085
|
+
- [ ] **Step 6: Run the quality gate**
|
|
1086
|
+
|
|
1087
|
+
Run: `npm run typecheck && npm test && npm run build`
|
|
1088
|
+
Expected: all exit 0.
|
|
1089
|
+
|
|
1090
|
+
- [ ] **Step 7: Commit**
|
|
1091
|
+
|
|
1092
|
+
```bash
|
|
1093
|
+
git add src/setup-ownership.ts src/setup-main.ts test/setup-ownership.test.ts test/setup-main.test.ts
|
|
1094
|
+
git commit # subject: "feat: take the setup lock and filesystem rule on macOS", body per Global Constraints
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
### Task 4: Platform-neutral process fixture, runner and lifecycle tests
|
|
1100
|
+
|
|
1101
|
+
**Files:**
|
|
1102
|
+
- Modify: `test/process-fixture.ts`
|
|
1103
|
+
- Modify: `test/setup-process.test.ts` (split the `describe` at line 32)
|
|
1104
|
+
- Modify: `test/setup-lifecycle.test.ts:36`
|
|
1105
|
+
|
|
1106
|
+
**Interfaces:**
|
|
1107
|
+
- Consumes from Task 1: `currentSetupPlatform`.
|
|
1108
|
+
- Produces new test-fixture exports:
|
|
1109
|
+
- `processInspectionSupported: boolean`
|
|
1110
|
+
- `processState(pid: number): Promise<string | undefined>`: the first state
|
|
1111
|
+
letter, or undefined when the process is gone;
|
|
1112
|
+
- `processCommandLine(pid: number): Promise<string | undefined>`
|
|
1113
|
+
- `isLockHeld(path: string): boolean`
|
|
1114
|
+
- `expectProcessTerminated(pid, readText?)` and
|
|
1115
|
+
`waitForProcessTerminated(pid, readText?, timeoutMs?)`: without an injected
|
|
1116
|
+
reader they use `ps` on macOS; with an injected reader they keep the Linux
|
|
1117
|
+
`/proc` text logic.
|
|
1118
|
+
|
|
1119
|
+
- [ ] **Step 1: Add the platform helpers to `test/process-fixture.ts`**
|
|
1120
|
+
|
|
1121
|
+
Replace the import lines with:
|
|
1122
|
+
|
|
1123
|
+
```ts
|
|
1124
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
1125
|
+
import { closeSync, constants, existsSync, openSync, readSync } from "node:fs";
|
|
1126
|
+
import { readFile } from "node:fs/promises";
|
|
1127
|
+
import { expect } from "vitest";
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
Add after the `ZOMBIE_STAT` constant:
|
|
1131
|
+
|
|
1132
|
+
```ts
|
|
1133
|
+
/** Process inspection in these fixtures uses `/proc` on Linux and `ps` on macOS. */
|
|
1134
|
+
export const processInspectionSupported = process.platform === "linux" || process.platform === "darwin";
|
|
1135
|
+
|
|
1136
|
+
/** From XNU `bsd/sys/fcntl.h`, as in src/setup-platform.ts. */
|
|
1137
|
+
const DARWIN_O_NONBLOCK = 0x4;
|
|
1138
|
+
const DARWIN_O_EXLOCK = 0x20;
|
|
1139
|
+
|
|
1140
|
+
function darwinProcessField(pid: number, field: "stat=" | "command="): Promise<string | undefined> {
|
|
1141
|
+
return new Promise((resolve, reject) => {
|
|
1142
|
+
execFile("/bin/ps", ["-o", field, "-p", String(pid)], { env: { LC_ALL: "C" }, encoding: "utf8" }, (error, stdout) => {
|
|
1143
|
+
if (!error) { resolve(stdout.trim()); return; }
|
|
1144
|
+
// ps exits 1 with no output when the PID does not exist.
|
|
1145
|
+
if (error.code === 1 && stdout.trim() === "") { resolve(undefined); return; }
|
|
1146
|
+
reject(error);
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** The first state letter of a process (`Z` for a zombie), or undefined when it is gone. */
|
|
1152
|
+
export async function processState(pid: number): Promise<string | undefined> {
|
|
1153
|
+
if (process.platform === "darwin") return (await darwinProcessField(pid, "stat="))?.[0];
|
|
1154
|
+
const stat = await readTextIfPresent(`/proc/${pid}/stat`);
|
|
1155
|
+
return stat === undefined ? undefined : stat[stat.lastIndexOf(") ") + 2];
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** The command line of a process, or undefined when it is gone. */
|
|
1159
|
+
export async function processCommandLine(pid: number): Promise<string | undefined> {
|
|
1160
|
+
if (process.platform === "darwin") return darwinProcessField(pid, "command=");
|
|
1161
|
+
return readTextIfPresent(`/proc/${pid}/cmdline`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** True while another open file description holds the setup lock file. */
|
|
1165
|
+
export function isLockHeld(path: string): boolean {
|
|
1166
|
+
if (process.platform === "darwin") {
|
|
1167
|
+
let fd: number;
|
|
1168
|
+
try { fd = openSync(path, constants.O_RDWR | DARWIN_O_EXLOCK | DARWIN_O_NONBLOCK); }
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1171
|
+
if (code === "EAGAIN" || code === "EWOULDBLOCK") return true;
|
|
1172
|
+
throw error;
|
|
1173
|
+
}
|
|
1174
|
+
closeSync(fd);
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
const flockPath = existsSync("/usr/bin/flock") ? "/usr/bin/flock" : "/bin/flock";
|
|
1178
|
+
const status = spawnSync(flockPath, ["-n", path, "/bin/true"], { stdio: "ignore" }).status;
|
|
1179
|
+
if (status !== 0 && status !== 1) throw new Error("fixture_lock_probe_failed");
|
|
1180
|
+
return status === 1;
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
Replace `expectProcessTerminated` with:
|
|
1185
|
+
|
|
1186
|
+
```ts
|
|
1187
|
+
/** Proves the process stopped executing; it does not prove reaping or fence release. */
|
|
1188
|
+
export async function expectProcessTerminated(pid: number, readText?: TextReader): Promise<void> {
|
|
1189
|
+
expect(Number.isSafeInteger(pid) && pid > 1).toBe(true);
|
|
1190
|
+
if (readText === undefined && process.platform === "darwin") {
|
|
1191
|
+
const state = await processState(pid);
|
|
1192
|
+
if (state !== undefined) expect(state).toBe("Z");
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const stat = await readTextIfPresent(`/proc/${pid}/stat`, readText ?? readProcStatText);
|
|
1196
|
+
// Keep the assertion outside the read catch so a live process always fails.
|
|
1197
|
+
if (stat !== undefined) expect(stat).toMatch(ZOMBIE_STAT);
|
|
1198
|
+
}
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
Replace the signature and first two lines of `waitForProcessTerminated` with
|
|
1202
|
+
the following. The existing `/proc` loop stays below it unchanged, except
|
|
1203
|
+
that `readText` becomes `reader`:
|
|
1204
|
+
|
|
1205
|
+
```ts
|
|
1206
|
+
export async function waitForProcessTerminated(pid: number, readText?: TextReader, timeoutMs = 5_000): Promise<void> {
|
|
1207
|
+
expect(Number.isSafeInteger(pid) && pid > 1).toBe(true);
|
|
1208
|
+
const deadline = Date.now() + timeoutMs;
|
|
1209
|
+
if (readText === undefined && process.platform === "darwin") {
|
|
1210
|
+
while (true) {
|
|
1211
|
+
const state = await processState(pid);
|
|
1212
|
+
if (state === undefined || state === "Z") return;
|
|
1213
|
+
if (Date.now() >= deadline) { expect(state).toBe("Z"); return; }
|
|
1214
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const reader = readText ?? readProcStatText;
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
In the remaining loop, change `readTextIfPresent(\`/proc/${pid}/stat\`, readText)`
|
|
1221
|
+
to `readTextIfPresent(\`/proc/${pid}/stat\`, reader)`.
|
|
1222
|
+
|
|
1223
|
+
- [ ] **Step 2: Split `test/setup-process.test.ts` by platform**
|
|
1224
|
+
|
|
1225
|
+
- Add the imports
|
|
1226
|
+
`import { currentSetupPlatform } from "../src/setup-platform.js";` and
|
|
1227
|
+
`processState` (from `./process-fixture.js`).
|
|
1228
|
+
- Change line 32 to
|
|
1229
|
+
`describe.skipIf(!currentSetupPlatform())("setup process runner", () => {`.
|
|
1230
|
+
- In "rejects a real living descendant in the shared termination assertion",
|
|
1231
|
+
replace
|
|
1232
|
+
|
|
1233
|
+
```ts
|
|
1234
|
+
expect(await readFile(`/proc/${pid}/stat`, "utf8")).not.toMatch(/^\d+ \(.*\) Z /);
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
with
|
|
1238
|
+
|
|
1239
|
+
```ts
|
|
1240
|
+
const state = await processState(pid);
|
|
1241
|
+
expect(state).toBeDefined();
|
|
1242
|
+
expect(state).not.toBe("Z");
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
- Close this `describe` after that test with `});`.
|
|
1246
|
+
- Open `describe.skipIf(process.platform !== "linux")("Linux /proc fixture", () => {`
|
|
1247
|
+
before "preserves one complete kernel stat sample through actual owned
|
|
1248
|
+
reaping". Everything from that test down to "rejects %s during cleanup
|
|
1249
|
+
polling" stays in this Linux block.
|
|
1250
|
+
- Move "cleans a living fixture even when another cleanup step rejects" into
|
|
1251
|
+
the "setup process runner" block. It has no `/proc` dependency.
|
|
1252
|
+
|
|
1253
|
+
- [ ] **Step 3: Run the lifecycle store test on every setup platform**
|
|
1254
|
+
|
|
1255
|
+
In `test/setup-lifecycle.test.ts`:
|
|
1256
|
+
- Add `import { currentSetupPlatform } from "../src/setup-platform.js";`.
|
|
1257
|
+
- Change `it.skipIf(process.platform !== "linux")` (line 36) to
|
|
1258
|
+
`it.skipIf(!currentSetupPlatform())`.
|
|
1259
|
+
|
|
1260
|
+
- [ ] **Step 4: Run the affected tests**
|
|
1261
|
+
|
|
1262
|
+
Run: `npx vitest run test/setup-process.test.ts test/setup-lifecycle.test.ts test/setup-ownership.test.ts test/setup-main.test.ts`
|
|
1263
|
+
Expected: PASS on Linux with the same number of executed tests as before,
|
|
1264
|
+
plus the tests added in Tasks 2–3.
|
|
1265
|
+
|
|
1266
|
+
- [ ] **Step 5: Run the quality gate**
|
|
1267
|
+
|
|
1268
|
+
Run: `npm run typecheck && npm test && npm run build`
|
|
1269
|
+
Expected: all exit 0.
|
|
1270
|
+
|
|
1271
|
+
- [ ] **Step 6: Commit**
|
|
1272
|
+
|
|
1273
|
+
```bash
|
|
1274
|
+
git add test/process-fixture.ts test/setup-process.test.ts test/setup-lifecycle.test.ts
|
|
1275
|
+
git commit # subject: "test: inspect processes with ps on macOS in the setup fixtures", body per Global Constraints
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
### Task 5: Ownership and signal integration tests on macOS
|
|
1281
|
+
|
|
1282
|
+
**Files:**
|
|
1283
|
+
- Modify: `test/setup-ownership.test.ts`
|
|
1284
|
+
- Modify: `test/setup-main.test.ts` (the two signal tests, lines 63–198)
|
|
1285
|
+
|
|
1286
|
+
**Interfaces:**
|
|
1287
|
+
- Consumes from Task 1: `currentSetupPlatform`, `openLockedDarwin` (by URL,
|
|
1288
|
+
in raw-Node harnesses).
|
|
1289
|
+
- Consumes from Task 4: `processState`, `isLockHeld`.
|
|
1290
|
+
|
|
1291
|
+
- [ ] **Step 1: Add shared harness helpers to `test/setup-ownership.test.ts`**
|
|
1292
|
+
|
|
1293
|
+
- Add `import { currentSetupPlatform } from "../src/setup-platform.js";`.
|
|
1294
|
+
- Add `isLockHeld` and `processState` to the `./process-fixture.js` import.
|
|
1295
|
+
- Remove `spawnSync` and `existsSync` once they are unused.
|
|
1296
|
+
- After `afterAll(removeTemporaryRoots);`, add:
|
|
1297
|
+
|
|
1298
|
+
```ts
|
|
1299
|
+
const processModuleUrl = pathToFileURL(resolve("src/setup-process.ts")).href;
|
|
1300
|
+
const platformModuleUrl = pathToFileURL(resolve("src/setup-platform.ts")).href;
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* A raw-Node harness that holds the fence through a real guardian. macOS locks
|
|
1304
|
+
* at open time; Linux locks the same open file description through flock.
|
|
1305
|
+
*/
|
|
1306
|
+
function guardianHarness(lockPath: string, body: readonly string[]): string {
|
|
1307
|
+
return [
|
|
1308
|
+
"import { constants } from 'node:fs'; import { open } from 'node:fs/promises'; const fs=await import('node:fs');",
|
|
1309
|
+
`import { acquireSetupGuardian } from ${JSON.stringify(processModuleUrl)};`,
|
|
1310
|
+
`import { openLockedDarwin } from ${JSON.stringify(platformModuleUrl)};`,
|
|
1311
|
+
`const handle=process.platform==='darwin'?await openLockedDarwin(${JSON.stringify(lockPath)},{timeoutMs:5000}):await open(${JSON.stringify(lockPath)},constants.O_CREAT|constants.O_RDWR|constants.O_NOFOLLOW,0o600);`,
|
|
1312
|
+
"const guardian=await acquireSetupGuardian(handle.fd,5000);",
|
|
1313
|
+
...body,
|
|
1314
|
+
].join("\n");
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** A mutation-side statement that records its parent, the guardian, in `marker`. */
|
|
1318
|
+
function recordGuardian(marker: string): string {
|
|
1319
|
+
return `fs.writeFileSync(${JSON.stringify(`${marker}.tmp`)},String(process.ppid));fs.renameSync(${JSON.stringify(`${marker}.tmp`)},${JSON.stringify(marker)});`;
|
|
1320
|
+
}
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
- Change the suite gate to
|
|
1324
|
+
`describe.skipIf(!currentSetupPlatform())("durable setup ownership", () => {`.
|
|
1325
|
+
- In "persists only bounded path-free ownership data with restrictive modes",
|
|
1326
|
+
extend the regex to `/PI_SESSION_MCP_CONFIG|\/home\/|\/Users\/|\/private\/|\.\.\/|SECRET/`.
|
|
1327
|
+
|
|
1328
|
+
- [ ] **Step 2: Make the lock-wait tests platform-aware**
|
|
1329
|
+
|
|
1330
|
+
In "holds one lock across the full ownership transaction", replace
|
|
1331
|
+
|
|
1332
|
+
```ts
|
|
1333
|
+
await Promise.race([secondLockOpened, observed]);
|
|
1334
|
+
// The second descriptor is open while the first transaction still holds its gate.
|
|
1335
|
+
const flockPath = existsSync("/usr/bin/flock") ? "/usr/bin/flock" : "/bin/flock";
|
|
1336
|
+
expect(spawnSync(flockPath, ["-n", `${path}.flock`, "/bin/true"], { stdio: "ignore" }).status).toBe(1);
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
with
|
|
1340
|
+
|
|
1341
|
+
```ts
|
|
1342
|
+
if (process.platform === "darwin") {
|
|
1343
|
+
// macOS takes the lock at open time, so the waiting store has no descriptor yet.
|
|
1344
|
+
let settled = false;
|
|
1345
|
+
void observed.then(() => { settled = true; }, () => { settled = true; });
|
|
1346
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 200));
|
|
1347
|
+
expect(settled).toBe(false);
|
|
1348
|
+
} else {
|
|
1349
|
+
// The second descriptor is open while the first transaction still holds its gate.
|
|
1350
|
+
await Promise.race([secondLockOpened, observed]);
|
|
1351
|
+
}
|
|
1352
|
+
expect(isLockHeld(`${path}.flock`)).toBe(true);
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
Change "rejects a lock inode replaced while acquisition is waiting" to
|
|
1356
|
+
`it.skipIf(process.platform !== "linux")(…)`. Add this comment directly above
|
|
1357
|
+
it:
|
|
1358
|
+
|
|
1359
|
+
```ts
|
|
1360
|
+
// Only Linux has a descriptor while it waits; macOS waits before open. The
|
|
1361
|
+
// replaced-after-open case below covers both platforms.
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
- [ ] **Step 3: Rewrite the three guardian harness tests**
|
|
1365
|
+
|
|
1366
|
+
In "keeps the kernel fence through parent SIGKILL until guardian cleanup":
|
|
1367
|
+
|
|
1368
|
+
- Delete the `moduleUrl` line.
|
|
1369
|
+
- Prefix `mutation` with the guardian record, so its first two elements are:
|
|
1370
|
+
|
|
1371
|
+
```ts
|
|
1372
|
+
"const {spawn}=require('node:child_process'); const fs=require('node:fs');",
|
|
1373
|
+
recordGuardian(guardianMarker),
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
- Replace the `harness` definition with:
|
|
1377
|
+
|
|
1378
|
+
```ts
|
|
1379
|
+
const harness = guardianHarness(`${path}.flock`, [
|
|
1380
|
+
`const output=await guardian.runner.run(process.execPath,['-e',${JSON.stringify(mutation)}]);process.exitCode=output.exitCode;`,
|
|
1381
|
+
]);
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
- Replace the guardian-stopped loop body with:
|
|
1385
|
+
|
|
1386
|
+
```ts
|
|
1387
|
+
guardianStopped = (await processState(guardianPid)) === "T";
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
- Replace the `helperStat`, `flockPath` and `competing` lines with:
|
|
1391
|
+
|
|
1392
|
+
```ts
|
|
1393
|
+
expect(await processState(helperPid)).not.toBe("Z");
|
|
1394
|
+
expect(await processState(helperPid)).toBeDefined();
|
|
1395
|
+
expect(isLockHeld(`${path}.flock`)).toBe(true);
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
In "retains the fence after indeterminate cleanup reports exit 126":
|
|
1399
|
+
|
|
1400
|
+
- Delete the `moduleUrl` line.
|
|
1401
|
+
- Insert `${recordGuardian(guardianMarker)}` into `mutation` directly after
|
|
1402
|
+
`process.on('SIGTERM',()=>{});`.
|
|
1403
|
+
- Replace the `harness` definition with:
|
|
1404
|
+
|
|
1405
|
+
```ts
|
|
1406
|
+
const harness = guardianHarness(`${path}.flock`, [
|
|
1407
|
+
"const controller=new AbortController(); process.on('SIGTERM',()=>controller.abort());",
|
|
1408
|
+
`const output=await guardian.runner.run(process.execPath,['-e',${JSON.stringify(mutation)}],controller.signal);`,
|
|
1409
|
+
`fs.writeFileSync(${JSON.stringify(`${resultMarker}.tmp`)},String(output.exitCode)); fs.renameSync(${JSON.stringify(`${resultMarker}.tmp`)},${JSON.stringify(resultMarker)}); await guardian.release(); await handle.close();`,
|
|
1410
|
+
]);
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
- Replace the `flockPath` line and the `spawnSync` assertion with
|
|
1414
|
+
`expect(isLockHeld(\`${path}.flock\`)).toBe(true);`.
|
|
1415
|
+
|
|
1416
|
+
In "lets the guardian clean its detached command with a %s mutation marker":
|
|
1417
|
+
|
|
1418
|
+
- Delete the `moduleUrl` line.
|
|
1419
|
+
- Insert `${recordGuardian(guardianMarker)}` into `mutation` directly after
|
|
1420
|
+
`process.on('SIGTERM',()=>{});`.
|
|
1421
|
+
- Replace the `harness` definition with:
|
|
1422
|
+
|
|
1423
|
+
```ts
|
|
1424
|
+
const harness = guardianHarness(lockPath, [
|
|
1425
|
+
`await guardian.runner.run(process.execPath,['-e',${JSON.stringify(mutation)}]);`,
|
|
1426
|
+
]);
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
- [ ] **Step 4: Run the signal tests on macOS too**
|
|
1430
|
+
|
|
1431
|
+
In `test/setup-main.test.ts`:
|
|
1432
|
+
|
|
1433
|
+
- Add `import { currentSetupPlatform } from "../src/setup-platform.js";`.
|
|
1434
|
+
- Add `isLockHeld` and `processState` to the `./process-fixture.js` import.
|
|
1435
|
+
- Remove the now-unused `spawnSync`, `existsSync`, `readFile` and
|
|
1436
|
+
`readTextIfPresent` imports.
|
|
1437
|
+
- Change both `it.skipIf(process.platform !== "linux").each(` to
|
|
1438
|
+
`it.skipIf(!currentSetupPlatform()).each(`.
|
|
1439
|
+
- In "holds the fence while $signal cleans a stubborn real process group":
|
|
1440
|
+
- Rename `let competingStatus: number | null | undefined;` to
|
|
1441
|
+
`let lockWasHeld: boolean | undefined;`.
|
|
1442
|
+
- Replace the observation block, from the `stoppedDeadline` loop through
|
|
1443
|
+
`helperStayedLive = …`, with:
|
|
1444
|
+
|
|
1445
|
+
```ts
|
|
1446
|
+
const stoppedDeadline = Date.now() + 5_000;
|
|
1447
|
+
do {
|
|
1448
|
+
guardianWasStopped = (await processState(guardianPid)) === "T";
|
|
1449
|
+
if (!guardianWasStopped) await new Promise((resolveDelay) => setTimeout(resolveDelay, 10));
|
|
1450
|
+
} while (!guardianWasStopped && Date.now() < stoppedDeadline);
|
|
1451
|
+
const helperState = await processState(helperPid);
|
|
1452
|
+
helperWasLive = helperState !== undefined && helperState !== "Z";
|
|
1453
|
+
lockWasHeld = isLockHeld(`${ownershipPath}.flock`);
|
|
1454
|
+
const afterProbe = await processState(helperPid);
|
|
1455
|
+
helperStayedLive = afterProbe !== undefined && afterProbe !== "Z";
|
|
1456
|
+
```
|
|
1457
|
+
|
|
1458
|
+
- Replace `expect(competingStatus).toBe(1);` with `expect(lockWasHeld).toBe(true);`.
|
|
1459
|
+
|
|
1460
|
+
- [ ] **Step 5: Run the affected tests**
|
|
1461
|
+
|
|
1462
|
+
Run: `npx vitest run test/setup-ownership.test.ts test/setup-main.test.ts`
|
|
1463
|
+
Expected: PASS on Linux, with no test newly skipped on Linux.
|
|
1464
|
+
|
|
1465
|
+
- [ ] **Step 6: Run the quality gate**
|
|
1466
|
+
|
|
1467
|
+
Run: `npm run typecheck && npm test && npm run build`
|
|
1468
|
+
Expected: all exit 0.
|
|
1469
|
+
|
|
1470
|
+
- [ ] **Step 7: Commit**
|
|
1471
|
+
|
|
1472
|
+
```bash
|
|
1473
|
+
git add test/setup-ownership.test.ts test/setup-main.test.ts
|
|
1474
|
+
git commit # subject: "test: run the setup fence integration tests on macOS", body per Global Constraints
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
---
|
|
1478
|
+
|
|
1479
|
+
### Task 6: Server stdin-EOF lifecycle on macOS
|
|
1480
|
+
|
|
1481
|
+
**Files:**
|
|
1482
|
+
- Modify: `test/process-stdin-eof.test.ts` (lines 24, 38–39, 88–93, 197, 259)
|
|
1483
|
+
|
|
1484
|
+
**Interfaces:**
|
|
1485
|
+
- Consumes from Task 4: `processInspectionSupported`, `processCommandLine`,
|
|
1486
|
+
and the platform-aware `waitForProcessTerminated`.
|
|
1487
|
+
|
|
1488
|
+
- [ ] **Step 1: Change the gate and the command-line probe**
|
|
1489
|
+
|
|
1490
|
+
- Add `processCommandLine` and `processInspectionSupported` to the
|
|
1491
|
+
`./process-fixture.js` import.
|
|
1492
|
+
- Remove `readTextIfPresent` from it if it becomes unused.
|
|
1493
|
+
- Replace lines 38–39 with:
|
|
1494
|
+
|
|
1495
|
+
```ts
|
|
1496
|
+
/** Termination checks use `/proc` on Linux and `ps` on macOS, as in the other process-level tests. */
|
|
1497
|
+
const describeWithProcessInspection = describe.skipIf(!processInspectionSupported);
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
- Rename both uses of `describeOnLinux(` (lines 197 and 259) to
|
|
1501
|
+
`describeWithProcessInspection(`.
|
|
1502
|
+
- In `stopFixtureChild`, replace
|
|
1503
|
+
`try { commandLine = await readTextIfPresent(\`/proc/${pid}/cmdline\`); }`
|
|
1504
|
+
with `try { commandLine = await processCommandLine(pid); }`.
|
|
1505
|
+
|
|
1506
|
+
- [ ] **Step 2: Run the tests**
|
|
1507
|
+
|
|
1508
|
+
Run: `npx vitest run test/process-stdin-eof.test.ts`
|
|
1509
|
+
Expected: PASS on Linux with the same test count as before.
|
|
1510
|
+
|
|
1511
|
+
- [ ] **Step 3: Run the quality gate**
|
|
1512
|
+
|
|
1513
|
+
Run: `npm run typecheck && npm test && npm run build`
|
|
1514
|
+
Expected: all exit 0.
|
|
1515
|
+
|
|
1516
|
+
- [ ] **Step 4: Commit**
|
|
1517
|
+
|
|
1518
|
+
```bash
|
|
1519
|
+
git add test/process-stdin-eof.test.ts
|
|
1520
|
+
git commit # subject: "test: check the server's stdin-EOF exit on macOS", body per Global Constraints
|
|
1521
|
+
```
|
|
1522
|
+
|
|
1523
|
+
---
|
|
1524
|
+
|
|
1525
|
+
### Task 7: CI on Linux and macOS
|
|
1526
|
+
|
|
1527
|
+
**Files:**
|
|
1528
|
+
- Modify: `.github/workflows/ci.yml`
|
|
1529
|
+
|
|
1530
|
+
**Interfaces:** The ruleset "Protect main" requires a status check named
|
|
1531
|
+
exactly `verify`. The matrix job is therefore named `test`, and a job named
|
|
1532
|
+
`verify` aggregates it.
|
|
1533
|
+
|
|
1534
|
+
- [ ] **Step 1: Replace the `jobs:` section**
|
|
1535
|
+
|
|
1536
|
+
```yaml
|
|
1537
|
+
jobs:
|
|
1538
|
+
test:
|
|
1539
|
+
name: test (${{ matrix.os }})
|
|
1540
|
+
strategy:
|
|
1541
|
+
fail-fast: false
|
|
1542
|
+
matrix:
|
|
1543
|
+
os: [ubuntu-latest, macos-15, macos-26]
|
|
1544
|
+
runs-on: ${{ matrix.os }}
|
|
1545
|
+
timeout-minutes: 30
|
|
1546
|
+
steps:
|
|
1547
|
+
- uses: actions/checkout@v4
|
|
1548
|
+
- uses: actions/setup-node@v4
|
|
1549
|
+
with:
|
|
1550
|
+
node-version: 22.19.0
|
|
1551
|
+
cache: npm
|
|
1552
|
+
- run: npm ci
|
|
1553
|
+
- run: npm run typecheck
|
|
1554
|
+
- run: npm run build:cli
|
|
1555
|
+
- run: npm test
|
|
1556
|
+
- run: npm run build
|
|
1557
|
+
|
|
1558
|
+
# The "Protect main" ruleset requires a check named "verify"; it passes only
|
|
1559
|
+
# when every platform in the matrix passed.
|
|
1560
|
+
verify:
|
|
1561
|
+
needs: test
|
|
1562
|
+
if: always()
|
|
1563
|
+
runs-on: ubuntu-latest
|
|
1564
|
+
timeout-minutes: 5
|
|
1565
|
+
steps:
|
|
1566
|
+
- env:
|
|
1567
|
+
RESULT: ${{ needs.test.result }}
|
|
1568
|
+
run: test "$RESULT" = success
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
- [ ] **Step 2: Validate the YAML locally**
|
|
1572
|
+
|
|
1573
|
+
Run: `node -e "const t=require('node:fs').readFileSync('.github/workflows/ci.yml','utf8'); if(!/needs: test/.test(t)||!/macos-26/.test(t)) process.exit(1)"`
|
|
1574
|
+
Expected: exit 0.
|
|
1575
|
+
|
|
1576
|
+
If `actionlint` is installed, also run `actionlint .github/workflows/ci.yml`.
|
|
1577
|
+
Expected: no findings.
|
|
1578
|
+
|
|
1579
|
+
- [ ] **Step 3: Commit**
|
|
1580
|
+
|
|
1581
|
+
```bash
|
|
1582
|
+
git add .github/workflows/ci.yml
|
|
1583
|
+
git commit # subject: "ci: test on macOS 15 and 26 next to Linux", body per Global Constraints
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
---
|
|
1587
|
+
|
|
1588
|
+
### Task 8: Documentation, ADR 0005 and the agent rule
|
|
1589
|
+
|
|
1590
|
+
**Files:**
|
|
1591
|
+
- Create: `docs/adr/0005-macos-setup-platform.md`
|
|
1592
|
+
- Modify:
|
|
1593
|
+
- `AGENTS.md` (lines 18–23);
|
|
1594
|
+
- `README.md` (Status section);
|
|
1595
|
+
- `docs/installation.md` (Requirements, "Register without setup");
|
|
1596
|
+
- `docs/client-setup-design.md` (platform paragraph and ownership paragraphs);
|
|
1597
|
+
- `docs/threat-model.md` ("Concurrent setup corruption", "Unsupported
|
|
1598
|
+
process-group guarantees");
|
|
1599
|
+
- `docs/acceptance.md` (Level 2: a macOS section);
|
|
1600
|
+
- `docs/macos-setup-design.md` (status line);
|
|
1601
|
+
- `CHANGELOG.md` (Unreleased).
|
|
1602
|
+
|
|
1603
|
+
**Interfaces:** Documentation only. Every statement must match the code from
|
|
1604
|
+
Tasks 1–7 and must not claim acceptance on real hardware.
|
|
1605
|
+
|
|
1606
|
+
The session owner writes this task, not an implementer subagent, because the
|
|
1607
|
+
wording is judgment-heavy and must match the verified code and evidence.
|
|
1608
|
+
|
|
1609
|
+
- [ ] **Step 1: Write ADR 0005**
|
|
1610
|
+
|
|
1611
|
+
Create `docs/adr/0005-macos-setup-platform.md` in the style of ADR 0004:
|
|
1612
|
+
|
|
1613
|
+
- **Status:** Accepted (date of the commit).
|
|
1614
|
+
- **Context:**
|
|
1615
|
+
- #28 hit the ADR 0003 trigger "repeated setup friction beyond version
|
|
1616
|
+
drift";
|
|
1617
|
+
- the macOS facts from the design (no `flock(1)`, no `/proc`, dynamic
|
|
1618
|
+
`f_type`).
|
|
1619
|
+
- **Decision:** approach A as implemented. Name the module, the three
|
|
1620
|
+
primitives, the unchanged public codes, the `verify` aggregator check, and
|
|
1621
|
+
the condition that macOS counts as accepted only after Level-2 evidence
|
|
1622
|
+
from real hardware.
|
|
1623
|
+
- **Rejected alternatives:**
|
|
1624
|
+
- a `lockf(1)` helper with `/sbin/mount` parsing, because the fd mode on
|
|
1625
|
+
macOS 15 and the `mount` format are unverified;
|
|
1626
|
+
- a weaker macOS profile, because it breaks the fence guarantee.
|
|
1627
|
+
- **Revisit triggers:**
|
|
1628
|
+
- the `ps` output format changes;
|
|
1629
|
+
- the `O_EXLOCK` semantics change;
|
|
1630
|
+
- the minimum macOS version supported by Node.js changes;
|
|
1631
|
+
- a request for Intel Macs in CI or for another platform.
|
|
1632
|
+
|
|
1633
|
+
- [ ] **Step 2: Update the agent rule**
|
|
1634
|
+
|
|
1635
|
+
In `AGENTS.md`, replace the bullet that starts "Mutating setup is Linux-only"
|
|
1636
|
+
(through "An indeterminate scan retains the fence.") with:
|
|
1637
|
+
|
|
1638
|
+
```markdown
|
|
1639
|
+
- Mutating setup runs on Linux and macOS only. Linux requires a supported
|
|
1640
|
+
local-filesystem kernel `flock`, a trusted fixed util-linux binary, and
|
|
1641
|
+
readable `/proc`; macOS requires an `O_EXLOCK` lock on the APFS volume type
|
|
1642
|
+
of `/` and the trusted system `/bin/ps`. On both, keep the stable lock inode
|
|
1643
|
+
and guardian-held fence until a determinate process-table scan confirms no
|
|
1644
|
+
non-zombie mutating client process-group member remains. A zombie is
|
|
1645
|
+
terminated and holds no file descriptors; this is a termination guarantee,
|
|
1646
|
+
not a PID-reaping guarantee. An indeterminate scan retains the fence.
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
- [ ] **Step 3: Update README, installation guide and CHANGELOG**
|
|
1650
|
+
|
|
1651
|
+
- **README Status:** replace the "Linux only" bullet with:
|
|
1652
|
+
- **Linux and macOS**, Node.js 22.19.0 or newer.
|
|
1653
|
+
- macOS is CI-tested on macOS 15 and 26 (Apple silicon). Intel Macs are
|
|
1654
|
+
expected to work but are untested. Level-2 setup acceptance on macOS is
|
|
1655
|
+
pending until the evidence is recorded in
|
|
1656
|
+
[Acceptance](docs/acceptance.md).
|
|
1657
|
+
- Other platforms refuse with `platform_unsupported`.
|
|
1658
|
+
- **`docs/installation.md` Requirements:**
|
|
1659
|
+
- Linux or macOS;
|
|
1660
|
+
- the ownership directory `~/.local/state/pi-session-mcp/` must be on a
|
|
1661
|
+
supported local filesystem: on macOS the APFS volume type of `/`, so an
|
|
1662
|
+
external exFAT/HFS+ or network home fails closed;
|
|
1663
|
+
- the server and `setup` work on both.
|
|
1664
|
+
- **`docs/installation.md` "Register without setup":** remove the macOS
|
|
1665
|
+
special case. The section stays as the general alternative for clients or
|
|
1666
|
+
environments without `setup`, and it keeps the statement that its checks
|
|
1667
|
+
are weaker than `--verify`.
|
|
1668
|
+
- **`CHANGELOG.md` Unreleased:** add a first bullet. It says that `setup` now
|
|
1669
|
+
supports macOS with the same fence; it names `O_EXLOCK`, `/bin/ps` and the
|
|
1670
|
+
APFS rule; it says that public codes, statuses and exit codes are unchanged
|
|
1671
|
+
and that CI runs on Linux, macOS 15 and macOS 26. Reword the existing
|
|
1672
|
+
macOS bullet so that it no longer says macOS support is planned.
|
|
1673
|
+
|
|
1674
|
+
- [ ] **Step 4: Update design, threat model and acceptance**
|
|
1675
|
+
|
|
1676
|
+
- **`docs/client-setup-design.md`:**
|
|
1677
|
+
- the platform paragraph names both platforms and their requirements;
|
|
1678
|
+
- the ownership paragraphs say "a kernel lock (util-linux `flock` on Linux,
|
|
1679
|
+
`O_EXLOCK` at open on macOS)" and "a determinate process-table scan
|
|
1680
|
+
(`/proc` on Linux, `/bin/ps` on macOS)".
|
|
1681
|
+
- **`docs/threat-model.md`:**
|
|
1682
|
+
- the same substitutions in "Concurrent setup corruption";
|
|
1683
|
+
- the macOS filesystem rule;
|
|
1684
|
+
- "Unsupported process-group guarantees" becomes: every platform other
|
|
1685
|
+
than Linux and macOS fails closed before client spawn.
|
|
1686
|
+
- **`docs/acceptance.md` Level 2:** add a subsection "macOS". It contains the
|
|
1687
|
+
checklist from the design's "Acceptance on real hardware", with the
|
|
1688
|
+
corrected lock-contention expectation, and the evidence format: versions,
|
|
1689
|
+
exit codes, sanitized human and JSON results, and no paths.
|
|
1690
|
+
- **`docs/macos-setup-design.md`:** status becomes "Implemented; Level-2
|
|
1691
|
+
acceptance on real hardware pending".
|
|
1692
|
+
|
|
1693
|
+
- [ ] **Step 5: Check the documentation for stale statements**
|
|
1694
|
+
|
|
1695
|
+
Run: `grep -rn "Linux only\|Linux-only\|macOS support is planned\|non-Linux" README.md AGENTS.md docs CHANGELOG.md`
|
|
1696
|
+
Expected: no hits, except inside `CHANGELOG.md` history for released
|
|
1697
|
+
versions and inside ADR 0003, which records history.
|
|
1698
|
+
|
|
1699
|
+
- [ ] **Step 6: Run the quality gate**
|
|
1700
|
+
|
|
1701
|
+
Run: `npm run typecheck && npm test && npm run build`
|
|
1702
|
+
Expected: all exit 0.
|
|
1703
|
+
|
|
1704
|
+
- [ ] **Step 7: Commit**
|
|
1705
|
+
|
|
1706
|
+
```bash
|
|
1707
|
+
git add AGENTS.md README.md CHANGELOG.md docs/adr/0005-macos-setup-platform.md docs/installation.md docs/client-setup-design.md docs/threat-model.md docs/acceptance.md docs/macos-setup-design.md
|
|
1708
|
+
git commit # subject: "docs: support the setup lifecycle on macOS", body per Global Constraints
|
|
1709
|
+
```
|
|
1710
|
+
|
|
1711
|
+
---
|
|
1712
|
+
|
|
1713
|
+
### After the tasks: delivery and evidence
|
|
1714
|
+
|
|
1715
|
+
These steps are not for implementer subagents; the session owner runs them.
|
|
1716
|
+
|
|
1717
|
+
1. **Whole-branch review** by the `reviewer` agent, plus a Codex review.
|
|
1718
|
+
Address the findings in follow-up commits.
|
|
1719
|
+
2. **Ask the owner** before the first push. Then push `feat/macos-setup` and
|
|
1720
|
+
open one PR that references #32 and calls out the `AGENTS.md` rule change.
|
|
1721
|
+
3. **Watch CI** on all three runners. Fix macOS-only failures in focused
|
|
1722
|
+
commits. Do not push while a CI run is still in progress unless it is
|
|
1723
|
+
necessary.
|
|
1724
|
+
4. **The owner runs the Level-2 macOS checklist** on a Mac against the PR
|
|
1725
|
+
branch build. The evidence is committed to the PR before merge.
|