@chatpanel/bridge 0.2.16 → 0.3.0
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 +5 -37
- package/src/engines/custom.js +148 -0
- 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.0",
|
|
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,7 +16,7 @@
|
|
|
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
|
const TIMEOUT_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
|
|
22
22
|
// Read-only tools allowed without approval in headless mode; writes/shell are
|
|
@@ -65,46 +65,13 @@ function buildPrompt(messages) {
|
|
|
65
65
|
return prompt;
|
|
66
66
|
}
|
|
67
67
|
|
|
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
68
|
// Spawn claude (however it resolves) and stream its stream-json output via
|
|
102
69
|
// `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
|
|
103
70
|
// null (no spawn) when claude can't be resolved, so the caller can fall back.
|
|
104
71
|
function runClaude({ prompt, args, cwd, emit }) {
|
|
105
72
|
const spec = resolveClaude();
|
|
106
73
|
if (!spec) return null;
|
|
107
|
-
const [bin, argv, opts] =
|
|
74
|
+
const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
|
|
108
75
|
|
|
109
76
|
return new Promise((resolve, reject) => {
|
|
110
77
|
let child;
|
|
@@ -159,8 +126,9 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
159
126
|
}
|
|
160
127
|
|
|
161
128
|
// Map one stream-json message to emit() calls. Returns { streamed, result }.
|
|
162
|
-
// The CLI's stream-json mirrors the SDK message shapes.
|
|
163
|
-
|
|
129
|
+
// The CLI's stream-json mirrors the SDK message shapes. Exported so the custom
|
|
130
|
+
// engine can reuse it for agents that emit Claude-style stream-json.
|
|
131
|
+
export function handleMessage(msg, emit, alreadyStreamed) {
|
|
164
132
|
const out = { streamed: false, result: null };
|
|
165
133
|
if (msg.type === 'stream_event') {
|
|
166
134
|
const ev = msg.event;
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
const TIMEOUT_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
|
|
25
|
+
|
|
26
|
+
export async function available() {
|
|
27
|
+
// The engine ships in every bridge; individual custom agents are user-defined
|
|
28
|
+
// (Pro) and validated per request and via /agent-check.
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The bridge is stateless, so replay the conversation as a single prompt.
|
|
33
|
+
function buildPrompt(messages, system) {
|
|
34
|
+
let p = system ? `${system}\n\n` : '';
|
|
35
|
+
const history = messages.slice(0, -1);
|
|
36
|
+
const last = messages[messages.length - 1];
|
|
37
|
+
if (history.length) {
|
|
38
|
+
p += 'Conversation so far:\n';
|
|
39
|
+
for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
|
|
40
|
+
p += '---\n\n';
|
|
41
|
+
}
|
|
42
|
+
p += last ? last.content : '';
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function chat({ messages, system, options }, emit) {
|
|
47
|
+
// Pro gate — verified, not just UI. No valid signed entitlement → no run.
|
|
48
|
+
if (!(await isProEntitled(options.entitlement))) {
|
|
49
|
+
throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const spec = options.custom || {};
|
|
53
|
+
if (!spec.command) throw new Error('This custom agent has no command configured.');
|
|
54
|
+
|
|
55
|
+
const resolved = resolveCommand(spec.command);
|
|
56
|
+
if (!resolved) {
|
|
57
|
+
throw new Error(`Couldn't find "${spec.command}". Enter its full path, or install it on your PATH (or in WSL).`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const prompt = buildPrompt(messages, system);
|
|
61
|
+
const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
62
|
+
const label = spec.label || spec.command;
|
|
63
|
+
const fmt = spec.format === 'claude-stream-json' ? 'claude-stream-json' : 'text';
|
|
64
|
+
|
|
65
|
+
// Args: either a real array or a space-split string. With promptVia:'arg' we
|
|
66
|
+
// substitute {prompt} (or append it if there's no placeholder); otherwise the
|
|
67
|
+
// prompt goes in on stdin.
|
|
68
|
+
const promptVia = spec.promptVia === 'arg' ? 'arg' : 'stdin';
|
|
69
|
+
let args = Array.isArray(spec.args)
|
|
70
|
+
? spec.args.slice()
|
|
71
|
+
: spec.args
|
|
72
|
+
? String(spec.args).split(/\s+/).filter(Boolean)
|
|
73
|
+
: [];
|
|
74
|
+
if (promptVia === 'arg') {
|
|
75
|
+
let placed = false;
|
|
76
|
+
args = args.map((a) => {
|
|
77
|
+
if (a.includes('{prompt}')) {
|
|
78
|
+
placed = true;
|
|
79
|
+
return a.replaceAll('{prompt}', prompt);
|
|
80
|
+
}
|
|
81
|
+
return a;
|
|
82
|
+
});
|
|
83
|
+
if (!placed) args.push(prompt);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const [bin, argv, opts] = buildSpawnSpec(resolved, args, cwd);
|
|
87
|
+
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
let child;
|
|
90
|
+
try {
|
|
91
|
+
child = spawn(bin, argv, opts);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let stderr = '';
|
|
97
|
+
let streamedAny = false;
|
|
98
|
+
let resultText = '';
|
|
99
|
+
let jsonBuf = '';
|
|
100
|
+
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
child.kill('SIGKILL');
|
|
103
|
+
reject(new Error(`${label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
|
|
104
|
+
}, TIMEOUT_MS);
|
|
105
|
+
|
|
106
|
+
child.stdout.on('data', (d) => {
|
|
107
|
+
const s = d.toString();
|
|
108
|
+
if (fmt === 'claude-stream-json') {
|
|
109
|
+
jsonBuf += s;
|
|
110
|
+
let nl;
|
|
111
|
+
while ((nl = jsonBuf.indexOf('\n')) >= 0) {
|
|
112
|
+
const line = jsonBuf.slice(0, nl).trim();
|
|
113
|
+
jsonBuf = jsonBuf.slice(nl + 1);
|
|
114
|
+
if (!line.startsWith('{')) continue;
|
|
115
|
+
let msg;
|
|
116
|
+
try {
|
|
117
|
+
msg = JSON.parse(line);
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const r = handleMessage(msg, emit, streamedAny);
|
|
122
|
+
if (r.streamed) streamedAny = true;
|
|
123
|
+
if (r.result != null) resultText = r.result;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
streamedAny = true;
|
|
127
|
+
emit({ type: 'delta', text: s });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
131
|
+
child.on('error', (e) => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
134
|
+
});
|
|
135
|
+
child.on('close', (code) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
139
|
+
resolve();
|
|
140
|
+
} else {
|
|
141
|
+
reject(new Error(`${label} exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (promptVia === 'stdin') child.stdin.write(prompt);
|
|
146
|
+
child.stdin.end();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -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.0';
|
|
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
|
}
|