pum-agent 0.2.37-beta.1 → 0.2.38-beta.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/package.json +1 -1
- package/src/app.tsx +3 -3
- package/src/main.tsx +3 -1
- package/src/mcp-config.ts +13 -3
- package/src/mcp-process.ts +182 -17
- package/src/mcp-protocol.ts +8 -1
- package/src/mcp.ts +18 -5
- package/src/sandbox/windows.ts +43 -3
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
|
|
|
10
10
|
import { diagnosticsCommandText } from "./request-diagnostics-access";
|
|
11
11
|
import { checkpointControllerForSession, checkpointRecoveryFailureText } from "./file-checkpoints";
|
|
12
12
|
import { VALIDATION_CUSTOM_TYPE, validationForSession } from "./project-validation";
|
|
13
|
-
import type
|
|
13
|
+
import { mcpCommandErrorMessage, type McpController } from "./mcp";
|
|
14
14
|
import type { LspController } from "./lsp";
|
|
15
15
|
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
16
16
|
import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
@@ -4877,9 +4877,9 @@ export function App({
|
|
|
4877
4877
|
}
|
|
4878
4878
|
void controller.command(promptText).then((text) => {
|
|
4879
4879
|
if (sessionRef.current === session && mcpForSession?.(session) === controller) report(text);
|
|
4880
|
-
}).catch(() => {
|
|
4880
|
+
}).catch((error: unknown) => {
|
|
4881
4881
|
if (sessionRef.current === session && mcpForSession?.(session) === controller) {
|
|
4882
|
-
report(
|
|
4882
|
+
report(mcpCommandErrorMessage(error), true);
|
|
4883
4883
|
}
|
|
4884
4884
|
});
|
|
4885
4885
|
return;
|
package/src/main.tsx
CHANGED
|
@@ -329,6 +329,8 @@ export async function start(
|
|
|
329
329
|
let mcpSession: AgentSession | undefined;
|
|
330
330
|
let mcpDisposed = false;
|
|
331
331
|
const mcpProcess = createMcpProcessAdapter({ configDir: AGENT_DIR });
|
|
332
|
+
// LSP keeps the reviewed Linux-only boundary; only the MCP controller uses MXC on Windows.
|
|
333
|
+
const lspProcess = createMcpProcessAdapter({ configDir: AGENT_DIR, allowWindows: false });
|
|
332
334
|
const mcp: McpController = new McpController({
|
|
333
335
|
cwd,
|
|
334
336
|
spawn: (request) => mcpProcess.spawn(request),
|
|
@@ -337,7 +339,7 @@ export async function start(
|
|
|
337
339
|
});
|
|
338
340
|
const lsp: LspController = new LspController({
|
|
339
341
|
cwd,
|
|
340
|
-
spawn: (request) =>
|
|
342
|
+
spawn: (request) => lspProcess.spawn(request),
|
|
341
343
|
isIdle: () => isRuntimeIdle(mcpSession),
|
|
342
344
|
isCurrent: (): boolean => !mcpDisposed && !!mcpSession && lspControllers.get(mcpSession) === lsp,
|
|
343
345
|
});
|
package/src/mcp-config.ts
CHANGED
|
@@ -21,8 +21,18 @@ function string(value: unknown, maximum: number): value is string {
|
|
|
21
21
|
}
|
|
22
22
|
function invalid(): never { throw new Error("Invalid MCP proposal: use the documented bounded version 1 stdio configuration."); }
|
|
23
23
|
|
|
24
|
+
/** One platform-native absolute executable path. Drive-qualified only on Windows:
|
|
25
|
+
* drive roots, UNC/device paths, `.`/`..` segments and trailing separators are rejected. */
|
|
26
|
+
export function isAbsoluteMcpExecutable(value: string, platform: NodeJS.Platform): boolean {
|
|
27
|
+
if (platform === "win32") {
|
|
28
|
+
return /^[A-Za-z]:[\\/][^\\/]/.test(value)
|
|
29
|
+
&& !value.split(/[\\/]/).slice(1).some((part) => part === "" || part === "." || part === "..");
|
|
30
|
+
}
|
|
31
|
+
return posix.isAbsolute(value) && value !== "/";
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/** Parsing is not authorization. Unknown transport/auth/environment fields fail closed. */
|
|
25
|
-
export function parseMcpConfig(text: string): McpConfig {
|
|
35
|
+
export function parseMcpConfig(text: string, platform: NodeJS.Platform = process.platform): McpConfig {
|
|
26
36
|
if (Buffer.byteLength(text) > MCP_MAX_CONFIG_BYTES) invalid();
|
|
27
37
|
let value: unknown;
|
|
28
38
|
try { value = JSON.parse(text); } catch { invalid(); }
|
|
@@ -32,8 +42,8 @@ export function parseMcpConfig(text: string): McpConfig {
|
|
|
32
42
|
const servers = value.servers.map((entry): McpServerConfig => {
|
|
33
43
|
if (!object(entry) || !keys(entry, ["name", "executable", "args"])
|
|
34
44
|
|| typeof entry.name !== "string" || !namePattern.test(entry.name) || names.has(entry.name)
|
|
35
|
-
|| !string(entry.executable, 4096) || !
|
|
36
|
-
||
|
|
45
|
+
|| !string(entry.executable, 4096) || !isAbsoluteMcpExecutable(entry.executable, platform)
|
|
46
|
+
|| !Array.isArray(entry.args) || entry.args.length > 16
|
|
37
47
|
|| !entry.args.every((argument) => string(argument, 1024))) invalid();
|
|
38
48
|
names.add(entry.name);
|
|
39
49
|
return { name: entry.name, executable: entry.executable, args: [...entry.args] as string[] };
|
package/src/mcp-process.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { lstat, opendir, realpath } from "node:fs/promises";
|
|
1
|
+
import { lstat, mkdtemp, opendir, realpath, rm } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { posix } from "node:path";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { posix, win32, join } from "node:path";
|
|
5
5
|
import { AGENT_DIR } from "./config";
|
|
6
6
|
import { isCredentialSensitivePath } from "./credential-path";
|
|
7
7
|
import { createBubblewrapBackend } from "./sandbox/linux";
|
|
8
|
-
import
|
|
8
|
+
import { createWindowsSandboxBackend } from "./sandbox/windows";
|
|
9
|
+
import type { SandboxBackend, SandboxPolicy, SandboxProcessHandle } from "./sandbox/types";
|
|
9
10
|
|
|
10
11
|
export interface McpProcessRequest {
|
|
11
12
|
executable: string;
|
|
@@ -28,13 +29,17 @@ export const MCP_MAX_PENDING_WRITES = 64;
|
|
|
28
29
|
export const MCP_WRITE_TIMEOUT_MS = 5_000;
|
|
29
30
|
export const MCP_CLOSE_GRACE_MS = 1_000;
|
|
30
31
|
const failure = () => new Error("MCP process unavailable or blocked by native policy");
|
|
32
|
+
/** Only the adapter may identify an unavailable mandatory native boundary. */
|
|
33
|
+
export class McpNativeUnavailableError extends Error {
|
|
34
|
+
constructor() { super("MCP native enforcement unavailable (Linux Bubblewrap or Windows MXC BaseContainer)"); }
|
|
35
|
+
}
|
|
31
36
|
|
|
32
37
|
export const MCP_SCAN_MAX_ENTRIES = 50_000;
|
|
33
38
|
export const MCP_SCAN_MAX_DEPTH = 32;
|
|
34
39
|
export const MCP_SCAN_MAX_MASKS = 2_048;
|
|
35
40
|
export const MCP_SCAN_MAX_PATH_BYTES = 256 * 1024;
|
|
36
41
|
export const MCP_SCAN_TIMEOUT_MS = 5_000;
|
|
37
|
-
export interface McpProcessAdapterOptions { configDir?: string }
|
|
42
|
+
export interface McpProcessAdapterOptions { configDir?: string; platform?: NodeJS.Platform; allowWindows?: boolean; windowsBackend?: SandboxBackend }
|
|
38
43
|
|
|
39
44
|
const within = (root: string, path: string) => path === root || path.startsWith(root === "/" ? "/" : `${root}/`);
|
|
40
45
|
/** Pure comparison after caller validation/canonicalization; no authority grant. */
|
|
@@ -157,24 +162,184 @@ export async function buildMcpProcessPolicy(
|
|
|
157
162
|
} catch { throw failure(); }
|
|
158
163
|
}
|
|
159
164
|
|
|
165
|
+
async function spawnLinuxMcp(request: McpProcessRequest, options: McpProcessAdapterOptions): Promise<McpProcessHandle> {
|
|
166
|
+
const bubblewrap = ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync);
|
|
167
|
+
if (!bubblewrap) throw new McpNativeUnavailableError();
|
|
168
|
+
const backend = createBubblewrapBackend({ executable: bubblewrap, systemMounts: MCP_SYSTEM_MOUNTS });
|
|
169
|
+
if ((await backend.probe()).state !== "enforced") throw new McpNativeUnavailableError();
|
|
170
|
+
if (request.signal?.aborted) throw failure();
|
|
171
|
+
const policy = await buildMcpProcessPolicy(request, options);
|
|
172
|
+
if (request.signal?.aborted) throw failure();
|
|
173
|
+
return manageMcpProcessHandle(backend.spawn(policy, {
|
|
174
|
+
persistentStdin: true, signal: request.signal,
|
|
175
|
+
onStdout: request.onStdout, onStderr: request.onStderr,
|
|
176
|
+
}), request.signal ?? new AbortController().signal);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
|
|
180
|
+
const pathControls = /[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/;
|
|
181
|
+
/** Drive-qualified absolute only: drive roots, UNC/device, `.`/`..` and separators fail. */
|
|
182
|
+
function windowsSafe(value: string): boolean {
|
|
183
|
+
return typeof value === "string" && WINDOWS_DRIVE.test(value) && !pathControls.test(value)
|
|
184
|
+
&& !value.split(/[\\/]/).slice(1).some((part) => part === "" || part === "." || part === "..");
|
|
185
|
+
}
|
|
186
|
+
function windowsSame(a: string, b: string): boolean {
|
|
187
|
+
return win32.normalize(a).toLowerCase() === win32.normalize(b).toLowerCase();
|
|
188
|
+
}
|
|
189
|
+
function windowsWithin(root: string, path: string): boolean {
|
|
190
|
+
const r = win32.normalize(root).toLowerCase();
|
|
191
|
+
const p = win32.normalize(path).toLowerCase();
|
|
192
|
+
const base = r.endsWith("\\") ? r : `${r}\\`;
|
|
193
|
+
return p === r || p.startsWith(base);
|
|
194
|
+
}
|
|
195
|
+
/** Canonical path without link/junction components; checks compare lowercased spellings. */
|
|
196
|
+
async function windowsExactPath(value: string, directory: boolean): Promise<string> {
|
|
197
|
+
if (!windowsSafe(value)) throw failure();
|
|
198
|
+
const normalized = win32.normalize(value);
|
|
199
|
+
const parts = normalized.split(/[\\/]+/).slice(1);
|
|
200
|
+
let prefix = /^[A-Za-z]:/.exec(normalized)![0];
|
|
201
|
+
for (let index = 0; index < parts.length; index++) {
|
|
202
|
+
const part = parts[index]!;
|
|
203
|
+
if (!part) continue;
|
|
204
|
+
prefix = `${prefix}\\${part}`;
|
|
205
|
+
const stat = await lstat(prefix);
|
|
206
|
+
// Only ancestors must be directories; the final component may be the file.
|
|
207
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) throw failure();
|
|
208
|
+
}
|
|
209
|
+
const final = await lstat(normalized);
|
|
210
|
+
if (final.isSymbolicLink() || (directory ? !final.isDirectory() : !final.isFile())) throw failure();
|
|
211
|
+
const canonical = (await realpath(normalized)).replace(/^\\\\\?\\/u, "");
|
|
212
|
+
if (!windowsSame(canonical, normalized)) throw failure();
|
|
213
|
+
return win32.normalize(canonical);
|
|
214
|
+
}
|
|
215
|
+
/** Bind the config directory and every ancestor for this policy build; never guessed. */
|
|
216
|
+
async function windowsConfigBoundary(path: string): Promise<{ path: string; identity: string }> {
|
|
217
|
+
const canonical = await windowsExactPath(path, true);
|
|
218
|
+
const identities: string[] = [];
|
|
219
|
+
let prefix = /^[A-Za-z]:/.exec(canonical)![0];
|
|
220
|
+
for (const part of canonical.split(/[\\/]+/).slice(1)) {
|
|
221
|
+
if (!part) continue;
|
|
222
|
+
prefix = `${prefix}\\${part}`;
|
|
223
|
+
const stat = await lstat(prefix);
|
|
224
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure();
|
|
225
|
+
identities.push(`${prefix.toLowerCase()}:${stat.dev}:${stat.ino}`);
|
|
226
|
+
}
|
|
227
|
+
if (!windowsSame(await windowsExactPath(path, true), canonical)) throw failure();
|
|
228
|
+
return { path: canonical, identity: identities.join("\n") };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Windows MCP policy: exact cwd and executable file readonly, config denied, no network.
|
|
232
|
+
* The adapter owns the real private temp; this builder only validates and records it. */
|
|
233
|
+
export async function buildWindowsMcpProcessPolicy(
|
|
234
|
+
request: Pick<McpProcessRequest, "cwd" | "executable" | "args" | "signal">,
|
|
235
|
+
options: McpProcessAdapterOptions & { privateTemp?: string } = {},
|
|
236
|
+
): Promise<SandboxPolicy> {
|
|
237
|
+
try {
|
|
238
|
+
const start = Date.now();
|
|
239
|
+
const check = () => { if (request.signal?.aborted || Date.now() - start > MCP_SCAN_TIMEOUT_MS) throw failure(); };
|
|
240
|
+
check();
|
|
241
|
+
const cwd = await windowsExactPath(request.cwd, true);
|
|
242
|
+
const executable = await windowsExactPath(request.executable, false);
|
|
243
|
+
const configInput = options.configDir ?? AGENT_DIR;
|
|
244
|
+
const boundary = await windowsConfigBoundary(configInput);
|
|
245
|
+
const forbidden = [...new Set([win32.normalize(configInput), boundary.path])];
|
|
246
|
+
const home = await realpath(homedir());
|
|
247
|
+
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? "C:\\Windows";
|
|
248
|
+
const privateTemp = options.privateTemp ?? "";
|
|
249
|
+
if (!windowsSafe(privateTemp) || home === cwd || windowsWithin(cwd, home)
|
|
250
|
+
|| isCredentialSensitivePath(cwd) || isCredentialSensitivePath(executable)
|
|
251
|
+
|| forbidden.some((path) => windowsWithin(path, cwd) || windowsWithin(path, executable))
|
|
252
|
+
|| windowsWithin(systemRoot, cwd) || windowsWithin(systemRoot, configInput) || windowsWithin(configInput, systemRoot)
|
|
253
|
+
|| windowsWithin(privateTemp, cwd) || windowsWithin(privateTemp, executable)
|
|
254
|
+
|| windowsWithin(cwd, privateTemp) || windowsWithin(executable, privateTemp)) throw failure();
|
|
255
|
+
const tempStat = await lstat(privateTemp);
|
|
256
|
+
if (!tempStat.isDirectory() || tempStat.isSymbolicLink()) throw failure();
|
|
257
|
+
if (!Array.isArray(request.args) || request.args.length > 16 || request.args.some((arg) =>
|
|
258
|
+
typeof arg !== "string" || Buffer.byteLength(arg) > 1024
|
|
259
|
+
|| /[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/.test(arg))) throw failure();
|
|
260
|
+
let entries = 0;
|
|
261
|
+
let maskBytes = 0;
|
|
262
|
+
const deniedPaths: string[] = [];
|
|
263
|
+
const mask = (path: string) => {
|
|
264
|
+
deniedPaths.push(path);
|
|
265
|
+
maskBytes += Buffer.byteLength(path);
|
|
266
|
+
if (deniedPaths.length > MCP_SCAN_MAX_MASKS || maskBytes > MCP_SCAN_MAX_PATH_BYTES) throw failure();
|
|
267
|
+
};
|
|
268
|
+
const scan = async (directory: string, depth: number): Promise<void> => {
|
|
269
|
+
check();
|
|
270
|
+
if (depth > MCP_SCAN_MAX_DEPTH || (await lstat(directory)).isSymbolicLink()
|
|
271
|
+
|| !windowsSame(await realpath(directory), directory)) throw failure();
|
|
272
|
+
for await (const entry of await opendir(directory)) {
|
|
273
|
+
check();
|
|
274
|
+
if (++entries > MCP_SCAN_MAX_ENTRIES) throw failure();
|
|
275
|
+
const path = win32.join(directory, entry.name);
|
|
276
|
+
const sensitive = isCredentialSensitivePath(`${path}\\`) || isCredentialSensitivePath(path)
|
|
277
|
+
|| forbidden.some((root) => windowsWithin(root, path));
|
|
278
|
+
const stat = await lstat(path);
|
|
279
|
+
if (sensitive) {
|
|
280
|
+
if (stat.isSymbolicLink()) throw failure();
|
|
281
|
+
mask(path);
|
|
282
|
+
} else if (stat.isDirectory()) await scan(path, depth + 1);
|
|
283
|
+
else if (!stat.isFile() && !stat.isSymbolicLink()) throw failure();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
await scan(cwd, 0);
|
|
287
|
+
const currentBoundary = await windowsConfigBoundary(configInput);
|
|
288
|
+
if (currentBoundary.path !== boundary.path || currentBoundary.identity !== boundary.identity) throw failure();
|
|
289
|
+
check();
|
|
290
|
+
if (deniedPaths.some((path) => windowsWithin(path, executable))) throw failure();
|
|
291
|
+
return {
|
|
292
|
+
version: 1, exactCommand: "MCP approved stdio process", executable, args: [...request.args], cwd,
|
|
293
|
+
readOnlyPaths: [cwd, executable], readWritePaths: [], deniedPaths,
|
|
294
|
+
privateTemp,
|
|
295
|
+
environment: {
|
|
296
|
+
PATH: `${win32.join(systemRoot, "System32")};${systemRoot}`,
|
|
297
|
+
SystemRoot: systemRoot, HOME: privateTemp, USERPROFILE: privateTemp,
|
|
298
|
+
},
|
|
299
|
+
network: "deny",
|
|
300
|
+
rationale: "Explicitly approved readonly live-project Windows MCP (MXC BaseContainer); masks are check-time defense in depth",
|
|
301
|
+
accesses: [],
|
|
302
|
+
};
|
|
303
|
+
} catch { throw failure(); }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function spawnWindowsMcp(request: McpProcessRequest, options: McpProcessAdapterOptions): Promise<McpProcessHandle> {
|
|
307
|
+
const backend = options.windowsBackend ?? createWindowsSandboxBackend();
|
|
308
|
+
const capability = await backend.probe();
|
|
309
|
+
if (capability.state !== "enforced") throw new McpNativeUnavailableError();
|
|
310
|
+
if (request.signal?.aborted) throw failure();
|
|
311
|
+
const privateTemp = await mkdtemp(join(tmpdir(), "pum-mcp-private-"));
|
|
312
|
+
let child: SandboxProcessHandle;
|
|
313
|
+
try {
|
|
314
|
+
const policy = await buildWindowsMcpProcessPolicy(request, { ...options, privateTemp });
|
|
315
|
+
if (request.signal?.aborted) throw failure();
|
|
316
|
+
child = backend.spawn(policy, {
|
|
317
|
+
persistentStdin: true, signal: request.signal, onStdout: request.onStdout, onStderr: request.onStderr,
|
|
318
|
+
});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
await rm(privateTemp, { recursive: true, force: true }).catch(() => {});
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const completed = child.completed.finally(() => rm(privateTemp, { recursive: true, force: true }).catch(() => {}));
|
|
324
|
+
return manageMcpProcessHandle({ ...child, completed }, request.signal ?? new AbortController().signal);
|
|
325
|
+
}
|
|
326
|
+
|
|
160
327
|
/** No startup work until spawn. The controller, not this adapter, owns consent.
|
|
161
|
-
* Mandatory
|
|
328
|
+
* Mandatory platform-native enforcement regardless of Check mode; never a direct fallback.
|
|
329
|
+
* Linux requires Bubblewrap; Windows requires MXC BaseContainer (never AppContainer+DACL). */
|
|
162
330
|
export function createMcpProcessAdapter(options: McpProcessAdapterOptions = {}): McpProcessAdapter {
|
|
331
|
+
const platform = options.platform ?? process.platform;
|
|
163
332
|
return {
|
|
164
333
|
async spawn(request) {
|
|
165
334
|
try {
|
|
166
|
-
if (process.platform !== "linux" || request.signal?.aborted) throw failure();
|
|
167
|
-
const bubblewrap = ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync);
|
|
168
|
-
if (!bubblewrap) throw failure();
|
|
169
|
-
const backend = createBubblewrapBackend({ executable: bubblewrap, systemMounts: MCP_SYSTEM_MOUNTS });
|
|
170
|
-
if ((await backend.probe()).state !== "enforced" || request.signal?.aborted) throw failure();
|
|
171
|
-
const policy = await buildMcpProcessPolicy(request, options);
|
|
172
335
|
if (request.signal?.aborted) throw failure();
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
336
|
+
if (platform === "linux") return spawnLinuxMcp(request, options);
|
|
337
|
+
if (platform === "win32" && options.allowWindows !== false) return spawnWindowsMcp(request, options);
|
|
338
|
+
throw new McpNativeUnavailableError();
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof McpNativeUnavailableError && !request.signal?.aborted) throw error;
|
|
341
|
+
throw failure();
|
|
342
|
+
}
|
|
178
343
|
},
|
|
179
344
|
};
|
|
180
345
|
}
|
package/src/mcp-protocol.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* basic/lifecycle,basic/utilities/cancellation,server/tools}.
|
|
4
4
|
* This deliberately rejects unsupported JSON Schema keywords rather than claiming full coverage.
|
|
5
5
|
*/
|
|
6
|
+
import { McpNativeUnavailableError } from "./mcp-process";
|
|
7
|
+
|
|
6
8
|
export const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
7
9
|
export const MCP_LIMITS = Object.freeze({ frameBytes: 256 * 1024, stdoutBytes: 16 * 1024 * 1024,
|
|
8
10
|
discoveryBytes: 256 * 1024, tools: 32, pages: 4, schemaBytes: 16 * 1024,
|
|
@@ -228,7 +230,12 @@ export class McpProtocolClient {
|
|
|
228
230
|
return client;
|
|
229
231
|
};
|
|
230
232
|
try { return await Promise.race([start(), stopped]); }
|
|
231
|
-
catch
|
|
233
|
+
catch (error) {
|
|
234
|
+
const nativeUnavailable = !signal?.aborted && error instanceof McpNativeUnavailableError;
|
|
235
|
+
client.closeWith("MCP connection failed.");
|
|
236
|
+
if (nativeUnavailable) throw new McpNativeUnavailableError();
|
|
237
|
+
throw new Error("MCP connection failed.");
|
|
238
|
+
}
|
|
232
239
|
finally { clearTimeout(timer); }
|
|
233
240
|
}
|
|
234
241
|
private assertOpen(): void { if (this.closed || !this.handle) throw new Error("MCP connection closed."); }
|
package/src/mcp.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { defineTool, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { readMcpProposal, type McpProposal } from "./mcp-config";
|
|
7
|
+
import { McpNativeUnavailableError } from "./mcp-process";
|
|
7
8
|
import { McpProtocolClient, type McpSpawn, type McpTool } from "./mcp-protocol";
|
|
8
9
|
|
|
9
10
|
type Connection = { digest: string; abort: AbortController; client?: McpProtocolClient; tools?: McpTool[]; toolset?: string; approved: boolean };
|
|
@@ -12,7 +13,18 @@ const digestPattern = /^[a-f0-9]{64}$/;
|
|
|
12
13
|
const serverPattern = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
13
14
|
const textResult = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
|
14
15
|
const unavailable = () => new Error("MCP access unavailable: direct main-TUI connection and exact toolset approval are required.");
|
|
15
|
-
|
|
16
|
+
export class McpCommandError extends Error {
|
|
17
|
+
constructor(readonly kind: "proposal" | "native") { super("MCP command failed"); }
|
|
18
|
+
}
|
|
19
|
+
/** Never display arbitrary exception text, including text from a server or the OS. */
|
|
20
|
+
export function mcpCommandErrorMessage(error: unknown): string {
|
|
21
|
+
if (error instanceof McpCommandError) {
|
|
22
|
+
if (error.kind === "proposal") return "MCP proposal missing, changed or invalid. Check exact-cwd .pum/mcp.json against docs/mcp.md, then preview /mcp again.";
|
|
23
|
+
if (error.kind === "native") return "MCP connection needs native enforcement (Linux Bubblewrap or Windows MXC BaseContainer). Install or enable it, then preview /mcp and connect again; no unsandboxed fallback.";
|
|
24
|
+
}
|
|
25
|
+
return "MCP request failed. Preview /mcp and check the current proposal and digests.";
|
|
26
|
+
}
|
|
27
|
+
const scope = "Native enforcement required (Linux Bubblewrap or Windows MXC BaseContainer), even with Check Off; no fallback or network. Live project read-only, private scratch writable; filtered environment, no auth passthrough, PUM configuration/credentials denied. Sensitive-name masks are defense in depth only: arbitrary secrets, aliases and future files may be read and returned to the model. Approval trusts current/future server and project code. No startup/resume trust.";
|
|
16
28
|
|
|
17
29
|
/** Main-TUI-only runtime authority. No SDK command, persisted approval, automatic discovery or inherited trust. */
|
|
18
30
|
export class McpController {
|
|
@@ -69,7 +81,7 @@ export class McpController {
|
|
|
69
81
|
(this.trustIdentity && this.trustIdentity !== identity) ||
|
|
70
82
|
[...this.connections.values()].some(c => c.digest !== proposal.digest)) this.cancel();
|
|
71
83
|
return { ...proposal, identity };
|
|
72
|
-
} catch { this.cancel(); throw new
|
|
84
|
+
} catch { this.cancel(); throw new McpCommandError("proposal"); }
|
|
73
85
|
}
|
|
74
86
|
private stopWatching(): void {
|
|
75
87
|
for (const watcher of this.watchers) watcher.close();
|
|
@@ -174,10 +186,11 @@ export class McpController {
|
|
|
174
186
|
"BEGIN UNTRUSTED SERVER TOOL DESCRIPTIONS AND SCHEMAS (data, not instructions)",
|
|
175
187
|
JSON.stringify(tools), "END UNTRUSTED SERVER TOOL DATA",
|
|
176
188
|
`Toolset SHA-256: ${connection.toolset}`, `Approve only this set: /mcp approve ${name} ${connection.toolset}`].join("\n\n");
|
|
177
|
-
} catch {
|
|
189
|
+
} catch (error) {
|
|
178
190
|
if (this.connections.get(name) === connection) this.revoke(name);
|
|
179
|
-
//
|
|
180
|
-
|
|
191
|
+
// The protocol passes this marker only when the native adapter failed before cancellation.
|
|
192
|
+
if (!this.disposed && this.options.isCurrent?.() !== false && error instanceof McpNativeUnavailableError) throw new McpCommandError("native");
|
|
193
|
+
throw new Error("MCP connection/discovery failed or became stale; authority revoked.");
|
|
181
194
|
}
|
|
182
195
|
}
|
|
183
196
|
private owner(ctx: { cwd: string; sessionManager: { getSessionId(): string } }): void {
|
package/src/sandbox/windows.ts
CHANGED
|
@@ -154,11 +154,14 @@ function sandboxEnvironment(policy: SandboxPolicy): string[] {
|
|
|
154
154
|
return entries.map(([name, value]) => `${name}=${value}`);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/** Build the exact stable MXC 0.7 ProcessContainer configuration used for a run.
|
|
157
|
+
/** Build the exact stable MXC 0.7 ProcessContainer configuration used for a run.
|
|
158
|
+
* `runtimePaths` follows the writable-mutable Bash convention: grant the executable's
|
|
159
|
+
* runtime directory. MCP is stricter and passes false: only the exact executable file. */
|
|
158
160
|
export function buildWindowsMxcConfig(
|
|
159
161
|
sdk: Pick<MxcSdk, "createConfigFromPolicy">,
|
|
160
162
|
policy: SandboxPolicy,
|
|
161
163
|
timeoutSeconds?: number,
|
|
164
|
+
options: { runtimePaths?: boolean } = {},
|
|
162
165
|
): ContainerConfig {
|
|
163
166
|
const timeoutMs = timeoutSeconds === undefined ? 0 : Math.ceil(timeoutSeconds * 1000);
|
|
164
167
|
if (timeoutSeconds !== undefined && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
|
|
@@ -168,10 +171,11 @@ export function buildWindowsMxcConfig(
|
|
|
168
171
|
throw new Error("Invalid timeout: maximum is 2147483.647 seconds");
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
const runtimePaths = options.runtimePaths === false ? [] : windowsRuntimePaths(policy.executable);
|
|
171
175
|
const mxcPolicy: MxcPolicy = {
|
|
172
176
|
version: "0.7.0-alpha",
|
|
173
177
|
filesystem: {
|
|
174
|
-
readonlyPaths: uniqueWindowsPaths([...policy.readOnlyPaths, ...
|
|
178
|
+
readonlyPaths: uniqueWindowsPaths([...policy.readOnlyPaths, ...runtimePaths]),
|
|
175
179
|
readwritePaths: uniqueWindowsPaths([...policy.readWritePaths, policy.privateTemp]),
|
|
176
180
|
deniedPaths: uniqueWindowsPaths(policy.deniedPaths),
|
|
177
181
|
clearPolicyOnExit: true,
|
|
@@ -295,7 +299,10 @@ function startSandboxProcess(
|
|
|
295
299
|
});
|
|
296
300
|
|
|
297
301
|
child.stdin?.on("error", () => {});
|
|
298
|
-
|
|
302
|
+
// A persistent protocol owns the stdin pipe after spawn; one-shot stdin closes now.
|
|
303
|
+
if (!options.persistentStdin) {
|
|
304
|
+
child.stdin?.end(options.stdin ? Buffer.from(options.stdin) : undefined);
|
|
305
|
+
}
|
|
299
306
|
});
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -316,6 +323,23 @@ export class MxcSandboxBackend implements SandboxBackend {
|
|
|
316
323
|
spawn(policy: SandboxPolicy, options: SandboxProcessOptions): SandboxProcessHandle {
|
|
317
324
|
let child: ChildProcess | undefined;
|
|
318
325
|
let killRequested = false;
|
|
326
|
+
let stdinClosed = false;
|
|
327
|
+
const pendingWrites: { data: string; resolve(): void; reject(error: Error): void }[] = [];
|
|
328
|
+
const rejectWrites = (error: Error) => {
|
|
329
|
+
for (const write of pendingWrites.splice(0)) write.reject(error);
|
|
330
|
+
};
|
|
331
|
+
const dispatch = (entry: { data: string; resolve(): void; reject(error: Error): void }) => {
|
|
332
|
+
const stdin = child?.stdin;
|
|
333
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded || stdinClosed) {
|
|
334
|
+
entry.reject(new Error("Persistent process input is closed"));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
stdin.write(entry.data, "utf8", (error) => error ? entry.reject(error) : entry.resolve());
|
|
339
|
+
} catch (error) {
|
|
340
|
+
entry.reject(error instanceof Error ? error : new Error(String(error)));
|
|
341
|
+
}
|
|
342
|
+
};
|
|
319
343
|
const completed = (async () => {
|
|
320
344
|
if (this.#platform !== "win32") throw new Error("MXC ProcessContainer requires Windows");
|
|
321
345
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
@@ -325,14 +349,30 @@ export class MxcSandboxBackend implements SandboxBackend {
|
|
|
325
349
|
return startSandboxProcess(sdk, policy, options, (spawned) => {
|
|
326
350
|
child = spawned;
|
|
327
351
|
if (killRequested) child.kill();
|
|
352
|
+
for (const entry of pendingWrites.splice(0)) dispatch(entry);
|
|
353
|
+
if (stdinClosed) child.stdin?.end();
|
|
328
354
|
});
|
|
329
355
|
})();
|
|
356
|
+
void completed.catch(() => rejectWrites(new Error("Persistent process input is closed")));
|
|
330
357
|
return {
|
|
331
358
|
completed,
|
|
332
359
|
kill: () => {
|
|
333
360
|
killRequested = true;
|
|
334
361
|
child?.kill();
|
|
335
362
|
},
|
|
363
|
+
...(options.persistentStdin ? {
|
|
364
|
+
write(data: string): Promise<void> {
|
|
365
|
+
return new Promise<void>((resolve, reject) => {
|
|
366
|
+
const entry = { data, resolve, reject };
|
|
367
|
+
if (child?.stdin) dispatch(entry);
|
|
368
|
+
else pendingWrites.push(entry);
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
closeInput(): void {
|
|
372
|
+
stdinClosed = true;
|
|
373
|
+
child?.stdin?.end();
|
|
374
|
+
},
|
|
375
|
+
} : {}),
|
|
336
376
|
};
|
|
337
377
|
}
|
|
338
378
|
}
|