@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/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
- // The named causes worth translating. Each returns a sentence that says what to DO.
31
- function knownCause(text) {
32
- const server = /refresh OAuth tokens for server ([\w.-]+)/i.exec(text)?.[1]
33
- || /server ['"]?([\w.-]+)['"]? (?:requires|failed) auth/i.exec(text)?.[1];
34
- if (/refresh token (?:does not exist|was rejected)|invalid_grant/i.test(text)) {
35
- return `${server ? `its MCP server "${server}"` : 'one of its MCP servers'} 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.`;
36
- }
37
- if (/invalid_token|AuthRequired|www-authenticate|\b401\b/i.test(text)) {
38
- return `${server ? `its MCP server "${server}"` : 'one of its MCP servers'} rejected the agent's token (401/invalid_token). Re-authenticate that server in the agent, then retry.`;
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
- if (/HTTP 403|\b403\b/.test(text)) {
41
- return `${server ? `its MCP server "${server}"` : 'one of its MCP servers'} returned 403 and an error page — that server is refusing requests or is temporarily down. This is outside ChatPanel; retry when it recovers.`;
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
  }
@@ -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);
@@ -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
- export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
136
- const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
137
- const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
138
- const imageFiles = await writeImages(images, tag);
139
- const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
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), emit, evState);
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
- cleanupImages();
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(buildCliPrompt(messages, system));
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
+ }