@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.
- package/package.json +2 -2
- package/src/engines/antigravity.js +10 -2
- package/src/engines/args.js +47 -0
- package/src/engines/claude.js +32 -21
- package/src/engines/cli-agents.js +160 -8
- package/src/engines/codex.js +10 -2
- package/src/engines/custom.js +82 -75
- package/src/engines/stream-formats.js +207 -0
- package/src/entitlement.js +8 -12
- package/src/env.js +1 -1
- package/src/net.js +107 -0
- package/src/proc.js +21 -0
- package/src/server.js +31 -9
- package/src/ssrf.js +25 -110
package/src/engines/custom.js
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
64
|
-
//
|
|
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
|
|
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
|
|
108
|
-
//
|
|
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,
|
|
121
|
+
const [bin, argv, opts] = buildSpawnSpec(resolved, argvIn, cwd);
|
|
116
122
|
opts.env = { ...(opts.env || process.env), NO_COLOR: '1', CLICOLOR: '0' };
|
|
117
|
-
|
|
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(
|
|
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
|
-
//
|
|
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 —
|
|
372
|
-
|
|
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
|
-
|
|
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
|
|
398
|
-
// --dangerously
|
|
399
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/entitlement.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
// Offline
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
48
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|