mercury-agent 0.21.0 → 0.23.0-beta.1
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/container/Dockerfile +2 -1
- package/container/Dockerfile.base +2 -1
- package/container/agent-package.json +1 -1
- package/container/build.sh +6 -3
- package/docs/configuration.md +249 -1
- package/docs/container-lifecycle.md +30 -8
- package/docs/deployment.md +45 -4
- package/docs/extensions.md +44 -5
- package/docs/goals/feed-watch-social-sources/decisions.md +602 -1
- package/docs/goals/feed-watch-social-sources/roadmap.md +27 -8
- package/docs/goals/feed-watch-social-sources/source-review-task-draft.md +80 -0
- package/docs/goals/football-match-day-mode/decisions.md +194 -25
- package/docs/goals/football-match-day-mode/goal.md +3 -3
- package/docs/goals/football-match-day-mode/roadmap.md +109 -50
- package/docs/goals/football-reporter-profile/decisions.md +143 -0
- package/docs/goals/football-reporter-profile/roadmap.md +10 -7
- package/docs/goals/rehearsal-bench/decisions.md +76 -0
- package/docs/goals/rehearsal-bench/goal.md +1 -1
- package/docs/goals/rehearsal-bench/roadmap.md +9 -6
- package/docs/goals/release-gate/README.md +4 -2
- package/docs/goals/release-gate/decisions.md +118 -0
- package/docs/goals/release-gate/goal.md +2 -2
- package/docs/goals/release-gate/roadmap.md +23 -12
- package/docs/goals/whatsapp-bot-hardening/decisions.md +53 -0
- package/docs/goals/whatsapp-bot-hardening/roadmap.md +15 -1
- package/docs/live-testing.md +88 -119
- package/docs/pending-verification.md +738 -69
- package/docs/pipeline.md +4 -1
- package/docs/profile-guide.md +6 -2
- package/docs/release-gate.md +276 -0
- package/docs/smoke-fixtures.md +245 -0
- package/docs/subagents.md +8 -4
- package/examples/extensions/feed-watch/config.ts +157 -5
- package/examples/extensions/feed-watch/digest.ts +631 -29
- package/examples/extensions/feed-watch/feeds.ts +124 -10
- package/examples/extensions/feed-watch/index.ts +164 -10
- package/examples/extensions/feed-watch/items.ts +147 -10
- package/examples/extensions/feed-watch/match.ts +77 -15
- package/examples/extensions/feed-watch/posted.ts +275 -0
- package/examples/extensions/feed-watch/prompts/verify.md +9 -0
- package/examples/extensions/feed-watch/scorecard.ts +232 -0
- package/examples/extensions/feed-watch/skill/SKILL.md +80 -10
- package/examples/extensions/feed-watch/watch.ts +961 -46
- package/examples/extensions/feed-watch/yield.ts +221 -0
- package/examples/extensions/gws/index.ts +150 -14
- package/examples/extensions/longview/hook.ts +63 -4
- package/examples/extensions/longview/index.ts +9 -0
- package/examples/extensions/longview/render/telegraph-nodes.ts +5 -1
- package/examples/extensions/morning/index.ts +1 -1
- package/examples/extensions/napkin/index.ts +167 -64
- package/examples/extensions/napkin/pi-spawn.ts +157 -2
- package/examples/extensions/poster/index.ts +13 -3
- package/examples/extensions/tradestation/host/refresh.ts +20 -0
- package/examples/extensions/tradestation/index.ts +81 -1
- package/examples/extensions/voice-transcribe/index.ts +1 -1
- package/examples/extensions/yahoo-mail/index.ts +23 -2
- package/examples/extensions/yahoo-mail/lib/ymail.ts +147 -0
- package/examples/extensions/yahoo-mail/package.json +1 -1
- package/examples/profiles/_template/config.yaml +11 -1
- package/examples/profiles/football-reporter/AGENTS.md +95 -21
- package/examples/profiles/football-reporter/README.md +100 -0
- package/examples/profiles/football-reporter/config.yaml +651 -22
- package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +1 -1
- package/examples/profiles/football-reporter/seed/names.md +54 -0
- package/examples/profiles/football-reporter/standard.json +46 -6
- package/examples/profiles/football-reporter/tasks/daily-article.md +102 -30
- package/examples/profiles/football-reporter/tasks/memes.md +72 -0
- package/examples/profiles/football-reporter/tasks/roundup.md +52 -0
- package/examples/smoke/club-newsroom/smoke.yaml +46 -0
- package/examples/smoke/proposal-desk/brief.md +8 -0
- package/examples/smoke/proposal-desk/smoke.yaml +26 -0
- package/package.json +17 -12
- package/resources/agents/explore.md +8 -0
- package/resources/pi-extensions/subagent/spawn.ts +22 -0
- package/resources/templates/mercury.example.yaml +5 -0
- package/src/adapters/slack.ts +2 -0
- package/src/adapters/whatsapp-db-lookups.ts +95 -0
- package/src/adapters/whatsapp-egress.ts +135 -0
- package/src/adapters/whatsapp-ingress.ts +62 -13
- package/src/adapters/whatsapp-media.ts +46 -33
- package/src/adapters/whatsapp-mentions.ts +307 -0
- package/src/adapters/whatsapp-thread.ts +54 -0
- package/src/adapters/whatsapp.ts +100 -28
- package/src/agent/container-entry.ts +20 -1
- package/src/agent/container-runner.ts +236 -90
- package/src/agent/image-contract.ts +1 -0
- package/src/agent/image-refresh.ts +27 -10
- package/src/bridges/whatsapp.ts +110 -30
- package/src/cli/mercury.ts +712 -251
- package/src/cli/upgrade.ts +392 -2
- package/src/config-file.ts +29 -0
- package/src/config.ts +137 -3
- package/src/core/commands.ts +17 -1
- package/src/core/connection-health.ts +278 -151
- package/src/core/handler.ts +194 -12
- package/src/core/heartbeat.ts +188 -0
- package/src/core/inbox-write.ts +148 -0
- package/src/core/operator-alerts.ts +210 -0
- package/src/core/permissions.ts +19 -0
- package/src/core/reporter-lint-on-send.ts +293 -0
- package/src/core/routes/capability.ts +23 -1
- package/src/core/routes/chat.ts +44 -6
- package/src/core/routes/config-builtin.ts +27 -0
- package/src/core/routes/connections.ts +52 -4
- package/src/core/routes/dashboard.ts +22 -0
- package/src/core/routes/shadow.ts +470 -0
- package/src/core/runtime.ts +770 -68
- package/src/core/shadow-bridge.ts +609 -0
- package/src/core/shadow-outbox.ts +319 -0
- package/src/core/simulate-ingress.ts +1106 -0
- package/src/core/system-messages.ts +10 -0
- package/src/core/task-scheduler.ts +78 -0
- package/src/core/task-sender.ts +164 -0
- package/src/env-dump/diff.ts +721 -0
- package/src/env-dump/manifest.ts +6 -2
- package/src/extensions/api.ts +29 -1
- package/src/extensions/bash-timeout.ts +112 -0
- package/src/extensions/loader.ts +28 -0
- package/src/extensions/types.ts +119 -7
- package/src/logger.ts +51 -2
- package/src/main.ts +106 -101
- package/src/ops/shadow-root.ts +60 -0
- package/src/ops/shadow-service.ts +421 -14
- package/src/ops/shadow-snapshot.ts +130 -3
- package/src/preflight/boot.ts +118 -0
- package/src/preflight/checks/docker.ts +23 -1
- package/src/preflight/checks/host-deps.ts +130 -0
- package/src/preflight/checks/roundtrip.ts +4 -0
- package/src/preflight/report.ts +3 -0
- package/src/preflight/run.ts +2 -0
- package/src/profile/readonly-store.ts +118 -0
- package/src/profile/space-profile.ts +186 -8
- package/src/server.ts +32 -0
- package/src/smoke/boot.ts +303 -0
- package/src/smoke/cli.ts +256 -0
- package/src/smoke/evaluate.ts +401 -0
- package/src/smoke/fixture.ts +387 -0
- package/src/smoke/refusal.ts +96 -0
- package/src/smoke/run.ts +355 -0
- package/src/storage/db.ts +236 -10
- package/src/storage/pi-auth.ts +42 -10
- package/src/text/reporter-lint.ts +501 -74
- package/src/types.ts +15 -0
package/README.md
CHANGED
|
@@ -292,7 +292,7 @@ mercury service uninstall
|
|
|
292
292
|
mercury service status
|
|
293
293
|
mercury service logs [-f]
|
|
294
294
|
|
|
295
|
-
# upgrade (stops the service, installs globally, restarts)
|
|
295
|
+
# upgrade (pulls the matching agent image first, then stops the service, installs globally, restarts; --dry-run prints the plan)
|
|
296
296
|
mercury upgrade # latest
|
|
297
297
|
mercury upgrade 0.14.0 # a specific version
|
|
298
298
|
```
|
|
@@ -420,7 +420,7 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
|
|
|
420
420
|
|
|
421
421
|
| Variable | Default | Description |
|
|
422
422
|
|----------|---------|-------------|
|
|
423
|
-
| `MERCURY_AGENT_IMAGE` | `ghcr.io/avishai-tsabari/mercury-agent
|
|
423
|
+
| `MERCURY_AGENT_IMAGE` | `ghcr.io/avishai-tsabari/mercury-agent:<version>` | Container image; pinned to the installed version, see `docs/configuration.md` § Agent image |
|
|
424
424
|
| `MERCURY_CONTAINER_TIMEOUT_MS` | `300000` | Container timeout (5 min) |
|
|
425
425
|
| `MERCURY_CONTAINER_RUNTIME` | `runc` | `runc` (default) or `runsc` ([gVisor](https://gvisor.dev)) |
|
|
426
426
|
| `MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT` | `false` | Set `true` on Linux Docker Engine (see note below) |
|
package/container/Dockerfile
CHANGED
|
@@ -60,7 +60,7 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
60
60
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
61
61
|
|
|
62
62
|
# Install CLIs
|
|
63
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
63
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.85.1
|
|
64
64
|
|
|
65
65
|
WORKDIR /app
|
|
66
66
|
|
|
@@ -111,6 +111,7 @@ COPY src/cli/mrctl.ts /app/src/cli/mrctl.ts
|
|
|
111
111
|
COPY src/cli/mrctl-http.ts /app/src/cli/mrctl-http.ts
|
|
112
112
|
COPY src/extensions/reserved.ts /app/src/extensions/reserved.ts
|
|
113
113
|
COPY src/extensions/permission-guard.ts /app/src/extensions/permission-guard.ts
|
|
114
|
+
COPY src/extensions/bash-timeout.ts /app/src/extensions/bash-timeout.ts
|
|
114
115
|
COPY src/text/sanitize-text.ts /app/src/text/sanitize-text.ts
|
|
115
116
|
COPY src/types.ts /app/src/types.ts
|
|
116
117
|
COPY resources/ /app/resources/
|
|
@@ -42,7 +42,7 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
42
42
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
43
43
|
|
|
44
44
|
# Install CLIs
|
|
45
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
45
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.85.1
|
|
46
46
|
|
|
47
47
|
WORKDIR /app
|
|
48
48
|
|
|
@@ -80,6 +80,7 @@ COPY src/cli/mrctl.ts /app/src/cli/mrctl.ts
|
|
|
80
80
|
COPY src/cli/mrctl-http.ts /app/src/cli/mrctl-http.ts
|
|
81
81
|
COPY src/extensions/reserved.ts /app/src/extensions/reserved.ts
|
|
82
82
|
COPY src/extensions/permission-guard.ts /app/src/extensions/permission-guard.ts
|
|
83
|
+
COPY src/extensions/bash-timeout.ts /app/src/extensions/bash-timeout.ts
|
|
83
84
|
COPY src/text/sanitize-text.ts /app/src/text/sanitize-text.ts
|
|
84
85
|
COPY src/types.ts /app/src/types.ts
|
|
85
86
|
COPY resources/ /app/resources/
|
package/container/build.sh
CHANGED
|
@@ -31,9 +31,12 @@ docker build -f container/Dockerfile \
|
|
|
31
31
|
-t "${IMAGE_NAME}:${VERSION}" \
|
|
32
32
|
-t "${IMAGE_NAME}:latest" .
|
|
33
33
|
|
|
34
|
-
# Both tags, on purpose.
|
|
35
|
-
#
|
|
36
|
-
#
|
|
34
|
+
# Both tags, on purpose. `mercury-agent:latest` is the explicit local override
|
|
35
|
+
# a source-run box sets (`agent.image` / MERCURY_AGENT_IMAGE), so dropping it
|
|
36
|
+
# would break every such host; `mercury-agent:<version>` mirrors the registry
|
|
37
|
+
# tag the default now pins to (release-gate R0.6 — `src/config.ts` defaults to
|
|
38
|
+
# `ghcr.io/…/mercury-agent:<version>`, which `mercury build` tags as well).
|
|
39
|
+
# Neither local name has a registry host, so neither is ever pulled.
|
|
37
40
|
echo "✓ Built ${IMAGE_NAME}:${VERSION} and ${IMAGE_NAME}:latest"
|
|
38
41
|
echo ""
|
|
39
42
|
echo "Version label:"
|
package/docs/configuration.md
CHANGED
|
@@ -20,7 +20,9 @@ These must be supplied via environment variables only; they are **not** read fro
|
|
|
20
20
|
|
|
21
21
|
- `MERCURY_API_SECRET`
|
|
22
22
|
- `MERCURY_CHAT_API_KEY`
|
|
23
|
+
- `MERCURY_SHADOW_API_SECRET` (a shadow's own; see [Shadow mode](#shadow-mode-mercury_shadow))
|
|
23
24
|
- `MERCURY_DISCORD_GATEWAY_SECRET`
|
|
25
|
+
- `MERCURY_HEARTBEAT_URL` (see [Heartbeat](#heartbeat-mercury_heartbeat_url-mercury_heartbeat_tasks))
|
|
24
26
|
|
|
25
27
|
Platform tokens, provider API keys, and extension keys (e.g. `MERCURY_TELEGRAM_BOT_TOKEN`, `MERCURY_BRAVE_API_KEY`) are also **env-only** today—they are not part of the YAML schema.
|
|
26
28
|
|
|
@@ -103,6 +105,107 @@ Restarting *before* the make-up run lands drops it, the same way a restart drops
|
|
|
103
105
|
a pending retry: the boot pass leaves `next_run_at` off the cron grid, and the
|
|
104
106
|
next boot realigns normally.
|
|
105
107
|
|
|
108
|
+
## Heartbeat (`MERCURY_HEARTBEAT_URL`, `MERCURY_HEARTBEAT_TASKS`)
|
|
109
|
+
|
|
110
|
+
Nothing on the box can alert while the box is off. Twice in six days the box
|
|
111
|
+
was down overnight (2026-09-05, 2026-09-10) and the 09:00 article with it, and
|
|
112
|
+
the missed-run report above could only speak once the box was back. The
|
|
113
|
+
heartbeat is the reader that lives somewhere else: after a listed task
|
|
114
|
+
**delivers**, the host GETs a URL, and the *service* behind that URL alerts the
|
|
115
|
+
owner's phone when the ping is late. A dead box, a dead WSL distro and a dead
|
|
116
|
+
WhatsApp socket are all heard the same way, because nothing here has to be
|
|
117
|
+
alive to raise the alarm.
|
|
118
|
+
|
|
119
|
+
Both keys are **env-only** (the URL is a credential — anyone holding it can mark
|
|
120
|
+
the check up — and is listed under Secrets above; `mercury env dump` reports
|
|
121
|
+
both as present or absent and prints neither value, so a swapped pair cannot
|
|
122
|
+
leak the URL through the task list):
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# .env
|
|
126
|
+
MERCURY_HEARTBEAT_URL=https://hc-ping.com/<ping-key>/mercury-{task}
|
|
127
|
+
MERCURY_HEARTBEAT_TASKS=24,621
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- **`{task}`** in the URL is replaced by the task id, so one credential serves
|
|
131
|
+
one check per task — with healthchecks.io's slug pings the checks above are
|
|
132
|
+
`mercury-24` and `mercury-621` under one project ping key. A URL without the
|
|
133
|
+
placeholder is fine for a single listed task; with two or more, every ping
|
|
134
|
+
lands on one check and a delivered roundup would silence the alarm for a
|
|
135
|
+
missing article — `mercury preflight` warns on that shape.
|
|
136
|
+
- **When a ping is sent — one rule.** A run of a listed task that recorded
|
|
137
|
+
`status: ok` with `outcome: posted` (the ledger's own words, see
|
|
138
|
+
`mercury env dump` / `task_runs`). **Not** on `no_update` — the article that
|
|
139
|
+
found nothing to say is exactly the case to alert on. Not on an error, not on
|
|
140
|
+
a delivery failure, not for an unlisted task (extension one-shots, chat turns).
|
|
141
|
+
A manual **Run now** that delivers counts; a `silent` task that delivers
|
|
142
|
+
counts (the run ran and produced; whether the group heard it is `silent`'s
|
|
143
|
+
question).
|
|
144
|
+
- **Bounded and never fatal.** One GET with a 5-second bound, after the ledger
|
|
145
|
+
row and the schedule advance, so a slow or dead ping host holds neither
|
|
146
|
+
hostage. Success is `Heartbeat pinged taskId=24 status=200` at info; failure
|
|
147
|
+
is `Heartbeat ping failed taskId=24 reason=ENOTFOUND` at **warn**, never an
|
|
148
|
+
error and never a failed run — a missing ping is what the service alerts on.
|
|
149
|
+
The log line carries the task id and the status; never the URL.
|
|
150
|
+
- **A shadow never pings.** `MERCURY_SHADOW=1` (below) disables the heartbeat
|
|
151
|
+
whatever the copied `.env` says, so a rehearsal cannot mark the live check up.
|
|
152
|
+
- **`mercury preflight` / `mercury doctor` — `host.heartbeat`.** `skipped` on a
|
|
153
|
+
box with no active cron task; `warn` (never `blocked`) when there are cron
|
|
154
|
+
tasks and no URL, a URL and no listed task, a listed id that is not an active
|
|
155
|
+
cron task, a malformed URL, or the shared-check shape above; `pass` otherwise.
|
|
156
|
+
The evidence names task ids, never the URL.
|
|
157
|
+
|
|
158
|
+
**Setting up the service (recommended: healthchecks.io, free tier).** One check
|
|
159
|
+
per listed task, shaped by how often the task *delivers*, not by its cron: only
|
|
160
|
+
a `posted` run pings, a `no_update` slot does not. A task that posts on every
|
|
161
|
+
slot gets a cron-shaped check — the article as `0 9 * * *` Asia/Jerusalem with a
|
|
162
|
+
30-minute grace (alert at 09:30). A task that is allowed to stay silent on a
|
|
163
|
+
slot gets a *simple* check whose period covers its longest expected silence —
|
|
164
|
+
the roundup, where 3 of 9 slots in one week were `no_update`, as period 1 day
|
|
165
|
+
with a 6-hour grace; a cron-shaped check there alerts on every quiet slot.
|
|
166
|
+
Alert channels: email is enough — it reached the owner's phone within the
|
|
167
|
+
grace period the first time the box died (2026-09-12); a push channel
|
|
168
|
+
(Telegram, Signal) is optional. Never the bot's own WhatsApp, which is the
|
|
169
|
+
thing that is down.
|
|
170
|
+
|
|
171
|
+
**One healthchecks.io project per box, named after the box.** One account
|
|
172
|
+
will end up holding checks for several bots — a client's, a second project's,
|
|
173
|
+
this one's. The alert email leads with the *project* name, then the check
|
|
174
|
+
name, and `mercury-24` says nothing about which box missed. A project named
|
|
175
|
+
after the box (`Mercury — home PC (WSL)`) is the cheapest fix and the safest
|
|
176
|
+
shape: the project is the credential boundary — it owns the ping key, so a
|
|
177
|
+
key that leaks from a client's box cannot mark this bot's checks up — and it
|
|
178
|
+
owns its own alert channels and members, so a colleague added to a work
|
|
179
|
+
project never sees a private bot. Renaming the project touches no slug and
|
|
180
|
+
nothing on the box. Rename the checks inside it only if the task is not
|
|
181
|
+
obvious from the name (`article 09:00`, `roundup`, `WSL anchor`), and then
|
|
182
|
+
mind that the name and the **slug** are separate fields: the URL template
|
|
183
|
+
above pings by slug, so a rename must leave the slug (`mercury-24`) as it is
|
|
184
|
+
— read the Slug field in the same dialog before saving, and put it back if
|
|
185
|
+
the new name re-derived it — or the ping starts returning 404 and every
|
|
186
|
+
listed task reads as down. A changed slug is a box-side change: new
|
|
187
|
+
`MERCURY_HEARTBEAT_URL` template in `.env`, a restart in a gap, and the
|
|
188
|
+
anchor loop's own URL.
|
|
189
|
+
|
|
190
|
+
**Optional second signal, not built here.** A box that pings every few minutes
|
|
191
|
+
from outside Mercury gives the same service a "the machine is up" check that is
|
|
192
|
+
independent of any task — and it is the one that speaks first: a dead distro
|
|
193
|
+
is heard within its grace period instead of at 09:30. On the WSL box that is a
|
|
194
|
+
loop started by the owner's anchor script, against its own check
|
|
195
|
+
(`mercury-anchor`, a *simple* check — period 10 min, grace 10 min, pinged every
|
|
196
|
+
5 min):
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# ~/mercury-anchor.sh — alongside the sleep that holds the distro open
|
|
200
|
+
while true; do curl -fsS -m 10 --retry 2 -o /dev/null "https://hc-ping.com/<ping-key>/mercury-anchor" || true; sleep 300; done &
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The loop dies with the distro, and the logon task that starts the anchor fires
|
|
204
|
+
at Windows logon only — after a distro restart with no logon (a WSL crash, a
|
|
205
|
+
`wsl --shutdown`) the anchor and the loop are both gone until
|
|
206
|
+
`Start-ScheduledTask 'Start Mercury WSL'` is run again; the check going late a
|
|
207
|
+
second time is how that shows.
|
|
208
|
+
|
|
106
209
|
## Operator alerts (`alerts:`)
|
|
107
210
|
|
|
108
211
|
When a provider OAuth credential can no longer be refreshed, every space stops
|
|
@@ -138,6 +241,49 @@ Env equivalents: **`MERCURY_ALERTS_ENABLED`**, **`MERCURY_ALERTS_COOLDOWN_MS`**.
|
|
|
138
241
|
- **Transient failures** (HTTP 408/429/5xx from the token endpoint) alert only
|
|
139
242
|
after three in a row; a single blip heals itself. A rejected refresh token, or
|
|
140
243
|
a cause that could not be classified, alerts on the first occurrence.
|
|
244
|
+
- **Scheduled-task failures go two ways.** A task a person created reports its
|
|
245
|
+
final failure into its own space, as before (`scheduling.notify_on_failure`).
|
|
246
|
+
A task an **extension** opened — feed-watch's verify one-shots; any task named
|
|
247
|
+
after a loaded extension, or whose `created_by` is not a platform user id
|
|
248
|
+
(`system`, the console, `space-profile`) — reports to the operators above
|
|
249
|
+
instead, and its space hears nothing, even when no operator is reachable
|
|
250
|
+
(then the only record is the ERROR log line). No cooldown: a final failure is
|
|
251
|
+
already once per due cycle.
|
|
252
|
+
- **Partial deliveries.** A scheduled post that reached some of a space's linked
|
|
253
|
+
conversations and not others is **not** retried — a retry would post a second
|
|
254
|
+
copy where the first landed — and the operators are told which conversations
|
|
255
|
+
missed it.
|
|
256
|
+
|
|
257
|
+
## History per conversation, and the reporter lint (per-space)
|
|
258
|
+
|
|
259
|
+
Two behaviours that only matter to a space with **more than one linked
|
|
260
|
+
conversation** or with a **reporter standard** — every other space is
|
|
261
|
+
byte-identical.
|
|
262
|
+
|
|
263
|
+
**History per conversation.** Chat and ambient rows record the conversation
|
|
264
|
+
they were said in (`messages.conversation`, a thread id such as
|
|
265
|
+
`whatsapp:<group-jid>`). When a space has two or more linked conversations, a
|
|
266
|
+
run answering one of them reads only that conversation's rows plus the space's
|
|
267
|
+
own rows — scheduled posts, and rows written before the column existed — and
|
|
268
|
+
never the other conversation's chatter. A scheduled run in such a space reads
|
|
269
|
+
only the space's own rows. A space with one conversation reads exactly what it
|
|
270
|
+
always read. Nothing to configure.
|
|
271
|
+
|
|
272
|
+
**`reporter.standard`** (per space, unset by default) — the path, relative to
|
|
273
|
+
the space directory, of a reporter standard JSON file (the shape of
|
|
274
|
+
`examples/profiles/football-reporter/standard.json`; the football profile
|
|
275
|
+
seeds it there and sets the key). When set, every scheduled reply the space
|
|
276
|
+
sends is linted on the host **after** delivery and the findings are written
|
|
277
|
+
to the journal — one `reporter-lint: finding` line per finding with the rule
|
|
278
|
+
id and the task id, and one `reporter-lint: <n> findings` line per run. The
|
|
279
|
+
reply is never altered or held; an unreadable file is one warn line. A reply
|
|
280
|
+
that carries a `[longview:summary]` block is linted as the article, anything
|
|
281
|
+
else as a scan. The path must stay inside the space directory and end in
|
|
282
|
+
`.json`.
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
mrctl config set reporter.standard standard.json
|
|
286
|
+
```
|
|
141
287
|
|
|
142
288
|
## Ambient group context
|
|
143
289
|
|
|
@@ -218,6 +364,28 @@ Snapshot directories are built by `mercury shadow snapshot`, which generates
|
|
|
218
364
|
the shadow's `.env` and `mercury.yaml` — nothing here needs to be set by
|
|
219
365
|
hand. See [live-testing.md](live-testing.md) §2 and §7.
|
|
220
366
|
|
|
367
|
+
**A shadow answers to no live credential.** Every snapshot gets a fresh
|
|
368
|
+
**`MERCURY_SHADOW_API_SECRET`** (64 hex characters), and the generated `.env`
|
|
369
|
+
also overrides `MERCURY_API_SECRET` and `MERCURY_CHAT_API_KEY` with that same
|
|
370
|
+
value — so the live bot's secret opens nothing on the shadow: not `/api/*`,
|
|
371
|
+
not `/chat`, not the dashboard, and not the route below. The value lives
|
|
372
|
+
only in the snapshot's `.env`; `shadow snapshot`, `shadow up`, `shadow
|
|
373
|
+
status`, `/health` and the logs print key names, never the value. Rotation
|
|
374
|
+
is a new snapshot.
|
|
375
|
+
|
|
376
|
+
**`POST /api/shadow/simulate`** (goal `rehearsal-bench` M2.2b) exists only on
|
|
377
|
+
a shadow — on a live process the path is whatever `/api`'s guard says (401
|
|
378
|
+
without the live secret, 404 with it). It is authenticated by
|
|
379
|
+
`MERCURY_SHADOW_API_SECRET` **alone**: unset ⇒ 503 (a snapshot taken before
|
|
380
|
+
this key existed — take a fresh one), mismatch ⇒ 401. The body is a
|
|
381
|
+
`SyntheticWhatsAppEvent` by field name (`application/json`, or
|
|
382
|
+
`multipart/form-data` with an `event` part and `file` parts for attachments);
|
|
383
|
+
`isBotMentioned` / `isReplyToBot` are derived from the stanza and a body
|
|
384
|
+
carrying either is a 400. The response is `text/event-stream`: one `entry`
|
|
385
|
+
event per outbox line as it is journaled, then a `result` event with the
|
|
386
|
+
`SimulateResult`. `/health` reports `simulateRoute: true|false` and
|
|
387
|
+
`mercury shadow status` prints it.
|
|
388
|
+
|
|
221
389
|
## System prompt authorship (`agent.override_pi_system_prompt`)
|
|
222
390
|
|
|
223
391
|
Chooses whether Mercury replaces pi's own system prompt or appends to it:
|
|
@@ -300,7 +468,49 @@ and under an explicit override, the vars reaching every container:
|
|
|
300
468
|
Container env passthrough: explicit `all` override — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
|
|
301
469
|
```
|
|
302
470
|
|
|
303
|
-
For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
|
|
471
|
+
For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result. `hostOnly` is a required field: under `claimed` a claim is the only way a `MERCURY_*` var reaches a container, so a claim has to say whether it means to — `hostOnly: false` is a decision that something inside the container reads the var, and the repo gate (`tests/env-claims-declare-hostonly.test.ts`) holds those to an allowlist naming the reader. The loader warns at startup about any installed extension whose claim does not decide.
|
|
472
|
+
|
|
473
|
+
## Container shared memory (`agent.container_shm_size`)
|
|
474
|
+
|
|
475
|
+
How big `/dev/shm` is inside every agent container, in Docker's `--shm-size` spelling:
|
|
476
|
+
|
|
477
|
+
```yaml
|
|
478
|
+
agent:
|
|
479
|
+
container_shm_size: 512m # 512m (default) | 1g | any Docker size
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Env: `MERCURY_CONTAINER_SHM_SIZE`. Applies to both runtimes — `/dev/shm` is Docker's, not gVisor's.
|
|
483
|
+
|
|
484
|
+
Docker's own default is **64 MB**, and that is the wrong size for what these containers actually do. Chromium is the workload: the `web-browser` extension drives it, the `poster` extension renders through it, and an agent asked to screenshot a page runs it directly. A large frame (a 1856x2304 poster, say) exhausts 64 MB, and Chromium's response is not an error — it **hangs**. The turn then sits idle until the host kills the container at `container_timeout_ms`, and the user gets no reply, only the time-limit marker. Three customer-facing runs were lost that way on 2026-09-10 before the cause was found.
|
|
485
|
+
|
|
486
|
+
The default is deliberately generous rather than minimal. A tmpfs is allocated as it is used, so a container that renders nothing pays nothing for the headroom, and under `container_runtime: runsc` the total is bounded by the container's `--memory` limit anyway. Lower it only on a host that is genuinely short of RAM, and expect large renders to hang again if you go back to 64 MB.
|
|
487
|
+
|
|
488
|
+
Two related things worth knowing:
|
|
489
|
+
|
|
490
|
+
- Passing `--disable-dev-shm-usage` to Chromium sidesteps `/dev/shm` entirely and works at any size. The `poster` extension already does this, which is why it kept working while direct Chromium calls hung.
|
|
491
|
+
- `mercury preflight`'s round-trip probe spawns with the same value, so the shape it vouches for is the shape a real turn gets.
|
|
492
|
+
|
|
493
|
+
## Bash call timeout (`agent.container_bash_timeout_seconds`)
|
|
494
|
+
|
|
495
|
+
The longest a single `bash` tool call may run inside an agent container, in seconds:
|
|
496
|
+
|
|
497
|
+
```yaml
|
|
498
|
+
agent:
|
|
499
|
+
container_bash_timeout_seconds: 300 # 300 (default)
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Env: `MERCURY_CONTAINER_BASH_TIMEOUT_SECONDS`. It is both a default and a ceiling: a call that names no `timeout` gets this one, and a call that asks for more is clamped down to it. The agent can only ask for *less*.
|
|
503
|
+
|
|
504
|
+
pi's `bash` tool takes an optional `timeout` and arms no timer at all when the model omits it — its own parameter description says "optional, no default timeout". A command that hangs rather than failing therefore blocks the turn with nothing watching it, until the host kills the whole container at `container_timeout_ms` and the user gets no reply, only the time-limit marker. That is the other half of the 2026-09-10 incident described under `container_shm_size`: the hanging Chromium was the cause, and the missing bound is what turned each hang into a lost 20-minute run instead of a recoverable error.
|
|
505
|
+
|
|
506
|
+
When the bound fires, pi kills the command's whole process tree and returns `Command timed out after N seconds` as a normal tool error, so the turn can retry differently, fall back, or explain — while it still has budget left to reply.
|
|
507
|
+
|
|
508
|
+
Two things to keep in mind when changing it:
|
|
509
|
+
|
|
510
|
+
- **Keep it comfortably below `container_timeout_ms`.** A bound at or above the container limit buys nothing, because the SIGKILL arrives first. The defaults (300 s against 5 minutes) leave that ratio to whoever tunes both.
|
|
511
|
+
- **Long jobs need a different shape, not a bigger number.** Raising this to cover a slow build makes every hang that much more expensive. Prefer backgrounding the work and polling a file for its result.
|
|
512
|
+
|
|
513
|
+
The value in force is stated to the model in its system prompt, so it can plan around the ceiling instead of meeting it as a dead tool call.
|
|
304
514
|
|
|
305
515
|
## Agent run traces (`agent.trace_runs`)
|
|
306
516
|
|
|
@@ -380,6 +590,7 @@ a detail on the page can be answered.
|
|
|
380
590
|
| `longview.enabled` | `false` | Publish long replies from this space. |
|
|
381
591
|
| `longview.threshold_chars` | `2000` | Publish when a reply exceeds this many characters. Minimum 500. |
|
|
382
592
|
| `longview.target` | `telegraph` | Where pages are published. |
|
|
593
|
+
| `longview.sources` | `both` | Which replies are published: `both` (any reply over the threshold, whoever asked), `scheduled` (task runs only — a member's long answer stays in chat), or `chat` (the reverse). A turn whose origin the host did not report is never published under `scheduled` or `chat`. |
|
|
383
594
|
| `longview.summary_model` | *(empty)* | Model that writes the chat summary. Empty uses the space's configured model. |
|
|
384
595
|
|
|
385
596
|
`summary_model` must name a model on the space's **existing provider**. The
|
|
@@ -535,3 +746,40 @@ lands back in `default`.
|
|
|
535
746
|
The **Cursor Agent CLI** integration has been removed. All model legs use **pi** with standard providers (`anthropic`, `openai`, `google`, `mistral`, `groq`, `openrouter`, etc.).
|
|
536
747
|
|
|
537
748
|
If your chain still has `provider: cursor`, the agent run **fails fast** with an error that points here. Switch to the **native provider** for the model you want (for example `anthropic` for Claude, `openai` for GPT) and set the matching **`MERCURY_*_API_KEY`**.
|
|
749
|
+
|
|
750
|
+
## Agent image (`agent.image`)
|
|
751
|
+
|
|
752
|
+
Which container image every agent run spawns:
|
|
753
|
+
|
|
754
|
+
```yaml
|
|
755
|
+
agent:
|
|
756
|
+
image: mercury-agent:latest # a locally built image (container/build.sh or `mercury build`)
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Env: `MERCURY_AGENT_IMAGE`.
|
|
760
|
+
|
|
761
|
+
**The default is the registry image at this host's own version** —
|
|
762
|
+
`ghcr.io/avishai-tsabari/mercury-agent:<version>`, where `<version>` is the
|
|
763
|
+
installed package's `package.json` version (release-gate R0.6, since 0.22.0;
|
|
764
|
+
before that the default floated on `:latest`). Nothing needs to be set for it,
|
|
765
|
+
and a host upgrade changes the reference by itself, so the box asks for the
|
|
766
|
+
new tag on its next boot. The tag is what `release.yml` pushes on every
|
|
767
|
+
release; the package is private, so a box on the default needs a registry
|
|
768
|
+
token that can read it, and a box that has none should set an explicit image
|
|
769
|
+
instead. `mercury preflight`'s `docker.image` check names the pinned tag, the
|
|
770
|
+
host version and both ways out when the tag is absent — that is the check to
|
|
771
|
+
read, not the first failed spawn.
|
|
772
|
+
|
|
773
|
+
**An explicit value is kept exactly as written.** Nothing normalises it, and
|
|
774
|
+
the pin never rewrites it: a locally built `mercury-agent:latest`, another
|
|
775
|
+
registry, a digest-pinned reference. Two of those shapes are never pulled or
|
|
776
|
+
re-pulled — a name with no registry host (`mercury-agent:latest`,
|
|
777
|
+
`mercury-agent-ext:<hash>`) is a local build, and a digest cannot drift; see
|
|
778
|
+
[container-lifecycle.md](container-lifecycle.md) § Staying Current. An
|
|
779
|
+
explicit *floating* tag with a registry host is re-pulled when the host
|
|
780
|
+
version changes, as before.
|
|
781
|
+
|
|
782
|
+
`mercury env dump --json` reports the key with its source
|
|
783
|
+
(`config.keys[].key == "agentContainerImage"`, `source` one of `default`,
|
|
784
|
+
`file`, `env`), which is how a deploy tells a box on the pinned default from
|
|
785
|
+
one with an override before restarting it.
|
|
@@ -100,6 +100,12 @@ When a container exceeds the timeout:
|
|
|
100
100
|
4. A marker assistant message ("[System: this run was killed at the container time limit before replying. …]") is recorded in chat history, so the next run's agent knows the previous run was cut off and can check the workspace for partial work
|
|
101
101
|
5. Queue unblocks, next message can proceed
|
|
102
102
|
|
|
103
|
+
A container that hits the timeout was not necessarily busy. The commonest way to burn the whole budget is a child process that hangs rather than fails, and the commonest such child is Chromium on a too-small `/dev/shm`. Every spawn therefore sizes it explicitly — see `container_shm_size` in [configuration.md](configuration.md#container-shared-memory-agentcontainer_shm_size).
|
|
104
|
+
|
|
105
|
+
| Config | Env Var | Default | Applies to |
|
|
106
|
+
|--------|---------|---------|------------|
|
|
107
|
+
| `containerShmSize` | `MERCURY_CONTAINER_SHM_SIZE` | `512m` | both runtimes |
|
|
108
|
+
|
|
103
109
|
The host always injects a resolved **model chain** into the container (after `MERCURY_*` passthrough) so retries and fallbacks use the same policy Mercury loaded at startup:
|
|
104
110
|
|
|
105
111
|
| In-container env | Source (host) | Purpose |
|
|
@@ -176,8 +182,9 @@ Message received
|
|
|
176
182
|
# Set container timeout to 10 minutes
|
|
177
183
|
export MERCURY_CONTAINER_TIMEOUT_MS=600000
|
|
178
184
|
|
|
179
|
-
#
|
|
180
|
-
|
|
185
|
+
# Override the agent image. The default needs no setting: it is the registry
|
|
186
|
+
# image at this host's own version, ghcr.io/avishai-tsabari/mercury-agent:<version>
|
|
187
|
+
export MERCURY_AGENT_IMAGE=mercury-agent:latest # a local `mercury build` / container/build.sh
|
|
181
188
|
```
|
|
182
189
|
|
|
183
190
|
## Sandboxing (Bubblewrap)
|
|
@@ -330,12 +337,24 @@ Mercury publishes an image preset to GitHub Container Registry:
|
|
|
330
337
|
|
|
331
338
|
| Preset | Size | Contents |
|
|
332
339
|
|--------|------|----------|
|
|
333
|
-
| `ghcr.io/avishai-tsabari/mercury-agent
|
|
340
|
+
| `ghcr.io/avishai-tsabari/mercury-agent:<version>` | ~2.8GB | Full devcontainer: Bun, Node.js, Python, Go, git, build tools |
|
|
334
341
|
|
|
335
342
|
Images are published on each release by `release.yml`'s `publish-image` job,
|
|
336
343
|
which builds from the published npm tarball and pushes `:<version>` alongside
|
|
337
344
|
`:latest`. A prerelease publishes its own tag only and leaves `:latest` alone.
|
|
338
345
|
|
|
346
|
+
**The default is pinned to the host's version** (release-gate R0.6, since
|
|
347
|
+
0.22.0): a host running mercury `X.Y.Z` with no `agent.image` override asks
|
|
348
|
+
for `ghcr.io/avishai-tsabari/mercury-agent:X.Y.Z` — never `:latest`. So a
|
|
349
|
+
host upgrade changes the reference, the new tag is absent locally, and boot
|
|
350
|
+
pulls it (or fails loudly if it cannot); `mercury preflight`'s `docker.image`
|
|
351
|
+
check names the pinned tag, the host version and the two ways out when the
|
|
352
|
+
tag is missing. An explicit override (`agent.image` in `mercury.yaml`,
|
|
353
|
+
`MERCURY_AGENT_IMAGE`) is kept byte-for-byte: a source-run box that builds
|
|
354
|
+
its own image sets `mercury-agent:latest` and is untouched by the pin. The
|
|
355
|
+
package is private (G-007), so a box on the default needs a registry token
|
|
356
|
+
that can read it; the boot line and preflight are where its absence shows.
|
|
357
|
+
|
|
339
358
|
> Before 0.19.0 this sentence was aspirational: nothing in CI built the image,
|
|
340
359
|
> the registry copy was hand-pushed and stale, and an install outside the
|
|
341
360
|
> tagula fleet had no image to pull. See `docs/goals/release-gate/decisions.md`
|
|
@@ -348,8 +367,11 @@ A floating tag used to mean "whatever this box pulled the first time":
|
|
|
348
367
|
upgraded through npm kept running the old container against new host code with
|
|
349
368
|
`/health` at 200 and nothing in the log.
|
|
350
369
|
|
|
351
|
-
|
|
352
|
-
|
|
370
|
+
Two things close that. The default is now pinned to the host's version (above),
|
|
371
|
+
so on a default box the question is simply whether `:<version>` is present.
|
|
372
|
+
And for any **floating** reference an operator sets explicitly, startup
|
|
373
|
+
re-pulls the image whenever the host's Mercury version is not the one that
|
|
374
|
+
last pulled it, recording the pair in `<dataDir>/image-pull.json`.
|
|
353
375
|
Two references are never re-pulled: a **digest-pinned** one (`…@sha256:…`),
|
|
354
376
|
which cannot drift, and a **locally built** one, which exists in no registry —
|
|
355
377
|
so a pull would fail on every boot. "Locally built" means any name with no
|
|
@@ -365,9 +387,9 @@ image id, repo digest, version label and whether a refresh happened, so drift
|
|
|
365
387
|
is visible without being asked for:
|
|
366
388
|
|
|
367
389
|
```
|
|
368
|
-
Agent image resolved image=ghcr.io/avishai-tsabari/mercury-agent:
|
|
369
|
-
imageId=sha256:… digest=…@sha256:… imageVersion=
|
|
370
|
-
refresh=
|
|
390
|
+
Agent image resolved image=ghcr.io/avishai-tsabari/mercury-agent:0.22.0
|
|
391
|
+
imageId=sha256:… digest=…@sha256:… imageVersion=0.22.0 hostVersion=0.22.0
|
|
392
|
+
refresh=pulled reason=not-present
|
|
371
393
|
```
|
|
372
394
|
|
|
373
395
|
`imageVersion` is the image's `com.mercury.version` label (release-gate R0.1
|
package/docs/deployment.md
CHANGED
|
@@ -102,12 +102,32 @@ Not currently supported via `mercury service`. Options:
|
|
|
102
102
|
## Upgrading
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
|
-
mercury upgrade
|
|
106
|
-
mercury upgrade 0.14.0
|
|
105
|
+
mercury upgrade # to the latest published version
|
|
106
|
+
mercury upgrade 0.14.0 # to a specific version
|
|
107
|
+
mercury upgrade --dry-run # print the plan, do nothing
|
|
108
|
+
mercury upgrade --skip-image-pull # host only — for a box on a locally built image
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
**Image first, host second** (release-gate R0.5). The command resolves what
|
|
112
|
+
you asked for to one exact version (`latest`, a dist-tag or a range go
|
|
113
|
+
through `npm view`), then **pulls the agent image that version's host will
|
|
114
|
+
ask for** — the default is pinned to the version, so that is
|
|
115
|
+
`ghcr.io/avishai-tsabari/mercury-agent:<version>` — and refuses when the
|
|
116
|
+
registry has no such tag or refuses the credential, **leaving the host
|
|
117
|
+
untouched**. Only then, on macOS and Linux, does it stop the managed service,
|
|
118
|
+
install the new version globally and restart the service. It installs the
|
|
119
|
+
exact version it pulled the image for, not the spec, so the two cannot drift
|
|
120
|
+
apart between the pull and the install. Every step prints a line; the last
|
|
121
|
+
one names `mercury preflight` as what to run next from the project
|
|
122
|
+
directory (the derived image rebuilds on the first run, as before).
|
|
123
|
+
|
|
124
|
+
An explicit `agent.image` is respected: a registry reference is pulled as
|
|
125
|
+
configured (if it pins another version, preflight's `image.contract` says so
|
|
126
|
+
after the restart); a **local build** (`mercury-agent:latest`) cannot be
|
|
127
|
+
fetched, so the upgrade refuses unless you pass `--skip-image-pull` — then
|
|
128
|
+
run `mercury build` before the first run on the new version. The rules are
|
|
129
|
+
`docs/release-gate.md` Gate 4; `upgrade` does not run preflight itself
|
|
130
|
+
because it runs outside any project directory.
|
|
111
131
|
|
|
112
132
|
### Windows: stop Mercury before upgrading
|
|
113
133
|
|
|
@@ -135,6 +155,27 @@ first:
|
|
|
135
155
|
Mercury's own CLI does not load the WhatsApp stack at startup, so running
|
|
136
156
|
`mercury upgrade` never locks the tree it is replacing.
|
|
137
157
|
|
|
158
|
+
### Disk: prune the build cache after every upgrade
|
|
159
|
+
|
|
160
|
+
Each upgrade pulls a new base image (~2 GB of layers) and Mercury rebuilds the
|
|
161
|
+
derived `mercury-agent-ext` image on the next start. Removing the old base with
|
|
162
|
+
`docker rmi` / `docker image prune` does **not** free its layers: the derived
|
|
163
|
+
build leaves BuildKit cache records that keep the old base layers alive, so
|
|
164
|
+
overlay2 grows by roughly 2 GB per release and `docker system df` under-reports
|
|
165
|
+
it. A 38 GB box hit 93% after five releases (2026-09-08).
|
|
166
|
+
|
|
167
|
+
Once `/health` reports the new version and the journal shows `Built derived
|
|
168
|
+
agent image`, run:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
docker builder prune -af
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Safe with the bot running: it touches no image, container or data. The only
|
|
175
|
+
cost is that the next derived-image build starts cold, which it does after a
|
|
176
|
+
base change anyway. Never `docker image prune -a` on an idle box — with no
|
|
177
|
+
container running it removes the derived image and the base too.
|
|
178
|
+
|
|
138
179
|
## Auto-Restart Behavior
|
|
139
180
|
|
|
140
181
|
Both systemd and launchd are configured to automatically restart Mercury if it crashes:
|
package/docs/extensions.md
CHANGED
|
@@ -95,17 +95,23 @@ Can only be called once per extension.
|
|
|
95
95
|
|
|
96
96
|
### `mercury.env(def)`
|
|
97
97
|
|
|
98
|
-
Declare an environment variable this extension needs
|
|
98
|
+
Declare an environment variable this extension needs, and decide whether it enters the container. Claimed vars are excluded from the blind `MERCURY_*` passthrough; a claim with `hostOnly: false` is injected into containers only when the caller has permission for this extension.
|
|
99
99
|
|
|
100
100
|
```typescript
|
|
101
|
-
mercury.env({ from: "
|
|
102
|
-
mercury.env({ from: "MERCURY_GH_TOKEN",
|
|
103
|
-
mercury.env({ from: "
|
|
101
|
+
mercury.env({ from: "MERCURY_STT_API_KEY", hostOnly: true }); // host-side only — never enters a container
|
|
102
|
+
mercury.env({ from: "MERCURY_GH_TOKEN", hostOnly: false }); // injected as GH_TOKEN
|
|
103
|
+
mercury.env({ from: "MERCURY_GH_TOKEN", as: "GITHUB_TOKEN", hostOnly: false }); // custom container name
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
- `from` — env var name as set in `.env` (e.g. `MERCURY_GH_TOKEN`)
|
|
107
107
|
- `as` — (optional) name inside the container. Defaults to `from` with `MERCURY_` prefix stripped
|
|
108
|
-
- `hostOnly` —
|
|
108
|
+
- `hostOnly` — **required**, no default. `true`: claim the var (kept out of the blind `MERCURY_*` passthrough) but **never inject it into any container** — secrets consumed only by host-side hooks/jobs (an STT API key used in `before_container`, an OAuth refresh token the host exchanges, a credential a `mercury.capability()` handler uses). `false`: something inside the container reads it — name that reader, because a reviewer will ask.
|
|
109
|
+
|
|
110
|
+
**Why it is required.** The claim loop in `src/core/runtime.ts` skips a claim only when `hostOnly` is `true`; a claim that did not decide was injected. Under the default `claimed` passthrough (`docs/configuration.md`, *Container env passthrough*) a claim is the *only* way a `MERCURY_*` var reaches a container, so a bare claim was strictly wider than not claiming at all — the one call an author made in order to be careful about a secret was the call that exposed it. It shipped three times (`docs/debug/moderate/2026-09-01-tradestation-credentials-enter-the-container.md`, `docs/debug/moderate/2026-09-12-gws-env-claim-bypasses-the-credential-shape-guard.md`) before the field became required.
|
|
111
|
+
|
|
112
|
+
**The gate.** `tests/env-claims-declare-hostonly.test.ts` walks every `mercury.env()` call in `examples/**/index.ts` and `src/**`, loads every example extension through the real API, and asserts that the set of container-visible claims **equals** `ENV_CLAIM_ALLOWLIST` — a row per `hostOnly: false` claim naming the file inside the container that reads it. A new container-visible claim without a row, a bare claim, or a row whose reader no longer mentions the key is a red `bun run check`. The three rows today: `poster`'s two provider keys (the poster CLI runs inside the container) and `gws`'s legacy credentials-file path (a path, not a secret). Everything else is `hostOnly: true`.
|
|
113
|
+
|
|
114
|
+
**On a live box.** The type cannot reach an extension written outside this repo, so `ExtensionLoader` logs `Extension "<name>": env claim "<VAR>" does not state hostOnly; it is container-visible …` for each bare claim it loads. The extension still loads and behaves as before; the line is the reader.
|
|
109
115
|
|
|
110
116
|
Can be called multiple times for multiple env vars.
|
|
111
117
|
|
|
@@ -279,6 +285,39 @@ mercury.widget({
|
|
|
279
285
|
|
|
280
286
|
Widgets render HTML fragments in the dashboard overview. Errors show a placeholder — never crash the dashboard.
|
|
281
287
|
|
|
288
|
+
### `mercury.capability(name, handler)`
|
|
289
|
+
|
|
290
|
+
Register a host-side handler the agent reaches from inside the container with
|
|
291
|
+
`mrctl capability <name> <action> '<json>'` → `POST /api/capability/:name/:action`.
|
|
292
|
+
Credentials the handler uses stay on the host and never enter the container.
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// in extensions/rooms/index.ts
|
|
296
|
+
mercury.permission({ defaultRoles: ["admin"] });
|
|
297
|
+
mercury.capability("rooms", async (req, ctx) => {
|
|
298
|
+
if (req.action === "book") return { data: await bookRoom(req.callerId, req.body) };
|
|
299
|
+
return { status: 400, data: { error: "unknown action" } };
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- **`name` must equal the extension's own name** — registering under any other
|
|
304
|
+
name throws at load. The broker authorizes a call with `checkPerm(<name>)`,
|
|
305
|
+
so the equality is what makes that the *extension's* permission: the same
|
|
306
|
+
predicate the env-var injection rule and the sensitive-connection guard use.
|
|
307
|
+
A capability named after some other extension's permission would be reachable
|
|
308
|
+
by callers those two legs believe cannot reach it.
|
|
309
|
+
- **The extension must also call `mercury.permission()`.** Without a *registered*
|
|
310
|
+
permission of that name, `checkPerm` refuses every role — including `admin`,
|
|
311
|
+
whose grant is the enumerated set of built-in plus registered permissions, not
|
|
312
|
+
a wildcard — so the capability is unreachable, silently.
|
|
313
|
+
- An extension named after a **built-in** permission (`prompt`, `stop`, `clear`,
|
|
314
|
+
…) cannot register a capability at all: it could not own that permission
|
|
315
|
+
(`mercury.permission()` refuses a built-in name) while the broker would still
|
|
316
|
+
authorize the handler with it, and `prompt` is one every member holds.
|
|
317
|
+
- One capability per extension; branch on `req.action` for sub-verbs.
|
|
318
|
+
- The name is resolved host-wide, so two extensions cannot own it — a
|
|
319
|
+
collision is rejected at load time.
|
|
320
|
+
|
|
282
321
|
### `mercury.store`
|
|
283
322
|
|
|
284
323
|
Scoped key-value store for persistent state.
|