@chatpanel/bridge 0.10.21 → 0.10.23

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.
@@ -5,9 +5,7 @@
5
5
  // Windows cli.js+.cmd / WSL — same launcher as Claude), pipes the prompt in, and
6
6
  // streams output back.
7
7
  //
8
- // HARD Pro gate: a custom agent only runs if the request carries a valid,
9
- // server-signed entitlement token (verified OFFLINE here — no network). A forked
10
- // client or a raw POST can't forge it, so this is real gating, not UI.
8
+ // Custom agents require a valid entitlement token, verified offline here (no network).
11
9
  //
12
10
  // Output formats:
13
11
  // 'text' (default) — stream stdout straight through as text deltas. Works for
@@ -21,8 +19,10 @@ import os from 'node:os';
21
19
  import path from 'node:path';
22
20
  import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
23
21
  import { isProEntitled } from '../entitlement.js';
24
- import { handleMessage } from './claude.js';
22
+ import { killOnAbort } from '../proc.js';
25
23
  import { buildCliPrompt } from './prompt.js';
24
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
25
+ import { createStreamParser, STREAM_FORMATS, stripAnsi } from './stream-formats.js';
26
26
 
27
27
  // Write base64 data-URL images to temp files so a custom CLI can take them via
28
28
  // its configured `imageArg` template (e.g. "-i {path}", "@{path}"). Returns paths.
@@ -60,11 +60,8 @@ function imageTokensFor(imageArg, files) {
60
60
  // CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
61
61
  const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
62
62
 
63
- // Many CLIs emit ANSI colour/escape codes even when piped (kiro-cli does), which
64
- // leak into the answer as `\x1b[38;5;141m…`. Strip them from text output. (We
65
- // also set NO_COLOR on the child env, but this is the robust backstop.)
66
- const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
67
- const stripAnsi = (s) => s.replace(ANSI_RE, '');
63
+ // ANSI stripping moved to stream-formats.js (imported above), which is where
64
+ // text output is actually rendered - one implementation for every format.
68
65
  const OPENCODE_STABLE_MCP_URL = 'http://127.0.0.1:4319/mcp';
69
66
  const CHATPANEL_STABLE_MCP_URL = 'http://127.0.0.1:4319/mcp';
70
67
 
@@ -95,7 +92,7 @@ function parseModelList(stdout) {
95
92
 
96
93
  // Unified model listing: run the agent's CONFIGURED list-models invocation
97
94
  // (e.g. pi `--list-models`, opencode `models`) and parse the output. Returns []
98
- // when not configured. Pro-gated like chat (it runs the user's CLI).
95
+ // when not configured. Requires Pro (runs the user's CLI).
99
96
  export async function listModels(options = {}) {
100
97
  if (!(await isProEntitled(options.entitlement))) {
101
98
  throw new Error('Custom agents require ChatPanel Pro.');
@@ -104,27 +101,35 @@ export async function listModels(options = {}) {
104
101
  return listSpecModels(spec.command, spec.listModelsArgs, options.workingDir);
105
102
  }
106
103
 
107
- // Shared model listing (no Pro gate): run a CLI's list-models invocation and
108
- // parse it. Used by the Pro custom engine (gated above) AND built-in CLI agents.
104
+ // Shared model listing: run a CLI's list-models invocation and parse it. Used by
105
+ // the custom engine and the built-in CLI agents.
109
106
  export async function listSpecModels(command, listModelsArgs, workingDir) {
110
107
  const listArgs = String(listModelsArgs || '').trim();
111
108
  if (!command || !listArgs) return [];
109
+ const stdout = await runForStdout(command, listArgs.split(/\s+/).filter(Boolean), workingDir);
110
+ return parseModelList(stdout);
111
+ }
112
+
113
+ // Run a CLI to completion and return its stdout. Shared by every "ask the CLI
114
+ // something" path (model listing and the per-agent listModels overrides) so the
115
+ // spawn/resolve/timeout handling exists once.
116
+ export async function runForStdout(command, argvIn, workingDir, timeoutMs = 20000) {
117
+ if (!command) return '';
112
118
  const resolved = resolveCommand(command);
113
119
  if (!resolved) throw new Error(`Couldn't find "${command}".`);
114
120
  const cwd = workingDir ? path.resolve(workingDir) : null;
115
- const [bin, argv, opts] = buildSpawnSpec(resolved, listArgs.split(/\s+/).filter(Boolean), cwd);
121
+ const [bin, argv, opts] = buildSpawnSpec(resolved, argvIn, cwd);
116
122
  opts.env = { ...(opts.env || process.env), NO_COLOR: '1', CLICOLOR: '0' };
117
- const stdout = await new Promise((resolve, reject) => {
123
+ return new Promise((resolve, reject) => {
118
124
  let child;
119
125
  try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${command}: ${e.message}`)); }
120
126
  let out = '';
121
- const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Listing models timed out.')); }, 20000);
127
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`${command} timed out.`)); }, timeoutMs);
122
128
  child.stdout.on('data', (d) => (out += d.toString()));
123
129
  child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Failed to start ${command}: ${e.message}`)); });
124
130
  child.on('close', () => { clearTimeout(timer); resolve(out); });
125
131
  try { child.stdin.end(); } catch { /* some CLIs don't read stdin */ }
126
132
  });
127
- return parseModelList(stdout);
128
133
  }
129
134
 
130
135
  function mcpToolSpecs(mcp) {
@@ -360,18 +365,16 @@ export async function ensureStableMcpConfig(spec, cwd, label, emit, deps = {}) {
360
365
  throw new Error(`${label} browser-tool setup completed, but the MCP server is still not visible. Run: ${setupCommand}`);
361
366
  }
362
367
 
363
- export async function chat({ messages, system, options, images }, emit) {
364
- // Pro gate — verified, not just UI. No valid signed entitlement no run.
368
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
369
+ // Require a valid signed entitlement before running.
365
370
  if (!(await isProEntitled(options.entitlement))) {
366
371
  throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
367
372
  }
368
- return runSpec(options.custom || {}, { messages, system, options, images }, emit);
373
+ return runSpec(options.custom || {}, { messages, system, options, images }, emit, { signal });
369
374
  }
370
375
 
371
- // Run a CLI agent from a spec — SHARED by the Pro custom engine (gated in chat()
372
- // above) and the built-in CLI engines (pi/opencode/kiro). This never gates; the
373
- // built-in agents are bounded instead by the extension's free 1-agent limit.
374
- export async function runSpec(spec, { messages, system, options = {}, images }, emit) {
376
+ // Run a CLI agent from a spec — shared by the custom engine and the built-in CLI engines.
377
+ export async function runSpec(spec, { messages, system, options = {}, images }, emit, { signal } = {}) {
375
378
  if (!spec.command) throw new Error('This agent has no command configured.');
376
379
 
377
380
  const resolved = resolveCommand(spec.command);
@@ -382,7 +385,10 @@ export async function runSpec(spec, { messages, system, options = {}, images },
382
385
  const prompt = buildCliPrompt(messages, system);
383
386
  let cwd = options.workingDir ? path.resolve(options.workingDir) : null;
384
387
  const label = spec.label || spec.command;
385
- const fmt = ['claude-stream-json', 'opencode-json'].includes(spec.format) ? spec.format : 'text';
388
+ // Output dialect — resolved against the stream-format registry, so a new agent
389
+ // brings a format by NAME instead of a new branch in this runner. Unknown /
390
+ // absent names fall back to plain text.
391
+ const fmt = Object.hasOwn(STREAM_FORMATS, spec.format) ? spec.format : 'text';
386
392
 
387
393
  // Args: either a real array or a space-split string. With promptVia:'arg' we
388
394
  // substitute {prompt} (or append it if there's no placeholder); otherwise the
@@ -394,9 +400,16 @@ export async function runSpec(spec, { messages, system, options = {}, images },
394
400
  ? String(spec.args).split(/\s+/).filter(Boolean)
395
401
  : [];
396
402
  // User-supplied extra CLI flags (Settings → agent → "Extra arguments"), placed
397
- // right after the base args/subcommand e.g. opencode `run --format json
398
- // --dangerously-skip-permissions`. Applies to every built-in & custom CLI agent.
399
- if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
403
+ // right after the base args/subcommand. Sanitized (shared helper): escalation flags
404
+ // (--dangerously*, --skip-permissions, --trust-all-tools, --no-sandbox …) are dropped
405
+ // from the EXTRA args so an injected value can't unlock tools. The built-in agents'
406
+ // intentional autonomy flags live in their BASE spec (cli-agents.js), not here, so
407
+ // they're unaffected; a custom CLI that genuinely needs such a flag should carry it
408
+ // in its configured command/args, not the extra-args field.
409
+ // Built-in specs may name a stricter set than the generic `custom` one, since
410
+ // each CLI's escalation flags are spelled differently (Copilot's --allow-all-*
411
+ // family isn't matched by the generic pattern).
412
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN[spec.forbidden] || FORBIDDEN.custom, emit);
400
413
  // Inject the selected model via the agent's CONFIGURED model-arg template
401
414
  // (e.g. "--model {model}" or, for opencode, "-m {model}" with provider/model).
402
415
  // Without a template we can't know how this CLI takes a model, so options.model
@@ -411,6 +424,16 @@ export async function runSpec(spec, { messages, system, options = {}, images },
411
424
  // project path, so it never loads opencode.json / its MCP servers.
412
425
  args = [...args, ...injected];
413
426
  }
427
+ // Permission mode -> flags, declared per agent as
428
+ // permissionArgs: { default: [...], acceptEdits: [...], bypassPermissions: [...] }
429
+ // Agents whose autonomy flags are unconditional keep them in `args`; this is for
430
+ // CLIs (Copilot) with a real permission surface, so the extension's existing
431
+ // per-agent Permission mode actually means something. Unknown mode -> `default`.
432
+ if (spec.permissionArgs) {
433
+ const mode = String(options.permissionMode || 'default');
434
+ const perm = spec.permissionArgs[mode] || spec.permissionArgs.default || [];
435
+ args = [...args, ...perm.map(String)];
436
+ }
414
437
  // Images: write to temp files, expand the agent's imageArg template, then place
415
438
  // the tokens. An explicit {images} placeholder in args wins; otherwise they go
416
439
  // just before the prompt (arg mode) or get appended (stdin mode).
@@ -444,6 +467,28 @@ export async function runSpec(spec, { messages, system, options = {}, images },
444
467
  // /mcp endpoint is present before letting the agent answer with no tools.
445
468
  if (options.mcp?.url) await ensureStableMcpConfig(spec, cwd, label, emit);
446
469
 
470
+ // Model via a CONFIG-PATCH FILE, for agents with no --model flag (dsh takes the
471
+ // model as a Cordis config overlay: `--patch <file>` replacing one row by id).
472
+ // `build` is a function, so this can only come from a built-in spec — a custom
473
+ // BYO agent's spec arrives as JSON over HTTP and cannot carry one.
474
+ if (options.model && typeof spec.modelPatch?.build === 'function') {
475
+ // Keep the value to a conservative id charset: it lands in a config file the
476
+ // agent parses, so no quotes/newlines/path characters.
477
+ const safeModel = /^[A-Za-z0-9][\w.:-]{0,79}$/.test(String(options.model)) ? String(options.model) : '';
478
+ if (safeModel) {
479
+ const patchFile = path.join(os.tmpdir(), `chatpanel-modelpatch-${tag}.yml`);
480
+ await writeFile(patchFile, spec.modelPatch.build(safeModel));
481
+ mcpFiles.push(patchFile); // cleaned up with the other temp files
482
+ const tmpl = String(spec.modelPatch.arg || '--patch {file}');
483
+ const tokens = tmpl.includes('{file}')
484
+ ? tmpl.replaceAll('{file}', patchFile).split(/\s+/).filter(Boolean)
485
+ : [...tmpl.split(/\s+/).filter(Boolean), patchFile];
486
+ // PREPEND: these are launcher flags and must precede the task text (same
487
+ // reason mcpArg prepends).
488
+ args = [...tokens, ...args];
489
+ }
490
+ }
491
+
447
492
  const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
448
493
  let placedImages = false;
449
494
  if (imageTokens.length) {
@@ -486,10 +531,12 @@ export async function runSpec(spec, { messages, system, options = {}, images },
486
531
  return reject(new Error(`Failed to start ${label}: ${e.message}`));
487
532
  }
488
533
 
534
+ const detach = killOnAbort(child, signal); // Stop → terminate the CLI child
535
+
489
536
  let stderr = '';
490
- let streamedAny = false;
491
- let resultText = '';
492
- let jsonBuf = '';
537
+ // The output dialect is a plugin (stream-formats.js); it owns line buffering,
538
+ // "did anything stream", and the fallback answer text.
539
+ const parser = createStreamParser(fmt, emit);
493
540
 
494
541
  let idleTimer;
495
542
  const armIdle = () => {
@@ -504,62 +551,22 @@ export async function runSpec(spec, { messages, system, options = {}, images },
504
551
 
505
552
  child.stdout.on('data', (d) => {
506
553
  armIdle();
507
- const s = d.toString();
508
- if (fmt === 'claude-stream-json') {
509
- jsonBuf += s;
510
- let nl;
511
- while ((nl = jsonBuf.indexOf('\n')) >= 0) {
512
- const line = jsonBuf.slice(0, nl).trim();
513
- jsonBuf = jsonBuf.slice(nl + 1);
514
- if (!line.startsWith('{')) continue;
515
- let msg;
516
- try {
517
- msg = JSON.parse(line);
518
- } catch {
519
- continue;
520
- }
521
- const r = handleMessage(msg, emit, streamedAny);
522
- if (r.streamed) streamedAny = true;
523
- if (r.result != null) resultText = r.result;
524
- }
525
- } else if (fmt === 'opencode-json') {
526
- // opencode `run --format json` emits newline-delimited events: text parts,
527
- // tool/tool_use, and errors. Extract the answer text + surface tools/errors.
528
- jsonBuf += s;
529
- let nl;
530
- while ((nl = jsonBuf.indexOf('\n')) >= 0) {
531
- const line = jsonBuf.slice(0, nl).trim();
532
- jsonBuf = jsonBuf.slice(nl + 1);
533
- if (!line.startsWith('{')) continue;
534
- let ev;
535
- try { ev = JSON.parse(line); } catch { continue; }
536
- if (ev.type === 'text' && ev.part?.text) {
537
- streamedAny = true;
538
- emit({ type: 'delta', text: ev.part.text });
539
- } else if (ev.type === 'tool' || ev.type === 'tool_use') {
540
- const p = ev.part || {};
541
- emit({ type: 'tool', name: p.tool || p.name || p.type || 'tool', summary: '' });
542
- } else if (ev.type === 'error') {
543
- const msg = ev.error?.data?.message || ev.error?.message || ev.error?.name || 'error';
544
- emit({ type: 'status', text: String(msg).slice(0, 300) });
545
- }
546
- }
547
- } else {
548
- streamedAny = true;
549
- emit({ type: 'delta', text: stripAnsi(s) });
550
- }
554
+ parser.push(d.toString());
551
555
  });
552
556
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
553
557
  child.on('error', (e) => {
554
558
  clearTimeout(idleTimer);
559
+ detach();
555
560
  cleanup();
556
561
  reject(new Error(`Failed to start ${label}: ${e.message}`));
557
562
  });
558
563
  child.on('close', (code) => {
559
564
  clearTimeout(idleTimer);
565
+ detach();
560
566
  cleanup();
567
+ if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
561
568
  if (code === 0) {
562
- emit({ type: 'done', text: streamedAny ? '' : resultText });
569
+ emit({ type: 'done', text: parser.streamed ? '' : parser.finish() });
563
570
  resolve();
564
571
  } else {
565
572
  reject(new Error(`${label} exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
@@ -0,0 +1,207 @@
1
+ // Stream-format plugins — the seam that makes "add a CLI agent" a DATA change.
2
+ //
3
+ // Every headless CLI prints its turn in some shape: plain text, or one of a
4
+ // handful of NDJSON dialects. Previously each dialect was an inline `else if`
5
+ // branch in runSpec(), so a new agent with a new dialect meant editing the
6
+ // runner. That's the reinvention this registry removes: a format is a named
7
+ // plugin here, and an agent spec just names it (`format: 'copilot-json'`).
8
+ //
9
+ // Contract — a format is a factory `(emit) => parser` where parser has:
10
+ // push(chunk: string) feed raw stdout; emit deltas/tools/status as they parse
11
+ // finish(): string the final answer when nothing was streamed (fallback)
12
+ // get streamed(): bool true once any delta was emitted
13
+ //
14
+ // Emitted event types match the bridge's SSE vocabulary: delta | reasoning |
15
+ // tool | status. (`done`/`usage`/`error` stay the runner's job.)
16
+
17
+ import { handleMessage } from './claude.js';
18
+
19
+ // Many CLIs colourize even when piped; strip ANSI from anything we treat as text.
20
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
21
+ export const stripAnsi = (s) => String(s).replace(ANSI_RE, '');
22
+
23
+ // Shared NDJSON pump: buffers partial lines and hands complete JSON objects to
24
+ // `onEvent`. Every JSON dialect below is line-delimited, so they all reuse this.
25
+ function ndjson(onEvent) {
26
+ let buf = '';
27
+ return (chunk) => {
28
+ buf += chunk;
29
+ let nl;
30
+ while ((nl = buf.indexOf('\n')) >= 0) {
31
+ const line = buf.slice(0, nl).trim();
32
+ buf = buf.slice(nl + 1);
33
+ if (!line.startsWith('{')) continue;
34
+ let ev;
35
+ try {
36
+ ev = JSON.parse(line);
37
+ } catch {
38
+ continue; // a partial/garbage line is not fatal — keep streaming
39
+ }
40
+ onEvent(ev);
41
+ }
42
+ };
43
+ }
44
+
45
+ // --- text ------------------------------------------------------------------
46
+ // Anything that just prints a reply.
47
+ function textFormat(emit) {
48
+ let streamed = false;
49
+ return {
50
+ push(chunk) {
51
+ streamed = true;
52
+ emit({ type: 'delta', text: stripAnsi(chunk) });
53
+ },
54
+ finish: () => '',
55
+ get streamed() {
56
+ return streamed;
57
+ },
58
+ };
59
+ }
60
+
61
+ // --- claude-stream-json ----------------------------------------------------
62
+ // Claude Code's `--output-format stream-json`, parsed by the Claude engine.
63
+ function claudeStreamJson(emit) {
64
+ let streamed = false;
65
+ let result = '';
66
+ const pump = ndjson((msg) => {
67
+ const r = handleMessage(msg, emit, streamed);
68
+ if (r.streamed) streamed = true;
69
+ if (r.result != null) result = r.result;
70
+ });
71
+ return {
72
+ push: pump,
73
+ finish: () => result,
74
+ get streamed() {
75
+ return streamed;
76
+ },
77
+ };
78
+ }
79
+
80
+ // --- opencode-json ---------------------------------------------------------
81
+ // opencode `run --format json`: text parts, tool events, errors.
82
+ function opencodeJson(emit) {
83
+ let streamed = false;
84
+ const pump = ndjson((ev) => {
85
+ if (ev.type === 'text' && ev.part?.text) {
86
+ streamed = true;
87
+ emit({ type: 'delta', text: ev.part.text });
88
+ } else if (ev.type === 'tool' || ev.type === 'tool_use') {
89
+ const p = ev.part || {};
90
+ emit({ type: 'tool', name: p.tool || p.name || p.type || 'tool', summary: '' });
91
+ } else if (ev.type === 'error') {
92
+ const msg = ev.error?.data?.message || ev.error?.message || ev.error?.name || 'error';
93
+ emit({ type: 'status', text: String(msg).slice(0, 300) });
94
+ }
95
+ });
96
+ return {
97
+ push: pump,
98
+ finish: () => '',
99
+ get streamed() {
100
+ return streamed;
101
+ },
102
+ };
103
+ }
104
+
105
+ // --- copilot-json ----------------------------------------------------------
106
+ // GitHub Copilot CLI `--output-format json` (verified against 1.0.80).
107
+ //
108
+ // Event vocabulary (only the ones we surface):
109
+ // assistant.message_delta data.deltaContent -> streamed answer text
110
+ // assistant.message data.content -> whole answer (fallback)
111
+ // assistant.reasoning data.content -> thinking (usually opaque/empty)
112
+ // tool.execution_start data.toolName -> activity strip
113
+ // tool.execution_complete data.success/result -> surface failures
114
+ // session.auto_mode_resolved data.chosenModel -> which model `auto` picked
115
+ // result exitCode/usage -> terminal event
116
+ //
117
+ // Copilot reports a denied tool as a COMPLETED call whose result explains the
118
+ // permission gap, so a user on a low permission mode otherwise sees a confident
119
+ // "I can't do that" with no hint that ChatPanel gated it. Detect that shape and
120
+ // say which setting to raise.
121
+ const PERMISSION_RE = /permission denied|could not request permission|not allowed|denied by|requires approval/i;
122
+
123
+ function copilotJson(emit) {
124
+ let streamed = false;
125
+ let result = '';
126
+ let permissionHinted = false;
127
+
128
+ const hintPermissions = (text) => {
129
+ if (permissionHinted || !PERMISSION_RE.test(String(text || ''))) return;
130
+ permissionHinted = true;
131
+ emit({
132
+ type: 'status',
133
+ text: 'Copilot was denied a tool/URL — raise this agent’s Permission mode in Settings.',
134
+ });
135
+ };
136
+
137
+ const pump = ndjson((ev) => {
138
+ const type = ev?.type || '';
139
+ const d = ev?.data || {};
140
+ switch (type) {
141
+ case 'assistant.message_delta':
142
+ if (d.deltaContent) {
143
+ streamed = true;
144
+ emit({ type: 'delta', text: d.deltaContent });
145
+ }
146
+ break;
147
+ case 'assistant.message':
148
+ // Full turn text. Keep as the fallback answer for the non-streaming case
149
+ // (`--stream off`, or a turn that only produced a final message).
150
+ if (typeof d.content === 'string' && d.content) result = d.content;
151
+ break;
152
+ case 'assistant.reasoning':
153
+ if (d.content) emit({ type: 'reasoning', text: String(d.content) });
154
+ break;
155
+ case 'tool.execution_start':
156
+ emit({ type: 'tool', name: d.toolName || 'tool', summary: '' });
157
+ break;
158
+ case 'tool.execution_complete':
159
+ if (d.success === false) {
160
+ const msg = typeof d.result === 'string' ? d.result : d.result?.error || d.result?.message || '';
161
+ if (msg) emit({ type: 'status', text: String(msg).slice(0, 300) });
162
+ hintPermissions(msg);
163
+ } else {
164
+ hintPermissions(typeof d.result === 'string' ? d.result : '');
165
+ }
166
+ break;
167
+ case 'session.auto_mode_resolved':
168
+ if (d.chosenModel) emit({ type: 'status', text: `model: ${d.chosenModel}` });
169
+ break;
170
+ case 'session.mcp_server_status_changed':
171
+ // Only worth surfacing when our own browser-tool server fails to attach.
172
+ if (d.status === 'failed' && d.error) {
173
+ emit({ type: 'status', text: `MCP ${d.serverName}: ${String(d.error).slice(0, 160)}` });
174
+ }
175
+ break;
176
+ case 'result':
177
+ // Terminal event; `exitCode` is authoritative for failure (the process
178
+ // can still exit 0 while a tool failed). Surface a non-zero code.
179
+ if (ev.exitCode) emit({ type: 'status', text: `copilot exited ${ev.exitCode}` });
180
+ break;
181
+ default:
182
+ break;
183
+ }
184
+ });
185
+
186
+ return {
187
+ push: pump,
188
+ finish: () => result,
189
+ get streamed() {
190
+ return streamed;
191
+ },
192
+ };
193
+ }
194
+
195
+ // The registry. Adding a dialect = adding one entry here; agent specs reference
196
+ // it by name, so no runner change is needed.
197
+ export const STREAM_FORMATS = {
198
+ text: textFormat,
199
+ 'claude-stream-json': claudeStreamJson,
200
+ 'opencode-json': opencodeJson,
201
+ 'copilot-json': copilotJson,
202
+ };
203
+
204
+ export function createStreamParser(format, emit) {
205
+ const make = STREAM_FORMATS[format] || STREAM_FORMATS.text;
206
+ return make(emit);
207
+ }
@@ -1,11 +1,7 @@
1
- // Offline Pro/Team entitlement verification the HARD gate for paid features
2
- // (e.g. custom "bring your own CLI" agents).
3
- //
4
- // The license server (Cloudflare Worker) signs a compact entitlement token with
5
- // an ECDSA P-256 private key that lives ONLY there. The bridge ships the matching
6
- // PUBLIC key and verifies the signature locally — no network, no secret. A forked
7
- // client or a raw `curl` to the bridge can't forge entitlement without the
8
- // private key, so this is a real cryptographic gate, not a UI check.
1
+ // Offline entitlement verification. The license server signs a compact entitlement
2
+ // token with an ECDSA P-256 private key; the bridge ships the matching public key
3
+ // and verifies the token's signature locally (no network). Gates Pro features such
4
+ // as "bring your own" custom CLI agents.
9
5
  //
10
6
  // Token format (identical to the extension's, extension/js/license.js):
11
7
  // token = base64url(JSON payload) + "." + base64url(raw ECDSA signature)
@@ -44,9 +40,8 @@ function publicKey() {
44
40
  }
45
41
 
46
42
  // Verify a server entitlement token. Returns its payload, or null. Checks the
47
- // ECDSA signature (unforgeable without the private key), the token type, and
48
- // expiry. install_id binding is the extension's concern — for the bridge gate the
49
- // signature is what matters.
43
+ // ECDSA signature, the token type, and expiry. install_id binding is handled by
44
+ // the extension; the bridge checks the signature.
50
45
  export async function verifyEntitlement(token) {
51
46
  if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
52
47
  const [head, sig] = token.split('.');
@@ -70,7 +65,8 @@ export async function verifyEntitlement(token) {
70
65
  return null;
71
66
  }
72
67
  if (payload.typ !== 'ent') return null;
73
- if (payload.exp && Date.now() > payload.exp) return null;
68
+ // exp is required; the worker always mints a finite exp, so requiring one is non-breaking.
69
+ if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp) || Date.now() > payload.exp) return null;
74
70
  return payload;
75
71
  }
76
72
 
package/src/env.js CHANGED
@@ -14,7 +14,7 @@ let enriched = false;
14
14
 
15
15
  // The agent CLIs the bridge shells out to. Claude has its own richer resolution
16
16
  // (resolveClaude: native / cli.js / WSL / SDK) below.
17
- export const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli'];
17
+ export const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli', 'copilot', 'dsh'];
18
18
 
19
19
  // Is `name` executable somewhere on the current PATH?
20
20
  function onPath(name) {
package/src/net.js ADDED
@@ -0,0 +1,107 @@
1
+ // VENDORED COPY of chatpanel-pii/net.js — keep in sync with the canonical engine
2
+ // (the bridge stays dependency-free, so this is a copy, not an import). Regenerate
3
+ // by copying ../chatpanel-pii/net.js over this file (then re-add this header).
4
+ //
5
+ // Shared host classifier + outbound-URL guard — the SSRF primitive.
6
+ //
7
+ // One implementation of "what is a loopback / cloud-metadata / private host",
8
+ // delivered the way the rest of @chatpanel/pii is: npm dependency for the
9
+ // gateway/bridge, vendorable into the browser extension (pure — only URL + string
10
+ // ops, no node APIs, so it runs in a Worker/service-worker too). Replaces the
11
+ // hand-maintained copies in the bridge (src/ssrf.js) and the extension
12
+ // (js/context.js isBlockedHost) so a security guard can't silently drift between
13
+ // the direct client path and the proxied path. See docs/secure-data-plane.md.
14
+ //
15
+ // The policy knobs cover the two legitimate trust contexts:
16
+ // • A MODEL / API / MCP endpoint (gateway upstream, bridge MCP proxy) may live on
17
+ // loopback (Ollama, LM Studio) or the LAN (a homelab GPU box) — so those are
18
+ // allowed by default — but must NEVER reach cloud instance metadata.
19
+ // • A WEB PAGE fetch (link title, page context) has no business touching loopback
20
+ // or any private host at all — call with { allowLoopback:false, allowPrivate:false }.
21
+ // Cloud metadata (169.254.169.254 & friends) and non-http(s) schemes are blocked in
22
+ // BOTH contexts, unconditionally. Re-run the assert on every redirect hop.
23
+
24
+ function ipv4(h) {
25
+ const m = String(h).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
26
+ if (!m) return null;
27
+ const o = m.slice(1).map(Number);
28
+ if (o.some((n) => n > 255)) return null;
29
+ return o;
30
+ }
31
+
32
+ const norm = (hostname) => String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
33
+
34
+ // Loopback = this host's own services (127.0.0.0/8, ::1, localhost, *.localhost).
35
+ export function isLoopbackHost(hostname) {
36
+ const h = norm(hostname);
37
+ if (!h) return false;
38
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
39
+ if (h === '::1') return true;
40
+ const o = ipv4(h);
41
+ return !!(o && o[0] === 127);
42
+ }
43
+
44
+ // Cloud instance metadata — the sharpest SSRF target (credential theft). Covers the
45
+ // link-local IMDS address used by AWS/GCP/Azure/DO (169.254.169.254), Alibaba's
46
+ // 100.100.100.200, and the GCP/name-based metadata hosts. ALWAYS blocked.
47
+ export function isMetadataHost(hostname) {
48
+ const h = norm(hostname);
49
+ if (h === 'metadata.google.internal' || h === 'metadata') return true;
50
+ const o = ipv4(h);
51
+ if (!o) return false;
52
+ if (o[0] === 169 && o[1] === 254) return true; // 169.254.169.254 (+ link-local)
53
+ if (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200) return true; // Alibaba IMDS
54
+ return false;
55
+ }
56
+
57
+ // Private / internal address space, EXCLUDING loopback + metadata (checked
58
+ // separately): RFC1918, CGNAT, IPv6 ULA/link-local, mDNS .local, this-host 0.x/::.
59
+ export function isPrivateHost(hostname) {
60
+ const h = norm(hostname);
61
+ if (!h) return true;
62
+ if (h.endsWith('.local')) return true;
63
+ if (
64
+ h === '::' || h.startsWith('fc') || h.startsWith('fd') // IPv6 ULA
65
+ || h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb') // link-local
66
+ ) return true;
67
+ const o = ipv4(h);
68
+ if (o) {
69
+ const [a, b] = o;
70
+ if (a === 0 || a === 10) return true; // this-host / RFC1918
71
+ if (a === 169 && b === 254) return true; // link-local
72
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
73
+ if (a === 192 && b === 168) return true; // RFC1918
74
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
75
+ }
76
+ return false;
77
+ }
78
+
79
+ // Policy-driven classifier. Returns true if `hostname` must be blocked under `policy`.
80
+ // Defaults model the ENDPOINT context (loopback + LAN allowed, metadata never).
81
+ export function isBlockedHost(hostname, { allowLoopback = true, allowPrivate = true } = {}) {
82
+ const h = norm(hostname);
83
+ if (!h) return true;
84
+ if (isMetadataHost(h)) return true; // never, in any context
85
+ if (isLoopbackHost(h)) return !allowLoopback;
86
+ if (isPrivateHost(h)) return !allowPrivate;
87
+ return false; // public host
88
+ }
89
+
90
+ // Assert a URL is fetchable under `policy`; returns the parsed URL or throws.
91
+ // Call on the initial URL AND after every redirect hop.
92
+ export function assertFetchableUrl(u, policy = {}) {
93
+ let parsed;
94
+ try { parsed = new URL(u); } catch { throw new Error(`invalid URL: ${u}`); }
95
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
96
+ throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
97
+ }
98
+ if (isBlockedHost(parsed.hostname, policy)) {
99
+ throw new Error(`refusing to reach a blocked address (${parsed.hostname})`);
100
+ }
101
+ return parsed;
102
+ }
103
+
104
+ // Endpoint context: model/API/MCP upstream — loopback + LAN OK, metadata never.
105
+ export const assertEndpointUrl = (u, opts = {}) => assertFetchableUrl(u, { allowLoopback: true, allowPrivate: true, ...opts });
106
+ // Web-page context: no loopback, no private, no metadata — genuinely public only.
107
+ export const assertPublicWebUrl = (u) => assertFetchableUrl(u, { allowLoopback: false, allowPrivate: false });
package/src/proc.js ADDED
@@ -0,0 +1,21 @@
1
+ // Terminate a spawned CLI child when an AbortSignal fires. The extension's Stop
2
+ // button aborts the /chat request; server.js turns that disconnect into an abort on
3
+ // this signal. Without this, the agent CLI (codex / claude / agy / custom) keeps
4
+ // running to completion in the background after Stop — burning tokens and holding the
5
+ // session — and only the 3-minute idle timer would eventually reap it.
6
+ //
7
+ // SIGTERM first so the CLI can flush + exit cleanly (its own child procs get the
8
+ // signal via the process group where the platform delivers it), then SIGKILL after a
9
+ // short grace if it's still alive. Returns a detach() to drop the listener once the
10
+ // child exits normally.
11
+ export function killOnAbort(child, signal, { graceMs = 1500 } = {}) {
12
+ if (!signal || !child) return () => {};
13
+ const onAbort = () => {
14
+ try { child.kill('SIGTERM'); } catch { /* already exited */ }
15
+ const t = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, graceMs);
16
+ if (t.unref) t.unref(); // don't keep the event loop alive just for the grace timer
17
+ };
18
+ if (signal.aborted) { onAbort(); return () => {}; }
19
+ signal.addEventListener('abort', onAbort, { once: true });
20
+ return () => { try { signal.removeEventListener('abort', onAbort); } catch { /* noop */ } };
21
+ }