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
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
|
|
3
|
+
// Source-mode tests run this guardian through Node type stripping, which cannot
|
|
4
|
+
// map a static `./setup-platform.js` import to its `.ts` source.
|
|
5
|
+
const platform = await import(import.meta.url.endsWith(".ts") ? "./setup-platform.ts" : "./setup-platform.js");
|
|
6
|
+
const platformName = platform.currentSetupPlatform();
|
|
7
|
+
const table = platformName ? platform.createProcessTable(platformName) : undefined;
|
|
4
8
|
const TERM_GRACE_MS = 100;
|
|
5
9
|
const CLEANUP_REPORT_MS = 2_000;
|
|
6
10
|
const RETRY_MS = 25;
|
|
11
|
+
// Each macOS scan starts `ps`, so cleanup loops poll no faster than the table allows.
|
|
12
|
+
const CLEANUP_RETRY_MS = Math.max(RETRY_MS, table?.pollMs ?? RETRY_MS);
|
|
7
13
|
let active;
|
|
8
14
|
let shuttingDown = false;
|
|
9
15
|
// Source-mode signal injection is available only to credential-free tests. The
|
|
@@ -54,65 +60,10 @@ function parseRequest(value) {
|
|
|
54
60
|
return undefined;
|
|
55
61
|
return { type: "run", id: input.id, command: input.command, args: input.args, timeoutMs: input.timeoutMs, maxOutputBytes: input.maxOutputBytes };
|
|
56
62
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
const close = line.lastIndexOf(") ");
|
|
60
|
-
const state = line[close + 2];
|
|
61
|
-
if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ")
|
|
62
|
-
throw new Error("process_stat_invalid");
|
|
63
|
-
const fields = line.slice(close + 4).trim().split(/\s+/);
|
|
64
|
-
const group = Number(fields[1]);
|
|
65
|
-
if (!Number.isSafeInteger(group) || group < 0)
|
|
66
|
-
throw new Error("process_group_invalid");
|
|
67
|
-
return { group, state };
|
|
68
|
-
}
|
|
69
|
-
function processTableReadable() {
|
|
70
|
-
let sawSelf = false;
|
|
71
|
-
try {
|
|
72
|
-
for (const entry of readdirSync("/proc", { withFileTypes: true })) {
|
|
73
|
-
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
74
|
-
continue;
|
|
75
|
-
try {
|
|
76
|
-
const pid = Number(entry.name);
|
|
77
|
-
readProcessGroup(pid);
|
|
78
|
-
if (pid === process.pid)
|
|
79
|
-
sawSelf = true;
|
|
80
|
-
}
|
|
81
|
-
catch (error) {
|
|
82
|
-
if (error.code !== "ENOENT")
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
return sawSelf;
|
|
91
|
-
}
|
|
92
|
-
function groupState(group) {
|
|
93
|
-
if (forceUnknownForSourceTest)
|
|
94
|
-
return "unknown";
|
|
95
|
-
let entries;
|
|
96
|
-
try {
|
|
97
|
-
entries = readdirSync("/proc", { withFileTypes: true });
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
63
|
+
async function groupState(group) {
|
|
64
|
+
if (forceUnknownForSourceTest || !table)
|
|
100
65
|
return "unknown";
|
|
101
|
-
|
|
102
|
-
for (const entry of entries) {
|
|
103
|
-
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
104
|
-
continue;
|
|
105
|
-
try {
|
|
106
|
-
const processState = readProcessGroup(Number(entry.name));
|
|
107
|
-
if (processState.group === group && processState.state !== "Z")
|
|
108
|
-
return "alive";
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
if (error.code !== "ENOENT")
|
|
112
|
-
return "unknown";
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return "gone";
|
|
66
|
+
return table.groupState(group);
|
|
116
67
|
}
|
|
117
68
|
function signalGroup(group, signal) {
|
|
118
69
|
try {
|
|
@@ -176,7 +127,7 @@ function start(request) {
|
|
|
176
127
|
signalGroup(execution.group, "SIGKILL");
|
|
177
128
|
const reportDeadline = Date.now() + CLEANUP_REPORT_MS;
|
|
178
129
|
while (true) {
|
|
179
|
-
const state = groupState(execution.group);
|
|
130
|
+
const state = await groupState(execution.group);
|
|
180
131
|
if (state === "gone") {
|
|
181
132
|
finish(reportedCode);
|
|
182
133
|
active = undefined;
|
|
@@ -187,7 +138,7 @@ function start(request) {
|
|
|
187
138
|
if (!execution.resultSent && Date.now() >= reportDeadline)
|
|
188
139
|
finish(126);
|
|
189
140
|
signalGroup(execution.group, "SIGKILL");
|
|
190
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, CLEANUP_RETRY_MS));
|
|
191
142
|
}
|
|
192
143
|
};
|
|
193
144
|
execution.timeout = setTimeout(() => { void cleanup(124); }, request.timeoutMs);
|
|
@@ -202,15 +153,20 @@ function start(request) {
|
|
|
202
153
|
child.once("close", (code) => {
|
|
203
154
|
if (execution.cleanupStarted)
|
|
204
155
|
return;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
156
|
+
if (execution.timeout)
|
|
157
|
+
clearTimeout(execution.timeout);
|
|
158
|
+
void groupState(execution.group).then((state) => {
|
|
159
|
+
if (execution.cleanupStarted)
|
|
160
|
+
return;
|
|
161
|
+
if (state === "gone") {
|
|
162
|
+
finish(code ?? 1);
|
|
163
|
+
active = undefined;
|
|
164
|
+
if (shuttingDown || !process.connected)
|
|
165
|
+
exitWhenSafe();
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
void cleanup(code ?? 1);
|
|
169
|
+
});
|
|
214
170
|
});
|
|
215
171
|
}
|
|
216
172
|
process.on("message", (value) => {
|
|
@@ -253,7 +209,7 @@ async function terminateActive() {
|
|
|
253
209
|
signalGroup(execution.group, "SIGKILL");
|
|
254
210
|
const reportDeadline = Date.now() + CLEANUP_REPORT_MS;
|
|
255
211
|
while (true) {
|
|
256
|
-
const state = groupState(execution.group);
|
|
212
|
+
const state = await groupState(execution.group);
|
|
257
213
|
if (state === "gone") {
|
|
258
214
|
if (!execution.resultSent) {
|
|
259
215
|
execution.resultSent = true;
|
|
@@ -269,7 +225,7 @@ async function terminateActive() {
|
|
|
269
225
|
send({ type: "result", id: execution.id, exitCode: 126, stdout: "", stderr: "" });
|
|
270
226
|
}
|
|
271
227
|
signalGroup(execution.group, "SIGKILL");
|
|
272
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, CLEANUP_RETRY_MS));
|
|
273
229
|
}
|
|
274
230
|
}
|
|
275
231
|
process.on("disconnect", () => {
|
|
@@ -287,7 +243,7 @@ process.once("SIGTERM", () => { shuttingDown = true; if (active)
|
|
|
287
243
|
void terminateActive();
|
|
288
244
|
else
|
|
289
245
|
exitWhenSafe(); });
|
|
290
|
-
if (
|
|
246
|
+
if (table && await table.readable())
|
|
291
247
|
send({ type: "ready" });
|
|
292
248
|
else {
|
|
293
249
|
clearInterval(parentWatch);
|
package/dist/setup-main.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { isEntryPoint } from "./entry-point.js";
|
|
5
5
|
import { createProcessRunner } from "./setup-process.js";
|
|
6
|
+
import { currentSetupPlatform } from "./setup-platform.js";
|
|
6
7
|
import { createProductionSetup, isSupportedTarget } from "./setup.js";
|
|
7
8
|
import { formatSetupHuman, formatSetupResult, parseSetupArgs } from "./setup-cli.js";
|
|
8
9
|
import { result } from "./setup-result.js";
|
|
@@ -17,7 +18,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
17
18
|
return write(cliFailure("dry-run", [], "usage_invalid", 64), json);
|
|
18
19
|
}
|
|
19
20
|
const operation = request.operation ?? "dry-run";
|
|
20
|
-
if (
|
|
21
|
+
if (!currentSetupPlatform())
|
|
21
22
|
return write(cliFailure(operation, request.targets, "platform_unsupported", 1), request.json);
|
|
22
23
|
const config = process.env.PI_SESSION_MCP_CONFIG;
|
|
23
24
|
if (!config)
|
package/dist/setup-ownership.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { chmod, lstat, mkdir, open, readFile, rename, rm, stat
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { acquireSetupGuardian } from "./setup-process.js";
|
|
6
|
+
import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin } from "./setup-platform.js";
|
|
6
7
|
/** Safe default for one process; callers needing recovery inject a durable store. */
|
|
7
8
|
export class MemoryOwnershipStore {
|
|
8
9
|
entries = new Map();
|
|
@@ -48,10 +49,11 @@ export class DurableOwnershipStore {
|
|
|
48
49
|
return this.withLock(operation);
|
|
49
50
|
}
|
|
50
51
|
async withLock(operation) {
|
|
51
|
-
|
|
52
|
+
const platform = currentSetupPlatform();
|
|
53
|
+
if (!platform)
|
|
52
54
|
throw new Error("platform_unsupported");
|
|
53
55
|
const directory = dirname(this.path);
|
|
54
|
-
await ensureOwnershipDirectory(directory);
|
|
56
|
+
await ensureOwnershipDirectory(directory, platform);
|
|
55
57
|
const controller = new AbortController();
|
|
56
58
|
const abort = () => controller.abort();
|
|
57
59
|
this.options.signal?.addEventListener("abort", abort, { once: true });
|
|
@@ -62,9 +64,14 @@ export class DurableOwnershipStore {
|
|
|
62
64
|
throw new Error("operation_aborted");
|
|
63
65
|
};
|
|
64
66
|
const lockPath = `${this.path}.flock`;
|
|
67
|
+
const acquireTimeoutMs = this.options.acquireTimeoutMs ?? 30_000;
|
|
65
68
|
let lockHandle;
|
|
66
69
|
try {
|
|
67
|
-
|
|
70
|
+
// macOS takes the kernel lock at open time; on Linux the trusted flock
|
|
71
|
+
// locks this same open file description in acquireSetupGuardian.
|
|
72
|
+
lockHandle = platform === "darwin"
|
|
73
|
+
? await openLockedDarwin(lockPath, { timeoutMs: acquireTimeoutMs, signal: controller.signal })
|
|
74
|
+
: await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
68
75
|
await lockHandle.chmod(0o600);
|
|
69
76
|
await lockHandle.sync();
|
|
70
77
|
await syncDirectory(directory);
|
|
@@ -73,10 +80,11 @@ export class DurableOwnershipStore {
|
|
|
73
80
|
throw new Error("ownership_lock_invalid");
|
|
74
81
|
await this.options.afterLockOpen?.();
|
|
75
82
|
}
|
|
76
|
-
catch {
|
|
83
|
+
catch (error) {
|
|
77
84
|
this.options.signal?.removeEventListener("abort", abort);
|
|
78
85
|
await lockHandle?.close().catch(() => undefined);
|
|
79
|
-
|
|
86
|
+
const code = error.message;
|
|
87
|
+
throw new Error(["ownership_lock_busy", "operation_aborted"].includes(code) ? code : "ownership_lock_unavailable");
|
|
80
88
|
}
|
|
81
89
|
const validateLock = async () => {
|
|
82
90
|
const [descriptor, linked] = await Promise.all([lockHandle.stat(), stat(lockPath)]);
|
|
@@ -85,7 +93,7 @@ export class DurableOwnershipStore {
|
|
|
85
93
|
};
|
|
86
94
|
let guardian;
|
|
87
95
|
try {
|
|
88
|
-
guardian = await acquireSetupGuardian(lockHandle.fd,
|
|
96
|
+
guardian = await acquireSetupGuardian(lockHandle.fd, acquireTimeoutMs, controller.signal, validateLock);
|
|
89
97
|
}
|
|
90
98
|
catch (error) {
|
|
91
99
|
this.options.signal?.removeEventListener("abort", abort);
|
|
@@ -153,20 +161,7 @@ export class DurableOwnershipStore {
|
|
|
153
161
|
await rm(temporary, { force: true }).catch(() => undefined);
|
|
154
162
|
} }
|
|
155
163
|
}
|
|
156
|
-
|
|
157
|
-
// Linux filesystems covered by the setup test matrix.
|
|
158
|
-
const SUPPORTED_LOCAL_FILESYSTEMS = new Set([
|
|
159
|
-
0xef53, // ext2/3/4
|
|
160
|
-
0x58465342, // XFS
|
|
161
|
-
0x9123683e, // Btrfs
|
|
162
|
-
0x01021994, // tmpfs
|
|
163
|
-
0x794c7630, // overlayfs
|
|
164
|
-
0x2fc12fc1, // ZFS
|
|
165
|
-
0xf2f52010, // F2FS
|
|
166
|
-
0x24051905, // UBIFS
|
|
167
|
-
0xca451a4e, // bcachefs
|
|
168
|
-
]);
|
|
169
|
-
async function ensureOwnershipDirectory(directory) {
|
|
164
|
+
async function ensureOwnershipDirectory(directory, platform) {
|
|
170
165
|
const missing = [];
|
|
171
166
|
let cursor = directory;
|
|
172
167
|
while (true) {
|
|
@@ -186,7 +181,7 @@ async function ensureOwnershipDirectory(directory) {
|
|
|
186
181
|
cursor = parent;
|
|
187
182
|
}
|
|
188
183
|
}
|
|
189
|
-
await assertSupportedFilesystem(cursor);
|
|
184
|
+
await assertSupportedFilesystem(cursor, platform);
|
|
190
185
|
for (const candidate of missing.reverse()) {
|
|
191
186
|
try {
|
|
192
187
|
await mkdir(candidate, { mode: 0o700 });
|
|
@@ -203,12 +198,7 @@ async function ensureOwnershipDirectory(directory) {
|
|
|
203
198
|
await syncDirectory(dirname(candidate));
|
|
204
199
|
}
|
|
205
200
|
await chmod(directory, 0o700);
|
|
206
|
-
await assertSupportedFilesystem(directory);
|
|
207
|
-
}
|
|
208
|
-
async function assertSupportedFilesystem(path) {
|
|
209
|
-
const filesystem = await statfs(path);
|
|
210
|
-
if (!SUPPORTED_LOCAL_FILESYSTEMS.has(filesystem.type))
|
|
211
|
-
throw new Error("ownership_filesystem_unsupported");
|
|
201
|
+
await assertSupportedFilesystem(directory, platform);
|
|
212
202
|
}
|
|
213
203
|
async function syncDirectory(path) {
|
|
214
204
|
const handle = await open(path, "r");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import type { FileHandle } from "node:fs/promises";
|
|
3
|
+
/**
|
|
4
|
+
* Operating-system facts behind the setup fence. This module has no relative
|
|
5
|
+
* imports: the transaction guardian and source-mode harnesses load it through
|
|
6
|
+
* Node type stripping, which cannot map a `.js` specifier to its `.ts` source.
|
|
7
|
+
*/
|
|
8
|
+
export type SetupPlatform = "linux" | "darwin";
|
|
9
|
+
export type ProcessGroupState = "alive" | "gone" | "unknown";
|
|
10
|
+
export interface ProcessEntry {
|
|
11
|
+
readonly pid: number;
|
|
12
|
+
readonly group: number;
|
|
13
|
+
readonly state: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ProcessTable {
|
|
16
|
+
/** "alive" if any non-zombie process has this process group ID. */
|
|
17
|
+
groupState(group: number): Promise<ProcessGroupState>;
|
|
18
|
+
/** True only if a scan is determinate and contains the calling process. */
|
|
19
|
+
readable(): Promise<boolean>;
|
|
20
|
+
/** Poll interval for cleanup loops. */
|
|
21
|
+
readonly pollMs: number;
|
|
22
|
+
}
|
|
23
|
+
export interface PsOutput {
|
|
24
|
+
readonly exitCode: number;
|
|
25
|
+
readonly stdout: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ProcessTableDependencies {
|
|
28
|
+
/** Test seam for the macOS `ps` call; undefined means it could not run. */
|
|
29
|
+
readonly runPs?: () => Promise<PsOutput | undefined>;
|
|
30
|
+
readonly selfPid?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function currentSetupPlatform(platform?: NodeJS.Platform): SetupPlatform | undefined;
|
|
33
|
+
/** A regular, root-owned executable that no group or other user can replace. */
|
|
34
|
+
export declare function isTrustedSystemBinary(path: string, statFile?: (path: string) => Pick<Stats, "isFile" | "uid" | "mode">): boolean;
|
|
35
|
+
export declare const DARWIN_PS_PATH = "/bin/ps";
|
|
36
|
+
/** Parses `ps -A -o pid=,pgid=,stat=`; any unexpected line rejects the whole sample. */
|
|
37
|
+
export declare function parsePsTable(output: string): readonly ProcessEntry[] | undefined;
|
|
38
|
+
export declare function createProcessTable(platform: SetupPlatform, dependencies?: ProcessTableDependencies): ProcessTable;
|
|
39
|
+
export type StatfsReader = (path: string) => Promise<{
|
|
40
|
+
readonly type: number;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function assertSupportedFilesystem(path: string, platform: SetupPlatform, readStatfs?: StatfsReader): Promise<void>;
|
|
43
|
+
/** From XNU `bsd/sys/fcntl.h`; Node's `fs.constants` does not export these. */
|
|
44
|
+
export declare const DARWIN_O_NONBLOCK = 4;
|
|
45
|
+
export declare const DARWIN_O_EXLOCK = 32;
|
|
46
|
+
export interface DarwinLockOptions {
|
|
47
|
+
readonly timeoutMs: number;
|
|
48
|
+
readonly signal?: AbortSignal;
|
|
49
|
+
readonly retryMs?: number;
|
|
50
|
+
readonly openFile?: (path: string, flags: number, mode: number) => Promise<FileHandle>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Opens the lock file with an exclusive lock on its open file description,
|
|
54
|
+
* which has flock(2) semantics on macOS. A busy lock is retried until the
|
|
55
|
+
* acquisition bound; a failed attempt holds no descriptor.
|
|
56
|
+
*/
|
|
57
|
+
export declare function openLockedDarwin(path: string, options: DarwinLockOptions): Promise<FileHandle>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { open, statfs } from "node:fs/promises";
|
|
4
|
+
export function currentSetupPlatform(platform = process.platform) {
|
|
5
|
+
return platform === "linux" || platform === "darwin" ? platform : undefined;
|
|
6
|
+
}
|
|
7
|
+
/** A regular, root-owned executable that no group or other user can replace. */
|
|
8
|
+
export function isTrustedSystemBinary(path, statFile = statSync) {
|
|
9
|
+
try {
|
|
10
|
+
const file = statFile(path);
|
|
11
|
+
return file.isFile() && file.uid === 0 && (file.mode & 0o022) === 0 && (file.mode & 0o111) !== 0;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function readLinuxProcessGroup(pid) {
|
|
18
|
+
const line = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
|
19
|
+
const close = line.lastIndexOf(") ");
|
|
20
|
+
const state = line[close + 2];
|
|
21
|
+
if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ")
|
|
22
|
+
throw new Error("process_stat_invalid");
|
|
23
|
+
const fields = line.slice(close + 4).trim().split(/\s+/);
|
|
24
|
+
const group = Number(fields[1]);
|
|
25
|
+
if (!Number.isSafeInteger(group) || group < 0)
|
|
26
|
+
throw new Error("process_group_invalid");
|
|
27
|
+
return { group, state };
|
|
28
|
+
}
|
|
29
|
+
function linuxGroupState(group) {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync("/proc", { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "unknown";
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
const sample = readLinuxProcessGroup(Number(entry.name));
|
|
42
|
+
if (sample.group === group && sample.state !== "Z")
|
|
43
|
+
return "alive";
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== "ENOENT")
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "gone";
|
|
51
|
+
}
|
|
52
|
+
function linuxTableReadable(selfPid) {
|
|
53
|
+
let sawSelf = false;
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of readdirSync("/proc", { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const pid = Number(entry.name);
|
|
60
|
+
readLinuxProcessGroup(pid);
|
|
61
|
+
if (pid === selfPid)
|
|
62
|
+
sawSelf = true;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT")
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return sawSelf;
|
|
74
|
+
}
|
|
75
|
+
export const DARWIN_PS_PATH = "/bin/ps";
|
|
76
|
+
const PS_ARGUMENTS = ["-A", "-o", "pid=,pgid=,stat="];
|
|
77
|
+
const PS_TIMEOUT_MS = 2_000;
|
|
78
|
+
const PS_MAX_OUTPUT_BYTES = 1_048_576;
|
|
79
|
+
/** Parses `ps -A -o pid=,pgid=,stat=`; any unexpected line rejects the whole sample. */
|
|
80
|
+
export function parsePsTable(output) {
|
|
81
|
+
const body = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
82
|
+
if (body.length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const entries = [];
|
|
85
|
+
for (const line of body.split("\n")) {
|
|
86
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
|
|
87
|
+
if (!match)
|
|
88
|
+
return undefined;
|
|
89
|
+
const pid = Number(match[1]);
|
|
90
|
+
const group = Number(match[2]);
|
|
91
|
+
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(group))
|
|
92
|
+
return undefined;
|
|
93
|
+
entries.push({ pid, group, state: match[3] });
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
function runDarwinPs() {
|
|
98
|
+
if (!isTrustedSystemBinary(DARWIN_PS_PATH))
|
|
99
|
+
return Promise.resolve(undefined);
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
try {
|
|
102
|
+
execFile(DARWIN_PS_PATH, [...PS_ARGUMENTS], { env: { LC_ALL: "C" }, timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_OUTPUT_BYTES, encoding: "utf8" }, (error, stdout) => {
|
|
103
|
+
// A non-zero exit, timeout, output overflow or spawn failure is indeterminate.
|
|
104
|
+
resolve(error ? undefined : { exitCode: 0, stdout });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
resolve(undefined);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function createProcessTable(platform, dependencies = {}) {
|
|
113
|
+
const selfPid = dependencies.selfPid ?? process.pid;
|
|
114
|
+
if (platform === "linux") {
|
|
115
|
+
return { pollMs: 10, groupState: async (group) => linuxGroupState(group), readable: async () => linuxTableReadable(selfPid) };
|
|
116
|
+
}
|
|
117
|
+
const runPs = dependencies.runPs ?? runDarwinPs;
|
|
118
|
+
const scan = async () => {
|
|
119
|
+
const output = await runPs();
|
|
120
|
+
if (!output || output.exitCode !== 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
const entries = parsePsTable(output.stdout);
|
|
123
|
+
// A sample without the caller cannot be a complete process table.
|
|
124
|
+
return entries?.some((entry) => entry.pid === selfPid) ? entries : undefined;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
pollMs: 50,
|
|
128
|
+
async groupState(group) {
|
|
129
|
+
const entries = await scan();
|
|
130
|
+
if (!entries)
|
|
131
|
+
return "unknown";
|
|
132
|
+
return entries.some((entry) => entry.group === group && !entry.state.startsWith("Z")) ? "alive" : "gone";
|
|
133
|
+
},
|
|
134
|
+
async readable() { return (await scan()) !== undefined; },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Kernel flock and directory fsync semantics are accepted only on the local
|
|
138
|
+
// Linux filesystems covered by the setup test matrix.
|
|
139
|
+
const SUPPORTED_LINUX_FILESYSTEMS = new Set([
|
|
140
|
+
0xef53, // ext2/3/4
|
|
141
|
+
0x58465342, // XFS
|
|
142
|
+
0x9123683e, // Btrfs
|
|
143
|
+
0x01021994, // tmpfs
|
|
144
|
+
0x794c7630, // overlayfs
|
|
145
|
+
0x2fc12fc1, // ZFS
|
|
146
|
+
0xf2f52010, // F2FS
|
|
147
|
+
0x24051905, // UBIFS
|
|
148
|
+
0xca451a4e, // bcachefs
|
|
149
|
+
]);
|
|
150
|
+
export async function assertSupportedFilesystem(path, platform, readStatfs = (target) => statfs(target)) {
|
|
151
|
+
const filesystem = await readStatfs(path);
|
|
152
|
+
if (platform === "linux") {
|
|
153
|
+
if (!SUPPORTED_LINUX_FILESYSTEMS.has(filesystem.type))
|
|
154
|
+
throw new Error("ownership_filesystem_unsupported");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// macOS assigns f_type dynamically, so the directory must share the type of
|
|
158
|
+
// `/`, the sealed APFS system volume on every macOS that Node.js 22 supports.
|
|
159
|
+
if (filesystem.type !== (await readStatfs("/")).type)
|
|
160
|
+
throw new Error("ownership_filesystem_unsupported");
|
|
161
|
+
}
|
|
162
|
+
/** From XNU `bsd/sys/fcntl.h`; Node's `fs.constants` does not export these. */
|
|
163
|
+
export const DARWIN_O_NONBLOCK = 0x4;
|
|
164
|
+
export const DARWIN_O_EXLOCK = 0x20;
|
|
165
|
+
/**
|
|
166
|
+
* Opens the lock file with an exclusive lock on its open file description,
|
|
167
|
+
* which has flock(2) semantics on macOS. A busy lock is retried until the
|
|
168
|
+
* acquisition bound; a failed attempt holds no descriptor.
|
|
169
|
+
*/
|
|
170
|
+
export async function openLockedDarwin(path, options) {
|
|
171
|
+
const openFile = options.openFile ?? ((target, flags, mode) => open(target, flags, mode));
|
|
172
|
+
const flags = constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | DARWIN_O_EXLOCK | DARWIN_O_NONBLOCK;
|
|
173
|
+
const boundMs = Number.isFinite(options.timeoutMs) ? Math.max(1_000, Math.min(options.timeoutMs, 120_000)) : 120_000;
|
|
174
|
+
const deadline = Date.now() + boundMs;
|
|
175
|
+
const retryMs = options.retryMs ?? 50;
|
|
176
|
+
while (true) {
|
|
177
|
+
if (options.signal?.aborted)
|
|
178
|
+
throw new Error("operation_aborted");
|
|
179
|
+
try {
|
|
180
|
+
return await openFile(path, flags, 0o600);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const code = error.code;
|
|
184
|
+
if (code !== "EAGAIN" && code !== "EWOULDBLOCK")
|
|
185
|
+
throw new Error("ownership_lock_unavailable");
|
|
186
|
+
}
|
|
187
|
+
if (Date.now() >= deadline)
|
|
188
|
+
throw new Error("ownership_lock_busy");
|
|
189
|
+
await new Promise((resolve) => {
|
|
190
|
+
const done = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", done); resolve(); };
|
|
191
|
+
const timer = setTimeout(done, retryMs);
|
|
192
|
+
options.signal?.addEventListener("abort", done, { once: true });
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/setup-process.d.ts
CHANGED
|
@@ -23,8 +23,11 @@ export interface McpLauncherOptions {
|
|
|
23
23
|
/** Starts the local MCP server with only the controlled configuration environment. */
|
|
24
24
|
export declare function createMcpStdioLauncher(options: McpLauncherOptions): (signal: AbortSignal) => Promise<McpStdioProcess>;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
26
|
+
* Starts the transaction guardian with the kernel fence held. On Linux the
|
|
27
|
+
* trusted util-linux `flock` locks the caller's open file description first;
|
|
28
|
+
* on macOS the caller passes a descriptor from `openLockedDarwin`, which is
|
|
29
|
+
* locked at open time. The guardian inherits the locked file description, so
|
|
30
|
+
* parent death cannot release the fence while a client mutation or
|
|
31
|
+
* descendant is still live.
|
|
29
32
|
*/
|
|
30
33
|
export declare function acquireSetupGuardian(lockFd: number, timeoutMs: number, signal?: AbortSignal, validateFence?: () => Promise<void>): Promise<SetupGuardian>;
|