@chatpanel/gateway 0.6.74 → 0.6.76

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/gateway",
3
- "version": "0.6.74",
3
+ "version": "0.6.76",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=18"
28
28
  },
29
29
  "dependencies": {
30
- "@chatpanel/pii": "^0.7.1",
30
+ "@chatpanel/pii": "^0.7.2",
31
31
  "@huggingface/transformers": "^4.2.0",
32
32
  "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
33
33
  "phonemizer": "^1.2.1"
package/src/config.js CHANGED
@@ -57,6 +57,14 @@ export const DEFAULTS = {
57
57
  // Bearer token for the bridge's privileged /chat route. Empty = read the
58
58
  // per-install token from ~/.chatpanel/bridge-token.
59
59
  token: '',
60
+ // What a CLI agent driven THROUGH the gateway may do — the same three modes the
61
+ // extension offers per agent: 'default' (read-only, asks before writing),
62
+ // 'acceptEdits' (edits files in its workspace on its own), 'bypassPermissions' (full
63
+ // access and shell). Absent, the bridge ran every gateway turn read-only and told the
64
+ // model to "set Permissions" in a settings page the desktop did not have.
65
+ permissionMode: 'default',
66
+ // Where the agent works. Empty = the bridge's ChatPanel workspace.
67
+ workingDir: '',
60
68
  },
61
69
 
62
70
  upstreams: {
@@ -46,7 +46,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
46
46
  backend: cfg.backend,
47
47
  // Strip per-destination apiKey (write-only).
48
48
  destinations: (Array.isArray(cfg.destinations) ? cfg.destinations : []).map((d) => { const { apiKey, ...rest } = d; return { ...rest, hasKey: !!apiKey }; }),
49
- bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token },
49
+ bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token, permissionMode: cfg.bridge?.permissionMode || 'default', workingDir: cfg.bridge?.workingDir || '' },
50
50
  upstreams: cfg.upstreams,
51
51
  redaction: {
52
52
  tier: cfg.redaction?.tier,
@@ -98,6 +98,18 @@ export function isSelfDestination(d, cfg = {}) {
98
98
  return loop && port === (Number(cfg.port) || 4320);
99
99
  }
100
100
 
101
+ export const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions'];
102
+
103
+ /** The per-turn options a gateway-driven agent receives — the bridge's own vocabulary. */
104
+ export function bridgeAgentOptions(cfg, extra = {}) {
105
+ const b = cfg?.bridge || {};
106
+ return {
107
+ ...(PERMISSION_MODES.includes(b.permissionMode) && b.permissionMode !== 'default' ? { permissionMode: b.permissionMode } : {}),
108
+ ...(typeof b.workingDir === 'string' && b.workingDir.trim() ? { workingDir: b.workingDir.trim() } : {}),
109
+ ...extra,
110
+ };
111
+ }
112
+
101
113
  export function applyConfigPatch(cfg, patch = {}) {
102
114
  if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
103
115
  if (Array.isArray(patch.destinations)) {
@@ -122,6 +134,8 @@ export function applyConfigPatch(cfg, patch = {}) {
122
134
  if (patch.bridge && typeof patch.bridge === 'object') {
123
135
  if (typeof patch.bridge.url === 'string') cfg.bridge.url = patch.bridge.url;
124
136
  if (typeof patch.bridge.agent === 'string') cfg.bridge.agent = patch.bridge.agent;
137
+ if (PERMISSION_MODES.includes(patch.bridge.permissionMode)) cfg.bridge.permissionMode = patch.bridge.permissionMode;
138
+ if (typeof patch.bridge.workingDir === 'string') cfg.bridge.workingDir = patch.bridge.workingDir.trim().slice(0, 1024);
125
139
  }
126
140
  // api backend: where redacted traffic is forwarded (the client still picks the
127
141
  // model + sends its own key).
package/src/server.js CHANGED
@@ -47,7 +47,7 @@ import { rawOrtAvailable } from './ort.js';
47
47
  import * as ttsVoices from './tts-voices.js';
48
48
  import { resolveTtsVoice } from './tts-voice-resolve.js';
49
49
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
50
- import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
50
+ import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath, bridgeAgentOptions } from './configstore.js';
51
51
  import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
52
52
  import { makeAccessEvent } from './observability.js';
53
53
  import { createPersistentAccessLog } from './access-log-store.js';
@@ -56,7 +56,7 @@ import * as openai from './openai.js';
56
56
  import * as responses from './responses.js';
57
57
  import * as anthropic from './anthropic.js';
58
58
 
59
- export const VERSION = '0.6.74';
59
+ export const VERSION = '0.6.76';
60
60
 
61
61
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
62
62
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -401,7 +401,7 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
401
401
  // redaction in the main handler), so toTurn() carried it here — nothing to add.
402
402
  let resp;
403
403
  try {
404
- resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
404
+ resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options: bridgeAgentOptions(cfg), signal: undefined });
405
405
  } catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
406
406
  s.reader = resp.body.getReader();
407
407
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
@@ -453,14 +453,23 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
453
453
  const shaper = shaperFor(kind, body?.model || agent);
454
454
  const token = readBridgeToken(cfg.bridge.token);
455
455
  const ac = new AbortController();
456
- req.on('close', () => ac.abort());
456
+ // STOP MUST REACH THE AGENT. The client pressing Stop aborts ITS request; the bridge only
457
+ // kills the CLI when the gateway's request to it goes away, and that happened only if the
458
+ // gateway noticed. It listened on `req 'close'`, which on this Node fires when the REQUEST
459
+ // is complete — the body was read long ago — not when the socket is cut mid-response. So
460
+ // Stop in the desktop stopped nothing: Codex wrote its whole essay to nobody (measured:
461
+ // the process outlived the abort by the length of the answer). The RESPONSE closing is
462
+ // the signal for a severed connection; a response that finished normally is not a stop.
463
+ res.on('close', () => { if (!res.writableFinished) ac.abort(); });
457
464
 
458
465
  // The model half of `claude/opus`, handed to the CLI as its `--model`. Absent for a bare
459
466
  // agent id, which leaves the agent on its own default exactly as before.
460
467
  const { agentModel } = parseAgentModel(body?.model, cfg);
461
468
  const turn = {
462
469
  bridgeUrl: await resolveBridgeUrl(cfg), agent, token, messages, system, signal: ac.signal,
463
- ...(agentModel ? { options: { model: agentModel } } : {}),
470
+ // Permissions and working directory from the gateway's config (the desktop's Settings →
471
+ // Engine → Agents), plus the model half of `claude/opus` when the caller named one.
472
+ options: bridgeAgentOptions(cfg, agentModel ? { model: agentModel } : {}),
464
473
  };
465
474
 
466
475
  if (!wantStream) {
@@ -1764,7 +1773,7 @@ export function createGateway(cfg = loadConfig()) {
1764
1773
  if (!redactionOff) await ensureNer(cfg);
1765
1774
  const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
1766
1775
  const ac = new AbortController();
1767
- req.on('close', () => ac.abort());
1776
+ res.on('close', () => { if (!res.writableFinished) ac.abort(); });
1768
1777
  const rd0 = trace ? trace.clock() : 0;
1769
1778
  // Redact at the configured tier for everyone (free users get name/org
1770
1779
  // redaction within their allowance); the custom dictionary stays capped for
@@ -1799,7 +1808,16 @@ export function createGateway(cfg = loadConfig()) {
1799
1808
  // AFTER redaction so the note isn't itself redacted. Covers BOTH the API
1800
1809
  // forward and the relay (which reads system from this same body).
1801
1810
  if (!redactionOff && Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
1802
- r.adapter.injectSystemNote(body, placeholderToolNote({ toolData: cfg.tools?.toolData }));
1811
+ // A relayed CLI agent brings tools of its own that run PAST this proxy and get the
1812
+ // placeholder literally; an API model has only the tools in this request. The note
1813
+ // has to say which is which, so the destination is looked up here — the same
1814
+ // resolution as below, on the same hint, just earlier. No destination means the
1815
+ // legacy bridge path, which is an agent.
1816
+ const early = resolveDestination(body?.model, cfg, r.kind, {
1817
+ destination: String(req.headers['x-chatpanel-destination'] || body?.chatpanel?.destination || '').trim(),
1818
+ });
1819
+ const ownTools = !(early && early.type === 'api');
1820
+ r.adapter.injectSystemNote(body, placeholderToolNote({ toolData: cfg.tools?.toolData, ownTools }));
1803
1821
  }
1804
1822
  outBody = Buffer.from(JSON.stringify(body), 'utf8');
1805
1823
  }