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.
Files changed (56) hide show
  1. package/.pi-session-mcp-cli/config.js +11 -1
  2. package/.pi-session-mcp-cli/doctor.js +22 -1
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/extension-spec.js +26 -0
  5. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  6. package/CHANGELOG.md +62 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +2 -2
  9. package/SECURITY.md +1 -1
  10. package/dist/capabilities.d.ts +1 -0
  11. package/dist/capabilities.js +2 -1
  12. package/dist/client-adapters/mcp-verifier.js +5 -2
  13. package/dist/config.d.ts +1 -0
  14. package/dist/config.js +11 -1
  15. package/dist/doctor.d.ts +4 -1
  16. package/dist/doctor.js +22 -1
  17. package/dist/execution-profile.d.ts +3 -0
  18. package/dist/execution-profile.js +1 -0
  19. package/dist/extension-spec.d.ts +15 -0
  20. package/dist/extension-spec.js +26 -0
  21. package/dist/main.js +6 -2
  22. package/dist/pi-adapter.d.ts +1 -1
  23. package/dist/pi-adapter.js +4 -0
  24. package/dist/pi-extensions.d.ts +17 -0
  25. package/dist/pi-extensions.js +67 -0
  26. package/dist/sdk-pi-adapter.d.ts +24 -6
  27. package/dist/sdk-pi-adapter.js +256 -86
  28. package/dist/server.js +1 -0
  29. package/dist/session-tools.d.ts +9 -0
  30. package/dist/session-tools.js +41 -0
  31. package/dist/setup-command-guardian.js +29 -73
  32. package/dist/setup-main.js +2 -1
  33. package/dist/setup-ownership.js +18 -28
  34. package/dist/setup-platform.d.ts +57 -0
  35. package/dist/setup-platform.js +195 -0
  36. package/dist/setup-process.d.ts +6 -3
  37. package/dist/setup-process.js +85 -93
  38. package/dist/setup.js +2 -1
  39. package/dist/stdout-guard-install.d.ts +5 -0
  40. package/dist/stdout-guard-install.js +6 -0
  41. package/dist/stdout-guard.d.ts +20 -0
  42. package/dist/stdout-guard.js +72 -0
  43. package/docs/acceptance.md +343 -0
  44. package/docs/adr/0005-macos-setup-platform.md +84 -0
  45. package/docs/adr/0006-pi-extension-allowlist.md +58 -0
  46. package/docs/architecture.md +1 -1
  47. package/docs/client-setup-design.md +21 -9
  48. package/docs/client-setup.md +25 -10
  49. package/docs/doctor-design.md +7 -2
  50. package/docs/installation.md +125 -4
  51. package/docs/macos-setup-design.md +386 -0
  52. package/docs/pi-extensions-design.md +437 -0
  53. package/docs/releasing.md +16 -7
  54. package/docs/threat-model.md +54 -18
  55. package/docs/tool-contracts.md +8 -3
  56. package/package.json +2 -1
@@ -1,10 +1,24 @@
1
1
  import { spawn } from "node:child_process";
2
- import { lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
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
- if (process.platform !== "linux")
68
- return Promise.resolve({ exitCode: 126, stdout: "", stderr: "" });
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
- if (processGroupState(child) === "gone") {
99
- finish(successCode);
100
- return;
101
- }
102
- if (Date.now() >= groupDeadline) {
103
- finish(126);
104
- return;
105
- }
106
- groupPoll = setTimeout(() => finishAfterGroup(successCode), 10);
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
- while (processGroupState(child) === "alive" && Date.now() < groupDeadline)
199
- await delay(10);
200
- if (!childClosed || processGroupState(child) !== "gone")
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
- * Acquires the Linux kernel fence and starts the transaction guardian. The
208
- * guardian inherits the locked file description, so parent death cannot release
209
- * the fence while a client mutation or descendant is still live.
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
- if (process.platform !== "linux")
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
- try {
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
- const waitSeconds = String(Math.ceil(Math.max(1_000, Math.min(timeoutMs, 120_000)) / 1_000));
235
- let locker;
236
- try {
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
- process.kill(-locker.pid, "SIGKILL");
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
- for (const required of ["main.js", "setup-command-guardian.js"]) {
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,5 @@
1
+ /**
2
+ * Installed when this module is evaluated. `main.ts` imports it first, so the guard is
3
+ * in place before any other module, the Pi SDK or an extension, can write to stdout.
4
+ */
5
+ export declare const stdoutGuard: import("./stdout-guard.js").StdoutGuard;
@@ -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
+ }