@chatpanel/bridge 0.10.13 → 0.10.15
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/README.md +5 -3
- package/package.json +3 -2
- package/src/engines/antigravity.js +67 -5
- package/src/env.js +1 -1
- package/src/server.js +9 -27
- package/src/ssrf.js +102 -0
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@ so this bridges the gap.
|
|
|
7
7
|
- **Claude Code** — embedded via `@anthropic-ai/claude-agent-sdk`, using your
|
|
8
8
|
existing Claude Code login (or `ANTHROPIC_API_KEY`).
|
|
9
9
|
- **Codex** — driven via the `codex exec` CLI, using your `codex login`.
|
|
10
|
-
- **
|
|
10
|
+
- **Antigravity CLI** — driven via the `agy -p` CLI, using your Antigravity login.
|
|
11
|
+
This is Google's successor to Gemini CLI; **Gemini CLI** itself remains available
|
|
12
|
+
for business/enterprise (paid API keys) and can be added as a custom agent.
|
|
11
13
|
|
|
12
14
|
Bring whichever agent you already have installed — the extension auto-detects
|
|
13
15
|
the ones the bridge reports as available.
|
|
@@ -27,7 +29,7 @@ in-place upgrade.
|
|
|
27
29
|
### Windows — via Node (recommended)
|
|
28
30
|
|
|
29
31
|
Windows SmartScreen flags unsigned downloads, so on Windows run the bridge through
|
|
30
|
-
Node — you already have it if you use Claude Code / Codex
|
|
32
|
+
Node — you already have it if you use Claude Code / Codex (both npm CLIs),
|
|
31
33
|
and there's no security prompt:
|
|
32
34
|
|
|
33
35
|
```powershell
|
|
@@ -64,7 +66,7 @@ The agents you want to use must already be set up:
|
|
|
64
66
|
|
|
65
67
|
- **Claude Code**: installed and signed in (`claude`), or set `ANTHROPIC_API_KEY`.
|
|
66
68
|
- **Codex**: `codex` on your `PATH` and `codex login` done.
|
|
67
|
-
- **
|
|
69
|
+
- **Antigravity CLI**: `agy` on your `PATH` and signed in (install the Antigravity app, then run `agy` once to sign in). Replaces Gemini CLI; business/enterprise users can still run `gemini` as a custom agent.
|
|
68
70
|
|
|
69
71
|
The extension polls `/health` and shows each agent as available/unavailable.
|
|
70
72
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.15",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
5
|
+
"description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"chatpanel",
|
|
8
8
|
"claude-code",
|
|
9
9
|
"codex",
|
|
10
|
+
"antigravity-cli",
|
|
10
11
|
"gemini-cli",
|
|
11
12
|
"chrome-extension",
|
|
12
13
|
"ai-agents",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// path so the model opens it.
|
|
12
12
|
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
-
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { mkdirSync, writeFileSync, unlinkSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { findAgentBin } from '../env.js';
|
|
@@ -78,6 +78,57 @@ function writeImages(images, dir) {
|
|
|
78
78
|
return files;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Antigravity has no per-run MCP flag (`agy --help` shows none). The CLI only
|
|
82
|
+
// discovers MCP servers from config files: <cwd>/.agents/mcp_config.json
|
|
83
|
+
// (workspace) or ~/.gemini/config/mcp_config.json (global). To give a headless
|
|
84
|
+
// `agy -p` run our page/MCP tools, write the workspace file pointing at the
|
|
85
|
+
// bridge's per-session HTTP MCP server. Remote servers MUST use `serverUrl`
|
|
86
|
+
// (Antigravity rejects the legacy `url`/`httpUrl` fields). This is the agy
|
|
87
|
+
// equivalent of what claude.js/codex.js do via --mcp-config / -c mcp_servers.
|
|
88
|
+
//
|
|
89
|
+
// Non-destructive: an existing .agents/mcp_config.json is parsed, our one server
|
|
90
|
+
// merged in, and the original restored on cleanup. If the file exists but isn't
|
|
91
|
+
// JSON we recognize, we leave it untouched (and tools simply won't attach) rather
|
|
92
|
+
// than risk corrupting the user's config. Returns a cleanup fn (always callable).
|
|
93
|
+
function setupMcpConfig(mcp, cwd) {
|
|
94
|
+
if (!mcp?.url) return () => {};
|
|
95
|
+
const dir = path.join(cwd, '.agents');
|
|
96
|
+
const file = path.join(dir, 'mcp_config.json');
|
|
97
|
+
const serverName = mcp.serverName || 'chatpanel_browser';
|
|
98
|
+
const hadDir = existsSync(dir);
|
|
99
|
+
let prev = null; // original file contents (null = file did not exist)
|
|
100
|
+
|
|
101
|
+
let config = { mcpServers: {} };
|
|
102
|
+
if (existsSync(file)) {
|
|
103
|
+
try {
|
|
104
|
+
prev = readFileSync(file, 'utf8');
|
|
105
|
+
const parsed = JSON.parse(prev);
|
|
106
|
+
if (!parsed || typeof parsed !== 'object') return () => {};
|
|
107
|
+
config = parsed;
|
|
108
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') config.mcpServers = {};
|
|
109
|
+
} catch {
|
|
110
|
+
return () => {}; // unreadable / not JSON — don't clobber it
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
config.mcpServers[serverName] = { serverUrl: mcp.url };
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(dir, { recursive: true });
|
|
117
|
+
writeFileSync(file, JSON.stringify(config, null, 2));
|
|
118
|
+
} catch {
|
|
119
|
+
return () => {};
|
|
120
|
+
}
|
|
121
|
+
return () => {
|
|
122
|
+
try {
|
|
123
|
+
if (prev !== null) writeFileSync(file, prev); // restore original contents
|
|
124
|
+
else if (hadDir) rmSync(file, { force: true }); // remove just the file we added
|
|
125
|
+
else rmSync(dir, { recursive: true, force: true }); // remove the dir we created
|
|
126
|
+
} catch {
|
|
127
|
+
/* best effort */
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
export async function chat({ messages, system, options, images }, emit) {
|
|
82
133
|
try {
|
|
83
134
|
mkdirSync(SCRATCH, { recursive: true });
|
|
@@ -90,18 +141,29 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
90
141
|
// reads @-referenced files (incl. images) inline as multimodal input, so no
|
|
91
142
|
// read-tool approval is needed in headless `-p` mode. (Confirmed working.)
|
|
92
143
|
const imageFiles = writeImages(images, cwd);
|
|
93
|
-
|
|
144
|
+
// Wire our page/MCP tools into agy via a workspace mcp_config.json (it has no
|
|
145
|
+
// per-run MCP flag). Tools then route: agy → bridge /mcp/<session> → extension.
|
|
146
|
+
const cleanupMcp = setupMcpConfig(options.mcp, cwd);
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
|
|
149
|
+
cleanupMcp();
|
|
150
|
+
};
|
|
94
151
|
let prompt = buildCliPrompt(messages, system);
|
|
95
152
|
if (imageFiles.length) {
|
|
96
153
|
prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
|
|
97
154
|
}
|
|
98
155
|
|
|
99
156
|
// `-p` runs one prompt non-interactively. --model picks the model.
|
|
100
|
-
// --dangerously-skip-permissions auto-approves tool use (headless has no human
|
|
101
|
-
// approver) only when the user opted into bypassPermissions.
|
|
102
157
|
const args = ['-p', prompt];
|
|
103
158
|
if (options.model) args.push('--model', options.model);
|
|
104
|
-
|
|
159
|
+
// Headless `agy -p` cannot prompt for tool approval — without this flag any MCP
|
|
160
|
+
// tool call just times out (it never reaches the server). So skip agy's own
|
|
161
|
+
// approval whenever the user opted into bypass OR we've attached page/MCP tools.
|
|
162
|
+
// The real gate stays on the ChatPanel side: each relayed call goes back to the
|
|
163
|
+
// extension, which applies its per-action confirmation for risky page actions.
|
|
164
|
+
if (options.permissionMode === 'bypassPermissions' || options.mcp?.url) {
|
|
165
|
+
args.push('--dangerously-skip-permissions');
|
|
166
|
+
}
|
|
105
167
|
if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
|
|
106
168
|
|
|
107
169
|
await new Promise((resolve, reject) => {
|
package/src/env.js
CHANGED
|
@@ -76,7 +76,7 @@ function shellWhich(name) {
|
|
|
76
76
|
// ---------------------------------------------------------------------------
|
|
77
77
|
// Claude Code launcher resolution.
|
|
78
78
|
//
|
|
79
|
-
// The Codex/
|
|
79
|
+
// The Codex/Antigravity engines can assume `spawn('codex', …)` runs a directly
|
|
80
80
|
// executable file on the current OS's PATH. Claude needs more care:
|
|
81
81
|
// • On Windows, npm installs `claude.cmd` / `claude.ps1` / an extensionless
|
|
82
82
|
// bash shim — NONE of which Node's spawn() can execute directly (that's the
|
package/src/server.js
CHANGED
|
@@ -32,11 +32,12 @@ import { installService, uninstallService, serviceStatus, restartService } from
|
|
|
32
32
|
import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
33
33
|
import { checkForUpdate, selfUpdate } from './update.js';
|
|
34
34
|
import { callLocalMcp } from './mcp-local.js';
|
|
35
|
+
import { assertPublicHttpUrl } from './ssrf.js';
|
|
35
36
|
|
|
36
37
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
37
38
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
38
39
|
// this drifts from package.json, so the two can't silently diverge.
|
|
39
|
-
const VERSION = '0.10.
|
|
40
|
+
const VERSION = '0.10.15';
|
|
40
41
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
41
42
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
42
43
|
|
|
@@ -233,31 +234,12 @@ const PRIVILEGED_POST = new Set([
|
|
|
233
234
|
]);
|
|
234
235
|
const PRIVILEGED_GET = new Set(['/debug']);
|
|
235
236
|
|
|
236
|
-
// SSRF guard for /mcp-remote
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (h === '::1' || h === '::' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true;
|
|
243
|
-
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
244
|
-
if (m) {
|
|
245
|
-
const a = Number(m[1]), b = Number(m[2]);
|
|
246
|
-
if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / RFC1918
|
|
247
|
-
if (a === 169 && b === 254) return true; // link-local + cloud metadata
|
|
248
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
249
|
-
if (a === 192 && b === 168) return true; // RFC1918
|
|
250
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
251
|
-
}
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
function assertPublicHttpUrl(u) {
|
|
255
|
-
let parsed;
|
|
256
|
-
try { parsed = new URL(u); } catch { throw new Error(`invalid URL: ${u}`); }
|
|
257
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
|
|
258
|
-
if (isBlockedHttpHost(parsed.hostname)) throw new Error(`refusing to proxy a private/loopback/metadata address (${parsed.hostname})`);
|
|
259
|
-
return parsed;
|
|
260
|
-
}
|
|
237
|
+
// SSRF guard for /mcp-remote lives in ./ssrf.js (assertPublicHttpUrl). Loopback
|
|
238
|
+
// is allowed (the user's own localhost MCP server — the common "via bridge"
|
|
239
|
+
// case; the extension can reach it directly anyway), cloud metadata is always
|
|
240
|
+
// blocked, and other private/LAN ranges are blocked unless the operator sets
|
|
241
|
+
// CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1. Checked on the initial URL AND after
|
|
242
|
+
// any redirect.
|
|
261
243
|
|
|
262
244
|
// Returns an error code if the request must be blocked, else null.
|
|
263
245
|
function guard(req, pathname) {
|
|
@@ -732,7 +714,7 @@ function runMcpStdioProxy(url) {
|
|
|
732
714
|
}
|
|
733
715
|
|
|
734
716
|
function startServer() {
|
|
735
|
-
enrichPath(); // so codex/
|
|
717
|
+
enrichPath(); // so codex/agy (Antigravity) are found even under a minimal service PATH
|
|
736
718
|
ensureToken(); // per-install bearer token for privileged routes (defense-in-depth)
|
|
737
719
|
server.listen(PORT, HOST, async () => {
|
|
738
720
|
log('info', `listening on http://${HOST}:${PORT}`);
|
package/src/ssrf.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SSRF guard for the /mcp-remote proxy.
|
|
2
|
+
//
|
|
3
|
+
// The bridge proxies ONE JSON-RPC message to a remote MCP server *from this
|
|
4
|
+
// machine* (no browser Origin header), so the extension can reach servers that
|
|
5
|
+
// reject browser origins. That route is already privileged — it requires the
|
|
6
|
+
// extension origin or the per-install bridge token, so a random web page cannot
|
|
7
|
+
// drive it. This guard is the second layer: even when driven by the extension,
|
|
8
|
+
// the bridge must not become a relay that a prompt-injected agent could point at
|
|
9
|
+
// cloud metadata or use to sweep the LAN.
|
|
10
|
+
//
|
|
11
|
+
// Policy:
|
|
12
|
+
// • Loopback (127.0.0.0/8, ::1, localhost, *.localhost) → ALLOWED.
|
|
13
|
+
// It's the user's own host — the same place the bridge runs, and a place the
|
|
14
|
+
// extension can already fetch DIRECTLY. Proxying it grants no new reach; it
|
|
15
|
+
// only drops the browser Origin header (the whole point of "via bridge").
|
|
16
|
+
// Localhost MCP servers are the common case.
|
|
17
|
+
// • Cloud instance metadata (169.254.169.254) → ALWAYS BLOCKED.
|
|
18
|
+
// This is the sharpest SSRF target (credential theft) and is blocked even
|
|
19
|
+
// when private hosts are opted in.
|
|
20
|
+
// • Everything else private/internal (RFC1918, CGNAT, link-local, IPv6 ULA,
|
|
21
|
+
// 0.0.0.0, ::, *.local) → BLOCKED, unless the operator opts in with
|
|
22
|
+
// CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1 (for reaching an MCP server on
|
|
23
|
+
// another machine on a trusted LAN).
|
|
24
|
+
// • Non-http(s) schemes → BLOCKED.
|
|
25
|
+
//
|
|
26
|
+
// The same checks run on the initial URL AND after any redirect.
|
|
27
|
+
|
|
28
|
+
const ALLOW_PRIVATE_HOSTS = /^(1|true|yes|on)$/i.test(
|
|
29
|
+
process.env.CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS || '',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
function ipv4(h) {
|
|
33
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
const o = m.slice(1).map(Number);
|
|
36
|
+
if (o.some((n) => n > 255)) return null;
|
|
37
|
+
return o;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Loopback = this host's own services. Reachable by the extension directly, so
|
|
41
|
+
// allowing the bridge to reach it adds no capability.
|
|
42
|
+
export function isLoopbackHost(hostname) {
|
|
43
|
+
const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
44
|
+
if (!h) return false;
|
|
45
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
46
|
+
if (h === '::1') return true;
|
|
47
|
+
const o = ipv4(h);
|
|
48
|
+
return !!(o && o[0] === 127);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cloud instance metadata — credential-theft vector. Always blocked.
|
|
52
|
+
function isMetadataHost(hostname) {
|
|
53
|
+
const o = ipv4(hostname);
|
|
54
|
+
return !!(o && o[0] === 169 && o[1] === 254);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isBlockedHttpHost(hostname) {
|
|
58
|
+
const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
59
|
+
if (!h) return true;
|
|
60
|
+
if (isLoopbackHost(h)) return false; // user's own host — allowed
|
|
61
|
+
if (isMetadataHost(h)) return true; // never proxy cloud metadata, even when private is opted in
|
|
62
|
+
if (ALLOW_PRIVATE_HOSTS) return false; // operator opted in to LAN/private targets
|
|
63
|
+
|
|
64
|
+
// Default deny for the rest of the private/internal space.
|
|
65
|
+
if (h.endsWith('.local')) return true; // mDNS / LAN
|
|
66
|
+
if (
|
|
67
|
+
h === '::' ||
|
|
68
|
+
h.startsWith('fc') ||
|
|
69
|
+
h.startsWith('fd') || // IPv6 ULA
|
|
70
|
+
h.startsWith('fe8') ||
|
|
71
|
+
h.startsWith('fe9') ||
|
|
72
|
+
h.startsWith('fea') ||
|
|
73
|
+
h.startsWith('feb') // IPv6 link-local
|
|
74
|
+
) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const o = ipv4(h);
|
|
78
|
+
if (o) {
|
|
79
|
+
const [a, b] = o;
|
|
80
|
+
if (a === 0 || a === 10) return true; // this-host / RFC1918
|
|
81
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
82
|
+
if (a === 192 && b === 168) return true; // RFC1918
|
|
83
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function assertPublicHttpUrl(u) {
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = new URL(u);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`invalid URL: ${u}`);
|
|
94
|
+
}
|
|
95
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
96
|
+
throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
|
|
97
|
+
}
|
|
98
|
+
if (isBlockedHttpHost(parsed.hostname)) {
|
|
99
|
+
throw new Error(`refusing to proxy a private/metadata address (${parsed.hostname})`);
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|