pi-session-mcp 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/config.js +11 -1
- package/.pi-session-mcp-cli/doctor.js +22 -1
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/extension-spec.js +26 -0
- package/.pi-session-mcp-cli/pi-extensions.js +67 -0
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +5 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -1
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +22 -1
- package/dist/execution-profile.d.ts +3 -0
- package/dist/execution-profile.js +1 -0
- package/dist/extension-spec.d.ts +15 -0
- package/dist/extension-spec.js +26 -0
- package/dist/main.js +6 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +4 -0
- package/dist/pi-extensions.d.ts +17 -0
- package/dist/pi-extensions.js +67 -0
- package/dist/sdk-pi-adapter.d.ts +24 -6
- package/dist/sdk-pi-adapter.js +256 -86
- package/dist/server.js +1 -0
- package/dist/session-tools.d.ts +9 -0
- package/dist/session-tools.js +41 -0
- package/dist/setup-command-guardian.js +29 -73
- package/dist/setup-main.js +2 -1
- package/dist/setup-ownership.js +18 -28
- package/dist/setup-platform.d.ts +57 -0
- package/dist/setup-platform.js +195 -0
- package/dist/setup-process.d.ts +6 -3
- package/dist/setup-process.js +85 -93
- package/dist/setup.js +2 -1
- package/dist/stdout-guard-install.d.ts +5 -0
- package/dist/stdout-guard-install.js +6 -0
- package/dist/stdout-guard.d.ts +20 -0
- package/dist/stdout-guard.js +72 -0
- package/docs/acceptance.md +343 -0
- package/docs/adr/0005-macos-setup-platform.md +84 -0
- package/docs/adr/0006-pi-extension-allowlist.md +58 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +21 -9
- package/docs/client-setup.md +25 -10
- package/docs/doctor-design.md +7 -2
- package/docs/installation.md +125 -4
- package/docs/macos-setup-design.md +386 -0
- package/docs/pi-extensions-design.md +437 -0
- package/docs/releasing.md +16 -7
- package/docs/threat-model.md +54 -18
- package/docs/tool-contracts.md +8 -3
- package/package.json +2 -1
package/dist/setup-process.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { lstatSync
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
const DEFAULT_TIMEOUT = 10_000;
|
|
5
5
|
const DEFAULT_MAX = 65_536;
|
|
6
6
|
const ABORT_GROUP_CLEANUP_TIMEOUT = 2_000;
|
|
7
7
|
const GUARDIAN_START_TIMEOUT = 5_000;
|
|
8
|
+
let platformModule;
|
|
9
|
+
/**
|
|
10
|
+
* Source-mode harnesses import this file through Node type stripping, which
|
|
11
|
+
* cannot map a static `./setup-platform.js` import to its `.ts` source.
|
|
12
|
+
*/
|
|
13
|
+
function loadSetupPlatform() {
|
|
14
|
+
platformModule ??= import(import.meta.url.endsWith(".ts") ? "./setup-platform.ts" : "./setup-platform.js");
|
|
15
|
+
return platformModule;
|
|
16
|
+
}
|
|
17
|
+
async function currentProcessTable() {
|
|
18
|
+
const platform = await loadSetupPlatform();
|
|
19
|
+
const name = platform.currentSetupPlatform();
|
|
20
|
+
return name ? platform.createProcessTable(name) : undefined;
|
|
21
|
+
}
|
|
8
22
|
/** Kill a detached process and every descendant in its process group. */
|
|
9
23
|
function terminateProcessGroup(child, signal) {
|
|
10
24
|
if (child.pid === undefined)
|
|
@@ -21,51 +35,16 @@ function terminateProcessGroup(child, signal) {
|
|
|
21
35
|
}
|
|
22
36
|
catch { /* already exited */ }
|
|
23
37
|
}
|
|
24
|
-
function processGroupState(child) {
|
|
25
|
-
if (child.pid === undefined || process.platform !== "linux")
|
|
26
|
-
return "unknown";
|
|
27
|
-
const group = child.pid;
|
|
28
|
-
try {
|
|
29
|
-
for (const entry of readdirSync("/proc", { withFileTypes: true })) {
|
|
30
|
-
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
31
|
-
continue;
|
|
32
|
-
try {
|
|
33
|
-
const stat = readProcStat(Number(entry.name));
|
|
34
|
-
if (stat.pgrp === group && stat.state !== "Z")
|
|
35
|
-
return "alive";
|
|
36
|
-
}
|
|
37
|
-
catch (error) {
|
|
38
|
-
if (error.code !== "ENOENT")
|
|
39
|
-
return "unknown";
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return "unknown";
|
|
45
|
-
}
|
|
46
|
-
return "gone";
|
|
47
|
-
}
|
|
48
|
-
function readProcStat(pid) {
|
|
49
|
-
const line = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
|
50
|
-
const close = line.lastIndexOf(") ");
|
|
51
|
-
const state = line[close + 2];
|
|
52
|
-
if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ")
|
|
53
|
-
throw new Error("process_stat_invalid");
|
|
54
|
-
const fields = line.slice(close + 4).trim().split(/\s+/);
|
|
55
|
-
const pgrp = Number(fields[1]);
|
|
56
|
-
if (!Number.isSafeInteger(pgrp) || pgrp < 0)
|
|
57
|
-
throw new Error("process_group_invalid");
|
|
58
|
-
return { pgrp, state };
|
|
59
|
-
}
|
|
60
38
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
61
39
|
/** Runs only an explicit executable/argv pair, with bounded output and termination. */
|
|
62
40
|
export function createProcessRunner(options = {}) {
|
|
63
41
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
64
42
|
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX;
|
|
65
|
-
return { run(command, args, signal) {
|
|
43
|
+
return { async run(command, args, signal) {
|
|
66
44
|
const effectiveSignal = options.signal && signal ? AbortSignal.any([options.signal, signal]) : options.signal ?? signal;
|
|
67
|
-
|
|
68
|
-
|
|
45
|
+
const table = await currentProcessTable();
|
|
46
|
+
if (!table)
|
|
47
|
+
return { exitCode: 126, stdout: "", stderr: "" };
|
|
69
48
|
return new Promise((resolve) => {
|
|
70
49
|
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"], env: options.env, detached: process.platform !== "win32" });
|
|
71
50
|
let stdout = "";
|
|
@@ -92,18 +71,26 @@ export function createProcessRunner(options = {}) {
|
|
|
92
71
|
clearTimeout(killTimer); if (!timedOut && hardTimer)
|
|
93
72
|
clearTimeout(hardTimer); if (groupPoll)
|
|
94
73
|
clearTimeout(groupPoll); resolve({ exitCode, stdout: overflow ? Buffer.from(stdout).subarray(0, maxOutputBytes).toString("utf8") : stdout, stderr: overflow ? "output_limit" : stderr }); };
|
|
74
|
+
let groupCheck = false;
|
|
95
75
|
const finishAfterGroup = (successCode) => {
|
|
96
|
-
if (done)
|
|
76
|
+
if (done || groupCheck)
|
|
97
77
|
return;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
78
|
+
groupCheck = true;
|
|
79
|
+
const group = child.pid;
|
|
80
|
+
void (group === undefined ? Promise.resolve("unknown") : table.groupState(group)).then((state) => {
|
|
81
|
+
groupCheck = false;
|
|
82
|
+
if (done)
|
|
83
|
+
return;
|
|
84
|
+
if (state === "gone") {
|
|
85
|
+
finish(successCode);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() >= groupDeadline) {
|
|
89
|
+
finish(126);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
groupPoll = setTimeout(() => finishAfterGroup(successCode), table.pollMs);
|
|
93
|
+
});
|
|
107
94
|
};
|
|
108
95
|
const abort = () => { if (done)
|
|
109
96
|
return; aborted = true; groupDeadline = Date.now() + ABORT_GROUP_CLEANUP_TIMEOUT; terminateProcessGroup(child, "SIGTERM"); killTimer = setTimeout(() => { terminateProcessGroup(child, "SIGKILL"); finishAfterGroup(125); }, 100); };
|
|
@@ -137,6 +124,7 @@ export function createProcessRunner(options = {}) {
|
|
|
137
124
|
/** Starts the local MCP server with only the controlled configuration environment. */
|
|
138
125
|
export function createMcpStdioLauncher(options) {
|
|
139
126
|
return async (signal) => {
|
|
127
|
+
const table = await currentProcessTable();
|
|
140
128
|
const child = spawn(options.nodePath, [options.entryPath], { stdio: ["pipe", "pipe", "pipe"], env: { PI_SESSION_MCP_CONFIG: options.configPath }, detached: process.platform !== "win32" });
|
|
141
129
|
const max = options.maxFrameBytes ?? DEFAULT_MAX;
|
|
142
130
|
let buffer = "";
|
|
@@ -194,33 +182,35 @@ export function createMcpStdioLauncher(options) {
|
|
|
194
182
|
await delay(100);
|
|
195
183
|
terminateProcessGroup(child, "SIGKILL");
|
|
196
184
|
const childClosed = await waitForClose(1_000);
|
|
185
|
+
const group = child.pid;
|
|
186
|
+
const groupState = async () => table && group !== undefined ? table.groupState(group) : "unknown";
|
|
197
187
|
const groupDeadline = Date.now() + 1_000;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
188
|
+
let state = await groupState();
|
|
189
|
+
while (state !== "gone" && Date.now() < groupDeadline) {
|
|
190
|
+
await delay(table?.pollMs ?? 10);
|
|
191
|
+
state = await groupState();
|
|
192
|
+
}
|
|
193
|
+
if (!childClosed || state !== "gone")
|
|
201
194
|
throw new Error("mcp_process_cleanup_failed");
|
|
202
195
|
},
|
|
203
196
|
};
|
|
204
197
|
};
|
|
205
198
|
}
|
|
206
199
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* the
|
|
200
|
+
* Starts the transaction guardian with the kernel fence held. On Linux the
|
|
201
|
+
* trusted util-linux `flock` locks the caller's open file description first;
|
|
202
|
+
* on macOS the caller passes a descriptor from `openLockedDarwin`, which is
|
|
203
|
+
* locked at open time. The guardian inherits the locked file description, so
|
|
204
|
+
* parent death cannot release the fence while a client mutation or
|
|
205
|
+
* descendant is still live.
|
|
210
206
|
*/
|
|
211
207
|
export async function acquireSetupGuardian(lockFd, timeoutMs, signal, validateFence) {
|
|
212
|
-
|
|
208
|
+
const platform = await loadSetupPlatform();
|
|
209
|
+
const platformName = platform.currentSetupPlatform();
|
|
210
|
+
if (!platformName)
|
|
213
211
|
throw new Error("platform_unsupported");
|
|
214
|
-
const flockPath = ["/usr/bin/flock", "/bin/flock"].find((candidate) =>
|
|
215
|
-
|
|
216
|
-
const file = statSync(candidate);
|
|
217
|
-
return file.isFile() && file.uid === 0 && (file.mode & 0o022) === 0 && (file.mode & 0o111) !== 0;
|
|
218
|
-
}
|
|
219
|
-
catch {
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
if (!flockPath)
|
|
212
|
+
const flockPath = platformName === "linux" ? ["/usr/bin/flock", "/bin/flock"].find((candidate) => platform.isTrustedSystemBinary(candidate)) : undefined;
|
|
213
|
+
if (platformName === "linux" && !flockPath)
|
|
224
214
|
throw new Error("ownership_lock_unavailable");
|
|
225
215
|
const modulePath = fileURLToPath(import.meta.url);
|
|
226
216
|
const guardianPath = fileURLToPath(new URL(modulePath.endsWith(".ts") ? "./setup-command-guardian.ts" : "./setup-command-guardian.js", import.meta.url));
|
|
@@ -231,34 +221,36 @@ export async function acquireSetupGuardian(lockFd, timeoutMs, signal, validateFe
|
|
|
231
221
|
catch {
|
|
232
222
|
throw new Error("ownership_lock_unavailable");
|
|
233
223
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
locker = spawn(flockPath, ["-E", "75", "-w", waitSeconds, "3"], {
|
|
238
|
-
stdio: ["ignore", "ignore", "ignore", lockFd],
|
|
239
|
-
detached: true,
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
throw new Error("ownership_lock_unavailable");
|
|
244
|
-
}
|
|
245
|
-
const stopAcquisition = () => { if (locker.pid !== undefined) {
|
|
224
|
+
if (flockPath) {
|
|
225
|
+
const waitSeconds = String(Math.ceil(Math.max(1_000, Math.min(timeoutMs, 120_000)) / 1_000));
|
|
226
|
+
let locker;
|
|
246
227
|
try {
|
|
247
|
-
|
|
228
|
+
locker = spawn(flockPath, ["-E", "75", "-w", waitSeconds, "3"], {
|
|
229
|
+
stdio: ["ignore", "ignore", "ignore", lockFd],
|
|
230
|
+
detached: true,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw new Error("ownership_lock_unavailable");
|
|
235
|
+
}
|
|
236
|
+
const stopAcquisition = () => { if (locker.pid !== undefined) {
|
|
237
|
+
try {
|
|
238
|
+
process.kill(-locker.pid, "SIGKILL");
|
|
239
|
+
}
|
|
240
|
+
catch { /* already gone */ }
|
|
241
|
+
} };
|
|
242
|
+
if (signal?.aborted)
|
|
243
|
+
stopAcquisition();
|
|
244
|
+
signal?.addEventListener("abort", stopAcquisition, { once: true });
|
|
245
|
+
try {
|
|
246
|
+
await new Promise((resolve, reject) => {
|
|
247
|
+
locker.once("error", () => reject(new Error("ownership_lock_unavailable")));
|
|
248
|
+
locker.once("close", (code) => code === 0 ? resolve() : reject(new Error(code === 75 ? "ownership_lock_busy" : signal?.aborted ? "operation_aborted" : "ownership_lock_unavailable")));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
signal?.removeEventListener("abort", stopAcquisition);
|
|
248
253
|
}
|
|
249
|
-
catch { /* already gone */ }
|
|
250
|
-
} };
|
|
251
|
-
if (signal?.aborted)
|
|
252
|
-
stopAcquisition();
|
|
253
|
-
signal?.addEventListener("abort", stopAcquisition, { once: true });
|
|
254
|
-
try {
|
|
255
|
-
await new Promise((resolve, reject) => {
|
|
256
|
-
locker.once("error", () => reject(new Error("ownership_lock_unavailable")));
|
|
257
|
-
locker.once("close", (code) => code === 0 ? resolve() : reject(new Error(code === 75 ? "ownership_lock_busy" : signal?.aborted ? "operation_aborted" : "ownership_lock_unavailable")));
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
finally {
|
|
261
|
-
signal?.removeEventListener("abort", stopAcquisition);
|
|
262
254
|
}
|
|
263
255
|
if (signal?.aborted)
|
|
264
256
|
throw new Error("operation_aborted");
|
package/dist/setup.js
CHANGED
|
@@ -254,7 +254,8 @@ export async function immutableReleaseBinding(packageRoot) {
|
|
|
254
254
|
throw new Error("release_version_mismatch");
|
|
255
255
|
const runtimeRoot = join(packageRoot, "dist");
|
|
256
256
|
const runtimeFiles = await listRuntimeFiles(runtimeRoot);
|
|
257
|
-
|
|
257
|
+
// The guardian loads setup-platform.js at runtime, so it is mandatory too.
|
|
258
|
+
for (const required of ["main.js", "setup-command-guardian.js", "setup-platform.js"]) {
|
|
258
259
|
if (!runtimeFiles.includes(required))
|
|
259
260
|
throw new Error("release_entry_missing");
|
|
260
261
|
const entry = await lstat(join(runtimeRoot, required));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { installStdoutGuard } from "./stdout-guard.js";
|
|
2
|
+
/**
|
|
3
|
+
* Installed when this module is evaluated. `main.ts` imports it first, so the guard is
|
|
4
|
+
* in place before any other module, the Pi SDK or an extension, can write to stdout.
|
|
5
|
+
*/
|
|
6
|
+
export const stdoutGuard = installStdoutGuard();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
export interface StdoutGuardOptions {
|
|
3
|
+
readonly stream?: NodeJS.WriteStream;
|
|
4
|
+
readonly console?: Pick<Console, "log" | "info" | "debug">;
|
|
5
|
+
readonly report?: (line: string) => void;
|
|
6
|
+
readonly intervalMs?: number;
|
|
7
|
+
readonly onExit?: (flush: () => void) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface StdoutGuard {
|
|
10
|
+
/** The only writer that reaches the real stdout: hand it to the MCP transport. */
|
|
11
|
+
readonly transportStdout: Writable;
|
|
12
|
+
/** Writes the pending suppression counts to stderr. */
|
|
13
|
+
flush(): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reserves stdout for MCP frames. Any other write, an extension's `console.log`
|
|
17
|
+
* included, is dropped; stderr gets only a byte and write count, never the content,
|
|
18
|
+
* because the content may hold prompts or secrets.
|
|
19
|
+
*/
|
|
20
|
+
export declare function installStdoutGuard(options?: StdoutGuardOptions): StdoutGuard;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { format } from "node:util";
|
|
2
|
+
/**
|
|
3
|
+
* Reserves stdout for MCP frames. Any other write, an extension's `console.log`
|
|
4
|
+
* included, is dropped; stderr gets only a byte and write count, never the content,
|
|
5
|
+
* because the content may hold prompts or secrets.
|
|
6
|
+
*/
|
|
7
|
+
export function installStdoutGuard(options = {}) {
|
|
8
|
+
const stream = options.stream ?? process.stdout;
|
|
9
|
+
const target = options.console ?? console;
|
|
10
|
+
const report = options.report ?? ((line) => { process.stderr.write(line); });
|
|
11
|
+
const intervalMs = options.intervalMs ?? 1_000;
|
|
12
|
+
const realWrite = stream.write.bind(stream);
|
|
13
|
+
const realEnd = stream.end.bind(stream);
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
let writes = 0;
|
|
16
|
+
let timer;
|
|
17
|
+
const flush = () => {
|
|
18
|
+
if (timer) {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
timer = undefined;
|
|
21
|
+
}
|
|
22
|
+
if (writes === 0)
|
|
23
|
+
return;
|
|
24
|
+
report(JSON.stringify({ level: "warning", event: "foreign_stdout_suppressed", bytes, writes }) + "\n");
|
|
25
|
+
bytes = 0;
|
|
26
|
+
writes = 0;
|
|
27
|
+
};
|
|
28
|
+
const suppress = (chunk, encoding) => {
|
|
29
|
+
bytes += typeof chunk === "string"
|
|
30
|
+
? Buffer.byteLength(chunk, typeof encoding === "string" ? encoding : "utf8")
|
|
31
|
+
: chunk instanceof Uint8Array ? chunk.byteLength : 0;
|
|
32
|
+
writes += 1;
|
|
33
|
+
if (!timer) {
|
|
34
|
+
timer = setTimeout(flush, intervalMs);
|
|
35
|
+
timer.unref();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
stream.write = ((chunk, encodingOrCallback, callback) => {
|
|
39
|
+
suppress(chunk, encodingOrCallback);
|
|
40
|
+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
41
|
+
if (typeof done === "function")
|
|
42
|
+
queueMicrotask(() => { done(); });
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
// A foreign `end` must neither leak content nor close the real stdout, which would break every later frame.
|
|
46
|
+
stream.end = ((...args) => {
|
|
47
|
+
const [first, second] = args;
|
|
48
|
+
if (typeof first === "string" || first instanceof Uint8Array)
|
|
49
|
+
suppress(first, second);
|
|
50
|
+
const done = args.find((arg) => typeof arg === "function");
|
|
51
|
+
if (typeof done === "function")
|
|
52
|
+
queueMicrotask(() => { done(); });
|
|
53
|
+
return stream;
|
|
54
|
+
});
|
|
55
|
+
for (const method of ["log", "info", "debug"]) {
|
|
56
|
+
target[method] = (...args) => { suppress(`${format(...args)}\n`); };
|
|
57
|
+
}
|
|
58
|
+
// Write-through, not a buffering wrapper: queued frames must reach stdout before `process.exit`,
|
|
59
|
+
// and a wrapper `Writable` would hold all but one of them until a later tick.
|
|
60
|
+
const transportStdout = new Proxy(stream, {
|
|
61
|
+
get(target, property) {
|
|
62
|
+
if (property === "write")
|
|
63
|
+
return realWrite;
|
|
64
|
+
if (property === "end")
|
|
65
|
+
return realEnd;
|
|
66
|
+
const value = Reflect.get(target, property, target);
|
|
67
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
(options.onExit ?? ((exitFlush) => { process.once("exit", exitFlush); }))(flush);
|
|
71
|
+
return { transportStdout, flush };
|
|
72
|
+
}
|