@chatpanel/bridge 0.10.41 → 0.10.42

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": "@chatpanel/bridge",
3
- "version": "0.10.41",
3
+ "version": "0.10.42",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
package/src/api-compat.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // are rejected when accepting them would produce misleading behavior.
7
7
 
8
8
  import { randomUUID } from 'node:crypto';
9
+ import { normalizeNames } from './mcp-quarantine.js';
9
10
 
10
11
  const AGENT_IDS = new Set(['claude', 'codex', 'antigravity', 'pi', 'opencode', 'kiro', 'copilot', 'deepseek']);
11
12
 
@@ -76,6 +77,9 @@ function resolveTarget(model, chatpanel = {}) {
76
77
  workingDir: chatpanel.working_dir || chatpanel.workingDir || process.env.CHATPANEL_API_WORKING_DIR || '',
77
78
  permissionMode,
78
79
  useLocalConfig: chatpanel.use_local_config ?? chatpanel.useLocalConfig ?? true,
80
+ // Servers in the agent's OWN MCP config to leave out of this run — one that can't
81
+ // authenticate or can't be reached otherwise kills the whole turn (see mcp-quarantine.js).
82
+ mcpDisabled: normalizeNames(chatpanel.mcp_disabled ?? chatpanel.mcpDisabled),
79
83
  ...(engineModel ? { model: engineModel } : {}),
80
84
  },
81
85
  };
package/src/cli-errors.js CHANGED
@@ -7,6 +7,10 @@
7
7
  //
8
8
  // The failure is also usually not ChatPanel's to fix. Saying whose it is, and naming the
9
9
  // server, is the difference between "something broke" and "re-auth that server".
10
+ //
11
+ // `mcpFailure` is the machine-readable half of the same knowledge: mcp-quarantine.js reads
12
+ // it to DROP the offending server and run again, so the common cases never reach a human at
13
+ // all. One set of patterns serves both — a second copy would drift.
10
14
 
11
15
  const NOISE = [
12
16
  /^\s*$/,
@@ -27,19 +31,79 @@ export function stripMarkup(text) {
27
31
  .replace(/[ \t]+/g, ' ');
28
32
  }
29
33
 
30
- // The named causes worth translating. Each returns a sentence that says what to DO.
31
- function knownCause(text) {
32
- const server = /refresh OAuth tokens for server ([\w.-]+)/i.exec(text)?.[1]
33
- || /server ['"]?([\w.-]+)['"]? (?:requires|failed) auth/i.exec(text)?.[1];
34
- if (/refresh token (?:does not exist|was rejected)|invalid_grant/i.test(text)) {
35
- return `${server ? `its MCP server "${server}"` : 'one of its MCP servers'} needs re-authentication — its saved OAuth token has expired or been revoked. Re-login to that server in the agent's own config; ChatPanel can't refresh it.`;
36
- }
37
- if (/invalid_token|AuthRequired|www-authenticate|\b401\b/i.test(text)) {
38
- return `${server ? `its MCP server "${server}"` : 'one of its MCP servers'} rejected the agent's token (401/invalid_token). Re-authenticate that server in the agent, then retry.`;
34
+ // Which server the output blames, if it names one. Codex and Claude Code phrase this several
35
+ // ways, so try each; a name we cannot read means there is nothing to disable, which is why
36
+ // every caller must tolerate `null`.
37
+ const SERVER_PATTERNS = [
38
+ /(?:refresh OAuth tokens|refresh tools) for (?:MCP )?server ['"`]?([\w.-]+)['"`]?/i,
39
+ /MCP server ['"`]([\w.-]+)['"`]/i,
40
+ /server ['"]?([\w.-]+)['"]? (?:requires|failed) auth/i,
41
+ ];
42
+ export function mcpServerName(text) {
43
+ for (const re of SERVER_PATTERNS) {
44
+ const name = re.exec(String(text || ''))?.[1];
45
+ if (name) return name;
39
46
  }
40
- if (/HTTP 403|\b403\b/.test(text)) {
41
- return `${server ? `its MCP server "${server}"` : 'one of its MCP servers'} returned 403 and an error page — that server is refusing requests or is temporarily down. This is outside ChatPanel; retry when it recovers.`;
47
+ return null;
48
+ }
49
+
50
+ // The named MCP causes worth translating. `say` is the sentence for a human; `short` is the
51
+ // clause used when we skip the server and carry on. Order matters — specific before generic.
52
+ const MCP_CAUSES = [
53
+ {
54
+ kind: 'expired',
55
+ test: /refresh token (?:does not exist|was rejected)|invalid_grant/i,
56
+ short: 'its saved login has expired',
57
+ say: (who) => `${who} needs re-authentication — its saved OAuth token has expired or been revoked. Re-login to that server in the agent's own config; ChatPanel can't refresh it.`,
58
+ },
59
+ {
60
+ kind: 'unauthorized',
61
+ test: /invalid_token|AuthRequired|www-authenticate|\b401\b/i,
62
+ short: 'it rejected the agent\'s token',
63
+ say: (who) => `${who} rejected the agent's token (401/invalid_token). Re-authenticate that server in the agent, then retry.`,
64
+ },
65
+ {
66
+ kind: 'refused',
67
+ test: /HTTP 403|\b403\b/,
68
+ short: 'it is refusing requests',
69
+ say: (who) => `${who} returned 403 and an error page — that server is refusing requests or is temporarily down. This is outside ChatPanel; retry when it recovers.`,
70
+ },
71
+ {
72
+ // The off-network case: a VPN-only server seen from a cafe. Only counted when the text
73
+ // is talking about MCP at all, so an ordinary provider timeout is not misread as this.
74
+ kind: 'unreachable',
75
+ requiresMcpContext: true,
76
+ test: /handshaking with MCP server failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|dns error|connection (?:refused|reset)|failed to start and is unavailable|network is unreachable/i,
77
+ short: 'it could not be reached',
78
+ say: (who) => `${who} could not be reached — it is offline, or needs a network (VPN) this machine is not on. ChatPanel can't reach it either; retry when it is available.`,
79
+ },
80
+ ];
81
+
82
+ /**
83
+ * Classify an MCP-server failure in an agent's output.
84
+ * @returns {{server: string|null, kind: string, short: string, say: string}|null}
85
+ */
86
+ export function mcpFailure(text) {
87
+ const raw = String(text || '');
88
+ const server = mcpServerName(raw);
89
+ const hasMcpContext = /\bmcp\b/i.test(raw) || !!server;
90
+ for (const cause of MCP_CAUSES) {
91
+ if (cause.requiresMcpContext && !hasMcpContext) continue;
92
+ if (!cause.test.test(raw)) continue;
93
+ return {
94
+ server,
95
+ kind: cause.kind,
96
+ short: cause.short,
97
+ say: cause.say(server ? `its MCP server "${server}"` : 'one of its MCP servers'),
98
+ };
42
99
  }
100
+ return null;
101
+ }
102
+
103
+ // The named causes worth translating. Each returns a sentence that says what to DO.
104
+ function knownCause(text) {
105
+ const mcp = mcpFailure(text);
106
+ if (mcp) return mcp.say;
43
107
  if (/ENOENT|command not found/i.test(text)) return 'the command could not be found on PATH.';
44
108
  return null;
45
109
  }
@@ -25,6 +25,7 @@ import { buildCliPrompt } from './prompt.js';
25
25
  import { pushExtraArgs, FORBIDDEN } from './args.js';
26
26
  import { resolveWorkdir } from '../workdir.js';
27
27
  import { summarizeCliError } from '../cli-errors.js';
28
+ import { disabledMcpServers, planMcpRetry, quarantine } from '../mcp-quarantine.js';
28
29
 
29
30
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
30
31
  // streaming never trips it — only true silence does. Override with
@@ -117,6 +118,40 @@ export function codexMcpConfigArgs(mcp) {
117
118
  return args;
118
119
  }
119
120
 
121
+ // Which MCP servers this Codex actually has configured.
122
+ //
123
+ // READ THIS BEFORE TOUCHING codexDisableArgs. `-c mcp_servers.X.enabled=false` for a name
124
+ // that is NOT already in config.toml does not disable anything — it DEFINES a new server
125
+ // that has no transport, and Codex then refuses to start at all:
126
+ // Error: failed to load bootstrap configuration
127
+ // Caused by: invalid transport in `mcp_servers.does_not_exist`
128
+ // So a stale quarantine entry, or a typo in the user's deny list, would break EVERY run —
129
+ // strictly worse than the failure this feature exists to fix. We therefore only ever disable
130
+ // a server we can see, and a name we cannot see is left alone (a no-op, never a break).
131
+ //
132
+ // Read from config.toml — instant, and the same source listModels() already reads — rather
133
+ // than shelling out to `codex mcp list --json`, which is authoritative but costs ~0.8s on
134
+ // every single turn.
135
+ export function configuredMcpServers(home) {
136
+ try {
137
+ const cfg = readFileSync(path.join(home, 'config.toml'), 'utf8');
138
+ const names = new Set();
139
+ for (const m of cfg.matchAll(/^\s*\[mcp_servers\.([A-Za-z0-9_-]+)\s*[.\]]/gm)) names.add(m[1]);
140
+ return names;
141
+ } catch {
142
+ return new Set(); // no config (or the isolated home) — there is nothing to disable
143
+ }
144
+ }
145
+
146
+ // Turn off servers from the user's own config.toml for THIS RUN only — never by editing
147
+ // their file. Names are validated upstream (mcp-quarantine.js) and filtered against
148
+ // configuredMcpServers by the caller, which is what makes it safe to interpolate one into a
149
+ // `-c` override key; `-c` is otherwise blocked in extraArgs precisely because it reaches
150
+ // config that matters.
151
+ export function codexDisableArgs(names = []) {
152
+ return names.flatMap((name) => ['-c', `mcp_servers.${name}.enabled=false`]);
153
+ }
154
+
120
155
  // Write base64 data-URL images to temp files so `codex exec -i <file>` can
121
156
  // attach them to the prompt as vision input. Returns the paths (caller cleans up).
122
157
  async function writeImages(images, tag) {
@@ -132,54 +167,15 @@ async function writeImages(images, tag) {
132
167
  return files;
133
168
  }
134
169
 
135
- export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
136
- const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
137
- const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
138
- const imageFiles = await writeImages(images, tag);
139
- const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
140
-
141
- const cwd = resolveWorkdir(options.workingDir);
142
- const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
143
- // Headless exec has no human to approve actions. With MCP/browser tools armed
144
- // Codex would otherwise raise an approval prompt it can't show — and cancel the
145
- // tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
146
- // use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
147
- // keep the sandbox + never-ask, which auto-runs within bounds.
148
- if (options.permissionMode === 'bypassPermissions') {
149
- args.push('--dangerously-bypass-approvals-and-sandbox');
150
- } else {
151
- const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
152
- args.push('-s', sandbox, '-c', 'approval_policy=never');
153
- }
154
- if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
155
- // Ask Codex to emit reasoning SUMMARIES so the panel can stream the model's thinking.
156
- // Additive + safe: a no-op for models/providers that don't produce summaries (e.g. some
157
- // hosted models expose none), and cheap at the default effort. forwardEvent renders them.
158
- args.push('-c', 'model_reasoning_summary=auto');
159
- // Browser tools: register the bridge's MCP server as a stdio MCP server (the
160
- // bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
161
- // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
162
- args.push(...codexMcpConfigArgs(options.mcp));
163
- if (options.model) args.push('-m', options.model);
164
- // Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
165
- pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
166
- for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
167
- args.push('-');
168
-
169
- // Default: use the user's skills/config. Opt-out → isolated home.
170
- const useLocal = options.useLocalConfig !== false;
171
- const env = { ...process.env };
172
- if (!useLocal) {
173
- const home = ensureIsolatedHome();
174
- if (home) env.CODEX_HOME = home;
175
- }
176
-
177
- await new Promise((resolve, reject) => {
170
+ // A single `codex exec` attempt. Resolves with how it ended rather than emitting the final
171
+ // message itself, so the caller can decide whether the run is worth ATTEMPTING AGAIN — the
172
+ // terminal delta/done belongs to whichever attempt actually answered.
173
+ function runCodex({ args, cwd, env, prompt, outFile, emit, signal }) {
174
+ return new Promise((resolve, reject) => {
178
175
  let child;
179
176
  try {
180
177
  child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, ...spawnGroupOpts });
181
178
  } catch (e) {
182
- cleanupImages();
183
179
  return reject(new Error(`Failed to start codex: ${e.message}`));
184
180
  }
185
181
 
@@ -187,6 +183,13 @@ export async function chat({ messages, system, options, images }, emit, { signal
187
183
 
188
184
  let stdout = '';
189
185
  let stderr = '';
186
+ // Retrying is only honest while nothing the user can see has been produced. Status lines
187
+ // are ours and are replaceable; a delta, a tool call or a reasoning summary is not.
188
+ let streamed = false;
189
+ const relay = (ev) => {
190
+ if (ev?.type !== 'status') streamed = true;
191
+ emit(ev);
192
+ };
190
193
  // Per-run event state: correlate a command's started/completed events and emit each
191
194
  // reasoning summary once (Codex sends the same item id across item.started/updated/completed).
192
195
  const evState = { started: new Set(), reasoned: new Set(), n: 0 };
@@ -209,7 +212,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
209
212
  stdout = stdout.slice(nl + 1);
210
213
  if (!line.startsWith('{')) continue;
211
214
  try {
212
- forwardEvent(JSON.parse(line), emit, evState);
215
+ forwardEvent(JSON.parse(line), relay, evState);
213
216
  } catch {
214
217
  /* not a JSON event line */
215
218
  }
@@ -219,7 +222,6 @@ export async function chat({ messages, system, options, images }, emit, { signal
219
222
  child.on('error', (e) => {
220
223
  clearTimeout(idleTimer);
221
224
  detach();
222
- cleanupImages();
223
225
  reject(e);
224
226
  });
225
227
  child.on('close', async (code) => {
@@ -232,22 +234,98 @@ export async function chat({ messages, system, options, images }, emit, { signal
232
234
  /* no message file */
233
235
  }
234
236
  unlink(outFile).catch(() => {});
235
- cleanupImages();
236
- if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
237
- if (code === 0) {
238
- emit({ type: 'delta', text: text || '(no output)' });
239
- emit({ type: 'done', text: '' });
240
- resolve();
241
- } else {
242
- reject(new Error(summarizeCliError('Codex', code, stderr)));
243
- }
237
+ resolve({ code, stderr, text, streamed });
244
238
  });
245
239
 
246
- child.stdin.write(buildCliPrompt(messages, system));
240
+ child.stdin.write(prompt);
247
241
  child.stdin.end();
248
242
  });
249
243
  }
250
244
 
245
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
246
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
247
+ const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
248
+ const imageFiles = await writeImages(images, tag);
249
+ const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
250
+
251
+ const cwd = resolveWorkdir(options.workingDir);
252
+ // Sanitized once, not per attempt — pushExtraArgs emits a status when it drops something,
253
+ // and a retry must not say it twice.
254
+ const extraArgs = [];
255
+ pushExtraArgs(extraArgs, options.extraArgs, FORBIDDEN.codex, emit);
256
+
257
+ const buildArgs = (disabled) => {
258
+ const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
259
+ // Headless exec has no human to approve actions. With MCP/browser tools armed
260
+ // Codex would otherwise raise an approval prompt it can't show — and cancel the
261
+ // tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
262
+ // use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
263
+ // keep the sandbox + never-ask, which auto-runs within bounds.
264
+ if (options.permissionMode === 'bypassPermissions') {
265
+ args.push('--dangerously-bypass-approvals-and-sandbox');
266
+ } else {
267
+ const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
268
+ args.push('-s', sandbox, '-c', 'approval_policy=never');
269
+ }
270
+ if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
271
+ // Ask Codex to emit reasoning SUMMARIES so the panel can stream the model's thinking.
272
+ // Additive + safe: a no-op for models/providers that don't produce summaries (e.g. some
273
+ // hosted models expose none), and cheap at the default effort. forwardEvent renders them.
274
+ args.push('-c', 'model_reasoning_summary=auto');
275
+ // Browser tools: register the bridge's MCP server as a stdio MCP server (the
276
+ // bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
277
+ // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
278
+ args.push(...codexMcpConfigArgs(options.mcp));
279
+ // ...and the other direction: the user's own servers we've been told to leave out.
280
+ args.push(...codexDisableArgs(disabled));
281
+ if (options.model) args.push('-m', options.model);
282
+ // Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
283
+ args.push(...extraArgs);
284
+ for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
285
+ args.push('-');
286
+ return args;
287
+ };
288
+
289
+ // Default: use the user's skills/config. Opt-out → isolated home.
290
+ const useLocal = options.useLocalConfig !== false;
291
+ const env = { ...process.env };
292
+ if (!useLocal) {
293
+ const home = ensureIsolatedHome();
294
+ if (home) env.CODEX_HOME = home;
295
+ }
296
+
297
+ const ownServer = options.mcp?.serverName || 'chatpanel_browser';
298
+ // Only servers Codex already knows about can be switched off — see configuredMcpServers.
299
+ const known = configuredMcpServers(env.CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
300
+ let disabled = disabledMcpServers('codex', options, [ownServer]).filter((n) => known.has(n));
301
+ const prompt = buildCliPrompt(messages, system);
302
+ const attempt = () => runCodex({ args: buildArgs(disabled), cwd, env, prompt, outFile, emit, signal });
303
+
304
+ try {
305
+ let result = await attempt();
306
+
307
+ // The failure this exists for: one of the user's OWN MCP servers could not authenticate
308
+ // or could not be reached, and took a turn with it that never needed that server. Drop it
309
+ // and run again — once, out loud, and only while nothing has been streamed.
310
+ if (!signal?.aborted && result.code !== 0 && !result.streamed) {
311
+ const drop = planMcpRetry({ agent: 'codex', text: result.stderr, protect: [ownServer], already: disabled });
312
+ if (drop && known.has(drop.server)) {
313
+ quarantine('codex', drop.server);
314
+ emit({ type: 'status', text: `MCP server "${drop.server}" failed to load — ${drop.short}. Skipping it and retrying.` });
315
+ disabled = [...disabled, drop.server];
316
+ result = await attempt();
317
+ }
318
+ }
319
+
320
+ if (signal?.aborted) return; // Stop pressed — end quietly, no error
321
+ if (result.code !== 0) throw new Error(summarizeCliError('Codex', result.code, result.stderr));
322
+ emit({ type: 'delta', text: result.text || '(no output)' });
323
+ emit({ type: 'done', text: '' });
324
+ } finally {
325
+ cleanupImages();
326
+ }
327
+ }
328
+
251
329
  // Translate Codex `exec --json` events into the bridge's streaming vocabulary the panel
252
330
  // renders richly. Codex sends item.started (in_progress) then item.completed for each item,
253
331
  // reusing the item id — so we correlate a tool's start/done by that id and show its command,
@@ -17,4 +17,4 @@
17
17
  // zero runtime dependencies by design; pulling the capability machinery and the event
18
18
  // schema behind it to reach a five-element array would be the transitive-graph mistake
19
19
  // the extension's first-paint budget exists to prevent, one repo over.
20
- export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'page', 'files', 'net']);
20
+ export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'memory', 'page', 'files', 'net']);
@@ -0,0 +1,110 @@
1
+ // Why a turn should not die because a server it never used could not log in.
2
+ //
3
+ // A CLI agent loads EVERY MCP server in the user's own config on every single run. One that
4
+ // cannot authenticate — an expired OAuth token, a VPN-only host seen from a coffee shop —
5
+ // takes the whole turn down with it, even when the question had nothing to do with that
6
+ // server. The user is then told to go re-login to something they never asked for.
7
+ //
8
+ // So: read the failure, name the server, drop it, run again. Two ways in —
9
+ // 1. the user's own deny list (`options.mcpDisabled`), for servers they know they don't
10
+ // want ChatPanel to load at all; and
11
+ // 2. QUARANTINE — a server that just killed a run is dropped automatically and stays
12
+ // dropped for the rest of the bridge's session, since it will not have healed in the
13
+ // twenty seconds before the next message.
14
+ //
15
+ // Two rules keep this honest. It is never silent: dropping a tool without saying so is worse
16
+ // than failing loudly, so the engine emits a status line naming what it skipped. And it never
17
+ // drops ChatPanel's OWN injected server — that one failing is our bug to surface, not a
18
+ // nuisance to route around (silently disabling it would take "Act on page" with it).
19
+ //
20
+ // Engine-agnostic on purpose: Codex renders this as `-c mcp_servers.X.enabled=false`, Claude
21
+ // Code and Copilot as their own flags, but the POLICY — which servers may load this turn —
22
+ // is one decision, made here, not re-derived per engine.
23
+
24
+ import { mcpFailure } from './cli-errors.js';
25
+
26
+ // A server that failed is dropped for this long. Long enough that a chat session never pays
27
+ // the same failed startup twice; short enough that reconnecting the VPN and waiting a while
28
+ // brings the server back without restarting the bridge.
29
+ const TTL_MS = Number(process.env.CHATPANEL_MCP_QUARANTINE_MS) || 30 * 60_000;
30
+
31
+ const dropped = new Map(); // `${agent} ${server}` -> expiry epoch ms
32
+
33
+ const key = (agent, server) => `${agent} ${server}`;
34
+
35
+ function prune(now = Date.now()) {
36
+ for (const [k, expiry] of dropped) if (expiry <= now) dropped.delete(k);
37
+ }
38
+
39
+ /**
40
+ * Server names are interpolated into a config override key (`mcp_servers.<name>.enabled`),
41
+ * so they are validated rather than escaped: anything that isn't a plain MCP server name is
42
+ * dropped. Dots are excluded too — Codex's `-c` parser reads them as further path segments
43
+ * and rejects the quoted form. Accepts an array or a comma/space-separated string (what the
44
+ * settings field holds).
45
+ */
46
+ const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
47
+
48
+ /** One name, validated whole — no splitting, so junk is rejected rather than chopped valid. */
49
+ export function validName(value) {
50
+ const name = String(value || '').trim();
51
+ return NAME_RE.test(name) ? name : null;
52
+ }
53
+
54
+ export function normalizeNames(value) {
55
+ const list = Array.isArray(value) ? value : String(value || '').split(/[,\s]+/);
56
+ const out = [];
57
+ for (const raw of list) {
58
+ const name = validName(raw);
59
+ if (name && !out.includes(name)) out.push(name);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** The servers currently quarantined for an agent. */
65
+ export function quarantined(agent) {
66
+ prune();
67
+ const prefix = key(agent, '');
68
+ return [...dropped.keys()].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
69
+ }
70
+
71
+ /** Drop a server for this agent. Returns false when it was already dropped. */
72
+ export function quarantine(agent, server) {
73
+ const name = validName(server);
74
+ if (!name) return false;
75
+ const k = key(agent, name);
76
+ const fresh = !dropped.has(k);
77
+ dropped.set(k, Date.now() + TTL_MS);
78
+ return fresh;
79
+ }
80
+
81
+ /** Test seam — the store is process-global by design. */
82
+ export function resetQuarantine() {
83
+ dropped.clear();
84
+ }
85
+
86
+ /**
87
+ * Every server this run must not load: the user's deny list plus anything quarantined,
88
+ * minus the servers ChatPanel itself injected (never route around our own).
89
+ */
90
+ export function disabledMcpServers(agent, options = {}, protect = []) {
91
+ const guard = new Set(normalizeNames(protect));
92
+ const names = [...normalizeNames(options.mcpDisabled), ...quarantined(agent)];
93
+ return [...new Set(names)].filter((n) => !guard.has(n));
94
+ }
95
+
96
+ /**
97
+ * Should this failed run be retried without one of the agent's own MCP servers?
98
+ * Returns the server to drop, or null — and null is the safe answer: a failure that names no
99
+ * server, or names one we already dropped, means retrying would only fail the same way.
100
+ * @returns {{server: string, kind: string, short: string}|null}
101
+ */
102
+ export function planMcpRetry({ agent = '', text = '', protect = [], already = [] } = {}) {
103
+ const failure = mcpFailure(text);
104
+ if (!failure?.server) return null;
105
+ if (!validName(failure.server)) return null; // a name we could never write as an override
106
+ if (normalizeNames(protect).includes(failure.server)) return null;
107
+ if (normalizeNames(already).includes(failure.server)) return null;
108
+ if (quarantined(agent).includes(failure.server)) return null;
109
+ return { server: failure.server, kind: failure.kind, short: failure.short };
110
+ }
package/src/server.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
68
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
69
69
  // this drifts from package.json, so the two can't silently diverge.
70
- const VERSION = '0.10.41';
70
+ const VERSION = '0.10.42';
71
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
72
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
73
73