@chatpanel/bridge 0.10.41 → 0.11.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 +6 -2
- package/scripts/sync-channels.mjs +93 -0
- package/scripts/sync-events.mjs +9 -1
- package/scripts/sync-pii.mjs +71 -0
- package/src/api-compat.js +4 -0
- package/src/channels/adapters/telegram.js +248 -0
- package/src/channels/bridge.js +59 -0
- package/src/channels/eventlog.js +57 -0
- package/src/channels/invoke.js +172 -0
- package/src/channels/normalize.js +58 -0
- package/src/channels/pairing.js +80 -0
- package/src/channels/service.js +270 -0
- package/src/channels/stream.js +86 -0
- package/src/cli-errors.js +75 -11
- package/src/engines/claude.js +41 -1
- package/src/engines/codex.js +134 -56
- package/src/events/capability.js +134 -0
- package/src/events/event.js +183 -0
- package/src/events/reach.js +31 -0
- package/src/events/ref.js +60 -0
- package/src/events/scopes.js +1 -1
- package/src/events/view.js +96 -0
- package/src/mcp-quarantine.js +110 -0
- package/src/pii/index.js +29 -0
- package/src/pii/net.js +111 -0
- package/src/pii/pii-detect.js +177 -0
- package/src/pii/pii-redact.js +399 -0
- package/src/pii/pipeline.js +137 -0
- package/src/pii/sanitize.js +179 -0
- package/src/pii/tool-harness.js +152 -0
- package/src/pii/tool-rank.js +110 -0
- package/src/sanitize.js +6 -136
- package/src/server.js +76 -2
package/src/cli-errors.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The failure is also usually not ChatPanel's to fix. Saying whose it is, and naming the
|
|
9
9
|
// server, is the difference between "something broke" and "re-auth that server".
|
|
10
|
+
//
|
|
11
|
+
// `mcpFailure` is the machine-readable half of the same knowledge: mcp-quarantine.js reads
|
|
12
|
+
// it to DROP the offending server and run again, so the common cases never reach a human at
|
|
13
|
+
// all. One set of patterns serves both — a second copy would drift.
|
|
10
14
|
|
|
11
15
|
const NOISE = [
|
|
12
16
|
/^\s*$/,
|
|
@@ -27,19 +31,79 @@ export function stripMarkup(text) {
|
|
|
27
31
|
.replace(/[ \t]+/g, ' ');
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// Which server the output blames, if it names one. Codex and Claude Code phrase this several
|
|
35
|
+
// ways, so try each; a name we cannot read means there is nothing to disable, which is why
|
|
36
|
+
// every caller must tolerate `null`.
|
|
37
|
+
const SERVER_PATTERNS = [
|
|
38
|
+
/(?:refresh OAuth tokens|refresh tools) for (?:MCP )?server ['"`]?([\w.-]+)['"`]?/i,
|
|
39
|
+
/MCP server ['"`]([\w.-]+)['"`]/i,
|
|
40
|
+
/server ['"]?([\w.-]+)['"]? (?:requires|failed) auth/i,
|
|
41
|
+
];
|
|
42
|
+
export function mcpServerName(text) {
|
|
43
|
+
for (const re of SERVER_PATTERNS) {
|
|
44
|
+
const name = re.exec(String(text || ''))?.[1];
|
|
45
|
+
if (name) return name;
|
|
39
46
|
}
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The named MCP causes worth translating. `say` is the sentence for a human; `short` is the
|
|
51
|
+
// clause used when we skip the server and carry on. Order matters — specific before generic.
|
|
52
|
+
const MCP_CAUSES = [
|
|
53
|
+
{
|
|
54
|
+
kind: 'expired',
|
|
55
|
+
test: /refresh token (?:does not exist|was rejected)|invalid_grant/i,
|
|
56
|
+
short: 'its saved login has expired',
|
|
57
|
+
say: (who) => `${who} needs re-authentication — its saved OAuth token has expired or been revoked. Re-login to that server in the agent's own config; ChatPanel can't refresh it.`,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: 'unauthorized',
|
|
61
|
+
test: /invalid_token|AuthRequired|www-authenticate|\b401\b/i,
|
|
62
|
+
short: 'it rejected the agent\'s token',
|
|
63
|
+
say: (who) => `${who} rejected the agent's token (401/invalid_token). Re-authenticate that server in the agent, then retry.`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: 'refused',
|
|
67
|
+
test: /HTTP 403|\b403\b/,
|
|
68
|
+
short: 'it is refusing requests',
|
|
69
|
+
say: (who) => `${who} returned 403 and an error page — that server is refusing requests or is temporarily down. This is outside ChatPanel; retry when it recovers.`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
// The off-network case: a VPN-only server seen from a cafe. Only counted when the text
|
|
73
|
+
// is talking about MCP at all, so an ordinary provider timeout is not misread as this.
|
|
74
|
+
kind: 'unreachable',
|
|
75
|
+
requiresMcpContext: true,
|
|
76
|
+
test: /handshaking with MCP server failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|dns error|connection (?:refused|reset)|failed to start and is unavailable|network is unreachable/i,
|
|
77
|
+
short: 'it could not be reached',
|
|
78
|
+
say: (who) => `${who} could not be reached — it is offline, or needs a network (VPN) this machine is not on. ChatPanel can't reach it either; retry when it is available.`,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Classify an MCP-server failure in an agent's output.
|
|
84
|
+
* @returns {{server: string|null, kind: string, short: string, say: string}|null}
|
|
85
|
+
*/
|
|
86
|
+
export function mcpFailure(text) {
|
|
87
|
+
const raw = String(text || '');
|
|
88
|
+
const server = mcpServerName(raw);
|
|
89
|
+
const hasMcpContext = /\bmcp\b/i.test(raw) || !!server;
|
|
90
|
+
for (const cause of MCP_CAUSES) {
|
|
91
|
+
if (cause.requiresMcpContext && !hasMcpContext) continue;
|
|
92
|
+
if (!cause.test.test(raw)) continue;
|
|
93
|
+
return {
|
|
94
|
+
server,
|
|
95
|
+
kind: cause.kind,
|
|
96
|
+
short: cause.short,
|
|
97
|
+
say: cause.say(server ? `its MCP server "${server}"` : 'one of its MCP servers'),
|
|
98
|
+
};
|
|
42
99
|
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The named causes worth translating. Each returns a sentence that says what to DO.
|
|
104
|
+
function knownCause(text) {
|
|
105
|
+
const mcp = mcpFailure(text);
|
|
106
|
+
if (mcp) return mcp.say;
|
|
43
107
|
if (/ENOENT|command not found/i.test(text)) return 'the command could not be found on PATH.';
|
|
44
108
|
return null;
|
|
45
109
|
}
|
package/src/engines/claude.js
CHANGED
|
@@ -49,6 +49,37 @@ const IDLE_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
|
|
|
49
49
|
// gated behind the agent's permission mode.
|
|
50
50
|
const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
|
|
51
51
|
|
|
52
|
+
// A remote (channel) caller declares the actor's trust tier via options.reach. Unlike
|
|
53
|
+
// permissionMode (which a local, already-trusted UI chooses), reach is a CEILING enforced HERE
|
|
54
|
+
// at the trust boundary — a paired-but-prompt-injected phone cannot exceed its tier no matter
|
|
55
|
+
// what the message says. This is the tool-authorization half of feature-f7 §7; pairing proves
|
|
56
|
+
// WHO, this constrains WHAT.
|
|
57
|
+
//
|
|
58
|
+
// Posture (security-first): the exfil chain needs read-a-secret AND an egress to send it. We cut
|
|
59
|
+
// the egress on every capped tier by dropping web tools, so even machine-wide reads can't phone
|
|
60
|
+
// home; writes/shell are never granted to a capped tier.
|
|
61
|
+
// device — conversational only: no filesystem, no web, no writes/shell.
|
|
62
|
+
// trusted — machine-wide READ (inspect your own files/projects), but NO web and NO writes/shell.
|
|
63
|
+
// any — no cap here (operator/local console); falls through to permissionMode below.
|
|
64
|
+
const CHANNEL_ALLOW = Object.freeze({
|
|
65
|
+
device: Object.freeze(['TodoWrite', 'Task']),
|
|
66
|
+
trusted: Object.freeze(['Read', 'Grep', 'Glob', 'TodoWrite', 'Task']),
|
|
67
|
+
});
|
|
68
|
+
// Belt-and-suspenders: the allow-list already omits these, but we also forbid them explicitly so
|
|
69
|
+
// a capped turn can never reach shell, writes, or a network egress even via an MCP alias.
|
|
70
|
+
const CHANNEL_DENY = Object.freeze(['Bash', 'Edit', 'Write', 'WebFetch', 'WebSearch']);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tool policy for a channel/remote caller. Returns { allow, deny } for a capped tier, or null
|
|
74
|
+
* when reach is absent or 'any' (no cap — the existing permissionMode logic applies). An unknown
|
|
75
|
+
* tier is treated as the MOST restrictive ('device'), never as "no cap" — fail closed.
|
|
76
|
+
*/
|
|
77
|
+
export function channelToolPolicy(reach) {
|
|
78
|
+
if (!reach || reach === 'any') return null;
|
|
79
|
+
const allow = CHANNEL_ALLOW[reach] || CHANNEL_ALLOW.device;
|
|
80
|
+
return { allow: [...allow], deny: [...CHANNEL_DENY] };
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
let lastReason = 'Claude Code not found.';
|
|
53
84
|
let lastProbe = 0;
|
|
54
85
|
let cachedOk = false;
|
|
@@ -272,10 +303,19 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
272
303
|
mcpAllow.push(...mcpConfig.allowedTools);
|
|
273
304
|
}
|
|
274
305
|
|
|
306
|
+
// A remote (channel) caller's reach tier caps the toolset and OVERRIDES permissionMode: a
|
|
307
|
+
// capped tier never gets --permission-mode, so writes/shell stay denied headlessly even if a
|
|
308
|
+
// message (or a bug upstream) asked to escalate. Local callers (no reach) keep the existing
|
|
309
|
+
// permissionMode behavior unchanged.
|
|
310
|
+
const channelPolicy = channelToolPolicy(options.reach);
|
|
311
|
+
if (channelPolicy) {
|
|
312
|
+
args.push('--allowedTools', ...channelPolicy.allow, ...mcpAllow);
|
|
313
|
+
args.push('--disallowedTools', ...channelPolicy.deny);
|
|
314
|
+
}
|
|
275
315
|
// Gate writes/shell behind the chosen mode; otherwise restrict to read-only
|
|
276
316
|
// tools so headless runs never block on an approval prompt. The relayed browser
|
|
277
317
|
// tools are always pre-allowed (the user explicitly armed them this turn).
|
|
278
|
-
if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
|
|
318
|
+
else if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
|
|
279
319
|
else if (permissionMode === 'acceptEdits') {
|
|
280
320
|
args.push('--permission-mode', 'acceptEdits');
|
|
281
321
|
if (mcpAllow.length) args.push('--allowedTools', ...mcpAllow);
|
package/src/engines/codex.js
CHANGED
|
@@ -25,6 +25,7 @@ import { buildCliPrompt } from './prompt.js';
|
|
|
25
25
|
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
26
26
|
import { resolveWorkdir } from '../workdir.js';
|
|
27
27
|
import { summarizeCliError } from '../cli-errors.js';
|
|
28
|
+
import { disabledMcpServers, planMcpRetry, quarantine } from '../mcp-quarantine.js';
|
|
28
29
|
|
|
29
30
|
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
30
31
|
// streaming never trips it — only true silence does. Override with
|
|
@@ -117,6 +118,40 @@ export function codexMcpConfigArgs(mcp) {
|
|
|
117
118
|
return args;
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// Which MCP servers this Codex actually has configured.
|
|
122
|
+
//
|
|
123
|
+
// READ THIS BEFORE TOUCHING codexDisableArgs. `-c mcp_servers.X.enabled=false` for a name
|
|
124
|
+
// that is NOT already in config.toml does not disable anything — it DEFINES a new server
|
|
125
|
+
// that has no transport, and Codex then refuses to start at all:
|
|
126
|
+
// Error: failed to load bootstrap configuration
|
|
127
|
+
// Caused by: invalid transport in `mcp_servers.does_not_exist`
|
|
128
|
+
// So a stale quarantine entry, or a typo in the user's deny list, would break EVERY run —
|
|
129
|
+
// strictly worse than the failure this feature exists to fix. We therefore only ever disable
|
|
130
|
+
// a server we can see, and a name we cannot see is left alone (a no-op, never a break).
|
|
131
|
+
//
|
|
132
|
+
// Read from config.toml — instant, and the same source listModels() already reads — rather
|
|
133
|
+
// than shelling out to `codex mcp list --json`, which is authoritative but costs ~0.8s on
|
|
134
|
+
// every single turn.
|
|
135
|
+
export function configuredMcpServers(home) {
|
|
136
|
+
try {
|
|
137
|
+
const cfg = readFileSync(path.join(home, 'config.toml'), 'utf8');
|
|
138
|
+
const names = new Set();
|
|
139
|
+
for (const m of cfg.matchAll(/^\s*\[mcp_servers\.([A-Za-z0-9_-]+)\s*[.\]]/gm)) names.add(m[1]);
|
|
140
|
+
return names;
|
|
141
|
+
} catch {
|
|
142
|
+
return new Set(); // no config (or the isolated home) — there is nothing to disable
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Turn off servers from the user's own config.toml for THIS RUN only — never by editing
|
|
147
|
+
// their file. Names are validated upstream (mcp-quarantine.js) and filtered against
|
|
148
|
+
// configuredMcpServers by the caller, which is what makes it safe to interpolate one into a
|
|
149
|
+
// `-c` override key; `-c` is otherwise blocked in extraArgs precisely because it reaches
|
|
150
|
+
// config that matters.
|
|
151
|
+
export function codexDisableArgs(names = []) {
|
|
152
|
+
return names.flatMap((name) => ['-c', `mcp_servers.${name}.enabled=false`]);
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
// Write base64 data-URL images to temp files so `codex exec -i <file>` can
|
|
121
156
|
// attach them to the prompt as vision input. Returns the paths (caller cleans up).
|
|
122
157
|
async function writeImages(images, tag) {
|
|
@@ -132,54 +167,15 @@ async function writeImages(images, tag) {
|
|
|
132
167
|
return files;
|
|
133
168
|
}
|
|
134
169
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const cwd = resolveWorkdir(options.workingDir);
|
|
142
|
-
const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
|
|
143
|
-
// Headless exec has no human to approve actions. With MCP/browser tools armed
|
|
144
|
-
// Codex would otherwise raise an approval prompt it can't show — and cancel the
|
|
145
|
-
// tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
|
|
146
|
-
// use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
|
|
147
|
-
// keep the sandbox + never-ask, which auto-runs within bounds.
|
|
148
|
-
if (options.permissionMode === 'bypassPermissions') {
|
|
149
|
-
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
150
|
-
} else {
|
|
151
|
-
const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
|
|
152
|
-
args.push('-s', sandbox, '-c', 'approval_policy=never');
|
|
153
|
-
}
|
|
154
|
-
if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
|
|
155
|
-
// Ask Codex to emit reasoning SUMMARIES so the panel can stream the model's thinking.
|
|
156
|
-
// Additive + safe: a no-op for models/providers that don't produce summaries (e.g. some
|
|
157
|
-
// hosted models expose none), and cheap at the default effort. forwardEvent renders them.
|
|
158
|
-
args.push('-c', 'model_reasoning_summary=auto');
|
|
159
|
-
// Browser tools: register the bridge's MCP server as a stdio MCP server (the
|
|
160
|
-
// bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
|
|
161
|
-
// `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
|
|
162
|
-
args.push(...codexMcpConfigArgs(options.mcp));
|
|
163
|
-
if (options.model) args.push('-m', options.model);
|
|
164
|
-
// Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
|
|
165
|
-
pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
|
|
166
|
-
for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
|
|
167
|
-
args.push('-');
|
|
168
|
-
|
|
169
|
-
// Default: use the user's skills/config. Opt-out → isolated home.
|
|
170
|
-
const useLocal = options.useLocalConfig !== false;
|
|
171
|
-
const env = { ...process.env };
|
|
172
|
-
if (!useLocal) {
|
|
173
|
-
const home = ensureIsolatedHome();
|
|
174
|
-
if (home) env.CODEX_HOME = home;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
await new Promise((resolve, reject) => {
|
|
170
|
+
// A single `codex exec` attempt. Resolves with how it ended rather than emitting the final
|
|
171
|
+
// message itself, so the caller can decide whether the run is worth ATTEMPTING AGAIN — the
|
|
172
|
+
// terminal delta/done belongs to whichever attempt actually answered.
|
|
173
|
+
function runCodex({ args, cwd, env, prompt, outFile, emit, signal }) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
178
175
|
let child;
|
|
179
176
|
try {
|
|
180
177
|
child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, ...spawnGroupOpts });
|
|
181
178
|
} catch (e) {
|
|
182
|
-
cleanupImages();
|
|
183
179
|
return reject(new Error(`Failed to start codex: ${e.message}`));
|
|
184
180
|
}
|
|
185
181
|
|
|
@@ -187,6 +183,13 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
187
183
|
|
|
188
184
|
let stdout = '';
|
|
189
185
|
let stderr = '';
|
|
186
|
+
// Retrying is only honest while nothing the user can see has been produced. Status lines
|
|
187
|
+
// are ours and are replaceable; a delta, a tool call or a reasoning summary is not.
|
|
188
|
+
let streamed = false;
|
|
189
|
+
const relay = (ev) => {
|
|
190
|
+
if (ev?.type !== 'status') streamed = true;
|
|
191
|
+
emit(ev);
|
|
192
|
+
};
|
|
190
193
|
// Per-run event state: correlate a command's started/completed events and emit each
|
|
191
194
|
// reasoning summary once (Codex sends the same item id across item.started/updated/completed).
|
|
192
195
|
const evState = { started: new Set(), reasoned: new Set(), n: 0 };
|
|
@@ -209,7 +212,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
209
212
|
stdout = stdout.slice(nl + 1);
|
|
210
213
|
if (!line.startsWith('{')) continue;
|
|
211
214
|
try {
|
|
212
|
-
forwardEvent(JSON.parse(line),
|
|
215
|
+
forwardEvent(JSON.parse(line), relay, evState);
|
|
213
216
|
} catch {
|
|
214
217
|
/* not a JSON event line */
|
|
215
218
|
}
|
|
@@ -219,7 +222,6 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
219
222
|
child.on('error', (e) => {
|
|
220
223
|
clearTimeout(idleTimer);
|
|
221
224
|
detach();
|
|
222
|
-
cleanupImages();
|
|
223
225
|
reject(e);
|
|
224
226
|
});
|
|
225
227
|
child.on('close', async (code) => {
|
|
@@ -232,22 +234,98 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
232
234
|
/* no message file */
|
|
233
235
|
}
|
|
234
236
|
unlink(outFile).catch(() => {});
|
|
235
|
-
|
|
236
|
-
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
|
|
237
|
-
if (code === 0) {
|
|
238
|
-
emit({ type: 'delta', text: text || '(no output)' });
|
|
239
|
-
emit({ type: 'done', text: '' });
|
|
240
|
-
resolve();
|
|
241
|
-
} else {
|
|
242
|
-
reject(new Error(summarizeCliError('Codex', code, stderr)));
|
|
243
|
-
}
|
|
237
|
+
resolve({ code, stderr, text, streamed });
|
|
244
238
|
});
|
|
245
239
|
|
|
246
|
-
child.stdin.write(
|
|
240
|
+
child.stdin.write(prompt);
|
|
247
241
|
child.stdin.end();
|
|
248
242
|
});
|
|
249
243
|
}
|
|
250
244
|
|
|
245
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
246
|
+
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
247
|
+
const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
|
|
248
|
+
const imageFiles = await writeImages(images, tag);
|
|
249
|
+
const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
|
|
250
|
+
|
|
251
|
+
const cwd = resolveWorkdir(options.workingDir);
|
|
252
|
+
// Sanitized once, not per attempt — pushExtraArgs emits a status when it drops something,
|
|
253
|
+
// and a retry must not say it twice.
|
|
254
|
+
const extraArgs = [];
|
|
255
|
+
pushExtraArgs(extraArgs, options.extraArgs, FORBIDDEN.codex, emit);
|
|
256
|
+
|
|
257
|
+
const buildArgs = (disabled) => {
|
|
258
|
+
const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
|
|
259
|
+
// Headless exec has no human to approve actions. With MCP/browser tools armed
|
|
260
|
+
// Codex would otherwise raise an approval prompt it can't show — and cancel the
|
|
261
|
+
// tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
|
|
262
|
+
// use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
|
|
263
|
+
// keep the sandbox + never-ask, which auto-runs within bounds.
|
|
264
|
+
if (options.permissionMode === 'bypassPermissions') {
|
|
265
|
+
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
266
|
+
} else {
|
|
267
|
+
const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
|
|
268
|
+
args.push('-s', sandbox, '-c', 'approval_policy=never');
|
|
269
|
+
}
|
|
270
|
+
if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
|
|
271
|
+
// Ask Codex to emit reasoning SUMMARIES so the panel can stream the model's thinking.
|
|
272
|
+
// Additive + safe: a no-op for models/providers that don't produce summaries (e.g. some
|
|
273
|
+
// hosted models expose none), and cheap at the default effort. forwardEvent renders them.
|
|
274
|
+
args.push('-c', 'model_reasoning_summary=auto');
|
|
275
|
+
// Browser tools: register the bridge's MCP server as a stdio MCP server (the
|
|
276
|
+
// bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
|
|
277
|
+
// `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
|
|
278
|
+
args.push(...codexMcpConfigArgs(options.mcp));
|
|
279
|
+
// ...and the other direction: the user's own servers we've been told to leave out.
|
|
280
|
+
args.push(...codexDisableArgs(disabled));
|
|
281
|
+
if (options.model) args.push('-m', options.model);
|
|
282
|
+
// Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
|
|
283
|
+
args.push(...extraArgs);
|
|
284
|
+
for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
|
|
285
|
+
args.push('-');
|
|
286
|
+
return args;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Default: use the user's skills/config. Opt-out → isolated home.
|
|
290
|
+
const useLocal = options.useLocalConfig !== false;
|
|
291
|
+
const env = { ...process.env };
|
|
292
|
+
if (!useLocal) {
|
|
293
|
+
const home = ensureIsolatedHome();
|
|
294
|
+
if (home) env.CODEX_HOME = home;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const ownServer = options.mcp?.serverName || 'chatpanel_browser';
|
|
298
|
+
// Only servers Codex already knows about can be switched off — see configuredMcpServers.
|
|
299
|
+
const known = configuredMcpServers(env.CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
|
|
300
|
+
let disabled = disabledMcpServers('codex', options, [ownServer]).filter((n) => known.has(n));
|
|
301
|
+
const prompt = buildCliPrompt(messages, system);
|
|
302
|
+
const attempt = () => runCodex({ args: buildArgs(disabled), cwd, env, prompt, outFile, emit, signal });
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
let result = await attempt();
|
|
306
|
+
|
|
307
|
+
// The failure this exists for: one of the user's OWN MCP servers could not authenticate
|
|
308
|
+
// or could not be reached, and took a turn with it that never needed that server. Drop it
|
|
309
|
+
// and run again — once, out loud, and only while nothing has been streamed.
|
|
310
|
+
if (!signal?.aborted && result.code !== 0 && !result.streamed) {
|
|
311
|
+
const drop = planMcpRetry({ agent: 'codex', text: result.stderr, protect: [ownServer], already: disabled });
|
|
312
|
+
if (drop && known.has(drop.server)) {
|
|
313
|
+
quarantine('codex', drop.server);
|
|
314
|
+
emit({ type: 'status', text: `MCP server "${drop.server}" failed to load — ${drop.short}. Skipping it and retrying.` });
|
|
315
|
+
disabled = [...disabled, drop.server];
|
|
316
|
+
result = await attempt();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (signal?.aborted) return; // Stop pressed — end quietly, no error
|
|
321
|
+
if (result.code !== 0) throw new Error(summarizeCliError('Codex', result.code, result.stderr));
|
|
322
|
+
emit({ type: 'delta', text: result.text || '(no output)' });
|
|
323
|
+
emit({ type: 'done', text: '' });
|
|
324
|
+
} finally {
|
|
325
|
+
cleanupImages();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
251
329
|
// Translate Codex `exec --json` events into the bridge's streaming vocabulary the panel
|
|
252
330
|
// renders richly. Codex sends item.started (in_progress) then item.completed for each item,
|
|
253
331
|
// reusing the item id — so we correlate a tool's start/done by that id and show its command,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-events/capability.js (npm @chatpanel/events).
|
|
3
|
+
// Edit there, then run: npm run sync:events
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve.
|
|
8
|
+
|
|
9
|
+
// The capability signature — one call shape a rule, a schedule, the user or a model all
|
|
10
|
+
// invoke identically, through one policy path.
|
|
11
|
+
//
|
|
12
|
+
// `actor` is the field that makes capabilities turn-independent; it is the whole of the
|
|
13
|
+
// "capabilities are not turn-shaped" principle expressed as data rather than as a
|
|
14
|
+
// subsystem.
|
|
15
|
+
//
|
|
16
|
+
// `requirements` is what the router dispatches on: not "which model" but "what must be
|
|
17
|
+
// true". {maxLatencyMs:100, deterministic:true, egress:'none'} selects class R or M on a
|
|
18
|
+
// host that can realize it — or REFUSES. Silently exceeding a declared budget is the
|
|
19
|
+
// failure mode this exists to prevent.
|
|
20
|
+
|
|
21
|
+
import { CLASSES, EFFECTS, EGRESS, ACTOR_KINDS, SCOPE_KINDS, EventError } from './event.js';
|
|
22
|
+
import { DATA_SCOPES } from './scopes.js';
|
|
23
|
+
import { validateView } from './view.js';
|
|
24
|
+
|
|
25
|
+
export { DATA_SCOPES } from './scopes.js';
|
|
26
|
+
|
|
27
|
+
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
28
|
+
const strs = (v, allowed = null) => Array.isArray(v) && v.every((x) => str(x) && (!allowed || allowed.includes(x)));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate a capability DECLARATION — the static surface a reviewer, a user or an admin
|
|
32
|
+
* approves BEFORE the capability runs. Everything here is readable without executing
|
|
33
|
+
* anything, which is what makes load-time approval possible.
|
|
34
|
+
*/
|
|
35
|
+
export function validateCapability(c) {
|
|
36
|
+
if (!c || typeof c !== 'object') throw new EventError('SHAPE', 'capability must be an object');
|
|
37
|
+
if (!str(c.id)) throw new EventError('SHAPE', 'capability.id required');
|
|
38
|
+
if (!str(c.version)) throw new EventError('SHAPE', 'capability.version required');
|
|
39
|
+
if (!CLASSES.includes(c.class)) throw new EventError('SHAPE', `capability.class must be one of ${CLASSES}`);
|
|
40
|
+
if (!strs(c.requires)) throw new EventError('SHAPE', 'capability.requires must be string[]');
|
|
41
|
+
if (!strs(c.provides)) throw new EventError('SHAPE', 'capability.provides must be string[]');
|
|
42
|
+
if (!strs(c.reads, DATA_SCOPES)) throw new EventError('SHAPE', `capability.reads must be within ${DATA_SCOPES}`);
|
|
43
|
+
if (!strs(c.writes, DATA_SCOPES)) throw new EventError('SHAPE', `capability.writes must be within ${DATA_SCOPES}`);
|
|
44
|
+
if (!EGRESS.includes(c.egress)) throw new EventError('SHAPE', `capability.egress must be one of ${EGRESS}`);
|
|
45
|
+
if (!EFFECTS.includes(c.effects)) throw new EventError('SHAPE', `capability.effects must be one of ${EFFECTS}`);
|
|
46
|
+
if (typeof c.invoke !== 'function') throw new EventError('SHAPE', 'capability.invoke required');
|
|
47
|
+
if (typeof c.disclose !== 'function') throw new EventError('SHAPE', 'capability.disclose required');
|
|
48
|
+
if (!c.output || typeof c.output.render !== 'function') {
|
|
49
|
+
throw new EventError('SHAPE', 'capability.output.render required — canonical value and rendering are separate');
|
|
50
|
+
}
|
|
51
|
+
// A capability MAY ship its own UI. Optional, and validated against this capability so a
|
|
52
|
+
// view can never name a capability its owner isn't already allowed to call.
|
|
53
|
+
if (c.view != null) validateView(c.view, c);
|
|
54
|
+
// A class-R capability that declares egress is a contradiction: R is a determinism
|
|
55
|
+
// guarantee, and a network round-trip is not deterministic.
|
|
56
|
+
if (c.class === 'R' && c.egress !== 'none') {
|
|
57
|
+
throw new EventError('CONTRADICTION', 'class R must declare egress:none');
|
|
58
|
+
}
|
|
59
|
+
return c;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate an INVOCATION. Enforces the one rule that is easiest to forget and worst to
|
|
64
|
+
* miss: a capability that is not `pure` cannot be invoked without an idempotency key,
|
|
65
|
+
* because a retried delegated call would otherwise perform the side effect twice.
|
|
66
|
+
*/
|
|
67
|
+
export function validateInvocation(inv, capability) {
|
|
68
|
+
if (!inv || typeof inv !== 'object') throw new EventError('SHAPE', 'invocation must be an object');
|
|
69
|
+
if (!str(inv.capability)) throw new EventError('SHAPE', 'invocation.capability required');
|
|
70
|
+
if (!inv.actor || !ACTOR_KINDS.includes(inv.actor.kind) || !str(inv.actor.id)) {
|
|
71
|
+
throw new EventError('SHAPE', `invocation.actor.kind must be one of ${ACTOR_KINDS}`);
|
|
72
|
+
}
|
|
73
|
+
if (!inv.scope || !SCOPE_KINDS.includes(inv.scope.kind) || !str(inv.scope.id)) {
|
|
74
|
+
throw new EventError('SHAPE', `invocation.scope.kind must be one of ${SCOPE_KINDS}`);
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(inv.causes)) throw new EventError('SHAPE', 'invocation.causes must be string[]');
|
|
77
|
+
const effects = capability ? capability.effects : inv.effects;
|
|
78
|
+
if (effects && effects !== 'pure' && !str(inv.idempotencyKey)) {
|
|
79
|
+
throw new EventError('IDEMPOTENCY', `invocation of a '${effects}' capability requires an idempotencyKey`);
|
|
80
|
+
}
|
|
81
|
+
return inv;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Can this capability satisfy these requirements on this host?
|
|
86
|
+
* Returns { ok, reasons[] } — REFUSING is a valid, expected outcome.
|
|
87
|
+
*
|
|
88
|
+
* `host` supplies what it can actually realize: { realizes: {R:{maxMs},M:{maxMs},...} }.
|
|
89
|
+
* Class is intrinsic (a guarantee); latency is host-bound. Never fuse the two.
|
|
90
|
+
*/
|
|
91
|
+
export function canSatisfy(capability, requirements = {}, host = null) {
|
|
92
|
+
const reasons = [];
|
|
93
|
+
const { maxLatencyMs, deterministic, egress, maxCostUsd } = requirements;
|
|
94
|
+
|
|
95
|
+
if (deterministic === true && !['R', 'M'].includes(capability.class)) {
|
|
96
|
+
reasons.push(`class ${capability.class} is not deterministic`);
|
|
97
|
+
}
|
|
98
|
+
if (egress === 'none' && capability.egress !== 'none') {
|
|
99
|
+
reasons.push(`capability egresses '${capability.egress}', requirement is 'none'`);
|
|
100
|
+
}
|
|
101
|
+
if (egress === 'redacted' && capability.egress === 'delegated') {
|
|
102
|
+
reasons.push('delegated egress is not controlled, requirement is redacted');
|
|
103
|
+
}
|
|
104
|
+
if (maxLatencyMs != null && host) {
|
|
105
|
+
const realized = host.realizes && host.realizes[capability.class];
|
|
106
|
+
if (!realized) reasons.push(`host cannot realize class ${capability.class}`);
|
|
107
|
+
else if (realized.maxMs > maxLatencyMs) {
|
|
108
|
+
reasons.push(`host realizes class ${capability.class} at ~${realized.maxMs}ms, requirement is ${maxLatencyMs}ms`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (maxCostUsd != null && capability.class === 'C' && maxCostUsd <= 0) {
|
|
112
|
+
reasons.push('cloud class requires a positive cost ceiling');
|
|
113
|
+
}
|
|
114
|
+
return { ok: reasons.length === 0, reasons };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The model-facing projection — an ALLOWLIST built from exactly three fields.
|
|
119
|
+
*
|
|
120
|
+
* Never an omit-list. An omit-list leaks the next field someone adds; this cannot,
|
|
121
|
+
* because `invoke`, `effects`, `cost`, `writes` and `egress` are never copied.
|
|
122
|
+
*/
|
|
123
|
+
export function toModelSchema(capability) {
|
|
124
|
+
return {
|
|
125
|
+
name: capability.id,
|
|
126
|
+
description: capability.disclose().gist,
|
|
127
|
+
parameters: capability.input || { type: 'object', properties: {} },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The same allowlist over a toolset — the only supported way to build a model request. */
|
|
132
|
+
export function toModelSchemas(capabilities) {
|
|
133
|
+
return capabilities.map(toModelSchema);
|
|
134
|
+
}
|