mercury-agent 0.5.2 → 0.5.6
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/package.json +1 -1
- package/docs/ARCHITECTURE.md +0 -34
- package/docs/DESIGN.md +0 -42
- package/docs/ROADMAP.md +0 -43
- package/docs/TODOS.md +0 -147
- package/docs/VISION.md +0 -32
- package/docs/archive/.gitkeep +0 -0
- package/docs/archive/applicative-profiles/2026-07-01-applicative-profiles.md +0 -270
- package/docs/archive/applicative-profiles/2026-07-01-caller-bound-capability-token.md +0 -184
- package/docs/archive/applicative-profiles/2026-07-01-dm-auto-space.md +0 -273
- package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +0 -71
- package/docs/backlog/.gitkeep +0 -0
- package/docs/bugs/.gitkeep +0 -0
- package/docs/debug/major/.gitkeep +0 -0
- package/docs/debug/major/2026-07-01-strict-yaml-schema-crash.md +0 -85
- package/docs/debug/major/2026-07-02-bwrap-privileged-linux-docker.md +0 -160
- package/docs/debug/major/2026-07-02-e2big-prompt-delivery-fix.md +0 -382
- package/docs/debug/major/2026-07-02-registry-pull-no-local-fallback.md +0 -194
- package/docs/debug/minor/.gitkeep +0 -0
- package/docs/debug/moderate/.gitkeep +0 -0
- package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +0 -72
- package/docs/html-slides/.gitkeep +0 -0
- package/docs/ideas/.gitkeep +0 -0
- package/docs/ideas/business-extensions.md +0 -48
- package/docs/in-progress/.gitkeep +0 -0
- package/docs/notes/.gitkeep +0 -0
- package/docs/pending-updates/.gitkeep +0 -0
- package/docs/runbooks/gws-oauth-setup.md +0 -211
- package/docs/runbooks/publish-checklist.md +0 -54
- package/docs/templates/TEMPLATE-AUTOPILOT-CONFIG.yaml +0 -35
- package/docs/templates/TEMPLATE-BUG.md +0 -71
- package/docs/templates/TEMPLATE-CI.yml +0 -25
- package/docs/templates/TEMPLATE-DECISIONS.md +0 -11
- package/docs/templates/TEMPLATE-FEATURE.md +0 -127
- package/docs/templates/TEMPLATE-GOAL.md +0 -31
- package/docs/templates/TEMPLATE-IDEA.md +0 -31
- package/docs/templates/TEMPLATE-NOTE.md +0 -27
- package/docs/templates/TEMPLATE-ROADMAP.md +0 -21
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
# Caller-Bound Capability Token
|
|
2
|
-
|
|
3
|
-
**Status**: Done
|
|
4
|
-
**Slug**: caller-bound-capability-token
|
|
5
|
-
**Created**: 2026-07-01
|
|
6
|
-
**Last updated**: 2026-07-01
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## Goal
|
|
11
|
-
|
|
12
|
-
Mercury's in-container CLIs (`mrctl` and extension CLIs) call back to the host control-plane API to perform privileged work. Today that callback authenticates with a **single shared secret** (`API_SECRET`, injected into every container) and the host **trusts the `x-mercury-caller` / `x-mercury-space` request headers verbatim** (`src/core/api.ts:50-72`, `src/cli/mrctl.ts:34-40`). Because `API_SECRET` is readable inside the container, any code running there can call the host API asserting **any** caller or space — i.e. caller identity is spoofable.
|
|
13
|
-
|
|
14
|
-
This is acceptable today because host-brokered capabilities (TradeStation, TTS) are **admin-only** — everyone who can invoke them is already trusted. It becomes a security hole the moment an **untrusted member** gets a host-brokered capability, which is exactly what [[applicative-profiles]] introduces (e.g. a barber-shop customer who must only touch their own appointments). A spoofed caller header would let one customer act as another.
|
|
15
|
-
|
|
16
|
-
This feature adds a **per-turn capability token** minted at container spawn, cryptographically bound to `{callerId, spaceId}`, that the host verifies and uses as the authoritative identity for authorization — instead of trusting request headers.
|
|
17
|
-
|
|
18
|
-
## User Stories
|
|
19
|
-
|
|
20
|
-
- As a Mercury operator, I want host-brokered capabilities to attribute each request to the real caller so an untrusted member cannot impersonate another member.
|
|
21
|
-
- As a profile developer, I want a trustworthy `callerId` on the host side so I can enforce per-user ownership rules safely.
|
|
22
|
-
- As a Mercury maintainer, I want existing admin-only brokers (TradeStation) to stop relying on spoofable headers, closing an inherited weakness.
|
|
23
|
-
|
|
24
|
-
## MVP Scope
|
|
25
|
-
|
|
26
|
-
**In scope:**
|
|
27
|
-
- Mint a per-turn token at container spawn (`src/agent/container-runner.ts`), bound to `{callerId, spaceId}` plus an expiry, injected as an env var (e.g. `MERCURY_CALLER_TOKEN`)
|
|
28
|
-
- Host-side verification in the API auth middleware (`src/core/api.ts`): when the token is present, derive `callerId`/`spaceId` **from the token**, not from `x-mercury-*` headers, for all authorization decisions
|
|
29
|
-
- HMAC signing with a host-only key (no new dependency); token is opaque to the container
|
|
30
|
-
- Migrate the TradeStation caller-bound confirm guard to token-derived identity (`src/core/routes/tradestation.ts:153-158, 245-254`)
|
|
31
|
-
- Backward compatibility: shared-`API_SECRET` path remains for system/admin flows; token is authoritative when present
|
|
32
|
-
|
|
33
|
-
**Out of scope (deferred):**
|
|
34
|
-
- Full JWT/JWKS or key rotation infrastructure — a single host-held HMAC key is sufficient for v1
|
|
35
|
-
- Multi-turn / session-scoped tokens — one token per container turn is enough
|
|
36
|
-
- Revocation lists — tokens are short-lived (expire with the turn)
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## Context for Claude
|
|
41
|
-
|
|
42
|
-
- `src/core/api.ts` — Hono control-plane API; auth middleware at lines 35-75 (`safeCompare`, header identity resolution at 50-72). This is where token verification is added
|
|
43
|
-
- `src/cli/mrctl.ts` — in-container CLI; builds request headers at lines 33-41, reads `API_URL`/`API_SECRET`/`CALLER_ID`/`SPACE_ID` from env
|
|
44
|
-
- `src/agent/container-runner.ts` — container spawn; injects `API_URL`/`API_SECRET`/`CALLER_ID`/`SPACE_ID` at ~810-817. This is where the token is minted and injected
|
|
45
|
-
- `src/core/routes/tradestation.ts` — existing caller-bound guard (lines 153-158, 245-254) that currently trusts header identity; the reference consumer to migrate
|
|
46
|
-
- `src/config.ts` — `apiSecret` config (line 212); add the host-only signing key here
|
|
47
|
-
|
|
48
|
-
---
|
|
49
|
-
|
|
50
|
-
## Architecture & Data
|
|
51
|
-
|
|
52
|
-
### Token shape
|
|
53
|
-
|
|
54
|
-
Opaque HMAC-signed token (no new dependency — Node `crypto`):
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
token = base64url(payload) + "." + base64url(HMAC_SHA256(key, base64url(payload)))
|
|
58
|
-
payload = { c: callerId, s: spaceId, exp: <unix-seconds> }
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
- `key` — a host-only secret (`MERCURY_CALLER_TOKEN_KEY`, auto-derived from `API_SECRET` if unset so no new required config). Never injected into containers.
|
|
62
|
-
- `exp` — short TTL (e.g. spawn time + container timeout). The token is useless after the turn.
|
|
63
|
-
|
|
64
|
-
### Flow
|
|
65
|
-
|
|
66
|
-
1. **Spawn** (`container-runner.ts`): mint token from `{callerId, spaceId, exp}`, inject as `MERCURY_CALLER_TOKEN`. Do **not** inject the signing key.
|
|
67
|
-
2. **Callback** (`mrctl.ts`): forward `MERCURY_CALLER_TOKEN` as an `x-mercury-token` header (in addition to the existing `Authorization: Bearer API_SECRET`).
|
|
68
|
-
3. **Verify** (`api.ts` middleware): if `x-mercury-token` is present, verify the HMAC and expiry; on success, set the request's authoritative `callerId`/`spaceId` from the token payload and **ignore** `x-mercury-caller`/`x-mercury-space` for authorization. If absent, fall back to today's behavior (shared secret + headers) for backward compatibility.
|
|
69
|
-
|
|
70
|
-
### Why the token can't be forged from inside the container
|
|
71
|
-
|
|
72
|
-
The signing key never enters the container. The container holds only its **own** turn's token (valid for its own caller/space). It cannot mint a token for a different caller, and the shared `API_SECRET` alone no longer authorizes an identity claim once a route requires the token.
|
|
73
|
-
|
|
74
|
-
### File & Folder Structure
|
|
75
|
-
|
|
76
|
-
| Path | New / Modified | Purpose |
|
|
77
|
-
|------|---------------|---------|
|
|
78
|
-
| `src/core/caller-token.ts` | New | `mintCallerToken()` / `verifyCallerToken()` HMAC helpers |
|
|
79
|
-
| `src/agent/container-runner.ts` | Modified | Mint + inject `MERCURY_CALLER_TOKEN` at spawn; keep key host-side |
|
|
80
|
-
| `src/cli/mrctl.ts` | Modified | Forward token as `x-mercury-token` header |
|
|
81
|
-
| `src/core/api.ts` | Modified | Verify token; prefer token identity over headers |
|
|
82
|
-
| `src/core/routes/tradestation.ts` | Modified | Use token-derived caller for the confirm guard |
|
|
83
|
-
| `src/config.ts` | Modified | Add `callerTokenKey` (derives from `apiSecret` when unset) |
|
|
84
|
-
| `tests/caller-token.test.ts` | New | Mint/verify, tamper, expiry, spoof-rejection tests |
|
|
85
|
-
|
|
86
|
-
### Implementation Sequence
|
|
87
|
-
|
|
88
|
-
1. **Token helpers** — `mintCallerToken({callerId, spaceId, exp}, key)` and `verifyCallerToken(token, key)` in `caller-token.ts`.
|
|
89
|
-
- Read first: `src/config.ts`
|
|
90
|
-
- Verify: unit tests for round-trip + tamper rejection pass
|
|
91
|
-
2. **Mint at spawn** — inject `MERCURY_CALLER_TOKEN` in `container-runner.ts`; ensure the key is NOT injected (add to the env blocklist alongside `MERCURY_API_SECRET`).
|
|
92
|
-
- Read first: `src/agent/container-runner.ts` (~810-817, blocklist ~684)
|
|
93
|
-
- Verify: token present in container env; key absent
|
|
94
|
-
- Blocker: Step 1
|
|
95
|
-
3. **Forward in mrctl** — add `x-mercury-token` header.
|
|
96
|
-
- Read first: `src/cli/mrctl.ts` (33-41)
|
|
97
|
-
- Verify: header present on requests
|
|
98
|
-
- Blocker: Step 2
|
|
99
|
-
4. **Verify host-side** — token-preferred identity in `api.ts` middleware; header fallback retained.
|
|
100
|
-
- Read first: `src/core/api.ts` (35-75)
|
|
101
|
-
- Verify: request with valid token uses token identity; spoofed `x-mercury-caller` is ignored
|
|
102
|
-
- Blocker: Steps 1, 3
|
|
103
|
-
5. **Migrate TradeStation guard** — derive confirm-guard caller from token.
|
|
104
|
-
- Read first: `src/core/routes/tradestation.ts` (153-158, 245-254)
|
|
105
|
-
- Verify: existing TradeStation tests pass with token identity
|
|
106
|
-
- Blocker: Step 4
|
|
107
|
-
6. **Tests** — spoof rejection, tamper, expiry, backward-compat fallback.
|
|
108
|
-
- Verify: `bun test` passes
|
|
109
|
-
- Blocker: Steps 1-5
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
## Non-Negotiable Rules
|
|
114
|
-
|
|
115
|
-
- **Signing key never enters a container** — it stays host-side; only per-turn tokens are injected.
|
|
116
|
-
- **Token identity wins** — when a token is present and valid, `x-mercury-caller`/`x-mercury-space` are ignored for authorization.
|
|
117
|
-
- **Short-lived** — tokens expire with the turn; no long-lived caller tokens.
|
|
118
|
-
- **Backward compatible** — absent a token, existing shared-secret + header behavior is unchanged, so nothing breaks for current admin-only brokers during rollout.
|
|
119
|
-
- **Constant-time verification** — HMAC compare uses `crypto.timingSafeEqual` (mirror `safeCompare` in `api.ts`).
|
|
120
|
-
|
|
121
|
-
---
|
|
122
|
-
|
|
123
|
-
## Edge Cases & Risks
|
|
124
|
-
|
|
125
|
-
| Scenario | Handling |
|
|
126
|
-
|----------|---------|
|
|
127
|
-
| Token expired mid-long-turn | Route returns 401; treat as a transient auth failure. TTL should exceed the container timeout to avoid this. |
|
|
128
|
-
| System-triggered task (`callerId = "system"`) | Mint a token for `system` too, or allow the shared-secret path for system callers explicitly. |
|
|
129
|
-
| Rollout with old containers (no token) | Header/shared-secret fallback keeps them working until images are rebuilt. |
|
|
130
|
-
| Signing key unset | Derive deterministically from `API_SECRET` (documented) so no new required config. |
|
|
131
|
-
| Attacker replays a captured token | Bound to `{caller, space, exp}` and only valid for that caller's own actions; replay grants nothing beyond what that caller already had, and expires with the turn. |
|
|
132
|
-
|
|
133
|
-
---
|
|
134
|
-
|
|
135
|
-
## Implementation Checklist
|
|
136
|
-
|
|
137
|
-
### Phase 1 — Goal
|
|
138
|
-
- [x] Goal, User Stories, and MVP Scope written and reviewed
|
|
139
|
-
|
|
140
|
-
### Phase 2 — Architecture
|
|
141
|
-
- [x] Architecture & Data section complete
|
|
142
|
-
- [x] Non-Negotiable Rules defined
|
|
143
|
-
- [x] Edge Cases covered
|
|
144
|
-
- [x] Context for Claude pointers filled in
|
|
145
|
-
|
|
146
|
-
### Implementation
|
|
147
|
-
- [x] Step 1 — Token helpers (`src/core/caller-token.ts`)
|
|
148
|
-
- [x] Step 2 — Mint at spawn (`container-runner.ts`; key added to `BLOCKED_ENV_VARS`)
|
|
149
|
-
- [x] Step 3 — Forward in mrctl (`x-mercury-token` header)
|
|
150
|
-
- [x] Step 4 — Verify host-side (`api.ts` middleware; token identity wins)
|
|
151
|
-
- [x] Step 5 — TradeStation guard: **no change needed** — it reads `c.get("auth")`, which now derives from the token
|
|
152
|
-
- [x] Step 6 — Tests (`tests/caller-token.test.ts` unit; `tests/api.test.ts` integration: token overrides spoofed header, invalid token → 401)
|
|
153
|
-
- [x] `bun run check` passes (one unrelated discord-adapter timeout flake under full parallel run; passes in isolation)
|
|
154
|
-
- [x] No secrets committed
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## Open Questions
|
|
159
|
-
|
|
160
|
-
- [ ] Should `system` callers use a token or keep the shared-secret path? — owner: TBD
|
|
161
|
-
- [ ] TTL value — fixed, or derived from the per-turn container timeout? — owner: TBD
|
|
162
|
-
|
|
163
|
-
---
|
|
164
|
-
|
|
165
|
-
## Retrospective
|
|
166
|
-
|
|
167
|
-
**Residual risks / follow-ups:**
|
|
168
|
-
- none
|
|
169
|
-
|
|
170
|
-
**What changed from the plan:**
|
|
171
|
-
- **Signing key is NOT derived from `API_SECRET`.** The plan suggested deriving it when unset, but `API_SECRET` is injected into containers — the agent could recompute the key and forge tokens. Instead: an optional host-only `callerTokenKey` (env `MERCURY_CALLER_TOKEN_KEY`, in `BLOCKED_ENV_VARS`), falling back to an **ephemeral random key** generated once per host process. Zero required config; nothing forgeable from inside the container.
|
|
172
|
-
- **TradeStation guard needed no code change** — it already reads identity from `c.get("auth")`, which the middleware now sets from the token.
|
|
173
|
-
- Token injected as container env `CALLER_TOKEN` (unprefixed, like `CALLER_ID`/`API_SECRET`), forwarded as `x-mercury-token`.
|
|
174
|
-
|
|
175
|
-
**Key decisions made during implementation:**
|
|
176
|
-
- HMAC-SHA256 over `base64url(payload).base64url(sig)`, payload `{c,s,exp}`; constant-time compare; no new dependency (`node:crypto`).
|
|
177
|
-
- TTL = container timeout + 60s buffer, so a token never expires mid-turn but stays short-lived vs the permanent shared secret.
|
|
178
|
-
- Header path retained as fallback → fully backward compatible; existing header-based tests and old container images keep working.
|
|
179
|
-
|
|
180
|
-
**Architecture impact:**
|
|
181
|
-
- [ ] New package/module added
|
|
182
|
-
- [ ] New data model or schema change
|
|
183
|
-
- [ ] New inter-package interaction
|
|
184
|
-
- [ ] Deployment topology changed
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
# Auto-Create Space per DM
|
|
2
|
-
|
|
3
|
-
**Status**: Done
|
|
4
|
-
**Slug**: dm-auto-space
|
|
5
|
-
**Created**: 2026-06-30
|
|
6
|
-
**Last updated**: 2026-07-01
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## Goal
|
|
11
|
-
|
|
12
|
-
When Mercury serves a business (e.g. a barber shop), each customer texts from a unique phone number. Today every new DM creates a conversation with `space_id = null`, so the bot has no persistent per-customer context — it can't remember preferences, appointment history, or prior conversations. This feature adds a config-driven option to auto-create a space per customer and link their conversation to it, giving each customer an isolated, persistent context.
|
|
13
|
-
|
|
14
|
-
## User Stories
|
|
15
|
-
|
|
16
|
-
- As a business owner, I want each customer who messages my bot to automatically get their own space so the bot remembers their history across conversations.
|
|
17
|
-
- As a business owner, I want my own number and staff numbers to auto-link to the `main` space instead of creating new spaces.
|
|
18
|
-
- As a business owner, I want customers restricted to chat only — no tools, no scripts, no web search.
|
|
19
|
-
- As a Mercury operator, I want this behavior off by default so existing deployments are unaffected.
|
|
20
|
-
- As a Mercury operator, I want a global daily rate limit per role so I don't have to configure each space individually.
|
|
21
|
-
|
|
22
|
-
## MVP Scope
|
|
23
|
-
|
|
24
|
-
**In scope:**
|
|
25
|
-
- New `dm_auto_space` config block in `mercury.yaml` with `enabled` (default `false`), `admin_numbers` (auto-link to `main`), `default_system_prompt`, and `default_member_permissions`
|
|
26
|
-
- On new DM from a non-admin number: find or create a space for that phone number, **and link the conversation to it** (without linking, messages are silently dropped by the handler)
|
|
27
|
-
- Admin numbers auto-link their DM conversation to `main` space
|
|
28
|
-
- Auto-created spaces seeded with: `trigger.match = "always"`, `context.mode = "context"`, member permissions restricted to config value
|
|
29
|
-
- Space `display_name` derived from the sender's push name (if available) or phone number
|
|
30
|
-
- Returning customers hitting the same conversation get their existing space — no duplicate creation
|
|
31
|
-
- New global daily rate limit defaults in `mercury.yaml` (`runtime.rate_limit_daily_member`, `runtime.rate_limit_daily_admin`) — applied as fallback when no per-space override exists
|
|
32
|
-
- Dashboard UI: add rate limit controls to the space settings panel
|
|
33
|
-
|
|
34
|
-
**Out of scope:**
|
|
35
|
-
- Space merging when a customer contacts from multiple numbers — complex identity resolution, not MVP
|
|
36
|
-
- Auto-space for group chats — DMs only for now
|
|
37
|
-
- Per-bridge config (WhatsApp vs Telegram) — single global toggle for now, can be extended later
|
|
38
|
-
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
## Context for Claude
|
|
42
|
-
|
|
43
|
-
> Key files to read before touching anything.
|
|
44
|
-
|
|
45
|
-
- `src/config.ts` — Zod `schema` object (line 53) and `AppConfig` type; add new config fields here
|
|
46
|
-
- `src/config-file.ts` — `mercuryFileSchema` (Zod for YAML parsing, line 25), `flattenMercuryFile()` (line 185), and `CAMEL_TO_ENV` (line 280); add the YAML blocks + env mappings here
|
|
47
|
-
- `src/core/conversation.ts` — `resolveConversation()` (line 9) returns `null` when `space_id` is null; this is the hook point for auto-create + link
|
|
48
|
-
- `src/core/handler.ts` — `createMessageHandler()` (line 17) calls `resolveConversation()` at line 50; needs to pass config + db context for auto-space logic
|
|
49
|
-
- `src/storage/db.ts` — `ensureSpace()` (line 334), `linkConversation()` (line 611), `ensureConversation()` (line 471), `setSpaceConfig()`, `getSpaceConfig()`; space IDs must match `/^[a-z0-9][a-z0-9-]*$/`
|
|
50
|
-
- `src/core/runtime.ts` — daily role-based rate limit check (line 397); burst rate limit fallback (line 424); needs fallback to new global daily rate limit config
|
|
51
|
-
- `src/core/routes/dashboard.ts` — space settings panels (trigger, context); add rate limit panel here
|
|
52
|
-
- `src/core/routes/config-builtin.ts` — `BUILTIN_CONFIG_KEYS` and `BUILTIN_CONFIG_DESCRIPTIONS`; add `rate_limit`, `rate_limit.member`, `rate_limit.admin` here so dashboard can manage them
|
|
53
|
-
- `src/storage/memory.ts` — `ensureSpaceWorkspace()` (line 27) creates per-space dirs with empty `AGENTS.md`; already called lazily at container execution time (runtime.ts line 1142), no changes needed
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## Architecture & Data
|
|
58
|
-
|
|
59
|
-
### Data Models
|
|
60
|
-
|
|
61
|
-
No schema changes. Uses existing tables:
|
|
62
|
-
|
|
63
|
-
- `spaces` — auto-created via `db.ensureSpace(spaceId)` with a derived slug
|
|
64
|
-
- `conversations` — linked via `db.linkConversation(conversationId, spaceId)`
|
|
65
|
-
- `space_config` — seeded with trigger, context, rate limit, permissions, and system prompt defaults on auto-created spaces
|
|
66
|
-
|
|
67
|
-
**Space ID derivation:** Phone numbers (e.g. `972501234567@s.whatsapp.net`) must be converted to valid space IDs matching `/^[a-z0-9][a-z0-9-]*$/`. Strategy: strip the `@s.whatsapp.net` suffix and prefix with `dm-` → `dm-972501234567`. For other platforms: `dm-{platform}-{sanitized-external-id}`.
|
|
68
|
-
|
|
69
|
-
**Space display name:** Use the sender's push name if available from the message, otherwise fall back to the phone number.
|
|
70
|
-
|
|
71
|
-
### Config Shape
|
|
72
|
-
|
|
73
|
-
```yaml
|
|
74
|
-
# mercury.yaml
|
|
75
|
-
|
|
76
|
-
# --- Part 1: Auto-space for DMs ---
|
|
77
|
-
dm_auto_space:
|
|
78
|
-
enabled: false # default off
|
|
79
|
-
admin_numbers: # auto-link to "main" instead of creating a new space
|
|
80
|
-
- "972501234567"
|
|
81
|
-
default_system_prompt: "You are a barber shop assistant..." # optional, seeded into space_config
|
|
82
|
-
default_member_permissions: "prompt,prefs.get" # restrict customers to chat only
|
|
83
|
-
|
|
84
|
-
# --- Part 2: Global daily rate limits (independent of dm_auto_space) ---
|
|
85
|
-
runtime:
|
|
86
|
-
rate_limit_daily_member: 20 # NEW — global default daily cap for members, 0 = unlimited
|
|
87
|
-
rate_limit_daily_admin: 0 # NEW — global default daily cap for admins, 0 = unlimited
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**Part 1 — dm_auto_space config keys:**
|
|
91
|
-
- `dmAutoSpaceEnabled` → `MERCURY_DM_AUTO_SPACE_ENABLED` (boolean, default `false`)
|
|
92
|
-
- `dmAutoSpaceAdminNumbers` → `MERCURY_DM_AUTO_SPACE_ADMIN_NUMBERS` (comma-separated string, default `""`)
|
|
93
|
-
- `dmAutoSpaceDefaultSystemPrompt` → `MERCURY_DM_AUTO_SPACE_DEFAULT_SYSTEM_PROMPT` (string, default `""`)
|
|
94
|
-
- `dmAutoSpaceDefaultMemberPermissions` → `MERCURY_DM_AUTO_SPACE_DEFAULT_MEMBER_PERMISSIONS` (string, default `"prompt,prefs.get"`)
|
|
95
|
-
|
|
96
|
-
**Part 2 — global daily rate limit config keys:**
|
|
97
|
-
- `rateLimitDailyMember` → `MERCURY_RATE_LIMIT_DAILY_MEMBER` (number, default `0` = unlimited)
|
|
98
|
-
- `rateLimitDailyAdmin` → `MERCURY_RATE_LIMIT_DAILY_ADMIN` (number, default `0` = unlimited)
|
|
99
|
-
|
|
100
|
-
### Space seeding on auto-create
|
|
101
|
-
|
|
102
|
-
When a new customer space is created, the following `space_config` keys are seeded:
|
|
103
|
-
|
|
104
|
-
| Key | Value | Why |
|
|
105
|
-
|-----|-------|-----|
|
|
106
|
-
| `trigger.match` | `"always"` | Bot responds to every message in DMs |
|
|
107
|
-
| `context.mode` | `"context"` | Bot remembers conversation history |
|
|
108
|
-
| `role.member.permissions` | from `default_member_permissions` config | Restrict customers to chat only |
|
|
109
|
-
| `system_prompt` | from `default_system_prompt` config (if set) | Customer-facing persona |
|
|
110
|
-
| `rate_limit.member` | from `rate_limit_daily_member` config (if >0) | Daily cap seeded from global default |
|
|
111
|
-
|
|
112
|
-
### Global daily rate limit fallback
|
|
113
|
-
|
|
114
|
-
In `runtime.ts` line 397, the daily role-based rate check currently reads `rate_limit.{role}` from `space_config` only. When the key is null (not set per-space), it currently skips the daily check entirely. Change: when the per-space key is null, fall back to the new global config value (`rateLimitDailyMember` / `rateLimitDailyAdmin`). A value of `0` means unlimited (skip check), which is the default — preserving backward compatibility.
|
|
115
|
-
|
|
116
|
-
### API Contracts
|
|
117
|
-
|
|
118
|
-
No new API endpoints. Auto-created spaces are fully managed through the existing space API (`/api/spaces`, `/api/conversations/:id/link`). Rate limits are managed via the existing `POST /dashboard/api/space-config` endpoint.
|
|
119
|
-
|
|
120
|
-
### Dashboard: Rate limit panel
|
|
121
|
-
|
|
122
|
-
Add a new "Rate Limits" section to the space settings page in `dashboard.ts`, following the same pattern as the Trigger and Context panels. Fields:
|
|
123
|
-
|
|
124
|
-
| Label | Config key | Input | Notes |
|
|
125
|
-
|-------|-----------|-------|-------|
|
|
126
|
-
| Burst rate limit | `rate_limit` | number input | Per-user per-minute, falls back to global `rate_limit_per_user` |
|
|
127
|
-
| Daily member limit | `rate_limit.member` | number input | Per-user per-day, falls back to global `rate_limit_daily_member` |
|
|
128
|
-
| Daily admin limit | `rate_limit.admin` | number input | Per-user per-day, falls back to global `rate_limit_daily_admin` |
|
|
129
|
-
|
|
130
|
-
Each row shows the effective value (per-space override or global default) and a "Reset" button to clear the override.
|
|
131
|
-
|
|
132
|
-
Requires adding `rate_limit`, `rate_limit.member`, `rate_limit.admin` to `BUILTIN_CONFIG_KEYS`, `BUILTIN_CONFIG_DESCRIPTIONS`, and validators in `config-builtin.ts`.
|
|
133
|
-
|
|
134
|
-
### File & Folder Structure
|
|
135
|
-
|
|
136
|
-
| Path | New / Modified | Purpose |
|
|
137
|
-
|------|---------------|---------|
|
|
138
|
-
| `src/config-file.ts` | Modified | Add `dm_auto_space` and `runtime.rate_limit_daily_*` to YAML schema, flatten, env mappings |
|
|
139
|
-
| `src/config.ts` | Modified | Add all new config fields to Zod schema |
|
|
140
|
-
| `src/core/conversation.ts` | Modified | Add auto-space + admin-link logic in `resolveConversation()` |
|
|
141
|
-
| `src/core/handler.ts` | Modified | Thread auto-space config into `resolveConversation()` call |
|
|
142
|
-
| `src/core/runtime.ts` | Modified | Add global daily rate limit fallback in `handleRawInput()` |
|
|
143
|
-
| `src/core/routes/dashboard.ts` | Modified | Add rate limit settings panel to space page |
|
|
144
|
-
| `src/core/routes/config-builtin.ts` | Modified | Add rate limit keys to builtin config set |
|
|
145
|
-
| `resources/templates/mercury.example.yaml` | Modified | Add commented dm_auto_space section and rate_limit_daily_* |
|
|
146
|
-
| `tests/dm-auto-space.test.ts` | New | Unit tests for auto-space flow |
|
|
147
|
-
| `tests/global-daily-rate-limit.test.ts` | New | Unit tests for global daily rate limit fallback |
|
|
148
|
-
|
|
149
|
-
### Implementation Sequence
|
|
150
|
-
|
|
151
|
-
1. **Add config fields** — Add `dm_auto_space` block and `runtime.rate_limit_daily_*` to `mercuryFileSchema` in `config-file.ts`, flatten in `flattenMercuryFile()`, add env mappings to `CAMEL_TO_ENV`, and add all new fields to the Zod schema in `config.ts`.
|
|
152
|
-
- Read first: `src/config-file.ts`, `src/config.ts`
|
|
153
|
-
- Verify: `bun run typecheck` passes
|
|
154
|
-
|
|
155
|
-
2. **Add global daily rate limit fallback** — In `runtime.ts` `handleRawInput()`, when `rate_limit.{role}` is not set per-space, fall back to `config.rateLimitDailyMember` / `config.rateLimitDailyAdmin` instead of skipping.
|
|
156
|
-
- Read first: `src/core/runtime.ts` (lines 395-430)
|
|
157
|
-
- Verify: `bun run typecheck` passes
|
|
158
|
-
- Blocker: Step 1 (config fields must exist)
|
|
159
|
-
|
|
160
|
-
3. **Add rate limit to dashboard + config-builtin** — Add `rate_limit`, `rate_limit.member`, `rate_limit.admin` to `BUILTIN_CONFIG_KEYS` and `BUILTIN_CONFIG_DESCRIPTIONS` in `config-builtin.ts`. Add a rate limit panel to the space page in `dashboard.ts` following the trigger/context panel pattern.
|
|
161
|
-
- Read first: `src/core/routes/config-builtin.ts`, `src/core/routes/dashboard.ts` (trigger panel ~line 250, context panel ~line 345)
|
|
162
|
-
- Verify: `bun run typecheck` passes
|
|
163
|
-
- Blocker: Step 1 (need global defaults to show "project default" label)
|
|
164
|
-
|
|
165
|
-
4. **Implement auto-space logic in `resolveConversation()`** — When `conversation.spaceId` is null AND kind is `"dm"` AND config has `dmAutoSpaceEnabled: true`:
|
|
166
|
-
- If sender is in `admin_numbers`: link conversation to `"main"`, return resolution
|
|
167
|
-
- Otherwise: derive space ID (`dm-{sanitized-id}`), call `db.ensureSpace()`, seed `space_config` keys (trigger, context, permissions, system prompt, rate limit), call `db.linkConversation()`, return resolution
|
|
168
|
-
- Read first: `src/core/conversation.ts`, `src/core/handler.ts`, `src/storage/db.ts`
|
|
169
|
-
- Verify: `bun run typecheck` passes
|
|
170
|
-
- Blocker: Steps 1-2 (config fields and rate limit fallback must exist)
|
|
171
|
-
|
|
172
|
-
5. **Thread config and author name through the call chain** — `resolveConversation()` currently takes `(db, platform, externalId, kind, observedTitle?)`. Add an `autoSpaceConfig` parameter with `{ enabled, adminNumbers, defaultSystemPrompt, defaultMemberPermissions, rateLimitDailyMember }` and an `authorName` parameter. The handler passes config from `config` and author name from `message.author.userName` (available before `resolveConversation` is called; set by WhatsApp adapter from `pushName`). Author name is used for the space display name.
|
|
173
|
-
- Read first: `src/core/handler.ts` (line 50 where resolveConversation is called), `src/adapters/whatsapp.ts` (line 497, 576 — `userName`/`fullName` set from pushName)
|
|
174
|
-
- Verify: `bun run typecheck` passes
|
|
175
|
-
|
|
176
|
-
6. **Write tests** — Test the auto-space flow: enabled + non-admin → space created + linked + config seeded; admin number → linked to main; disabled → returns null; returning customer → reuses existing space. Test global daily rate limit fallback: per-space set → uses per-space; per-space not set → uses global; global is 0 → unlimited.
|
|
177
|
-
- Read first: existing test files in `tests/` for patterns
|
|
178
|
-
- Verify: `bun test` passes
|
|
179
|
-
|
|
180
|
-
7. **Update example config** — Add commented `dm_auto_space` section and `rate_limit_daily_*` to `resources/templates/mercury.example.yaml`.
|
|
181
|
-
- Read first: `resources/templates/mercury.example.yaml`
|
|
182
|
-
- Verify: file contains the new sections
|
|
183
|
-
|
|
184
|
-
---
|
|
185
|
-
|
|
186
|
-
## Non-Negotiable Rules
|
|
187
|
-
|
|
188
|
-
- **Default off** — `dm_auto_space.enabled` must default to `false`. Existing deployments must not change behavior.
|
|
189
|
-
- **Admin numbers link to main** — admin numbers are auto-linked to the `main` space, never dropped. They never create a new space.
|
|
190
|
-
- **Customer permissions are locked down** — auto-created spaces seed `role.member.permissions` from config (default `"prompt,prefs.get"`), stripping all extension tool access.
|
|
191
|
-
- **Space ID format** — derived IDs must always pass the `/^[a-z0-9][a-z0-9-]*$/` regex. Strip all characters that don't match.
|
|
192
|
-
- **No data loss** — auto-created spaces are regular spaces. Deleting them via the existing API unlinks conversations (FK ON DELETE SET NULL), which is the correct behavior.
|
|
193
|
-
- **Idempotent** — `ensureSpace()` already does INSERT OR IGNORE. Space config seeding must use a read-before-write pattern (`getSpaceConfig` check before `setSpaceConfig`) because `setSpaceConfig` uses `ON CONFLICT DO UPDATE` which would overwrite manual overrides.
|
|
194
|
-
- **Backward compatible rate limits** — global daily rate limit defaults to `0` (unlimited), preserving existing behavior for all deployments.
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
## Edge Cases & Risks
|
|
199
|
-
|
|
200
|
-
| Scenario | Handling |
|
|
201
|
-
|----------|---------|
|
|
202
|
-
| Same customer sends multiple messages before first one is processed | `ensureSpace()` is idempotent (INSERT OR IGNORE); `linkConversation()` is an UPDATE — safe for concurrent calls. Space config seeding uses read-before-write (`getSpaceConfig` → `setSpaceConfig` only if null), so first write wins and subsequent messages don't overwrite. |
|
|
203
|
-
| Customer number produces an invalid space ID (starts with dash, etc.) | Sanitize: strip `@...` suffix, prefix with `dm-`, remove non-alphanumeric chars except hyphens |
|
|
204
|
-
| Admin numbers in different formats (+972 vs 972 vs 972...@s.whatsapp.net) | Normalize: strip leading `+`, strip `@s.whatsapp.net` suffix before comparison |
|
|
205
|
-
| Admin links a DM conversation to a different space manually | `resolveConversation()` only auto-creates when `spaceId` is null — if already linked, uses the existing link |
|
|
206
|
-
| Feature is enabled then disabled | Existing auto-created spaces and links remain; new DMs from unknown numbers go back to being dropped |
|
|
207
|
-
| Admin overrides rate limit per space via dashboard | Per-space value takes precedence over global default; "Reset" button clears override to fall back to global |
|
|
208
|
-
| Very large number of unique customers | Each gets a space row + workspace dir + space_config entries. Acceptable for MVP; monitoring via `listSpaces()` count |
|
|
209
|
-
| `main` space doesn't exist when admin number messages | `ensureSpace("main")` is called at runtime boot (runtime.ts line 88); always exists |
|
|
210
|
-
| Race in space config seeding (read-before-write) | Two concurrent messages could both read null and both write. `setSpaceConfig` uses `ON CONFLICT DO UPDATE` so second write wins — acceptable since both write the same default values. The race only matters if an admin manually changed the config between the two messages, which is vanishingly unlikely during the first-message window. |
|
|
211
|
-
| Push name not available (e.g., privacy settings) | Fall back to phone number as space display name. `message.author.userName` may be just the JID prefix — acceptable for MVP. |
|
|
212
|
-
|
|
213
|
-
---
|
|
214
|
-
|
|
215
|
-
## Implementation Checklist
|
|
216
|
-
|
|
217
|
-
### Phase 1 — Goal (fill before asking for approval)
|
|
218
|
-
- [x] Goal, User Stories, and MVP Scope written and reviewed
|
|
219
|
-
|
|
220
|
-
### Phase 2 — Architecture (fill after Goal approved)
|
|
221
|
-
- [x] Architecture & Data section complete
|
|
222
|
-
- [x] Non-Negotiable Rules defined
|
|
223
|
-
- [x] Edge Cases covered
|
|
224
|
-
- [x] Context for Claude pointers filled in
|
|
225
|
-
|
|
226
|
-
### Implementation (tick off as you go)
|
|
227
|
-
- [x] Step 1 — Add config fields to `config-file.ts` and `config.ts`
|
|
228
|
-
- [x] Step 2 — Add global daily rate limit fallback in `runtime.ts`
|
|
229
|
-
- [x] Step 3 — Add rate limit to dashboard + config-builtin
|
|
230
|
-
- [x] Step 4 — Implement auto-space logic in `resolveConversation()`
|
|
231
|
-
- [x] Step 5 — Thread config through handler → resolveConversation call chain
|
|
232
|
-
- [x] Step 6 — Write tests
|
|
233
|
-
- [x] Step 7 — Update example config
|
|
234
|
-
- [x] `bun run check` passes
|
|
235
|
-
- [x] No secrets or `.env` files committed
|
|
236
|
-
- [x] No unrelated files modified
|
|
237
|
-
- [x] All user stories verifiably met
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
## Open Questions
|
|
242
|
-
|
|
243
|
-
- [x] Config key naming — confirmed: `dm_auto_space: { enabled, admin_numbers, default_system_prompt, default_member_permissions }`
|
|
244
|
-
- [x] Should `exclude` match exact numbers or support patterns/wildcards? — exact phone numbers only
|
|
245
|
-
- [x] What happens to excluded/admin numbers? — auto-linked to `main`, not dropped
|
|
246
|
-
- [x] How to restrict customer tools? — seed `role.member.permissions` via `default_member_permissions` config
|
|
247
|
-
- [x] How to set trigger mode? — auto-seeded `trigger.match = "always"` on customer spaces
|
|
248
|
-
- [x] Global daily rate limits? — added to `runtime` config block, per-space dashboard override on top
|
|
249
|
-
|
|
250
|
-
---
|
|
251
|
-
|
|
252
|
-
## Retrospective
|
|
253
|
-
|
|
254
|
-
> Fill this section when archiving.
|
|
255
|
-
|
|
256
|
-
**Residual risks / follow-ups:**
|
|
257
|
-
- Per-space AGENTS.md for auto-created spaces is empty by default; the global AGENTS.md applies but a dedicated customer-facing template could improve quality — accepted
|
|
258
|
-
- Business logic (appointment booking, etc.) must be handled by dedicated business extensions with deterministic code, NOT via LLM prompting → ideas/business-extensions.md
|
|
259
|
-
|
|
260
|
-
**What changed from the plan:**
|
|
261
|
-
- Steps 4 and 5 (auto-space logic + config threading) implemented together as they are tightly coupled
|
|
262
|
-
- Added `.toLowerCase()` to `deriveSpaceId` for future non-numeric platform IDs (caught in code review)
|
|
263
|
-
- `memory.ts` removed from modified files list — `ensureSpaceWorkspace` is already called lazily at container execution time
|
|
264
|
-
|
|
265
|
-
**Key decisions made during implementation:**
|
|
266
|
-
- Used `seedSpaceConfigIfAbsent` helper (read-before-write) instead of raw `setSpaceConfig` to avoid overwriting manual admin overrides
|
|
267
|
-
- Author name sourced from `message.author.userName` (set from WhatsApp pushName) before `resolveConversation` — available before bridge normalization
|
|
268
|
-
|
|
269
|
-
**Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
|
|
270
|
-
- [ ] New package/module added
|
|
271
|
-
- [ ] New data model or schema change
|
|
272
|
-
- [ ] New inter-package interaction
|
|
273
|
-
- [ ] Deployment topology changed
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# Recent Dev Summary
|
|
2
|
-
|
|
3
|
-
**Date:** 2026-07-02
|
|
4
|
-
**Covers:** 3 shipped features · 2026-06-30 → 2026-07-01
|
|
5
|
-
|
|
6
|
-
Three tightly-related features shipped together to lay the groundwork for customer-facing (member) profiles: a per-DM auto-space so each customer gets isolated persistent context, a caller-bound capability token so the host can trust *who* is calling, and applicative profiles that scope member capabilities and route privileged work through a host-side broker. The unifying thread is a **security pivot**: moving from "trust whoever holds a tool" to "keep secrets on the host and bind identity cryptographically" — a prerequisite for exposing capabilities to untrusted members.
|
|
7
|
-
|
|
8
|
-
## Shipped
|
|
9
|
-
|
|
10
|
-
| Feature | Slug | Date shipped | Key takeaway |
|
|
11
|
-
|---------|------|-------------|--------------|
|
|
12
|
-
| Auto-Create Space per DM | dm-auto-space | 2026-07-01 | Config-driven per-customer spaces; default-off, idempotent seeding via read-before-write |
|
|
13
|
-
| Caller-Bound Capability Token | caller-bound-capability-token | 2026-07-01 | Per-turn HMAC token binds `{callerId, spaceId}`; host trusts the token, not spoofable headers |
|
|
14
|
-
| Applicative Profiles | applicative-profiles | 2026-07-01 | Profiles scope member permissions + run privileged work host-side; secrets never enter member containers |
|
|
15
|
-
|
|
16
|
-
## Build patterns
|
|
17
|
-
|
|
18
|
-
### 1. Security by architecture, not by guardrail
|
|
19
|
-
|
|
20
|
-
Rather than hardening a bypassable boundary, these features removed the sensitive material from the untrusted zone entirely.
|
|
21
|
-
|
|
22
|
-
| Feature | Observation |
|
|
23
|
-
|---------|-------------|
|
|
24
|
-
| applicative-profiles | Credentials stay in host storage; member containers get only a self-scoped per-turn token — so `printenv` leaks nothing. Explicitly does *not* rely on the bypassable permission guard (TODO-2). |
|
|
25
|
-
| caller-bound-capability-token | Signing key never enters the container; the container holds only its own turn's token and cannot forge another caller. |
|
|
26
|
-
|
|
27
|
-
**Rule:** When an untrusted actor shares an execution environment, don't guard the secret — remove it from that environment. Security should come from where the secret lives, not from blocking the paths to it.
|
|
28
|
-
|
|
29
|
-
### 2. Reuse the proven pattern over inventing one
|
|
30
|
-
|
|
31
|
-
New capabilities deliberately mirrored an existing production mechanism instead of designing fresh machinery.
|
|
32
|
-
|
|
33
|
-
| Feature | Observation |
|
|
34
|
-
|---------|-------------|
|
|
35
|
-
| applicative-profiles | The host-side capability broker is the TradeStation pattern generalized (`mrctl`→control-plane API, creds host-side), not a new transport. |
|
|
36
|
-
| caller-bound-capability-token | TradeStation's confirm guard needed *no code change* — it already read identity from `c.get("auth")`, which the new middleware now populates from the token. |
|
|
37
|
-
|
|
38
|
-
**Rule:** Before building new infrastructure, find the closest production-proven pattern and generalize it. Consumers that read from the right abstraction inherit the upgrade for free.
|
|
39
|
-
|
|
40
|
-
### 3. Backward-compatible, additive rollout
|
|
41
|
-
|
|
42
|
-
Every feature shipped default-off or fallback-preserving so existing deployments were unaffected.
|
|
43
|
-
|
|
44
|
-
| Feature | Observation |
|
|
45
|
-
|---------|-------------|
|
|
46
|
-
| dm-auto-space | `enabled` defaults to `false`; global daily rate limits default to `0` (unlimited). |
|
|
47
|
-
| caller-bound-capability-token | Absent a token, the old shared-secret + header path is unchanged, so old container images keep working during rollout. |
|
|
48
|
-
| applicative-profiles | All new profile schema fields optional; brokers additive; existing profiles still parse. |
|
|
49
|
-
|
|
50
|
-
**Rule:** Ship member-facing and security-sensitive changes as additive with a safe default; make the new path authoritative only when explicitly present, so rollout never breaks in-flight deployments.
|
|
51
|
-
|
|
52
|
-
### 4. Plan-vs-reality divergence on the crypto detail
|
|
53
|
-
|
|
54
|
-
Each retrospective flagged where the plan's shortcut was unsafe and was corrected during implementation.
|
|
55
|
-
|
|
56
|
-
| Feature | Observation |
|
|
57
|
-
|---------|-------------|
|
|
58
|
-
| caller-bound-capability-token | Plan proposed deriving the signing key from `API_SECRET`; rejected because `API_SECRET` is readable in the container — switched to an ephemeral host-only key. |
|
|
59
|
-
| applicative-profiles | Profile activation moved from per-space `space_config` writes to a module-level registry + persisted `.mercury/active-profile.json`; schema dropped `.strict()` to honor the strict-yaml crash post-mortem. |
|
|
60
|
-
|
|
61
|
-
**Rule:** Re-examine any "derive the key from the existing secret" shortcut against the threat model — if the base secret is reachable by the attacker, the derived key is too.
|
|
62
|
-
|
|
63
|
-
## Comparison with previous dev summary
|
|
64
|
-
|
|
65
|
-
None — this is the first dev summary. Future summaries should watch whether the "security by architecture" and "reuse the proven pattern" themes persist as more member-facing profiles land in the private repo.
|
|
66
|
-
|
|
67
|
-
## Action items
|
|
68
|
-
|
|
69
|
-
1. **Land the private barber-appointments profile** against the now-public contract (schema + broker + caller token) to validate the design end-to-end.
|
|
70
|
-
2. **Audit remaining header-trusting routes** to migrate onto token-derived identity now that the mechanism exists.
|
|
71
|
-
3. **Document the host-side broker contract** (`docs/authoring-profiles.md`) as the canonical reference for external profile authors, and keep it in sync as capabilities grow.
|
package/docs/backlog/.gitkeep
DELETED
|
File without changes
|
package/docs/bugs/.gitkeep
DELETED
|
File without changes
|
|
File without changes
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# Bug: Strict YAML schema crashes Mercury on unknown config keys
|
|
2
|
-
|
|
3
|
-
**Status**: Fixed
|
|
4
|
-
**Severity**: major
|
|
5
|
-
**Slug**: strict-yaml-schema-crash
|
|
6
|
-
**Reported**: 2026-07-01
|
|
7
|
-
**Last updated**: 2026-07-01
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Summary
|
|
12
|
-
`mercuryFileSchema` uses `.strict()` on all Zod objects, causing Mercury to crash on startup when `mercury.yaml` contains any unrecognized key — a typo, a key from a newer version, or a renamed field. Combined with PM2's default unlimited restarts and Baileys' session-based auth, this creates a cascade: config error → crash loop → WhatsApp session revoked → extended downtime requiring manual QR re-scan.
|
|
13
|
-
|
|
14
|
-
## Steps to Reproduce
|
|
15
|
-
1. Add any unrecognized key to `mercury.yaml`, e.g. `dm_auto_space: { admin_numbers: ["123"] }` (renamed to `admin_ids` in 0.4.27)
|
|
16
|
-
2. Start Mercury (`mercury run` or `mercury service install`)
|
|
17
|
-
3. Mercury crashes with `Invalid mercury.yaml: dm_auto_space: Unrecognized key: "admin_numbers"`
|
|
18
|
-
4. PM2 restarts Mercury in a crash loop, each restart fails identically
|
|
19
|
-
5. Crash loop causes WhatsApp session to expire (reason=401), requiring re-authentication
|
|
20
|
-
|
|
21
|
-
## Expected Behavior
|
|
22
|
-
Mercury should log a warning about the unknown key and continue starting normally. Invalid *values* (wrong type, out of range) should still crash — the distinction is between "unknown key" (safe to ignore) and "invalid value" (can't run correctly).
|
|
23
|
-
|
|
24
|
-
## Actual Behavior
|
|
25
|
-
Mercury throws an unhandled error and exits. PM2 restart loop compounds the damage by expiring the WhatsApp session.
|
|
26
|
-
|
|
27
|
-
## Impact
|
|
28
|
-
- **Service downtime** — entire Mercury instance goes down, no messages processed
|
|
29
|
-
- **WhatsApp session loss** — crash loop causes Baileys auth to expire, requiring manual QR re-scan
|
|
30
|
-
- **Version upgrade risk** — any field rename between versions (like `admin_numbers` → `admin_ids`) crashes deployments that haven't updated their yaml
|
|
31
|
-
- **Typo intolerance** — a single typo in mercury.yaml takes down the whole service
|
|
32
|
-
|
|
33
|
-
## Root Cause Analysis
|
|
34
|
-
|
|
35
|
-
Two separate concerns are conflated by `.strict()`:
|
|
36
|
-
|
|
37
|
-
1. **Unknown keys** (typo, version mismatch, renamed field) — should warn, not crash. The config isn't invalid, it's just unrecognized. Even with WABA, crashing because of a typo is wrong.
|
|
38
|
-
2. **Invalid values** (wrong type, out of range) — should crash. If `port: "abc"`, the service can't run correctly. Fail fast is correct.
|
|
39
|
-
|
|
40
|
-
The Baileys crash-loop cascade is a separate but compounding problem — on the unofficial WhatsApp protocol, rapid reconnect attempts trigger session revocation by Meta. This makes *any* crash loop catastrophic, not just config-related ones.
|
|
41
|
-
|
|
42
|
-
## Suspected Location
|
|
43
|
-
- `src/config-file.ts` — `mercuryFileSchema` (line 25) and all nested objects use `.strict()`, which rejects unknown keys
|
|
44
|
-
- `src/config-file.ts` — `mergeRawMercuryConfig()` (line 352) calls `safeParse` but throws on failure instead of warning
|
|
45
|
-
|
|
46
|
-
## Fix Plan
|
|
47
|
-
|
|
48
|
-
### Phase 1 — Short term (now)
|
|
49
|
-
|
|
50
|
-
**Config resilience:** Remove `.strict()` from `mercuryFileSchema` and all nested objects (Zod default behavior strips unknown keys silently). Add a post-parse diff that logs warnings for any stripped keys so operators notice typos without crashing.
|
|
51
|
-
|
|
52
|
-
**PM2 restart limit:** Configure `max_restarts` and `min_uptime` in the PM2 ecosystem config so a crash loop stops after N attempts instead of running forever. Send an alert when the limit is hit.
|
|
53
|
-
|
|
54
|
-
### Phase 2 — Medium term
|
|
55
|
-
|
|
56
|
-
**Circuit breaker for container errors:** Container failures (model API errors, malformed responses) should not crash the host process. The host should log the error, reply to the user with a friendly message, and continue.
|
|
57
|
-
|
|
58
|
-
### Phase 3 — Long term (WABA)
|
|
59
|
-
|
|
60
|
-
With WABA, auth is a permanent API token — crash loops no longer revoke auth. At that point, strict validation on values remains correct (fail fast on truly invalid config), while unknown keys should still warn-not-crash regardless of the WhatsApp transport.
|
|
61
|
-
|
|
62
|
-
## Notes
|
|
63
|
-
- Industry standard (Kubernetes, OpenCode/SST) is to warn on unknown keys, not crash. Zod's default behavior is `.strip()` (silently remove unknown keys) — Mercury explicitly opted into `.strict()` which is the most aggressive mode.
|
|
64
|
-
- OpenCode hit the same bug and fixed it by switching to passthrough + warnings (github.com/sst/opencode/issues/6145).
|
|
65
|
-
- The Baileys session loss is a known risk of the unofficial protocol — Meta actively discourages bot usage on consumer WhatsApp. WABA eliminates this class of risk entirely.
|
|
66
|
-
|
|
67
|
-
---
|
|
68
|
-
|
|
69
|
-
## Post-Mortem
|
|
70
|
-
|
|
71
|
-
### Investigation
|
|
72
|
-
Read `src/config-file.ts` — `mercuryFileSchema` and all 13 nested Zod object schemas used `.strict()`. The `mergeRawMercuryConfig()` function called `safeParse()` correctly but threw unconditionally on any parse failure, including unknown keys. The Zod `.strict()` mode treats unknown keys the same as type errors — both produce parse failures.
|
|
73
|
-
|
|
74
|
-
### Root Cause
|
|
75
|
-
`.strict()` on Zod object schemas rejects unknown keys with the same error severity as invalid values. There was no distinction between "unrecognized key" (safe to ignore) and "invalid value" (can't run). A renamed field (`admin_numbers` → `admin_ids`) caused Mercury to crash on startup, PM2's unlimited restarts amplified this into a crash loop, and the rapid reconnection pattern caused WhatsApp/Baileys to revoke the session.
|
|
76
|
-
|
|
77
|
-
### Fix
|
|
78
|
-
- **`src/config-file.ts`**: Replaced all 13 `.strict()` calls with `.strip()` (Zod default — silently removes unknown keys). Added `KNOWN_TOP_KEYS` and `KNOWN_SECTION_KEYS` static key sets, and a `warnUnknownKeys()` function that runs before parsing to log `[WARN]` messages for any unrecognized keys. Invalid values (wrong types, out-of-range numbers) still throw — only unknown keys are tolerated.
|
|
79
|
-
- **`mergeRawMercuryConfig()`**: Added an optional `log` parameter (defaults to `console.warn`) so the warning function is testable. Called `warnUnknownKeys()` before `safeParse()`.
|
|
80
|
-
- **`tests/config.test.ts`**: Added 4 tests: unknown top-level key warns but doesn't crash, unknown nested key warns, invalid value type still throws, valid config produces no warnings.
|
|
81
|
-
|
|
82
|
-
### Lessons
|
|
83
|
-
- Separate "unknown key" from "invalid value" in config validation — they have different severity and different correct responses.
|
|
84
|
-
- Any feature that adds config fields must consider version mismatch — a user on version N with a yaml from version N+1 should not crash.
|
|
85
|
-
- On Baileys, any crash loop is catastrophic — PM2 restart limits should be configured.
|