@chatpanel/bridge 0.10.21 → 0.10.23
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 +2 -2
- package/src/engines/antigravity.js +10 -2
- package/src/engines/args.js +47 -0
- package/src/engines/claude.js +32 -21
- package/src/engines/cli-agents.js +160 -8
- package/src/engines/codex.js +10 -2
- package/src/engines/custom.js +82 -75
- package/src/engines/stream-formats.js +207 -0
- package/src/entitlement.js +8 -12
- package/src/env.js +1 -1
- package/src/net.js +107 -0
- package/src/proc.js +21 -0
- package/src/server.js +31 -9
- package/src/ssrf.js +25 -110
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.23",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local bridge that exposes the AI coding agents installed on your machine
|
|
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": [
|
|
7
7
|
"chatpanel",
|
|
8
8
|
"claude-code",
|
|
@@ -16,6 +16,8 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { findAgentBin } from '../env.js';
|
|
18
18
|
import { buildCliPrompt } from './prompt.js';
|
|
19
|
+
import { killOnAbort } from '../proc.js';
|
|
20
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
19
21
|
|
|
20
22
|
const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
|
|
21
23
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
|
|
@@ -78,7 +80,7 @@ function writeImages(images, dir) {
|
|
|
78
80
|
return files;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
83
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
82
84
|
try {
|
|
83
85
|
mkdirSync(SCRATCH, { recursive: true });
|
|
84
86
|
} catch {
|
|
@@ -102,7 +104,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
102
104
|
const args = ['-p', prompt];
|
|
103
105
|
if (options.model) args.push('--model', options.model);
|
|
104
106
|
if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
|
|
105
|
-
|
|
107
|
+
// Drop caller extras that would auto-approve tools (shared sanitizer).
|
|
108
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.antigravity, emit);
|
|
106
109
|
|
|
107
110
|
await new Promise((resolve, reject) => {
|
|
108
111
|
let child;
|
|
@@ -113,6 +116,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
113
116
|
return reject(new Error(`Failed to start agy: ${e.message}`));
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
const detach = killOnAbort(child, signal); // Stop → terminate the agy child
|
|
120
|
+
|
|
116
121
|
let out = '';
|
|
117
122
|
let err = '';
|
|
118
123
|
let streamed = false;
|
|
@@ -137,12 +142,15 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
137
142
|
child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
|
|
138
143
|
child.on('error', (e) => {
|
|
139
144
|
clearTimeout(idleTimer);
|
|
145
|
+
detach();
|
|
140
146
|
cleanup();
|
|
141
147
|
reject(new Error(`Failed to start agy: ${e.message}`));
|
|
142
148
|
});
|
|
143
149
|
child.on('close', (code) => {
|
|
144
150
|
clearTimeout(idleTimer);
|
|
151
|
+
detach();
|
|
145
152
|
cleanup();
|
|
153
|
+
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
|
|
146
154
|
if (code === 0) {
|
|
147
155
|
if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
|
|
148
156
|
emit({ type: 'done', text: '' });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Caller-supplied "extra CLI arguments" sanitizer (shared by every engine).
|
|
2
|
+
//
|
|
3
|
+
// The extension/model can pass free-form extraArgs to the agent CLI. A prompt-
|
|
4
|
+
// injected model must NOT be able to smuggle a flag that re-opens the sandbox /
|
|
5
|
+
// permission boundary the engine deliberately sets (e.g. Codex's
|
|
6
|
+
// --dangerously-bypass-approvals-and-sandbox, Claude's --permission-mode). Since
|
|
7
|
+
// these flags take values, partial filtering is unsafe — if ANY forbidden token is
|
|
8
|
+
// present we drop the WHOLE extraArgs. Previously only claude.js did this; codex,
|
|
9
|
+
// antigravity and custom pushed extraArgs unfiltered (this closes that gap).
|
|
10
|
+
|
|
11
|
+
export function splitArgs(raw) {
|
|
12
|
+
return String(raw || '').split(/\s+/).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Per-engine escalation flags. Long flags use \b so `--flag=value` is caught too;
|
|
16
|
+
// short flags are exact-matched so we don't over-block a benign token.
|
|
17
|
+
export const FORBIDDEN = {
|
|
18
|
+
claude: /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i,
|
|
19
|
+
// Codex: sandbox / approval escalation + `-c key=val` (can set approval_policy or
|
|
20
|
+
// sandbox_mode in TOML) + `-C/--cd` (retarget the working dir).
|
|
21
|
+
codex: /^(-s|-a|-c|-C)$|^--(dangerously[\w-]*|sandbox|ask-for-approval|full-auto|yolo|config|cd)\b/i,
|
|
22
|
+
antigravity: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|yolo|full-auto)\b/i,
|
|
23
|
+
// Custom runs an arbitrary CLI, so only clearly-dangerous LONG flags are blocked
|
|
24
|
+
// (no short-flag guesses that might collide with a benign tool option).
|
|
25
|
+
custom: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|no-sandbox|bypass|yolo|full-auto|permission-mode|allowed-?tools|disallowed-?tools|mcp-config)\b/i,
|
|
26
|
+
// Copilot's escalation surface is its own family of --allow-* flags (which the
|
|
27
|
+
// `custom` pattern above does NOT cover: "allow-all-tools" != "allowed-tools").
|
|
28
|
+
// Also block re-targeting the working dir (-C / --add-dir) and injecting MCP
|
|
29
|
+
// servers, since the engine sets those deliberately per turn.
|
|
30
|
+
copilot: /^(-C)$|^--(allow-all[\w-]*|allow-tool|allow-url|allow-path|yolo|add-dir|additional-mcp-config|disable-builtin-mcps|deny-tool|deny-url|autopilot|mode)\b/i,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Returns { args, blocked }. `blocked:true` => the whole extraArgs was dropped.
|
|
34
|
+
export function sanitizeExtraArgs(raw, forbidden) {
|
|
35
|
+
const tokens = splitArgs(raw);
|
|
36
|
+
if (tokens.some((t) => forbidden.test(t))) return { args: [], blocked: true };
|
|
37
|
+
return { args: tokens, blocked: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Convenience for the engines: sanitize, push the safe tokens onto `args`, and emit
|
|
41
|
+
// a status when something was dropped.
|
|
42
|
+
export function pushExtraArgs(args, raw, forbidden, emit) {
|
|
43
|
+
if (!raw) return;
|
|
44
|
+
const { args: extra, blocked } = sanitizeExtraArgs(raw, forbidden);
|
|
45
|
+
if (blocked) { try { emit?.({ type: 'status', text: '(ignored unsafe extraArgs)' }); } catch { /* ignore */ } return; }
|
|
46
|
+
args.push(...extra);
|
|
47
|
+
}
|
package/src/engines/claude.js
CHANGED
|
@@ -19,6 +19,8 @@ import os from 'node:os';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { resolveClaude, buildSpawnSpec, isCompiledBinary, selfMcpStdio } from '../env.js';
|
|
21
21
|
import { buildCliPrompt } from './prompt.js';
|
|
22
|
+
import { killOnAbort } from '../proc.js';
|
|
23
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
22
24
|
|
|
23
25
|
// Write base64 data-URL images to temp files. Claude Code reads them with its
|
|
24
26
|
// Read tool (which feeds images to the model as vision), so we just reference the
|
|
@@ -95,7 +97,7 @@ export function claudeMcpConfig(mcp) {
|
|
|
95
97
|
// Spawn claude (however it resolves) and stream its stream-json output via
|
|
96
98
|
// `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
|
|
97
99
|
// null (no spawn) when claude can't be resolved, so the caller can fall back.
|
|
98
|
-
function runClaude({ prompt, args, cwd, emit }) {
|
|
100
|
+
function runClaude({ prompt, args, cwd, emit, signal }) {
|
|
99
101
|
const spec = resolveClaude();
|
|
100
102
|
if (!spec) return null;
|
|
101
103
|
const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
|
|
@@ -108,6 +110,8 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
108
110
|
return reject(new Error(`Failed to start claude: ${e.message}`));
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
const detach = killOnAbort(child, signal); // Stop → terminate the claude child
|
|
114
|
+
|
|
111
115
|
let stdout = '';
|
|
112
116
|
let stderr = '';
|
|
113
117
|
let streamedAny = false;
|
|
@@ -145,10 +149,13 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
145
149
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
146
150
|
child.on('error', (e) => {
|
|
147
151
|
clearTimeout(idleTimer);
|
|
152
|
+
detach();
|
|
148
153
|
reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
|
|
149
154
|
});
|
|
150
155
|
child.on('close', (code) => {
|
|
151
156
|
clearTimeout(idleTimer);
|
|
157
|
+
detach();
|
|
158
|
+
if (signal?.aborted) { resolve({ streamedAny, resultText }); return; } // Stop pressed — end quietly
|
|
152
159
|
if (code === 0) resolve({ streamedAny, resultText });
|
|
153
160
|
else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
154
161
|
});
|
|
@@ -204,7 +211,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
|
|
|
204
211
|
return out;
|
|
205
212
|
}
|
|
206
213
|
|
|
207
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
214
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
208
215
|
const permissionMode = options.permissionMode || 'default';
|
|
209
216
|
// Explicit project dir, else null → CLI runs in home (or WSL home).
|
|
210
217
|
const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
@@ -259,22 +266,13 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
259
266
|
}: ${imageFiles.join(', ')}`;
|
|
260
267
|
}
|
|
261
268
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// whole extraArgs (these tokens take values, so partial filtering is unsafe).
|
|
267
|
-
const FORBIDDEN = /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i;
|
|
268
|
-
if (extra.some((t) => FORBIDDEN.test(t))) {
|
|
269
|
-
emit({ type: 'status', text: '(ignored unsafe extraArgs)' });
|
|
270
|
-
} else {
|
|
271
|
-
args.push(...extra);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const run = runClaude({ prompt, args, cwd, emit });
|
|
269
|
+
// Never let caller-supplied extras re-open the read-only boundary the mode flags
|
|
270
|
+
// above establish (shared sanitizer — see args.js).
|
|
271
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.claude, emit);
|
|
272
|
+
const run = runClaude({ prompt, args, cwd, emit, signal });
|
|
275
273
|
if (run === null) {
|
|
276
274
|
cleanup(); // SDK fallback doesn't take images yet
|
|
277
|
-
return sdkChat({ messages, system, options }, emit);
|
|
275
|
+
return sdkChat({ messages, system, options }, emit, { signal });
|
|
278
276
|
}
|
|
279
277
|
try {
|
|
280
278
|
const { streamedAny, resultText } = await run;
|
|
@@ -335,11 +333,18 @@ function loadSdk() {
|
|
|
335
333
|
return sdkPromise;
|
|
336
334
|
}
|
|
337
335
|
|
|
338
|
-
async function sdkChat({ messages, system, options }, emit) {
|
|
336
|
+
async function sdkChat({ messages, system, options }, emit, { signal } = {}) {
|
|
339
337
|
const sdk = await loadSdk();
|
|
340
338
|
if (!sdk) throw new Error(lastReason);
|
|
341
339
|
const { query } = sdk;
|
|
342
340
|
|
|
341
|
+
// The SDK cancels the run when this controller aborts — forward the request signal.
|
|
342
|
+
const abortController = new AbortController();
|
|
343
|
+
if (signal) {
|
|
344
|
+
if (signal.aborted) abortController.abort();
|
|
345
|
+
else signal.addEventListener('abort', () => abortController.abort(), { once: true });
|
|
346
|
+
}
|
|
347
|
+
|
|
343
348
|
const permissionMode = options.permissionMode || 'default';
|
|
344
349
|
const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
|
|
345
350
|
const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
|
|
@@ -358,6 +363,7 @@ async function sdkChat({ messages, system, options }, emit) {
|
|
|
358
363
|
permissionMode,
|
|
359
364
|
includePartialMessages: true,
|
|
360
365
|
canUseTool,
|
|
366
|
+
abortController,
|
|
361
367
|
settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
|
|
362
368
|
systemPrompt: system
|
|
363
369
|
? { type: 'preset', preset: 'claude_code', append: system }
|
|
@@ -366,10 +372,15 @@ async function sdkChat({ messages, system, options }, emit) {
|
|
|
366
372
|
...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
|
|
367
373
|
},
|
|
368
374
|
});
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
try {
|
|
376
|
+
for await (const message of iterator) {
|
|
377
|
+
const r = handleMessage(message, emit, streamedAny);
|
|
378
|
+
if (r.streamed) streamedAny = true;
|
|
379
|
+
if (r.result != null) resultText = r.result;
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
if (signal?.aborted || abortController.signal.aborted) return; // Stop pressed — end quietly
|
|
383
|
+
throw e;
|
|
373
384
|
}
|
|
374
385
|
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
375
386
|
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// Built-in CLI agents — pi, opencode, kiro — that reuse the shared custom-engine
|
|
2
|
-
// runner (runSpec) with a
|
|
3
|
-
//
|
|
4
|
-
// free users to a single usable agent (FREE_LIMITS.bridgeAgents). Bring-your-own
|
|
5
|
-
// arbitrary CLIs stay Pro via custom.js.
|
|
2
|
+
// runner (runSpec) with a fixed spec each. These are fixed built-in engines and are
|
|
3
|
+
// not entitlement-gated; custom BYO CLIs are handled by custom.js.
|
|
6
4
|
//
|
|
7
5
|
// Specs come from each CLI's actual flags:
|
|
8
6
|
// pi — pi -p "<prompt>" · --model · @{path} images · --list-models
|
|
9
7
|
// opencode — opencode run "<prompt>" · -m provider/model · -f {path} images · models
|
|
10
8
|
// kiro — kiro-cli chat --no-interactive "<prompt>" · --model · --list-models
|
|
11
9
|
|
|
12
|
-
import { runSpec, listSpecModels } from './custom.js';
|
|
10
|
+
import { runSpec, listSpecModels, runForStdout } from './custom.js';
|
|
13
11
|
import { findAgentBin } from '../env.js';
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
// `listModels` override hook: a CLI whose model listing isn't "one id per line"
|
|
14
|
+
// (Copilot prints a quoted list inside `help config`) passes its own parser here
|
|
15
|
+
// instead of forcing the generic one to grow special cases.
|
|
16
|
+
function makeCliAgent(command, spec, notFoundHint, overrides = {}) {
|
|
16
17
|
let installed = false;
|
|
17
18
|
let lastProbe = 0;
|
|
18
19
|
const resolvedSpec = { ...spec, command };
|
|
@@ -32,10 +33,11 @@ function makeCliAgent(command, spec, notFoundHint) {
|
|
|
32
33
|
return installed ? { ok: true } : { ok: false, reason: notFoundHint };
|
|
33
34
|
},
|
|
34
35
|
listModels(options = {}) {
|
|
36
|
+
if (overrides.listModels) return overrides.listModels(command, options);
|
|
35
37
|
return listSpecModels(command, spec.listModelsArgs, options.workingDir);
|
|
36
38
|
},
|
|
37
|
-
chat(input, emit) {
|
|
38
|
-
return runSpec(resolvedSpec, input, emit);
|
|
39
|
+
chat(input, emit, opts) {
|
|
40
|
+
return runSpec(resolvedSpec, input, emit, opts);
|
|
39
41
|
},
|
|
40
42
|
};
|
|
41
43
|
}
|
|
@@ -96,3 +98,153 @@ export const kiro = makeCliAgent(
|
|
|
96
98
|
},
|
|
97
99
|
'kiro-cli not found on PATH. Install Kiro CLI, then sign in.',
|
|
98
100
|
);
|
|
101
|
+
|
|
102
|
+
// GitHub Copilot CLI. Verified against 1.0.80 (`copilot`, not the old
|
|
103
|
+
// `gh copilot` extension).
|
|
104
|
+
//
|
|
105
|
+
// The two things that make or break headless Copilot:
|
|
106
|
+
// 1. --allow-all-tools is REQUIRED in -p mode. Without it EVERY tool call dies
|
|
107
|
+
// with "Permission denied and could not request permission from user" — the
|
|
108
|
+
// CLI has no TTY to ask on — so the agent answers as if it had no tools.
|
|
109
|
+
// 2. --output-format json turns the TUI into clean NDJSON we can stream;
|
|
110
|
+
// the default text output is not parseable as a live stream.
|
|
111
|
+
// Both live in the base spec because neither is optional for this integration.
|
|
112
|
+
//
|
|
113
|
+
// Permission mode maps onto Copilot's real permission surface (see
|
|
114
|
+
// permissionArgs): tools always run unattended, while filesystem reach beyond the
|
|
115
|
+
// working dir and arbitrary URL access are what the mode actually escalates.
|
|
116
|
+
const COPILOT_BASE_ARGS = [
|
|
117
|
+
'-p', '{prompt}',
|
|
118
|
+
'--output-format', 'json',
|
|
119
|
+
'--no-color',
|
|
120
|
+
'--log-level', 'none',
|
|
121
|
+
// Headless hygiene: never block asking the user a question, never self-update
|
|
122
|
+
// mid-turn, and don't ship the session to GitHub web/mobile from a ChatPanel
|
|
123
|
+
// turn (Copilot exports by default; ChatPanel keeps chat local by policy).
|
|
124
|
+
'--no-ask-user',
|
|
125
|
+
'--no-auto-update',
|
|
126
|
+
'--no-remote',
|
|
127
|
+
'--no-remote-export',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
export const copilot = makeCliAgent(
|
|
131
|
+
'copilot',
|
|
132
|
+
{
|
|
133
|
+
args: COPILOT_BASE_ARGS,
|
|
134
|
+
promptVia: 'arg',
|
|
135
|
+
modelArg: '--model {model}',
|
|
136
|
+
// Non-interactive-only flag; one per image.
|
|
137
|
+
imageArg: '--attachment {path}',
|
|
138
|
+
format: 'copilot-json',
|
|
139
|
+
// Copilot takes per-run MCP servers, so browser tools work WITHOUT touching
|
|
140
|
+
// the user's ~/.copilot/mcp-config.json (unlike opencode/kiro, which only
|
|
141
|
+
// read global config). `@` prefix = "this is a file path, not inline JSON";
|
|
142
|
+
// the shape runSpec writes ({mcpServers:{name:{command,args}}}) is the one
|
|
143
|
+
// Copilot expects — verified.
|
|
144
|
+
mcpArg: '--additional-mcp-config @{file}',
|
|
145
|
+
permissionArgs: {
|
|
146
|
+
// Tools yes (or nothing works); no path escape, no arbitrary URLs.
|
|
147
|
+
default: ['--allow-all-tools'],
|
|
148
|
+
// Edits anywhere on disk, still no arbitrary URL fetching.
|
|
149
|
+
acceptEdits: ['--allow-all-tools', '--allow-all-paths'],
|
|
150
|
+
// == --allow-all-tools --allow-all-paths --allow-all-urls
|
|
151
|
+
bypassPermissions: ['--allow-all'],
|
|
152
|
+
},
|
|
153
|
+
forbidden: 'copilot',
|
|
154
|
+
label: 'GitHub Copilot',
|
|
155
|
+
},
|
|
156
|
+
'copilot not found on PATH. Install GitHub Copilot CLI, then run `copilot login`.',
|
|
157
|
+
{ listModels: (command, options) => listCopilotModels(command, options.workingDir) },
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Copilot has no `--list-models`, and an invalid --model reports only that the
|
|
161
|
+
// model is unavailable without naming the valid ones. `copilot help config`
|
|
162
|
+
// documents the live list under its `model` key, so read it there — the ids stay
|
|
163
|
+
// in step with the installed CLI instead of being hardcoded here.
|
|
164
|
+
export async function listCopilotModels(command = 'copilot', workingDir) {
|
|
165
|
+
const stdout = await runForStdout(command, ['help', 'config'], workingDir);
|
|
166
|
+
const out = [];
|
|
167
|
+
const seen = new Set();
|
|
168
|
+
let inModelBlock = false;
|
|
169
|
+
for (const raw of String(stdout || '').split('\n')) {
|
|
170
|
+
// Section keys are printed as ` `key`: description`.
|
|
171
|
+
const key = /^\s*`([A-Za-z][\w.]*)`\s*:/.exec(raw);
|
|
172
|
+
if (key) {
|
|
173
|
+
inModelBlock = key[1] === 'model';
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!inModelBlock) continue;
|
|
177
|
+
const m = /^\s*-\s*"([^"]+)"\s*$/.exec(raw);
|
|
178
|
+
if (!m) continue;
|
|
179
|
+
const id = m[1];
|
|
180
|
+
if (seen.has(id)) continue;
|
|
181
|
+
seen.add(id);
|
|
182
|
+
out.push(id);
|
|
183
|
+
if (out.length >= 200) break;
|
|
184
|
+
}
|
|
185
|
+
// `auto` is a real, documented value (Copilot routes the turn itself) but it is
|
|
186
|
+
// not in the enumerated list — offer it first.
|
|
187
|
+
return out.length ? ['auto', ...out] : [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// DeepSeek Harness. The launcher is `dsh`; `--profile headless` is the shipped
|
|
191
|
+
// one-shot template ("answer one task, print the final assistant message, exit").
|
|
192
|
+
//
|
|
193
|
+
// Deliberately sparse compared to the others, because the CLI surface is sparse:
|
|
194
|
+
// the headless profile takes ONLY the task text. There is no --model, no JSON
|
|
195
|
+
// output and no resume flag — dsh is a Cordis plugin tree, so the model adapter,
|
|
196
|
+
// tools and MCP are chosen by the PROFILE, not by argv. Power users retarget it
|
|
197
|
+
// with `--patch <file>` through the agent's "Extra arguments" field.
|
|
198
|
+
//
|
|
199
|
+
// Two consequences worth knowing before wiring UI to this:
|
|
200
|
+
// * `modelArg` is absent, so a model picked in Settings is ignored by design
|
|
201
|
+
// (runSpec skips model injection without a template) — configure it in the
|
|
202
|
+
// profile instead.
|
|
203
|
+
// * Output arrives as ONE chunk at the end, not token-by-token, so the turn
|
|
204
|
+
// shows progress but no live typing.
|
|
205
|
+
export const deepseek = makeCliAgent(
|
|
206
|
+
'dsh',
|
|
207
|
+
{
|
|
208
|
+
args: ['--profile', 'headless', '{prompt}'],
|
|
209
|
+
promptVia: 'arg',
|
|
210
|
+
format: 'text',
|
|
211
|
+
// No --model flag exists. dsh takes the model as a CONFIG OVERLAY: --patch
|
|
212
|
+
// replaces one row of the composed plugin tree by id, and the model lives in
|
|
213
|
+
// the `agent-default-model` row. runSpec writes this file per turn.
|
|
214
|
+
modelPatch: {
|
|
215
|
+
arg: '--patch {file}',
|
|
216
|
+
// A patch replaces the row's WHOLE config, so provider must be restated.
|
|
217
|
+
build: (model) => `- id: ${DSH_MODEL_ROW}\n config:\n provider: ${DSH_PROVIDER}\n model: ${JSON.stringify(model)}\n`,
|
|
218
|
+
},
|
|
219
|
+
label: 'DeepSeek Harness',
|
|
220
|
+
},
|
|
221
|
+
'dsh not found on PATH. Install DeepSeek Harness, then run `dsh --profile headless "hi"` once to check the profile.',
|
|
222
|
+
{ listModels: (command, options) => listDshModels(command, options.workingDir) },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const DSH_MODEL_ROW = 'agent-default-model';
|
|
226
|
+
const DSH_PROVIDER = 'deepseek-official';
|
|
227
|
+
// dsh ships no model catalog command. The provider names its supported ids only
|
|
228
|
+
// when you send a bad one ("The supported API model names are ..."), so these are
|
|
229
|
+
// those ids, unioned with whatever the profile is actually set to. The field
|
|
230
|
+
// still accepts any id — this is a convenience menu, not a whitelist.
|
|
231
|
+
const DSH_KNOWN_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro'];
|
|
232
|
+
|
|
233
|
+
// dsh publishes no model catalog — there is no `--list-models`, and the composed
|
|
234
|
+
// config tree carries only the model the profile is CURRENTLY set to. So "Load
|
|
235
|
+
// models" reports that one truthfully rather than inventing a menu; the field
|
|
236
|
+
// still accepts any id the provider serves, applied via the patch above.
|
|
237
|
+
export async function listDshModels(command = 'dsh', workingDir) {
|
|
238
|
+
const stdout = await runForStdout(command, ['--profile', 'headless', '--dump-config'], workingDir, 60000);
|
|
239
|
+
const lines = String(stdout || '').split('\n');
|
|
240
|
+
const rowStart = new RegExp(`^-\\s*id:\\s*${DSH_MODEL_ROW}\\s*$`);
|
|
241
|
+
for (let i = 0; i < lines.length; i++) {
|
|
242
|
+
if (!rowStart.test(lines[i])) continue;
|
|
243
|
+
// Scan this row's block for `model: <id>`, stopping at the next top-level row.
|
|
244
|
+
for (let j = i + 1; j < lines.length && !/^-\s*id:/.test(lines[j]); j++) {
|
|
245
|
+
const m = /^\s*model:\s*['"]?([\w.:-]+)['"]?\s*$/.exec(lines[j]);
|
|
246
|
+
if (m) return [...new Set([m[1], ...DSH_KNOWN_MODELS])];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return [...DSH_KNOWN_MODELS];
|
|
250
|
+
}
|
package/src/engines/codex.js
CHANGED
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
// the agent to point it at a real project.
|
|
16
16
|
|
|
17
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
+
import { killOnAbort } from '../proc.js';
|
|
18
19
|
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
19
20
|
import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
20
21
|
import os from 'node:os';
|
|
21
22
|
import path from 'node:path';
|
|
22
23
|
import { findAgentBin, selfMcpStdio } from '../env.js';
|
|
23
24
|
import { buildCliPrompt } from './prompt.js';
|
|
25
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
24
26
|
|
|
25
27
|
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
26
28
|
// streaming never trips it — only true silence does. Override with
|
|
@@ -136,7 +138,7 @@ async function writeImages(images, tag) {
|
|
|
136
138
|
return files;
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
141
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
140
142
|
ensureScratch();
|
|
141
143
|
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
142
144
|
const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
|
|
@@ -162,7 +164,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
162
164
|
// `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
|
|
163
165
|
args.push(...codexMcpConfigArgs(options.mcp));
|
|
164
166
|
if (options.model) args.push('-m', options.model);
|
|
165
|
-
|
|
167
|
+
// Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
|
|
168
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
|
|
166
169
|
for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
|
|
167
170
|
args.push('-');
|
|
168
171
|
|
|
@@ -183,6 +186,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
183
186
|
return reject(new Error(`Failed to start codex: ${e.message}`));
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
const detach = killOnAbort(child, signal); // Stop → SIGTERM/SIGKILL the codex child
|
|
190
|
+
|
|
186
191
|
let stdout = '';
|
|
187
192
|
let stderr = '';
|
|
188
193
|
let idleTimer;
|
|
@@ -213,11 +218,13 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
213
218
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
214
219
|
child.on('error', (e) => {
|
|
215
220
|
clearTimeout(idleTimer);
|
|
221
|
+
detach();
|
|
216
222
|
cleanupImages();
|
|
217
223
|
reject(e);
|
|
218
224
|
});
|
|
219
225
|
child.on('close', async (code) => {
|
|
220
226
|
clearTimeout(idleTimer);
|
|
227
|
+
detach();
|
|
221
228
|
let text = '';
|
|
222
229
|
try {
|
|
223
230
|
text = (await readFile(outFile, 'utf8')).trim();
|
|
@@ -226,6 +233,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
226
233
|
}
|
|
227
234
|
unlink(outFile).catch(() => {});
|
|
228
235
|
cleanupImages();
|
|
236
|
+
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
|
|
229
237
|
if (code === 0) {
|
|
230
238
|
emit({ type: 'delta', text: text || '(no output)' });
|
|
231
239
|
emit({ type: 'done', text: '' });
|