pum-agent 0.2.36-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.36-beta.1",
3
+ "version": "0.2.38-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,8 +50,8 @@
50
50
  "pack:check": "bun run scripts/validate-pack.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@earendil-works/pi-ai": "^0.87.1",
54
- "@earendil-works/pi-coding-agent": "^0.87.1",
53
+ "@earendil-works/pi-ai": "1.1.0",
54
+ "@earendil-works/pi-coding-agent": "1.1.0",
55
55
  "@opentui/core": "^0.5.1",
56
56
  "@opentui/react": "^0.5.1",
57
57
  "react": "^19.2.8",
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 { McpController } from "./mcp";
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";
@@ -62,7 +62,6 @@ import {
62
62
  rawToolText,
63
63
  resolveGoalReview,
64
64
  resolvePendingDelivery,
65
- settleTranscriptMessage,
66
65
  streamedDelta,
67
66
  StreamLine,
68
67
  TextLine,
@@ -74,6 +73,7 @@ import {
74
73
  } from "./transcript";
75
74
  import { bashOutput, bashResultDisplay, editCounts, toolArgs, type ToolCall } from "./tool-line";
76
75
  import { interruptedToolCall, settledToolCall, startedToolCall } from "./tool-row";
76
+ import { settleAssistantTranscript } from "./provider-error";
77
77
  import { toolPreviewFromResult, toolPreviewFromStart } from "./tool-preview";
78
78
  import { readBranch, watchBranch } from "./git-branch";
79
79
  import { HelpPopup, maxHelpScrollOffset } from "./help-popup";
@@ -2222,7 +2222,7 @@ export function App({
2222
2222
  }
2223
2223
  case "message_end":
2224
2224
  if (event.message.role === "assistant") {
2225
- setTx((value) => settleTranscriptMessage(value));
2225
+ setTx((value) => settleAssistantTranscript(value, event.message));
2226
2226
  } else if (event.message.role === "custom" && event.message.customType === VALIDATION_CUSTOM_TYPE) {
2227
2227
  append({
2228
2228
  kind: "text",
@@ -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("MCP request failed. Preview /mcp and check the current proposal and digests.", true);
4882
+ report(mcpCommandErrorMessage(error), true);
4883
4883
  }
4884
4884
  });
4885
4885
  return;
@@ -580,7 +580,12 @@ export type BashToolLike = {
580
580
  signal?: AbortSignal,
581
581
  onUpdate?: (update: { content: unknown[]; details?: unknown }) => void,
582
582
  ctx?: unknown,
583
- ) => Promise<{ content: Array<{ type: string; text: string }>; details?: { fullOutputPath?: string } }>;
583
+ ) => Promise<{
584
+ content: Array<{ type: string; text: string }>;
585
+ details?: { fullOutputPath?: string };
586
+ isError?: boolean;
587
+ structuredContent?: unknown;
588
+ }>;
584
589
  };
585
590
 
586
591
  export const bashOutputParameters = Type.Object({
@@ -666,16 +671,27 @@ export function withBashOutput(
666
671
  await capture.remove();
667
672
  return result; // Fail open to pi's bounded native result; never lose output.
668
673
  }
669
- const source = captured ?? textOf(result.content);
670
- const summary = summarizeBashOutput(source, perCall, {
671
- exitCode: 0,
674
+ const nativeText = textOf(result.content);
675
+ // pi 1.1.0 returns nonzero exits as error results, not thrown Errors.
676
+ // Keep native status/error identity while summarizing our trusted stream.
677
+ const exit = result.isError ? EXIT_RE.exec(nativeText) : null;
678
+ const status = exit ? `Command exited with code ${exit[2]}` : undefined;
679
+ if (result.isError && !exit) {
680
+ await capture?.remove();
681
+ return result; // Unknown error shape: preserve native evidence intact.
682
+ }
683
+ const source = captured ?? (exit ? exit[1] : nativeText);
684
+ const summary = summarizeBashOutput(source, status
685
+ ? { ...perCall, maxBytes: Math.max(64, perCall.maxBytes - byteLen(status) - 2) }
686
+ : perCall, {
687
+ exitCode: exit ? Number(exit[2]) : 0,
672
688
  path: capture?.path,
673
689
  patterns,
674
690
  });
675
691
  if (capture && !summary.lossy) await capture.remove();
676
692
  return {
677
693
  ...result,
678
- content: [{ type: "text", text: summary.content }],
694
+ content: [{ type: "text", text: status ? `${summary.content}\n\n${status}` : summary.content }],
679
695
  details: capture && summary.lossy ? { fullOutputPath: capture.path } : result.details,
680
696
  };
681
697
  } catch (error) {
@@ -1,4 +1,4 @@
1
- import { createReadToolDefinition, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { createReadToolDefinition, type ExtensionToolContext } from "@earendil-works/pi-coding-agent";
2
2
  import { createHash } from "node:crypto";
3
3
  import { constants, type Stats } from "node:fs";
4
4
  import { lstat, open } from "node:fs/promises";
@@ -138,11 +138,11 @@ export class FileMutationGuard {
138
138
  }
139
139
 
140
140
  /** Preserve native image handling and filename retries; observe only a stable successful read. */
141
- async read(id: string, input: any, signal?: AbortSignal, onUpdate?: any, ctx?: ExtensionContext, autoResizeImages?: boolean): Promise<any> {
141
+ async read(id: string, input: any, signal?: AbortSignal, onUpdate?: any, ctx?: ExtensionToolContext, autoResizeImages?: boolean): Promise<any> {
142
142
  const target = await this.target(input.path, "read");
143
143
  const before = await this.snapshot(target.path, "read");
144
144
  const result = await createReadToolDefinition(this.cwd, { autoResizeImages }).execute(
145
- id, input, signal, onUpdate, ctx ?? { cwd: this.cwd } as ExtensionContext,
145
+ id, input, signal, onUpdate, ctx ?? { cwd: this.cwd } as ExtensionToolContext,
146
146
  );
147
147
  const after = await this.snapshot(target.path, "read");
148
148
  if (!signal?.aborted && !this.disposed && before.fingerprint === after.fingerprint) this.remember(target.key, before);
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) => mcpProcess.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) || !posix.isAbsolute(entry.executable)
36
- || entry.executable === "/" || !Array.isArray(entry.args) || entry.args.length > 16
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[] };
@@ -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 type { SandboxPolicy, SandboxProcessHandle } from "./sandbox/types";
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 Linux Bubblewrap regardless of Check mode; never a direct fallback. */
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 manageMcpProcessHandle(backend.spawn(policy, {
174
- persistentStdin: true, signal: request.signal,
175
- onStdout: request.onStdout, onStderr: request.onStderr,
176
- }), request.signal ?? new AbortController().signal);
177
- } catch { throw failure(); }
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
  }
@@ -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 { client.closeWith("MCP connection failed."); throw new Error("MCP connection failed."); }
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
- const scope = "Linux native Bubblewrap required, 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
+ 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 Error("MCP proposal missing, changed or invalid; preview and approve again."); }
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
- // Never expose raw server/OS errors or stderr through the transcript.
180
- throw new Error("MCP connection/discovery failed or became stale; authority revoked. Native Linux Bubblewrap enforcement is required.");
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 {
@@ -290,8 +290,9 @@ export class ProjectValidationController {
290
290
  outcome = deadline.signal.aborted ? "timeout" : "cancelled";
291
291
  } else {
292
292
  const result = await tool.execute(toolCall.id, args, signal);
293
- const after = await untilAborted(Promise.resolve(session.agent.afterToolCall?.({ assistantMessage, toolCall, args, result, isError: false, context }, signal)), signal);
294
- if (after?.isError) outcome = "failed";
293
+ const isError = result.isError === true;
294
+ const after = await untilAborted(Promise.resolve(session.agent.afterToolCall?.({ assistantMessage, toolCall, args, result, isError, context }, signal)), signal);
295
+ if (after?.isError ?? isError) outcome = "failed";
295
296
  output = (after?.content ?? result.content).filter((item) => item.type === "text").map((item) => item.text).join("\n");
296
297
  }
297
298
  } catch (error) {
@@ -0,0 +1,66 @@
1
+ import { flushStream, settleTranscriptMessage, type Line, type PendingTranscriptState } from "./transcript";
2
+
3
+ /** Redact entire header values, not just the scheme or first credential token. */
4
+ function redactAuthorization(text: string): string {
5
+ const header = /(["']?\b(?:proxy-)?authorization["']?\s*[:=]\s*)/gi;
6
+ let safe = "";
7
+ let copied = 0;
8
+ for (let match = header.exec(text); match; match = header.exec(text)) {
9
+ const start = header.lastIndex;
10
+ let end = start;
11
+ let quote = "";
12
+ for (; end < text.length; end++) {
13
+ const char = text[end]!;
14
+ if (quote) {
15
+ // JSON-escaped Digest quotes are part of the value, not its boundary.
16
+ if (char === "\\") { end++; continue; }
17
+ if (char === quote) quote = "";
18
+ continue;
19
+ }
20
+ if (char === '"' || char === "'") { quote = char; continue; }
21
+ // Digest commas and arbitrary scheme whitespace/semicolons can contain
22
+ // credentials. Only a structured next field or explicit schema diagnostic
23
+ // is a safe boundary; ambiguous/unclosed values consume the remaining text.
24
+ if (char === "," && /^,\s*["'][^"'\n]+["']\s*:/.test(text.slice(end))) break;
25
+ if (char === ";" && /^;\s*(?:[45]\d\d\s+)?tools\.\d+\.custom\b/.test(text.slice(end))) break;
26
+ }
27
+ safe += text.slice(copied, start) + "[redacted]";
28
+ copied = end;
29
+ header.lastIndex = end;
30
+ }
31
+ return safe + text.slice(copied);
32
+ }
33
+
34
+ /** Provider text is untrusted display data, never a diagnostic or durable rewrite. */
35
+ export function providerErrorLine(message: unknown): Line | null {
36
+ const assistant = message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | null;
37
+ // An aborted response can also carry errorMessage; cancellation is not failure.
38
+ if (assistant?.role !== "assistant" || assistant.stopReason !== "error") return null;
39
+ const raw = typeof assistant.errorMessage === "string" ? assistant.errorMessage : "";
40
+ // As in login-flow.safeError, redact credential-shaped keys. Provider failures
41
+ // can also echo headers/JSON or an OAuth URL, so remove those before bounding.
42
+ const normalized = raw
43
+ // Normalize control sequences first so they cannot split a credential marker.
44
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
45
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
46
+ .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "");
47
+ const safe = redactAuthorization(normalized)
48
+ .replace(/https?:\/\/[^\s<>"']+/gi, "[redacted URL]")
49
+ .replace(/\bBearer\s+[^\s,"'}]+/gi, "Bearer [redacted]")
50
+ // Quoted JSON/header values may contain escaped quotes or span whitespace.
51
+ // An unterminated quoted secret is redacted through the end, never partially.
52
+ .replace(/(["']?\b(?:api[_ -]?key|x-api-key|access[_ -]?token|refresh[_ -]?token|password|secret|client[_ -]?secret)["']?\s*[:=]\s*)(?:"(?:\\[\s\S]|[^"\\])*(?:"|\\?$)|'(?:\\[\s\S]|[^'\\])*(?:'|\\?$)|[^\s,}\n]+)/gi, "$1[redacted]")
53
+ .replace(/\b(?:sk|key|token)-[A-Za-z0-9_.-]{8,}\b/gi, "[redacted]")
54
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, "[redacted]")
55
+ .trim();
56
+ const detail = safe ? safe.slice(0, 4000) + (safe.length > 4000 ? "…" : "") : "The request failed without an error description.";
57
+ return { kind: "text", role: "error", text: `Provider error: ${detail}` };
58
+ }
59
+
60
+ /** Finish partial output before its failure, then deliver queued messages. */
61
+ export function settleAssistantTranscript<T extends PendingTranscriptState>(value: T, message: unknown): T {
62
+ const error = providerErrorLine(message);
63
+ if (!error) return settleTranscriptMessage(value);
64
+ const flushed = flushStream(value);
65
+ return settleTranscriptMessage({ ...flushed, lines: [...flushed.lines, error] });
66
+ }
package/src/replay.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessageKind, Line } from "./transcript";
2
+ import { providerErrorLine } from "./provider-error";
2
3
  import { toolArgs, type ToolCall } from "./tool-line";
3
4
  import { interruptedToolCall, settledToolCall, startedToolCall } from "./tool-row";
4
5
  import {
@@ -303,8 +304,8 @@ export function replayEntries(
303
304
  continue;
304
305
  }
305
306
 
306
- if (message?.role === "assistant" && Array.isArray(message.content)) {
307
- for (const block of message.content) {
307
+ if (message?.role === "assistant") {
308
+ for (const block of Array.isArray(message.content) ? message.content : []) {
308
309
  if (block?.type === "text" && block.text?.trim()) {
309
310
  lines.push({ kind: "text", role: "assistant", text: block.text.trim() });
310
311
  } else if (showThinking && block?.type === "thinking" && block.thinking?.trim()) {
@@ -322,6 +323,8 @@ export function replayEntries(
322
323
  lines.push({ kind: "tool", call });
323
324
  }
324
325
  }
326
+ const error = providerErrorLine(message);
327
+ if (error) lines.push(error);
325
328
  continue;
326
329
  }
327
330
 
@@ -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, ...windowsRuntimePaths(policy.executable)]),
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
- child.stdin?.end(options.stdin ? Buffer.from(options.stdin) : undefined);
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
  }
@@ -20,6 +20,7 @@ import {
20
20
  usageFromEntries,
21
21
  } from "../agent-usage";
22
22
  import { replayEntries } from "../replay";
23
+ import { settleAssistantTranscript } from "../provider-error";
23
24
  import { registerOperationalNotice } from "../operational-context";
24
25
  import { bindFileCheckpointSession } from "../file-checkpoints";
25
26
  import { ProjectValidationController, VALIDATION_CUSTOM_TYPE } from "../project-validation";
@@ -76,7 +77,6 @@ import { GOAL_VERDICT_TOOL_NAME, goalVerdictParameters } from "../goal-judge";
76
77
  import {
77
78
  flushStream,
78
79
  resolvePendingDelivery,
79
- settleTranscriptMessage,
80
80
  streamedDelta,
81
81
  type Line,
82
82
  type PendingLine,
@@ -1041,7 +1041,7 @@ export class SubagentManager {
1041
1041
  this.appendLine(record, { kind: "text", role: event.message.details?.outcome === "passed" ? "system" : "error", text: String(event.message.content) });
1042
1042
  }
1043
1043
  if (event.message?.role === "assistant") {
1044
- this.updateTranscript(record, (value) => settleTranscriptMessage(value));
1044
+ this.updateTranscript(record, (value) => settleAssistantTranscript(value, event.message));
1045
1045
  }
1046
1046
  break;
1047
1047
  case "message_update": {