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 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) | | Generic profile layer for business logic scoping |
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. But it has no runtime behavior: it can't scope which capabilities members access, enforce data isolation (ownership), or declare capability dependencies. Business workflows (appointment booking, customer management) require deterministic logic that can't be reliably delegated to the LLM.
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 existing profile system with a **runtime contract** a profile declares what capabilities it wraps, what commands it exposes to members, and what isolation rules it enforces. Mercury provides the schema, loader, and runtime enforcement; profiles live in external repos. The barber-appointments profile is the first implementation to validate the design.
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 package from an external repo so my bot gains domain-specific business logic without modifying Mercury core.
19
- - As a barber shop owner, I want my customers (DM members) to book and cancel only their own appointments through the bot, without seeing other customers' schedules.
20
- - As a barber shop owner, I want my customers to have zero access to my Gmail, even though the profile uses my GWS credentials for calendar access internally.
21
- - As a profile developer, I want a clear schema/contract for defining profiles so I know what to implement (commands, permissions, capability dependencies, data isolation rules).
22
- - As a Mercury admin, I want raw capability extensions (GWS, web search) to remain admin-only while the profile's scoped commands are member-facing.
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 list for members), and `system_prompt` (profile-specific agent persona)
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 dependency validation — profile apply fails if required extensions aren't installed
30
- - Profile activation per space a space can have an active profile that governs its runtime behavior
31
- - `CALLER_ID` injection profile extensions receive the caller's identity so they can enforce ownership in their CLI code
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
- - Profile marketplace/registry profiles are installed manually (npm/bun add or git clone) for now
36
- - Profile versioning/migrationno schema version negotiation yet
37
- - Multi-profile per space one profile per space for MVP
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/registryprofiles are installed manually (git clone / package) for now
45
+ - Profile versioning/migrationno 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 data isolation rules — ownership enforcement is in profile extension code, not a schema declaration (too domain-specific for a generic schema in v1)
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
- > Key files to read before touching anything.
46
-
47
- - `src/core/profiles.ts` — Existing profile system: `profileSchema`, `loadProfileFromDir()`, `applyProfile()`, `resolveProfileSource()`. Bundles extensions + AGENTS.md + env + defaults into `.mercury/`
48
- - `src/extensions/types.ts` — Extension type system: `MercuryExtensionAPI`, `ExtensionMeta`, `PermissionDef`, `CliDef`, `EnvDef`. The API surface extensions use during setup
49
- - `src/core/permissions.ts` — Permission resolution: `getRolePermissions()`, `registerPermission()`, `resolveRole()`. Role-based (admin/member) per space with extension-registered defaults
50
- - `src/extensions/permission-guard.ts` — Container-side enforcement: reads `MERCURY_DENIED_CLIS` env var and blocks unauthorized CLI calls
51
- - `src/extensions/loader.ts` — `ExtensionRegistry` class: scans `.mercury/extensions/`, validates, runs setup functions, collects `ExtensionMeta`
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
- ### Data Models
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
- Extend the existing `profileSchema` in `src/core/profiles.ts`:
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 fields ...
65
- name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
66
- description: z.string().optional(),
67
- version: z.string().default("0.1.0"),
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
- New DB storage per-space active profile tracking via existing `space_config`:
81
- - Key: `profile.active` — value: profile name (e.g. `"barber-appointments"`)
82
- - Key: `profile.member_permissions` — value: comma-separated permission list from the profile manifest
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
- No new tables or migrations needed.
94
+ **`system_prompt` precedence** (highest wins): per-space `system_prompt` override → profile `system_prompt` → global AGENTS.md.
85
95
 
86
- ### How it works at runtime
96
+ ### Part 2 Host-side capability broker (the core security mechanism)
87
97
 
88
- 1. **Profile apply** (`applyProfile()` extended): copies extensions + AGENTS.md (existing behavior), then validates that all `capabilities` are installed as extensions, and stores the profile's `member_permissions` in a resolvable location.
98
+ This is the crux, and it reuses an existing, production-proven pattern rather than inventing one.
89
99
 
90
- 2. **Permission resolution** (`getRolePermissions()` extended): when a space has `profile.active` set, member permissions come from `profile.member_permissions` instead of the default `prompt + prefs.get + extension defaults`. Admin permissions are unchanged (all). This means raw capabilities (e.g., `gws`) stay admin-only unless the profile explicitly lists them in `member_permissions`.
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
- 3. **Capability scoping**: the profile's own extensions call `mercury.permission({ defaultRoles: ["member"] })` to make their commands member-accessible. Raw capability extensions (e.g., `gws`) are NOT in `member_permissions`, so their CLIs are denied by `permission-guard.ts`. The profile extension uses the raw capability internally (server-side hooks, or by passing credentials via env vars that only the extension code — not the container — can access).
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
- 4. **Data isolation / ownership**: handled in the profile extension's CLI code. The container already receives `CALLER_ID` (the platform user ID). The profile's CLI checks the caller against the data before acting (e.g., "does this appointment belong to this caller?"). This is intentionally not a generic schema it's too domain-specific.
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
- 5. **Gmail protection**: the `gws` extension exposes both Calendar and Gmail CLIs. When the barber profile is active, `member_permissions` does NOT include `gws`, so the permission guard blocks all `gws` CLI calls for members. The barber extension accesses Calendar server-side (via hooks or its own env vars) — members never touch GWS directly.
112
+ ### Part 3 Trustworthy caller identity (dependency)
97
113
 
98
- ### Example: barber-appointments profile
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
- capabilities:
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 customers book, view, and cancel
130
- their appointments. You can only help each customer with their own
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: "always"
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` with `capabilities`, `member_permissions`, `system_prompt`. Add validation in `applyProfile()` |
154
- | `src/core/permissions.ts` | Modified | Profile-aware `getRolePermissions()` — check `profile.member_permissions` for the space |
155
- | `src/config.ts` | Modified | Add `profile` config key for static profile activation |
156
- | `src/config-file.ts` | Modified | Add `profile` to YAML schema and env mapping |
157
- | `tests/applicative-profiles.test.ts` | New | Tests for profile permission scoping and capability validation |
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** — Add `capabilities`, `member_permissions`, `system_prompt` to `profileSchema` in `profiles.ts`. These are optional fields so existing profiles remain valid.
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` passes
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: `bun run typecheck` passes
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: `bun run typecheck` passes
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: `bun run typecheck` passes
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
- 6. **Write tests** — Profile permission scoping: member gets only profile-declared permissions; admin gets all; raw capability blocked for members. Capability validation: apply fails when required extension missing. Profile activation: space_config correctly set.
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` passes
188
- - Blocker: Steps 1-5
189
-
190
- 7. **Update example config** Add commented `profile` section to `mercury.example.yaml`.
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** — Mercury core provides the schema, loader, and runtime. No specific profile (barber, etc.) lives in this repo.
199
- - **Member permissions are exhaustive** — When a profile declares `member_permissions`, that is the COMPLETE set. No extension defaults are merged in. This prevents capability leakage.
200
- - **Admin permissions unchanged** — Admins always get all permissions regardless of profile. Profiles scope members only.
201
- - **Capability validation is strict** `applyProfile()` must fail loudly if a required capability extension is not installed. Silent degradation is not acceptable.
202
- - **Data isolation is code, not config** — Ownership enforcement happens in the profile extension's CLI code, not in a declarative schema. The extension checks `CALLER_ID` before acting.
203
- - **Backward compatible** — Existing profiles without the new fields continue to work exactly as before. All new fields are optional.
204
- - **No Gmail exposure** — When a profile wraps GWS for Calendar access, the raw `gws` CLI (which includes Gmail) must be blocked for members via permission scoping.
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
- | Profile declares `member_permissions: ["nonexistent"]` | `isValidPermission()` filters invalid names. Log a warning but don't crash the invalid permission is silently dropped. |
213
- | Profile requires capability `gws` but it's not installed | `applyProfile()` throws with a clear error listing missing capabilities. |
214
- | Admin manually overrides `role.member.permissions` per space | Per-space override in `space_config` takes precedence over profile defaults (existing behavior). Profile sets the baseline; admin can further restrict or expand. |
215
- | Profile is removed/changed but space_config still references it | `profile.active` becomes stale. Permission resolution still works from the stored `profile.member_permissions`. On next `applyProfile()` the values are updated. |
216
- | Two extensions declare the same CLI name | Existing loader behavior: second registration throws. No change needed. |
217
- | Profile extension tries to call raw capability CLI in container | Allowed only if the extension is running as admin (hooks run on host, not in container). Container CLI calls go through permission guard. |
218
- | Caller ID not available (e.g., system-triggered task) | `CALLER_ID` is already `"system"` for scheduled tasks. Profile extension should handle this gracefully (e.g., skip ownership check for system callers). |
219
- | Existing deployment upgrades Mercury with profiles feature | No behavioral change. `member_permissions` is optional; when absent, existing permission resolution applies. |
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 (fill before asking for approval)
210
+ ### Phase 1 — Goal
226
211
  - [x] Goal, User Stories, and MVP Scope written and reviewed
227
212
 
228
- ### Phase 2 — Architecture (fill after Goal approved)
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 (tick off as you go)
219
+ ### Implementation
235
220
  - [ ] Step 1 — Extend profile schema
236
- - [ ] Step 2 — Add capability validation
237
- - [ ] Step 3 — Store profile activation in space_config
238
- - [ ] Step 4 — Profile-aware permission resolution
239
- - [ ] Step 5 — System prompt injection
240
- - [ ] Step 6 — Write tests
241
- - [ ] Step 7 — Update example config
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 a profile for new customer spaces? (Likely yes — `dm_auto_space.profile: barber-appointments` config key) — owner: TBD
252
- - [ ] How does `CALLER_ID` map to appointment ownership? Phone number? Display name? A registration step? — owner: profile developer (barber repo)
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
- - none
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -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
- .strict()
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
- .strict()
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
- .strict()
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
- .strict()
91
+ .strip()
92
92
  .optional(),
93
93
 
94
94
  scheduling: z
95
95
  .object({
96
96
  default_timezone: z.string().optional(),
97
97
  })
98
- .strict()
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
- .strict()
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
- .strict()
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
- .strict()
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
- .strict()
142
+ .strip()
143
143
  .optional(),
144
144
 
145
145
  telegram: z
146
146
  .object({
147
147
  format_enabled: z.boolean().optional(),
148
148
  })
149
- .strict()
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
- .strict()
157
+ .strip()
158
158
  .optional(),
159
159
 
160
160
  permissions: z
161
161
  .object({
162
162
  admins: z.string().optional(),
163
163
  })
164
- .strict()
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
- .strict()
174
+ .strip()
175
175
  .optional(),
176
176
  })
177
- .strict();
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. */