@chatpanel/bridge 0.10.21 → 0.10.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and 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": [
@@ -16,6 +16,8 @@ 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';
20
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
19
21
 
20
22
  const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
21
23
  const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
@@ -78,7 +80,7 @@ function writeImages(images, dir) {
78
80
  return files;
79
81
  }
80
82
 
81
- export async function chat({ messages, system, options, images }, emit) {
83
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
82
84
  try {
83
85
  mkdirSync(SCRATCH, { recursive: true });
84
86
  } catch {
@@ -102,7 +104,8 @@ export async function chat({ messages, system, options, images }, emit) {
102
104
  const args = ['-p', prompt];
103
105
  if (options.model) args.push('--model', options.model);
104
106
  if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
105
- if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
107
+ // Drop caller extras that would auto-approve tools (shared sanitizer).
108
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.antigravity, emit);
106
109
 
107
110
  await new Promise((resolve, reject) => {
108
111
  let child;
@@ -113,6 +116,8 @@ export async function chat({ messages, system, options, images }, emit) {
113
116
  return reject(new Error(`Failed to start agy: ${e.message}`));
114
117
  }
115
118
 
119
+ const detach = killOnAbort(child, signal); // Stop → terminate the agy child
120
+
116
121
  let out = '';
117
122
  let err = '';
118
123
  let streamed = false;
@@ -137,12 +142,15 @@ export async function chat({ messages, system, options, images }, emit) {
137
142
  child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
138
143
  child.on('error', (e) => {
139
144
  clearTimeout(idleTimer);
145
+ detach();
140
146
  cleanup();
141
147
  reject(new Error(`Failed to start agy: ${e.message}`));
142
148
  });
143
149
  child.on('close', (code) => {
144
150
  clearTimeout(idleTimer);
151
+ detach();
145
152
  cleanup();
153
+ if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
146
154
  if (code === 0) {
147
155
  if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
148
156
  emit({ type: 'done', text: '' });
@@ -0,0 +1,42 @@
1
+ // Caller-supplied "extra CLI arguments" sanitizer (shared by every engine).
2
+ //
3
+ // The extension/model can pass free-form extraArgs to the agent CLI. A prompt-
4
+ // injected model must NOT be able to smuggle a flag that re-opens the sandbox /
5
+ // permission boundary the engine deliberately sets (e.g. Codex's
6
+ // --dangerously-bypass-approvals-and-sandbox, Claude's --permission-mode). Since
7
+ // these flags take values, partial filtering is unsafe — if ANY forbidden token is
8
+ // present we drop the WHOLE extraArgs. Previously only claude.js did this; codex,
9
+ // antigravity and custom pushed extraArgs unfiltered (this closes that gap).
10
+
11
+ export function splitArgs(raw) {
12
+ return String(raw || '').split(/\s+/).filter(Boolean);
13
+ }
14
+
15
+ // Per-engine escalation flags. Long flags use \b so `--flag=value` is caught too;
16
+ // short flags are exact-matched so we don't over-block a benign token.
17
+ export const FORBIDDEN = {
18
+ claude: /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i,
19
+ // Codex: sandbox / approval escalation + `-c key=val` (can set approval_policy or
20
+ // sandbox_mode in TOML) + `-C/--cd` (retarget the working dir).
21
+ codex: /^(-s|-a|-c|-C)$|^--(dangerously[\w-]*|sandbox|ask-for-approval|full-auto|yolo|config|cd)\b/i,
22
+ antigravity: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|yolo|full-auto)\b/i,
23
+ // Custom runs an arbitrary CLI, so only clearly-dangerous LONG flags are blocked
24
+ // (no short-flag guesses that might collide with a benign tool option).
25
+ custom: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|no-sandbox|bypass|yolo|full-auto|permission-mode|allowed-?tools|disallowed-?tools|mcp-config)\b/i,
26
+ };
27
+
28
+ // Returns { args, blocked }. `blocked:true` => the whole extraArgs was dropped.
29
+ export function sanitizeExtraArgs(raw, forbidden) {
30
+ const tokens = splitArgs(raw);
31
+ if (tokens.some((t) => forbidden.test(t))) return { args: [], blocked: true };
32
+ return { args: tokens, blocked: false };
33
+ }
34
+
35
+ // Convenience for the engines: sanitize, push the safe tokens onto `args`, and emit
36
+ // a status when something was dropped.
37
+ export function pushExtraArgs(args, raw, forbidden, emit) {
38
+ if (!raw) return;
39
+ const { args: extra, blocked } = sanitizeExtraArgs(raw, forbidden);
40
+ if (blocked) { try { emit?.({ type: 'status', text: '(ignored unsafe extraArgs)' }); } catch { /* ignore */ } return; }
41
+ args.push(...extra);
42
+ }
@@ -19,6 +19,8 @@ import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { resolveClaude, buildSpawnSpec, isCompiledBinary, selfMcpStdio } from '../env.js';
21
21
  import { buildCliPrompt } from './prompt.js';
22
+ import { killOnAbort } from '../proc.js';
23
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
22
24
 
23
25
  // Write base64 data-URL images to temp files. Claude Code reads them with its
24
26
  // Read tool (which feeds images to the model as vision), so we just reference the
@@ -95,7 +97,7 @@ export function claudeMcpConfig(mcp) {
95
97
  // Spawn claude (however it resolves) and stream its stream-json output via
96
98
  // `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
97
99
  // null (no spawn) when claude can't be resolved, so the caller can fall back.
98
- function runClaude({ prompt, args, cwd, emit }) {
100
+ function runClaude({ prompt, args, cwd, emit, signal }) {
99
101
  const spec = resolveClaude();
100
102
  if (!spec) return null;
101
103
  const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
@@ -108,6 +110,8 @@ function runClaude({ prompt, args, cwd, emit }) {
108
110
  return reject(new Error(`Failed to start claude: ${e.message}`));
109
111
  }
110
112
 
113
+ const detach = killOnAbort(child, signal); // Stop → terminate the claude child
114
+
111
115
  let stdout = '';
112
116
  let stderr = '';
113
117
  let streamedAny = false;
@@ -145,10 +149,13 @@ function runClaude({ prompt, args, cwd, emit }) {
145
149
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
146
150
  child.on('error', (e) => {
147
151
  clearTimeout(idleTimer);
152
+ detach();
148
153
  reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
149
154
  });
150
155
  child.on('close', (code) => {
151
156
  clearTimeout(idleTimer);
157
+ detach();
158
+ if (signal?.aborted) { resolve({ streamedAny, resultText }); return; } // Stop pressed — end quietly
152
159
  if (code === 0) resolve({ streamedAny, resultText });
153
160
  else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
154
161
  });
@@ -204,7 +211,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
204
211
  return out;
205
212
  }
206
213
 
207
- export async function chat({ messages, system, options, images }, emit) {
214
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
208
215
  const permissionMode = options.permissionMode || 'default';
209
216
  // Explicit project dir, else null → CLI runs in home (or WSL home).
210
217
  const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
@@ -259,22 +266,13 @@ export async function chat({ messages, system, options, images }, emit) {
259
266
  }: ${imageFiles.join(', ')}`;
260
267
  }
261
268
 
262
- if (options.extraArgs) {
263
- const extra = String(options.extraArgs).split(/\s+/).filter(Boolean);
264
- // Never let caller-supplied extras re-open the read-only boundary the mode
265
- // flags above establish. If ANY security-sensitive flag is present, drop the
266
- // whole extraArgs (these tokens take values, so partial filtering is unsafe).
267
- const FORBIDDEN = /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i;
268
- if (extra.some((t) => FORBIDDEN.test(t))) {
269
- emit({ type: 'status', text: '(ignored unsafe extraArgs)' });
270
- } else {
271
- args.push(...extra);
272
- }
273
- }
274
- const run = runClaude({ prompt, args, cwd, emit });
269
+ // Never let caller-supplied extras re-open the read-only boundary the mode flags
270
+ // above establish (shared sanitizer — see args.js).
271
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.claude, emit);
272
+ const run = runClaude({ prompt, args, cwd, emit, signal });
275
273
  if (run === null) {
276
274
  cleanup(); // SDK fallback doesn't take images yet
277
- return sdkChat({ messages, system, options }, emit);
275
+ return sdkChat({ messages, system, options }, emit, { signal });
278
276
  }
279
277
  try {
280
278
  const { streamedAny, resultText } = await run;
@@ -335,11 +333,18 @@ function loadSdk() {
335
333
  return sdkPromise;
336
334
  }
337
335
 
338
- async function sdkChat({ messages, system, options }, emit) {
336
+ async function sdkChat({ messages, system, options }, emit, { signal } = {}) {
339
337
  const sdk = await loadSdk();
340
338
  if (!sdk) throw new Error(lastReason);
341
339
  const { query } = sdk;
342
340
 
341
+ // The SDK cancels the run when this controller aborts — forward the request signal.
342
+ const abortController = new AbortController();
343
+ if (signal) {
344
+ if (signal.aborted) abortController.abort();
345
+ else signal.addEventListener('abort', () => abortController.abort(), { once: true });
346
+ }
347
+
343
348
  const permissionMode = options.permissionMode || 'default';
344
349
  const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
345
350
  const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
@@ -358,6 +363,7 @@ async function sdkChat({ messages, system, options }, emit) {
358
363
  permissionMode,
359
364
  includePartialMessages: true,
360
365
  canUseTool,
366
+ abortController,
361
367
  settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
362
368
  systemPrompt: system
363
369
  ? { type: 'preset', preset: 'claude_code', append: system }
@@ -366,10 +372,15 @@ async function sdkChat({ messages, system, options }, emit) {
366
372
  ...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
367
373
  },
368
374
  });
369
- for await (const message of iterator) {
370
- const r = handleMessage(message, emit, streamedAny);
371
- if (r.streamed) streamedAny = true;
372
- if (r.result != null) resultText = r.result;
375
+ try {
376
+ for await (const message of iterator) {
377
+ const r = handleMessage(message, emit, streamedAny);
378
+ if (r.streamed) streamedAny = true;
379
+ if (r.result != null) resultText = r.result;
380
+ }
381
+ } catch (e) {
382
+ if (signal?.aborted || abortController.signal.aborted) return; // Stop pressed — end quietly
383
+ throw e;
373
384
  }
374
385
  emit({ type: 'done', text: streamedAny ? '' : resultText });
375
386
  }
@@ -1,8 +1,6 @@
1
1
  // Built-in CLI agents — pi, opencode, kiro — that reuse the shared custom-engine
2
- // runner (runSpec) with a FIXED spec each. Unlike the Pro "custom" engine, these
3
- // are NOT entitlement-gated: they ship as built-ins, and the extension bounds
4
- // free users to a single usable agent (FREE_LIMITS.bridgeAgents). Bring-your-own
5
- // arbitrary CLIs stay Pro via custom.js.
2
+ // runner (runSpec) with a fixed spec each. These are fixed built-in engines and are
3
+ // not entitlement-gated; custom BYO CLIs are handled by custom.js.
6
4
  //
7
5
  // Specs come from each CLI's actual flags:
8
6
  // pi — pi -p "<prompt>" · --model · @{path} images · --list-models
@@ -34,8 +32,8 @@ function makeCliAgent(command, spec, notFoundHint) {
34
32
  listModels(options = {}) {
35
33
  return listSpecModels(command, spec.listModelsArgs, options.workingDir);
36
34
  },
37
- chat(input, emit) {
38
- return runSpec(resolvedSpec, input, emit);
35
+ chat(input, emit, opts) {
36
+ return runSpec(resolvedSpec, input, emit, opts);
39
37
  },
40
38
  };
41
39
  }
@@ -15,12 +15,14 @@
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
19
  import { readFile, unlink, writeFile } from 'node:fs/promises';
19
20
  import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
20
21
  import os from 'node:os';
21
22
  import path from 'node:path';
22
23
  import { findAgentBin, selfMcpStdio } from '../env.js';
23
24
  import { buildCliPrompt } from './prompt.js';
25
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
24
26
 
25
27
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
26
28
  // streaming never trips it — only true silence does. Override with
@@ -136,7 +138,7 @@ async function writeImages(images, tag) {
136
138
  return files;
137
139
  }
138
140
 
139
- export async function chat({ messages, system, options, images }, emit) {
141
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
140
142
  ensureScratch();
141
143
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
142
144
  const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
@@ -162,7 +164,8 @@ export async function chat({ messages, system, options, images }, emit) {
162
164
  // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
163
165
  args.push(...codexMcpConfigArgs(options.mcp));
164
166
  if (options.model) args.push('-m', options.model);
165
- if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
167
+ // Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
168
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
166
169
  for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
167
170
  args.push('-');
168
171
 
@@ -183,6 +186,8 @@ export async function chat({ messages, system, options, images }, emit) {
183
186
  return reject(new Error(`Failed to start codex: ${e.message}`));
184
187
  }
185
188
 
189
+ const detach = killOnAbort(child, signal); // Stop → SIGTERM/SIGKILL the codex child
190
+
186
191
  let stdout = '';
187
192
  let stderr = '';
188
193
  let idleTimer;
@@ -213,11 +218,13 @@ export async function chat({ messages, system, options, images }, emit) {
213
218
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
214
219
  child.on('error', (e) => {
215
220
  clearTimeout(idleTimer);
221
+ detach();
216
222
  cleanupImages();
217
223
  reject(e);
218
224
  });
219
225
  child.on('close', async (code) => {
220
226
  clearTimeout(idleTimer);
227
+ detach();
221
228
  let text = '';
222
229
  try {
223
230
  text = (await readFile(outFile, 'utf8')).trim();
@@ -226,6 +233,7 @@ export async function chat({ messages, system, options, images }, emit) {
226
233
  }
227
234
  unlink(outFile).catch(() => {});
228
235
  cleanupImages();
236
+ if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
229
237
  if (code === 0) {
230
238
  emit({ type: 'delta', text: text || '(no output)' });
231
239
  emit({ type: 'done', text: '' });
@@ -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';
22
+ import { killOnAbort } from '../proc.js';
24
23
  import { handleMessage } from './claude.js';
25
24
  import { buildCliPrompt } from './prompt.js';
25
+ import { pushExtraArgs, FORBIDDEN } from './args.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.
@@ -95,7 +95,7 @@ function parseModelList(stdout) {
95
95
 
96
96
  // Unified model listing: run the agent's CONFIGURED list-models invocation
97
97
  // (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).
98
+ // when not configured. Requires Pro (runs the user's CLI).
99
99
  export async function listModels(options = {}) {
100
100
  if (!(await isProEntitled(options.entitlement))) {
101
101
  throw new Error('Custom agents require ChatPanel Pro.');
@@ -104,8 +104,8 @@ export async function listModels(options = {}) {
104
104
  return listSpecModels(spec.command, spec.listModelsArgs, options.workingDir);
105
105
  }
106
106
 
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.
107
+ // Shared model listing: run a CLI's list-models invocation and parse it. Used by
108
+ // the custom engine and the built-in CLI agents.
109
109
  export async function listSpecModels(command, listModelsArgs, workingDir) {
110
110
  const listArgs = String(listModelsArgs || '').trim();
111
111
  if (!command || !listArgs) return [];
@@ -360,18 +360,16 @@ export async function ensureStableMcpConfig(spec, cwd, label, emit, deps = {}) {
360
360
  throw new Error(`${label} browser-tool setup completed, but the MCP server is still not visible. Run: ${setupCommand}`);
361
361
  }
362
362
 
363
- export async function chat({ messages, system, options, images }, emit) {
364
- // Pro gate — verified, not just UI. No valid signed entitlement no run.
363
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
364
+ // Require a valid signed entitlement before running.
365
365
  if (!(await isProEntitled(options.entitlement))) {
366
366
  throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
367
367
  }
368
- return runSpec(options.custom || {}, { messages, system, options, images }, emit);
368
+ return runSpec(options.custom || {}, { messages, system, options, images }, emit, { signal });
369
369
  }
370
370
 
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) {
371
+ // Run a CLI agent from a spec — shared by the custom engine and the built-in CLI engines.
372
+ export async function runSpec(spec, { messages, system, options = {}, images }, emit, { signal } = {}) {
375
373
  if (!spec.command) throw new Error('This agent has no command configured.');
376
374
 
377
375
  const resolved = resolveCommand(spec.command);
@@ -394,9 +392,13 @@ export async function runSpec(spec, { messages, system, options = {}, images },
394
392
  ? String(spec.args).split(/\s+/).filter(Boolean)
395
393
  : [];
396
394
  // 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));
395
+ // right after the base args/subcommand. Sanitized (shared helper): escalation flags
396
+ // (--dangerously*, --skip-permissions, --trust-all-tools, --no-sandbox …) are dropped
397
+ // from the EXTRA args so an injected value can't unlock tools. The built-in agents'
398
+ // intentional autonomy flags live in their BASE spec (cli-agents.js), not here, so
399
+ // they're unaffected; a custom CLI that genuinely needs such a flag should carry it
400
+ // in its configured command/args, not the extra-args field.
401
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.custom, emit);
400
402
  // Inject the selected model via the agent's CONFIGURED model-arg template
401
403
  // (e.g. "--model {model}" or, for opencode, "-m {model}" with provider/model).
402
404
  // Without a template we can't know how this CLI takes a model, so options.model
@@ -486,6 +488,8 @@ export async function runSpec(spec, { messages, system, options = {}, images },
486
488
  return reject(new Error(`Failed to start ${label}: ${e.message}`));
487
489
  }
488
490
 
491
+ const detach = killOnAbort(child, signal); // Stop → terminate the CLI child
492
+
489
493
  let stderr = '';
490
494
  let streamedAny = false;
491
495
  let resultText = '';
@@ -552,12 +556,15 @@ export async function runSpec(spec, { messages, system, options = {}, images },
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
569
  emit({ type: 'done', text: streamedAny ? '' : resultText });
563
570
  resolve();
@@ -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/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
+ }
package/src/server.js CHANGED
@@ -30,6 +30,7 @@ import { pi, opencode, kiro } from './engines/cli-agents.js';
30
30
  import * as custom from './engines/custom.js';
31
31
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
32
32
  import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
33
+ import { stripHidden } from './sanitize.js';
33
34
  import { checkForUpdate, selfUpdate } from './update.js';
34
35
  import { callLocalMcp } from './mcp-local.js';
35
36
  import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
@@ -37,7 +38,7 @@ import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
37
38
  // Hardcoded (not read from package.json) so it survives Bun's single-file
38
39
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
39
40
  // this drifts from package.json, so the two can't silently diverge.
40
- const VERSION = '0.10.21';
41
+ const VERSION = '0.10.22';
41
42
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
42
43
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
43
44
 
@@ -108,10 +109,13 @@ function relayToolCall(session, name, input) {
108
109
 
109
110
  // The extension returns a string OR { text, image(dataURL) }; map to MCP content.
110
111
  function toMcpContent(result) {
112
+ // L5: de-steganographize tool-result TEXT before it flows back to the CLI/model —
113
+ // the bridge is a public localhost endpoint, so (like the prompt path) it must strip
114
+ // ASCII-smuggled / bidi Unicode from relayed results, not assume the caller did.
111
115
  if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
112
- if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
116
+ if (typeof result === 'string') return { content: [{ type: 'text', text: stripHidden(result) }] };
113
117
  const content = [];
114
- if (result.text) content.push({ type: 'text', text: String(result.text) });
118
+ if (result.text) content.push({ type: 'text', text: stripHidden(String(result.text)) });
115
119
  if (typeof result.image === 'string') {
116
120
  const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
117
121
  if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
@@ -330,9 +334,13 @@ async function handleChat(req, res) {
330
334
  if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
331
335
  };
332
336
 
333
- // If the client disconnects, stop caring about late writes.
337
+ // If the client disconnects (Stop, or the panel closes), stop caring about late
338
+ // writes AND abort the run so the engine kills its CLI child instead of letting it
339
+ // finish in the background. Older engines ignore the signal (harmless); the spawn
340
+ // engines honor it via killOnAbort.
334
341
  let closed = false;
335
- req.on('close', () => (closed = true));
342
+ const ac = new AbortController();
343
+ req.on('close', () => { closed = true; ac.abort(); });
336
344
 
337
345
  const safeEmit = (obj) => { if (!closed) emit(obj); };
338
346
 
@@ -358,6 +366,7 @@ async function handleChat(req, res) {
358
366
  images: Array.isArray(body.images) ? body.images : [],
359
367
  },
360
368
  safeEmit,
369
+ { signal: ac.signal },
361
370
  );
362
371
  } catch (e) {
363
372
  log('error', `${body.agent} chat failed: ${e?.message || e}`);
@@ -718,11 +727,16 @@ const server = createServer(async (req, res) => {
718
727
  try {
719
728
  if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
720
729
  if (req.method === 'GET' && url.pathname === '/debug') {
730
+ // L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
731
+ // to diagnose "is codex installed?". The full home dir, $PATH, and resolved
732
+ // binary paths (which embed the username / home) leak environment detail, so
733
+ // they're opt-in behind CHATPANEL_BRIDGE_DEBUG=1. The extension doesn't read
734
+ // this route, so trimming it by default breaks nothing.
735
+ const verbose = /^(1|true|yes|on)$/i.test(process.env.CHATPANEL_BRIDGE_DEBUG || '');
721
736
  return json(res, 200, {
722
737
  version: VERSION,
723
- home: os.homedir(),
724
- agents: Object.fromEntries(AGENT_CLIS.map((name) => [name, findAgentBin(name) || null])),
725
- path: process.env.PATH,
738
+ agents: Object.fromEntries(AGENT_CLIS.map((name) => [name, verbose ? (findAgentBin(name) || null) : !!findAgentBin(name)])),
739
+ ...(verbose ? { home: os.homedir(), path: process.env.PATH } : {}),
726
740
  });
727
741
  }
728
742
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
@@ -826,6 +840,12 @@ function startServer() {
826
840
  });
827
841
  server.listen(PORT, HOST, async () => {
828
842
  log('info', `listening on http://${HOST}:${PORT}`);
843
+ // M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed
844
+ // returns true for any Host), so the only inbound guard left is the per-install
845
+ // token / extension Origin. Make that trade-off LOUD — it's rarely what you want.
846
+ if (!LOOPBACK_HOSTNAMES.has(HOST)) {
847
+ log('error', `⚠ SECURITY: bound to NON-LOOPBACK host ${HOST}. The anti-rebinding Host check is OFF, so any device that reaches this port (and any web page via a spoofed Host header) can drive local agents — gated only by the bridge token. Only do this on a trusted, firewalled network; prefer 127.0.0.1.`);
848
+ }
829
849
  for (const [, { engine, label, hidden }] of Object.entries(ENGINES)) {
830
850
  if (hidden) continue;
831
851
  const a = await engine.available().catch(() => ({ ok: false }));
package/src/ssrf.js CHANGED
@@ -1,88 +1,35 @@
1
- // SSRF guard for the /mcp-remote proxy.
1
+ // SSRF guard for the bridge's outbound proxies (/mcp-remote, /fetch-title).
2
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.
3
+ // The host CLASSIFICATION (what is loopback / cloud-metadata / RFC1918 / CGNAT /
4
+ // ULA / link-local / .local) now lives in ONE shared place — src/net.js, a vendored
5
+ // copy of @chatpanel/pii/net.js, the same classifier the gateway and extension use.
6
+ // This file keeps only the bridge's two POLICIES + their exact error messages, so a
7
+ // security guard can't drift between the direct client path and the proxied path.
8
+ // See docs/secure-data-plane.md.
10
9
  //
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.
10
+ // Two policies:
11
+ // • /mcp-remote (assertPublicHttpUrl): loopback ALLOWED (the user's own MCP
12
+ // servers — the whole point of "via bridge"), cloud metadata ALWAYS blocked,
13
+ // every other private/LAN range blocked UNLESS the operator opts in with
14
+ // CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1 (reaching an MCP server on a trusted LAN).
15
+ // /fetch-title (assertPublicWebUrl): STRICTER a page fetch has no business
16
+ // touching loopback OR any private host, so loopback + metadata + every private
17
+ // range are blocked unconditionally (the opt-in is deliberately NOT honored).
25
18
  //
26
- // The same checks run on the initial URL AND after any redirect.
19
+ // Non-http(s) schemes are blocked in both. Run the assert on the initial URL AND
20
+ // after every redirect hop.
21
+
22
+ import { isLoopbackHost, isBlockedHost } from './net.js';
23
+
24
+ export { isLoopbackHost };
27
25
 
28
26
  const ALLOW_PRIVATE_HOSTS = /^(1|true|yes|on)$/i.test(
29
27
  process.env.CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS || '',
30
28
  );
31
29
 
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
-
30
+ // MCP-proxy policy: loopback ok, metadata never, other private only when opted in.
57
31
  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;
32
+ return isBlockedHost(hostname, { allowLoopback: true, allowPrivate: ALLOW_PRIVATE_HOSTS });
86
33
  }
87
34
 
88
35
  export function assertPublicHttpUrl(u) {
@@ -101,41 +48,9 @@ export function assertPublicHttpUrl(u) {
101
48
  return parsed;
102
49
  }
103
50
 
104
- // STRICTER guard for fetching arbitrary WEB pages (the /fetch-title route). Unlike the MCP proxy,
105
- // a page fetch has NO legitimate reason to reach the user's own loopback services or any LAN /
106
- // private host — those would be pure SSRF (port-scan the LAN, hit a localhost admin panel, read
107
- // cloud metadata). So loopback + metadata + every private/internal range are blocked
108
- // UNCONDITIONALLY here; the CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS opt-in (meant for reaching a LAN
109
- // MCP server, a different trust context) is deliberately NOT honored. Only genuinely public
110
- // http(s) hosts pass. Re-run this on every redirect hop, not just the initial URL.
51
+ // Web-fetch policy (stricter): block loopback + metadata + all private, always.
111
52
  export function isDisallowedWebHost(hostname) {
112
- const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
113
- if (!h) return true;
114
- if (isLoopbackHost(h)) return true; // localhost/127.x — a page fetch must never touch it
115
- if (isMetadataHost(h)) return true; // 169.254.169.254 — credential theft
116
- if (h.endsWith('.local')) return true; // mDNS / LAN
117
- if (
118
- h === '::' ||
119
- h.startsWith('fc') ||
120
- h.startsWith('fd') || // IPv6 ULA
121
- h.startsWith('fe8') ||
122
- h.startsWith('fe9') ||
123
- h.startsWith('fea') ||
124
- h.startsWith('feb') // IPv6 link-local
125
- ) {
126
- return true;
127
- }
128
- const o = ipv4(h);
129
- if (o) {
130
- const [a, b] = o;
131
- if (a === 0 || a === 10) return true; // this-host / RFC1918
132
- if (a === 127) return true; // loopback (also caught above; explicit for clarity)
133
- if (a === 169 && b === 254) return true; // link-local (incl. metadata)
134
- if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
135
- if (a === 192 && b === 168) return true; // RFC1918
136
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
137
- }
138
- return false;
53
+ return isBlockedHost(hostname, { allowLoopback: false, allowPrivate: false });
139
54
  }
140
55
 
141
56
  export function assertPublicWebUrl(u) {