mercury-agent 0.10.0 → 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.
@@ -95,6 +95,104 @@ In YAML, use a list of `{ provider, model }` objects under `model.chain` (max 4
95
95
 
96
96
  Optional **`model.capabilities`** may be a mapping; it is applied like `MERCURY_MODEL_CAPABILITIES` JSON.
97
97
 
98
+ ### Custom endpoints (`models.json`)
99
+
100
+ A model leg is `{ provider, model }` and carries no host, so it cannot on its own
101
+ reach an endpoint pi does not already know about — a metering gateway, a local
102
+ model server (Ollama, LM Studio, vLLM), or a private/regional deployment.
103
+
104
+ pi solves this with its own config file, and Mercury mounts it. Drop a
105
+ `models.json` into the **global dir** (`<dataDir>/global/`, alongside
106
+ `AGENTS.md`); it is bind-mounted read-only at `PI_CODING_AGENT_DIR` and pi reads
107
+ it natively. No Mercury config key is involved, and the model-chain schema is
108
+ unchanged.
109
+
110
+ **Repoint an existing provider** — keeps pi's model metadata, costs and
111
+ capabilities, and every `provider: openai` leg goes to the new host:
112
+
113
+ ```json
114
+ { "providers": { "openai": { "baseUrl": "https://gateway.example.com/v1" } } }
115
+ ```
116
+
117
+ **Define custom models** — here pi requires `baseUrl`, and `api` is effectively
118
+ mandatory too: pi silently skips any model whose wire format it cannot resolve,
119
+ so omitting it makes the model vanish without an error.
120
+
121
+ ```json
122
+ {
123
+ "providers": {
124
+ "mygateway": {
125
+ "baseUrl": "https://gateway.example.com/v1",
126
+ "api": "openai-completions",
127
+ "apiKey": "$MYGATEWAY_API_KEY",
128
+ "models": [{ "id": "openai/gpt-4o-mini", "contextWindow": 128000 }]
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ ### Credentials in `models.json`
135
+
136
+ Which credential path applies depends on which shape you used:
137
+
138
+ - **Repointing a built-in provider** (`openai`, `anthropic`, …) changes nothing:
139
+ the key keeps travelling as `MERCURY_<PROVIDER>_API_KEY`, exactly as before.
140
+ - **A provider that exists only in `models.json`** is unknown to pi's env-var
141
+ map, so no `*_API_KEY` variable is read for it. Its key must come from the
142
+ provider entry — written as a **reference**, never a literal:
143
+ `"apiKey": "$MYGATEWAY_API_KEY"` (`${VAR}` and `!some-command` also work).
144
+ Pass the secret itself as `MERCURY_MYGATEWAY_API_KEY` and it reaches the
145
+ container through the usual passthrough. This assumes the default
146
+ `agent.env_passthrough: all`; under `claimed`, only the provider variables
147
+ Mercury knows about are forwarded, and a custom provider's key is not among
148
+ them, so pi fails to resolve it.
149
+
150
+ > ⚠️ **Never store a credential literally.** Mercury refuses to mount a
151
+ > `models.json` that has a literal value in `apiKey`, in a credential-shaped
152
+ > field or header (`Authorization`, `x-api-key`, anything containing
153
+ > key/token/secret/auth/cookie/credential/passw), or as userinfo or a
154
+ > credential-shaped query parameter in a `baseUrl` — provider-level and
155
+ > per-model alike — and logs an error naming the fields. Every other entry
156
+ > mounted into the agent container is a resource kind that cannot hold a secret
157
+ > (`auth.json` is excluded precisely so the OAuth refresh token never reaches
158
+ > the least-trusted part of the system), and `models.json` is held to the same
159
+ > rule.
160
+ >
161
+ > **References are fine**, and so is a value that merely *contains* one:
162
+ > `"Bearer $GW_KEY"` is the ordinary spelling of a bearer header, because pi
163
+ > interpolates rather than matching whole values. What is left over once the
164
+ > references are removed still has to look like configuration — stapling an
165
+ > unused `$VAR` onto a pasted key does not buy passage.
166
+ >
167
+ > This is a guardrail against accident, not a control against a hostile writer:
168
+ > the bind source is live, so anyone able to write the global dir could change
169
+ > the file after the check. Anyone who can do that can already do worse.
170
+ >
171
+ > A file that cannot be read or parsed is refused for the same reason — its
172
+ > contents are unverifiable. The check parses exactly what pi parses: `//` line
173
+ > comments and trailing commas are fine, **`/* … */` block comments are not** —
174
+ > pi cannot read those either, so a file containing one is refused rather than
175
+ > mounted and then silently ignored by pi.
176
+ >
177
+ > **What refusal costs depends on the shape.** A repointed built-in provider
178
+ > falls back to its default endpoint, so traffic quietly leaves your gateway; a
179
+ > provider defined only in `models.json` stops resolving altogether. Either way,
180
+ > grep the logs for `Ignoring models.json`.
181
+
182
+ **Capabilities for a custom provider.** Capability detection is host-side and
183
+ registry-based, so a provider that exists only in `models.json` is invisible to
184
+ it and the leg resolves to `DEFAULT_CAPABILITIES` (`tools: true`, everything
185
+ else false). This degrades gracefully: tool use — the one that matters — stays
186
+ on, and a `false` from an unresolved model is treated as *unknown* rather than
187
+ *unsupported*, so capability-gated extensions and skills still install. To state
188
+ capabilities explicitly, add the model to `<dataDir>/model-capabilities.yaml`,
189
+ which is consulted before the registry.
190
+
191
+ The **override-only** shape mostly avoids the question, since the provider stays
192
+ a known one — but only for model ids that are themselves in pi's registry.
193
+ Repointing `openai`'s `baseUrl` and then naming a model pi has never heard of
194
+ lands back in `default`.
195
+
98
196
  ### Removed: `provider: cursor`
99
197
 
100
198
  The **Cursor Agent CLI** integration has been removed. All model legs use **pi** with standard providers (`anthropic`, `openai`, `google`, `mistral`, `groq`, `openrouter`, etc.).
package/docs/scheduler.md CHANGED
@@ -61,27 +61,31 @@ TaskScheduler.start()
61
61
 
62
62
  ├─► Query DB for due tasks (active=1, next_run_at <= now)
63
63
 
64
- ├─► For each due task:
64
+ ├─► For each due task (skipped if a run is already in flight):
65
65
  │ │
66
- │ ├─► [Cron task]
67
- ├─► Compute next run time from cron expression
68
- │ │ ├─► Update next_run_at in DB
69
- │ │ └─► Execute handler
66
+ │ ├─► Execute handler
67
+ │ ├─► Record the outcome on the task row
70
68
  │ │
71
- └─► [At task]
72
- ├─► Execute handler
73
- └─► Delete task from DB
69
+ ├─► [Failed, retries left]
70
+ │ └─► Set next_run_at to the retry time — schedule untouched
71
+
72
+ │ └─► [Succeeded, or out of retries]
73
+ │ ├─► Report the failure to the space (failures only)
74
+ │ ├─► [Cron task] Compute and store the next run time
75
+ │ └─► [At task] Delete task from DB
74
76
 
75
77
  └─► Schedule next poll
76
78
  ```
77
79
 
78
80
  Tasks are processed sequentially within a poll cycle. Each task runs as if the `createdBy` user sent the prompt.
79
81
 
82
+ The schedule is consumed **after** the attempt, not before. A task therefore stays "due" for the whole time it is running; the in-flight guard is what stops the poll starting a second run, and it is also why a long run shows as `now` in the dashboard's Next Run column until it finishes.
83
+
80
84
  **At-task lifecycle:**
81
85
  1. Created with a future timestamp
82
86
  2. Waits until scheduled time
83
- 3. Executes once
84
- 4. Auto-deletes (regardless of success/failure)
87
+ 3. Executes once, plus any retries
88
+ 4. Auto-deletes once it succeeds or runs out of retries
85
89
 
86
90
  ## Creating Tasks
87
91
 
@@ -185,6 +189,9 @@ CREATE TABLE tasks (
185
189
  silent INTEGER NOT NULL DEFAULT 0,
186
190
  next_run_at INTEGER NOT NULL,
187
191
  created_by TEXT NOT NULL,
192
+ last_run_at INTEGER, -- Epoch ms of the last completed attempt
193
+ last_status TEXT, -- 'ok' | 'error'
194
+ last_error TEXT, -- Failure message, capped at 500 chars
188
195
  created_at INTEGER NOT NULL,
189
196
  updated_at INTEGER NOT NULL
190
197
  );
@@ -197,6 +204,11 @@ CREATE INDEX idx_tasks_next ON tasks(active, next_run_at);
197
204
  | `cron` | Cron expression for recurring tasks (null for at-tasks) |
198
205
  | `at` | ISO 8601 timestamp for one-shot tasks (null for cron-tasks) |
199
206
  | `silent` | If 1, task runs but doesn't post results to chat |
207
+ | `last_run_at` | When the last attempt finished. Null until the task has run |
208
+ | `last_status` | `ok` or `error`. Written for every attempt, retries included |
209
+ | `last_error` | Why the last attempt failed; null after a success |
210
+
211
+ The last-run columns are shown in the dashboard's task table (**Last Run**), with the error text as the cell's tooltip. Existing rows are not backfilled — a task that has never run since the upgrade reads as `never`.
200
212
 
201
213
  ## Permissions
202
214
 
@@ -241,13 +253,29 @@ The scheduler stops cleanly on shutdown — no orphaned timers.
241
253
  ### `TaskScheduler`
242
254
 
243
255
  ```typescript
244
- const scheduler = new TaskScheduler(db, pollIntervalMs);
256
+ const scheduler = new TaskScheduler(db, pollIntervalMs, {
257
+ retryAttempts, // extra attempts after the first failure
258
+ retryDelayMs, // how long to wait before one
259
+ });
245
260
 
246
- scheduler.start(handler); // Begin polling
247
- scheduler.stop(); // Stop polling
261
+ scheduler.start(handler, onFailure); // Begin polling
262
+ scheduler.stop(); // Stop polling
248
263
  scheduler.computeNextRun(cron, from); // Get next run time for cron tasks
249
264
  ```
250
265
 
266
+ `onFailure` is optional and is called **once per due cycle**, only after the retries are spent — never once per attempt.
267
+
268
+ ```typescript
269
+ type TaskFailure = {
270
+ id: number;
271
+ spaceId: string;
272
+ name: string | null;
273
+ silent: boolean; // the task's flag; the reporter decides whether to honour it
274
+ error: string; // capped at 500 chars
275
+ attempts: number; // attempts made this cycle, including the first
276
+ };
277
+ ```
278
+
251
279
  ### Handler Signature
252
280
 
253
281
  ```typescript
@@ -276,13 +304,47 @@ db.setTaskActive(id, active); // Pause/resume
276
304
  db.deleteTask(id, spaceId); // Delete task (with space check)
277
305
  db.deleteTaskById(id); // Delete task (no space check, for scheduler)
278
306
  db.updateTaskNextRun(id, nextRunAt); // Update next execution time
307
+ db.recordTaskRun(id, status, error?); // Record one attempt's outcome
279
308
  ```
280
309
 
281
310
  ## Error Handling
282
311
 
283
- If a task handler fails:
284
- - Error is logged
285
- - Task is not retried in the same cycle
286
- - **Cron tasks:** `next_run_at` is already updated, so it will run again at the next scheduled time
287
- - **At tasks:** Still deleted after execution (one-shot behavior preserved)
288
- - Other tasks in the cycle continue to execute
312
+ A task that fails is retried, then reported. Nothing about a failure is silent.
313
+
314
+ When a handler throws:
315
+
316
+ 1. The error is logged and written to the task row (`last_status = 'error'`, `last_error`).
317
+ 2. If attempts remain, `next_run_at` is set to the retry time and the poll picks the task up again. Nothing is posted to the space — a pending retry is not news yet.
318
+ 3. Once the attempts are spent, the space is told (see below) and only **then** is the schedule consumed: a cron task advances to its next occurrence, a one-shot task is deleted.
319
+ 4. Other tasks in the cycle are unaffected.
320
+
321
+ The retry time is `now + retry_delay_ms`, **clamped to the task's own next occurrence** — a 5-minute delay on an every-minute task must not swallow four scheduled runs.
322
+
323
+ ### The failure notice
324
+
325
+ On a final failure Mercury posts one message into the task's space:
326
+
327
+ > ⚠️ Scheduled task "Daily football" failed and produced nothing (attempts: 2). Reason: Container timed out for group 19
328
+
329
+ `silent` is **not** honoured here. It suppresses a task's routine output, and a silent task that fails is exactly the one whose failure nobody would otherwise discover. Set `scheduling.notify_on_failure: false` to turn the notices off entirely.
330
+
331
+ The message is locale-aware (`messages.locale`, `en` and `he`), like every other host-generated system message.
332
+
333
+ ### Configuration
334
+
335
+ | YAML | Env | Default | What |
336
+ |------|-----|---------|------|
337
+ | `scheduling.retry_attempts` | `MERCURY_TASK_RETRY_ATTEMPTS` | `1` | Extra attempts after the first failure. `0` disables retrying |
338
+ | `scheduling.retry_delay_ms` | `MERCURY_TASK_RETRY_DELAY_MS` | `300000` (5 min) | Wait before a retry. Minimum 60 000 — an agent run costs tokens and a container slot, so retrying a systematic failure fast is worse than not retrying |
339
+ | `scheduling.notify_on_failure` | `MERCURY_TASK_NOTIFY_ON_FAILURE` | `true` | Post the notice above |
340
+
341
+ ```yaml
342
+ scheduling:
343
+ retry_attempts: 1
344
+ retry_delay_ms: 300000
345
+ notify_on_failure: true
346
+ ```
347
+
348
+ ### Unparseable cron expressions
349
+
350
+ A task whose cron cannot be parsed has no next occurrence to advance to. Rather than leave `next_run_at` in the past — which would make the poll re-run it, and spend a full agent turn, every few seconds — the scheduler **pauses the task** and logs why. Fix the expression and resume it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -84,13 +84,13 @@
84
84
  "@chat-adapter/slack": "^4.14.0",
85
85
  "@chat-adapter/teams": "^4.17.0",
86
86
  "@chat-adapter/telegram": "^4.26.0",
87
- "@earendil-works/pi-agent-core": "~0.79.6",
88
- "@earendil-works/pi-ai": "~0.79.10",
89
- "@earendil-works/pi-coding-agent": "~0.79.6",
87
+ "@earendil-works/pi-agent-core": "~0.83.0",
88
+ "@earendil-works/pi-ai": "~0.83.0",
89
+ "@earendil-works/pi-coding-agent": "~0.83.0",
90
90
  "@whiskeysockets/baileys": "^7.0.0-rc.9",
91
91
  "axios": "^1.15.1",
92
92
  "chat": "^4.14.0",
93
- "commander": "^14.0.3",
93
+ "commander": "^15.0.0",
94
94
  "cron-parser": "^5.5.0",
95
95
  "discord.js": "^14.26.3",
96
96
  "hono": "^4.12.34",
@@ -100,10 +100,10 @@
100
100
  },
101
101
  "devDependencies": {
102
102
  "@biomejs/biome": "^2.4.12",
103
- "@types/node": "^25.6.0",
103
+ "@types/node": "^26.1.2",
104
104
  "@types/qrcode-terminal": "^0.12.2",
105
105
  "bun-types": "^1.3.5",
106
- "typescript": "^5.8.0"
106
+ "typescript": "^7.0.2"
107
107
  },
108
108
  "engines": {
109
109
  "bun": ">=1.2.0"
@@ -1058,6 +1058,14 @@ function invokePiOnce(
1058
1058
  return;
1059
1059
  }
1060
1060
  const parsed = parsePiPrintJsonlOutput(stdout);
1061
+ if (parsed.droppedNarrationBlocks) {
1062
+ // Counts only — never the text. This is the one signal that survives
1063
+ // the --rm container, and it is what lets a live run confirm the
1064
+ // narration filter is doing its job.
1065
+ logTiming("container.pi.narration_dropped", {
1066
+ blocks: parsed.droppedNarrationBlocks,
1067
+ });
1068
+ }
1061
1069
  if (parsed.piFailureMessage) {
1062
1070
  reject(new Error(parsed.piFailureMessage));
1063
1071
  return;
@@ -52,10 +52,17 @@ const INNER_PI_AGENT_DIR = "/home/mercury/.pi/agent";
52
52
  * A superset of what Mercury itself writes (`.pi/`, `AGENTS.md`, `skills/`) —
53
53
  * the remaining entries are pi resource kinds a user may drop in by hand, none
54
54
  * of which can hold a credential.
55
+ *
56
+ * `models.json` is the one entry where that last clause is a *rule* rather than
57
+ * an observation: pi's `ProviderConfig` accepts an `apiKey` and arbitrary
58
+ * `headers`, so the file is checked for literal credentials before it is
59
+ * mounted (see `modelsJsonIsCredentialFree`). Env references are allowed —
60
+ * they are how a custom provider is meant to be keyed.
55
61
  */
56
62
  const PI_AGENT_RESOURCE_ENTRIES = [
57
63
  ".pi",
58
64
  "AGENTS.md",
65
+ "models.json",
59
66
  "skills",
60
67
  "agents",
61
68
  "commands",
@@ -63,6 +70,286 @@ const PI_AGENT_RESOURCE_ENTRIES = [
63
70
  "extensions",
64
71
  ] as const;
65
72
 
73
+ /**
74
+ * Names whose value is a credential rather than configuration. Applied both to
75
+ * header names and to the config fields beside them: pi's schema ignores an
76
+ * unknown field, but Typebox still lets it through, so a provider that spells
77
+ * `apiKey` as `api_key` gets no complaint from pi and the secret rides into the
78
+ * mounted file anyway.
79
+ */
80
+ const CREDENTIAL_FIELD_RE =
81
+ /(^authorization$|^cookie$|key|token|secret|auth|credential|passw)/i;
82
+
83
+ /** Env var names pi will resolve in a `$VAR` / `${VAR}` reference. */
84
+ const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
85
+ const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
86
+
87
+ /**
88
+ * A run long enough to be a pasted secret. Only ever applied to the *literal*
89
+ * remainder of a value that already carries an env reference, which is what
90
+ * separates `"Bearer $GW_KEY"` (a template, fine) from
91
+ * `"sk-live-abc…$GW_KEY"` (a secret with a reference stapled on). Blind to `/`
92
+ * and `.` so a URL-shaped literal cannot trip it.
93
+ */
94
+ const SECRET_SHAPED_RUN = /[A-Za-z0-9_-]{20,}/;
95
+
96
+ /**
97
+ * Split a config value the way pi's `parseConfigValueTemplate` does
98
+ * (`pi-coding-agent@0.79.10/dist/core/resolve-config-value.js`), reporting
99
+ * whether any part resolves from the environment and what literal text is left
100
+ * over once the references are removed.
101
+ *
102
+ * The parse has to be pi's, not an approximation of it: pi *interpolates*, so a
103
+ * reference may sit inside a longer string, and `$$` / `$!` escape a literal
104
+ * `$` / `!` rather than starting one.
105
+ */
106
+ function scanConfigValueTemplate(config: string): {
107
+ hasEnvRef: boolean;
108
+ literal: string;
109
+ } {
110
+ let literal = "";
111
+ let hasEnvRef = false;
112
+ let index = 0;
113
+ while (index < config.length) {
114
+ const dollar = config.indexOf("$", index);
115
+ if (dollar < 0) {
116
+ literal += config.slice(index);
117
+ break;
118
+ }
119
+ literal += config.slice(index, dollar);
120
+ const next = config[dollar + 1];
121
+ if (next === "$" || next === "!") {
122
+ literal += next;
123
+ index = dollar + 2;
124
+ continue;
125
+ }
126
+ if (next === "{") {
127
+ const end = config.indexOf("}", dollar + 2);
128
+ if (end < 0) {
129
+ literal += "$";
130
+ index = dollar + 1;
131
+ continue;
132
+ }
133
+ const name = config.slice(dollar + 2, end);
134
+ if (ENV_VAR_NAME_RE.test(name)) hasEnvRef = true;
135
+ else literal += config.slice(dollar, end + 1);
136
+ index = end + 1;
137
+ continue;
138
+ }
139
+ const name = config.slice(dollar + 1).match(ENV_VAR_NAME_PREFIX_RE)?.[0];
140
+ if (name) {
141
+ hasEnvRef = true;
142
+ index = dollar + 1 + name.length;
143
+ continue;
144
+ }
145
+ literal += "$";
146
+ index = dollar + 1;
147
+ }
148
+ return { hasEnvRef, literal };
149
+ }
150
+
151
+ /**
152
+ * True when pi resolves this value at run time instead of reading it from the
153
+ * file: `!cmd` (shell command), or a template carrying at least one `$VAR` /
154
+ * `${VAR}` reference.
155
+ *
156
+ * This distinction is the whole point of the check. `"apiKey": "$GATEWAY_KEY"`
157
+ * is a *reference* and is exactly how a custom provider is supposed to be
158
+ * keyed; `"apiKey": "sk-…"` is a secret sitting at rest in a mounted file.
159
+ *
160
+ * A template only has to *contain* a reference, because that is pi's rule:
161
+ * `"Authorization": "Bearer $GW_KEY"` is the ordinary spelling of a bearer
162
+ * header and holds no secret. What is left over after the references are
163
+ * removed still has to look like configuration rather than a key, so stapling
164
+ * an unused `$VAR` onto a pasted secret does not buy passage.
165
+ */
166
+ function isCredentialReference(value: string): boolean {
167
+ if (!value.trim()) return true; // nothing there to leak
168
+ // pi tests the raw value, so a leading space means this is not a command.
169
+ if (value.startsWith("!")) return true;
170
+ const { hasEnvRef, literal } = scanConfigValueTemplate(value);
171
+ if (!hasEnvRef) return false;
172
+ return !SECRET_SHAPED_RUN.test(literal);
173
+ }
174
+
175
+ /**
176
+ * JSONC → JSON, character-for-character as pi does it
177
+ * (`pi-coding-agent@0.79.10/dist/utils/json.js`): `//` line comments and
178
+ * trailing commas go, string literals are left alone. Block comments stay,
179
+ * because pi does not strip them either.
180
+ *
181
+ * Matching pi exactly is the requirement, not tolerance for its own sake. Strip
182
+ * *more* than pi and a file this check passed is one pi then fails to parse —
183
+ * pi keeps its built-in models and stashes the error, so a repointed provider
184
+ * quietly goes back to its default endpoint, which is the silent drift the
185
+ * mount was added to prevent. Strip *less* and a file pi reads happily is
186
+ * refused as unparseable.
187
+ */
188
+ function stripJsonComments(input: string): string {
189
+ return input
190
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
191
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) =>
192
+ tail !== undefined ? tail : m[0] === '"' ? m : "",
193
+ );
194
+ }
195
+
196
+ /** Every literal credential in a parsed models.json, as `provider.field` paths. */
197
+ function findLiteralCredentials(parsed: unknown): string[] {
198
+ const providers = (parsed as { providers?: Record<string, unknown> })
199
+ ?.providers;
200
+ if (!providers || typeof providers !== "object") return [];
201
+
202
+ const offenders: string[] = [];
203
+
204
+ /**
205
+ * Every string field whose *name* says it holds a credential. Serves both the
206
+ * `headers` record and the config object around it, which are the same
207
+ * question asked of a different bag of strings.
208
+ */
209
+ const checkFields = (obj: unknown, where: string) => {
210
+ if (!obj || typeof obj !== "object") return;
211
+ for (const [name, value] of Object.entries(
212
+ obj as Record<string, unknown>,
213
+ )) {
214
+ if (typeof value !== "string") continue;
215
+ if (!CREDENTIAL_FIELD_RE.test(name)) continue;
216
+ if (!isCredentialReference(value)) offenders.push(`${where}.${name}`);
217
+ }
218
+ };
219
+
220
+ /** Userinfo or a credential-shaped query parameter is a credential however it got into the URL. */
221
+ const checkUrl = (value: unknown, where: string) => {
222
+ if (typeof value !== "string") return;
223
+ let url: URL;
224
+ try {
225
+ url = new URL(value);
226
+ } catch {
227
+ return; // Not a URL — pi will complain about it; not this function's business.
228
+ }
229
+ if (url.username || url.password) {
230
+ offenders.push(where);
231
+ return;
232
+ }
233
+ for (const [name, param] of url.searchParams) {
234
+ if (CREDENTIAL_FIELD_RE.test(name) && !isCredentialReference(param)) {
235
+ offenders.push(`${where}?${name}`);
236
+ return;
237
+ }
238
+ }
239
+ };
240
+
241
+ for (const [name, raw] of Object.entries(providers)) {
242
+ const p = raw as Record<string, unknown> | null;
243
+ if (!p || typeof p !== "object") continue;
244
+
245
+ checkFields(p, name);
246
+ checkFields(p.headers, `${name}.headers`);
247
+ checkUrl(p.baseUrl, `${name}.baseUrl`);
248
+ if (Array.isArray(p.models)) {
249
+ for (const m of p.models as Array<Record<string, unknown> | null>) {
250
+ if (!m || typeof m !== "object") continue;
251
+ const at = `${name}.models[${String(m.id)}]`;
252
+ checkFields(m, at);
253
+ checkFields(m.headers, `${at}.headers`);
254
+ // pi takes a per-model baseUrl over the provider's, so it needs the
255
+ // same look: `modelDef.baseUrl ?? providerConfig.baseUrl`.
256
+ checkUrl(m.baseUrl, `${at}.baseUrl`);
257
+ }
258
+ }
259
+ if (p.modelOverrides && typeof p.modelOverrides === "object") {
260
+ for (const [id, ov] of Object.entries(
261
+ p.modelOverrides as Record<string, Record<string, unknown> | null>,
262
+ )) {
263
+ if (!ov || typeof ov !== "object") continue;
264
+ const at = `${name}.modelOverrides[${id}]`;
265
+ checkFields(ov, at);
266
+ checkFields(ov.headers, `${at}.headers`);
267
+ }
268
+ }
269
+ }
270
+ return offenders;
271
+ }
272
+
273
+ /** Cached verdict per file, keyed on the stat that would change if it changed. */
274
+ const modelsJsonVerdicts = new Map<
275
+ string,
276
+ { mtimeMs: number; size: number; ok: boolean }
277
+ >();
278
+
279
+ /**
280
+ * Refuse a `models.json` that stores a credential *literally*, so the
281
+ * allowlist's "none of which can hold a credential" note stays true for the one
282
+ * entry where pi's schema would otherwise permit a secret.
283
+ *
284
+ * References are fine and are the supported spelling: `"apiKey": "$GATEWAY_KEY"`
285
+ * resolves inside the container from the env Mercury already passes through.
286
+ * A literal is not, because the file is mounted into the least-trusted part of
287
+ * the system — the same reasoning that keeps `auth.json` off the allowlist.
288
+ *
289
+ * **A guardrail against accident, not a boundary against a hostile writer.**
290
+ * The bind source is live, so anyone who can write the global dir can change
291
+ * the file after this check and before `docker create`. Anyone who can do that
292
+ * can already do worse.
293
+ *
294
+ * Fails closed on a file it cannot read or parse: an unverifiable file is one
295
+ * whose invariant is unknown. Never fatal — a typo in an optional config file
296
+ * should not take the bot down — so it logs at error level instead, and the
297
+ * verdict is cached per (mtime, size) to keep that to one line per edit rather
298
+ * than one per turn.
299
+ */
300
+ function modelsJsonIsCredentialFree(file: string): boolean {
301
+ let stat: fs.Stats;
302
+ try {
303
+ stat = fs.statSync(file);
304
+ } catch {
305
+ return false;
306
+ }
307
+ const cached = modelsJsonVerdicts.get(file);
308
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
309
+ return cached.ok;
310
+ }
311
+
312
+ const remember = (ok: boolean) => {
313
+ modelsJsonVerdicts.set(file, {
314
+ mtimeMs: stat.mtimeMs,
315
+ size: stat.size,
316
+ ok,
317
+ });
318
+ return ok;
319
+ };
320
+
321
+ let raw: string;
322
+ try {
323
+ raw = fs.readFileSync(file, "utf8");
324
+ } catch (error) {
325
+ logger.error("Ignoring models.json: it could not be read.", {
326
+ file,
327
+ error: error instanceof Error ? error.message : String(error),
328
+ });
329
+ return remember(false);
330
+ }
331
+
332
+ let parsed: unknown;
333
+ try {
334
+ parsed = JSON.parse(stripJsonComments(raw));
335
+ } catch (error) {
336
+ logger.error("Ignoring models.json: it is not valid JSON.", {
337
+ file,
338
+ error: error instanceof Error ? error.message : String(error),
339
+ });
340
+ return remember(false);
341
+ }
342
+
343
+ const offenders = findLiteralCredentials(parsed);
344
+ if (offenders.length === 0) return remember(true);
345
+
346
+ logger.error(
347
+ 'Ignoring models.json: it stores a credential literally. Replace the value with an env reference such as "$MY_GATEWAY_KEY" and pass the secret as MERCURY_MY_GATEWAY_KEY. Until this is fixed, a repointed provider falls back to its default endpoint and a provider defined only in this file stops resolving.',
348
+ { file, fields: offenders.join(", ") },
349
+ );
350
+ return remember(false);
351
+ }
352
+
66
353
  /**
67
354
  * Docker `-v` args exposing the global dir's resource entries at
68
355
  * `INNER_PI_AGENT_DIR`, one mount per entry rather than one mount of the
@@ -84,7 +371,11 @@ export function buildPiAgentMountArgs(
84
371
  ): string[] {
85
372
  const args: string[] = [];
86
373
  for (const entry of PI_AGENT_RESOURCE_ENTRIES) {
87
- if (!fs.existsSync(path.join(hostGlobalDir, entry))) continue;
374
+ const hostPath = path.join(hostGlobalDir, entry);
375
+ if (!fs.existsSync(hostPath)) continue;
376
+ if (entry === "models.json" && !modelsJsonIsCredentialFree(hostPath)) {
377
+ continue;
378
+ }
88
379
  args.push(
89
380
  "-v",
90
381
  `${path.join(innerGlobalDir, entry)}:${INNER_PI_AGENT_DIR}/${entry}:ro`,
@@ -4,7 +4,10 @@
4
4
 
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import path from "node:path";
7
- import { getModels, type KnownProvider } from "@earendil-works/pi-ai";
7
+ import {
8
+ type BuiltinProvider,
9
+ getBuiltinModels,
10
+ } from "@earendil-works/pi-ai/providers/all";
8
11
  import { parse as parseYaml } from "yaml";
9
12
  import { z } from "zod";
10
13
  import type { ModelLeg } from "../config.js";
@@ -96,8 +99,8 @@ function matchBuiltinCapabilities(
96
99
  provider: string,
97
100
  modelId: string,
98
101
  ): ModelCapabilities | null {
99
- // Cast to KnownProvidergetModels returns [] for unrecognised providers at runtime
100
- const model = getModels(provider as KnownProvider).find(
102
+ // Cast to BuiltinProvidergetBuiltinModels returns [] for unrecognised providers at runtime
103
+ const model = getBuiltinModels(provider as BuiltinProvider).find(
101
104
  (m) => m.id === modelId,
102
105
  );
103
106
  if (!model) return null;