mercury-agent 0.4.27 → 0.4.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -2
- package/docs/backlog/applicative-profiles.md +135 -149
- package/docs/backlog/caller-bound-capability-token.md +180 -0
- package/docs/debug/major/2026-07-01-strict-yaml-schema-crash.md +85 -0
- package/docs/ideas/business-extensions.md +48 -0
- package/docs/runbooks/gws-oauth-setup.md +211 -0
- package/package.json +1 -1
- package/src/config-file.ts +114 -14
- package/src/core/routes/console.ts +32 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Mercury is a personal AI assistant that lives where you chat. It connects to Wha
|
|
|
19
19
|
|
|
20
20
|
- **[Node.js](https://nodejs.org/)** >= 18 — Required for `npm install -g mercury-agent`
|
|
21
21
|
- **[Bun](https://bun.sh)** >= 1.0 — JavaScript runtime used by Mercury
|
|
22
|
-
- **[Docker](https://docs.docker.com/get-docker/)** — Required for running agent containers
|
|
22
|
+
- **[Docker](https://docs.docker.com/get-docker/)** — Required for running agent containers. On Linux, also install `docker-buildx` (`apt-get install docker-buildx`) — Docker Desktop includes it, but Docker Engine on Linux does not.
|
|
23
23
|
- **Windows users:** Mercury runs best under [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install). Install WSL2 with `wsl --install`, then install Bun and Docker inside it.
|
|
24
24
|
|
|
25
25
|
## Quick Start
|
package/docs/ROADMAP.md
CHANGED
|
@@ -10,7 +10,7 @@ Single source of truth for priority. Rule: ≤15 min/week to maintain.
|
|
|
10
10
|
|
|
11
11
|
| Item | Doc | Notes |
|
|
12
12
|
|------|-----|-------|
|
|
13
|
-
|
|
|
13
|
+
| **business-extensions** | [doc](ideas/business-extensions.md) | Dedicated business extensions wrapping raw capabilities with deterministic logic |
|
|
14
14
|
|
|
15
15
|
## Now
|
|
16
16
|
|
|
@@ -26,7 +26,8 @@ Single source of truth for priority. Rule: ≤15 min/week to maintain.
|
|
|
26
26
|
|---|------|-----|-----------|---------|
|
|
27
27
|
| 1 | **autonomous-planning-tier** | [doc](backlog/autonomous-planning-tier.md) | — | Let agents decompose ideas, keep human gate |
|
|
28
28
|
| 2 | **dm-auto-space** | [doc](backlog/dm-auto-space.md) | — | Auto-create space per customer DM |
|
|
29
|
-
| 3 | **applicative-profiles** | [doc](backlog/applicative-profiles.md) |
|
|
29
|
+
| 3 | **applicative-profiles** | [doc](backlog/applicative-profiles.md) | caller-bound-capability-token | Generic profile layer; host-side broker keeps secrets off customer containers |
|
|
30
|
+
| 4 | **caller-bound-capability-token** | [doc](backlog/caller-bound-capability-token.md) | — | Per-turn token so brokers can trust callerId; also hardens TradeStation |
|
|
30
31
|
|
|
31
32
|
## Later
|
|
32
33
|
|
|
@@ -9,199 +9,184 @@
|
|
|
9
9
|
|
|
10
10
|
## Goal
|
|
11
11
|
|
|
12
|
-
Mercury's existing profile system (`src/core/profiles.ts`) is a static provisioning mechanism — it bundles extensions, AGENTS.md, env vars, and config defaults into a deployable template.
|
|
12
|
+
Mercury's existing profile system (`src/core/profiles.ts`) is a static provisioning mechanism — it bundles extensions, AGENTS.md, env vars, and config defaults into a deployable template. It has no runtime behavior: it can't scope which capabilities members access, keep secrets out of an untrusted member's container, or enforce per-user ownership. Business workflows (appointment booking, customer management) require deterministic logic that the LLM cannot be trusted to enforce.
|
|
13
13
|
|
|
14
|
-
Applicative Profiles extend the
|
|
14
|
+
Applicative Profiles extend the profile system with a **runtime contract**: a profile declares the capabilities it wraps, the commands it exposes to members, and its member permission set — and its privileged work runs **host-side**, behind an authenticated broker, so credentials never enter a customer-controlled container. Mercury (public repo) provides the schema, permission scoping, and broker mechanism; the actual business profiles live in a **separate private repo**. The barber-appointments profile is the first implementation and the design driver.
|
|
15
|
+
|
|
16
|
+
### Security framing (why the naive design fails)
|
|
17
|
+
|
|
18
|
+
Mercury's security model today **trusts whoever it hands a tool to** — appropriate because tool-callers have historically been admins. Two facts make a naive "wrap GWS in an extension" approach unsafe for customer-facing profiles:
|
|
19
|
+
|
|
20
|
+
1. **The permission guard is bypassable.** It's a regex denylist on CLI names (`src/extensions/permission-guard.ts`) that the project documents as defense-in-depth only (`docs/TODOS.md` TODO-2). It cannot stop `printenv`, `cat /proc/self/environ`, `curl`, `python -c`, etc.
|
|
21
|
+
2. **Member role does not remove bash.** Role only controls which CLIs are denied and which env vars are injected (`src/core/runtime.ts:1343-1376`); whether bash exists is decided by *model capability*, not role. So a member on a tool-capable model can read any env var in their container.
|
|
22
|
+
|
|
23
|
+
Therefore **any credential injected into a customer's container is readable by that customer.** The only safe design keeps the credential on the host — which Mercury already does for TradeStation (see Architecture).
|
|
15
24
|
|
|
16
25
|
## User Stories
|
|
17
26
|
|
|
18
|
-
- As a Mercury operator, I want to install a profile
|
|
19
|
-
- As a barber shop owner, I want
|
|
20
|
-
- As a barber shop owner, I want
|
|
21
|
-
- As a profile developer, I want a clear schema
|
|
22
|
-
- As a Mercury admin, I want raw capability extensions (GWS, web search) to
|
|
27
|
+
- As a Mercury operator, I want to install a profile from an external repo so my bot gains domain-specific business logic without modifying Mercury core.
|
|
28
|
+
- As a barber shop owner, I want customers (DM members) to book and cancel only their **own** appointments, never seeing others' schedules.
|
|
29
|
+
- As a barber shop owner, I want customers to have **zero** access to my Gmail/Google account, even though the profile uses my Google credentials for Calendar internally.
|
|
30
|
+
- As a profile developer, I want a clear schema + a host-side broker pattern so I can enforce ownership on the host where the real `callerId` is trustworthy.
|
|
31
|
+
- As a Mercury admin, I want raw capability extensions (GWS, web search) to stay admin-only while the profile's scoped commands are member-facing.
|
|
23
32
|
|
|
24
33
|
## MVP Scope
|
|
25
34
|
|
|
26
|
-
**In scope:**
|
|
27
|
-
- Extend `mercury-profile.yaml` schema with runtime fields: `capabilities` (required extensions), `member_permissions` (exhaustive permission
|
|
35
|
+
**In scope (all in `mercury-public`):**
|
|
36
|
+
- Extend `mercury-profile.yaml` schema with runtime fields: `capabilities` (required host-side extensions), `member_permissions` (exhaustive member permission set), `system_prompt` (agent persona)
|
|
28
37
|
- Profile-aware permission resolution — when a profile is active, member permissions come from the profile, not from extension defaults
|
|
29
|
-
- Capability
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
- Barber-appointments profile (separate repo) as the first implementation to validate the design
|
|
38
|
+
- Capability-dependency validation — activation fails if a required capability isn't installed
|
|
39
|
+
- A **host-side capability broker** convention: profile capabilities execute on the host (credentials in host storage), invoked from the container via the existing `mrctl`→control-plane-API channel — following the proven TradeStation pattern
|
|
40
|
+
- Depends on [[caller-bound-capability-token]] so the broker can trust `callerId` for ownership checks
|
|
33
41
|
|
|
34
42
|
**Out of scope (deferred):**
|
|
35
|
-
-
|
|
36
|
-
- Profile
|
|
37
|
-
-
|
|
43
|
+
- The actual barber business logic (availability rules, 2-barber support, per-number appointment limits) — lives in the **private profiles repo**, not here
|
|
44
|
+
- Profile marketplace/registry — profiles are installed manually (git clone / package) for now
|
|
45
|
+
- Profile versioning/migration — no schema-version negotiation yet
|
|
46
|
+
- Multi-profile per space — one active profile per deployment for MVP
|
|
38
47
|
- Profile UI in dashboard — managed via config/YAML only
|
|
39
|
-
- Declarative
|
|
48
|
+
- Declarative ownership rules — ownership is enforced in the profile's host-side route code using the trusted `callerId`, not a generic schema (too domain-specific for v1)
|
|
40
49
|
|
|
41
50
|
---
|
|
42
51
|
|
|
43
52
|
## Context for Claude
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- `src/core/
|
|
48
|
-
- `src/extensions/
|
|
49
|
-
- `src/core/
|
|
50
|
-
- `src/
|
|
51
|
-
- `src/
|
|
54
|
+
- `src/core/profiles.ts` — Existing profile system: `profileSchema`, `loadProfileFromDir()`, `applyProfile()` (project-level, no DB/space). Extended here
|
|
55
|
+
- `src/core/permissions.ts` — `getRolePermissions()`, `registerPermission()`, `resolveRole()`. Role-based (admin/member) per space
|
|
56
|
+
- `src/core/runtime.ts` — Container spawn: denied-CLI computation + per-permission env injection at 1343-1376
|
|
57
|
+
- `src/extensions/permission-guard.ts` — Bypassable CLI-name denylist (see `docs/TODOS.md` TODO-2)
|
|
58
|
+
- `src/core/routes/tradestation.ts` + `src/tradestation/host-api.ts` — **The reference broker.** Container calls `mrctl tradestation order` → host route executes with a token from `db.getExtState(...)`, never exposing it to the container ("keeps order placement off the agent container"). Includes a caller-bound confirm guard (153-158, 245-254)
|
|
59
|
+
- `src/core/api.ts` — Control-plane API + auth middleware (35-75) that broker routes mount onto
|
|
60
|
+
- `src/cli/mrctl.ts` — In-container CLI that calls the host API (the container→host transport)
|
|
52
61
|
|
|
53
62
|
---
|
|
54
63
|
|
|
55
64
|
## Architecture & Data
|
|
56
65
|
|
|
57
|
-
###
|
|
66
|
+
### The four parts (and where each lives)
|
|
67
|
+
|
|
68
|
+
| Part | Repo | What |
|
|
69
|
+
|------|------|------|
|
|
70
|
+
| 1. Profile schema + permission scoping | `mercury-public` | New `mercury-profile.yaml` fields; profile-aware member permissions |
|
|
71
|
+
| 2. Host-side capability broker | `mercury-public` | Profile capabilities run host-side via `mrctl`→API, à la TradeStation; secrets stay on host |
|
|
72
|
+
| 3. Per-turn caller token | `mercury-public` | [[caller-bound-capability-token]] — trustworthy `callerId` on the host (dependency) |
|
|
73
|
+
| 4. Barber business logic | **private profiles repo** | Availability, 2 barbers, per-number limits, ownership rules — the moat |
|
|
58
74
|
|
|
59
|
-
|
|
75
|
+
### Part 1 — Schema + permission scoping
|
|
76
|
+
|
|
77
|
+
Extend `profileSchema` in `src/core/profiles.ts` (all new fields optional → backward compatible):
|
|
60
78
|
|
|
61
79
|
```typescript
|
|
62
|
-
// New fields added to profileSchema
|
|
63
80
|
const profileSchema = z.object({
|
|
64
|
-
// ... existing
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
agents_md: z.string().optional(),
|
|
69
|
-
extensions: z.array(profileExtensionSchema).default([]),
|
|
70
|
-
env: z.array(profileEnvVarSchema).default([]),
|
|
71
|
-
defaults: profileDefaultsSchema.optional(),
|
|
72
|
-
|
|
73
|
-
// NEW — runtime applicative fields
|
|
74
|
-
capabilities: z.array(z.string()).default([]), // required raw extensions (e.g. ["gws"])
|
|
75
|
-
member_permissions: z.array(z.string()).optional(), // exhaustive member permission list when profile is active
|
|
76
|
-
system_prompt: z.string().optional(), // profile-specific agent persona
|
|
81
|
+
// ... existing: name, description, version, agents_md, extensions, env, defaults ...
|
|
82
|
+
capabilities: z.array(z.string()).default([]), // required host-side capability extensions, e.g. ["gws"]
|
|
83
|
+
member_permissions: z.array(z.string()).optional(), // EXHAUSTIVE member permission set when active
|
|
84
|
+
system_prompt: z.string().optional(), // profile persona
|
|
77
85
|
});
|
|
78
86
|
```
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
88
|
+
Activation is **project-wide** (a deployment runs one profile — the "app"), stored in `space_config` on `main` / applied to member-facing spaces:
|
|
89
|
+
- `profile.active` — profile name
|
|
90
|
+
- `profile.member_permissions` — comma-separated list from the manifest
|
|
91
|
+
|
|
92
|
+
Permission resolution (`getRolePermissions()`): when `profile.member_permissions` is set, that is the **complete** member set — no extension defaults merged in. Admin/system unchanged (all). Consequence: raw `gws` is absent from the member set, so its CLI is denied **and** its env vars are withheld from member containers (`runtime.ts:1360`).
|
|
83
93
|
|
|
84
|
-
|
|
94
|
+
**`system_prompt` precedence** (highest wins): per-space `system_prompt` override → profile `system_prompt` → global AGENTS.md.
|
|
85
95
|
|
|
86
|
-
###
|
|
96
|
+
### Part 2 — Host-side capability broker (the core security mechanism)
|
|
87
97
|
|
|
88
|
-
|
|
98
|
+
This is the crux, and it reuses an existing, production-proven pattern rather than inventing one.
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
**How TradeStation already does it (the template):**
|
|
101
|
+
- Container runs `mrctl tradestation order …` → `POST /api/tradestation/orders`
|
|
102
|
+
- The route runs **on the host** (`src/core/routes/tradestation.ts`) using an OAuth token read from host storage (`db.getExtState(...)`, `src/tradestation/host-api.ts:27`) — the token **never enters the container**
|
|
91
103
|
|
|
92
|
-
|
|
104
|
+
**The barber broker is the same shape, Calendar-flavored:**
|
|
105
|
+
- New host routes (e.g. `POST /api/barber/availability|book|cancel`, `GET /api/barber/my-appointments`) mounted on the existing API (`src/core/api.ts`)
|
|
106
|
+
- New `mrctl` (or thin profile-CLI) subcommands the member agent invokes
|
|
107
|
+
- Host route calls Google Calendar using credentials in **host storage** (`extension_state` / host env) — never injected into the customer container
|
|
108
|
+
- Ownership enforced host-side: the route filters/authorizes by the **token-derived** `callerId` (Part 3), so a customer can only see/cancel their own appointments
|
|
93
109
|
|
|
94
|
-
|
|
110
|
+
**Why Gmail is safe:** the customer container gets neither the `gws` CLI nor any Google credential. Even if the customer reads every env var in their container, there is no secret there — only a per-turn token scoped to themselves. Calendar access happens on the host; Gmail is never wired into the broker at all.
|
|
95
111
|
|
|
96
|
-
|
|
112
|
+
### Part 3 — Trustworthy caller identity (dependency)
|
|
97
113
|
|
|
98
|
-
|
|
114
|
+
The broker's ownership checks require a `callerId` the customer cannot forge. Today `callerId` arrives as a spoofable header gated only by a shared `API_SECRET` (`api.ts:50-72`). [[caller-bound-capability-token]] mints a per-turn token bound to `{callerId, spaceId}` at spawn and makes it authoritative host-side. **This feature depends on that one landing** (or shipping together).
|
|
115
|
+
|
|
116
|
+
### Part 4 — Barber business logic (private repo)
|
|
117
|
+
|
|
118
|
+
Lives in `tagula-agent-profiles` (or similar). Contains the `mercury-profile.yaml`, the agent persona, and the host-side route implementations' business rules (availability windows, 2-barber scheduling, per-number booking limits, double-booking prevention). Consumes Parts 1-3 as the public contract; never touches `mercury-public`.
|
|
119
|
+
|
|
120
|
+
### Example manifest (in the private repo)
|
|
99
121
|
|
|
100
122
|
```yaml
|
|
101
|
-
# mercury-profile.yaml (in separate repo)
|
|
102
123
|
name: barber-appointments
|
|
103
124
|
description: Appointment booking for a barber shop
|
|
104
125
|
version: 0.1.0
|
|
105
|
-
|
|
106
126
|
agents_md: ./AGENTS.md
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
- gws # requires GWS extension installed (for Calendar)
|
|
110
|
-
|
|
111
|
-
extensions:
|
|
112
|
-
- name: barber
|
|
113
|
-
source: ./extensions/barber
|
|
114
|
-
|
|
115
|
-
member_permissions:
|
|
116
|
-
- prompt
|
|
117
|
-
- prefs.get
|
|
118
|
-
- barber # only the barber extension — NOT gws
|
|
119
|
-
|
|
120
|
-
env:
|
|
121
|
-
- key: MERCURY_BARBER_BUSINESS_HOURS
|
|
122
|
-
description: "Business hours (e.g. 09:00-18:00)"
|
|
123
|
-
default: "09:00-18:00"
|
|
124
|
-
- key: MERCURY_BARBER_SLOT_DURATION
|
|
125
|
-
description: "Appointment slot duration in minutes"
|
|
126
|
-
default: "30"
|
|
127
|
-
|
|
127
|
+
capabilities: [gws] # Calendar via host-side broker
|
|
128
|
+
member_permissions: [prompt, prefs.get, barber] # barber only — NOT gws
|
|
128
129
|
system_prompt: |
|
|
129
|
-
You are a barber shop assistant. Help
|
|
130
|
-
their appointments.
|
|
131
|
-
appointments — never reveal other customers' schedules.
|
|
132
|
-
|
|
130
|
+
You are a barber shop assistant. Help each customer book, view, and cancel
|
|
131
|
+
ONLY their own appointments. Never reveal other customers' schedules.
|
|
133
132
|
defaults:
|
|
134
|
-
trigger_patterns:
|
|
133
|
+
trigger_patterns: always
|
|
135
134
|
```
|
|
136
135
|
|
|
137
|
-
The barber extension (`./extensions/barber/index.ts`):
|
|
138
|
-
- Calls `mercury.cli({ name: "barber", ... })` — installs a CLI with subcommands: `barber book`, `barber availability`, `barber cancel`, `barber my-appointments`
|
|
139
|
-
- Calls `mercury.permission({ defaultRoles: ["member"] })` — members can use the `barber` CLI
|
|
140
|
-
- The CLI internally uses GWS Calendar API (via env var credentials) but filters results by `CALLER_ID`
|
|
141
|
-
- Members NEVER see the `gws` CLI — it's blocked by the permission guard
|
|
142
|
-
|
|
143
|
-
### API Contracts
|
|
144
|
-
|
|
145
|
-
No new HTTP API endpoints. Profile activation is done via:
|
|
146
|
-
1. `mercury.yaml` — `profile: barber-appointments` (static config)
|
|
147
|
-
2. `space_config` — `profile.active: barber-appointments` (per-space override)
|
|
148
|
-
|
|
149
136
|
### File & Folder Structure
|
|
150
137
|
|
|
151
138
|
| Path | New / Modified | Purpose |
|
|
152
139
|
|------|---------------|---------|
|
|
153
|
-
| `src/core/profiles.ts` | Modified | Extend `profileSchema
|
|
154
|
-
| `src/core/permissions.ts` | Modified | Profile-aware `getRolePermissions()`
|
|
155
|
-
| `src/config.ts` | Modified |
|
|
156
|
-
| `src/
|
|
157
|
-
| `
|
|
140
|
+
| `src/core/profiles.ts` | Modified | Extend `profileSchema`; validate `capabilities`; write `profile.*` to `space_config` |
|
|
141
|
+
| `src/core/permissions.ts` | Modified | Profile-aware `getRolePermissions()` |
|
|
142
|
+
| `src/config.ts` / `src/config-file.ts` | Modified | `profile` config key for activation |
|
|
143
|
+
| `src/core/api.ts` | Modified | Mount profile broker routes (generic mounting point) |
|
|
144
|
+
| `src/core/routes/` | New (per profile capability) | Host-side broker routes — pattern mirrors `tradestation.ts` |
|
|
145
|
+
| `tests/applicative-profiles.test.ts` | New | Permission scoping + capability validation tests |
|
|
158
146
|
|
|
159
147
|
### Implementation Sequence
|
|
160
148
|
|
|
161
|
-
1. **Extend profile schema** —
|
|
149
|
+
1. **Extend profile schema** — add `capabilities`, `member_permissions`, `system_prompt` (optional).
|
|
162
150
|
- Read first: `src/core/profiles.ts`
|
|
163
|
-
- Verify: `bun run typecheck
|
|
164
|
-
|
|
165
|
-
2. **Add capability validation** — In `applyProfile()`, after copying extensions, check that every entry in `capabilities` has a matching directory in `.mercury/extensions/`. Throw a clear error if a required capability is missing.
|
|
151
|
+
- Verify: `bun run typecheck`; existing profiles still parse
|
|
152
|
+
2. **Capability validation** — in `applyProfile()`, fail loudly if any `capabilities` entry has no matching extension.
|
|
166
153
|
- Read first: `src/core/profiles.ts`, `src/extensions/loader.ts`
|
|
167
|
-
- Verify:
|
|
154
|
+
- Verify: apply throws on missing capability; passes when present
|
|
168
155
|
- Blocker: Step 1
|
|
169
|
-
|
|
170
|
-
3. **Store profile activation in space_config** — When `applyProfile()` runs for a space, write `profile.active` and `profile.member_permissions` to `space_config`. Add `profile` config key to `config.ts` / `config-file.ts` for static activation.
|
|
156
|
+
3. **Activation + config** — write `profile.active` / `profile.member_permissions` to `space_config`; add `profile` config key.
|
|
171
157
|
- Read first: `src/config.ts`, `src/config-file.ts`, `src/storage/db.ts`
|
|
172
|
-
- Verify: `
|
|
158
|
+
- Verify: `space_config` reflects active profile
|
|
173
159
|
- Blocker: Step 1
|
|
174
|
-
|
|
175
|
-
4. **Profile-aware permission resolution** — In `getRolePermissions()`, when the space has `profile.member_permissions` set, use that as the member permission set instead of the default. Admin/system roles unchanged.
|
|
160
|
+
4. **Profile-aware permissions** — `getRolePermissions()` uses `profile.member_permissions` as the exhaustive member set.
|
|
176
161
|
- Read first: `src/core/permissions.ts`
|
|
177
|
-
- Verify:
|
|
178
|
-
- Blocker: Step 3
|
|
179
|
-
|
|
180
|
-
5. **System prompt injection** — When a profile has `system_prompt`, inject it via the existing `before_container` hook mechanism or config seeding (same pattern as `dm-auto-space`'s `default_system_prompt`).
|
|
181
|
-
- Read first: `src/core/runtime.ts`, `src/extensions/hooks.ts`
|
|
182
|
-
- Verify: `bun run typecheck` passes
|
|
162
|
+
- Verify: member gets only profile perms; admin unchanged; `gws` CLI denied + env withheld for members
|
|
183
163
|
- Blocker: Step 3
|
|
184
|
-
|
|
185
|
-
|
|
164
|
+
5. **Caller token dependency** — land [[caller-bound-capability-token]] (or ship together) so brokers can trust `callerId`.
|
|
165
|
+
- Verify: broker route reads token-derived caller; spoofed header ignored
|
|
166
|
+
- Blocker: separate doc
|
|
167
|
+
6. **Broker route pattern** — document + provide the mounting point and a reference so a profile's host-side routes follow `tradestation.ts` (host-held creds, caller-bound authorization).
|
|
168
|
+
- Read first: `src/core/routes/tradestation.ts`, `src/tradestation/host-api.ts`, `src/core/api.ts`
|
|
169
|
+
- Verify: a sample broker route executes host-side, credential never injected into the container
|
|
170
|
+
- Blocker: Steps 4, 5
|
|
171
|
+
7. **Tests** — scoping (member/admin/raw-capability), capability validation, activation.
|
|
186
172
|
- Read first: `tests/permissions.test.ts`
|
|
187
|
-
- Verify: `bun test`
|
|
188
|
-
- Blocker: Steps 1-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
- Read first: `resources/templates/mercury.example.yaml`
|
|
192
|
-
- Verify: file contains the new section
|
|
173
|
+
- Verify: `bun test`
|
|
174
|
+
- Blocker: Steps 1-6
|
|
175
|
+
8. **Example config** — commented `profile` section in `mercury.example.yaml`.
|
|
176
|
+
- Verify: file contains the section
|
|
193
177
|
|
|
194
178
|
---
|
|
195
179
|
|
|
196
180
|
## Non-Negotiable Rules
|
|
197
181
|
|
|
198
|
-
- **Profiles are external** —
|
|
199
|
-
- **
|
|
200
|
-
- **
|
|
201
|
-
- **
|
|
202
|
-
- **
|
|
203
|
-
- **
|
|
204
|
-
- **
|
|
182
|
+
- **Profiles are external** — no specific business profile (barber, etc.) lives in `mercury-public`. Only schema, scoping, and broker mechanism.
|
|
183
|
+
- **Credentials never enter a member container** — capability credentials live in host storage and are used only by host-side broker routes. This is the whole point; violating it re-opens the Gmail leak.
|
|
184
|
+
- **Member permissions are exhaustive** — a profile's `member_permissions` is the COMPLETE set; no extension defaults merged. Prevents capability leakage.
|
|
185
|
+
- **Ownership is enforced host-side with the trusted `callerId`** — never with a container-supplied argument, never by prompting the LLM.
|
|
186
|
+
- **Admin permissions unchanged** — admins always get all permissions; profiles scope members only.
|
|
187
|
+
- **Capability validation is strict** — activation fails loudly if a required capability extension is missing.
|
|
188
|
+
- **Backward compatible** — existing profiles and existing brokers keep working; all new fields optional; token path is additive.
|
|
189
|
+
- **Do not rely on the permission guard as a security boundary** — it is bypassable (TODO-2). Security comes from *not putting secrets in the container*, not from blocking CLIs.
|
|
205
190
|
|
|
206
191
|
---
|
|
207
192
|
|
|
@@ -209,36 +194,37 @@ No new HTTP API endpoints. Profile activation is done via:
|
|
|
209
194
|
|
|
210
195
|
| Scenario | Handling |
|
|
211
196
|
|----------|---------|
|
|
212
|
-
|
|
|
213
|
-
|
|
|
214
|
-
|
|
|
215
|
-
|
|
|
216
|
-
|
|
|
217
|
-
|
|
|
218
|
-
|
|
|
219
|
-
| Existing deployment upgrades
|
|
197
|
+
| Customer asks the agent to `printenv` / read files | Allowed — but the container holds no capability secret, only a self-scoped per-turn token. Nothing sensitive to leak. |
|
|
198
|
+
| Customer tries to `curl` the broker spoofing another `callerId` | Rejected — broker authorizes by the token-derived caller ([[caller-bound-capability-token]]), not the header. |
|
|
199
|
+
| Profile requires `gws` but it isn't installed | `applyProfile()` throws listing missing capabilities. |
|
|
200
|
+
| `member_permissions` lists an invalid permission | Filtered by `isValidPermission()`; warn, don't crash. |
|
|
201
|
+
| Admin overrides `role.member.permissions` per space | Per-space override still wins (existing behavior); profile sets the baseline. |
|
|
202
|
+
| Broker route needs Calendar but customer has no appointment yet | Ownership filter returns empty; booking creates a record keyed to `callerId`. |
|
|
203
|
+
| System-triggered task (`callerId = "system"`) hits a broker | Broker skips per-customer ownership for system caller (or per Part 3's system-token decision). |
|
|
204
|
+
| Existing deployment upgrades to this version | No behavior change: `member_permissions` optional; brokers additive. |
|
|
220
205
|
|
|
221
206
|
---
|
|
222
207
|
|
|
223
208
|
## Implementation Checklist
|
|
224
209
|
|
|
225
|
-
### Phase 1 — Goal
|
|
210
|
+
### Phase 1 — Goal
|
|
226
211
|
- [x] Goal, User Stories, and MVP Scope written and reviewed
|
|
227
212
|
|
|
228
|
-
### Phase 2 — Architecture
|
|
213
|
+
### Phase 2 — Architecture
|
|
229
214
|
- [x] Architecture & Data section complete
|
|
230
215
|
- [x] Non-Negotiable Rules defined
|
|
231
216
|
- [x] Edge Cases covered
|
|
232
217
|
- [x] Context for Claude pointers filled in
|
|
233
218
|
|
|
234
|
-
### Implementation
|
|
219
|
+
### Implementation
|
|
235
220
|
- [ ] Step 1 — Extend profile schema
|
|
236
|
-
- [ ] Step 2 —
|
|
237
|
-
- [ ] Step 3 —
|
|
238
|
-
- [ ] Step 4 — Profile-aware
|
|
239
|
-
- [ ] Step 5 —
|
|
240
|
-
- [ ] Step 6 —
|
|
241
|
-
- [ ] Step 7 —
|
|
221
|
+
- [ ] Step 2 — Capability validation
|
|
222
|
+
- [ ] Step 3 — Activation + config
|
|
223
|
+
- [ ] Step 4 — Profile-aware permissions
|
|
224
|
+
- [ ] Step 5 — Caller token dependency ([[caller-bound-capability-token]])
|
|
225
|
+
- [ ] Step 6 — Broker route pattern (mirror `tradestation.ts`)
|
|
226
|
+
- [ ] Step 7 — Tests
|
|
227
|
+
- [ ] Step 8 — Example config
|
|
242
228
|
- [ ] `bun run check` passes
|
|
243
229
|
- [ ] No secrets or `.env` files committed
|
|
244
230
|
- [ ] No unrelated files modified
|
|
@@ -248,17 +234,17 @@ No new HTTP API endpoints. Profile activation is done via:
|
|
|
248
234
|
|
|
249
235
|
## Open Questions
|
|
250
236
|
|
|
251
|
-
- [ ] Should `dm_auto_space` auto-activate
|
|
252
|
-
- [ ] How does `
|
|
237
|
+
- [ ] Should `dm_auto_space` auto-activate the profile for new customer spaces (`dm_auto_space.profile: barber-appointments`)? — owner: TBD
|
|
238
|
+
- [ ] How does `callerId` map to appointment ownership — phone number as-is, or a registration/name step? — owner: profile developer (private repo)
|
|
239
|
+
- [ ] Broker routes: generic `POST /api/profile/:capability/:action` mounting vs per-profile route files? — owner: TBD
|
|
253
240
|
|
|
254
241
|
---
|
|
255
242
|
|
|
256
243
|
## Retrospective
|
|
257
244
|
|
|
258
|
-
> Fill this section when archiving.
|
|
259
|
-
|
|
260
245
|
**Residual risks / follow-ups:**
|
|
261
|
-
-
|
|
246
|
+
- Per-turn caller token is a hard dependency for safe ownership → backlog/caller-bound-capability-token.md
|
|
247
|
+
- Barber business logic tracked in the private profiles repo (out of this repo's scope) — accepted
|
|
262
248
|
|
|
263
249
|
**What changed from the plan:**
|
|
264
250
|
- ...
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Caller-Bound Capability Token
|
|
2
|
+
|
|
3
|
+
**Status**: Backlog
|
|
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
|
+
- [ ] Step 1 — Token helpers
|
|
148
|
+
- [ ] Step 2 — Mint at spawn
|
|
149
|
+
- [ ] Step 3 — Forward in mrctl
|
|
150
|
+
- [ ] Step 4 — Verify host-side
|
|
151
|
+
- [ ] Step 5 — Migrate TradeStation guard
|
|
152
|
+
- [ ] Step 6 — Tests
|
|
153
|
+
- [ ] `bun run check` passes
|
|
154
|
+
- [ ] 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
|
+
- ...
|
|
172
|
+
|
|
173
|
+
**Key decisions made during implementation:**
|
|
174
|
+
- ...
|
|
175
|
+
|
|
176
|
+
**Architecture impact:**
|
|
177
|
+
- [ ] New package/module added
|
|
178
|
+
- [ ] New data model or schema change
|
|
179
|
+
- [ ] New inter-package interaction
|
|
180
|
+
- [ ] Deployment topology changed
|
|
@@ -0,0 +1,85 @@
|
|
|
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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: business-extensions
|
|
3
|
+
description: Dedicated business extensions that wrap raw capabilities (GWS, etc.) with deterministic domain logic — e.g. a barber-appointments extension
|
|
4
|
+
metadata:
|
|
5
|
+
type: idea
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Business Extensions
|
|
9
|
+
|
|
10
|
+
**Status**: Idea
|
|
11
|
+
**Created**: 2026-07-01
|
|
12
|
+
|
|
13
|
+
## Problem
|
|
14
|
+
|
|
15
|
+
Raw capability extensions (GWS Calendar, Gmail, web search) expose generic APIs. Business workflows (appointment booking, customer management) require deterministic logic — availability checks, double-booking prevention, confirmation flows — that can't be reliably delegated to the LLM via prompting alone.
|
|
16
|
+
|
|
17
|
+
Today the only options are: (1) let the LLM orchestrate raw APIs via system prompt instructions (not deterministic enough), or (2) build a full standalone application outside Mercury. Neither is ideal.
|
|
18
|
+
|
|
19
|
+
## Idea
|
|
20
|
+
|
|
21
|
+
A pattern for **business extensions** that wrap raw capabilities with domain-specific, deterministic logic and expose simplified CLIs to the agent.
|
|
22
|
+
|
|
23
|
+
Example: `barber-appointments` extension
|
|
24
|
+
- Wraps GWS Calendar internally
|
|
25
|
+
- Exposes: `book-appointment`, `list-availability`, `cancel-appointment`
|
|
26
|
+
- Enforces business rules in code: no double-booking, business hours only, minimum slot duration, confirmation required
|
|
27
|
+
- Registers as a single Mercury permission (`barber-appointments`)
|
|
28
|
+
- The LLM calls the simplified CLI; the extension handles the hard logic deterministically
|
|
29
|
+
|
|
30
|
+
This separates concerns:
|
|
31
|
+
- **Mercury** = platform (messaging, spaces, permissions, rate limits)
|
|
32
|
+
- **Capability extensions** = raw API access (GWS, web search, etc.) — admin-only or building blocks
|
|
33
|
+
- **Business extensions** = deterministic domain logic wrapping capabilities — customer-facing
|
|
34
|
+
|
|
35
|
+
## Why not just prompt engineering?
|
|
36
|
+
|
|
37
|
+
LLMs are non-deterministic. For a barber shop, a double-booked appointment or a cancelled-without-confirmation is a real-world problem. Business rules must be enforced in code, not hoped for via prompts.
|
|
38
|
+
|
|
39
|
+
## Open questions
|
|
40
|
+
|
|
41
|
+
- Should business extensions be a separate category in the extension system, or just a convention?
|
|
42
|
+
- How do business extensions declare their dependency on capability extensions (e.g., `barber-appointments` needs `gws`)?
|
|
43
|
+
- Should there be a template/scaffold for creating business extensions?
|
|
44
|
+
- How does this relate to "profiles" — is a profile = business extension + config preset?
|
|
45
|
+
|
|
46
|
+
## First candidate
|
|
47
|
+
|
|
48
|
+
`barber-appointments` — Calendar-based appointment booking for a barber shop. Quick win for first customer impression. Gmail integration deferred.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# GWS OAuth Setup Runbook
|
|
2
|
+
|
|
3
|
+
How to connect Google Calendar to a customer's Mercury assistant.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Access to the Tagula GCP project (console.cloud.google.com, project "Tagula")
|
|
8
|
+
- SSH access to the customer's VPS
|
|
9
|
+
- The customer's `MERCURY_API_SECRET` (from their `.env`)
|
|
10
|
+
- The customer's Google account email
|
|
11
|
+
|
|
12
|
+
## Option A: Auth Gateway (preferred — works on mobile + desktop)
|
|
13
|
+
|
|
14
|
+
Use this when `auth.tagula.ai` is set up. Customer just clicks a link.
|
|
15
|
+
|
|
16
|
+
### One-time gateway setup
|
|
17
|
+
|
|
18
|
+
> Skip if the gateway is already running at `auth.tagula.ai`.
|
|
19
|
+
|
|
20
|
+
1. **DNS**: Add an A record for `auth.tagula.ai` → gateway server IP (GoDaddy DNS panel)
|
|
21
|
+
|
|
22
|
+
2. **Server**: SSH into the gateway server and install nginx + certbot:
|
|
23
|
+
```bash
|
|
24
|
+
apt-get update && apt-get install -y nginx certbot python3-certbot-nginx
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. **Nginx config** (`/etc/nginx/sites-available/auth-tagula`):
|
|
28
|
+
```nginx
|
|
29
|
+
server {
|
|
30
|
+
listen 80;
|
|
31
|
+
server_name auth.tagula.ai;
|
|
32
|
+
location / {
|
|
33
|
+
proxy_pass http://127.0.0.1:3456;
|
|
34
|
+
proxy_set_header Host $host;
|
|
35
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
```bash
|
|
40
|
+
ln -s /etc/nginx/sites-available/auth-tagula /etc/nginx/sites-enabled/
|
|
41
|
+
nginx -t && systemctl reload nginx
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
4. **SSL**:
|
|
45
|
+
```bash
|
|
46
|
+
certbot --nginx -d auth.tagula.ai
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
5. **Gateway config**: Copy `gateway-config.example.json` → `gateway-config.json`, fill in:
|
|
50
|
+
- `google.clientId` and `google.clientSecret` from the Tagula GCP project
|
|
51
|
+
- `google.redirectUri` = `https://auth.tagula.ai/oauth/callback`
|
|
52
|
+
|
|
53
|
+
6. **GCP redirect URI**: In GCP Console → APIs & Services → Credentials → Web client → add `https://auth.tagula.ai/oauth/callback` to Authorized redirect URIs
|
|
54
|
+
|
|
55
|
+
7. **Start gateway**:
|
|
56
|
+
```bash
|
|
57
|
+
npm install -g pm2 # if not installed
|
|
58
|
+
pm2 start auth-gateway.js --name auth-gateway
|
|
59
|
+
pm2 save
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Per-customer setup
|
|
63
|
+
|
|
64
|
+
1. **Add customer to gateway config** (`gateway-config.json`):
|
|
65
|
+
```json
|
|
66
|
+
"assistants": {
|
|
67
|
+
"customer-name": {
|
|
68
|
+
"apiUrl": "http://<VPS_IP>:8787",
|
|
69
|
+
"apiSecret": "<MERCURY_API_SECRET from customer's .env>",
|
|
70
|
+
"envVar": "MERCURY_GWS_CREDENTIALS_JSON"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
Restart gateway: `pm2 restart auth-gateway`
|
|
75
|
+
|
|
76
|
+
2. **Add test user in GCP**: GCP Console → Google Auth Platform → Audience → Add users → add customer's Gmail
|
|
77
|
+
|
|
78
|
+
3. **Install gws extension on customer VPS** (if not already):
|
|
79
|
+
```bash
|
|
80
|
+
ssh root@<VPS_IP>
|
|
81
|
+
# Via dashboard: go to http://<VPS_IP>:8787/dashboard/page/features → Install gws
|
|
82
|
+
# Or via API:
|
|
83
|
+
curl -X POST http://localhost:8787/api/console/extensions/install \
|
|
84
|
+
-H "Authorization: Bearer <API_SECRET>" \
|
|
85
|
+
-H "Content-Type: application/json" \
|
|
86
|
+
-d '{"catalogName": "gws"}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
4. **Send the link to customer**:
|
|
90
|
+
```
|
|
91
|
+
https://auth.tagula.ai/connect/gws?assistant=customer-name
|
|
92
|
+
```
|
|
93
|
+
Customer clicks → signs in with Google → sees "Connected!" → done.
|
|
94
|
+
|
|
95
|
+
5. **Verify**: Check Mercury logs on the VPS:
|
|
96
|
+
```bash
|
|
97
|
+
pm2 logs 'mercury run' --lines 20 --nostream | grep -i gws
|
|
98
|
+
```
|
|
99
|
+
Should show: `Loaded extension: gws` and `Installed skill: gws`
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Option B: Manual (ngrok — no gateway needed)
|
|
104
|
+
|
|
105
|
+
Use this before the gateway is set up, or as a one-off.
|
|
106
|
+
|
|
107
|
+
### Setup
|
|
108
|
+
|
|
109
|
+
1. **Install ngrok on customer VPS**:
|
|
110
|
+
```bash
|
|
111
|
+
ssh root@<VPS_IP>
|
|
112
|
+
curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
|
|
113
|
+
| tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
|
|
114
|
+
echo 'deb https://ngrok-agent.s3.amazonaws.com buster main' \
|
|
115
|
+
| tee /etc/apt/sources.list.d/ngrok.list
|
|
116
|
+
apt-get update && apt-get install -y ngrok
|
|
117
|
+
ngrok config add-authtoken <YOUR_NGROK_TOKEN>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
2. **Write callback script** (`/tmp/oauth-callback.js`):
|
|
121
|
+
```bash
|
|
122
|
+
# Copy from tagula-agents-cloud-console/oauth-callback-standalone.js
|
|
123
|
+
# Update CLIENT_ID and CLIENT_SECRET from GCP
|
|
124
|
+
scp oauth-callback-standalone.js root@<VPS_IP>:/tmp/oauth-callback.js
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
3. **Start callback + ngrok**:
|
|
128
|
+
```bash
|
|
129
|
+
ssh root@<VPS_IP>
|
|
130
|
+
nohup node /tmp/oauth-callback.js > /tmp/oauth-callback.log 2>&1 &
|
|
131
|
+
ngrok http 9999
|
|
132
|
+
```
|
|
133
|
+
Note the ngrok URL (e.g., `https://abc123.ngrok-free.dev`)
|
|
134
|
+
|
|
135
|
+
4. **Configure redirect URI**:
|
|
136
|
+
- Set redirect URI in the callback script:
|
|
137
|
+
```bash
|
|
138
|
+
curl "http://localhost:9999/set-redirect?uri=https://<NGROK_URL>/oauth/callback"
|
|
139
|
+
```
|
|
140
|
+
- Add same URL to GCP: APIs & Services → Credentials → Web client → Authorized redirect URIs
|
|
141
|
+
|
|
142
|
+
5. **Add test user**: GCP → Google Auth Platform → Audience → Add customer's Gmail
|
|
143
|
+
|
|
144
|
+
6. **Send OAuth link to customer**:
|
|
145
|
+
```
|
|
146
|
+
https://accounts.google.com/o/oauth2/v2/auth?client_id=<CLIENT_ID>&redirect_uri=https://<NGROK_URL>/oauth/callback&response_type=code&scope=https://www.googleapis.com/auth/calendar.events&access_type=offline&prompt=consent
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
7. **After customer authorizes**:
|
|
150
|
+
- ngrok free tier shows an interstitial — customer clicks "Visit Site"
|
|
151
|
+
- Check logs: `cat /tmp/oauth-callback.log`
|
|
152
|
+
- Should show `SUCCESS - credentials saved`
|
|
153
|
+
|
|
154
|
+
8. **Move credentials to correct .env** (the callback script writes to `.mercury/.env` but Mercury reads from project root `.env`):
|
|
155
|
+
```bash
|
|
156
|
+
grep MERCURY_GWS /opt/path/to/assistant/.mercury/.env >> /opt/path/to/assistant/.env
|
|
157
|
+
sed -i '/MERCURY_GWS/d' /opt/path/to/assistant/.mercury/.env
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
9. **Restart Mercury and clean up**:
|
|
161
|
+
```bash
|
|
162
|
+
pm2 restart 'mercury run'
|
|
163
|
+
# Kill callback server and ngrok
|
|
164
|
+
kill $(lsof -ti:9999) 2>/dev/null
|
|
165
|
+
pkill ngrok
|
|
166
|
+
rm /tmp/oauth-callback.js /tmp/oauth-callback.log
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
10. **Verify**:
|
|
170
|
+
```bash
|
|
171
|
+
pm2 logs 'mercury run' --lines 20 --nostream | grep -i gws
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Option C: Copy-paste URL (desktop only)
|
|
177
|
+
|
|
178
|
+
Simplest, no infrastructure needed. Customer must be on a computer (not mobile).
|
|
179
|
+
|
|
180
|
+
1. **Create Desktop-type OAuth client** in GCP (not Web) — allows `http://localhost` redirect
|
|
181
|
+
2. **Generate OAuth URL** with `redirect_uri=http://localhost:1`
|
|
182
|
+
3. **Send link to customer** — they click, authorize, Google redirects to localhost (fails)
|
|
183
|
+
4. **Customer copies the full URL** from their browser bar and sends it back
|
|
184
|
+
5. **Extract code and exchange**:
|
|
185
|
+
```bash
|
|
186
|
+
# Extract code from the URL the customer sent
|
|
187
|
+
CODE="the_code_from_url"
|
|
188
|
+
curl -s -X POST https://oauth2.googleapis.com/token \
|
|
189
|
+
-d "code=$CODE&client_id=<CLIENT_ID>&client_secret=<CLIENT_SECRET>&redirect_uri=http://localhost:1&grant_type=authorization_code"
|
|
190
|
+
```
|
|
191
|
+
6. **Set env var and restart** — copy the tokens JSON into `.env` as `MERCURY_GWS_CREDENTIALS_JSON='...'`
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Troubleshooting
|
|
196
|
+
|
|
197
|
+
| Symptom | Cause | Fix |
|
|
198
|
+
|---------|-------|-----|
|
|
199
|
+
| `invalid_client` on Google consent | Client not propagated yet, or created in new Auth Platform UI | Wait 10 min, or recreate from legacy APIs & Services → Credentials |
|
|
200
|
+
| Extension installs but doesn't load | `MERCURY_GWS_CREDENTIALS_JSON` not set or in wrong `.env` | Check `grep MERCURY_GWS .env` in project root |
|
|
201
|
+
| `Removed stale skill: gws` in logs | Extension skipped by credential gate | Same as above — credential missing |
|
|
202
|
+
| `Failed to build derived image` | `docker-buildx` not installed | `apt-get install docker-buildx` |
|
|
203
|
+
| Customer's wrong Google account used | Browser auto-selects default account | Add all their accounts as test users, or use incognito |
|
|
204
|
+
| ngrok "Visit Site" interstitial | Free tier warning | Customer clicks "Visit Site" — shows once |
|
|
205
|
+
| `refresh_token_expires_in: 604799` | Token expires in 7 days (testing mode) | Publish the OAuth app in GCP to get non-expiring refresh tokens |
|
|
206
|
+
|
|
207
|
+
## Important notes
|
|
208
|
+
|
|
209
|
+
- **Test mode tokens expire in 7 days.** While the GCP app is in "Testing" status, refresh tokens expire after 7 days. To get permanent tokens, publish the app (requires Google verification for sensitive scopes). For now, re-run the OAuth flow when tokens expire.
|
|
210
|
+
- **docker-buildx** is required on Linux servers for Mercury to build derived images with extension CLIs. Install with `apt-get install docker-buildx`.
|
|
211
|
+
- **Rotate credentials** after sharing them in chat. Go to GCP → Credentials → Web client → rotate client secret. Update `gateway-config.json` and any callback scripts.
|
package/package.json
CHANGED
package/src/config-file.ts
CHANGED
|
@@ -29,7 +29,7 @@ const mercuryFileSchema = z
|
|
|
29
29
|
port: z.number().int().min(1).max(65535).optional(),
|
|
30
30
|
bot_username: z.string().optional(),
|
|
31
31
|
})
|
|
32
|
-
.
|
|
32
|
+
.strip()
|
|
33
33
|
.optional(),
|
|
34
34
|
|
|
35
35
|
model: z
|
|
@@ -48,7 +48,7 @@ const mercuryFileSchema = z
|
|
|
48
48
|
.optional(),
|
|
49
49
|
capabilities: z.record(z.string(), z.unknown()).optional(),
|
|
50
50
|
})
|
|
51
|
-
.
|
|
51
|
+
.strip()
|
|
52
52
|
.optional(),
|
|
53
53
|
|
|
54
54
|
/** Top-level alias for `model.chain` */
|
|
@@ -65,7 +65,7 @@ const mercuryFileSchema = z
|
|
|
65
65
|
whatsapp: z.boolean().optional(),
|
|
66
66
|
telegram: z.boolean().optional(),
|
|
67
67
|
})
|
|
68
|
-
.
|
|
68
|
+
.strip()
|
|
69
69
|
.optional(),
|
|
70
70
|
|
|
71
71
|
runtime: z
|
|
@@ -88,14 +88,14 @@ const mercuryFileSchema = z
|
|
|
88
88
|
rate_limit_daily_member: z.number().int().min(0).max(10000).optional(),
|
|
89
89
|
rate_limit_daily_admin: z.number().int().min(0).max(10000).optional(),
|
|
90
90
|
})
|
|
91
|
-
.
|
|
91
|
+
.strip()
|
|
92
92
|
.optional(),
|
|
93
93
|
|
|
94
94
|
scheduling: z
|
|
95
95
|
.object({
|
|
96
96
|
default_timezone: z.string().optional(),
|
|
97
97
|
})
|
|
98
|
-
.
|
|
98
|
+
.strip()
|
|
99
99
|
.optional(),
|
|
100
100
|
|
|
101
101
|
trigger: z
|
|
@@ -103,7 +103,7 @@ const mercuryFileSchema = z
|
|
|
103
103
|
patterns: z.string().optional(),
|
|
104
104
|
match: z.string().optional(),
|
|
105
105
|
})
|
|
106
|
-
.
|
|
106
|
+
.strip()
|
|
107
107
|
.optional(),
|
|
108
108
|
|
|
109
109
|
context: z
|
|
@@ -112,7 +112,7 @@ const mercuryFileSchema = z
|
|
|
112
112
|
window_size: z.number().int().min(1).max(50).optional(),
|
|
113
113
|
reply_chain_depth: z.number().int().min(1).max(50).optional(),
|
|
114
114
|
})
|
|
115
|
-
.
|
|
115
|
+
.strip()
|
|
116
116
|
.optional(),
|
|
117
117
|
|
|
118
118
|
agent: z
|
|
@@ -127,7 +127,7 @@ const mercuryFileSchema = z
|
|
|
127
127
|
container_bwrap_docker_compat: z.boolean().optional(),
|
|
128
128
|
override_pi_system_prompt: z.boolean().optional(),
|
|
129
129
|
})
|
|
130
|
-
.
|
|
130
|
+
.strip()
|
|
131
131
|
.optional(),
|
|
132
132
|
|
|
133
133
|
discord: z
|
|
@@ -139,14 +139,14 @@ const mercuryFileSchema = z
|
|
|
139
139
|
.max(60 * 60 * 1000)
|
|
140
140
|
.optional(),
|
|
141
141
|
})
|
|
142
|
-
.
|
|
142
|
+
.strip()
|
|
143
143
|
.optional(),
|
|
144
144
|
|
|
145
145
|
telegram: z
|
|
146
146
|
.object({
|
|
147
147
|
format_enabled: z.boolean().optional(),
|
|
148
148
|
})
|
|
149
|
-
.
|
|
149
|
+
.strip()
|
|
150
150
|
.optional(),
|
|
151
151
|
|
|
152
152
|
media: z
|
|
@@ -154,14 +154,14 @@ const mercuryFileSchema = z
|
|
|
154
154
|
enabled: z.boolean().optional(),
|
|
155
155
|
max_size_mb: z.number().min(1).max(100).optional(),
|
|
156
156
|
})
|
|
157
|
-
.
|
|
157
|
+
.strip()
|
|
158
158
|
.optional(),
|
|
159
159
|
|
|
160
160
|
permissions: z
|
|
161
161
|
.object({
|
|
162
162
|
admins: z.string().optional(),
|
|
163
163
|
})
|
|
164
|
-
.
|
|
164
|
+
.strip()
|
|
165
165
|
.optional(),
|
|
166
166
|
|
|
167
167
|
dm_auto_space: z
|
|
@@ -171,15 +171,109 @@ const mercuryFileSchema = z
|
|
|
171
171
|
default_system_prompt: z.string().optional(),
|
|
172
172
|
default_member_permissions: z.string().optional(),
|
|
173
173
|
})
|
|
174
|
-
.
|
|
174
|
+
.strip()
|
|
175
175
|
.optional(),
|
|
176
176
|
})
|
|
177
|
-
.
|
|
177
|
+
.strip();
|
|
178
178
|
|
|
179
179
|
type MercuryFile = z.infer<typeof mercuryFileSchema>;
|
|
180
180
|
|
|
181
181
|
export type RawMercuryConfigInput = Record<string, unknown>;
|
|
182
182
|
|
|
183
|
+
const KNOWN_TOP_KEYS = new Set([
|
|
184
|
+
"server",
|
|
185
|
+
"model",
|
|
186
|
+
"model_chain",
|
|
187
|
+
"ingress",
|
|
188
|
+
"runtime",
|
|
189
|
+
"scheduling",
|
|
190
|
+
"trigger",
|
|
191
|
+
"context",
|
|
192
|
+
"agent",
|
|
193
|
+
"discord",
|
|
194
|
+
"telegram",
|
|
195
|
+
"media",
|
|
196
|
+
"permissions",
|
|
197
|
+
"dm_auto_space",
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const KNOWN_SECTION_KEYS: Record<string, Set<string>> = {
|
|
201
|
+
server: new Set(["port", "bot_username"]),
|
|
202
|
+
model: new Set([
|
|
203
|
+
"chain",
|
|
204
|
+
"provider",
|
|
205
|
+
"model",
|
|
206
|
+
"fallback_provider",
|
|
207
|
+
"fallback",
|
|
208
|
+
"max_retries_per_leg",
|
|
209
|
+
"chain_budget_ms",
|
|
210
|
+
"capabilities",
|
|
211
|
+
]),
|
|
212
|
+
ingress: new Set(["discord", "slack", "teams", "whatsapp", "telegram"]),
|
|
213
|
+
runtime: new Set([
|
|
214
|
+
"data_dir",
|
|
215
|
+
"auth_path",
|
|
216
|
+
"whatsapp_auth_dir",
|
|
217
|
+
"max_concurrency",
|
|
218
|
+
"log_level",
|
|
219
|
+
"log_format",
|
|
220
|
+
"rate_limit_per_user",
|
|
221
|
+
"rate_limit_window_ms",
|
|
222
|
+
"rate_limit_daily_member",
|
|
223
|
+
"rate_limit_daily_admin",
|
|
224
|
+
]),
|
|
225
|
+
scheduling: new Set(["default_timezone"]),
|
|
226
|
+
trigger: new Set(["patterns", "match"]),
|
|
227
|
+
context: new Set(["mode", "window_size", "reply_chain_depth"]),
|
|
228
|
+
agent: new Set([
|
|
229
|
+
"image",
|
|
230
|
+
"container_timeout_ms",
|
|
231
|
+
"container_bwrap_docker_compat",
|
|
232
|
+
"override_pi_system_prompt",
|
|
233
|
+
]),
|
|
234
|
+
discord: new Set(["gateway_duration_ms"]),
|
|
235
|
+
telegram: new Set(["format_enabled"]),
|
|
236
|
+
media: new Set(["enabled", "max_size_mb"]),
|
|
237
|
+
permissions: new Set(["admins"]),
|
|
238
|
+
dm_auto_space: new Set([
|
|
239
|
+
"enabled",
|
|
240
|
+
"admin_ids",
|
|
241
|
+
"default_system_prompt",
|
|
242
|
+
"default_member_permissions",
|
|
243
|
+
]),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
function warnUnknownKeys(
|
|
247
|
+
rawYaml: Record<string, unknown>,
|
|
248
|
+
configPath: string,
|
|
249
|
+
log: (msg: string) => void,
|
|
250
|
+
): void {
|
|
251
|
+
for (const key of Object.keys(rawYaml)) {
|
|
252
|
+
if (!KNOWN_TOP_KEYS.has(key)) {
|
|
253
|
+
log(
|
|
254
|
+
`[WARN] Unknown key "${key}" in ${configPath} — ignored. Check spelling or update mercury-agent.`,
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const section = rawYaml[key];
|
|
259
|
+
const knownKeys = KNOWN_SECTION_KEYS[key];
|
|
260
|
+
if (
|
|
261
|
+
knownKeys &&
|
|
262
|
+
section != null &&
|
|
263
|
+
typeof section === "object" &&
|
|
264
|
+
!Array.isArray(section)
|
|
265
|
+
) {
|
|
266
|
+
for (const subKey of Object.keys(section)) {
|
|
267
|
+
if (!knownKeys.has(subKey)) {
|
|
268
|
+
log(
|
|
269
|
+
`[WARN] Unknown key "${key}.${subKey}" in ${configPath} — ignored. Check spelling or update mercury-agent.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
183
277
|
function resolveConfigPath(cwd: string): string | null {
|
|
184
278
|
const explicit = process.env.MERCURY_CONFIG_FILE;
|
|
185
279
|
if (explicit !== undefined) {
|
|
@@ -391,6 +485,7 @@ function envValueForSchema(
|
|
|
391
485
|
export function mergeRawMercuryConfig(
|
|
392
486
|
env: NodeJS.ProcessEnv = process.env,
|
|
393
487
|
cwd: string = process.cwd(),
|
|
488
|
+
log: (msg: string) => void = console.warn,
|
|
394
489
|
): RawMercuryConfigInput {
|
|
395
490
|
const configPath = resolveConfigPath(cwd);
|
|
396
491
|
let fromFile: RawMercuryConfigInput = {};
|
|
@@ -404,6 +499,11 @@ export function mergeRawMercuryConfig(
|
|
|
404
499
|
throw new Error(`Failed to read mercury config ${configPath}: ${msg}`);
|
|
405
500
|
}
|
|
406
501
|
if (rawYaml == null) rawYaml = {};
|
|
502
|
+
|
|
503
|
+
if (typeof rawYaml === "object" && !Array.isArray(rawYaml)) {
|
|
504
|
+
warnUnknownKeys(rawYaml as Record<string, unknown>, configPath, log);
|
|
505
|
+
}
|
|
506
|
+
|
|
407
507
|
const parsed = mercuryFileSchema.safeParse(rawYaml);
|
|
408
508
|
if (!parsed.success) {
|
|
409
509
|
const issues = parsed.error.issues
|
|
@@ -275,6 +275,38 @@ export function createConsoleApp(opts: {
|
|
|
275
275
|
return c.json({ connections });
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
/* ── Credential injection ────────────────────────────────────── */
|
|
279
|
+
|
|
280
|
+
app.post("/credentials", async (c) => {
|
|
281
|
+
const body = (await c.req.json().catch(() => ({}))) as {
|
|
282
|
+
envVar?: string;
|
|
283
|
+
value?: string;
|
|
284
|
+
restart?: boolean;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const envVar = typeof body.envVar === "string" ? body.envVar.trim() : "";
|
|
288
|
+
const value = typeof body.value === "string" ? body.value : "";
|
|
289
|
+
const shouldRestart = body.restart !== false;
|
|
290
|
+
|
|
291
|
+
if (!envVar || !/^[A-Z_][A-Z0-9_]*$/.test(envVar)) {
|
|
292
|
+
return c.json(
|
|
293
|
+
{ error: "envVar must be a valid SCREAMING_SNAKE_CASE name" },
|
|
294
|
+
400,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const envPath = path.join(opts.projectRoot, ".env");
|
|
299
|
+
updateDotEnv(envPath, { [envVar]: value || null });
|
|
300
|
+
logger.info(`Console: credential written`, { envVar, removed: !value });
|
|
301
|
+
|
|
302
|
+
if (shouldRestart && value) {
|
|
303
|
+
setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
|
|
304
|
+
return c.json({ ok: true, envVar, restarting: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return c.json({ ok: true, envVar, restarting: false });
|
|
308
|
+
});
|
|
309
|
+
|
|
278
310
|
/* ── Adapter management ──────────────────────────────────────── */
|
|
279
311
|
|
|
280
312
|
/** Return current adapter enable/disable state and credential presence. */
|