@chatpanel/bridge 0.2.16 → 0.3.1
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/engines/claude.js +23 -45
- package/src/engines/codex.js +17 -8
- package/src/engines/custom.js +157 -0
- package/src/engines/gemini.js +17 -8
- package/src/entitlement.js +81 -0
- package/src/env.js +90 -47
- package/src/server.js +38 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
package/src/engines/claude.js
CHANGED
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { resolveClaude,
|
|
19
|
+
import { resolveClaude, buildSpawnSpec, isCompiledBinary } from '../env.js';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Idle timeout: kill the run only after this long with NO output. The timer
|
|
22
|
+
// re-arms on every stdout/stderr chunk, so a task that keeps streaming can run
|
|
23
|
+
// indefinitely — only a truly stuck/silent process is killed. Override with
|
|
24
|
+
// CHATPANEL_CLAUDE_TIMEOUT_MS (ms).
|
|
25
|
+
const IDLE_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
|
|
22
26
|
// Read-only tools allowed without approval in headless mode; writes/shell are
|
|
23
27
|
// gated behind the agent's permission mode.
|
|
24
28
|
const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
|
|
@@ -65,46 +69,13 @@ function buildPrompt(messages) {
|
|
|
65
69
|
return prompt;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
// Turn a launch spec + the claude CLI args into a concrete [bin, argv, options]
|
|
69
|
-
// for spawn(). `cwd` is the resolved working dir (Windows path on win32), or null
|
|
70
|
-
// to use the home directory.
|
|
71
|
-
function buildSpawn(spec, args, cwd) {
|
|
72
|
-
if (spec.kind === 'wsl') {
|
|
73
|
-
// Run claude inside WSL's login shell so nvm/etc. PATH resolves it. The
|
|
74
|
-
// `'exec claude "$@"'` + 'chatpanel' ($0) trick passes our args through as a
|
|
75
|
-
// proper argv array — no manual quoting, even for multi-line system prompts.
|
|
76
|
-
const pre = [];
|
|
77
|
-
if (cwd) {
|
|
78
|
-
const wslCwd = toWslPath(cwd);
|
|
79
|
-
if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
|
|
80
|
-
}
|
|
81
|
-
const argv = [...pre, '-e', 'bash', '-lic', 'exec claude "$@"', 'chatpanel', ...args];
|
|
82
|
-
return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const spawnCwd = cwd || os.homedir();
|
|
86
|
-
const opts = { cwd: spawnCwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true };
|
|
87
|
-
if (spec.kind === 'script') {
|
|
88
|
-
// Run cli.js with the interpreter already running the bridge (node/bun).
|
|
89
|
-
return [process.execPath, [spec.script, ...args], opts];
|
|
90
|
-
}
|
|
91
|
-
if (spec.kind === 'cmd') {
|
|
92
|
-
// Launch the .cmd/.bat shim via cmd.exe with a real argv (shell:false). Node
|
|
93
|
-
// applies cmd.exe-aware quoting here, so args are passed safely — unlike
|
|
94
|
-
// spawn(..., { shell: true }), which concatenates (DEP0190).
|
|
95
|
-
return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
|
|
96
|
-
}
|
|
97
|
-
// kind === 'native' — a directly executable file (mac/linux binary or .exe).
|
|
98
|
-
return [spec.bin, args, opts];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
72
|
// Spawn claude (however it resolves) and stream its stream-json output via
|
|
102
73
|
// `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
|
|
103
74
|
// null (no spawn) when claude can't be resolved, so the caller can fall back.
|
|
104
75
|
function runClaude({ prompt, args, cwd, emit }) {
|
|
105
76
|
const spec = resolveClaude();
|
|
106
77
|
if (!spec) return null;
|
|
107
|
-
const [bin, argv, opts] =
|
|
78
|
+
const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
|
|
108
79
|
|
|
109
80
|
return new Promise((resolve, reject) => {
|
|
110
81
|
let child;
|
|
@@ -119,12 +90,18 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
119
90
|
let streamedAny = false;
|
|
120
91
|
let resultText = '';
|
|
121
92
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
93
|
+
let idleTimer;
|
|
94
|
+
const armIdle = () => {
|
|
95
|
+
clearTimeout(idleTimer);
|
|
96
|
+
idleTimer = setTimeout(() => {
|
|
97
|
+
child.kill('SIGKILL');
|
|
98
|
+
reject(new Error(`Claude Code timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
99
|
+
}, IDLE_MS);
|
|
100
|
+
};
|
|
101
|
+
armIdle();
|
|
126
102
|
|
|
127
103
|
child.stdout.on('data', (d) => {
|
|
104
|
+
armIdle();
|
|
128
105
|
stdout += d.toString();
|
|
129
106
|
let nl;
|
|
130
107
|
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
@@ -142,13 +119,13 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
142
119
|
if (r.result != null) resultText = r.result;
|
|
143
120
|
}
|
|
144
121
|
});
|
|
145
|
-
child.stderr.on('data', (d) => (stderr += d.toString())
|
|
122
|
+
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
146
123
|
child.on('error', (e) => {
|
|
147
|
-
clearTimeout(
|
|
124
|
+
clearTimeout(idleTimer);
|
|
148
125
|
reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
|
|
149
126
|
});
|
|
150
127
|
child.on('close', (code) => {
|
|
151
|
-
clearTimeout(
|
|
128
|
+
clearTimeout(idleTimer);
|
|
152
129
|
if (code === 0) resolve({ streamedAny, resultText });
|
|
153
130
|
else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
154
131
|
});
|
|
@@ -159,8 +136,9 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
159
136
|
}
|
|
160
137
|
|
|
161
138
|
// Map one stream-json message to emit() calls. Returns { streamed, result }.
|
|
162
|
-
// The CLI's stream-json mirrors the SDK message shapes.
|
|
163
|
-
|
|
139
|
+
// The CLI's stream-json mirrors the SDK message shapes. Exported so the custom
|
|
140
|
+
// engine can reuse it for agents that emit Claude-style stream-json.
|
|
141
|
+
export function handleMessage(msg, emit, alreadyStreamed) {
|
|
164
142
|
const out = { streamed: false, result: null };
|
|
165
143
|
if (msg.type === 'stream_event') {
|
|
166
144
|
const ev = msg.event;
|
package/src/engines/codex.js
CHANGED
|
@@ -21,7 +21,10 @@ import os from 'node:os';
|
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { findAgentBin } from '../env.js';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
25
|
+
// streaming never trips it — only true silence does. Override with
|
|
26
|
+
// CHATPANEL_CODEX_TIMEOUT_MS (ms).
|
|
27
|
+
const IDLE_MS = Number(process.env.CHATPANEL_CODEX_TIMEOUT_MS) || 180_000;
|
|
25
28
|
const REASONING = process.env.CHATPANEL_CODEX_EFFORT ?? 'low'; // '' → respect config
|
|
26
29
|
|
|
27
30
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-codex-scratch');
|
|
@@ -131,12 +134,18 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
131
134
|
|
|
132
135
|
let stdout = '';
|
|
133
136
|
let stderr = '';
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
let idleTimer;
|
|
138
|
+
const armIdle = () => {
|
|
139
|
+
clearTimeout(idleTimer);
|
|
140
|
+
idleTimer = setTimeout(() => {
|
|
141
|
+
child.kill('SIGKILL');
|
|
142
|
+
reject(new Error(`Codex timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
143
|
+
}, IDLE_MS);
|
|
144
|
+
};
|
|
145
|
+
armIdle();
|
|
138
146
|
|
|
139
147
|
child.stdout.on('data', (d) => {
|
|
148
|
+
armIdle();
|
|
140
149
|
stdout += d.toString();
|
|
141
150
|
let nl;
|
|
142
151
|
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
@@ -150,13 +159,13 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
});
|
|
153
|
-
child.stderr.on('data', (d) => (stderr += d.toString())
|
|
162
|
+
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
154
163
|
child.on('error', (e) => {
|
|
155
|
-
clearTimeout(
|
|
164
|
+
clearTimeout(idleTimer);
|
|
156
165
|
reject(e);
|
|
157
166
|
});
|
|
158
167
|
child.on('close', async (code) => {
|
|
159
|
-
clearTimeout(
|
|
168
|
+
clearTimeout(idleTimer);
|
|
160
169
|
let text = '';
|
|
161
170
|
try {
|
|
162
171
|
text = (await readFile(outFile, 'utf8')).trim();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Custom ("bring your own") engine — runs ANY CLI the user onboards from the
|
|
2
|
+
// extension's Agents settings (opencode, pi, ollama, a shell script, …) WITHOUT
|
|
3
|
+
// a bridge code change per tool. The command spec travels in the chat request's
|
|
4
|
+
// `options.custom`; this engine resolves the command cross-platform (PATH /
|
|
5
|
+
// Windows cli.js+.cmd / WSL — same launcher as Claude), pipes the prompt in, and
|
|
6
|
+
// streams output back.
|
|
7
|
+
//
|
|
8
|
+
// HARD Pro gate: a custom agent only runs if the request carries a valid,
|
|
9
|
+
// server-signed entitlement token (verified OFFLINE here — no network). A forked
|
|
10
|
+
// client or a raw POST can't forge it, so this is real gating, not UI.
|
|
11
|
+
//
|
|
12
|
+
// Output formats:
|
|
13
|
+
// 'text' (default) — stream stdout straight through as text deltas. Works for
|
|
14
|
+
// any program that prints a reply.
|
|
15
|
+
// 'claude-stream-json' — parse Claude Code-style stream-json (for tools that
|
|
16
|
+
// speak it), reusing the Claude engine's parser.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { resolveCommand, buildSpawnSpec } from '../env.js';
|
|
21
|
+
import { isProEntitled } from '../entitlement.js';
|
|
22
|
+
import { handleMessage } from './claude.js';
|
|
23
|
+
|
|
24
|
+
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
25
|
+
// streaming never trips it — only true silence does. Override with
|
|
26
|
+
// CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
|
|
27
|
+
const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
|
|
28
|
+
|
|
29
|
+
export async function available() {
|
|
30
|
+
// The engine ships in every bridge; individual custom agents are user-defined
|
|
31
|
+
// (Pro) and validated per request and via /agent-check.
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The bridge is stateless, so replay the conversation as a single prompt.
|
|
36
|
+
function buildPrompt(messages, system) {
|
|
37
|
+
let p = system ? `${system}\n\n` : '';
|
|
38
|
+
const history = messages.slice(0, -1);
|
|
39
|
+
const last = messages[messages.length - 1];
|
|
40
|
+
if (history.length) {
|
|
41
|
+
p += 'Conversation so far:\n';
|
|
42
|
+
for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
|
|
43
|
+
p += '---\n\n';
|
|
44
|
+
}
|
|
45
|
+
p += last ? last.content : '';
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function chat({ messages, system, options }, emit) {
|
|
50
|
+
// Pro gate — verified, not just UI. No valid signed entitlement → no run.
|
|
51
|
+
if (!(await isProEntitled(options.entitlement))) {
|
|
52
|
+
throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const spec = options.custom || {};
|
|
56
|
+
if (!spec.command) throw new Error('This custom agent has no command configured.');
|
|
57
|
+
|
|
58
|
+
const resolved = resolveCommand(spec.command);
|
|
59
|
+
if (!resolved) {
|
|
60
|
+
throw new Error(`Couldn't find "${spec.command}". Enter its full path, or install it on your PATH (or in WSL).`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const prompt = buildPrompt(messages, system);
|
|
64
|
+
const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
65
|
+
const label = spec.label || spec.command;
|
|
66
|
+
const fmt = spec.format === 'claude-stream-json' ? 'claude-stream-json' : 'text';
|
|
67
|
+
|
|
68
|
+
// Args: either a real array or a space-split string. With promptVia:'arg' we
|
|
69
|
+
// substitute {prompt} (or append it if there's no placeholder); otherwise the
|
|
70
|
+
// prompt goes in on stdin.
|
|
71
|
+
const promptVia = spec.promptVia === 'arg' ? 'arg' : 'stdin';
|
|
72
|
+
let args = Array.isArray(spec.args)
|
|
73
|
+
? spec.args.slice()
|
|
74
|
+
: spec.args
|
|
75
|
+
? String(spec.args).split(/\s+/).filter(Boolean)
|
|
76
|
+
: [];
|
|
77
|
+
if (promptVia === 'arg') {
|
|
78
|
+
let placed = false;
|
|
79
|
+
args = args.map((a) => {
|
|
80
|
+
if (a.includes('{prompt}')) {
|
|
81
|
+
placed = true;
|
|
82
|
+
return a.replaceAll('{prompt}', prompt);
|
|
83
|
+
}
|
|
84
|
+
return a;
|
|
85
|
+
});
|
|
86
|
+
if (!placed) args.push(prompt);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [bin, argv, opts] = buildSpawnSpec(resolved, args, cwd);
|
|
90
|
+
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
let child;
|
|
93
|
+
try {
|
|
94
|
+
child = spawn(bin, argv, opts);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let stderr = '';
|
|
100
|
+
let streamedAny = false;
|
|
101
|
+
let resultText = '';
|
|
102
|
+
let jsonBuf = '';
|
|
103
|
+
|
|
104
|
+
let idleTimer;
|
|
105
|
+
const armIdle = () => {
|
|
106
|
+
clearTimeout(idleTimer);
|
|
107
|
+
idleTimer = setTimeout(() => {
|
|
108
|
+
child.kill('SIGKILL');
|
|
109
|
+
reject(new Error(`${label} timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
110
|
+
}, IDLE_MS);
|
|
111
|
+
};
|
|
112
|
+
armIdle();
|
|
113
|
+
|
|
114
|
+
child.stdout.on('data', (d) => {
|
|
115
|
+
armIdle();
|
|
116
|
+
const s = d.toString();
|
|
117
|
+
if (fmt === 'claude-stream-json') {
|
|
118
|
+
jsonBuf += s;
|
|
119
|
+
let nl;
|
|
120
|
+
while ((nl = jsonBuf.indexOf('\n')) >= 0) {
|
|
121
|
+
const line = jsonBuf.slice(0, nl).trim();
|
|
122
|
+
jsonBuf = jsonBuf.slice(nl + 1);
|
|
123
|
+
if (!line.startsWith('{')) continue;
|
|
124
|
+
let msg;
|
|
125
|
+
try {
|
|
126
|
+
msg = JSON.parse(line);
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const r = handleMessage(msg, emit, streamedAny);
|
|
131
|
+
if (r.streamed) streamedAny = true;
|
|
132
|
+
if (r.result != null) resultText = r.result;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
streamedAny = true;
|
|
136
|
+
emit({ type: 'delta', text: s });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
140
|
+
child.on('error', (e) => {
|
|
141
|
+
clearTimeout(idleTimer);
|
|
142
|
+
reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
143
|
+
});
|
|
144
|
+
child.on('close', (code) => {
|
|
145
|
+
clearTimeout(idleTimer);
|
|
146
|
+
if (code === 0) {
|
|
147
|
+
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
148
|
+
resolve();
|
|
149
|
+
} else {
|
|
150
|
+
reject(new Error(`${label} exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (promptVia === 'stdin') child.stdin.write(prompt);
|
|
155
|
+
child.stdin.end();
|
|
156
|
+
});
|
|
157
|
+
}
|
package/src/engines/gemini.js
CHANGED
|
@@ -14,7 +14,10 @@ import os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { findAgentBin } from '../env.js';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
18
|
+
// streaming never trips it — only true silence does. Override with
|
|
19
|
+
// CHATPANEL_GEMINI_TIMEOUT_MS (ms).
|
|
20
|
+
const IDLE_MS = Number(process.env.CHATPANEL_GEMINI_TIMEOUT_MS) || 180_000;
|
|
18
21
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-gemini-scratch');
|
|
19
22
|
|
|
20
23
|
let installed = false;
|
|
@@ -76,24 +79,30 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
76
79
|
let out = '';
|
|
77
80
|
let err = '';
|
|
78
81
|
let streamed = false;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
let idleTimer;
|
|
83
|
+
const armIdle = () => {
|
|
84
|
+
clearTimeout(idleTimer);
|
|
85
|
+
idleTimer = setTimeout(() => {
|
|
86
|
+
child.kill('SIGKILL');
|
|
87
|
+
reject(new Error(`Gemini timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
88
|
+
}, IDLE_MS);
|
|
89
|
+
};
|
|
90
|
+
armIdle();
|
|
83
91
|
|
|
84
92
|
child.stdout.on('data', (d) => {
|
|
93
|
+
armIdle();
|
|
85
94
|
const s = d.toString();
|
|
86
95
|
out += s;
|
|
87
96
|
streamed = true;
|
|
88
97
|
emit({ type: 'delta', text: s });
|
|
89
98
|
});
|
|
90
|
-
child.stderr.on('data', (d) => (err += d.toString())
|
|
99
|
+
child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
|
|
91
100
|
child.on('error', (e) => {
|
|
92
|
-
clearTimeout(
|
|
101
|
+
clearTimeout(idleTimer);
|
|
93
102
|
reject(new Error(`Failed to start gemini: ${e.message}`));
|
|
94
103
|
});
|
|
95
104
|
child.on('close', (code) => {
|
|
96
|
-
clearTimeout(
|
|
105
|
+
clearTimeout(idleTimer);
|
|
97
106
|
if (code === 0) {
|
|
98
107
|
if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
|
|
99
108
|
emit({ type: 'done', text: '' });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Offline Pro/Team entitlement verification — the HARD gate for paid features
|
|
2
|
+
// (e.g. custom "bring your own CLI" agents).
|
|
3
|
+
//
|
|
4
|
+
// The license server (Cloudflare Worker) signs a compact entitlement token with
|
|
5
|
+
// an ECDSA P-256 private key that lives ONLY there. The bridge ships the matching
|
|
6
|
+
// PUBLIC key and verifies the signature locally — no network, no secret. A forked
|
|
7
|
+
// client or a raw `curl` to the bridge can't forge entitlement without the
|
|
8
|
+
// private key, so this is a real cryptographic gate, not a UI check.
|
|
9
|
+
//
|
|
10
|
+
// Token format (identical to the extension's, extension/js/license.js):
|
|
11
|
+
// token = base64url(JSON payload) + "." + base64url(raw ECDSA signature)
|
|
12
|
+
// signed over UTF-8(head); payload = { typ:'ent', plan, install_id, sub, exp }
|
|
13
|
+
//
|
|
14
|
+
// Keep ENTITLEMENT_PUBLIC_JWK in sync with the extension's copy.
|
|
15
|
+
|
|
16
|
+
import { webcrypto } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
const ENTITLEMENT_PUBLIC_JWK = {
|
|
19
|
+
kty: 'EC',
|
|
20
|
+
crv: 'P-256',
|
|
21
|
+
x: 'CmgKLC4e3xDMvwhbjVqF7jbDe1JhC1KKQi8JN3qVX_4',
|
|
22
|
+
y: 'r40l6fQiyCcJYqW-SvB4VoSyn4F36yhSt82ZAOSo78E',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PRO_PLANS = new Set(['pro', 'team']);
|
|
26
|
+
|
|
27
|
+
const b64urlToBytes = (s) => {
|
|
28
|
+
const norm = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
29
|
+
return new Uint8Array(Buffer.from(norm, 'base64'));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let keyPromise = null;
|
|
33
|
+
function publicKey() {
|
|
34
|
+
if (!keyPromise) {
|
|
35
|
+
keyPromise = webcrypto.subtle.importKey(
|
|
36
|
+
'jwk',
|
|
37
|
+
ENTITLEMENT_PUBLIC_JWK,
|
|
38
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
39
|
+
false,
|
|
40
|
+
['verify'],
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return keyPromise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Verify a server entitlement token. Returns its payload, or null. Checks the
|
|
47
|
+
// ECDSA signature (unforgeable without the private key), the token type, and
|
|
48
|
+
// expiry. install_id binding is the extension's concern — for the bridge gate the
|
|
49
|
+
// signature is what matters.
|
|
50
|
+
export async function verifyEntitlement(token) {
|
|
51
|
+
if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
|
|
52
|
+
const [head, sig] = token.split('.');
|
|
53
|
+
const enc = new TextEncoder();
|
|
54
|
+
let ok = false;
|
|
55
|
+
try {
|
|
56
|
+
ok = await webcrypto.subtle.verify(
|
|
57
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
58
|
+
await publicKey(),
|
|
59
|
+
b64urlToBytes(sig),
|
|
60
|
+
enc.encode(head),
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!ok) return null;
|
|
66
|
+
let payload;
|
|
67
|
+
try {
|
|
68
|
+
payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(head)));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (payload.typ !== 'ent') return null;
|
|
73
|
+
if (payload.exp && Date.now() > payload.exp) return null;
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// True when `token` is a valid, unexpired Pro (or Team) entitlement.
|
|
78
|
+
export async function isProEntitled(token) {
|
|
79
|
+
const p = await verifyEntitlement(token);
|
|
80
|
+
return !!(p && PRO_PLANS.has(p.plan));
|
|
81
|
+
}
|
package/src/env.js
CHANGED
|
@@ -91,52 +91,74 @@ function shellWhich(name) {
|
|
|
91
91
|
// than escapes (Node DEP0190 / a real injection surface). The 'script' and 'cmd'
|
|
92
92
|
// kinds run the shim safely with a proper argv instead.
|
|
93
93
|
export function resolveClaude() {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
if (process.env.CHATPANEL_CLAUDE_PATH) return resolveCommand(process.env.CHATPANEL_CLAUDE_PATH);
|
|
95
|
+
// The Claude npm package additionally ships a cli.js we prefer; otherwise this
|
|
96
|
+
// is the same generic resolution every command uses.
|
|
97
|
+
return resolveCommand('claude');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve ANY command (a bare name like `opencode`, or an absolute/relative path)
|
|
101
|
+
// to a launch spec, the same way resolveClaude does — so custom user-onboarded
|
|
102
|
+
// agents get identical cross-platform launching (PATH, Windows cli.js/.cmd, WSL).
|
|
103
|
+
//
|
|
104
|
+
// Returns one of:
|
|
105
|
+
// { kind: 'native', bin } → spawn(bin, args)
|
|
106
|
+
// { kind: 'script', script } → spawn(process.execPath, [script, ...args])
|
|
107
|
+
// { kind: 'cmd', bin } → spawn('cmd.exe', ['/c', bin, ...args])
|
|
108
|
+
// { kind: 'wsl', command } → spawn('wsl.exe', [prefix, command, ...args])
|
|
109
|
+
// null → not found
|
|
110
|
+
//
|
|
111
|
+
// We never use spawn's `shell: true` — with an args array it concatenates rather
|
|
112
|
+
// than escapes (Node DEP0190 / a real injection surface). The 'script'/'cmd'/'wsl'
|
|
113
|
+
// kinds run things safely with a proper argv instead.
|
|
114
|
+
export function resolveCommand(command) {
|
|
115
|
+
if (!command) return null;
|
|
116
|
+
const looksLikePath = command.includes('/') || command.includes('\\') || /\.[a-z0-9]+$/i.test(command);
|
|
117
|
+
|
|
118
|
+
if (looksLikePath) {
|
|
119
|
+
if (!existsSync(command)) return null; // an explicit path: don't PATH-search
|
|
120
|
+
const ext = path.extname(command).toLowerCase();
|
|
121
|
+
if (!isCompiledBinary() && /^\.(c?js|mjs)$/.test(ext)) return { kind: 'script', script: command };
|
|
122
|
+
if (process.platform === 'win32' && (ext === '.cmd' || ext === '.bat')) return { kind: 'cmd', bin: command };
|
|
123
|
+
return { kind: 'native', bin: command };
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
if (process.platform === 'win32') {
|
|
103
|
-
const win =
|
|
127
|
+
const win = findCommandWindows(command);
|
|
104
128
|
if (win) return win;
|
|
105
|
-
|
|
129
|
+
// Common: tool only installed inside WSL. Only probe safe, simple names.
|
|
130
|
+
if (/^[\w.-]+$/.test(command) && commandInWsl(command)) return { kind: 'wsl', command };
|
|
106
131
|
return null;
|
|
107
132
|
}
|
|
108
133
|
|
|
109
|
-
|
|
110
|
-
const bin = findAgentBin('claude');
|
|
134
|
+
const bin = findAgentBin(command);
|
|
111
135
|
return bin ? { kind: 'native', bin } : null;
|
|
112
136
|
}
|
|
113
137
|
|
|
114
|
-
// Locate a runnable
|
|
115
|
-
//
|
|
116
|
-
// .
|
|
117
|
-
function
|
|
138
|
+
// Locate a runnable command on Windows. Prefer a runnable JS entry (run with our
|
|
139
|
+
// own Node/Bun — clean arg passing, no cmd.exe quoting), then a real .exe, then a
|
|
140
|
+
// .cmd/.bat shim launched safely via cmd.exe.
|
|
141
|
+
function findCommandWindows(name) {
|
|
118
142
|
const dirs = (process.env.PATH || '').split(path.delimiter);
|
|
119
143
|
for (const d of dirs) {
|
|
120
144
|
if (!d) continue;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
);
|
|
124
|
-
if (!hasShim) continue;
|
|
145
|
+
const exts = ['', '.cmd', '.exe', '.ps1', '.bat'];
|
|
146
|
+
if (!exts.some((e) => existsSync(path.join(d, name + e)))) continue;
|
|
125
147
|
// Running cli.js with our own interpreter only works under a real Node/Bun,
|
|
126
148
|
// not inside a compiled single-file binary (which is not a JS interpreter).
|
|
127
149
|
if (!isCompiledBinary()) {
|
|
128
|
-
const js = claudeCliJs(d) || shimTarget(d);
|
|
150
|
+
const js = (name === 'claude' && claudeCliJs(d)) || shimTarget(d, name);
|
|
129
151
|
if (js) return { kind: 'script', script: js };
|
|
130
152
|
}
|
|
131
|
-
if (existsSync(path.join(d, '
|
|
132
|
-
if (existsSync(path.join(d, '
|
|
133
|
-
if (existsSync(path.join(d, '
|
|
153
|
+
if (existsSync(path.join(d, name + '.exe'))) return { kind: 'native', bin: path.join(d, name + '.exe') };
|
|
154
|
+
if (existsSync(path.join(d, name + '.cmd'))) return { kind: 'cmd', bin: path.join(d, name + '.cmd') };
|
|
155
|
+
if (existsSync(path.join(d, name + '.bat'))) return { kind: 'cmd', bin: path.join(d, name + '.bat') };
|
|
134
156
|
}
|
|
135
157
|
return null;
|
|
136
158
|
}
|
|
137
159
|
|
|
138
|
-
// The npm
|
|
139
|
-
//
|
|
160
|
+
// The Claude npm package additionally ships a cli.js we prefer (static guesses
|
|
161
|
+
// before parsing the shim).
|
|
140
162
|
function claudeCliJs(dir) {
|
|
141
163
|
const rels = [
|
|
142
164
|
['node_modules', '@anthropic-ai', 'claude-code', 'cli.js'],
|
|
@@ -150,12 +172,12 @@ function claudeCliJs(dir) {
|
|
|
150
172
|
return null;
|
|
151
173
|
}
|
|
152
174
|
|
|
153
|
-
// Robust fallback: every npm/pnpm/yarn/volta shim literally names the JS
|
|
154
|
-
// runs, relative to the shim dir (`%dp0%\…\cli.js` in .cmd,
|
|
155
|
-
// in the sh/.ps1 shims). Extract that so any install layout
|
|
156
|
-
//
|
|
157
|
-
function shimTarget(dir) {
|
|
158
|
-
for (const shim of [
|
|
175
|
+
// Robust, general fallback: every npm/pnpm/yarn/volta shim literally names the JS
|
|
176
|
+
// entry it runs, relative to the shim dir (`%dp0%\…\cli.js` in .cmd,
|
|
177
|
+
// `$basedir/…/cli.js` in the sh/.ps1 shims). Extract that so any install layout
|
|
178
|
+
// resolves to a real JS entry we can run with our own interpreter.
|
|
179
|
+
function shimTarget(dir, name) {
|
|
180
|
+
for (const shim of [`${name}.cmd`, name, `${name}.ps1`]) {
|
|
159
181
|
let txt;
|
|
160
182
|
try {
|
|
161
183
|
txt = readFileSync(path.join(dir, shim), 'utf8');
|
|
@@ -171,25 +193,46 @@ function shimTarget(dir) {
|
|
|
171
193
|
return null;
|
|
172
194
|
}
|
|
173
195
|
|
|
174
|
-
// Is `
|
|
175
|
-
// re-probed (throttled) while not found so it self-heals once
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
// Is `name` reachable inside the default WSL distro's login shell? Cached per
|
|
197
|
+
// name; re-probed (throttled) while not found so it self-heals once it appears.
|
|
198
|
+
const wslSeen = new Map(); // name -> { ok, at }
|
|
199
|
+
function commandInWsl(name) {
|
|
200
|
+
const c = wslSeen.get(name);
|
|
201
|
+
if (c && (c.ok || Date.now() - c.at < 4000)) return c.ok;
|
|
202
|
+
let ok = false;
|
|
203
|
+
try {
|
|
204
|
+
const r = spawnSync('wsl.exe', ['-e', 'bash', '-lic', `command -v ${name}`], {
|
|
205
|
+
encoding: 'utf8',
|
|
206
|
+
timeout: 8000,
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
});
|
|
209
|
+
ok = r.status === 0 && /\S/.test(stripBom(r.stdout || ''));
|
|
210
|
+
} catch {
|
|
211
|
+
ok = false;
|
|
212
|
+
}
|
|
213
|
+
wslSeen.set(name, { ok, at: Date.now() });
|
|
214
|
+
return ok;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Turn a launch spec + CLI args into a concrete [bin, argv, opts] for spawn().
|
|
218
|
+
// `cwd` is the resolved working dir (a Windows path on win32), or null for home.
|
|
219
|
+
export function buildSpawnSpec(spec, args, cwd) {
|
|
220
|
+
if (spec.kind === 'wsl') {
|
|
221
|
+
// Run inside WSL's login shell so nvm/etc. PATH resolves the tool. The
|
|
222
|
+
// `exec <cmd> "$@"` + 'chatpanel' ($0) trick passes our args through as a
|
|
223
|
+
// proper argv array — no manual quoting, even for multi-line prompts.
|
|
224
|
+
const pre = [];
|
|
225
|
+
if (cwd) {
|
|
226
|
+
const wslCwd = toWslPath(cwd);
|
|
227
|
+
if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
|
|
190
228
|
}
|
|
229
|
+
const argv = [...pre, '-e', 'bash', '-lic', `exec ${spec.command} "$@"`, 'chatpanel', ...args];
|
|
230
|
+
return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
|
|
191
231
|
}
|
|
192
|
-
|
|
232
|
+
const opts = { cwd: cwd || os.homedir(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true };
|
|
233
|
+
if (spec.kind === 'script') return [process.execPath, [spec.script, ...args], opts];
|
|
234
|
+
if (spec.kind === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
|
|
235
|
+
return [spec.bin, args, opts]; // native
|
|
193
236
|
}
|
|
194
237
|
|
|
195
238
|
// Translate a Windows path to its WSL (/mnt/c/…) equivalent. Returns null on
|
package/src/server.js
CHANGED
|
@@ -19,14 +19,15 @@ import os from 'node:os';
|
|
|
19
19
|
import * as claude from './engines/claude.js';
|
|
20
20
|
import * as codex from './engines/codex.js';
|
|
21
21
|
import * as gemini from './engines/gemini.js';
|
|
22
|
+
import * as custom from './engines/custom.js';
|
|
22
23
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
23
|
-
import { enrichPath, findAgentBin } from './env.js';
|
|
24
|
+
import { enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
24
25
|
import { checkForUpdate, selfUpdate } from './update.js';
|
|
25
26
|
|
|
26
27
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
27
28
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
28
29
|
// this drifts from package.json, so the two can't silently diverge.
|
|
29
|
-
const VERSION = '0.
|
|
30
|
+
const VERSION = '0.3.1';
|
|
30
31
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
31
32
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
32
33
|
|
|
@@ -34,6 +35,10 @@ const ENGINES = {
|
|
|
34
35
|
claude: { engine: claude, label: 'Claude Code' },
|
|
35
36
|
codex: { engine: codex, label: 'Codex' },
|
|
36
37
|
gemini: { engine: gemini, label: 'Gemini CLI' },
|
|
38
|
+
// "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
|
|
39
|
+
// from /health (it's not a single installable agent; the extension manages the
|
|
40
|
+
// list and validates commands via /agent-check).
|
|
41
|
+
custom: { engine: custom, label: 'Custom', hidden: true },
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
// --------------------------------------------------------------------------
|
|
@@ -80,10 +85,12 @@ function readBody(req) {
|
|
|
80
85
|
// --------------------------------------------------------------------------
|
|
81
86
|
async function handleHealth(res) {
|
|
82
87
|
const agents = await Promise.all(
|
|
83
|
-
Object.entries(ENGINES)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
Object.entries(ENGINES)
|
|
89
|
+
.filter(([, e]) => !e.hidden)
|
|
90
|
+
.map(async ([id, { engine, label }]) => {
|
|
91
|
+
const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
|
|
92
|
+
return { id, label, available: a.ok, reason: a.reason };
|
|
93
|
+
}),
|
|
87
94
|
);
|
|
88
95
|
const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
|
|
89
96
|
json(res, 200, { ok: true, version: VERSION, agents, update });
|
|
@@ -184,6 +191,28 @@ async function handleComplete(req, res) {
|
|
|
184
191
|
}
|
|
185
192
|
}
|
|
186
193
|
|
|
194
|
+
// POST /agent-check → { command } → { ok, via } — does this command resolve on
|
|
195
|
+
// this machine? Powers the "✓ found" indicator when onboarding a custom agent.
|
|
196
|
+
// `via` tells the user HOW it resolved (native / script / cmd / wsl) so a Windows
|
|
197
|
+
// user sees e.g. "found in WSL". No execution, no entitlement needed (read-only).
|
|
198
|
+
async function handleAgentCheck(req, res) {
|
|
199
|
+
let body;
|
|
200
|
+
try {
|
|
201
|
+
body = await readBody(req);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return json(res, 400, { error: 'Bad JSON: ' + e.message });
|
|
204
|
+
}
|
|
205
|
+
const command = String(body.command || '').trim();
|
|
206
|
+
if (!command) return json(res, 400, { error: 'No command' });
|
|
207
|
+
let spec = null;
|
|
208
|
+
try {
|
|
209
|
+
spec = resolveCommand(command);
|
|
210
|
+
} catch {
|
|
211
|
+
spec = null;
|
|
212
|
+
}
|
|
213
|
+
return json(res, 200, { ok: !!spec, via: spec ? spec.kind : null });
|
|
214
|
+
}
|
|
215
|
+
|
|
187
216
|
const server = createServer(async (req, res) => {
|
|
188
217
|
cors(req, res);
|
|
189
218
|
if (req.method === 'OPTIONS') {
|
|
@@ -204,6 +233,7 @@ const server = createServer(async (req, res) => {
|
|
|
204
233
|
}
|
|
205
234
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
206
235
|
if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
|
|
236
|
+
if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
|
|
207
237
|
if (req.method === 'POST' && url.pathname === '/update') return handleUpdate(res);
|
|
208
238
|
json(res, 404, { error: 'Not found' });
|
|
209
239
|
} catch (e) {
|
|
@@ -220,7 +250,8 @@ function startServer() {
|
|
|
220
250
|
enrichPath(); // so codex/gemini are found even under a minimal service PATH
|
|
221
251
|
server.listen(PORT, HOST, async () => {
|
|
222
252
|
log('info', `listening on http://${HOST}:${PORT}`);
|
|
223
|
-
for (const [, { engine, label }] of Object.entries(ENGINES)) {
|
|
253
|
+
for (const [, { engine, label, hidden }] of Object.entries(ENGINES)) {
|
|
254
|
+
if (hidden) continue;
|
|
224
255
|
const a = await engine.available().catch(() => ({ ok: false }));
|
|
225
256
|
log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
|
|
226
257
|
}
|