pi-mcp-extension 1.9.8 → 1.9.9

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/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/pi-mcp-extension.svg)](https://www.npmjs.com/package/pi-mcp-extension)
4
4
  [![license](https://img.shields.io/npm/l/pi-mcp-extension.svg)](https://github.com/irahardianto/pi-mcp-extension/blob/main/LICENSE)
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/irahardianto/pi-mcp-extension/badge)](https://scorecard.dev/viewer/?uri=github.com/irahardianto/pi-mcp-extension)
5
6
 
6
7
  **Connect [Pi](https://pi.dev) to any MCP server.** Supabase, DeepSource, Playwright, Context7, filesystem, databases — if it speaks [MCP](https://modelcontextprotocol.io), Pi can use it.
7
8
 
@@ -289,6 +290,10 @@ The test suite includes:
289
290
  - **Tool bridge & management tools** — JSON Schema → TypeBox conversion, name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, and slash command completions
290
291
  - **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
291
292
 
293
+ ## Security
294
+
295
+ Please report vulnerabilities privately, not in public issues. See [SECURITY.md](https://github.com/irahardianto/pi-mcp-extension/blob/main/SECURITY.md) for scope, supported versions, and how to report.
296
+
292
297
  ## License
293
298
 
294
299
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.8",
3
+ "version": "1.9.9",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@eslint/js": "^10.0.1",
46
- "@types/node": "^22.0.0",
46
+ "@types/node": "^26.6.5",
47
47
  "eslint": "^10.13.0",
48
48
  "tsx": "^4.19.4",
49
49
  "typescript": "^5.7.0",
package/src/config.ts CHANGED
@@ -64,7 +64,7 @@ const AuthConfigSchema = z.preprocess(
64
64
  callbackServerConfigFromRedirectUrl(auth.redirectUrl);
65
65
  } catch (err) {
66
66
  ctx.addIssue({
67
- code: z.ZodIssueCode.custom,
67
+ code: "custom",
68
68
  path: ["redirectUrl"],
69
69
  message: err instanceof Error ? err.message : String(err),
70
70
  });
@@ -104,7 +104,7 @@ export const ServerConfigSchema = z.preprocess(
104
104
  * These merge with process.env; project env overrides parent env.
105
105
  * No ${VAR} interpolation — set vars in your shell environment instead.
106
106
  */
107
- env: z.record(z.string()).optional(),
107
+ env: z.record(z.string(), z.string()).optional(),
108
108
  /** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
109
109
  transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
110
110
  /**
@@ -117,7 +117,7 @@ export const ServerConfigSchema = z.preprocess(
117
117
  * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
118
118
  * For OAuth2, use the "auth" field instead.
119
119
  */
120
- headers: z.record(z.string()).optional(),
120
+ headers: z.record(z.string(), z.string()).optional(),
121
121
  /**
122
122
  * OAuth2 configuration for servers that require authorization.
123
123
  * When set, the transport will use the SDK's OAuth flow (discovery,
@@ -139,27 +139,36 @@ export const ServerConfigSchema = z.preprocess(
139
139
  */
140
140
  healthCheckIntervalMs: z.number().positive().optional(),
141
141
  })
142
- .refine(
143
- (cfg) => {
144
- if (cfg.transport === "stdio") return cfg.command !== undefined;
145
- return cfg.url !== undefined;
146
- },
147
- (cfg) => ({
148
- message:
149
- cfg.transport === "stdio"
150
- ? `"command" is required for stdio transport`
151
- : `"url" is required for ${cfg.transport} transport`,
152
- }),
153
- )
154
- .refine(
155
- (cfg) => !(cfg.transport === "stdio" && cfg.auth !== undefined),
156
- {
157
- message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
158
- path: ["auth"],
159
- },
160
- ),
142
+ .superRefine((cfg, ctx) => {
143
+ if (cfg.transport === "stdio") {
144
+ if (cfg.command === undefined) {
145
+ ctx.addIssue({
146
+ code: "custom",
147
+ message: '"command" is required for stdio transport',
148
+ });
149
+ }
150
+ if (cfg.auth !== undefined) {
151
+ ctx.addIssue({
152
+ code: "custom",
153
+ path: ["auth"],
154
+ message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
155
+ });
156
+ }
157
+ } else if (cfg.url === undefined) {
158
+ ctx.addIssue({
159
+ code: "custom",
160
+ message: `"url" is required for ${cfg.transport} transport`,
161
+ });
162
+ }
163
+ }),
161
164
  );
162
165
 
166
+ const DEFAULT_SETTINGS = {
167
+ toolPrefix: "mcp",
168
+ requestTimeoutMs: 30000,
169
+ maxRetries: 5,
170
+ } as const;
171
+
163
172
  const SettingsSchema = z.object({
164
173
  /**
165
174
  * Prefix used in Pi tool names: <prefix>_<server>_<tool>.
@@ -168,16 +177,16 @@ const SettingsSchema = z.object({
168
177
  toolPrefix: z
169
178
  .string()
170
179
  .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
171
- .default("mcp"),
180
+ .default(DEFAULT_SETTINGS.toolPrefix),
172
181
  /** Default per-request timeout in ms for all servers. Default: 30000. */
173
- requestTimeoutMs: z.number().positive().default(30000),
182
+ requestTimeoutMs: z.number().positive().default(DEFAULT_SETTINGS.requestTimeoutMs),
174
183
  /** Maximum retry attempts when a server fails to connect. Default: 5. */
175
- maxRetries: z.number().int().min(0).max(10).default(5),
184
+ maxRetries: z.number().int().min(0).max(10).default(DEFAULT_SETTINGS.maxRetries),
176
185
  });
177
186
 
178
187
  const McpConfigSchema = z.object({
179
- settings: SettingsSchema.default({}),
180
- mcpServers: z.record(ServerConfigSchema).default({}),
188
+ settings: SettingsSchema.default(() => ({ ...DEFAULT_SETTINGS })),
189
+ mcpServers: z.record(z.string(), ServerConfigSchema).default({}),
181
190
  });
182
191
 
183
192
  /**
@@ -195,7 +204,7 @@ const ProjectSettingsSchema = z.object({
195
204
  });
196
205
  const ProjectConfigSchema = z.object({
197
206
  settings: ProjectSettingsSchema.default({}),
198
- mcpServers: z.record(ServerConfigSchema).default({}),
207
+ mcpServers: z.record(z.string(), ServerConfigSchema).default({}),
199
208
  });
200
209
  type ProjectConfig = z.output<typeof ProjectConfigSchema>;
201
210
 
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import { loadConfig, parseRegisteredServerConfig, type McpConfig, type ServerConfig } from "./config.js";
18
18
  import { ServerManager } from "./server-manager.js";
19
+ import { sanitizeTerminalText } from "./terminal-text.js";
19
20
  import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
20
21
  import * as Type from "typebox";
21
22
  import { ToolBridge } from "./tool-bridge.js";
@@ -65,7 +66,7 @@ function openBrowser(url: string): Promise<void> {
65
66
  });
66
67
 
67
68
  child.once("error", (err) => {
68
- console.error(`[pi-mcp] Failed to open browser: ${err.message}`);
69
+ console.error(`[pi-mcp] Failed to open browser: ${sanitizeTerminalText(err.message)}`);
69
70
  reject(err);
70
71
  });
71
72
 
@@ -86,7 +87,7 @@ export const startupAuthLaterOption = "Not now";
86
87
 
87
88
  function formatErrorMessage(err: unknown): string {
88
89
  const msg = err instanceof McpError ? err.userMessage : err instanceof Error ? err.message : String(err);
89
- return msg.replace(/^AuthRequiredError:\s*/, "");
90
+ return sanitizeTerminalText(msg.replace(/^AuthRequiredError:\s*/, ""));
90
91
  }
91
92
 
92
93
  interface OAuthCallbackResult {
@@ -220,7 +221,7 @@ export async function waitForOAuthCallback(
220
221
  });
221
222
  } catch (err) {
222
223
  if (err instanceof CallbackUrlValidationError) {
223
- context.ui.notify?.(`pi-mcp: Invalid redirect URL — ${err.message}`, "error");
224
+ context.ui.notify?.(`pi-mcp: Invalid redirect URL — ${sanitizeTerminalText(err.message)}`, "error");
224
225
  continue;
225
226
  }
226
227
  throw err;
@@ -346,7 +347,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
346
347
  } catch (err) {
347
348
  // Can't notify yet (no ctx), so log to stderr. The session_start handler
348
349
  // will re-try with the real cwd and surface errors properly.
349
- console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
350
+ console.error(`[pi-mcp] Config error: ${sanitizeTerminalText(err instanceof McpError ? err.message : String(err))}`);
350
351
  }
351
352
 
352
353
  const getRegisteredEntries = (): RegisteredMcpServer[] =>
@@ -357,7 +358,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
357
358
  let extensionServers = collectRegisteredServers(
358
359
  getRegisteredEntries(),
359
360
  fileConfig.mcpServers,
360
- (err) => console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`),
361
+ (err) => console.error(`[pi-mcp] Config error: ${sanitizeTerminalText(err instanceof McpError ? err.message : String(err))}`),
361
362
  );
362
363
  let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
363
364
 
@@ -808,7 +809,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
808
809
  `Server: ${serverName}`,
809
810
  `State: ${server.state}`,
810
811
  `Retries: ${server.retryCount}`,
811
- server.lastError ? `Last error: ${server.lastError.message}` : null,
812
+ server.lastError ? `Last error: ${sanitizeTerminalText(server.lastError.message)}` : null,
812
813
  "",
813
814
  "Recent output:",
814
815
  logs,
@@ -867,7 +868,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
867
868
  ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
868
869
  } catch (err) {
869
870
  if (err instanceof AuthCancelledError) {
870
- ctx.ui.notify(`pi-mcp: ${err.message}`, "info");
871
+ ctx.ui.notify(`pi-mcp: ${sanitizeTerminalText(err.message)}`, "info");
871
872
  return;
872
873
  }
873
874
  const msg = formatErrorMessage(err);
@@ -950,7 +951,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
950
951
  );
951
952
  } catch (err) {
952
953
  ctx.ui.notify(
953
- `pi-mcp: Failed to open browser for ${serverName} (${err instanceof Error ? err.message : String(err)}). Open ${url.toString()} manually or choose "${authPasteOption}".`,
954
+ `pi-mcp: Failed to open browser for ${serverName} (${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}). Open ${url.toString()} manually or choose "${authPasteOption}".`,
954
955
  "error",
955
956
  );
956
957
  }
@@ -977,7 +978,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
977
978
  await authProvider.invalidateCredentials("tokens");
978
979
  } catch (err) {
979
980
  if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
980
- console.error(`[pi-mcp] ${err.message}`);
981
+ console.error(`[pi-mcp] ${sanitizeTerminalText(err.message)}`);
981
982
  } else {
982
983
  throw err;
983
984
  }
@@ -1044,7 +1045,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1044
1045
  if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
1045
1046
  // WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
1046
1047
  // Retry auth() once so interactive authorization proceeds cleanly.
1047
- console.error(`[pi-mcp] ${err.message}`);
1048
+ console.error(`[pi-mcp] ${sanitizeTerminalText(err.message)}`);
1048
1049
  authResult = await auth(authProvider, authOptions);
1049
1050
  } else {
1050
1051
  throw err;
@@ -1292,7 +1293,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1292
1293
  } catch (err) {
1293
1294
  // WHY: Never let a single server's corrupt state file abort mcp_status for all servers.
1294
1295
  // getAuthStatus() already backed up and deleted the corrupt file before throwing.
1295
- console.error(`[pi-mcp] ${err instanceof Error ? err.message : String(err)}`);
1296
+ console.error(`[pi-mcp] ${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}`);
1296
1297
  authCorrupt = true;
1297
1298
  }
1298
1299
  }
@@ -1364,7 +1365,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1364
1365
  }
1365
1366
  try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
1366
1367
  catch (err) {
1367
- ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
1368
+ ctx.ui.notify(`pi-mcp: ${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}`, err instanceof AuthCancelledError ? "info" : "error");
1368
1369
  }
1369
1370
  },
1370
1371
  getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers(), { authOnly: true }),
@@ -164,7 +164,7 @@ export const StoredStateSchema = z.object({
164
164
  serverUrl: z.string().optional(),
165
165
  clientInfo: StoredClientInfoSchema.optional(),
166
166
  tokens: StoredTokensSchema.optional(),
167
- discoveryState: z.record(z.unknown()).optional(),
167
+ discoveryState: z.record(z.string(), z.unknown()).optional(),
168
168
  });
169
169
 
170
170
  // ─── File helpers ─────────────────────────────────────────────────────────────
@@ -34,8 +34,16 @@ import {
34
34
  getClientDefaultOptions,
35
35
  type ToolRefreshOptions,
36
36
  } from "./tool-bridge.js";
37
+ import { sanitizeTerminalText, type SanitizeTerminalTextOptions } from "./terminal-text.js";
37
38
 
38
- export { setClientDefaultOptions, getClientDefaultOptions, type ToolRefreshOptions, AuthCancelledError };
39
+ export {
40
+ setClientDefaultOptions,
41
+ getClientDefaultOptions,
42
+ type ToolRefreshOptions,
43
+ AuthCancelledError,
44
+ sanitizeTerminalText,
45
+ type SanitizeTerminalTextOptions,
46
+ };
39
47
 
40
48
 
41
49
  export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>()): boolean {
@@ -101,12 +109,19 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
101
109
  }
102
110
 
103
111
  if (
112
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
104
113
  /^\s*401\s*$/.test(msg) ||
114
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
105
115
  /\bHTTP\s+401\b/i.test(msg) ||
116
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
106
117
  /\bstatus(?:\s+code)?[:\s(]+401\b/i.test(msg) ||
118
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
107
119
  /\breturned\s+401\b/i.test(msg) ||
120
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
108
121
  /\b401\s+Unauthorized\b/i.test(msg) ||
122
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
109
123
  /:\s*401\b/.test(msg) ||
124
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
110
125
  /\(401\)/.test(msg) ||
111
126
  /\bUnauthorized\b/i.test(msg) ||
112
127
  /\binvalid_token\b/i.test(msg)
@@ -129,13 +144,7 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
129
144
  * control characters from a single server stderr line before storing or logging.
130
145
  */
131
146
  export function sanitizeStderrLine(line: string): string {
132
- return line
133
- .replace(/\r$/, "")
134
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
135
- .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
136
- .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, "")
137
- .replace(/\x1b[@-Z\\-_]/g, "")
138
- .replace(/\x1b|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
147
+ return sanitizeTerminalText(line);
139
148
  }
140
149
 
141
150
  function resolveEnv(extra?: Record<string, string>): Record<string, string> {
@@ -411,7 +420,7 @@ export class ServerManager {
411
420
  if (all.length === 0) return "pi-mcp: No servers configured (create ~/.pi/agent/mcp.json)";
412
421
  const lines = all.map((s) => {
413
422
  const icon = s.state === "ready" ? "✓" : s.state === "starting" ? "⟳" : "✗";
414
- const err = s.lastError ? ` — ${s.lastError.message}` : "";
423
+ const err = s.lastError ? ` — ${sanitizeTerminalText(s.lastError.message)}` : "";
415
424
  return ` ${icon} ${s.name} (${s.state})${err}`;
416
425
  });
417
426
  const ready = all.filter((s) => s.state === "ready").length;
@@ -820,9 +829,9 @@ export class ServerManager {
820
829
  signal,
821
830
  });
822
831
  } catch (err) {
832
+ const msg = sanitizeTerminalText(err instanceof Error ? err.message : String(err));
823
833
  console.error(
824
- `[pi-mcp] Failed to refresh tools for ${server.name}:`,
825
- err,
834
+ `[pi-mcp] Failed to refresh tools for ${server.name}: ${msg}`,
826
835
  );
827
836
  }
828
837
  }
@@ -834,9 +843,12 @@ export class ServerManager {
834
843
  LoggingMessageNotificationSchema,
835
844
  async (notification) => {
836
845
  const { level = "info", logger = server.name, data } = notification.params ?? {};
837
- const msg = typeof data === "string" ? data : JSON.stringify(data);
838
- console.error(`[pi-mcp:${server.name}] [${level}] ${logger}: ${msg}`);
839
- appendStderr(`[${level}] ${logger}: ${msg}`);
846
+ const rawMsg = typeof data === "string" ? data : JSON.stringify(data);
847
+ const safeLevel = sanitizeTerminalText(String(level), { maxLength: 32 });
848
+ const safeLogger = sanitizeTerminalText(String(logger), { maxLength: 100 });
849
+ const safeMsg = sanitizeTerminalText(rawMsg ?? "");
850
+ console.error(`[pi-mcp:${server.name}] [${safeLevel}] ${safeLogger}: ${safeMsg}`);
851
+ appendStderr(`[${safeLevel}] ${safeLogger}: ${safeMsg}`);
840
852
  },
841
853
  );
842
854
 
@@ -956,14 +968,15 @@ export class ServerManager {
956
968
  if (server.state !== "ready" || signal.aborted || server.client !== client) {
957
969
  return;
958
970
  }
959
- const msg = err instanceof Error ? err.message : String(err);
971
+ const rawMsg = err instanceof Error ? err.message : String(err);
960
972
  if (
961
973
  /unknown message ID|Received a response for an unknown message ID|notifications\/progress|unknown token/i.test(
962
- msg,
974
+ rawMsg,
963
975
  )
964
976
  ) {
965
977
  return;
966
978
  }
979
+ const msg = sanitizeTerminalText(rawMsg);
967
980
  if (server.config.transport === "sse") {
968
981
  console.error(`[pi-mcp] Server ${server.name} SSE stream error, reconnecting: ${msg}`);
969
982
  scheduleErrorRecovery();
@@ -982,7 +995,7 @@ export class ServerManager {
982
995
  // on GET. Ignore it so POST-only servers do not enter a spurious recovery loop; genuine
983
996
  // stream drops on established streams report "SSE stream disconnected" or
984
997
  // "Maximum reconnection attempts (0) exceeded." instead.
985
- if (/Failed to open SSE stream:/i.test(msg)) {
998
+ if (/Failed to open SSE stream:/i.test(rawMsg)) {
986
999
  return;
987
1000
  }
988
1001
  console.error(`[pi-mcp] Server ${server.name} HTTP stream error, reconnecting: ${msg}`);
@@ -1017,7 +1030,7 @@ export class ServerManager {
1017
1030
  // WHY: McpOAuthProvider._loadState() already backed up and removed the corrupt
1018
1031
  // state file before throwing this error. Log the warning and retry once immediately
1019
1032
  // without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
1020
- console.error(`[pi-mcp] ${error.message}`);
1033
+ console.error(`[pi-mcp] ${sanitizeTerminalText(error.message)}`);
1021
1034
  if (signal.aborted) throw new AuthCancelledError(server.name);
1022
1035
  return this._connect(server, server.cwd ?? cwd, signal, true);
1023
1036
  }
@@ -1089,7 +1102,7 @@ export class ServerManager {
1089
1102
  if (err instanceof AuthCancelledError) return;
1090
1103
  const error = err instanceof Error ? err : new Error(String(err));
1091
1104
  server.lastError = error;
1092
- console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${error.message}`);
1105
+ console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${sanitizeTerminalText(error.message)}`);
1093
1106
  this.onRecoveryFailed?.(server.name, error);
1094
1107
  });
1095
1108
  });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pure terminal-text sanitization helpers.
3
+ *
4
+ * Strips ANSI/VT escape sequences (7-bit ESC and 8-bit C1 CSI/OSC/DCS/PM/APC),
5
+ * C0/C1 control characters, and embedded line breaks, and bounds string length
6
+ * before server-controlled strings reach Pi UI notifications or process.stderr.
7
+ */
8
+
9
+ /** Default maximum character budget for a single log or notification string. */
10
+ export const DEFAULT_TERMINAL_TEXT_MAX_LENGTH = 500;
11
+
12
+ export interface SanitizeTerminalTextOptions {
13
+ /** Maximum UTF-16 code units before truncating with `…` (default: 500). */
14
+ maxLength?: number | undefined;
15
+ }
16
+
17
+ function isHighSurrogate(code: number): boolean {
18
+ return code >= 0xd800 && code <= 0xdbff;
19
+ }
20
+
21
+ function isLowSurrogate(code: number): boolean {
22
+ return code >= 0xdc00 && code <= 0xdfff;
23
+ }
24
+
25
+ /**
26
+ * Strip ANSI CSI/OSC/DCS/PM/APC sequences (7-bit and 8-bit C1), single-char ESC
27
+ * sequences, and C0/C1 control characters, collapse embedded CR/LF into spaces
28
+ * so a single log or notification cannot inject fake lines, and bound length
29
+ * with `…` without splitting a UTF-16 surrogate pair.
30
+ */
31
+ export function sanitizeTerminalText(
32
+ text: string,
33
+ opts?: SanitizeTerminalTextOptions,
34
+ ): string {
35
+ const maxLength = opts?.maxLength ?? DEFAULT_TERMINAL_TEXT_MAX_LENGTH;
36
+ if (maxLength <= 0) {
37
+ return "";
38
+ }
39
+
40
+ const cleaned = text
41
+ .replace(/\r$/, "")
42
+ .replace(/(?:\x1b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
43
+ .replace(/(?:\x1b\]|\u009d)[\s\S]*?(?:\x07|\x1b\\|\u009c|$)/g, "")
44
+ .replace(/(?:\x1b[PX^_]|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\x1b\\|\u009c|\x07|$)/g, "")
45
+ .replace(/\x1b[@-Z\\-_]/g, "")
46
+ .replace(/[\r\n\t]+/g, " ")
47
+ .replace(/\x1b|[\x00-\x1f\x7f-\x9f]/g, "");
48
+
49
+ if (cleaned.length <= maxLength) {
50
+ return cleaned;
51
+ }
52
+
53
+ if (maxLength === 1) {
54
+ return "…";
55
+ }
56
+
57
+ let end = maxLength - 1;
58
+ if (
59
+ end > 0 &&
60
+ isHighSurrogate(cleaned.charCodeAt(end - 1)) &&
61
+ isLowSurrogate(cleaned.charCodeAt(end))
62
+ ) {
63
+ end -= 1;
64
+ }
65
+
66
+ return `${cleaned.slice(0, end)}…`;
67
+ }