@dotdrelle/wiki-manager 0.15.34 → 0.15.35

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/.env.example CHANGED
@@ -57,12 +57,15 @@ CONNECTORS_MCP_AUTH_TOKEN=
57
57
  CONNECTORS_ENABLED=false
58
58
  #
59
59
  # The wikiLLM Google OAuth application is baked into the agent-connectors image
60
- # at build time, from agent-external/agent-connectors/.env.build.local. When you
61
- # build the image locally, that file must exist — otherwise the container starts
62
- # with empty credentials.
63
- # Optional runtime override (no rebuild needed):
60
+ # at build time, from agent-external/agent-connectors/.env.build.local. Neither
61
+ # value is a security boundary (public Desktop-type client); baking them is what
62
+ # makes the connector work out of the box.
63
+ #
64
+ # Set either key below to use YOUR OWN Google application instead — it overrides
65
+ # the baked default at runtime, no rebuild needed. LEAVE THEM COMMENTED to keep
66
+ # the baked one: an empty value here is forwarded as an empty value and disables
67
+ # the client entirely.
64
68
  # GOOGLE_OAUTH_CLIENT_ID=
65
- # Optional confidential-client compatibility override:
66
69
  # GOOGLE_OAUTH_CLIENT_SECRET=
67
70
  #
68
71
  # The callback URL is generated automatically
@@ -94,22 +94,13 @@ services:
94
94
 
95
95
  connectors:
96
96
  profiles: [connectors]
97
- # Local iteration: build the image from the connectors repo instead of
98
- # pulling the published tag. `image:` is kept so the build is tagged with the
99
- # same name; restore image-only before publishing a release.
100
- build:
101
- context: ../agent-external/agent-connectors
102
- dockerfile: Dockerfile
103
- args:
104
- # Without these the ARGs stay unset and the image ships empty
105
- # WIKILLM_GOOGLE_OAUTH_* values, so the container ends up with no Google
106
- # client at all. `wiki-workspace agents up` exports them from the
107
- # connectors repo's .env.build.local before invoking Compose.
108
- # Deliberately value-less: Compose then forwards each host variable only
109
- # when it is defined. Never write `${VAR:-}` here — an empty
110
- # --build-arg pins the ARG to the empty string.
111
- WIKILLM_GOOGLE_OAUTH_CLIENT_ID:
112
- WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET:
97
+ # Image only, jamais de `build:` ici. Ce fichier est livré dans le paquet
98
+ # npm, où le dépôt agent-connectors n'existe pas : un contexte de build
99
+ # pointant vers `../agent-external/…` y est irrésolvable. La construction
100
+ # appartient à `build-and-push.sh`, qui cuit l'application OAuth wikiLLM
101
+ # dans l'image publiée. Pour utiliser sa propre application Google, on
102
+ # surcharge GOOGLE_OAUTH_CLIENT_ID / _SECRET dans le .env du manager — sans
103
+ # reconstruire quoi que ce soit.
113
104
  image: dotdrelle/agent-connectors:latest
114
105
  user: "${UID:-1000}:${GID:-1000}"
115
106
  ports:
@@ -119,6 +110,10 @@ services:
119
110
  - MCP_AUTH_TOKEN=${CONNECTORS_MCP_AUTH_TOKEN:-}
120
111
  - WORKSPACES_ROOT=/workspaces
121
112
  - AGENT_DATA_DIR=/data
113
+ # Vides par défaut, et c'est sans conséquence : l'application OAuth
114
+ # embarquée dans l'image est lue depuis un fichier, pas depuis
115
+ # l'environnement. Renseigner l'une de ces deux clés dans le .env du
116
+ # manager la fait gagner sur la valeur embarquée.
122
117
  - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
123
118
  - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
124
119
  - GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}
@@ -144,13 +144,14 @@ x-wiki-manager:
144
144
  description: "Full workspace service set."
145
145
  ui:
146
146
  targets: [serve]
147
- description: "Alias for serve: workspace web UI."
148
- wiki:
149
- targets: [mcp-http]
150
- description: "Alias for mcp-http: wiki MCP server."
151
- mcp:
152
- targets: [mcp-http]
153
- description: "Alias for mcp-http: wiki MCP server."
147
+ description: "Chat, wiki browser and Donna's web UI."
148
+ # Pas d'alias `wiki` ici : ce nom est deja celui d'un service Compose (le
149
+ # CLI one-shot de `/wiki run`). L'alias le masquait, et `/start wiki`
150
+ # demarrait mcp-http sans le dire.
151
+ #
152
+ # Pas d'alias `mcp` non plus : mcp-http fait partie du socle demarre par
153
+ # `all` et `services`, personne ne le demarre seul. Il reste adressable
154
+ # sous son nom Compose.
154
155
  production:
155
156
  targets: [production-mcp]
156
- description: "Alias for production-mcp: production jobs."
157
+ description: "Ingest, build, export and polish jobs."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.34",
3
+ "version": "0.15.35",
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/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/agentsCompose.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/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.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/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.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/agentsCompose.test.js src/core/profileServiceStatus.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/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.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/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.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",
@@ -1019,12 +1019,32 @@ export function buildAgentSystemPrompt(state) {
1019
1019
  ? 'The runtime is connected and runtime__delegate is available for any requested capability action that has no matching direct tool. When a matching direct tool is offered, call it directly; otherwise let the runtime resolve the objective from discovered capability contracts.'
1020
1020
  : 'No runtime is connected, so you cannot execute actions. State that plainly and name the runtime connection as the missing capability — do not invent a workaround.',
1021
1021
  'If the connector or service needed for a requested read or action is absent from the Connected MCP tools above (its service is not running — e.g. CME, documents, or production), say plainly that this service is not connected and name it as the missing capability. Never redirect a simple read (e.g. "give me the CME config") to an "agent action", never invent its result, and never propose a workaround. Only requests you can actually serve with a listed tool are answered with data.',
1022
+ // Cas observé : « récupère ce mail » et « envoie un mail » refusés comme
1023
+ // impossibles, alors que l'agent connectors déclare `external-source.collect`
1024
+ // (écrit dans raw/untracked/) et `communication.send-email`. Les outils de
1025
+ // lecture directs ne rendent que des métadonnées : les prendre pour la
1026
+ // limite de l'agent transforme un travail délégable en refus.
1027
+ 'The direct read tools of a connector are a preview, not the measure of what its agent can do. Bringing external content INTO the workspace (retrieve, import, fetch, save, "récupère") is a collect capability, and acting on the outside world (send, publish, notify) is an action capability: both are delegated through runtime__delegate, not answered from a read tool. Never conclude that something is impossible because the listed read tool returns only metadata — check the capabilities the agents declare, and delegate the objective as stated.',
1028
+ // Un droit manquant n'est pas une fonctionnalité absente : l'un se
1029
+ // réautorise en une commande, l'autre n'existe pas. Les confondre envoie
1030
+ // l'utilisateur croire que le produit ne sait pas faire.
1031
+ 'When an action fails or is refused for lack of an authorization grant or scope (rather than a missing capability), say exactly that and name the primitive that grants it. Do not describe the feature as unavailable.',
1022
1032
  'For an action with no matching direct tool, call runtime__delegate with the user objective only. The runtime chooses the capability, operation, agent and plan, including a validated single task for executor-only agents. Never choose those identifiers yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
1023
1033
  'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
1024
1034
  'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
1025
1035
  'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
1026
1036
  'For workspace inventory and page listings, use the connected wiki MCP read tools. Never invent or call a /wiki shell command through shell__run_command. Use /workspace init <name> [path] for low-level non-interactive workspace creation; in the interactive TUI, /new <name> opens the setup wizard.',
1027
1037
  'If an action requires tools or skills not available yet, explain the limitation and name the expected primitive.',
1038
+ // Les outils help_* étaient exposés — ils font partie de l'allow-list du
1039
+ // mode chat — mais rien dans ce prompt ne disait qu'ils sont LA source des
1040
+ // questions produit. Donna répondait donc de mémoire. Cas observé :
1041
+ // « comment activer les connecteurs ? » → un `cme.yaml`, un « manifeste des
1042
+ // services actifs » et un redémarrage de service, tous inventés, là où la
1043
+ // réponse tient dans un drapeau du `.env` du manager.
1044
+ 'The bundled product documentation is your source for how llm-wiki works: what a feature is, how to enable or configure it, where a setting lives, what a message means, how to get started, how to troubleshoot. For any such question, call the documentation search/read tools FIRST and answer from what they return. Do not answer these from memory, even when you feel certain.',
1045
+ // Une réponse fausse et assurée sur la configuration coûte plus cher qu'un
1046
+ // « je ne sais pas » : elle envoie éditer des fichiers qui n'existent pas.
1047
+ 'Configuration facts are never answered from memory. File names, environment variables, config keys, directory layouts and enabling procedures must come from a tool result or from the documentation you actually read in this conversation. If the documentation does not cover it, say plainly that you could not find where it is configured — never reconstruct a plausible-looking file name, key or procedure. A confident wrong answer sends the user editing files that do not exist.',
1028
1048
  workspaceProfile
1029
1049
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
1030
1050
  : null,
@@ -3,6 +3,8 @@ import { openExternalUrl } from '../shell/openExternal.js';
3
3
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
4
4
  import { join, relative } from 'node:path';
5
5
  import { composeServices, listServices, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
6
+ import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
7
+ import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
6
8
  import {
7
9
  applyMcpRuntimeStatus,
8
10
  buildMcpStatus,
@@ -1058,6 +1060,12 @@ export async function handleSlashCommand(line, context) {
1058
1060
  // falls back to the hardcoded COMPOSE_SERVICES constant instead.
1059
1061
  const service = args[1];
1060
1062
  if (service === 'agents' || service === 'agent') return runAgentCommand(startAgents, 'start');
1063
+ // Un agent nommé appartient à la pile agents (projet Compose distinct),
1064
+ // pas à celle du workspace : le router ici évite un « no such service »
1065
+ // sur un nom que la complétion propose pourtant.
1066
+ if (agentServiceNames().includes(service)) {
1067
+ return runAgentCommand((options) => startAgents({ ...options, services: [service] }), 'start');
1068
+ }
1061
1069
  // "all" used to mean "the workspace services", which left the external
1062
1070
  // agents down and looked like nothing had happened. It now means what an
1063
1071
  // operator reads into it: the whole stack. `/start services` keeps the
@@ -1094,9 +1102,22 @@ export async function handleSlashCommand(line, context) {
1094
1102
  case 'stop': {
1095
1103
  const service = args[1];
1096
1104
  if (service === 'agents') return runAgentCommand(stopAgents, 'stop');
1105
+ if (agentServiceNames().includes(service)) {
1106
+ return runAgentCommand((options) => stopAgents({ ...options, services: [service] }), 'stop');
1107
+ }
1108
+ // Symétrique de `/start all` : « all » désigne toute la pile, agents
1109
+ // compris. Il ne stoppait que les services du workspace et laissait les
1110
+ // agents debout — donc `/start all` puis `/stop all` ne revenait pas à
1111
+ // l'état de départ.
1112
+ const stopsAgents = service === 'all';
1113
+ const stopTarget = service === 'services' ? undefined : service;
1097
1114
  try {
1098
1115
  step(`Services: stopping ${service ?? 'workspace services'}…`);
1099
- await stopService(context.session, service);
1116
+ await stopService(context.session, stopTarget);
1117
+ if (stopsAgents) {
1118
+ const agentsResult = await runAgentCommand(stopAgents, 'stop');
1119
+ if (agentsResult?.failed) return agentsResult;
1120
+ }
1100
1121
  step('Services: refreshing MCP runtime…');
1101
1122
  await refreshMcpRuntimeStatus(context.session);
1102
1123
  return localizedOperationResult({
@@ -1186,7 +1207,9 @@ export async function handleSlashCommand(line, context) {
1186
1207
  await refreshMcpRuntimeStatus(context.session);
1187
1208
  const connectorMcp = context.session.mcp?.connectors;
1188
1209
  if (!connectorMcp || connectorMcp.status !== 'connected') {
1189
- return connectorResult('The connectors service is unavailable or disabled.');
1210
+ // Le manager sait exactement pourquoi : ne pas renvoyer un constat
1211
+ // vague que Donna comblerait en inventant.
1212
+ return connectorResult(profileServiceStatus('connectors').message);
1190
1213
  }
1191
1214
  if (subcommand === 'list') {
1192
1215
  if (args[2]) return connectorResult('The connector list request has invalid extra arguments.');
@@ -1198,38 +1221,78 @@ export async function handleSlashCommand(line, context) {
1198
1221
  { workspace: context.session.workspace },
1199
1222
  );
1200
1223
  const payload = parseJsonText(formatMcpToolResult(result));
1201
- const status = payload?.status === 'configured' ? 'authorized' : 'not authorized';
1202
- return connectorResult(`google (Gmail read-only): ${status}`);
1224
+ if (payload?.status !== 'configured') {
1225
+ return connectorResult('google (Gmail): not authorized. Run `/connector auth google` to authorize reading and sending.');
1226
+ }
1227
+ // Le libellé annonçait « read-only » quels que soient les droits
1228
+ // réellement accordés — donc il mentait dès qu'on autorisait l'envoi,
1229
+ // et n'aidait pas à comprendre pourquoi l'envoi échouait sinon.
1230
+ const grants = Array.isArray(payload?.grants) ? payload.grants : [];
1231
+ const missing = GOOGLE_GRANTS.filter((grant) => !grants.includes(grant));
1232
+ const held = grants.map((grant) => `${grant} — ${GOOGLE_GRANT_LABELS[grant] ?? 'unknown grant'}`);
1233
+ const lines = [
1234
+ `google (Gmail): authorized for ${grants.join(', ') || 'nothing'}`,
1235
+ ...held.map((line) => ` ✓ ${line}`),
1236
+ ...missing.map((grant) => ` ✗ ${grant} — ${GOOGLE_GRANT_LABELS[grant]}`),
1237
+ ];
1238
+ if (missing.length > 0) {
1239
+ lines.push(`Run \`/connector auth google ${missing.join(' ')}\` to add the missing grant(s); existing ones are kept.`);
1240
+ }
1241
+ return connectorResult(lines.join('\n'));
1203
1242
  } catch (err) {
1204
- return connectorResult(`google (Gmail read-only): unavailable (${err instanceof Error ? err.message : String(err)})`);
1243
+ return connectorResult(`google (Gmail): unavailable (${err instanceof Error ? err.message : String(err)})`);
1205
1244
  }
1206
1245
  }
1207
1246
  if (subcommand === 'auth') {
1208
1247
  const connector = String(args[2] ?? '').toLowerCase();
1209
1248
  if (!['google', 'gmail'].includes(connector)) {
1210
- return connectorResult('The requested connector is unsupported. The available connector is google (Gmail read-only).');
1249
+ return connectorResult('The requested connector is unsupported. The available connector is google (Gmail).');
1250
+ }
1251
+ // Les droits demandés à Google. L'appel ne les passait pas, et le
1252
+ // serveur retombait sur son défaut `["read"]` : l'agent sait envoyer un
1253
+ // courriel, l'autorisation obtenue ne le permettait pas, et le refus
1254
+ // ressemblait à une fonctionnalité absente. On demande donc lecture ET
1255
+ // envoi par défaut, et les droits restants s'ajoutent à la demande.
1256
+ const requested = args.slice(3).map((value) => String(value).toLowerCase());
1257
+ const unknown = requested.filter((grant) => !GOOGLE_GRANTS.includes(grant));
1258
+ if (unknown.length > 0) {
1259
+ const available = GOOGLE_GRANTS.map((grant) => `${grant} (${GOOGLE_GRANT_LABELS[grant]})`).join('; ');
1260
+ return connectorResult(`Unsupported grant(s): ${unknown.join(', ')}. Available grants: ${available}.`);
1211
1261
  }
1262
+ // Par défaut, tout ce que l'agent sait faire — y compris `modify`, sans
1263
+ // quoi les actions que Donna propose d'elle-même (« marquer comme lu »,
1264
+ // « archiver ») échouent après coup. C'est la même incohérence que
1265
+ // l'envoi : promettre une action que l'autorisation ne couvre pas.
1266
+ const grants = requested.length > 0 ? [...new Set(requested)] : defaultGoogleGrants();
1212
1267
  try {
1213
1268
  const result = await callMcpTool(
1214
1269
  context.session.mcp,
1215
1270
  'connectors',
1216
1271
  'connectors_google_oauth_start',
1217
- { workspace: context.session.workspace },
1272
+ { workspace: context.session.workspace, grants },
1218
1273
  );
1219
1274
  const payload = parseJsonText(formatMcpToolResult(result));
1220
1275
  const authorizationUrl = payload?.authorizationUrl;
1276
+ if (payload?.error === 'send_capability_disabled') {
1277
+ return connectorResult(
1278
+ 'Sending is disabled in this deployment (CONNECTORS_SEND_ENABLED=false in the manager .env), so the send grant cannot be authorized. Run `/connector auth google read` for read-only access, or enable sending and restart the connectors agent.',
1279
+ );
1280
+ }
1221
1281
  if (payload?.ok !== true || typeof authorizationUrl !== 'string') {
1222
1282
  return connectorResult(`Google authorization could not start (${payload?.error ?? 'missing authorization URL'}).`);
1223
1283
  }
1284
+ // L'autorisation est incrémentale côté Google : redemander avec un
1285
+ // droit de plus ne révoque pas les précédents.
1286
+ const scopeNote = `Requested grants: ${grants.join(', ')}.`;
1224
1287
  if (openExternalUrl(authorizationUrl)) {
1225
- return connectorResult('Google authorization opened successfully in the user browser.');
1288
+ return connectorResult(`Google authorization opened successfully in the user browser. ${scopeNote}`);
1226
1289
  }
1227
- return connectorResult(`Google authorization requires the user to open this URL: ${authorizationUrl}`);
1290
+ return connectorResult(`Google authorization requires the user to open this URL: ${authorizationUrl} — ${scopeNote}`);
1228
1291
  } catch (err) {
1229
1292
  return connectorResult(`Google authorization could not start (${err instanceof Error ? err.message : String(err)}).`);
1230
1293
  }
1231
1294
  }
1232
- return connectorResult('The requested connector action is unsupported. Available actions: list and auth google.');
1295
+ return connectorResult(`The requested connector action is unsupported. Available actions: list, and auth google [${GOOGLE_GRANTS.join('|')}].`);
1233
1296
  }
1234
1297
  case 'cancel': {
1235
1298
  // Alias of /run cancel — people type /cancel when they want out.
@@ -126,9 +126,43 @@ test('/start completes to the three documented targets', async () => {
126
126
  for (const expected of ['all', 'agents', 'services']) {
127
127
  assert.ok(matches.includes(expected), `${expected} missing from ${JSON.stringify(matches)}`);
128
128
  }
129
- assert.match(completionDescription('all', ['/start']), /services AND the external agents/);
130
- assert.match(completionDescription('agents', ['/start']), /agents only/);
131
- assert.match(completionDescription('services', ['/start']), /workspace services only/);
129
+ assert.match(completionDescription('all', ['/start']), /services AND external agents/);
130
+ assert.match(completionDescription('agents', ['/start']), /agents only/i);
131
+ assert.match(completionDescription('services', ['/start']), /workspace services only/i);
132
+ });
133
+
134
+ test('/start offers the operator vocabulary, not the Compose service names twice', async () => {
135
+ const { completionDescription } = await import('../shell/repl.js');
136
+ const { matches } = completionContext('/start ', {});
137
+
138
+ // `all` apparaissait deux fois — mot-clé du shell ET alias Compose — et
139
+ // chaque alias était doublé du service qu'il désigne : dix entrées pour cinq
140
+ // actions.
141
+ assert.equal(new Set(matches).size, matches.length, `duplicates in ${JSON.stringify(matches)}`);
142
+ for (const raw of ['serve', 'mcp-http', 'production-mcp']) {
143
+ assert.ok(!matches.includes(raw), `${raw} is already covered by an alias`);
144
+ }
145
+ for (const alias of ['ui', 'production']) {
146
+ assert.ok(matches.includes(alias), `${alias} missing from ${JSON.stringify(matches)}`);
147
+ assert.notEqual(
148
+ completionDescription(alias, ['/start']),
149
+ 'Start this Docker Compose service.',
150
+ `${alias} must explain what it starts`,
151
+ );
152
+ }
153
+ // `wiki` est le service one-shot de `/wiki run` : en faire un alias de
154
+ // mcp-http le masquait silencieusement.
155
+ assert.ok(!matches.includes('wiki'), 'wiki must not shadow the one-shot CLI service');
156
+ // mcp-http fait partie du socle demarre par `all` et `services` : son alias
157
+ // n'ajoutait qu'une ligne a lire pour un service que personne ne lance seul.
158
+ assert.ok(!matches.includes('mcp'), 'mcp is covered by services/all');
159
+ // Les agents du socle ne s'adressent pas un par un, et `mailer` n'existe
160
+ // meme pas tant qu'il n'est pas decommente dans l'override de l'operateur.
161
+ for (const agent of ['cme', 'documents', 'mailer']) {
162
+ assert.ok(!matches.includes(agent), `${agent} is started by "agents", not on its own`);
163
+ }
164
+ // Seuls les agents derriere un drapeau de profil se pilotent separement.
165
+ assert.ok(matches.includes('connectors'), 'connectors is opt-in, so it must be addressable');
132
166
  });
133
167
 
134
168
  test('/workspace delete removes files and clears current session context after confirmation', async () => {
@@ -1,5 +1,6 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import YAML from 'yaml';
3
4
 
4
5
  import { managerComposeOverrideFile, managerEnvFile, readEnvFile } from './env.js';
5
6
  import { managerRoot } from './workspaces.js';
@@ -16,10 +17,94 @@ export const PROFILE_FLAGS = Object.freeze({
16
17
  connectors: 'CONNECTORS_ENABLED',
17
18
  });
18
19
 
20
+ /**
21
+ * Services réellement déclarés dans la pile agents, override compris.
22
+ *
23
+ * Lue du fichier plutôt que codée en dur : une liste figée avait fini par
24
+ * annoncer `mailer`, qui ne vit que dans l'exemple d'override et n'existe donc
25
+ * pas tant que l'opérateur ne l'a pas décommenté. Proposer un service absent,
26
+ * c'est promettre un « no such service ».
27
+ *
28
+ * Sert au ROUTAGE : la pile agents est un projet Compose distinct de celui du
29
+ * workspace, donc `/start <nom>` doit partir vers `wiki-workspace agents`.
30
+ */
31
+ export function agentServiceNames({ env = resolvedManagerEnv() } = {}) {
32
+ const { composeFiles } = resolveAgentsComposeContext({ env });
33
+ const names = new Set();
34
+ for (const file of composeFiles) {
35
+ try {
36
+ const parsed = YAML.parse(readFileSync(file, 'utf8')) ?? {};
37
+ for (const name of Object.keys(parsed.services ?? {})) names.add(name);
38
+ } catch { /* fichier illisible : il ne déclare rien d'adressable */ }
39
+ }
40
+ return [...names].sort();
41
+ }
42
+
43
+ /**
44
+ * Agents qu'un opérateur pilote un par un, proposés dans les complétions.
45
+ *
46
+ * Uniquement ceux placés derrière un drapeau de profil : ce sont les seuls
47
+ * qu'on active et désactive délibérément. `cme` et `documents` font partie du
48
+ * socle — on les démarre avec `agents`, les distinguer n'apporte rien et
49
+ * allonge une liste que l'opérateur doit lire à chaque fois.
50
+ */
51
+ export function togglableAgentNames() {
52
+ return Object.keys(PROFILE_FLAGS).sort();
53
+ }
54
+
19
55
  export function isEnabled(value) {
20
56
  return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
21
57
  }
22
58
 
59
+ /**
60
+ * Pourquoi un service à profil est indisponible, et comment l'activer.
61
+ *
62
+ * Le message d'origine — « le service des connecteurs est indisponible ou
63
+ * désactivé » — ne disait ni lequel des deux, ni où regarder. Donna, à qui on
64
+ * demandait ensuite « comment l'activer », n'avait aucun fait à citer et
65
+ * inventait des fichiers (`cme.yaml`, un « manifeste des services actifs »)
66
+ * qui n'existent nulle part. Une réponse fausse et confiante coûte plus cher
67
+ * qu'un « je ne sais pas » : c'est le message qu'il faut rendre suffisant,
68
+ * pas le modèle qu'il faut espérer plus prudent.
69
+ *
70
+ * @returns {{ enabled: boolean, flag: string, envFile: string, message: string }}
71
+ */
72
+ export function profileServiceStatus(profile, { env = resolvedManagerEnv() } = {}) {
73
+ const flag = PROFILE_FLAGS[profile];
74
+ const envFile = managerEnvFile();
75
+ if (!flag) {
76
+ return {
77
+ enabled: false,
78
+ flag: null,
79
+ envFile,
80
+ message: `There is no "${profile}" service in this deployment.`,
81
+ };
82
+ }
83
+ const enabled = isEnabled(env[flag]);
84
+ if (enabled) {
85
+ return {
86
+ enabled: true,
87
+ flag,
88
+ envFile,
89
+ message: `The ${profile} service is enabled (${flag}) but not reachable — its container is probably not running. Start it with \`/start agents\`.`,
90
+ };
91
+ }
92
+ const current = String(env[flag] ?? '').trim();
93
+ return {
94
+ enabled: false,
95
+ flag,
96
+ envFile,
97
+ message: [
98
+ `The ${profile} service is disabled: ${flag} is ${current === '' ? 'not set' : `"${current}"`} in ${envFile}.`,
99
+ 'To enable it:',
100
+ ` 1. set ${flag}=true in ${envFile}`,
101
+ ' 2. run `/start agents` (or `wiki-workspace agents up`) to start its container',
102
+ ` 3. run \`/connector list\` to check it answers`,
103
+ `The ${profile} service runs behind a Compose profile, so while ${flag} is off its container does not exist at all — it will not appear in \`docker compose ps\`.`,
104
+ ].join('\n'),
105
+ };
106
+ }
107
+
23
108
  /**
24
109
  * The manager `.env` wins over the ambient process environment.
25
110
  *
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.34",
3
- "commit": "8b6b1aa"
2
+ "version": "0.15.35",
3
+ "commit": "9bca2fd"
4
4
  }
@@ -19,11 +19,17 @@ const execFileAsync = promisify(execFile);
19
19
  export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'production-mcp'];
20
20
  const SERVICE_DESCRIPTION_LABEL = 'wiki-manager.description';
21
21
 
22
+ // Deux alias seulement, pour les deux services qu'un opérateur pilote vraiment
23
+ // à part. `mcp-http` n'en a plus : il fait partie du socle démarré par `all` et
24
+ // `services`, et son alias `mcp` ajoutait une ligne à lire pour un service que
25
+ // personne ne démarre seul. Il reste adressable sous son nom Compose.
26
+ //
27
+ // Pas d'alias `wiki` non plus : c'est déjà le nom du service Compose one-shot
28
+ // derrière `/wiki run`. En faire un alias de `mcp-http` le masquait, et
29
+ // `/start wiki` démarrait silencieusement un autre service que celui nommé.
22
30
  const DEFAULT_SERVICE_ALIASES = {
23
31
  all: COMPOSE_SERVICES,
24
32
  ui: ['serve'],
25
- wiki: ['mcp-http'],
26
- mcp: ['mcp-http'],
27
33
  production: ['production-mcp'],
28
34
  };
29
35
 
@@ -92,6 +98,22 @@ export function serviceNames() {
92
98
  return [...new Set([...COMPOSE_SERVICES, ...Object.keys(serviceAliases())])].sort();
93
99
  }
94
100
 
101
+ /**
102
+ * Noms proposés à la complétion de `/start`, `/stop` et `/logs`.
103
+ *
104
+ * La liste complète mélangeait les alias et les services Compose qu'ils
105
+ * désignent — `mcp` et `mcp-http`, `ui` et `serve`, `production` et
106
+ * `production-mcp` — et affichait `all` deux fois, une fois comme mot-clé du
107
+ * shell et une fois comme alias Compose. Dix entrées pour cinq actions
108
+ * réelles. On ne propose donc que le vocabulaire destiné à l'opérateur ; les
109
+ * noms Compose bruts restent acceptés si on les tape.
110
+ */
111
+ export function serviceChoices() {
112
+ const aliases = Object.keys(serviceAliases());
113
+ // `all` est ajouté par l'appelant, en tête : c'est le choix par défaut.
114
+ return aliases.filter((name) => name !== 'all').sort();
115
+ }
116
+
95
117
  export function serviceDescription(name) {
96
118
  return serviceDescriptions()[name] ?? null;
97
119
  }
@@ -19,11 +19,29 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
19
19
  );
20
20
  });
21
21
 
22
+ test('shipped compose files never carry a build context', async () => {
23
+ // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
24
+ // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
25
+ // Compose irrésolvable chez l'utilisateur. Les images sont construites et
26
+ // publiées par build-and-push.sh, jamais par le manager.
27
+ for (const file of ['../../docker-compose.yml', '../../agents.docker-compose.yml']) {
28
+ const compose = YAML.parse(await readFile(new URL(file, import.meta.url), 'utf8'));
29
+ for (const [name, service] of Object.entries(compose.services ?? {})) {
30
+ assert.equal(service.build, undefined, `${file} ${name}: shipped files must reference an image, never build it`);
31
+ assert.ok(service.image, `${file} ${name}: must declare an image`);
32
+ }
33
+ }
34
+ });
35
+
22
36
  test('no compose service relies on a bare environment passthrough', async () => {
23
37
  // `- VAR` makes Compose print `The "VAR" variable is not set. Defaulting to a
24
38
  // blank string.` for every key the operator left as a commented placeholder —
25
39
  // CONNECTORS_MCP_PORT once connectors were enabled. That warning reached the
26
40
  // ShellUI looking like a failure. Every entry must carry its own default.
41
+ //
42
+ // Une valeur vide est ici sans danger : l'application OAuth embarquée dans
43
+ // l'image du connecteur est lue depuis un fichier, plus depuis un ENV que la
44
+ // chaîne vide écraserait.
27
45
  for (const file of ['../../docker-compose.yml', '../../agents.docker-compose.yml']) {
28
46
  const compose = YAML.parse(await readFile(new URL(file, import.meta.url), 'utf8'));
29
47
  for (const [name, service] of Object.entries(compose.services ?? {})) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Droits Gmail demandés à Google, et ce qu'ils ouvrent réellement.
3
+ *
4
+ * Les identifiants (`read`, `send`, `modify`) sont ceux de Google — `modify`
5
+ * vient du scope `gmail.modify` — et ceux de `GOOGLE_GRANTS` dans
6
+ * agent-connectors. Leur inventer un synonyme côté manager créerait une
7
+ * troisième orthographe à tenir à jour, exactement le travers qui avait donné
8
+ * une seconde paire de variables OAuth préfixées. On décrit, on ne renomme pas.
9
+ *
10
+ * Cette table est la source unique : la valeur par défaut de
11
+ * `/connector auth`, l'aide de la complétion et le rendu de `/connector list`
12
+ * en découlent tous, donc ils ne peuvent pas diverger.
13
+ */
14
+ export const GOOGLE_GRANT_LABELS = Object.freeze({
15
+ read: 'read messages and collect them into the workspace',
16
+ send: 'send email from your account (subject to the recipient allow-list)',
17
+ modify: 'mark read/unread, archive, label, star, trash (never permanent deletion)',
18
+ });
19
+
20
+ export const GOOGLE_GRANTS = Object.freeze(Object.keys(GOOGLE_GRANT_LABELS));
21
+
22
+ /**
23
+ * Droits demandés quand l'opérateur n'en nomme aucun.
24
+ *
25
+ * Tout ce que l'agent sait faire. Un défaut plus étroit promet des actions que
26
+ * l'autorisation ne couvre pas : `/connector auth google` ne demandait que
27
+ * `read`, alors que l'agent expose l'envoi et la gestion de boîte — Donna
28
+ * proposait « marquer comme lu », et l'action échouait après coup. Comme
29
+ * l'autorisation Google est incrémentale, un droit ajouté plus tard coûte un
30
+ * aller-retour de consentement supplémentaire, pas moins d'accès.
31
+ */
32
+ export function defaultGoogleGrants() {
33
+ return [...GOOGLE_GRANTS];
34
+ }
35
+
36
+ export function describeGoogleGrant(grant) {
37
+ return GOOGLE_GRANT_LABELS[grant] ?? null;
38
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from './googleGrants.js';
6
+
7
+ const connectorsSrc = (file) =>
8
+ readFileSync(fileURLToPath(new URL(`../../../agent-external/agent-connectors/src/${file}`, import.meta.url)), 'utf8');
9
+
10
+ test('the grant names mirror the agent, spelling included', () => {
11
+ // `modify` est le nom de Google (scope gmail.modify) et celui de l'agent.
12
+ // Un synonyme côté manager créerait une troisième orthographe à tenir à jour
13
+ // — le travers qui avait déjà donné une seconde paire de variables OAuth.
14
+ const tokens = connectorsSrc('googleTokens.ts');
15
+ const declared = tokens.match(/GOOGLE_GRANTS: readonly GoogleGrant\[\] = \[([^\]]+)\]/)?.[1] ?? '';
16
+ const agentGrants = [...declared.matchAll(/'([a-z]+)'/g)].map(([, grant]) => grant);
17
+
18
+ assert.deepEqual([...GOOGLE_GRANTS].sort(), agentGrants.sort());
19
+ });
20
+
21
+ test('every grant is described in plain words, never left as a bare token', () => {
22
+ for (const grant of GOOGLE_GRANTS) {
23
+ const label = GOOGLE_GRANT_LABELS[grant];
24
+ assert.ok(label && label.length > 10, `${grant} needs a human description`);
25
+ assert.notEqual(label, grant);
26
+ }
27
+ });
28
+
29
+ test('the default asks for everything the agent can actually do', () => {
30
+ // Un défaut plus étroit promet des actions que l'autorisation ne couvre pas :
31
+ // `/connector auth google` ne demandait que `read`, et l'envoi comme le
32
+ // marquage échouaient après coup, en ressemblant à des fonctions absentes.
33
+ assert.deepEqual(defaultGoogleGrants().sort(), [...GOOGLE_GRANTS].sort());
34
+
35
+ // Chaque droit du défaut doit ouvrir quelque chose de réellement exposé.
36
+ const server = connectorsSrc('server.ts');
37
+ const contract = connectorsSrc('contract.ts');
38
+ assert.match(contract, /CAPABILITY_ID = 'external-source\.collect'/, 'read feeds the collect capability');
39
+ assert.match(contract, /SEND_CAPABILITY_ID = 'communication\.send-email'/, 'send has a capability');
40
+ assert.match(server, /'connectors_gmail_modify'/, 'modify has a tool');
41
+ });
42
+
43
+ test('the default grants are not mutated by the caller', () => {
44
+ const first = defaultGoogleGrants();
45
+ first.push('bogus');
46
+ assert.deepEqual(defaultGoogleGrants().sort(), [...GOOGLE_GRANTS].sort());
47
+ });
48
+
49
+ test('the destructive Gmail tool needs approval and stays out of chat mode', () => {
50
+ const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
51
+ const block = script.slice(script.indexOf('config.mcpServers.connectors ??='), script.indexOf('delete connectorAccess.allowActions'));
52
+ const approval = block.slice(block.indexOf('requireApproval'), block.indexOf('chatAccess'));
53
+ const chatAllow = block.slice(block.indexOf('connectorAccess.allow ='));
54
+
55
+ assert.match(approval, /connectors_gmail_modify/, 'a destructive tool must be approval-gated');
56
+ // /chat est en lecture seule : une mutation n'y a pas sa place, elle passe
57
+ // par /agent où l'allow-list ne s'applique pas.
58
+ assert.ok(!chatAllow.includes('connectors_gmail_modify'), 'chat mode must stay read-only');
59
+ });