@chatpanel/bridge 0.11.3 → 0.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/sync-channels.mjs +1 -1
- package/src/channels/adapters/telegram.js +15 -8
- package/src/channels/gateway.js +106 -0
- package/src/channels/pairing.js +20 -5
- package/src/channels/service.js +34 -4
- package/src/channels/stream.js +20 -0
- package/src/engines/claude.js +44 -2
- package/src/server.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
24
24
|
// The service and everything under it. `config.js` is deliberately absent: it resolves paths
|
|
25
25
|
// and reads the bridge token from disk, and the bridge already knows both.
|
|
26
26
|
const FILES = [
|
|
27
|
-
'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js',
|
|
27
|
+
'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js', 'gateway.js',
|
|
28
28
|
'eventlog.js', 'service.js', 'adapters/telegram.js',
|
|
29
29
|
];
|
|
30
30
|
|
|
@@ -46,6 +46,12 @@ export function startTelegram({
|
|
|
46
46
|
savePairing = async () => {},
|
|
47
47
|
appender, // createEventLog(...) or nullEventLog()
|
|
48
48
|
agent = 'claude',
|
|
49
|
+
// Which transport answers, and with what. `bridge` runs a CLI agent on this machine;
|
|
50
|
+
// `gateway` reaches any destination the user configured there — an API provider or, via the
|
|
51
|
+
// gateway's own bridge backend, the same CLI agents. The adapter must not be able to tell
|
|
52
|
+
// which it has: both expose chat()/cancel() and fold into one reply state.
|
|
53
|
+
transport = bridge,
|
|
54
|
+
model = '',
|
|
49
55
|
system = '',
|
|
50
56
|
redact = { tier: 'basic' },
|
|
51
57
|
privacy = 'standard',
|
|
@@ -99,7 +105,7 @@ export function startTelegram({
|
|
|
99
105
|
// no six digits thumbed in from another screen. Bare /start is still the greeting.
|
|
100
106
|
const pairCode = name === 'pair' || (name === 'start' && args) ? args : '';
|
|
101
107
|
if (pairCode) {
|
|
102
|
-
const r = pairing.redeem(id, pairCode);
|
|
108
|
+
const r = pairing.redeem(id, pairCode, { label: norm.from?.name || '' });
|
|
103
109
|
await savePairing();
|
|
104
110
|
return void send(norm.chatId, r.ok
|
|
105
111
|
? `✅ paired (reach: ${r.reach}). Send me anything — I'll run it on your machine.`
|
|
@@ -113,7 +119,7 @@ export function startTelegram({
|
|
|
113
119
|
}
|
|
114
120
|
if (name === 'stop') {
|
|
115
121
|
const st = chatState(norm.chatId);
|
|
116
|
-
const ok = await
|
|
122
|
+
const ok = await transport.cancel(st.runId, { baseUrl, token });
|
|
117
123
|
st.runId = null;
|
|
118
124
|
return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
|
|
119
125
|
}
|
|
@@ -151,15 +157,16 @@ export function startTelegram({
|
|
|
151
157
|
const messages = [...st.history, { role: 'user', content: redacted }];
|
|
152
158
|
|
|
153
159
|
try {
|
|
154
|
-
const finalState = await
|
|
155
|
-
{ agent, system, messages, images, options: { reach } },
|
|
160
|
+
const finalState = await transport.chat(
|
|
161
|
+
{ agent, model, system, messages, images, options: { reach } },
|
|
156
162
|
{
|
|
157
163
|
baseUrl, token, signal,
|
|
158
164
|
onEvent: (ev, state) => {
|
|
159
|
-
if (
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
165
|
+
if (state.runId) st.runId = state.runId;
|
|
166
|
+
// Driven by the folded STATE, not by an event's `type`: the bridge emits
|
|
167
|
+
// {type:'delta'} and the gateway emits OpenAI chunks, and this has to work on both.
|
|
168
|
+
// The `first !== shown` guard below makes a no-text event a no-op anyway.
|
|
169
|
+
if (replyId && (state.done || gate.ready())) {
|
|
163
170
|
const text = outboundText(state.text, st.vault, { privacy });
|
|
164
171
|
const first = splitForTelegram(text || '…')[0];
|
|
165
172
|
if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/gateway.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// Gateway backend: reach ANY configured destination — an API provider or a CLI agent —
|
|
11
|
+
// through the ChatPanel gateway's OpenAI-compatible endpoint.
|
|
12
|
+
//
|
|
13
|
+
// channel → POST http://127.0.0.1:4320/v1/chat/completions { model, messages, stream }
|
|
14
|
+
//
|
|
15
|
+
// WHY THIS EXISTS ALONGSIDE bridge.js. The bridge runs CLI agents, and that is all it can
|
|
16
|
+
// answer with — so a phone could only ever talk to Claude Code or Codex, never to the OpenAI,
|
|
17
|
+
// Anthropic or local-model endpoints the same user already configured. Those live in the
|
|
18
|
+
// gateway, which persists its `destinations` (0600, keys included) and routes a model id to
|
|
19
|
+
// an API provider OR back to the bridge for an agent. So the gateway is the superset, and
|
|
20
|
+
// pointing a channel at it is what makes "answer from my phone" work with every target the
|
|
21
|
+
// user has rather than a subset.
|
|
22
|
+
//
|
|
23
|
+
// NO NEW SECRET. The alternative was teaching the bridge to hold provider API keys, which
|
|
24
|
+
// would have put them on disk a second time, in a second format, with a second thing to
|
|
25
|
+
// rotate. The gateway already holds them and already guards them; this borrows the routing
|
|
26
|
+
// instead of copying the credentials.
|
|
27
|
+
//
|
|
28
|
+
// The /v1 data plane is deliberately unauthenticated for local clients — that is the
|
|
29
|
+
// gateway's product — so there is no token to present here, and none to leak.
|
|
30
|
+
|
|
31
|
+
import { parseSse, foldOpenAiEvent, initialState } from './stream.js';
|
|
32
|
+
|
|
33
|
+
// Per-turn aborts, so /stop can cancel a gateway turn the way it cancels a bridge run. The
|
|
34
|
+
// bridge hands out a run id for this; OpenAI's API has no such handle, so we mint one and
|
|
35
|
+
// keep the controller behind it rather than leaving /stop silently broken on this transport.
|
|
36
|
+
const inflight = new Map();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drive one turn against a gateway destination. Same shape as bridge.chat so the adapter does
|
|
40
|
+
* not know which transport it has: streams events to onEvent(ev, state) and returns the folded
|
|
41
|
+
* final state.
|
|
42
|
+
*/
|
|
43
|
+
export async function chat({ model = '', system = '', messages = [], options = {} }, {
|
|
44
|
+
baseUrl, signal, onEvent = () => {},
|
|
45
|
+
} = {}) {
|
|
46
|
+
const runId = `gw_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
47
|
+
const ac = new AbortController();
|
|
48
|
+
inflight.set(runId, ac);
|
|
49
|
+
// The caller's signal (the poll loop shutting down) must still abort the turn.
|
|
50
|
+
const relay = () => ac.abort();
|
|
51
|
+
signal?.addEventListener?.('abort', relay, { once: true });
|
|
52
|
+
|
|
53
|
+
let state = initialState();
|
|
54
|
+
const emit = (ev) => { state = foldOpenAiEvent(state, ev); onEvent(ev, state); };
|
|
55
|
+
emit({ type: 'run', id: runId });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`${String(baseUrl).replace(/\/$/, '')}/v1/chat/completions`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
stream: true,
|
|
64
|
+
messages: [
|
|
65
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
66
|
+
...messages.map((m) => ({ role: m.role, content: String(m.content ?? '') })),
|
|
67
|
+
],
|
|
68
|
+
// Carried through so a capped phone's reach still reaches the router. The gateway
|
|
69
|
+
// ignores what it does not know, which is what keeps an older gateway working.
|
|
70
|
+
...(options?.reach ? { chatpanel: { reach: options.reach } } : {}),
|
|
71
|
+
}),
|
|
72
|
+
signal: ac.signal,
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok || !res.body) {
|
|
75
|
+
const detail = await res.text().catch(() => '');
|
|
76
|
+
throw new Error(`gateway ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
let buffer = '';
|
|
79
|
+
const decoder = new TextDecoder();
|
|
80
|
+
for await (const chunk of res.body) {
|
|
81
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
82
|
+
const { events, rest } = parseSse(buffer);
|
|
83
|
+
buffer = rest;
|
|
84
|
+
for (const ev of events) emit(ev);
|
|
85
|
+
}
|
|
86
|
+
// OpenAI streams end with `data: [DONE]`, which is not JSON and is dropped by parseSse —
|
|
87
|
+
// so a stream that ended cleanly still has to be marked done here.
|
|
88
|
+
if (!state.done) state = { ...state, done: true };
|
|
89
|
+
return state;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (ac.signal.aborted) return { ...state, done: true };
|
|
92
|
+
return { ...state, done: true, error: e?.message || String(e) };
|
|
93
|
+
} finally {
|
|
94
|
+
inflight.delete(runId);
|
|
95
|
+
signal?.removeEventListener?.('abort', relay);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Stop a run by the id emitted as the first {type:'run'} event. Best-effort, like the bridge's. */
|
|
100
|
+
export async function cancel(runId) {
|
|
101
|
+
const ac = runId && inflight.get(runId);
|
|
102
|
+
if (!ac) return false;
|
|
103
|
+
ac.abort();
|
|
104
|
+
inflight.delete(runId);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
package/src/channels/pairing.js
CHANGED
|
@@ -23,6 +23,17 @@ export { REACH };
|
|
|
23
23
|
|
|
24
24
|
// 6 digits: enough entropy for a short-lived, single-use enrollment code shown on a screen,
|
|
25
25
|
// short enough to thumb into a phone. It is NOT a password — it expires and burns on first use.
|
|
26
|
+
// A display name from a remote platform is untrusted text that lands in the owner's settings
|
|
27
|
+
// screen: strip control characters and bidi overrides (which can make one name render as
|
|
28
|
+
// another), collapse whitespace, and cap it. The UI escapes too — this is the other half.
|
|
29
|
+
function cleanLabel(value) {
|
|
30
|
+
return String(value || '')
|
|
31
|
+
.replace(/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, '')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim()
|
|
34
|
+
.slice(0, 48);
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
function sixDigits(randomInt) {
|
|
27
38
|
return String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
28
39
|
}
|
|
@@ -55,20 +66,24 @@ export function createPairingStore(state = {}, {
|
|
|
55
66
|
pending.set(code, { at: now(), ttlMs });
|
|
56
67
|
return code;
|
|
57
68
|
},
|
|
58
|
-
/** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'.
|
|
59
|
-
|
|
69
|
+
/** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'.
|
|
70
|
+
* `label` is whatever the platform calls the person (a Telegram first name or @handle) —
|
|
71
|
+
* stored so the owner's screen can say WHICH phone it just enrolled. An opaque
|
|
72
|
+
* 'telegram:789795542' is not something anyone can recognise, and the whole point of the
|
|
73
|
+
* list is deciding whether to revoke one. Display only: authorization is by actorId. */
|
|
74
|
+
redeem(actorId, code, { reach = 'trusted', label = '' } = {}) {
|
|
60
75
|
prune();
|
|
61
76
|
const c = String(code || '').trim();
|
|
62
77
|
if (!pending.has(c)) return { ok: false, reason: 'unknown or expired code' };
|
|
63
78
|
if (!REACH.includes(reach)) return { ok: false, reason: `unknown reach '${reach}'` };
|
|
64
79
|
pending.delete(c);
|
|
65
|
-
paired.set(actorId, { reach, at: now() });
|
|
80
|
+
paired.set(actorId, { reach, at: now(), label: cleanLabel(label) });
|
|
66
81
|
return { ok: true, reach };
|
|
67
82
|
},
|
|
68
83
|
/** Bootstrap without a code — for an operator-supplied allow list. Explicit, not silent. */
|
|
69
|
-
allow(actorId, { reach = 'trusted' } = {}) {
|
|
84
|
+
allow(actorId, { reach = 'trusted', label = '' } = {}) {
|
|
70
85
|
if (!REACH.includes(reach)) throw new Error(`unknown reach '${reach}'`);
|
|
71
|
-
paired.set(actorId, { reach, at: now() });
|
|
86
|
+
paired.set(actorId, { reach, at: now(), label: cleanLabel(label) });
|
|
72
87
|
},
|
|
73
88
|
revoke(actorId) { return paired.delete(actorId); },
|
|
74
89
|
isPaired(actorId) { return paired.has(actorId); },
|
package/src/channels/service.js
CHANGED
|
@@ -27,11 +27,22 @@
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { readFile, writeFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
29
29
|
import { createPairingStore } from './pairing.js';
|
|
30
|
+
import * as bridgeTransport from './bridge.js';
|
|
31
|
+
import * as gateway from './gateway.js';
|
|
32
|
+
|
|
33
|
+
// The gateway's fixed local port (see chatpanel-gateway: 4319 bridge / 4320 gateway).
|
|
34
|
+
const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4320';
|
|
30
35
|
import { createEventLog } from './eventlog.js';
|
|
31
36
|
import { startTelegram } from './adapters/telegram.js';
|
|
32
37
|
|
|
33
38
|
const TELEGRAM_API = 'https://api.telegram.org';
|
|
34
|
-
|
|
39
|
+
// `agent` routes through the bridge (a CLI on this machine). `model` routes through the
|
|
40
|
+
// gateway, which reaches every destination the user configured there — API providers AND, via
|
|
41
|
+
// its own bridge backend, the same agents. They are mutually exclusive: update() clears one
|
|
42
|
+
// when the other is set, because "which thing answers" is one choice, not two.
|
|
43
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
44
|
+
agent: 'claude', model: '', gatewayUrl: DEFAULT_GATEWAY_URL, privacy: 'standard', tier: 'basic',
|
|
45
|
+
});
|
|
35
46
|
|
|
36
47
|
// Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
|
|
37
48
|
// come back on its own — a channel nobody is watching is exactly the one that must self-heal —
|
|
@@ -120,9 +131,13 @@ export function createChannelService({
|
|
|
120
131
|
function spawnLoop(botToken) {
|
|
121
132
|
controller = new AbortController();
|
|
122
133
|
running = true;
|
|
134
|
+
// One choice, resolved here so the adapter never has to ask "which kind am I?".
|
|
135
|
+
const viaGateway = !!settings.model;
|
|
123
136
|
const done = startAdapter({
|
|
124
137
|
botToken,
|
|
125
|
-
|
|
138
|
+
transport: viaGateway ? gateway : bridgeTransport,
|
|
139
|
+
model: viaGateway ? settings.model : '',
|
|
140
|
+
baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
|
|
126
141
|
token: bridge.token,
|
|
127
142
|
pairing,
|
|
128
143
|
savePairing,
|
|
@@ -232,7 +247,13 @@ export function createChannelService({
|
|
|
232
247
|
async update(patch = {}) {
|
|
233
248
|
await load();
|
|
234
249
|
const next = { ...settings };
|
|
235
|
-
|
|
250
|
+
for (const k of ['agent', 'model', 'gatewayUrl', 'privacy', 'tier', 'system']) {
|
|
251
|
+
if (patch[k] != null) next[k] = patch[k];
|
|
252
|
+
}
|
|
253
|
+
// Picking one target unpicks the other. Without this a stale `model` would silently win
|
|
254
|
+
// over the agent the user just chose, and the screen would disagree with the machine.
|
|
255
|
+
if (patch.agent != null && patch.model == null) next.model = '';
|
|
256
|
+
if (patch.model) next.agent = '';
|
|
236
257
|
settings = next;
|
|
237
258
|
await saveSettings();
|
|
238
259
|
// Settings are read when the loop starts, so a change only lands on a restart.
|
|
@@ -273,7 +294,16 @@ export function createChannelService({
|
|
|
273
294
|
bot: bot ? { ...bot } : null,
|
|
274
295
|
error: lastError,
|
|
275
296
|
paired: pairing.list(),
|
|
276
|
-
settings: {
|
|
297
|
+
settings: {
|
|
298
|
+
agent: settings.agent,
|
|
299
|
+
model: settings.model || '',
|
|
300
|
+
gatewayUrl: settings.gatewayUrl || DEFAULT_GATEWAY_URL,
|
|
301
|
+
privacy: settings.privacy,
|
|
302
|
+
tier: settings.tier,
|
|
303
|
+
},
|
|
304
|
+
// Which transport a message will actually take, so a screen can say so rather than
|
|
305
|
+
// inferring it from two fields and getting it wrong.
|
|
306
|
+
via: settings.model ? 'gateway' : 'bridge',
|
|
277
307
|
};
|
|
278
308
|
},
|
|
279
309
|
};
|
package/src/channels/stream.js
CHANGED
|
@@ -35,6 +35,26 @@ export function foldEvent(state, ev) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Fold one OpenAI-style chunk (what the gateway streams) into the same reply state, so a
|
|
40
|
+
* caller cannot tell which transport it has. Shares parseSse: `data: [DONE]` is not JSON and
|
|
41
|
+
* is dropped there, which is why the transport marks `done` itself when the stream ends.
|
|
42
|
+
*/
|
|
43
|
+
export function foldOpenAiEvent(state, ev) {
|
|
44
|
+
if (ev?.type === 'run') return { ...state, runId: ev.id || state.runId };
|
|
45
|
+
// An error can arrive as a streamed frame rather than an HTTP status.
|
|
46
|
+
if (ev?.error) return { ...state, done: true, error: ev.error.message || String(ev.error) };
|
|
47
|
+
const choice = ev?.choices?.[0];
|
|
48
|
+
let next = state;
|
|
49
|
+
const delta = choice?.delta?.content;
|
|
50
|
+
if (typeof delta === 'string' && delta) next = { ...next, text: next.text + delta };
|
|
51
|
+
// Non-streaming replies (a gateway destination that cannot stream) carry the whole message.
|
|
52
|
+
const whole = choice?.message?.content;
|
|
53
|
+
if (!next.text && typeof whole === 'string' && whole) next = { ...next, text: whole };
|
|
54
|
+
if (choice?.finish_reason) next = { ...next, done: true };
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
/**
|
|
39
59
|
* Pull complete SSE events out of a growing buffer. Returns the parsed events and the
|
|
40
60
|
* UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
|
package/src/engines/claude.js
CHANGED
|
@@ -23,6 +23,7 @@ import { summarizeCliError } from '../cli-errors.js';
|
|
|
23
23
|
import { killOnAbort } from '../proc.js';
|
|
24
24
|
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
25
25
|
import { displayPath, resolveWorkdir } from '../workdir.js';
|
|
26
|
+
import { connectorsFor } from '../connectors.js';
|
|
26
27
|
|
|
27
28
|
// Write base64 data-URL images to temp files. Claude Code reads them with its
|
|
28
29
|
// Read tool (which feeds images to the model as vision), so we just reference the
|
|
@@ -69,6 +70,44 @@ const CHANNEL_ALLOW = Object.freeze({
|
|
|
69
70
|
// a capped turn can never reach shell, writes, or a network egress even via an MCP alias.
|
|
70
71
|
const CHANNEL_DENY = Object.freeze(['Bash', 'Edit', 'Write', 'WebFetch', 'WebSearch']);
|
|
71
72
|
|
|
73
|
+
// ChatPanel's OWN history/memory tools, which arrive as a user-configured MCP server rather
|
|
74
|
+
// than as built-ins — so the tier's allow-list, which only ever named built-ins, left them out
|
|
75
|
+
// and a headless channel turn had no way to approve them. The phone got "the search tools need
|
|
76
|
+
// your permission and it hasn't been granted yet", which is the one question a texting-your-
|
|
77
|
+
// machine product must never ask: nobody is at the keyboard.
|
|
78
|
+
//
|
|
79
|
+
// Granting them changes nothing about the posture. `trusted` already allows Read/Grep/Glob
|
|
80
|
+
// across the machine, so reading the user's own meetings and notes is narrower than what is
|
|
81
|
+
// already permitted, and egress stays cut — the only place an answer can go is the reply to the
|
|
82
|
+
// phone that is already paired. The MUTATING half is a different question and stays denied: a
|
|
83
|
+
// prompt-injected message must not be able to rewrite what the assistant remembers about you.
|
|
84
|
+
const CHANNEL_MCP_READ = Object.freeze([
|
|
85
|
+
'search_history', 'smart_search', 'get_record', 'list_history', 'find_related', 'recall',
|
|
86
|
+
'list_skills', 'open_skill', 'read_skill_file',
|
|
87
|
+
]);
|
|
88
|
+
const CHANNEL_MCP_WRITE = Object.freeze(['remember', 'forget']);
|
|
89
|
+
// Which tiers get the read half. `device` is "conversational only" and stays that way.
|
|
90
|
+
const CHANNEL_MCP_BY_REACH = Object.freeze({ device: Object.freeze([]), trusted: CHANNEL_MCP_READ });
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `mcp__<server>__<tool>` entries for the ChatPanel MCP servers this agent actually has
|
|
94
|
+
* configured. Names come from the agent's own config (connectors.js) because the user chooses
|
|
95
|
+
* them — 'chatpanel', 'chatpanel-history', whatever they typed. Anything not matching is left
|
|
96
|
+
* alone: a capped turn must not be handed a stranger's MCP server because it was present.
|
|
97
|
+
*/
|
|
98
|
+
export function channelMcpTools(reach, connectors = []) {
|
|
99
|
+
const reads = CHANNEL_MCP_BY_REACH[reach] || CHANNEL_MCP_BY_REACH.device;
|
|
100
|
+
const servers = (connectors || []).filter((n) => typeof n === 'string' && /^chatpanel/i.test(n));
|
|
101
|
+
const allow = [];
|
|
102
|
+
const deny = [];
|
|
103
|
+
for (const server of servers) {
|
|
104
|
+
for (const tool of reads) allow.push(`mcp__${server}__${tool}`);
|
|
105
|
+
// Denied on every capped tier, including the ones that get no reads.
|
|
106
|
+
for (const tool of CHANNEL_MCP_WRITE) deny.push(`mcp__${server}__${tool}`);
|
|
107
|
+
}
|
|
108
|
+
return { allow, deny };
|
|
109
|
+
}
|
|
110
|
+
|
|
72
111
|
/**
|
|
73
112
|
* Tool policy for a channel/remote caller. Returns { allow, deny } for a capped tier, or null
|
|
74
113
|
* when reach is absent or 'any' (no cap — the existing permissionMode logic applies). An unknown
|
|
@@ -309,8 +348,11 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
309
348
|
// permissionMode behavior unchanged.
|
|
310
349
|
const channelPolicy = channelToolPolicy(options.reach);
|
|
311
350
|
if (channelPolicy) {
|
|
312
|
-
|
|
313
|
-
|
|
351
|
+
// Read from the agent's own config each turn rather than cached at boot: a server the user
|
|
352
|
+
// added five minutes ago should work on the next message, not the next restart.
|
|
353
|
+
const own = channelMcpTools(options.reach, await connectorsFor('claude').catch(() => []));
|
|
354
|
+
args.push('--allowedTools', ...channelPolicy.allow, ...mcpAllow, ...own.allow);
|
|
355
|
+
args.push('--disallowedTools', ...channelPolicy.deny, ...own.deny);
|
|
314
356
|
}
|
|
315
357
|
// Gate writes/shell behind the chosen mode; otherwise restrict to read-only
|
|
316
358
|
// tools so headless runs never block on an approval prompt. The relayed browser
|
package/src/server.js
CHANGED
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
68
68
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
69
69
|
// this drifts from package.json, so the two can't silently diverge.
|
|
70
|
-
const VERSION = '0.11.
|
|
70
|
+
const VERSION = '0.11.5';
|
|
71
71
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
72
72
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
73
73
|
|