@dotdrelle/wiki-manager 0.14.23 → 0.15.26

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/README.md CHANGED
@@ -517,6 +517,45 @@ process environment (including the `.env` loaded at startup):
517
517
  Copy `mcp.endpoints.example.json` to `mcp.endpoints.json` and set the matching
518
518
  token variables in `.env`.
519
519
 
520
+ ### `chatAccess`: which tools chat may use
521
+
522
+ Declaring a server above makes its tools available to **`/agent`** — no further
523
+ declaration, ever. Plug in a new MCP and Donna discovers and uses its tools
524
+ immediately.
525
+
526
+ The optional `chatAccess` block is the authorization layer for **`/chat`**,
527
+ which is closed by default. It decides which tools chat may use, and nothing
528
+ else:
529
+
530
+ ```json
531
+ "chatAccess": {
532
+ "maxToolIterations": 8,
533
+ "servers": {
534
+ "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] },
535
+ "exa": { "allow": ["*"] }
536
+ }
537
+ }
538
+ ```
539
+
540
+ | entry | effect in `/chat` |
541
+ | --- | --- |
542
+ | server absent | none of its tools — agent-only |
543
+ | `"allow": ["*"]` | every tool the server exposes |
544
+ | `"allow": [names]` | exactly those tools |
545
+
546
+ Every server uses this one shape. The list is authoritative: naming a tool is
547
+ the decision, whatever the tool is called. No name heuristic filters it — a
548
+ tool name is not a contract, and a third-party MCP is free to name its tools
549
+ however it likes.
550
+
551
+ `/chat` carries no plan: it performs direct unitary actions only. The
552
+ orchestration entry points (`agent_plan`, `agent_execute`,
553
+ `production_start_job`, and plan mutation) are therefore never offered to it,
554
+ including under `"*"`. Multi-step work belongs to `/agent`.
555
+
556
+ An `allowActions` key written by an older manager is folded into `allow` on
557
+ read and removed on the next `agents up`.
558
+
520
559
  MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
521
560
  They also share a per-endpoint outbound control budget (45 RPM by default,
522
561
  configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
@@ -642,10 +681,11 @@ start/state secrets when missing. It adds a regular, standard MCP `connectors`
642
681
  entry to `mcp.endpoints.json`. Setting `CONNECTORS_ENABLED=false` and running
643
682
  `agents up` removes that entry again, so disabled services are not probed. No
644
683
  non-standard `enabled` property is written to MCP configuration files.
645
- The matching `chatAccess.connectors` policy is managed at the same time. Its
646
- read-only `allow` list exposes `connectors_google_status`, while the explicit
647
- `allowActions` list exposes only `connectors_google_oauth_start`. Orchestration
648
- tools such as `agent_execute` are never exposed directly to served chat.
684
+ The matching `chatAccess.connectors` policy is managed at the same time, in the
685
+ same shape as every other server — a single `allow` list holding
686
+ `connectors_google_status` and `connectors_google_oauth_start`, so chat can
687
+ report the authorization state and start it. See
688
+ [`chatAccess`](#chataccess-which-tools-chat-may-use).
649
689
 
650
690
  Connector authorization is also available without asking the LLM. These two
651
691
  commands work in both the Shell UI and the `llm-wiki serve` chat:
@@ -19,6 +19,8 @@
19
19
  # GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET — OAuth application
20
20
  # GOOGLE_OAUTH_CALLBACK_URL — exact public post-proxy callback URL
21
21
  # OAUTH_STATE_SECRET / OAUTH_START_TOKEN — distinct 32+ byte secrets
22
+ # CONNECTORS_SEND_ENABLED — true; set to false to disable outbound email entirely
23
+ # CONNECTORS_SEND_ALLOWED_RECIPIENTS — comma-separated addresses or @domain suffixes
22
24
  # DOCUMENT_LLM_BASE_URL — https://albert.api.etalab.gouv.fr/v1
23
25
  # DOCUMENT_LLM_MODEL — lightonai/LightOnOCR-2-1B
24
26
  # DOCUMENT_LLM_API_KEY — OpenAI/OpenAI-compatible API key for document OCR
@@ -109,6 +111,10 @@ services:
109
111
  - OAUTH_STATE_TTL_SECONDS=${OAUTH_STATE_TTL_SECONDS:-600}
110
112
  - CONNECTORS_RECOMMENDED_CONCURRENCY=${CONNECTORS_RECOMMENDED_CONCURRENCY:-2}
111
113
  - CONNECTORS_MAX_CONCURRENCY=${CONNECTORS_MAX_CONCURRENCY:-4}
114
+ - CONNECTORS_SEND_ENABLED=${CONNECTORS_SEND_ENABLED:-true}
115
+ - CONNECTORS_SEND_ALLOWED_RECIPIENTS=${CONNECTORS_SEND_ALLOWED_RECIPIENTS:-}
116
+ - CONNECTORS_SEND_MAX_RECIPIENTS=${CONNECTORS_SEND_MAX_RECIPIENTS:-25}
117
+ - CONNECTORS_SEND_MAX_BODY_BYTES=${CONNECTORS_SEND_MAX_BODY_BYTES:-262144}
112
118
  - NODE_USE_ENV_PROXY=${NODE_USE_ENV_PROXY:-}
113
119
  - HTTPS_PROXY=${HTTPS_PROXY:-}
114
120
  - HTTP_PROXY=${HTTP_PROXY:-}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.23",
3
+ "version": "0.15.26",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -1083,7 +1083,7 @@ export function isDonnaReadTool(item) {
1083
1083
  // production agent) is delegated for its DAG/parallelism; plain single-step
1084
1084
  // tools are called directly. This is a blocklist, not a whitelist, so adding a
1085
1085
  // new MCP never silently disables its tools.
1086
- function isOrchestrationBypassTool(name) {
1086
+ export function isOrchestrationBypassTool(name) {
1087
1087
  const full = String(name ?? '');
1088
1088
  if (!full) return true;
1089
1089
  if (full === 'wiki__plan_set' || full === 'wiki__plan_done') return true;
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.23",
3
- "commit": "f0519ef"
2
+ "version": "0.15.26",
3
+ "commit": "1af3dea"
4
4
  }
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.14.23';
4
+ const WIKI_MANAGER_VERSION = '0.15.26';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -64,11 +64,18 @@ function normalizeExternalUrlForRuntime(url) {
64
64
  return url;
65
65
  }
66
66
 
67
- // Config-driven policy for the /chat read-only toolset — NOT /agent, which has
68
- // the full toolset and ignores this. The endpoints file's "chatAccess" block
69
- // declares, per server, which tools /chat may call ("*" or a list), plus a
70
- // maxToolIterations budget. Operator-owned, agnostic allow-list. Returns null
71
- // when not configured — then /chat stays a plain, tool-less conversation.
67
+ // Config-driven policy for the /chat toolset — NOT /agent, which has the full
68
+ // toolset and ignores this. The endpoints file's "chatAccess" block declares,
69
+ // per server, which tools /chat may call ("*" or a list), plus a
70
+ // maxToolIterations budget. Every server uses the SAME shape: one `allow` key.
71
+ // Operator-owned, agnostic allow-list. Returns null when not configured — then
72
+ // /chat stays a plain, tool-less conversation.
73
+ //
74
+ // `allowActions` is a legacy key from the connectors work: it carved out a
75
+ // second list for tools the read-verb heuristic rejected, which made one
76
+ // server's entry shaped differently from every other. It is folded into
77
+ // `allow` on read so existing installs keep working without regenerating
78
+ // their endpoints file, but nothing writes it any more.
72
79
  export function readChatAccessConfig() {
73
80
  const filePath = managerMcpEndpointsFile();
74
81
  if (!existsSync(filePath)) return null;
@@ -81,14 +88,15 @@ export function readChatAccessConfig() {
81
88
  // "*" is also commonly written as a one-element array (["*"]) since every
82
89
  // other "allow" example in this config is an array of tool names — treat
83
90
  // both forms as the same wildcard rather than silently allowing nothing.
91
+ const legacyActions = Array.isArray(entry?.allowActions)
92
+ ? entry.allowActions.map(String).filter(Boolean)
93
+ : [];
84
94
  if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
85
95
  servers[name] = { allow: '*' };
86
96
  } else if (Array.isArray(entry?.allow)) {
87
- servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
88
- }
89
- if (Array.isArray(entry?.allowActions)) {
90
- servers[name] ??= { allow: [] };
91
- servers[name].allowActions = entry.allowActions.map(String).filter(Boolean);
97
+ servers[name] = { allow: [...new Set([...entry.allow.map(String).filter(Boolean), ...legacyActions])] };
98
+ } else if (legacyActions.length > 0) {
99
+ servers[name] = { allow: legacyActions };
92
100
  }
93
101
  }
94
102
  const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
@@ -115,7 +115,7 @@ export async function execute(task, assignment, {
115
115
  jobId,
116
116
  status: lastStatus?.result?.status ?? lastStatus?.status,
117
117
  outputs: lastStatus?.result?.outputRefs ?? [],
118
- error: lastStatus?.result?.error?.code ?? lastStatus?.result?.error?.message ?? null,
118
+ error: normalizeTaskError(lastStatus?.result?.error)?.code ?? null,
119
119
  detail: 'terminal status',
120
120
  }));
121
121
  return taskResultFromStatus(task, assignment, jobId, lastStatus, attempt);
@@ -135,7 +135,7 @@ export async function execute(task, assignment, {
135
135
  jobId,
136
136
  status: lastStatus?.result?.status ?? lastStatus?.status,
137
137
  outputs: lastStatus?.result?.outputRefs ?? [],
138
- error: lastStatus?.result?.error?.code ?? lastStatus?.result?.error?.message ?? null,
138
+ error: normalizeTaskError(lastStatus?.result?.error)?.code ?? null,
139
139
  detail: 'terminal status',
140
140
  }));
141
141
  return taskResultFromStatus(task, assignment, jobId, lastStatus, attempt);
@@ -213,20 +213,49 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
213
213
  status: result.status ?? statusPayload?.status,
214
214
  outputRefs: Array.isArray(result.outputRefs) ? result.outputRefs : [],
215
215
  metrics: result.metrics ?? {},
216
- error: result.error ?? null,
216
+ error: normalizeTaskError(result.error),
217
217
  rawStatus: statusPayload,
218
218
  };
219
219
  }
220
220
 
221
+ // An agent may report a terminal failure either as an object ({code, message})
222
+ // or as a bare reason string — the contract mandates neither, and the
223
+ // executor-only agents report the latter. Consumers (runner logs, the UIs)
224
+ // only ever read `.code`/`.message`, so an unnormalized string silently
225
+ // became `null` and every failure surfaced as a bare "failed" with no reason.
226
+ // Normalize once, here, so the shape is uniform for every downstream reader.
227
+ export function normalizeTaskError(rawError, { fallbackCode = null, fallbackMessage = null } = {}) {
228
+ if (rawError && typeof rawError === 'object') {
229
+ const code = String(rawError.code ?? rawError.message ?? fallbackCode ?? 'failed');
230
+ return {
231
+ code,
232
+ message: String(rawError.message ?? rawError.code ?? fallbackMessage ?? code),
233
+ retryable: rawError.retryable === true
234
+ || transientError(rawError.code)
235
+ || transientError(rawError.message),
236
+ };
237
+ }
238
+ if (rawError === null || rawError === undefined || String(rawError).trim() === '') {
239
+ if (!fallbackCode) return null;
240
+ return {
241
+ code: String(fallbackCode),
242
+ message: String(fallbackMessage ?? fallbackCode),
243
+ retryable: transientError(fallbackCode),
244
+ };
245
+ }
246
+ const code = String(rawError);
247
+ return {
248
+ code,
249
+ message: String(fallbackMessage ?? code),
250
+ retryable: transientError(code),
251
+ };
252
+ }
253
+
221
254
  function rejectedTaskResult(task, assignment, payload, attempt = null) {
222
- const rawError = payload?.error;
223
- const error = rawError && typeof rawError === 'object'
224
- ? { ...rawError }
225
- : {
226
- code: String(rawError ?? 'execution_rejected'),
227
- message: String(payload?.message ?? rawError ?? 'agent_execute rejected task'),
228
- retryable: transientError(rawError ?? payload?.message),
229
- };
255
+ const error = normalizeTaskError(payload?.error, {
256
+ fallbackCode: 'execution_rejected',
257
+ fallbackMessage: payload?.message ?? 'agent_execute rejected task',
258
+ });
230
259
  return {
231
260
  ok: false,
232
261
  taskId: String(task.id ?? task.step),
@@ -236,11 +265,7 @@ function rejectedTaskResult(task, assignment, payload, attempt = null) {
236
265
  status: 'failed',
237
266
  outputRefs: [],
238
267
  metrics: {},
239
- error: {
240
- code: String(error.code ?? 'execution_rejected'),
241
- message: String(error.message ?? error.code ?? 'agent_execute rejected task'),
242
- retryable: error.retryable === true || transientError(error.code) || transientError(error.message),
243
- },
268
+ error,
244
269
  rawStatus: payload,
245
270
  };
246
271
  }
@@ -80,3 +80,82 @@ test('dispatcher completes when an executor-only agent reports succeeded', async
80
80
  assert.equal(result.status, 'succeeded');
81
81
  assert.equal(result.jobId, 'job-connectors');
82
82
  });
83
+
84
+ test('dispatcher normalizes a bare string error reported on a terminal agent_status', async () => {
85
+ const session = {
86
+ workspace: 'test',
87
+ mcp: {
88
+ connectors: {
89
+ status: 'connected',
90
+ tools: [
91
+ { name: 'agent_execute' },
92
+ { name: 'agent_status' },
93
+ { name: 'agent_cancel' },
94
+ ],
95
+ },
96
+ },
97
+ activities: {},
98
+ };
99
+ const dispatcher = createDispatcher({
100
+ session,
101
+ pollIntervalMs: 1,
102
+ callTool: async (_mcp, _server, tool) => {
103
+ if (tool === 'agent_execute') {
104
+ return { accepted: true, jobId: 'job-connectors', status: 'queued' };
105
+ }
106
+ return {
107
+ jobId: 'job-connectors',
108
+ status: 'failed',
109
+ terminal: true,
110
+ // Executor-only agents report the reason as a bare string, which the
111
+ // contract permits; it must not be swallowed into a bare "failed".
112
+ result: { status: 'failed', error: 'authentication_required' },
113
+ };
114
+ },
115
+ });
116
+
117
+ const result = await dispatcher.execute(
118
+ {
119
+ id: 'collect-mail',
120
+ requiredCapability: 'external-source.collect',
121
+ operation: 'collect',
122
+ arguments: {},
123
+ },
124
+ { serverName: 'connectors', agentInstanceId: 'connectors' },
125
+ { attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
126
+ );
127
+
128
+ assert.equal(result.ok, false);
129
+ assert.equal(result.error.code, 'authentication_required');
130
+ assert.equal(result.error.message, 'authentication_required');
131
+ assert.equal(result.error.retryable, false);
132
+ });
133
+
134
+ test('dispatcher keeps a null error when a terminal status reports none', async () => {
135
+ const session = {
136
+ workspace: 'test',
137
+ mcp: {
138
+ connectors: {
139
+ status: 'connected',
140
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
141
+ },
142
+ },
143
+ activities: {},
144
+ };
145
+ const dispatcher = createDispatcher({
146
+ session,
147
+ pollIntervalMs: 1,
148
+ callTool: async (_mcp, _server, tool) => (tool === 'agent_execute'
149
+ ? { accepted: true, jobId: 'job-connectors', status: 'queued' }
150
+ : { jobId: 'job-connectors', status: 'succeeded', terminal: true, result: { status: 'succeeded' } }),
151
+ });
152
+
153
+ const result = await dispatcher.execute(
154
+ { id: 'collect-mail', requiredCapability: 'external-source.collect', operation: 'collect', arguments: {} },
155
+ { serverName: 'connectors', agentInstanceId: 'connectors' },
156
+ { attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
157
+ );
158
+
159
+ assert.equal(result.ok, true);
160
+ assert.equal(result.error, null);
161
+ });
@@ -841,7 +841,9 @@ async function runDispatchedTask(task, {
841
841
  assignment,
842
842
  jobId: result?.jobId,
843
843
  error: result?.error?.code ?? result?.error?.message ?? result?.status ?? 'failed',
844
- detail: 'task terminal',
844
+ // Carry the agent's own reason instead of a constant: "task terminal"
845
+ // told the operator nothing about WHY the task ended.
846
+ detail: result?.error?.message ?? result?.error?.code ?? 'task terminal',
845
847
  }));
846
848
  return { ok: false, taskId, result, assignment };
847
849
  }
package/src/shell/repl.js CHANGED
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import { stdin as input, stdout as output } from 'node:process';
8
8
  import { marked } from 'marked';
9
9
  import { markedTerminal } from 'marked-terminal';
10
- import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isDonnaReadTool } from '../agent/graph.js';
10
+ import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
11
11
  import { handleSlashCommand } from '../commands/slash.js';
12
12
  import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
13
13
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
@@ -291,24 +291,43 @@ function isDonnaRole(role) {
291
291
  return role === 'donna' || role === LEGACY_DONNA_ROLE;
292
292
  }
293
293
 
294
- // Read-only MCP tools exposed to /chat. The operator declares which tools per
295
- // server in the `chatAccess` config (mcp.endpoints.json). /chat is extended to
296
- // those tools ONLY, filtered through the same read-only test Donna's /agent
297
- // mode uses (isDonnaReadTool), and only when their server is connected — so
298
- // /chat can answer live state questions ("le CME est-il configuré ?") but can
299
- // never mutate or delegate. Actions still belong to /agent, which has all tools.
300
- export function chatReadTools(session) {
294
+ // MCP tools exposed to /chat. `chatAccess` (mcp.endpoints.json) is a per-mode
295
+ // authorization layer deciding which tools /chat may use — nothing else. It is
296
+ // the operator's declaration and it is AUTHORITATIVE:
297
+ //
298
+ // - server absent → none of its tools exist in /chat. Closed by default.
299
+ // - "allow": ["*"] → every tool the server exposes.
300
+ // - "allow": [names] → exactly those. Taking the time to name a tool IS the
301
+ // decision, whatever the name looks like.
302
+ //
303
+ // /agent is unaffected: it uses every discovered, connected server with no
304
+ // chatAccess declaration at all, so plugging in a new MCP makes it work with
305
+ // Donna immediately.
306
+ //
307
+ // This used to be double-gated by a read-verb name heuristic (isDonnaReadTool),
308
+ // which silently dropped an explicitly allow-listed tool that did not look
309
+ // read-only — the reason a second `allowActions` key had to be invented for
310
+ // connectors_google_oauth_start. A tool name is not a contract: the heuristic
311
+ // also classified `collect` as a read while external-source.collect writes to
312
+ // the workspace, and it would mis-sort any third-party MCP naming its tools
313
+ // differently. Gone.
314
+ //
315
+ // isOrchestrationBypassTool stays: /chat carries no plan, only direct unitary
316
+ // actions. agent_plan/agent_execute/production_start_job and plan mutation
317
+ // would start work outside the plan and its approval gate.
318
+ export function chatAllowedTools(session) {
301
319
  const servers = session?.chatAccess?.servers;
302
320
  if (!servers) return [];
303
321
  const scopedMcp = Object.fromEntries(
304
322
  Object.entries(session.mcp ?? {}).filter(([name]) => Object.hasOwn(servers, name)),
305
323
  );
306
324
  return buildLlmTools(scopedMcp).filter((item) => {
307
- const { server, tool } = parseToolCallName(item.function.name);
325
+ const name = item.function.name;
326
+ if (isOrchestrationBypassTool(name)) return false;
327
+ const { server, tool } = parseToolCallName(name);
308
328
  const entry = servers[server];
309
- const declared = entry.allow === '*' ? true : (Array.isArray(entry.allow) && entry.allow.includes(tool));
310
- const declaredAction = Array.isArray(entry.allowActions) && entry.allowActions.includes(tool);
311
- return (declared && isDonnaReadTool(item)) || declaredAction;
329
+ if (entry.allow === '*') return true;
330
+ return Array.isArray(entry.allow) && entry.allow.includes(tool);
312
331
  });
313
332
  }
314
333
 
@@ -1300,15 +1319,17 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
1300
1319
  return {};
1301
1320
  }
1302
1321
 
1303
- // Bounded read-only tool loop for /chat. Only the tools declared in chatAccess
1304
- // (and read-only) are offered; every call goes through callMcpTool, and any
1305
- // tool the model names outside the offered set is refused — /chat can never
1306
- // mutate or delegate. maxToolIterations caps the loop.
1307
- async function runChatReadToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, readTools, openWikiPages, contextMessages = [] }) {
1308
- const allowed = new Set(readTools.map((item) => item.function.name));
1322
+ // Bounded tool loop for /chat. Only the tools chatAccess authorizes for this
1323
+ // server are offered; every call goes through callMcpTool, and any tool the
1324
+ // model names outside the offered set is refused. /chat performs direct
1325
+ // unitary actions only — it never plans or delegates, which is why the
1326
+ // orchestration tools are filtered out upstream. maxToolIterations caps the
1327
+ // loop.
1328
+ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools, openWikiPages, contextMessages = [] }) {
1329
+ const allowed = new Set(allowedTools.map((item) => item.function.name));
1309
1330
  // /chat only supplies the policy; the loop mechanic lives in runBoundedToolLoop.
1310
1331
  // executeCall enforces the allow-list and turns each call into a text result;
1311
- // it never mutates and refuses anything outside the offered read set.
1332
+ // it refuses anything outside the offered set.
1312
1333
  const executeCall = async (call) => {
1313
1334
  const rawName = call.function?.name ?? '';
1314
1335
  const { server, tool } = resolveToolCallName(session.mcp, rawName);
@@ -1331,7 +1352,7 @@ async function runChatReadToolLoop({ input, session, history, donnaMessage, onUp
1331
1352
  llm: session.llm,
1332
1353
  system: buildDirectChatSystemPrompt(session, openWikiPages),
1333
1354
  messages: [...history, ...contextMessages, { role: 'user', content: input }],
1334
- tools: readTools,
1355
+ tools: allowedTools,
1335
1356
  executeCall,
1336
1357
  maxIterations: Math.min(8, Number(session?.chatAccess?.maxToolIterations) || 4),
1337
1358
  signal: session._abortSignal,
@@ -1355,11 +1376,11 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1355
1376
  const donnaMessage = { role: 'donna', content: '' };
1356
1377
  messages.push(donnaMessage);
1357
1378
  onUpdate?.();
1358
- const readTools = chatReadTools(session);
1359
- const canUseReadTools = readTools.length > 0 && typeof session.llm.completeWithTools === 'function';
1379
+ const allowedTools = chatAllowedTools(session);
1380
+ const canUseTools = allowedTools.length > 0 && typeof session.llm.completeWithTools === 'function';
1360
1381
  try {
1361
- if (canUseReadTools) {
1362
- await runChatReadToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, readTools });
1382
+ if (canUseTools) {
1383
+ await runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools });
1363
1384
  } else {
1364
1385
  onStep?.('Chat: streaming direct answer…');
1365
1386
  for await (const delta of session.llm.stream({
@@ -1392,14 +1413,14 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1392
1413
  }
1393
1414
 
1394
1415
  // Headless equivalent of runDirectChatTurn for HTTP callers (the runtime /turn
1395
- // in chat mode). Reuses the exact same read-only policy — chatReadTools +
1396
- // runChatReadToolLoop + buildDirectChatSystemPrompt — so there is no second
1416
+ // in chat mode). Reuses the exact same authorization policy — chatAllowedTools
1417
+ // + runChatToolLoop + buildDirectChatSystemPrompt — so there is no second
1397
1418
  // implementation of chat access; it just returns the final text instead of
1398
1419
  // driving a live repl bubble. The caller must have seeded session.chatAccess
1399
- // (and session.mcp) so chatReadTools can resolve the allow-listed read tools.
1420
+ // (and session.mcp) so chatAllowedTools can resolve the allow-listed tools.
1400
1421
  export async function runHeadlessChatTurn(session, input, { history = [], onStep, openWikiPages, openWikiPage } = {}) {
1401
1422
  const donnaMessage = { role: 'donna', content: '' };
1402
- const readTools = chatReadTools(session);
1423
+ const allowedTools = chatAllowedTools(session);
1403
1424
  // Keep the singular option as a compatibility input for older serve builds.
1404
1425
  // Only paths enter the prompt; reading remains the model's generic tool call.
1405
1426
  const selectedPages = sanitizeOpenWikiPages(openWikiPages ?? openWikiPage);
@@ -1408,9 +1429,9 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1408
1429
  // (up to five) are each folded in as their own delimited block.
1409
1430
  const attachedDocs = await readSelectedPageDocuments(session, selectedPages);
1410
1431
  const contextMessages = buildAttachedDocMessages(attachedDocs);
1411
- const canUseReadTools = readTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1412
- if (canUseReadTools) {
1413
- await runChatReadToolLoop({ input, session, history, donnaMessage, onStep, readTools, openWikiPages: selectedPages, contextMessages });
1432
+ const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1433
+ if (canUseTools) {
1434
+ await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
1414
1435
  return donnaMessage.content;
1415
1436
  }
1416
1437
  if (typeof session.llm?.stream === 'function') {
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  applyRuntimeStateToShellSession,
8
- chatReadTools,
8
+ chatAllowedTools,
9
9
  createSession,
10
10
  runHeadlessChatTurn,
11
11
  sanitizeOpenWikiPage,
@@ -622,7 +622,7 @@ test('submitRuntimeRun sends a control message instead of /run while a run is ac
622
622
  }
623
623
  });
624
624
 
625
- test('chatReadTools exposes only declared, read-only MCP tools to /chat', () => {
625
+ test('chatAllowedTools exposes exactly the declared MCP tools to /chat', () => {
626
626
  const session = {
627
627
  chatAccess: {
628
628
  servers: {
@@ -645,13 +645,14 @@ test('chatReadTools exposes only declared, read-only MCP tools to /chat', () =>
645
645
  },
646
646
  },
647
647
  };
648
- const names = chatReadTools(session).map((item) => item.function.name).sort();
649
- // cme_setup: not declared. cme_export_run: declared but a write (excluded by
650
- // the read-only guard). documents_status: server absent from chatAccess.
651
- assert.deepEqual(names, ['cme__cme_sources_list', 'cme__cme_status']);
648
+ const names = chatAllowedTools(session).map((item) => item.function.name).sort();
649
+ // cme_setup: not declared. documents_status: server absent from chatAccess.
650
+ // cme_export_run: declared, so it is offered — the allow-list is the
651
+ // operator's explicit decision, not a suggestion filtered by a heuristic.
652
+ assert.deepEqual(names, ['cme__cme_export_run', 'cme__cme_sources_list', 'cme__cme_status']);
652
653
  });
653
654
 
654
- test('chatReadTools accepts wiki_collect_context ("collect" is a read verb)', () => {
655
+ test('chatAllowedTools offers every declared tool, reads and writes alike', () => {
655
656
  const session = {
656
657
  chatAccess: {
657
658
  servers: {
@@ -669,39 +670,122 @@ test('chatReadTools accepts wiki_collect_context ("collect" is a read verb)', ()
669
670
  },
670
671
  },
671
672
  };
672
- const names = chatReadTools(session).map((item) => item.function.name).sort();
673
- // wiki_write_page: declared but a write — excluded by the read-only guard
674
- // even if an operator allow-lists it by mistake.
675
- assert.deepEqual(names, ['wiki__wiki_collect_context', 'wiki__wiki_search_context']);
673
+ const names = chatAllowedTools(session).map((item) => item.function.name).sort();
674
+ assert.deepEqual(
675
+ names,
676
+ ['wiki__wiki_collect_context', 'wiki__wiki_search_context', 'wiki__wiki_write_page'],
677
+ );
678
+ });
679
+
680
+ test('chatAllowedTools "*" offers every tool of the server, writes included', () => {
681
+ const session = {
682
+ chatAccess: { servers: { exa: { allow: '*' } } },
683
+ mcp: {
684
+ exa: {
685
+ status: 'connected',
686
+ tools: [
687
+ { name: 'web_search_exa', inputSchema: { type: 'object', properties: {} } },
688
+ { name: 'crawling_exa', inputSchema: { type: 'object', properties: {} } },
689
+ { name: 'deep_researcher_start', inputSchema: { type: 'object', properties: {} } },
690
+ ],
691
+ },
692
+ documents: {
693
+ status: 'connected',
694
+ tools: [{ name: 'documents_status', inputSchema: { type: 'object', properties: {} } }],
695
+ },
696
+ },
697
+ };
698
+ // No name is inspected: "*" is the operator saying "this whole server".
699
+ // documents stays out — it is absent from chatAccess, so it is agent-only.
700
+ assert.deepEqual(
701
+ chatAllowedTools(session).map((item) => item.function.name).sort(),
702
+ ['exa__crawling_exa', 'exa__deep_researcher_start', 'exa__web_search_exa'],
703
+ );
676
704
  });
677
705
 
678
- test('chatReadTools accepts only actions explicitly declared in allowActions', () => {
706
+ test('chatAllowedTools offers an action no read-verb heuristic would accept', () => {
679
707
  const session = {
680
708
  chatAccess: {
681
709
  servers: {
682
- connectors: { allow: [], allowActions: ['connectors_google_oauth_start'] },
710
+ connectors: {
711
+ allow: ['connectors_google_status', 'connectors_google_oauth_start'],
712
+ },
713
+ },
714
+ },
715
+ mcp: {
716
+ connectors: {
717
+ status: 'connected',
718
+ tools: [
719
+ { name: 'connectors_google_status', inputSchema: { type: 'object', properties: {} } },
720
+ { name: 'connectors_google_oauth_start', inputSchema: { type: 'object', properties: {} } },
721
+ ],
683
722
  },
684
723
  },
724
+ };
725
+
726
+ assert.deepEqual(
727
+ chatAllowedTools(session).map((item) => item.function.name).sort(),
728
+ ['connectors__connectors_google_oauth_start', 'connectors__connectors_google_status'],
729
+ );
730
+ });
731
+
732
+ // /chat carries no plan: it performs direct unitary actions only. The
733
+ // orchestration entry points stay out of it whichever way they are declared.
734
+ test('chatAllowedTools never offers orchestration tools, even when allow-listed', () => {
735
+ const session = {
736
+ chatAccess: {
737
+ servers: {
738
+ connectors: { allow: ['agent_execute', 'agent_plan', 'connectors_google_status'] },
739
+ },
740
+ },
741
+ mcp: {
742
+ connectors: {
743
+ status: 'connected',
744
+ tools: [
745
+ { name: 'agent_execute', inputSchema: { type: 'object', properties: {} } },
746
+ { name: 'agent_plan', inputSchema: { type: 'object', properties: {} } },
747
+ { name: 'connectors_google_status', inputSchema: { type: 'object', properties: {} } },
748
+ ],
749
+ },
750
+ },
751
+ };
752
+
753
+ assert.deepEqual(
754
+ chatAllowedTools(session).map((item) => item.function.name),
755
+ ['connectors__connectors_google_status'],
756
+ );
757
+
758
+ const wildcard = { ...session, chatAccess: { servers: { connectors: { allow: '*' } } } };
759
+ assert.deepEqual(
760
+ chatAllowedTools(wildcard).map((item) => item.function.name),
761
+ ['connectors__connectors_google_status'],
762
+ );
763
+ });
764
+
765
+ test('chatAllowedTools folds a legacy allowActions entry into allow', () => {
766
+ const session = {
767
+ chatAccess: {
768
+ // Shape produced by readChatAccessConfig for a file written by an older
769
+ // manager: the legacy key is already merged, so chat keeps working.
770
+ servers: { connectors: { allow: ['connectors_google_oauth_start'] } },
771
+ },
685
772
  mcp: {
686
773
  connectors: {
687
774
  status: 'connected',
688
- tools: [{
689
- name: 'connectors_google_oauth_start',
690
- inputSchema: { type: 'object', properties: {} },
691
- }],
775
+ tools: [{ name: 'connectors_google_oauth_start', inputSchema: { type: 'object', properties: {} } }],
692
776
  },
693
777
  },
694
778
  };
695
779
 
696
780
  assert.deepEqual(
697
- chatReadTools(session).map((item) => item.function.name),
781
+ chatAllowedTools(session).map((item) => item.function.name),
698
782
  ['connectors__connectors_google_oauth_start'],
699
783
  );
700
784
  });
701
785
 
702
- test('chatReadTools is empty when no chatAccess is configured', () => {
786
+ test('chatAllowedTools is empty when no chatAccess is configured', () => {
703
787
  const session = { mcp: { cme: { status: 'connected', tools: [{ name: 'cme_status', inputSchema: {} }] } } };
704
- assert.deepEqual(chatReadTools(session), []);
788
+ assert.deepEqual(chatAllowedTools(session), []);
705
789
  });
706
790
 
707
791
  test('/chat uses the tool-capable path when read tools are declared', async () => {
package/wiki-workspace CHANGED
@@ -487,18 +487,18 @@ if (enabled === 'true') {
487
487
  };
488
488
  config.chatAccess.servers.connectors ??= {};
489
489
  const connectorAccess = config.chatAccess.servers.connectors;
490
+ // One shape for every server: a single `allow` list. The legacy
491
+ // `allowActions` split is folded back in and removed, so a file written by
492
+ // an older manager converges to the common shape on the next `agents up`.
490
493
  connectorAccess.allow = [
491
494
  ...new Set([
492
495
  ...(Array.isArray(connectorAccess.allow) ? connectorAccess.allow : []),
493
- 'connectors_google_status',
494
- ].filter((tool) => tool !== 'connectors_google_oauth_start')),
495
- ];
496
- connectorAccess.allowActions = [
497
- ...new Set([
498
496
  ...(Array.isArray(connectorAccess.allowActions) ? connectorAccess.allowActions : []),
497
+ 'connectors_google_status',
499
498
  'connectors_google_oauth_start',
500
499
  ]),
501
500
  ];
501
+ delete connectorAccess.allowActions;
502
502
  } else {
503
503
  delete config.mcpServers.connectors;
504
504
  delete config.chatAccess.servers.connectors;