mercury-agent 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/configuration.md +98 -0
- package/docs/scheduler.md +81 -19
- package/package.json +7 -7
- package/src/agent/container-entry.ts +8 -0
- package/src/agent/container-runner.ts +292 -1
- package/src/agent/model-capabilities.ts +6 -3
- package/src/agent/pi-jsonl-parser.ts +123 -11
- package/src/cli/mercury.ts +103 -65
- package/src/config-file.ts +26 -1
- package/src/config.ts +32 -2
- package/src/core/api.ts +23 -17
- package/src/core/auth.ts +28 -0
- package/src/core/handler.ts +63 -3
- package/src/core/routes/chat.ts +31 -27
- package/src/core/routes/dashboard.ts +19 -6
- package/src/core/runtime.ts +59 -15
- package/src/core/system-messages.ts +5 -0
- package/src/core/task-scheduler.ts +223 -25
- package/src/main.ts +6 -0
- package/src/server.ts +53 -57
- package/src/storage/db.ts +73 -0
- package/src/storage/pi-auth.ts +48 -33
- package/src/types.ts +13 -0
package/README.md
CHANGED
|
@@ -361,8 +361,8 @@ Optional project file **`mercury.yaml`** (or **`mercury.yml`**) supplies non-sec
|
|
|
361
361
|
|
|
362
362
|
| Variable | Description |
|
|
363
363
|
|----------|-------------|
|
|
364
|
-
| `MERCURY_API_SECRET` | Shared secret for `/api/*`
|
|
365
|
-
| `MERCURY_CHAT_API_KEY` | Optional
|
|
364
|
+
| `MERCURY_API_SECRET` | Shared secret for `/api/*`, `/dashboard/*` and `/chat`. Requires `Authorization: Bearer <secret>`; when unset those routes refuse to serve (503). Auto-generated by `mercury setup`. |
|
|
365
|
+
| `MERCURY_CHAT_API_KEY` | Optional dedicated key for the `/chat` endpoint. Falls back to `MERCURY_API_SECRET` when unset; with neither configured, `/chat` returns 503. |
|
|
366
366
|
|
|
367
367
|
**Auth:**
|
|
368
368
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
│ ├─►
|
|
67
|
-
│
|
|
68
|
-
│ │ ├─► Update next_run_at in DB
|
|
69
|
-
│ │ └─► Execute handler
|
|
66
|
+
│ ├─► Execute handler
|
|
67
|
+
│ ├─► Record the outcome on the task row
|
|
70
68
|
│ │
|
|
71
|
-
│
|
|
72
|
-
│
|
|
73
|
-
│
|
|
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
|
|
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);
|
|
247
|
-
scheduler.stop();
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
3
|
+
"version": "0.12.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.
|
|
88
|
-
"@earendil-works/pi-ai": "~0.
|
|
89
|
-
"@earendil-works/pi-coding-agent": "~0.
|
|
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": "^
|
|
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": "^
|
|
103
|
+
"@types/node": "^26.1.2",
|
|
104
104
|
"@types/qrcode-terminal": "^0.12.2",
|
|
105
105
|
"bun-types": "^1.3.5",
|
|
106
|
-
"typescript": "^
|
|
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
|
-
|
|
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 {
|
|
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
|
|
100
|
-
const model =
|
|
102
|
+
// Cast to BuiltinProvider — getBuiltinModels 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;
|