@chatpanel/bridge 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -19,7 +19,7 @@ import { readFile, unlink, writeFile } from 'node:fs/promises';
19
19
  import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
- import { findAgentBin } from '../env.js';
22
+ import { findAgentBin, selfMcpStdio } from '../env.js';
23
23
 
24
24
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
25
25
  // streaming never trips it — only true silence does. Override with
@@ -133,19 +133,28 @@ export async function chat({ messages, system, options, images }, emit) {
133
133
  const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
134
134
 
135
135
  const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
136
- const sandbox =
137
- options.permissionMode === 'bypassPermissions'
138
- ? 'danger-full-access'
139
- : options.permissionMode === 'acceptEdits'
140
- ? 'workspace-write'
141
- : 'read-only';
142
-
143
- const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-o', outFile];
144
- // Headless exec has no human to approve commands. Auto-run within the sandbox
145
- // so skill/startup reads don't get "approval declined" (and skills can load in
146
- // local-config mode). The sandbox above still bounds what can actually happen.
147
- args.push('-c', 'approval_policy=never');
136
+ const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
137
+ // Headless exec has no human to approve actions. With MCP/browser tools armed
138
+ // Codex would otherwise raise an approval prompt it can't show — and cancel the
139
+ // tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
140
+ // use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
141
+ // keep the sandbox + never-ask, which auto-runs within bounds.
142
+ if (options.permissionMode === 'bypassPermissions') {
143
+ args.push('--dangerously-bypass-approvals-and-sandbox');
144
+ } else {
145
+ const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
146
+ args.push('-s', sandbox, '-c', 'approval_policy=never');
147
+ }
148
148
  if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
149
+ // Browser tools: register the bridge's MCP server as a stdio MCP server (the
150
+ // bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
151
+ // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
152
+ if (options.mcp?.url) {
153
+ const name = options.mcp.serverName || 'chatpanel_browser';
154
+ const { command, args: pargs } = selfMcpStdio(options.mcp.url);
155
+ args.push('-c', `mcp_servers.${name}.command=${JSON.stringify(command)}`);
156
+ args.push('-c', `mcp_servers.${name}.args=${JSON.stringify(pargs)}`);
157
+ }
149
158
  if (options.model) args.push('-m', options.model);
150
159
  for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
151
160
  args.push('-');
@@ -19,7 +19,7 @@ import { spawn } from 'node:child_process';
19
19
  import { writeFile, unlink } from 'node:fs/promises';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
- import { resolveCommand, buildSpawnSpec } from '../env.js';
22
+ import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
23
23
  import { isProEntitled } from '../entitlement.js';
24
24
  import { handleMessage } from './claude.js';
25
25
 
@@ -187,7 +187,26 @@ export async function runSpec(spec, { messages, system, options = {}, images },
187
187
  // just before the prompt (arg mode) or get appended (stdin mode).
188
188
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
189
189
  const imageFiles = spec.imageArg ? await writeImages(images, tag) : [];
190
- const cleanup = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
190
+ const mcpFiles = [];
191
+ const cleanup = () => [...imageFiles, ...mcpFiles].forEach((f) => unlink(f).catch(() => {}));
192
+
193
+ // Browser tools: when armed (options.mcp) AND this CLI knows how to take an MCP
194
+ // config file (spec.mcpArg, e.g. "--mcp-config {file}"), write a standard
195
+ // mcpServers JSON pointing at the bridge's stdio MCP proxy and inject the flag.
196
+ // Covers any CLI that reads the de-facto {mcpServers:{name:{command,args}}} shape.
197
+ if (options.mcp?.url && spec.mcpArg) {
198
+ const name = options.mcp.serverName || 'chatpanel_browser';
199
+ const { command, args: pargs } = selfMcpStdio(options.mcp.url);
200
+ const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
201
+ await writeFile(cfgFile, JSON.stringify({ mcpServers: { [name]: { command, args: pargs } } }));
202
+ mcpFiles.push(cfgFile);
203
+ const tmpl = String(spec.mcpArg);
204
+ const tokens = tmpl.includes('{file}')
205
+ ? tmpl.replaceAll('{file}', cfgFile).split(/\s+/).filter(Boolean)
206
+ : [...tmpl.split(/\s+/).filter(Boolean), cfgFile];
207
+ args = [...tokens, ...args];
208
+ }
209
+
191
210
  const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
192
211
  let placedImages = false;
193
212
  if (imageTokens.length) {
package/src/env.js CHANGED
@@ -48,6 +48,19 @@ export function isCompiledBinary() {
48
48
  return !(base.startsWith('node') || base.startsWith('bun'));
49
49
  }
50
50
 
51
+ // How to re-invoke THIS bridge as a child process running the stdio↔HTTP MCP
52
+ // proxy (`--mcp-stdio <url>`). Lets ANY stdio-capable MCP CLI (Codex, a custom
53
+ // CLI) reach the bridge's HTTP MCP server using the bridge itself as the server
54
+ // command — no extra runtime to install. Compiled binary → the binary; Node →
55
+ // node + this entry script. Returns { command, args }.
56
+ export function selfMcpStdio(url) {
57
+ const args = ['--mcp-stdio', url];
58
+ if (isCompiledBinary()) return { command: process.execPath, args };
59
+ // Node/Bun: re-run this same entry script (server.js) under the same runtime.
60
+ const entry = process.argv[1] || path.join(process.cwd(), 'src', 'server.js');
61
+ return { command: process.execPath, args: [entry, ...args] };
62
+ }
63
+
51
64
  // Ask the user's login shell to locate a command — no hardcoded locations, works
52
65
  // wherever the user actually installed it.
53
66
  function shellWhich(name) {
Binary file
package/src/server.js CHANGED
@@ -25,11 +25,12 @@ import * as custom from './engines/custom.js';
25
25
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
26
26
  import { enrichPath, findAgentBin, resolveCommand } from './env.js';
27
27
  import { checkForUpdate, selfUpdate } from './update.js';
28
+ import { callLocalMcp } from './mcp-local.js';
28
29
 
29
30
  // Hardcoded (not read from package.json) so it survives Bun's single-file
30
31
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
31
32
  // this drifts from package.json, so the two can't silently diverge.
32
- const VERSION = '0.6.0';
33
+ const VERSION = '0.8.0';
33
34
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
34
35
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
35
36
 
@@ -273,6 +274,36 @@ async function handleMcp(req, res, sessionId) {
273
274
  return fail(-32601, `Method not found: ${msg.method}`);
274
275
  }
275
276
 
277
+ // POST /mcp-local — proxy one JSON-RPC message to a user-configured STDIO MCP
278
+ // server that the bridge spawns and keeps alive. Body: { server:{id,command,args,
279
+ // env?,cwd?}, message }. Returns the full JSON-RPC response (or 202 for a
280
+ // notification). Lets the extension use local MCP servers it can't spawn itself.
281
+ async function handleMcpLocal(req, res) {
282
+ let body;
283
+ try {
284
+ body = await readBody(req);
285
+ } catch (e) {
286
+ return json(res, 400, { error: 'Bad JSON: ' + e.message });
287
+ }
288
+ const server = body.server || {};
289
+ const message = body.message;
290
+ if (!server.command || !message) return json(res, 400, { error: 'need server.command and message' });
291
+ try {
292
+ const result = await callLocalMcp(
293
+ { key: server.id, command: server.command, args: server.args, env: server.env, cwd: server.cwd },
294
+ message,
295
+ );
296
+ if (message.id == null) { res.writeHead(202); return res.end(); }
297
+ return json(res, 200, result); // the full JSON-RPC response message
298
+ } catch (e) {
299
+ return json(res, 200, {
300
+ jsonrpc: '2.0',
301
+ id: message.id ?? null,
302
+ error: { code: -32000, message: String(e?.message || e) },
303
+ });
304
+ }
305
+ }
306
+
276
307
  // POST /tool-result — the extension returns a relayed tool's result.
277
308
  async function handleToolResult(req, res) {
278
309
  let body;
@@ -400,6 +431,7 @@ const server = createServer(async (req, res) => {
400
431
  if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
401
432
  }
402
433
  if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
434
+ if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
403
435
  if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
404
436
  if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
405
437
  if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
@@ -415,6 +447,59 @@ function log(level, msg) {
415
447
  fn(`[chatpanel-bridge] ${msg}`);
416
448
  }
417
449
 
450
+ // `--mcp-stdio <url>` — run as a stdio↔HTTP MCP proxy: read newline-delimited
451
+ // JSON-RPC from stdin, forward each message to the bridge's HTTP MCP endpoint
452
+ // (<url> = http://127.0.0.1:PORT/mcp/<session>), and write responses to stdout.
453
+ // This lets ANY stdio-MCP CLI (Codex, a custom CLI) use the browser tools with
454
+ // the bridge binary itself as the MCP server command — no extra runtime needed.
455
+ function runMcpStdioProxy(url) {
456
+ let buf = '';
457
+ const queue = [];
458
+ let draining = false;
459
+ let ended = false;
460
+ const maybeExit = () => { if (ended && !draining && !queue.length) process.exit(0); };
461
+ const drain = async () => {
462
+ if (draining) return;
463
+ draining = true;
464
+ while (queue.length) {
465
+ const line = queue.shift();
466
+ let msg;
467
+ try { msg = JSON.parse(line); } catch { continue; }
468
+ try {
469
+ const res = await fetch(url, {
470
+ method: 'POST',
471
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
472
+ body: line,
473
+ });
474
+ if (msg.id == null) continue; // notification — no response expected
475
+ const text = (await res.text()).trim();
476
+ if (text) process.stdout.write(text + '\n');
477
+ } catch (e) {
478
+ if (msg.id != null) {
479
+ process.stdout.write(
480
+ JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: String(e?.message || e) } }) + '\n',
481
+ );
482
+ }
483
+ }
484
+ }
485
+ draining = false;
486
+ maybeExit(); // stdin closed mid-flight → exit only after the queue is drained
487
+ };
488
+ process.stdin.setEncoding('utf8');
489
+ process.stdin.on('data', (chunk) => {
490
+ buf += chunk;
491
+ let nl;
492
+ while ((nl = buf.indexOf('\n')) >= 0) {
493
+ const line = buf.slice(0, nl).trim();
494
+ buf = buf.slice(nl + 1);
495
+ if (line) queue.push(line);
496
+ }
497
+ drain();
498
+ });
499
+ process.stdin.on('end', () => { ended = true; maybeExit(); });
500
+ process.stdin.resume();
501
+ }
502
+
418
503
  function startServer() {
419
504
  enrichPath(); // so codex/gemini are found even under a minimal service PATH
420
505
  server.listen(PORT, HOST, async () => {
@@ -488,7 +573,15 @@ function runCli() {
488
573
  return false;
489
574
  }
490
575
 
491
- if (process.argv.includes('--update')) {
576
+ const mcpStdioIdx = process.argv.indexOf('--mcp-stdio');
577
+ if (mcpStdioIdx >= 0) {
578
+ const url = process.argv[mcpStdioIdx + 1];
579
+ if (!url) {
580
+ console.error('--mcp-stdio requires a URL');
581
+ process.exit(1);
582
+ }
583
+ runMcpStdioProxy(url);
584
+ } else if (process.argv.includes('--update')) {
492
585
  (async () => {
493
586
  try {
494
587
  const r = await selfUpdate(VERSION);