@chatpanel/bridge 0.11.3 → 0.11.4
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/src/channels/adapters/telegram.js +1 -1
- package/src/channels/pairing.js +20 -5
- 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.4",
|
|
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": [
|
|
@@ -99,7 +99,7 @@ export function startTelegram({
|
|
|
99
99
|
// no six digits thumbed in from another screen. Bare /start is still the greeting.
|
|
100
100
|
const pairCode = name === 'pair' || (name === 'start' && args) ? args : '';
|
|
101
101
|
if (pairCode) {
|
|
102
|
-
const r = pairing.redeem(id, pairCode);
|
|
102
|
+
const r = pairing.redeem(id, pairCode, { label: norm.from?.name || '' });
|
|
103
103
|
await savePairing();
|
|
104
104
|
return void send(norm.chatId, r.ok
|
|
105
105
|
? `✅ paired (reach: ${r.reach}). Send me anything — I'll run it on your machine.`
|
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/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.4';
|
|
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
|
|