pi-session-mcp 0.6.4 → 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.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 +37 -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 +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 +216 -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 +122 -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
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
|
+
}
|
package/docs/acceptance.md
CHANGED
|
@@ -124,6 +124,54 @@ that checkout with Doctor reporting the release version, and confirms that the
|
|
|
124
124
|
manifests and built runtime files are byte-identical before and after the
|
|
125
125
|
client cycles.
|
|
126
126
|
|
|
127
|
+
### macOS
|
|
128
|
+
|
|
129
|
+
macOS uses the same isolation, sequence and pass criteria, with the additions
|
|
130
|
+
below. A run for Claude Code is recorded under [Status](#status). macOS is
|
|
131
|
+
CI-tested, and it has not passed Level 2 for Codex.
|
|
132
|
+
|
|
133
|
+
- **Environment.** Record:
|
|
134
|
+
- the macOS version and architecture (`sw_vers`, `uname -m`);
|
|
135
|
+
- the Node.js version;
|
|
136
|
+
- the Codex CLI and Claude Code versions.
|
|
137
|
+
- **Disposable home.** It must be on the APFS volume type of `/`. A directory
|
|
138
|
+
below the user's home qualifies.
|
|
139
|
+
- For an ownership directory on another filesystem, `--apply`, `--verify`,
|
|
140
|
+
`--rollback` and `--remove` fail closed with `failed (operation_failed)`
|
|
141
|
+
and exit `1`.
|
|
142
|
+
- Keep the Codex home outside both `$TMPDIR` (normally below `/var/folders`)
|
|
143
|
+
and `/tmp`, for the reason given under [Isolation](#isolation).
|
|
144
|
+
- **Before the sequence:** run `npm run --silent doctor`.
|
|
145
|
+
- **Lock contention.** After step 9, start two `--apply` runs for
|
|
146
|
+
`claude-code:user:pi-session-mcp` at the same time.
|
|
147
|
+
- One run reports `ok (applied)`. The other waits for the lock, then
|
|
148
|
+
reports `unchanged (already_equivalent)`.
|
|
149
|
+
- Both exit with `0`.
|
|
150
|
+
- A following `--remove` reports `ok (removed)`.
|
|
151
|
+
- A waiting run that reports `failed (operation_failed)` with exit `1` did
|
|
152
|
+
not get the lock within the 30 s acquisition bound. That counts as a
|
|
153
|
+
failure of the run.
|
|
154
|
+
- **Interruption.** Start `--apply` for one target and press Ctrl-C while it
|
|
155
|
+
runs.
|
|
156
|
+
- The run exits with `130` and reports `operation_interrupted`.
|
|
157
|
+
- Afterwards `ps -A -o pid=,command=` lists no `claude mcp` or `codex mcp`
|
|
158
|
+
command and no `setup-main.js` or `setup-command-guardian.js` process of
|
|
159
|
+
the build under test.
|
|
160
|
+
- A following `--apply` reports one of three outcomes, depending on where
|
|
161
|
+
the interrupt landed:
|
|
162
|
+
- it reconciles the pending record (`ok (pending_recovered)`);
|
|
163
|
+
- it applies again (`ok (applied)`);
|
|
164
|
+
- it finds the registration already owned
|
|
165
|
+
(`unchanged (already_equivalent)`).
|
|
166
|
+
- A following `--remove` reports `ok (removed)`.
|
|
167
|
+
- If the operation completes before the interrupt arrives, record that and
|
|
168
|
+
repeat. An interrupt that never lands is not a pass.
|
|
169
|
+
- **Processes.** Check that no process remains with `ps` instead of `/proc`.
|
|
170
|
+
|
|
171
|
+
The evidence record names the build (commit or tag), the environment above,
|
|
172
|
+
every command with its exit status, and the sanitized JSON results. It
|
|
173
|
+
contains no paths, environment values or secrets.
|
|
174
|
+
|
|
127
175
|
## Level 3: live two-client acceptance
|
|
128
176
|
|
|
129
177
|
Live acceptance delegates real work from Codex and from Claude Code to Pi
|
|
@@ -213,6 +261,18 @@ external MCP grants, and client restart or resume.
|
|
|
213
261
|
A narrower live check can cover a single path. It must state its scope and does
|
|
214
262
|
not count as a ten-task cycle.
|
|
215
263
|
|
|
264
|
+
## Pi extension live check
|
|
265
|
+
|
|
266
|
+
Manual, on a machine with Pi, `pi-cursor-sdk@0.5.3` and a stored Cursor key. Run it from a working tree (`npm run build`, then `node dist/main.js`) with a temporary configuration, passed through `PI_SESSION_MCP_CONFIG`, whose profile names `"extensions": ["npm:pi-cursor-sdk@0.5.3"]` and a Cursor model from Pi's catalog.
|
|
267
|
+
|
|
268
|
+
1. `pi_session_start`, a prompt answered with "PONG", `pi_turn_get` completed, `pi_session_close`.
|
|
269
|
+
2. Two parallel sessions of that profile, each completing a prompt.
|
|
270
|
+
3. After close, no Cursor child process of the server remains.
|
|
271
|
+
4. stderr shows no `foreign_stdout_suppressed` storm and no stdout frame fails to parse.
|
|
272
|
+
5. Record how the Cursor local runtime starts its child processes (inherited or piped stdio).
|
|
273
|
+
|
|
274
|
+
Record date, source commit, versions and outcome under Status. No secret, path or prompt content goes into the record.
|
|
275
|
+
|
|
216
276
|
## Evidence rules
|
|
217
277
|
|
|
218
278
|
- Record only sanitized evidence: statuses, booleans, counts, durations,
|
|
@@ -269,3 +329,159 @@ Before its release, `v0.6.4` passed Level 1 and the working-tree Level 2 on its
|
|
|
269
329
|
release candidate, with Codex CLI `0.162.0` and Claude Code `2.1.296`. Its
|
|
270
330
|
server entry point `dist/main.js` is byte-identical to that of `4d333da` and of
|
|
271
331
|
`v0.6.3`.
|
|
332
|
+
|
|
333
|
+
After its release, `v0.6.4` passed Level 1 on a fresh clone of its tag. It also
|
|
334
|
+
passed Level 2 as published release and as published npm package, with Codex
|
|
335
|
+
CLI `0.162.0` and Claude Code `2.1.296`, on 2026-10-10. The installed
|
|
336
|
+
`pi-session-mcp-doctor` reported a healthy package when run from an unrelated
|
|
337
|
+
working directory.
|
|
338
|
+
|
|
339
|
+
macOS: `setup` supports macOS from the change that tracks #32. That change is
|
|
340
|
+
tested in CI on macOS 15 and macOS 26 (Apple silicon) next to Linux.
|
|
341
|
+
|
|
342
|
+
On 2026-10-11, the build of `d89740c`, the head of the pull request branch,
|
|
343
|
+
passed the working-tree Level 2 on a real Mac with Claude Code as the only
|
|
344
|
+
client:
|
|
345
|
+
- Environment: macOS 26.7.1 (`25G241`) on `arm64`, Node.js `22.23.3` and Claude
|
|
346
|
+
Code `2.1.294`. The disposable home was on the APFS data volume that the
|
|
347
|
+
root volume belongs to.
|
|
348
|
+
- Level 1 on that checkout: `npm ci`, `npm run typecheck`, `npm run build`,
|
|
349
|
+
`npm run build:cli` and `npm test` exited with `0`. The test run reported 531
|
|
350
|
+
passed and 23 skipped. The skipped tests are the 21 Linux `/proc` fixture
|
|
351
|
+
tests, the Linux-only lock-inode replacement test and the probe-only
|
|
352
|
+
controller output test. `npm audit` reported 0 vulnerabilities, and
|
|
353
|
+
`git diff --check` reported nothing. `git status --short` printed nothing
|
|
354
|
+
before the evidence was written.
|
|
355
|
+
- Doctor with the acceptance configuration reported no error.
|
|
356
|
+
- All three Claude Code targets (`user`, `project` and `local`) completed the
|
|
357
|
+
nine-step sequence. Every step reported the expected overall status, finding
|
|
358
|
+
and exit status.
|
|
359
|
+
- After the sequences the `pi-session-mcp` registration was absent, the sentinel
|
|
360
|
+
was still present and the ownership record set was empty. The ownership
|
|
361
|
+
directory had mode `0700` and the ownership file mode `0600`. No server,
|
|
362
|
+
setup or guardian process of the build remained, and the digest of the real
|
|
363
|
+
Claude Code registrations was unchanged. No report contained a path, an
|
|
364
|
+
environment value or a secret.
|
|
365
|
+
- Lock contention: two simultaneous `--apply` runs for
|
|
366
|
+
`claude-code:user:pi-session-mcp` finished within 5 s. One reported
|
|
367
|
+
`ok (applied)` and the other `unchanged (already_equivalent)`. Both exited
|
|
368
|
+
with `0`, and the following `--remove` reported `ok (removed)`.
|
|
369
|
+
- Interruption: SIGINT was sent to the process group of a running `--apply`
|
|
370
|
+
after 0.2 s, 0.5 s and 0.9 s. Each run exited with `130` and reported
|
|
371
|
+
`operation_interrupted`. None left a `claude mcp` command or a setup or
|
|
372
|
+
guardian process behind. In each case the following `--apply` reported
|
|
373
|
+
`ok (applied)` and the following `--remove` reported `ok (removed)`.
|
|
374
|
+
|
|
375
|
+
This run does not cover:
|
|
376
|
+
- Codex, which was not installed on the machine, so macOS has not passed
|
|
377
|
+
setup acceptance for Codex.
|
|
378
|
+
- A Ctrl-C keypress in an interactive terminal. A script sent the signal from a
|
|
379
|
+
shell without a terminal.
|
|
380
|
+
- The `pending_recovered` and `already_equivalent` outcomes after an interrupt.
|
|
381
|
+
The following `--apply` reported `applied` in every case.
|
|
382
|
+
- Intel Macs, the published-release and published-package variants, and
|
|
383
|
+
Level 3.
|
|
384
|
+
|
|
385
|
+
On 2026-10-11 the same build also passed a narrower live check on that Mac:
|
|
386
|
+
- Scope: Claude Code `2.1.294` as the only client, with Pi SDK `1.1.0` on a
|
|
387
|
+
local `qwen3.6-35b-a3b-splash` model served by LM Studio. The server was the
|
|
388
|
+
build of `d89740c`: the sources are identical to it, and `dist/main.js` has
|
|
389
|
+
the SHA-256 `a69bfec2f9f161b74a6d7b067fda9b76d94c294170bc59a771a79e3f29a7123c`.
|
|
390
|
+
- Method: three sequential headless invocations, one per task group, with a
|
|
391
|
+
temporary per-invocation MCP configuration (`--strict-mcp-config`), a
|
|
392
|
+
credential-free configuration with one `read-only` and one `coding` profile,
|
|
393
|
+
and a disposable Pi agent directory. The client could run `sleep` between
|
|
394
|
+
polls. The plan, prompts, oracles and fixture digests were fixed before the
|
|
395
|
+
first invocation, and no task was repeated.
|
|
396
|
+
- Preflight: one bounded request to the model returned HTTP `200` in about 1 s.
|
|
397
|
+
The loaded model was the same and idle before and after every invocation, and
|
|
398
|
+
nothing was loaded or unloaded.
|
|
399
|
+
- Read-only analysis with a same-session follow-up: both turns ended
|
|
400
|
+
`completed`. The first answer contained the expected marker, and the second
|
|
401
|
+
answer was exactly the expected number. The fixture digest (path, mode and
|
|
402
|
+
content) was identical before and after.
|
|
403
|
+
- Bounded coding change with a same-session follow-up: both turns ended
|
|
404
|
+
`completed`. The oracle passed on a snapshot taken between the turns and on
|
|
405
|
+
the final workspace, and that snapshot did not yet contain the second turn's
|
|
406
|
+
change. Only the one allowed file differed, with no added or removed file and
|
|
407
|
+
unchanged modes.
|
|
408
|
+
- Abort and recovery: the turn was `running`, then `aborted` after
|
|
409
|
+
`pi_session_abort`. `pi_session_close` succeeded, `pi_session_list` returned
|
|
410
|
+
no session, and a fresh session started `idle` and closed.
|
|
411
|
+
- Counts: `pi_turn_get` was called 3, 3 and 2 times in the three invocations,
|
|
412
|
+
and no Pi Session MCP tool returned an error.
|
|
413
|
+
- A scan of every Pi Session MCP tool result in the three transcripts found no
|
|
414
|
+
absolute path, configuration file name, endpoint, key or token marker, or stack
|
|
415
|
+
frame. No server or client process remained after any invocation, each
|
|
416
|
+
client's standard error was empty, and the digest of the real Claude Code
|
|
417
|
+
registrations was unchanged.
|
|
418
|
+
|
|
419
|
+
This check does not cover:
|
|
420
|
+
- Codex, or any task from a second client. It is not a ten-task cycle and does
|
|
421
|
+
not satisfy the Level 3 gate.
|
|
422
|
+
- The check that the first turn's terminal view stays unchanged after a second
|
|
423
|
+
turn started. The transcripts do not poll the first turn again.
|
|
424
|
+
- A git-based check of the allowed diff. The coding fixture is not a Git
|
|
425
|
+
repository, so a path, mode and content digest stood in for it.
|
|
426
|
+
- A coding turn that tries to change a file outside the allowed set.
|
|
427
|
+
- Interactive approval dialogs, external MCP grants, and client restart or
|
|
428
|
+
resume.
|
|
429
|
+
|
|
430
|
+
On 2026-10-11 the working-tree build of `eeaa597` on the `feat/pi-extensions`
|
|
431
|
+
branch (#34) passed the Pi extension live check on Linux:
|
|
432
|
+
- Environment: Node.js `26.11.1`, Pi SDK `1.1.0`, and `pi-cursor-sdk@0.5.3`
|
|
433
|
+
installed by Pi with a stored Cursor key. `dist/main.js` has the SHA-256
|
|
434
|
+
`47875d38886528ace6b242df2c6ad79d624c51e330e63c2f401576eadc449ed7`.
|
|
435
|
+
- Method: a scripted JSON-RPC client drove `node dist/main.js` over stdio, with
|
|
436
|
+
a temporary configuration passed through `PI_SESSION_MCP_CONFIG`. The
|
|
437
|
+
configuration had a `coding` and a `read-only` profile, each with provider
|
|
438
|
+
`cursor`, model `auto`, thinking level `off` and
|
|
439
|
+
`"extensions": ["npm:pi-cursor-sdk@0.5.3"]`.
|
|
440
|
+
- The `coding` profile started, its prompt completed with "PONG", and the
|
|
441
|
+
session closed.
|
|
442
|
+
- Two sessions, one of each profile, started in parallel and both prompts
|
|
443
|
+
completed with "PONG".
|
|
444
|
+
- After close, no descendant process of the server remained.
|
|
445
|
+
- No stdout line failed to parse as an MCP frame. stderr carried two
|
|
446
|
+
`foreign_stdout_suppressed` reports for 21 suppressed writes and no other
|
|
447
|
+
event.
|
|
448
|
+
- During turns the Cursor local runtime started `git`, `ssh`, `gh`, `node` and
|
|
449
|
+
the MCP servers configured for Cursor through `npm exec`, in the `read-only`
|
|
450
|
+
profile as well. None of them inherited the server's stdout: each had its own
|
|
451
|
+
pipe, a socket, `/dev/null` or a file as standard output.
|
|
452
|
+
- The first run, on the earlier build of `6740484`, failed to start the
|
|
453
|
+
`coding` profile with `execution_selection_mismatch`: `pi-cursor-sdk`
|
|
454
|
+
deactivates its own `cursor_activate_skill` tool when the session starts.
|
|
455
|
+
`eeaa597` lets an extension narrow its active tools while still rejecting
|
|
456
|
+
any tool outside the allowlist.
|
|
457
|
+
|
|
458
|
+
This check does not cover macOS, Codex or Claude Code as the client, the Cursor
|
|
459
|
+
cloud runtime, or a session that uses Cursor's own tools.
|
|
460
|
+
|
|
461
|
+
Before its release, `v0.7.0` passed Level 1 and the working-tree Level 2 on its
|
|
462
|
+
release candidate on Linux, with Node.js `26.11.1`, Codex CLI `0.162.1` and
|
|
463
|
+
Claude Code `2.1.296`, on 2026-10-11:
|
|
464
|
+
- The first working-tree Level 2, on `2d05e55`, failed. On all four targets
|
|
465
|
+
`--verify` (steps 5 and 8) reported `failed (mcp_failed)` with exit `1`; every
|
|
466
|
+
other step passed. The verifier rejected the `extensions` field that
|
|
467
|
+
`pi_capabilities_get` lists since #34. This is #36.
|
|
468
|
+
- After the fix, the working-tree Level 2 on `2c5ab3d` passed all nine steps on
|
|
469
|
+
all four targets, 96 of 96 checks.
|
|
470
|
+
- The real client registrations were unchanged, no process remained, and the
|
|
471
|
+
built files were byte-identical before and after the client cycles.
|
|
472
|
+
- The run placed one sentinel in each Claude Code scope instead of one per
|
|
473
|
+
client, so its check count differs from earlier records.
|
|
474
|
+
- Level 1 on `09b32a8`: every command exited with `0`.
|
|
475
|
+
- The test run reported 37 files, 660 passed and 5 skipped.
|
|
476
|
+
- `npm audit` and `npm audit --omit=dev` reported 0 vulnerabilities.
|
|
477
|
+
- Doctor reported `packageVersion` `0.7.0` and no finding.
|
|
478
|
+
- The commits after `2c5ab3d` change only tests and documentation. The built
|
|
479
|
+
`dist/` and `.pi-session-mcp-cli/` are byte-identical to those of `2c5ab3d`,
|
|
480
|
+
and `dist/main.js` has the SHA-256
|
|
481
|
+
`47875d38886528ace6b242df2c6ad79d624c51e330e63c2f401576eadc449ed7`.
|
|
482
|
+
- The Codex CLI `0.162.1` contract was recorded. It differs from `0.162.0` only
|
|
483
|
+
in the version line.
|
|
484
|
+
|
|
485
|
+
The Pi extension live check above ran on `eeaa597`, before the final fixes to
|
|
486
|
+
extension tool names and the `read-only` contract test. It has not been
|
|
487
|
+
repeated on the release candidate.
|