@chatpanel/gateway 0.6.72 → 0.6.74
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/bin/chatpanel-gateway.js +9 -1
- package/package.json +1 -1
- package/src/bridge.js +79 -30
- package/src/mcp-cli.js +113 -0
- package/src/server.js +1 -1
package/bin/chatpanel-gateway.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// chatpanel-gateway start the gateway (foreground)
|
|
5
5
|
// chatpanel-gateway mcp stdio MCP server exposing warm history as tools
|
|
6
|
+
// chatpanel-gateway tools list the same tools, from a shell — names and one-liners
|
|
7
|
+
// chatpanel-gateway tools schema <tool> one tool's full schema
|
|
8
|
+
// chatpanel-gateway call <tool> '<json>' run one tool (exit 0 ok · 1 tool error · 2 usage)
|
|
6
9
|
// chatpanel-gateway local show the local runtime — bridge + gateway, one view
|
|
7
10
|
// chatpanel-gateway connect point your CLI agents (Codex, Claude Code, …) at this server
|
|
8
11
|
// chatpanel-gateway --install register login auto-start + start now
|
|
@@ -21,6 +24,11 @@ try {
|
|
|
21
24
|
// server.js (which would open a second handle on the warm SQLite store).
|
|
22
25
|
const { runMcpServer } = await import('../src/mcp.js');
|
|
23
26
|
await runMcpServer();
|
|
27
|
+
} else if (arg === 'tools' || arg === 'call') {
|
|
28
|
+
// MCP2CLI: the MCP tools as shell verbs, for agents that only have a shell. Same
|
|
29
|
+
// in-process dispatcher as `mcp`, same no-server.js rule.
|
|
30
|
+
const { runMcpCli } = await import('../src/mcp-cli.js');
|
|
31
|
+
process.exit(await runMcpCli(process.argv.slice(2)));
|
|
24
32
|
} else if (arg === 'local') {
|
|
25
33
|
// Read-only unified view of both services. No server.js import — just HTTP probes.
|
|
26
34
|
const { localStatus, formatLocalStatus } = await import('../src/local-status.js');
|
|
@@ -52,7 +60,7 @@ try {
|
|
|
52
60
|
start();
|
|
53
61
|
break;
|
|
54
62
|
default:
|
|
55
|
-
console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|--install|--uninstall|--status|--version]`);
|
|
63
|
+
console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|tools list|tools schema <tool>|call <tool> '<json>'|local|connect|--install|--uninstall|--status|--version]`);
|
|
56
64
|
process.exit(2);
|
|
57
65
|
}
|
|
58
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.74",
|
|
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": {
|
package/src/bridge.js
CHANGED
|
@@ -58,14 +58,76 @@ export async function resolveBridgeUrl(cfg, { fallback, timeoutMs = 1500, now =
|
|
|
58
58
|
/** Test seam. */
|
|
59
59
|
export function resetBridgeResolution() { bridgeResolved = { at: 0, cfgUrl: '', url: '', fell: false }; }
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Once the bridge has REJECTED the configured token while the file held a different one,
|
|
62
|
+
// the file is what every later call presents. Set by `bridgeTokenRejected`, below.
|
|
63
|
+
let preferFileToken = false;
|
|
64
|
+
|
|
65
|
+
function fileToken(tokenPath) {
|
|
63
66
|
try {
|
|
64
67
|
if (existsSync(tokenPath)) return readFileSync(tokenPath, 'utf8').trim();
|
|
65
68
|
} catch { /* ignore */ }
|
|
66
69
|
return '';
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The bridge token: the config's value first, the file otherwise — until the bridge says
|
|
74
|
+
* the config's value is wrong.
|
|
75
|
+
*
|
|
76
|
+
* `bridge.token` in gateway.config.json is a COPY: the extension's Gateway tab writes its
|
|
77
|
+
* own setting there, and a bridge that regenerates its token leaves the copy behind. The
|
|
78
|
+
* desktop then relayed every Codex turn with the stale copy and got 403 from a bridge ten
|
|
79
|
+
* milliseconds away — an empty streaming bubble, forever. The file is written by the
|
|
80
|
+
* bridge itself, so when the configured token is rejected and the file differs, the file
|
|
81
|
+
* is tried once and, if it works, kept. Nothing is second-guessed before the bridge has
|
|
82
|
+
* actually said no: a pinned token on a loopback bridge stays a pinned token.
|
|
83
|
+
*/
|
|
84
|
+
export function readBridgeToken(cfgToken, tokenPath = DEFAULT_TOKEN_PATH) {
|
|
85
|
+
const file = fileToken(tokenPath);
|
|
86
|
+
if (!cfgToken) return file;
|
|
87
|
+
if (preferFileToken && file && file !== cfgToken) return file;
|
|
88
|
+
return cfgToken;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Called with the token the bridge just refused. Returns the file's token when it is a
|
|
93
|
+
* different one worth trying — and from then on `readBridgeToken` prefers it — or '' when
|
|
94
|
+
* there is nothing else to try.
|
|
95
|
+
*/
|
|
96
|
+
export function bridgeTokenRejected(rejected, tokenPath = DEFAULT_TOKEN_PATH) {
|
|
97
|
+
const file = fileToken(tokenPath);
|
|
98
|
+
if (!file || file === rejected) return '';
|
|
99
|
+
if (!preferFileToken) {
|
|
100
|
+
preferFileToken = true;
|
|
101
|
+
console.warn('[gateway] the bridge rejected bridge.token from gateway.config.json; using ~/.chatpanel/bridge-token (the bridge wrote it). Clear the config value to silence this.');
|
|
102
|
+
}
|
|
103
|
+
return file;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test seam. */
|
|
107
|
+
export function resetBridgeTokenPreference() { preferFileToken = false; }
|
|
108
|
+
|
|
109
|
+
const isAuthFailure = (status) => status === 401 || status === 403;
|
|
110
|
+
|
|
111
|
+
// One POST to /chat, retried once with the file's token when the bridge refuses the one
|
|
112
|
+
// it was given — the stale-copy case above.
|
|
113
|
+
async function postChat(bridgeUrl, token, body, signal, tokenPath) {
|
|
114
|
+
const send = (t) => fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'content-type': 'application/json', ...(t ? { authorization: `Bearer ${t}` } : {}) },
|
|
117
|
+
body,
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
// A caller hands over the token it resolved; once a rejection has flipped the
|
|
121
|
+
// preference, the file's token goes first here too.
|
|
122
|
+
const first = readBridgeToken(token, tokenPath);
|
|
123
|
+
let res = await send(first);
|
|
124
|
+
if (isAuthFailure(res.status)) {
|
|
125
|
+
const alt = bridgeTokenRejected(first, tokenPath);
|
|
126
|
+
if (alt) { await res.text().catch(() => {}); res = await send(alt); }
|
|
127
|
+
}
|
|
128
|
+
return res;
|
|
129
|
+
}
|
|
130
|
+
|
|
69
131
|
// Flatten an OpenAI/Anthropic message's content (string | parts[]) to plain text
|
|
70
132
|
// for the bridge, which expects string content. Image parts are dropped here (the
|
|
71
133
|
// bridge takes images separately; wire that later if needed).
|
|
@@ -88,19 +150,14 @@ export function toBridgeMessages(messages) {
|
|
|
88
150
|
|
|
89
151
|
// Open a bridge /chat stream WITH tool specs (pageTools), returning the raw fetch
|
|
90
152
|
// Response so the tool-relay can hold the reader open across the OpenAI round-trip.
|
|
91
|
-
export async function openBridgeChat({ bridgeUrl, agent, token, messages, system, specs, options, signal }) {
|
|
92
|
-
const res = await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
options: options || {},
|
|
100
|
-
...(Array.isArray(specs) && specs.length ? { pageTools: { specs } } : {}),
|
|
101
|
-
}),
|
|
102
|
-
signal,
|
|
103
|
-
});
|
|
153
|
+
export async function openBridgeChat({ bridgeUrl, agent, token, messages, system, specs, options, signal, tokenPath }) {
|
|
154
|
+
const res = await postChat(bridgeUrl, token, JSON.stringify({
|
|
155
|
+
agent,
|
|
156
|
+
messages: toBridgeMessages(messages),
|
|
157
|
+
system: system || '',
|
|
158
|
+
options: options || {},
|
|
159
|
+
...(Array.isArray(specs) && specs.length ? { pageTools: { specs } } : {}),
|
|
160
|
+
}), signal, tokenPath);
|
|
104
161
|
if (!res.ok || !res.body) {
|
|
105
162
|
const detail = await res.text().catch(() => '');
|
|
106
163
|
throw new Error(`bridge /chat HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
@@ -112,21 +169,13 @@ export async function openBridgeChat({ bridgeUrl, agent, token, messages, system
|
|
|
112
169
|
// onActivity(event) for everything else the agent reports — status lines, the working
|
|
113
170
|
// directory, tool calls, reasoning.
|
|
114
171
|
// of model text and returns the full (un-restored) text. Throws on bridge error.
|
|
115
|
-
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText, onActivity = null) {
|
|
116
|
-
const res = await
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
body: JSON.stringify({
|
|
123
|
-
agent,
|
|
124
|
-
messages: toBridgeMessages(messages),
|
|
125
|
-
system: system || '',
|
|
126
|
-
options: options || {},
|
|
127
|
-
}),
|
|
128
|
-
signal,
|
|
129
|
-
});
|
|
172
|
+
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal, tokenPath }, onText, onActivity = null) {
|
|
173
|
+
const res = await postChat(bridgeUrl, token, JSON.stringify({
|
|
174
|
+
agent,
|
|
175
|
+
messages: toBridgeMessages(messages),
|
|
176
|
+
system: system || '',
|
|
177
|
+
options: options || {},
|
|
178
|
+
}), signal, tokenPath);
|
|
130
179
|
|
|
131
180
|
if (!res.ok || !res.body) {
|
|
132
181
|
const detail = await res.text().catch(() => '');
|
package/src/mcp-cli.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// `chatpanel-gateway tools list | tools schema <tool> | call <tool> '<json>'` — the same
|
|
2
|
+
// tools the MCP server offers, reached from a shell.
|
|
3
|
+
//
|
|
4
|
+
// An MCP connection is a standing cost: every tool's schema sits in the agent's context on
|
|
5
|
+
// every turn, called or not. A shell verb costs nothing until it is used — `tools list`
|
|
6
|
+
// prints names and one-liners, `tools schema` pulls ONE schema at the moment of calling,
|
|
7
|
+
// `call` runs the tool. So an agent that has only a shell (a skill's `scripts/`, a CI job,
|
|
8
|
+
// a CLI with no MCP support) reaches history, memory and briefs at a cost that scales with
|
|
9
|
+
// use rather than with how many tools exist. The daemon's secrets never move: the CLI
|
|
10
|
+
// talks to the gateway exactly as the MCP server does, over loopback with the gateway
|
|
11
|
+
// token, and prints results.
|
|
12
|
+
//
|
|
13
|
+
// Exit codes, so a script can tell them apart: 0 the tool answered; 1 the tool itself
|
|
14
|
+
// reported an error (a real tool-level failure — the record was not found); 2 a usage or
|
|
15
|
+
// transport failure (bad JSON, unknown verb, gateway unreachable).
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { handleRpc } from './mcp.js';
|
|
19
|
+
|
|
20
|
+
const USAGE = [
|
|
21
|
+
'Usage:',
|
|
22
|
+
' chatpanel-gateway tools list names and one-liners (no schemas)',
|
|
23
|
+
' chatpanel-gateway tools schema <tool> one tool\'s full input schema',
|
|
24
|
+
' chatpanel-gateway call <tool> [\'<json>\'] run a tool; arguments as a JSON object',
|
|
25
|
+
' chatpanel-gateway call <tool> --file <path> arguments from a JSON file (no shell quoting)',
|
|
26
|
+
'',
|
|
27
|
+
'Exit codes: 0 ok · 1 the tool reported an error · 2 usage or transport failure',
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
/** First sentence, whitespace collapsed, capped — enough to choose a tool, not to call it. */
|
|
31
|
+
export function oneLiner(description, max = 100) {
|
|
32
|
+
const s = String(description || '').replace(/\s+/g, ' ').trim();
|
|
33
|
+
const cut = s.search(/[.!?]\s/);
|
|
34
|
+
const first = cut > 20 ? s.slice(0, cut + 1) : s;
|
|
35
|
+
return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatToolsList(tools) {
|
|
39
|
+
const width = Math.min(24, Math.max(...tools.map((t) => t.name.length), 4));
|
|
40
|
+
return tools.map((t) => {
|
|
41
|
+
const req = Array.isArray(t.inputSchema?.required) && t.inputSchema.required.length ? `(${t.inputSchema.required.join(', ')})` : '()';
|
|
42
|
+
return `${t.name.padEnd(width)} ${req.padEnd(22)} ${oneLiner(t.description)}`;
|
|
43
|
+
}).join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const rest = [...argv];
|
|
48
|
+
let file = null;
|
|
49
|
+
const i = rest.indexOf('--file');
|
|
50
|
+
if (i >= 0) { file = rest[i + 1]; rest.splice(i, 2); }
|
|
51
|
+
return { rest, file };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run one CLI invocation. Returns the exit code; writes through `out`/`err` so a test can
|
|
56
|
+
* capture without a process. `rpc` is the MCP dispatcher — `handleRpc` in production.
|
|
57
|
+
*/
|
|
58
|
+
export async function runMcpCli(argv, { out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), rpc = handleRpc } = {}) {
|
|
59
|
+
const [verb, ...more] = argv;
|
|
60
|
+
const list = async () => (await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }))?.result?.tools || [];
|
|
61
|
+
|
|
62
|
+
if (verb === 'tools') {
|
|
63
|
+
const [sub, name] = more;
|
|
64
|
+
if (sub === 'list') {
|
|
65
|
+
out(`${formatToolsList(await list())}\n`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (sub === 'schema') {
|
|
69
|
+
if (!name) { err(`tools schema: which tool?\n${USAGE}\n`); return 2; }
|
|
70
|
+
const tool = (await list()).find((t) => t.name === name);
|
|
71
|
+
if (!tool) { err(`no tool named "${name}". Run: chatpanel-gateway tools list\n`); return 2; }
|
|
72
|
+
out(`${JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }, null, 2)}\n`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
err(`${USAGE}\n`);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (verb === 'call') {
|
|
80
|
+
const { rest, file } = parseArgs(more);
|
|
81
|
+
const [name, inline] = rest;
|
|
82
|
+
if (!name) { err(`call: which tool?\n${USAGE}\n`); return 2; }
|
|
83
|
+
let args = {};
|
|
84
|
+
try {
|
|
85
|
+
const text = file ? readFileSync(file, 'utf8') : (inline ?? '{}');
|
|
86
|
+
args = JSON.parse(text);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
err(`call: arguments must be a JSON object (${e.message})\n`);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) { err('call: arguments must be a JSON object\n'); return 2; }
|
|
92
|
+
let reply;
|
|
93
|
+
try {
|
|
94
|
+
reply = await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
err(`call: ${e.message}\n`);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
if (reply?.error) { err(`call: ${reply.error.message}\n`); return 2; }
|
|
100
|
+
const text = (reply?.result?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
|
101
|
+
if (reply?.result?.isError) {
|
|
102
|
+
// The gateway being down looks like a tool error from the MCP layer; it is transport.
|
|
103
|
+
const transport = /fetch failed|ECONNREFUSED|not running|unreachable/i.test(text);
|
|
104
|
+
err(`${text}\n`);
|
|
105
|
+
return transport ? 2 : 1;
|
|
106
|
+
}
|
|
107
|
+
out(`${text}\n`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
err(`${USAGE}\n`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
package/src/server.js
CHANGED
|
@@ -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.
|
|
59
|
+
export const VERSION = '0.6.74';
|
|
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.
|