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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/docs/ARCHITECTURE.md +0 -34
  3. package/docs/DESIGN.md +0 -42
  4. package/docs/ROADMAP.md +0 -43
  5. package/docs/TODOS.md +0 -147
  6. package/docs/VISION.md +0 -32
  7. package/docs/archive/.gitkeep +0 -0
  8. package/docs/archive/applicative-profiles/2026-07-01-applicative-profiles.md +0 -270
  9. package/docs/archive/applicative-profiles/2026-07-01-caller-bound-capability-token.md +0 -184
  10. package/docs/archive/applicative-profiles/2026-07-01-dm-auto-space.md +0 -273
  11. package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +0 -71
  12. package/docs/backlog/.gitkeep +0 -0
  13. package/docs/bugs/.gitkeep +0 -0
  14. package/docs/debug/major/.gitkeep +0 -0
  15. package/docs/debug/major/2026-07-01-strict-yaml-schema-crash.md +0 -85
  16. package/docs/debug/major/2026-07-02-bwrap-privileged-linux-docker.md +0 -160
  17. package/docs/debug/major/2026-07-02-e2big-prompt-delivery-fix.md +0 -382
  18. package/docs/debug/major/2026-07-02-registry-pull-no-local-fallback.md +0 -194
  19. package/docs/debug/minor/.gitkeep +0 -0
  20. package/docs/debug/moderate/.gitkeep +0 -0
  21. package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +0 -72
  22. package/docs/html-slides/.gitkeep +0 -0
  23. package/docs/ideas/.gitkeep +0 -0
  24. package/docs/ideas/business-extensions.md +0 -48
  25. package/docs/in-progress/.gitkeep +0 -0
  26. package/docs/notes/.gitkeep +0 -0
  27. package/docs/pending-updates/.gitkeep +0 -0
  28. package/docs/runbooks/gws-oauth-setup.md +0 -211
  29. package/docs/runbooks/publish-checklist.md +0 -54
  30. package/docs/templates/TEMPLATE-AUTOPILOT-CONFIG.yaml +0 -35
  31. package/docs/templates/TEMPLATE-BUG.md +0 -71
  32. package/docs/templates/TEMPLATE-CI.yml +0 -25
  33. package/docs/templates/TEMPLATE-DECISIONS.md +0 -11
  34. package/docs/templates/TEMPLATE-FEATURE.md +0 -127
  35. package/docs/templates/TEMPLATE-GOAL.md +0 -31
  36. package/docs/templates/TEMPLATE-IDEA.md +0 -31
  37. package/docs/templates/TEMPLATE-NOTE.md +0 -27
  38. 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.
File without changes
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.