@chatpanel/gateway 0.6.91 → 0.6.92

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 CHANGED
@@ -37,14 +37,19 @@ The redaction engine is the **same code** the ChatPanel extension runs — the
37
37
  [`@chatpanel/pii`](https://github.com/chatpanel/chatpanel-pii) package is the
38
38
  single source of truth, so a privacy feature added once is shared everywhere.
39
39
 
40
- ## Quick start (bridge backend)
40
+ ## Quick start the one thing to install
41
41
 
42
- You need the [ChatPanel bridge](https://github.com/chatpanel/chatpanel-bridge)
43
- running and logged into codex/claude (the same bridge the extension uses).
42
+ The gateway **carries the [bridge](https://github.com/chatpanel/chatpanel-bridge)**
43
+ (0.6.92+): `@chatpanel/bridge` is a dependency, bundled into the same binary, and the
44
+ gateway starts it as a child process when nothing already answers on 4319 — or adopts a
45
+ bridge that is already running (the desktop app's, or a standalone you installed; a newer
46
+ standalone is preferred so bridge fixes keep their own cadence). Two processes on purpose:
47
+ the bridge spawns your CLIs and holds SCM tokens; the model runtimes live here. Log into
48
+ the CLIs (`claude`, `codex`, …) as you normally would — that is all.
44
49
 
45
50
  ```bash
46
51
  # Standalone binary — no Node.js required:
47
- curl -fsSL https://dl.chatpanel.net/gateway/install.sh | bash # macOS / Linux
52
+ curl -fsSL https://dl.chatpanel.net/install.sh | bash # macOS / Linux
48
53
  # Windows (PowerShell): irm https://dl.chatpanel.net/gateway/install.ps1 | iex
49
54
 
50
55
  # Or via npm (needs Node):
@@ -52,8 +57,15 @@ npm install -g @chatpanel/gateway
52
57
  chatpanel-gateway
53
58
  # → ChatPanel Privacy Gateway on http://127.0.0.1:4320
54
59
  # backend : bridge (agent: codex, via http://127.0.0.1:4319)
60
+ # bridge : starting the embedded bridge (v0.11.20)
55
61
  ```
56
62
 
63
+ `GET /health` says which bridge it runs: `bridge.mode` is `embedded`, `standalone`,
64
+ `adopted` or `off`. `CHATPANEL_BRIDGE_MANAGED=off` (or `bridge.managed: false`) turns the
65
+ supervision off for a host that runs its own bridge — the desktop app does. A
66
+ `bridge.url` that is not on this machine is left alone: nothing is started for a remote
67
+ bridge.
68
+
57
69
  > **Binary vs. npm — same features, very different local-AI speed.** Both run
58
70
  > identical redaction/routing. But the standalone binary runs the local models
59
71
  > (speech-to-text, diarization, NER) on the **WASM** runtime — **fp32-only,
@@ -12,6 +12,7 @@
12
12
  // chatpanel-gateway --uninstall remove login auto-start
13
13
  // chatpanel-gateway --status is auto-start registered?
14
14
  // chatpanel-gateway --version print version
15
+ // chatpanel-gateway --bridge run the embedded bridge (the supervisor's child; not for hands)
15
16
  //
16
17
  // Config comes from gateway.config.json / env (see src/config.js).
17
18
  export {}; // mark as an ES module (all imports below are dynamic)
@@ -19,7 +20,16 @@ export {}; // mark as an ES module (all imports below are dynamic)
19
20
  const arg = process.argv[2];
20
21
 
21
22
  try {
22
- if (arg === 'mcp') {
23
+ if (arg === '--bridge') {
24
+ // THE EMBEDDED BRIDGE. The gateway carries @chatpanel/bridge and starts it as a child of
25
+ // itself (src/bridge-supervisor.js) when nothing answers on 4319 — one install, two
26
+ // processes. The flag is consumed here; what remains of argv is the bridge's own
27
+ // (`--version` for the supervisor's check). CHATPANEL_BRIDGE_EMBEDDED tells the bridge its
28
+ // execPath is the gateway, so it never self-updates over it.
29
+ process.env.CHATPANEL_BRIDGE_EMBEDDED = '1';
30
+ process.argv.splice(2, 1);
31
+ await import('@chatpanel/bridge/src/server.js');
32
+ } else if (arg === 'mcp') {
23
33
  // Its own path: proxies to the running gateway over HTTP and must NOT import
24
34
  // server.js (which would open a second handle on the warm SQLite store).
25
35
  const { runMcpServer } = await import('../src/mcp.js');
@@ -60,7 +70,7 @@ try {
60
70
  start();
61
71
  break;
62
72
  default:
63
- console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|tools list|tools schema <tool>|call <tool> '<json>'|local|connect|--install|--uninstall|--status|--version]`);
73
+ console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|tools list|tools schema <tool>|call <tool> '<json>'|local|connect|--install|--uninstall|--status|--version|--bridge]`);
64
74
  process.exit(2);
65
75
  }
66
76
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.91",
4
- "description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
3
+ "version": "0.6.92",
4
+ "description": "Local privacy gateway redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "chatpanel-gateway": "bin/chatpanel-gateway.js"
@@ -27,6 +27,7 @@
27
27
  "node": ">=18"
28
28
  },
29
29
  "dependencies": {
30
+ "@chatpanel/bridge": "^0.11.20",
30
31
  "@chatpanel/pii": "^0.7.4",
31
32
  "@huggingface/transformers": "^4.2.0",
32
33
  "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
@@ -0,0 +1,176 @@
1
+ // The gateway carries the bridge — one install, two processes.
2
+ //
3
+ // A user installs ONE thing (this gateway: the binary, or `npm i -g @chatpanel/gateway`) and
4
+ // gets the bridge with it: `@chatpanel/bridge` is a dependency, bundled into the same binary,
5
+ // and the gateway STARTS it as a child process when nothing already answers on the bridge
6
+ // port. Two processes on purpose: the bridge spawns CLIs, holds SCM tokens and runs shells —
7
+ // a small zero-dependency process with its own failure domain — and the NER / STT / TTS
8
+ // runtimes that crash or eat memory live here, not there. A gateway restart never takes a
9
+ // running Claude Code task down with it.
10
+ //
11
+ // What is already there is ADOPTED, never fought: a bridge the desktop app installed, a
12
+ // standalone the user put in ~/.local/bin (preferred when it is NEWER than the embedded copy,
13
+ // so a bridge fix keeps shipping on its own tag), a remote bridge named in config. The plan
14
+ // is a pure function (`planBridge`) so the rule is testable without spawning anything.
15
+ //
16
+ // An embedded child is started with CHATPANEL_BRIDGE_EMBEDDED=1: its `process.execPath` is
17
+ // the GATEWAY binary, so the bridge's self-update is disabled there (it would overwrite the
18
+ // gateway with a bridge) and its /health says `update.embedded: 'gateway'`. Both children get
19
+ // CHATPANEL_MANAGED_BY=gateway so a client can say who runs it instead of offering install.sh.
20
+
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { existsSync } from 'node:fs';
24
+ import { spawn, spawnSync } from 'node:child_process';
25
+ import { resolveLaunch } from './service.js';
26
+ import { DEFAULT_BRIDGE_URL } from './bridge.js';
27
+
28
+ const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:(\d+))?\/?$/i;
29
+ const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000];
30
+ const STABLE_MS = 60_000; // a child up this long resets the back-off
31
+ const PROBE_TIMEOUT_MS = 1200;
32
+
33
+ /** Where the standalone bridge installer puts its binary (scripts/install.sh / install.ps1 in the bridge repo). */
34
+ export function standaloneCandidates({ home = os.homedir(), platform = process.platform, env = process.env } = {}) {
35
+ if (platform === 'win32') return [path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ChatPanel', 'chatpanel-bridge.exe')];
36
+ return [path.join(home, '.local', 'bin', 'chatpanel-bridge')];
37
+ }
38
+
39
+ /** semver-ish: 1 when a > b, -1 when a < b, 0 when equal or unreadable. */
40
+ export function compareVersions(a, b) {
41
+ const pa = String(a || '').split('.').map((x) => parseInt(x, 10)); const pb = String(b || '').split('.').map((x) => parseInt(x, 10));
42
+ if (pa.some(Number.isNaN) || pb.some(Number.isNaN) || pa.length < 3 || pb.length < 3) return 0;
43
+ for (let i = 0; i < 3; i += 1) { if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1; }
44
+ return 0;
45
+ }
46
+
47
+ /**
48
+ * What to do about the bridge — pure.
49
+ * managed: false / 'off' → off (the host runs its own, e.g. the desktop app)
50
+ * a non-loopback bridge.url → off (a remote bridge is the user's; never start one here)
51
+ * something answers the port → adopt
52
+ * a standalone newer than the embedded copy → spawn-standalone
53
+ * an embedded copy → spawn-embedded
54
+ * only a standalone → spawn-standalone
55
+ */
56
+ export function planBridge({ managed = true, cfgUrl = '', healthy = null, standalone = null, embeddedVersion = null } = {}) {
57
+ if (managed === false || managed === 'off') return { action: 'off', why: 'bridge supervision is off (the host runs its own)' };
58
+ const url = String(cfgUrl || DEFAULT_BRIDGE_URL);
59
+ if (!LOOPBACK.test(url)) return { action: 'off', why: `bridge.url ${url} is not on this machine; nothing to start here` };
60
+ if (healthy) return { action: 'adopt', why: `a bridge already answers at ${url}${healthy.version ? ` (v${healthy.version}${healthy.managedBy ? `, run by ${healthy.managedBy}` : ''})` : ''}` };
61
+ const sv = standalone?.version || null;
62
+ if (standalone?.path && embeddedVersion && compareVersions(sv, embeddedVersion) > 0) return { action: 'spawn-standalone', why: `the installed bridge (v${sv}) is newer than the embedded one (v${embeddedVersion})`, path: standalone.path };
63
+ if (embeddedVersion) return { action: 'spawn-embedded', why: `starting the embedded bridge (v${embeddedVersion})${standalone?.path ? ` — the installed one is v${sv || '?'}` : ''}` };
64
+ if (standalone?.path) return { action: 'spawn-standalone', why: `starting the installed bridge${sv ? ` (v${sv})` : ''}`, path: standalone.path };
65
+ return { action: 'off', why: 'no bridge to start (no embedded copy, no standalone install)' };
66
+ }
67
+
68
+ /** `GET /health` on the bridge, or null. */
69
+ export async function probeBridge(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl = fetch } = {}) {
70
+ const ctrl = new AbortController();
71
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
72
+ try {
73
+ const res = await fetchImpl(`${String(url).replace(/\/$/, '')}/health`, { signal: ctrl.signal });
74
+ if (!res.ok) return null;
75
+ const j = await res.json().catch(() => null);
76
+ return j && j.ok !== false ? { version: j.version || null, managedBy: j.managedBy || null } : null;
77
+ } catch { return null; } finally { clearTimeout(t); }
78
+ }
79
+
80
+ /** `<program> [...args] --version`, or null — one process, bounded. */
81
+ export function versionOf(program, args = [], { timeoutMs = 8000 } = {}) {
82
+ try {
83
+ const r = spawnSync(program, [...args, '--version'], { encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, CHATPANEL_BRIDGE_EMBEDDED: '1' } });
84
+ const v = String(r.stdout || '').trim().split('\n').pop();
85
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
86
+ } catch { return null; }
87
+ }
88
+
89
+ /** How THIS gateway starts its embedded bridge: itself, with `--bridge` (bin/chatpanel-gateway.js). */
90
+ export function embeddedLaunch() {
91
+ const { program, args } = resolveLaunch();
92
+ return { program, args: [...args, '--bridge'] };
93
+ }
94
+
95
+ const portOf = (url) => { const m = LOOPBACK.exec(String(url || DEFAULT_BRIDGE_URL)); return m?.[3] || '4319'; };
96
+
97
+ /**
98
+ * Make sure a bridge is running for this gateway, and keep it running. Returns a controller:
99
+ * `status()` → `{ mode: 'adopted'|'embedded'|'standalone'|'off', version, pid, restarts, why }`,
100
+ * `stop()` ends a child we started. Everything that touches the machine is injectable so the
101
+ * rule can be tested without a process: `probe`, `spawnImpl`, `version`, `candidates`, `exists`.
102
+ */
103
+ export async function ensureBridge(cfg, {
104
+ log = (line) => console.log(line),
105
+ probe = probeBridge,
106
+ spawnImpl = spawn,
107
+ version = versionOf,
108
+ candidates = standaloneCandidates(),
109
+ exists = existsSync,
110
+ launch = embeddedLaunch,
111
+ managed = process.env.CHATPANEL_BRIDGE_MANAGED === 'off' ? false : cfg?.bridge?.managed,
112
+ waitForSiblingMs = 2500,
113
+ now = Date.now,
114
+ setTimer = setTimeout,
115
+ } = {}) {
116
+ const url = String(cfg?.bridge?.url || DEFAULT_BRIDGE_URL).replace(/\/$/, '');
117
+ const state = { mode: 'off', version: null, pid: null, restarts: 0, why: '', child: null, stopping: false, backoff: 0 };
118
+ const settle = (mode, why, extra = {}) => { Object.assign(state, { mode, why }, extra); log(` bridge : ${why}`); };
119
+
120
+ // Someone else (the desktop's supervisor, launchd) may be starting a bridge this very
121
+ // second; wait a moment before deciding it is absent, or two bridges race for one port.
122
+ let healthy = await probe(url);
123
+ if (!healthy && managed !== false && managed !== 'off' && LOOPBACK.test(url) && waitForSiblingMs > 0) {
124
+ const until = now() + waitForSiblingMs;
125
+ while (!healthy && now() < until) { await new Promise((r) => setTimer(r, 500)); healthy = await probe(url); }
126
+ }
127
+ const emb = launch();
128
+ const embeddedVersion = managed === false || managed === 'off' || healthy ? null : version(emb.program, emb.args);
129
+ const standalonePath = candidates.find((p) => exists(p)) || null;
130
+ const standalone = standalonePath && !healthy ? { path: standalonePath, version: version(standalonePath, []) } : null;
131
+ const plan = planBridge({ managed, cfgUrl: url, healthy, standalone, embeddedVersion });
132
+
133
+ if (plan.action === 'off') { settle('off', plan.why); return controller(); }
134
+ if (plan.action === 'adopt') { settle('adopted', plan.why, { version: healthy.version }); return controller(); }
135
+
136
+ const spec = plan.action === 'spawn-embedded'
137
+ ? { ...emb, env: { CHATPANEL_BRIDGE_EMBEDDED: '1' }, mode: 'embedded', version: embeddedVersion }
138
+ : { program: plan.path, args: [], env: {}, mode: 'standalone', version: standalone?.version || null };
139
+ let startedAt = 0;
140
+
141
+ const start = () => {
142
+ if (state.stopping) return;
143
+ const child = spawnImpl(spec.program, spec.args, {
144
+ stdio: ['ignore', 'pipe', 'pipe'],
145
+ env: { ...process.env, ...spec.env, CHATPANEL_MANAGED_BY: 'gateway', CHATPANEL_BRIDGE_PORT: portOf(url) },
146
+ });
147
+ startedAt = now();
148
+ state.child = child; state.pid = child.pid || null;
149
+ const relay = (stream, tag) => stream?.on('data', (buf) => { for (const line of String(buf).split('\n')) if (line.trim()) log(`[bridge] ${line}`); });
150
+ relay(child.stdout, 'out'); relay(child.stderr, 'err');
151
+ child.on('error', (e) => log(`[bridge] could not start ${spec.program}: ${e?.message || e}`));
152
+ child.on('exit', async (code, sig) => {
153
+ state.child = null; state.pid = null;
154
+ if (state.stopping) return;
155
+ // The port may have been taken by a bridge someone else started while ours came up —
156
+ // that is not a failure to restart from, it is a bridge to adopt.
157
+ const other = await probe(url);
158
+ if (other) { settle('adopted', `a bridge already answers at ${url} (v${other.version || '?'}) — ours stepped aside`, { version: other.version }); return; }
159
+ if (now() - startedAt > STABLE_MS) state.backoff = 0;
160
+ const delay = BACKOFF_MS[Math.min(state.backoff, BACKOFF_MS.length - 1)];
161
+ state.backoff += 1; state.restarts += 1;
162
+ log(`[bridge] exited (${sig || code}); restarting in ${delay / 1000}s`);
163
+ setTimer(start, delay);
164
+ });
165
+ };
166
+ settle(spec.mode, plan.why, { version: spec.version });
167
+ start();
168
+ return controller();
169
+
170
+ function controller() {
171
+ return {
172
+ status: () => ({ mode: state.mode, version: state.version, pid: state.pid, restarts: state.restarts, why: state.why }),
173
+ stop: () => { state.stopping = true; if (state.child && !state.child.killed) { try { state.child.kill('SIGTERM'); } catch { /* gone */ } } },
174
+ };
175
+ }
176
+ }
@@ -101,5 +101,5 @@ export async function bridgePresenceNote(brOverride) {
101
101
  const n = (br.data.skills?.count ?? null);
102
102
  return `bridge detected at ${url} (v${br.data.version}${n != null ? `, ${n} skills` : ''}) — its agents and skills are available through this gateway.`;
103
103
  }
104
- return `bridge not detected at ${url} — local agents/skills are unavailable until it runs (curl -fsSL https://dl.chatpanel.net/bridge/install.sh | bash). The gateway runs fine without it.`;
104
+ return `bridge not detected at ${url} — a running gateway (0.6.92+) starts its embedded bridge itself; if this one did not, check ~/.chatpanel/gateway.log. The gateway runs fine without it, minus local agents/skills.`;
105
105
  }
package/src/server.js CHANGED
@@ -39,6 +39,7 @@ import { createScorecardStore } from './scorecard-store.js';
39
39
  import { createEngineLedgerStore } from './engine-ledger-store.js';
40
40
  import { createProjectStore } from './project-store.js';
41
41
  import { applicationsFor, recruitPass } from './recruiting.js';
42
+ import { ensureBridge } from './bridge-supervisor.js';
42
43
  import { createHistoryStore } from './sqlite-store.js';
43
44
  import { ingestBackups } from './backup-ingest.js';
44
45
  import * as nerEngine from './ner-engine.js';
@@ -62,7 +63,7 @@ import * as openai from './openai.js';
62
63
  import * as responses from './responses.js';
63
64
  import * as anthropic from './anthropic.js';
64
65
 
65
- export const VERSION = '0.6.91';
66
+ export const VERSION = '0.6.92';
66
67
 
67
68
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
68
69
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -745,6 +746,10 @@ export function createGateway(cfg = loadConfig()) {
745
746
  // on the login service it registers, so a client can say "provided by the desktop app"
746
747
  // and stop offering install.sh for a gateway that is already installed. Absent otherwise.
747
748
  ...(process.env.CHATPANEL_MANAGED_BY ? { managedBy: String(process.env.CHATPANEL_MANAGED_BY).slice(0, 32) } : {}),
749
+ // THE BRIDGE THIS GATEWAY RUNS OR ADOPTED — additive. `mode` is adopted | embedded |
750
+ // standalone | off; a client can say "bridge: provided by the gateway" and stop
751
+ // offering a second installer.
752
+ ...(bridgeSupervisor ? { bridge: bridgeSupervisor.status() } : {}),
748
753
  // `runtime` = 'native' (npm, fast quantized) | 'wasm' (binary, slow fp32) —
749
754
  // the extension uses it to advise the far-faster native gateway.
750
755
  stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL, runtime: stt.runtime, dtype: stt.dtype },
@@ -2208,6 +2213,8 @@ function readRunHint(header, legacy) {
2208
2213
  return Object.keys(out).length ? out : null;
2209
2214
  }
2210
2215
 
2216
+ let bridgeSupervisor = null; // the running gateway's bridge controller (bridge-supervisor.js), for /health and shutdown
2217
+
2211
2218
  export function start(cfg = loadConfig()) {
2212
2219
  installTimestampedConsole(); // every gateway log line gets a clock — before anything logs
2213
2220
  ensureGatewayToken(); // M2: load/create the admin-route token (best-effort)
@@ -2234,13 +2241,11 @@ export function start(cfg = loadConfig()) {
2234
2241
  server.listen(cfg.port, cfg.host, () => {
2235
2242
  console.log(`ChatPanel Privacy Gateway v${VERSION} on http://${cfg.host}:${cfg.port}`);
2236
2243
  console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
2237
- // U3: report the bridge at startup so the operator sees the unified picture without
2238
- // running anything. Detect only never force-spawn a managed service. Best-effort and
2239
- // non-fatal: a probe failure just logs "not detected".
2240
- import('./local-status.js')
2241
- .then((m) => m.bridgePresenceNote())
2242
- .then((note) => console.log(` bridge : ${note}`))
2243
- .catch(() => {});
2244
+ // THE GATEWAY CARRIES THE BRIDGE (bridge-supervisor.js): adopt one that already answers
2245
+ // (the desktop's, a standalone, launchd's), else start the embedded copy as a child and
2246
+ // keep it up. One install for the user; two processes on purpose. Best-effort and
2247
+ // non-fatal: the gateway runs without a bridge, it just has no local agents.
2248
+ ensureBridge(cfg).then((c) => { bridgeSupervisor = c; }).catch((e) => console.log(` bridge : not supervised (${e?.message || e})`));
2244
2249
  console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
2245
2250
  ? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
2246
2251
  // M7: a non-loopback bind exposes the gateway on the LAN, where the per-request
@@ -2258,7 +2263,7 @@ export function start(cfg = loadConfig()) {
2258
2263
  .then((r) => { if (r?.ok) console.log(` warm : seeded ${r.ingested} records from ${r.file}`); })
2259
2264
  .catch(() => {});
2260
2265
  }
2261
- const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
2266
+ const shutdown = () => { ner?.stop(); entitlement.stop(); bridgeSupervisor?.stop(); server.close(() => process.exit(0)); };
2262
2267
  process.on('SIGINT', shutdown);
2263
2268
  process.on('SIGTERM', shutdown);
2264
2269
  return server;