@chatpanel/bridge 0.10.23 → 0.10.26
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 +1 -1
- package/src/connectors.js +100 -0
- package/src/engines/antigravity.js +2 -2
- package/src/engines/codex.js +2 -2
- package/src/env.js +68 -2
- package/src/proc.js +76 -6
- package/src/runs.js +49 -0
- package/src/server.js +64 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.26",
|
|
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": [
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// What an agent can already reach, read from its own configuration.
|
|
2
|
+
//
|
|
3
|
+
// A CLI agent brings connectors ChatPanel cannot see: a Slack MCP, a Jira MCP, a filesystem.
|
|
4
|
+
// ChatPanel relays its own tools to that agent and says a great deal about them — all of it
|
|
5
|
+
// restrictive, because each line was written to stop one substitution — so an agent asked
|
|
6
|
+
// about an internal thread read the page, saw the reference, and told the user to go and look
|
|
7
|
+
// it up, while holding a connector that reaches it.
|
|
8
|
+
//
|
|
9
|
+
// Knowing the names turns a guess into a fact. It lets the harness say "you have slack
|
|
10
|
+
// connected — use it" instead of listing connectors the agent may not have, and lets routing
|
|
11
|
+
// treat "can reach Slack" as a capability rather than a hope.
|
|
12
|
+
//
|
|
13
|
+
// NAMES ONLY, AND NEVER THE CREDENTIALS. A server's name is what the agent already knows and
|
|
14
|
+
// what a prompt needs; its URL, argv and env are what a leak would be made of. This reads
|
|
15
|
+
// config files that belong to the user's own agents and returns a list of strings.
|
|
16
|
+
//
|
|
17
|
+
// CONFIGURED, NOT PROVEN. A server listed here may still fail to start. That is honest and
|
|
18
|
+
// useful — it is exactly what the agent itself will try — and probing for real would cost a
|
|
19
|
+
// process spawn per agent on every health poll.
|
|
20
|
+
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** Read a file and parse it as JSON, or null. Missing and malformed are the same answer. */
|
|
26
|
+
async function readJson(file) {
|
|
27
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every key of a `{ mcpServers: { name: {...} } }` block, wherever it appears. */
|
|
31
|
+
function fromMcpServers(obj) {
|
|
32
|
+
const m = obj && typeof obj === 'object' ? obj.mcpServers || obj.mcp_servers : null;
|
|
33
|
+
return m && typeof m === 'object' ? Object.keys(m) : [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `[mcp_servers.NAME]` table headers out of a TOML file.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately a regex rather than a TOML parser: the bridge is zero-runtime-dependency by
|
|
40
|
+
* design, and the only thing wanted here is the set of table names. Anything this misses
|
|
41
|
+
* simply is not reported, which is the safe direction — a missing name costs a sentence in a
|
|
42
|
+
* prompt, an invented one sends an agent looking for a connector it does not have.
|
|
43
|
+
*/
|
|
44
|
+
function fromToml(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const re = /^\s*\[\s*mcp_servers\s*\.\s*([A-Za-z0-9._-]+)\s*\]/gm;
|
|
47
|
+
for (const m of String(text || '').matchAll(re)) out.push(m[1]);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readToml(file) {
|
|
52
|
+
try { return fromToml(await readFile(file, 'utf8')); } catch { return []; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const home = () => os.homedir();
|
|
56
|
+
const configHome = () => process.env.XDG_CONFIG_HOME || path.join(home(), '.config');
|
|
57
|
+
|
|
58
|
+
// Where each agent keeps the list. One entry per agent id in the /health registry; an agent
|
|
59
|
+
// with no entry simply reports nothing, which is what an unknown agent should do.
|
|
60
|
+
const SOURCES = {
|
|
61
|
+
claude: async () => {
|
|
62
|
+
const [main, project] = await Promise.all([
|
|
63
|
+
readJson(path.join(home(), '.claude.json')),
|
|
64
|
+
readJson(path.join(process.cwd(), '.mcp.json')),
|
|
65
|
+
]);
|
|
66
|
+
return [...fromMcpServers(main), ...fromMcpServers(project)];
|
|
67
|
+
},
|
|
68
|
+
codex: async () => readToml(path.join(process.env.CODEX_HOME || path.join(home(), '.codex'), 'config.toml')),
|
|
69
|
+
opencode: async () => {
|
|
70
|
+
for (const f of [
|
|
71
|
+
path.join(configHome(), 'opencode', 'opencode.json'),
|
|
72
|
+
path.join(home(), 'Library', 'Application Support', 'opencode', 'opencode.jsonc'),
|
|
73
|
+
]) {
|
|
74
|
+
const j = await readJson(f);
|
|
75
|
+
const names = [...fromMcpServers(j), ...(j && j.mcp && typeof j.mcp === 'object' ? Object.keys(j.mcp) : [])];
|
|
76
|
+
if (names.length) return names;
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The connector names an agent is configured with — deduped, sorted, and bounded.
|
|
84
|
+
*
|
|
85
|
+
* Bounded because this rides in a health response and then into a system prompt: a user with
|
|
86
|
+
* forty servers should cost a line, not a paragraph. Sorted so the value is stable across
|
|
87
|
+
* polls and a settings page does not reorder itself.
|
|
88
|
+
*/
|
|
89
|
+
export async function connectorsFor(agentId, { max = 24 } = {}) {
|
|
90
|
+
const read = SOURCES[agentId];
|
|
91
|
+
if (!read) return [];
|
|
92
|
+
try {
|
|
93
|
+
const names = await read();
|
|
94
|
+
return [...new Set(names.filter((n) => typeof n === 'string' && n && n.length <= 64))].sort().slice(0, max);
|
|
95
|
+
} catch {
|
|
96
|
+
// A config we cannot read is a config we say nothing about. Never a reason to fail a
|
|
97
|
+
// health check the extension needs in order to show the agent at all.
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -16,7 +16,7 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { findAgentBin } from '../env.js';
|
|
18
18
|
import { buildCliPrompt } from './prompt.js';
|
|
19
|
-
import { killOnAbort } from '../proc.js';
|
|
19
|
+
import { killOnAbort, spawnGroupOpts } from '../proc.js';
|
|
20
20
|
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
21
21
|
|
|
22
22
|
const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
|
|
@@ -110,7 +110,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
110
110
|
await new Promise((resolve, reject) => {
|
|
111
111
|
let child;
|
|
112
112
|
try {
|
|
113
|
-
child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
113
|
+
child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, ...spawnGroupOpts });
|
|
114
114
|
} catch (e) {
|
|
115
115
|
cleanup();
|
|
116
116
|
return reject(new Error(`Failed to start agy: ${e.message}`));
|
package/src/engines/codex.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// the agent to point it at a real project.
|
|
16
16
|
|
|
17
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
-
import { killOnAbort } from '../proc.js';
|
|
18
|
+
import { killOnAbort, spawnGroupOpts } from '../proc.js';
|
|
19
19
|
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
21
21
|
import os from 'node:os';
|
|
@@ -180,7 +180,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
180
180
|
await new Promise((resolve, reject) => {
|
|
181
181
|
let child;
|
|
182
182
|
try {
|
|
183
|
-
child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
183
|
+
child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, ...spawnGroupOpts });
|
|
184
184
|
} catch (e) {
|
|
185
185
|
cleanupImages();
|
|
186
186
|
return reject(new Error(`Failed to start codex: ${e.message}`));
|
package/src/env.js
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// by (1) asking your login shell for its PATH and (2) adding common bin dirs.
|
|
7
7
|
|
|
8
8
|
import os from 'node:os';
|
|
9
|
+
import { spawnGroupOpts } from './proc.js';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { spawnSync } from 'node:child_process';
|
|
11
12
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
12
13
|
|
|
13
14
|
let enriched = false;
|
|
15
|
+
let envEnriched = false;
|
|
14
16
|
|
|
15
17
|
// The agent CLIs the bridge shells out to. Claude has its own richer resolution
|
|
16
18
|
// (resolveClaude: native / cli.js / WSL / SDK) below.
|
|
@@ -235,9 +237,15 @@ export function buildSpawnSpec(spec, args, cwd) {
|
|
|
235
237
|
if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
|
|
236
238
|
}
|
|
237
239
|
const argv = [...pre, '-e', 'bash', '-lic', `exec ${spec.command} "$@"`, 'chatpanel', ...args];
|
|
238
|
-
return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
|
|
240
|
+
return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true, ...spawnGroupOpts }];
|
|
239
241
|
}
|
|
240
|
-
|
|
242
|
+
// Every spawned CLI leads its own process group, so Stop can signal the whole tree. An
|
|
243
|
+
// agent CLI is not one process — it runs shell commands and tools of its own — and killing
|
|
244
|
+
// only the pid we hold leaves those running after the user has stopped the turn.
|
|
245
|
+
const opts = {
|
|
246
|
+
cwd: cwd || os.homedir(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true,
|
|
247
|
+
...spawnGroupOpts,
|
|
248
|
+
};
|
|
241
249
|
if (spec.kind === 'script') return [process.execPath, [spec.script, ...args], opts];
|
|
242
250
|
if (spec.kind === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
|
|
243
251
|
return [spec.bin, args, opts]; // native
|
|
@@ -421,6 +429,64 @@ export function commandCandidateFiles(name, home = os.homedir(), platform = proc
|
|
|
421
429
|
return unique([...fromDirs, ...agentCandidateBins(name, home, platform, env)]);
|
|
422
430
|
}
|
|
423
431
|
|
|
432
|
+
// Agent credentials a LaunchAgent/systemd unit does NOT inherit.
|
|
433
|
+
//
|
|
434
|
+
// enrichPath() repairs the minimal PATH a service gets; this is the same problem
|
|
435
|
+
// one layer up. A CLI that authenticates from a FILE (claude, codex, copilot:
|
|
436
|
+
// ~/.copilot) works fine under the service, but one that reads an API key from
|
|
437
|
+
// the ENVIRONMENT (dsh -> DEEPSEEK_API_KEY) fails with a missing-credential
|
|
438
|
+
// error that looks like a ChatPanel bug — the launchd job's environment is just
|
|
439
|
+
// SSH_AUTH_SOCK and a bare PATH.
|
|
440
|
+
//
|
|
441
|
+
// So: ask the user's login shell for a NARROW allowlist of agent credential
|
|
442
|
+
// variables and fill in only the ones we don't already have. These are the
|
|
443
|
+
// user's own credentials, on their own machine, handed to CLIs the user
|
|
444
|
+
// configured ChatPanel to launch — exactly what would happen had they run the
|
|
445
|
+
// CLI from their terminal. Values are never logged, and /debug never dumps the
|
|
446
|
+
// environment (it exposes only PATH/home, and only under CHATPANEL_BRIDGE_DEBUG).
|
|
447
|
+
const AGENT_ENV_KEYS = [
|
|
448
|
+
'DEEPSEEK_API_KEY', 'DSH_HOME',
|
|
449
|
+
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
|
|
450
|
+
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE',
|
|
451
|
+
'GEMINI_API_KEY', 'GOOGLE_API_KEY',
|
|
452
|
+
'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'XAI_API_KEY', 'MISTRAL_API_KEY',
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
export function enrichAgentEnv() {
|
|
456
|
+
if (envEnriched) return;
|
|
457
|
+
envEnriched = true;
|
|
458
|
+
if (process.platform === 'win32') return; // service env is inherited there
|
|
459
|
+
|
|
460
|
+
const missing = AGENT_ENV_KEYS.filter((k) => !process.env[k]);
|
|
461
|
+
if (!missing.length) return;
|
|
462
|
+
try {
|
|
463
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
464
|
+
// Emit NAME\tVALUE for each var that is actually set; trailing `true` keeps
|
|
465
|
+
// the exit status clean when the last test fails.
|
|
466
|
+
const script = `${missing
|
|
467
|
+
.map((k) => `[ -n "\${${k}:-}" ] && printf '%s\\t%s\\n' ${k} "\$${k}"`)
|
|
468
|
+
.join('; ')}; true`;
|
|
469
|
+
// -lic, not -lc: these keys are typically exported from ~/.zshrc (or ~/.bashrc),
|
|
470
|
+
// which a LOGIN-only shell does NOT source — that's interactive-only. TERM=dumb
|
|
471
|
+
// keeps prompt/banner noise down, and the parser below ignores any line that
|
|
472
|
+
// isn't a clean NAME<TAB>VALUE, so rc-file chatter is harmless.
|
|
473
|
+
const r = spawnSync(shell, ['-lic', script], {
|
|
474
|
+
encoding: 'utf8',
|
|
475
|
+
timeout: 6000,
|
|
476
|
+
env: { ...process.env, TERM: 'dumb' },
|
|
477
|
+
});
|
|
478
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
479
|
+
const i = line.indexOf('\t');
|
|
480
|
+
if (i <= 0) continue;
|
|
481
|
+
const key = line.slice(0, i).trim();
|
|
482
|
+
const value = line.slice(i + 1);
|
|
483
|
+
if (AGENT_ENV_KEYS.includes(key) && !process.env[key] && value) process.env[key] = value;
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
/* no login shell / timeout — agents that need a file-based login still work */
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
424
490
|
export function enrichPath() {
|
|
425
491
|
if (enriched) return;
|
|
426
492
|
enriched = true;
|
package/src/proc.js
CHANGED
|
@@ -1,18 +1,88 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
// Terminate a spawned CLI child when an AbortSignal fires. The extension's Stop
|
|
2
3
|
// button aborts the /chat request; server.js turns that disconnect into an abort on
|
|
3
4
|
// this signal. Without this, the agent CLI (codex / claude / agy / custom) keeps
|
|
4
5
|
// running to completion in the background after Stop — burning tokens and holding the
|
|
5
6
|
// session — and only the 3-minute idle timer would eventually reap it.
|
|
6
7
|
//
|
|
7
|
-
// SIGTERM first so the CLI can flush + exit cleanly
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// SIGTERM first so the CLI can flush + exit cleanly, then SIGKILL after a short grace if
|
|
9
|
+
// it's still alive. Returns a detach() to drop the listener once the child exits normally.
|
|
10
|
+
//
|
|
11
|
+
// THE SIGNAL MUST REACH THE GRANDCHILDREN. An agent CLI is not one process: codex runs shell
|
|
12
|
+
// commands, claude runs tools, and each of those is a child of the child. `child.kill()`
|
|
13
|
+
// signals exactly one pid, so Stop killed the CLI and left its shell running — a user pressed
|
|
14
|
+
// Stop, watched the panel go quiet, and found the process still going.
|
|
15
|
+
//
|
|
16
|
+
// Signalling the process GROUP fixes that, and only works if the child leads a group of its
|
|
17
|
+
// own — which is what spawnGroupOpts is for. Without that spawn option the child sits in the
|
|
18
|
+
// bridge's own group, and a group kill would signal the bridge.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Spawn options that make a child its own process-group leader, so the whole tree can be
|
|
22
|
+
* signalled together. No-op on Windows, which has no process groups in this sense — there
|
|
23
|
+
* the taskkill fallback in killTree covers it.
|
|
24
|
+
*/
|
|
25
|
+
export const spawnGroupOpts = process.platform === 'win32' ? {} : { detached: true };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every pid descended from `pid`, snapshotted from the process table.
|
|
29
|
+
*
|
|
30
|
+
* A process-group kill is not enough on its own. codex runs each shell step in its OWN
|
|
31
|
+
* process group (`sleep 90` came back with PGID == its own pid), so signalling the group we
|
|
32
|
+
* created deliberately misses it — and once its parent dies it reparents to init, where
|
|
33
|
+
* nothing connects it to the run that started it.
|
|
34
|
+
*
|
|
35
|
+
* So the tree is read BEFORE anything is signalled. Afterwards the links are gone.
|
|
36
|
+
*/
|
|
37
|
+
function descendantsOf(pid) {
|
|
38
|
+
if (!pid || process.platform === 'win32') return [];
|
|
39
|
+
let table = '';
|
|
40
|
+
try {
|
|
41
|
+
table = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
|
|
42
|
+
} catch { return []; }
|
|
43
|
+
const kids = new Map();
|
|
44
|
+
for (const line of table.split('\n')) {
|
|
45
|
+
const [p, pp] = line.trim().split(/\s+/).map(Number);
|
|
46
|
+
if (!p || !pp) continue;
|
|
47
|
+
if (!kids.has(pp)) kids.set(pp, []);
|
|
48
|
+
kids.get(pp).push(p);
|
|
49
|
+
}
|
|
50
|
+
const out = [];
|
|
51
|
+
const walk = (root, depth = 0) => {
|
|
52
|
+
// A depth cap rather than a visited set: the table is a snapshot of a tree, and a cycle
|
|
53
|
+
// would mean the kernel lied. The cap is there so a malformed read cannot hang a Stop.
|
|
54
|
+
if (depth > 20) return;
|
|
55
|
+
for (const k of kids.get(root) || []) { out.push(k); walk(k, depth + 1); }
|
|
56
|
+
};
|
|
57
|
+
walk(pid);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Signal a child and everything it spawned. Falls back to the single process. */
|
|
62
|
+
function killTree(child, sig, known = []) {
|
|
63
|
+
if (!child?.pid) return;
|
|
64
|
+
// Descendants first, while the parent is still alive to be traced through. Orphaned
|
|
65
|
+
// grandchildren are the ones the user cannot find or stop afterwards.
|
|
66
|
+
for (const pid of known.length ? known : descendantsOf(child.pid)) {
|
|
67
|
+
try { process.kill(pid, sig); } catch { /* already gone */ }
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
// Negative pid = the whole group. Only valid for a detached child; the catch covers a
|
|
71
|
+
// child spawned without it, which is still better killed alone than not at all.
|
|
72
|
+
process.kill(-child.pid, sig);
|
|
73
|
+
return;
|
|
74
|
+
} catch { /* not a group leader, or already gone */ }
|
|
75
|
+
try { child.kill(sig); } catch { /* already exited */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
11
78
|
export function killOnAbort(child, signal, { graceMs = 1500 } = {}) {
|
|
12
79
|
if (!signal || !child) return () => {};
|
|
13
80
|
const onAbort = () => {
|
|
14
|
-
|
|
15
|
-
|
|
81
|
+
// Snapshot ONCE, up front: by the time the grace period expires the parent is gone and
|
|
82
|
+
// the tree cannot be walked, so the escalation would have nothing left to aim at.
|
|
83
|
+
const tree = descendantsOf(child.pid);
|
|
84
|
+
killTree(child, 'SIGTERM', tree);
|
|
85
|
+
const t = setTimeout(() => killTree(child, 'SIGKILL', tree), graceMs);
|
|
16
86
|
if (t.unref) t.unref(); // don't keep the event loop alive just for the grace timer
|
|
17
87
|
};
|
|
18
88
|
if (signal.aborted) { onAbort(); return () => {}; }
|
package/src/runs.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Every in-flight run, so Stop is an INSTRUCTION rather than an inference.
|
|
2
|
+
//
|
|
3
|
+
// Cancellation used to depend on Node noticing the client's socket close and firing
|
|
4
|
+
// `req.on('close')`. That is a signal about a socket, not about intent: it can arrive late,
|
|
5
|
+
// and on a request whose body was already consumed it does not reliably arrive at all — so
|
|
6
|
+
// a codex `shell:` step ran on for minutes after Stop with nothing left listening to it.
|
|
7
|
+
//
|
|
8
|
+
// A run registered here can be cancelled by name. The socket-close path stays as a safety
|
|
9
|
+
// net for a panel that is closed or crashes, but the button no longer depends on it.
|
|
10
|
+
const runs = new Map();
|
|
11
|
+
|
|
12
|
+
export function startRun(id) {
|
|
13
|
+
const ac = new AbortController();
|
|
14
|
+
const children = new Set();
|
|
15
|
+
const run = {
|
|
16
|
+
id,
|
|
17
|
+
signal: ac.signal,
|
|
18
|
+
// Children are tracked as well as signalled, because an agent that spawns sub-agents
|
|
19
|
+
// spawns processes this module never sees at spawn time. Whoever creates one registers
|
|
20
|
+
// it, and Stop reaches all of them.
|
|
21
|
+
track(child) { if (child?.pid) { children.add(child); child.once?.('exit', () => children.delete(child)); } },
|
|
22
|
+
cancel(reason = 'stopped') {
|
|
23
|
+
if (run.cancelled) return false;
|
|
24
|
+
run.cancelled = reason;
|
|
25
|
+
ac.abort();
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
cancelled: null,
|
|
29
|
+
children,
|
|
30
|
+
};
|
|
31
|
+
runs.set(id, run);
|
|
32
|
+
return run;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function endRun(id) { runs.delete(id); }
|
|
36
|
+
|
|
37
|
+
export function cancelRun(id, reason = 'stopped') {
|
|
38
|
+
const run = runs.get(id);
|
|
39
|
+
return run ? run.cancel(reason) : false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Used on shutdown: leaving a CLI running after the bridge exits is how orphans are made. */
|
|
43
|
+
export function cancelAll(reason = 'shutdown') {
|
|
44
|
+
let n = 0;
|
|
45
|
+
for (const run of runs.values()) if (run.cancel(reason)) n += 1;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const activeRuns = () => runs.size;
|
package/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// running on this machine (Claude Code, Codex and Antigravity, each via its CLI) to
|
|
4
4
|
// the ChatPanel Chrome extension. Zero runtime dependencies.
|
|
5
5
|
//
|
|
6
|
-
// GET /health → { ok, version, agents: [
|
|
6
|
+
// GET /health → { ok, version, agents: [{id,label,available,reason,connectors}], update }
|
|
7
7
|
// POST /update → self-update to the latest release (compiled binary installs)
|
|
8
8
|
// POST /chat → Server-Sent Events stream of { type, ... }:
|
|
9
9
|
// {type:'delta', text} incremental assistant text
|
|
@@ -27,18 +27,20 @@ import * as claude from './engines/claude.js';
|
|
|
27
27
|
import * as codex from './engines/codex.js';
|
|
28
28
|
import * as antigravity from './engines/antigravity.js';
|
|
29
29
|
import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
|
|
30
|
+
import { connectorsFor } from './connectors.js';
|
|
30
31
|
import * as custom from './engines/custom.js';
|
|
31
32
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
32
|
-
import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
33
|
+
import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
|
|
33
34
|
import { stripHidden } from './sanitize.js';
|
|
34
35
|
import { checkForUpdate, selfUpdate } from './update.js';
|
|
35
36
|
import { callLocalMcp } from './mcp-local.js';
|
|
36
37
|
import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
|
|
38
|
+
import { startRun, endRun, cancelRun, cancelAll, activeRuns } from './runs.js';
|
|
37
39
|
|
|
38
40
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
39
41
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
40
42
|
// this drifts from package.json, so the two can't silently diverge.
|
|
41
|
-
const VERSION = '0.10.
|
|
43
|
+
const VERSION = '0.10.26';
|
|
42
44
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
43
45
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
44
46
|
|
|
@@ -238,6 +240,10 @@ const PRIVILEGED_POST = new Set([
|
|
|
238
240
|
'/agent-check',
|
|
239
241
|
'/update',
|
|
240
242
|
'/tool-result',
|
|
243
|
+
// Cancelling someone else's run is a denial of service, small but real — and every other
|
|
244
|
+
// endpoint that touches a run is already guarded. An unauthenticated hole next to nine
|
|
245
|
+
// guarded neighbours is a hole regardless of how little it grants.
|
|
246
|
+
'/cancel',
|
|
241
247
|
]);
|
|
242
248
|
const PRIVILEGED_GET = new Set(['/debug']);
|
|
243
249
|
|
|
@@ -294,7 +300,13 @@ async function handleHealth(res) {
|
|
|
294
300
|
.filter(([, e]) => !e.hidden)
|
|
295
301
|
.map(async ([id, { engine, label }]) => {
|
|
296
302
|
const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
|
|
297
|
-
|
|
303
|
+
// WHAT THIS AGENT CAN ALREADY REACH, so the client stops guessing. A CLI agent brings
|
|
304
|
+
// its own connectors — a Slack MCP, a Jira MCP — that the extension cannot see, and
|
|
305
|
+
// an agent that was never told it may use them answers "go and look it up yourself".
|
|
306
|
+
// NAMES ONLY: a server's name is what a prompt needs; its URL, argv and env are what
|
|
307
|
+
// a leak would be made of. Additive, so an older extension ignores it.
|
|
308
|
+
const connectors = await connectorsFor(id).catch(() => []);
|
|
309
|
+
return { id, label, available: a.ok, reason: a.reason, connectors };
|
|
298
310
|
}),
|
|
299
311
|
);
|
|
300
312
|
const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
|
|
@@ -341,10 +353,22 @@ async function handleChat(req, res) {
|
|
|
341
353
|
// finish in the background. Older engines ignore the signal (harmless); the spawn
|
|
342
354
|
// engines honor it via killOnAbort.
|
|
343
355
|
let closed = false;
|
|
344
|
-
|
|
345
|
-
|
|
356
|
+
// A run id the client can cancel BY NAME. Emitted first, before anything else, so Stop
|
|
357
|
+
// works from the first millisecond rather than from whenever the engine gets going.
|
|
358
|
+
const runId = `run_${Math.random().toString(36).slice(2, 10)}`;
|
|
359
|
+
const run = startRun(runId);
|
|
360
|
+
const ac = { signal: run.signal, abort: () => run.cancel('client') };
|
|
361
|
+
// BOTH close events. On a request whose body was already consumed, req 'close' does not
|
|
362
|
+
// reliably signal a client disconnect — res 'close' does. Keeping both means a panel that
|
|
363
|
+
// crashes or is closed still tears the CLI down, while the Stop button no longer depends
|
|
364
|
+
// on either of them.
|
|
365
|
+
const onGone = () => { closed = true; run.cancel('disconnected'); };
|
|
366
|
+
req.on('close', onGone);
|
|
367
|
+
res.on('close', onGone);
|
|
368
|
+
res.on('error', onGone);
|
|
346
369
|
|
|
347
370
|
const safeEmit = (obj) => { if (!closed) emit(obj); };
|
|
371
|
+
emit({ type: 'run', id: runId });
|
|
348
372
|
|
|
349
373
|
// Browser-tools relay: when the extension sends page-tool specs, host an MCP
|
|
350
374
|
// server for this turn and tell the engine to point the CLI at it.
|
|
@@ -374,11 +398,31 @@ async function handleChat(req, res) {
|
|
|
374
398
|
log('error', `${body.agent} chat failed: ${e?.message || e}`);
|
|
375
399
|
emit({ type: 'error', error: e?.message || String(e) });
|
|
376
400
|
} finally {
|
|
401
|
+
endRun(runId);
|
|
377
402
|
if (session) deleteSession(session.id);
|
|
378
403
|
if (!res.writableEnded) res.end();
|
|
379
404
|
}
|
|
380
405
|
}
|
|
381
406
|
|
|
407
|
+
/**
|
|
408
|
+
* POST /cancel { id } — stop a run by name.
|
|
409
|
+
*
|
|
410
|
+
* Stop is now an instruction, not something inferred from a socket. Answers 200 whether or
|
|
411
|
+
* not the run was still live: 'already finished' and 'cancelled' are the same outcome to the
|
|
412
|
+
* caller, and returning 404 would make a harmless race look like a failure.
|
|
413
|
+
*/
|
|
414
|
+
async function handleCancel(req, res) {
|
|
415
|
+
// readBody already PARSES. Wrapping it in JSON.parse threw on every call, so the id was
|
|
416
|
+
// always empty and Stop silently cancelled nothing — the unit tests covered the registry
|
|
417
|
+
// and not the handler that feeds it, which is exactly where this hid.
|
|
418
|
+
let body = {};
|
|
419
|
+
try { body = (await readBody(req)) || {}; } catch { /* an empty body cancels nothing */ }
|
|
420
|
+
const id = String(body.id || '').trim();
|
|
421
|
+
const cancelled = id ? cancelRun(id, 'stopped') : false;
|
|
422
|
+
if (cancelled) log('info', `cancel: ${id} stopped by client`);
|
|
423
|
+
return json(res, 200, { ok: true, cancelled });
|
|
424
|
+
}
|
|
425
|
+
|
|
382
426
|
// POST /mcp/<session> (per-run, bridge-injected) OR POST /mcp (stable: routes to
|
|
383
427
|
// the active chat — for CLIs configured once, e.g. `opencode mcp add … …/mcp`).
|
|
384
428
|
// JSON-RPC; tools/call relays to the extension and waits for /tool-result.
|
|
@@ -754,6 +798,7 @@ const server = createServer(async (req, res) => {
|
|
|
754
798
|
if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
|
|
755
799
|
if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
|
|
756
800
|
}
|
|
801
|
+
if (req.method === 'POST' && url.pathname === '/cancel') return handleCancel(req, res);
|
|
757
802
|
if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
|
|
758
803
|
if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
|
|
759
804
|
if (req.method === 'POST' && url.pathname === '/mcp-remote') return handleMcpRemote(req, res);
|
|
@@ -828,6 +873,7 @@ function runMcpStdioProxy(url) {
|
|
|
828
873
|
|
|
829
874
|
function startServer() {
|
|
830
875
|
enrichPath(); // so codex/agy (Antigravity) are found even under a minimal service PATH
|
|
876
|
+
enrichAgentEnv(); // and so env-authenticated CLIs (dsh) have their key under launchd
|
|
831
877
|
ensureToken(); // per-install bearer token for privileged routes (defense-in-depth)
|
|
832
878
|
// Fail LOUD on a port clash. The bridge binds a FIXED 4319 so the extension always
|
|
833
879
|
// finds it; if it's taken, say how to recover instead of dying on a raw stack trace.
|
|
@@ -840,6 +886,18 @@ function startServer() {
|
|
|
840
886
|
log('error', `bridge server error: ${e?.message || e}`);
|
|
841
887
|
process.exit(1);
|
|
842
888
|
});
|
|
889
|
+
// Leaving a CLI running after the bridge exits is how orphans are made — and the user has
|
|
890
|
+
// no way to find or stop them, because the thing that spawned them is gone.
|
|
891
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
892
|
+
process.on(sig, () => {
|
|
893
|
+
const n = cancelAll('shutdown');
|
|
894
|
+
if (n) log('info', `shutdown: stopped ${n} running agent${n === 1 ? '' : 's'}`);
|
|
895
|
+
// Give SIGTERM a moment to land before the process goes; killTree escalates on its own.
|
|
896
|
+
setTimeout(() => process.exit(0), n ? 300 : 0).unref?.();
|
|
897
|
+
if (!n) process.exit(0);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
843
901
|
server.listen(PORT, HOST, async () => {
|
|
844
902
|
log('info', `listening on http://${HOST}:${PORT}`);
|
|
845
903
|
// M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed
|