mercury-agent 0.4.27 → 0.5.0
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/ARCHITECTURE.md +4 -0
- package/docs/ROADMAP.md +2 -1
- package/docs/archive/2026-07-01-applicative-profiles.md +270 -0
- package/docs/archive/2026-07-01-caller-bound-capability-token.md +184 -0
- package/docs/authoring-profiles.md +174 -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/pending-updates/applicative-profiles.md +15 -0
- package/docs/pending-updates/caller-bound-capability-token.md +15 -0
- package/docs/runbooks/gws-oauth-setup.md +211 -0
- package/package.json +1 -1
- package/resources/templates/mercury.example.yaml +99 -84
- package/src/agent/container-runner.ts +1346 -1323
- package/src/cli/mrctl.ts +636 -611
- package/src/config-file.ts +540 -437
- package/src/config.ts +339 -324
- package/src/core/api.ts +125 -105
- package/src/core/caller-token.ts +101 -0
- package/src/core/permissions.ts +228 -196
- package/src/core/profiles.ts +271 -245
- package/src/core/routes/capability.ts +70 -0
- package/src/core/routes/console.ts +32 -0
- package/src/core/routes/index.ts +15 -14
- package/src/core/runtime.ts +1530 -1516
- package/src/extensions/api.ts +273 -252
- package/src/extensions/loader.ts +286 -271
- package/src/extensions/types.ts +517 -462
- package/src/main.ts +605 -586
- package/docs/backlog/applicative-profiles.md +0 -273
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Authoring Applicative Profiles
|
|
2
|
+
|
|
3
|
+
The contract for building a **profile** — a package of deterministic business
|
|
4
|
+
logic that wraps raw capabilities (Calendar, email, …) and scopes what members
|
|
5
|
+
can do. Mercury (this repo) provides the schema, loader, permission scoping, and
|
|
6
|
+
the host-side capability broker; **profiles live in a separate repo** and depend
|
|
7
|
+
only on this contract.
|
|
8
|
+
|
|
9
|
+
> Layers: **Mercury** = platform (messaging, spaces, permissions, tokens,
|
|
10
|
+
> broker). **Capability extensions** = raw API access (e.g. `gws`), admin-only.
|
|
11
|
+
> **Profiles** = deterministic domain logic wrapping capabilities, member-facing.
|
|
12
|
+
|
|
13
|
+
## 1. Profile manifest — `mercury-profile.yaml`
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
name: barber-appointments # lowercase-kebab; required
|
|
17
|
+
description: Appointment booking for a barber shop
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
|
|
20
|
+
agents_md: ./AGENTS.md # optional; copied to the global AGENTS.md
|
|
21
|
+
|
|
22
|
+
# Raw capability extensions this profile requires to be installed. Validated at
|
|
23
|
+
# apply time — activation fails loudly if any are missing.
|
|
24
|
+
capabilities:
|
|
25
|
+
- gws
|
|
26
|
+
|
|
27
|
+
# Extensions this profile ships (copied into .mercury/extensions).
|
|
28
|
+
extensions:
|
|
29
|
+
- name: barber
|
|
30
|
+
source: ./extensions/barber
|
|
31
|
+
|
|
32
|
+
# EXHAUSTIVE member permission set while active. When present it REPLACES the
|
|
33
|
+
# default member permissions — nothing is merged in — so raw capabilities stay
|
|
34
|
+
# admin-only unless listed here. Omit entirely to keep built-in defaults.
|
|
35
|
+
member_permissions:
|
|
36
|
+
- prompt
|
|
37
|
+
- prefs.get
|
|
38
|
+
- barber # the profile's own capability, NOT gws
|
|
39
|
+
|
|
40
|
+
# Optional env the profile needs (host-side credentials for capabilities).
|
|
41
|
+
env:
|
|
42
|
+
- key: MERCURY_BARBER_BUSINESS_HOURS
|
|
43
|
+
description: "Business hours"
|
|
44
|
+
default: "09:00-18:00"
|
|
45
|
+
|
|
46
|
+
# Project-wide agent persona, injected into every container's system prompt.
|
|
47
|
+
system_prompt: |
|
|
48
|
+
You are a barber shop assistant. Help each customer book, view, and cancel
|
|
49
|
+
ONLY their own appointments. Never reveal other customers' schedules.
|
|
50
|
+
|
|
51
|
+
defaults:
|
|
52
|
+
trigger_patterns: always
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Apply it with `mercury profile apply <name|path|git-url>`. That validates
|
|
56
|
+
capabilities, copies extensions/AGENTS.md, and persists activation to
|
|
57
|
+
`.mercury/active-profile.json`, which Mercury loads at startup.
|
|
58
|
+
|
|
59
|
+
## 2. The capability broker (where the business logic runs)
|
|
60
|
+
|
|
61
|
+
A profile's privileged work runs **on the host**, so credentials never enter the
|
|
62
|
+
customer-controlled agent container. The profile's extension registers a
|
|
63
|
+
handler; the agent invokes it through a thin CLI.
|
|
64
|
+
|
|
65
|
+
### Register a handler (in the extension's `index.ts`)
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import type { ExtensionSetupFn } from "mercury-agent"; // types from this repo
|
|
69
|
+
|
|
70
|
+
const setup: ExtensionSetupFn = (mercury) => {
|
|
71
|
+
// Make the capability gate-able. Members get it via member_permissions.
|
|
72
|
+
mercury.permission({ defaultRoles: [] });
|
|
73
|
+
|
|
74
|
+
// Host-side credentials for the wrapped capability (never sent to the container).
|
|
75
|
+
mercury.env({ from: "MERCURY_BARBER_GOOGLE_CREDENTIALS" });
|
|
76
|
+
|
|
77
|
+
mercury.capability("barber", async (req, ctx) => {
|
|
78
|
+
// req.callerId is the TOKEN-DERIVED, unspoofable caller — safe for ownership.
|
|
79
|
+
switch (req.action) {
|
|
80
|
+
case "availability":
|
|
81
|
+
return { data: await listOpenSlots(req.body, ctx) };
|
|
82
|
+
case "book":
|
|
83
|
+
return { data: await book(req.callerId, req.body, ctx) };
|
|
84
|
+
case "cancel":
|
|
85
|
+
return { data: await cancelOwn(req.callerId, req.body, ctx) };
|
|
86
|
+
case "my-appointments":
|
|
87
|
+
return { data: await listOwn(req.callerId, ctx) };
|
|
88
|
+
default:
|
|
89
|
+
return { status: 400, data: { error: `unknown action: ${req.action}` } };
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default setup;
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### The contract types (exported from this repo)
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
type CapabilityHandler = (
|
|
101
|
+
req: CapabilityRequest,
|
|
102
|
+
ctx: MercuryExtensionContext,
|
|
103
|
+
) => Promise<CapabilityResult>;
|
|
104
|
+
|
|
105
|
+
interface CapabilityRequest {
|
|
106
|
+
name: string; // capability name
|
|
107
|
+
action: string; // sub-action (book, cancel, …)
|
|
108
|
+
callerId: string; // token-derived, TRUSTWORTHY — use for ownership checks
|
|
109
|
+
spaceId: string;
|
|
110
|
+
body: unknown; // parsed JSON request body (or null)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface CapabilityResult {
|
|
114
|
+
status?: number; // default 200
|
|
115
|
+
data: unknown; // JSON-serializable response
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`ctx` (`MercuryExtensionContext`) gives you `db`, `config`, `log`, and
|
|
120
|
+
`hasCallerPermission(spaceId, callerId, permission)`. Store per-profile state via
|
|
121
|
+
`mercury.store` (namespaced KV) or your own tables through `ctx.db`.
|
|
122
|
+
|
|
123
|
+
### How the agent invokes it
|
|
124
|
+
|
|
125
|
+
Inside the container the agent runs:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
mrctl capability barber book '{"slot":"2026-07-02T15:00"}'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
→ `POST /api/capability/barber/book`. Document these commands for the agent in
|
|
132
|
+
the extension's `SKILL.md` or the profile's `AGENTS.md`.
|
|
133
|
+
|
|
134
|
+
## 3. Permission & security model (non-negotiable)
|
|
135
|
+
|
|
136
|
+
- **`member_permissions` is exhaustive.** List every permission a member may
|
|
137
|
+
hold, including the capability name (e.g. `barber`). Anything not listed —
|
|
138
|
+
including raw capabilities like `gws` — is unavailable to members.
|
|
139
|
+
- **Authorization = permission named after the capability.** The broker route
|
|
140
|
+
requires the caller to hold the `<name>` permission; the same grant gates both
|
|
141
|
+
the `mrctl capability <name> …` CLI and the route. Keep capability name ==
|
|
142
|
+
permission name == the entry in `member_permissions`.
|
|
143
|
+
- **Credentials stay on the host.** Put capability credentials in host env
|
|
144
|
+
(`mercury.env`) / `mercury.store` and use them only inside the handler. Never
|
|
145
|
+
expose a raw capability CLI (e.g. `gws`) to members — its secret would be
|
|
146
|
+
readable inside the container.
|
|
147
|
+
- **Enforce ownership with `req.callerId`.** It is token-derived and cannot be
|
|
148
|
+
spoofed by the container. Never trust an id passed in `req.body`.
|
|
149
|
+
- **`callerId === "system"`** for scheduled/system runs — handle it explicitly
|
|
150
|
+
(e.g. skip per-customer ownership).
|
|
151
|
+
|
|
152
|
+
## 4. Suggested profile repo layout
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
<profile-repo>/
|
|
156
|
+
barber/ # one folder per profile
|
|
157
|
+
mercury-profile.yaml
|
|
158
|
+
AGENTS.md
|
|
159
|
+
extensions/
|
|
160
|
+
barber/
|
|
161
|
+
index.ts # registers permission + capability
|
|
162
|
+
package.json
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Depend on this repo (`mercury-agent`) for the types
|
|
166
|
+
(`ExtensionSetupFn`, `CapabilityRequest`, `CapabilityResult`,
|
|
167
|
+
`MercuryExtensionContext`). See `src/extensions/types.ts` for the full surface.
|
|
168
|
+
|
|
169
|
+
## 5. Local test loop
|
|
170
|
+
|
|
171
|
+
1. `mercury profile apply ./barber`
|
|
172
|
+
2. `mercury service install` (builds the derived image with the extension CLI)
|
|
173
|
+
3. DM the bot as a non-admin number; confirm you can `book`/`cancel` only your
|
|
174
|
+
own appointments and cannot reach `gws`/Gmail.
|
|
@@ -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,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: applicative-profiles
|
|
3
|
+
shipped: 2026-07-01
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## ROADMAP changes
|
|
7
|
+
|
|
8
|
+
**Remove from:** Next (table row where Item = applicative-profiles)
|
|
9
|
+
|
|
10
|
+
**Also pull:** none
|
|
11
|
+
|
|
12
|
+
**Add to Shipped table** (insert at top, after header row):
|
|
13
|
+
| **applicative-profiles** | 2026-07-01 | Generic profile layer: manifest schema, exhaustive member scoping, host-side capability broker |
|
|
14
|
+
|
|
15
|
+
**Milestone Map:** none
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: caller-bound-capability-token
|
|
3
|
+
shipped: 2026-07-01
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## ROADMAP changes
|
|
7
|
+
|
|
8
|
+
**Remove from:** Next (table row where Item = caller-bound-capability-token)
|
|
9
|
+
|
|
10
|
+
**Also pull:** none
|
|
11
|
+
|
|
12
|
+
**Add to Shipped table** (insert at top, after header row):
|
|
13
|
+
| **caller-bound-capability-token** | 2026-07-01 | Per-turn unspoofable caller token for host API auth; also hardens TradeStation |
|
|
14
|
+
|
|
15
|
+
**Milestone Map:** none
|
|
@@ -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.
|