mercury-agent 0.11.0 → 0.12.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 CHANGED
@@ -361,8 +361,8 @@ Optional project file **`mercury.yaml`** (or **`mercury.yml`**) supplies non-sec
361
361
 
362
362
  | Variable | Description |
363
363
  |----------|-------------|
364
- | `MERCURY_API_SECRET` | Shared secret for `/api/*` routes. When set, requires `Authorization: Bearer <secret>`. Auto-generated by `mercury setup`. |
365
- | `MERCURY_CHAT_API_KEY` | Optional API key for `/chat` endpoint. When set, requires Bearer token. When unset, `/chat` is open (for local use). |
364
+ | `MERCURY_API_SECRET` | Shared secret for `/api/*`, `/dashboard/*` and `/chat`. Requires `Authorization: Bearer <secret>`; when unset those routes refuse to serve (503). Auto-generated by `mercury setup`. |
365
+ | `MERCURY_CHAT_API_KEY` | Optional dedicated key for the `/chat` endpoint. Falls back to `MERCURY_API_SECRET` when unset; with neither configured, `/chat` returns 503. |
366
366
 
367
367
  **Auth:**
368
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -2058,6 +2058,18 @@ program
2058
2058
  }
2059
2059
 
2060
2060
  const url = `http://localhost:${options.port}/chat`;
2061
+
2062
+ // /chat fails closed: it requires MERCURY_CHAT_API_KEY (or falls back
2063
+ // to MERCURY_API_SECRET). Resolve the key the way the running service
2064
+ // does: `.env` values win over process env.
2065
+ const chatEnvPath = join(CWD, ".env");
2066
+ const chatEnvVars = existsSync(chatEnvPath)
2067
+ ? loadEnvFile(chatEnvPath)
2068
+ : {};
2069
+ const chatEnv = { ...process.env, ...chatEnvVars };
2070
+ const chatKey =
2071
+ chatEnv.MERCURY_CHAT_API_KEY || chatEnv.MERCURY_API_SECRET;
2072
+
2061
2073
  const body: Record<string, unknown> = {
2062
2074
  text,
2063
2075
  callerId: options.caller,
@@ -2087,7 +2099,10 @@ program
2087
2099
  try {
2088
2100
  const res = await fetch(url, {
2089
2101
  method: "POST",
2090
- headers: { "Content-Type": "application/json" },
2102
+ headers: {
2103
+ "Content-Type": "application/json",
2104
+ ...(chatKey ? { authorization: `Bearer ${chatKey}` } : {}),
2105
+ },
2091
2106
  body: JSON.stringify(body),
2092
2107
  });
2093
2108
 
package/src/config.ts CHANGED
@@ -248,7 +248,11 @@ const schema = z.object({
248
248
  profile: z.string().optional(),
249
249
 
250
250
  // ─── Security ─────────────────────────────────────────────────────
251
- /** Shared secret for API authentication. Required for /api/* routes. */
251
+ /**
252
+ * Shared secret for API authentication. Required for /api/* routes —
253
+ * when unset, /api/* (and /chat, absent a chatApiKey) refuse to serve
254
+ * with 503 rather than running unauthenticated.
255
+ */
252
256
  apiSecret: z.string().optional(),
253
257
  /**
254
258
  * Host-only HMAC key for signing per-turn caller tokens. Never injected into
@@ -257,7 +261,10 @@ const schema = z.object({
257
261
  * minting and verification run in separate processes.
258
262
  */
259
263
  callerTokenKey: z.string().optional(),
260
- /** Optional API key for the /chat endpoint. When unset, /chat is open (for local use). */
264
+ /**
265
+ * API key for the /chat endpoint. Falls back to apiSecret when unset;
266
+ * with neither configured, /chat refuses to serve (503).
267
+ */
261
268
  chatApiKey: z.string().optional(),
262
269
  /**
263
270
  * URL of the Mercury Cloud Console managing this agent (e.g. "https://console.mercury.app").
package/src/core/api.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { timingSafeEqual } from "node:crypto";
2
1
  import { Hono } from "hono";
2
+ import { logger } from "../logger.js";
3
3
  import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
+ import { authorizeApiSecret } from "./auth.js";
4
5
  import { verifyCallerToken } from "./caller-token.js";
5
6
  import { resolveRole } from "./permissions.js";
6
7
  import {
@@ -25,11 +26,6 @@ import {
25
26
  tts,
26
27
  } from "./routes/index.js";
27
28
 
28
- function safeCompare(a: string, b: string): boolean {
29
- if (a.length !== b.length) return false;
30
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
31
- }
32
-
33
29
  // ─── App Factory ──────────────────────────────────────────────────────────
34
30
 
35
31
  export function createApiApp(apiCtx: ApiContext): Hono<Env> {
@@ -38,17 +34,27 @@ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
38
34
  // ─── Auth Middleware ────────────────────────────────────────────────────
39
35
 
40
36
  app.use("*", async (c, next) => {
41
- // Validate API secret when configured
42
- const secret = apiCtx.config.apiSecret;
43
- if (secret) {
44
- const authHeader = c.req.header("authorization");
45
- const token = authHeader?.startsWith("Bearer ")
46
- ? authHeader.slice(7)
47
- : undefined;
48
-
49
- if (!token || !safeCompare(token, secret)) {
50
- return c.json({ error: "Unauthorized" }, 401);
51
- }
37
+ // Validate API secret. Fails closed: an agent host started without
38
+ // MERCURY_API_SECRET refuses to serve the control plane rather than
39
+ // serving it unauthenticated (auth guards gate on the ABSENCE of the
40
+ // secret, never wrap the comparison in a presence check).
41
+ const auth = authorizeApiSecret(
42
+ c.req.header("authorization"),
43
+ apiCtx.config.apiSecret,
44
+ );
45
+ if (!auth.ok) {
46
+ logger.warn("API auth denied", {
47
+ status: auth.status,
48
+ path: c.req.path,
49
+ callerId: c.req.header("x-mercury-caller"),
50
+ spaceId: c.req.header("x-mercury-space"),
51
+ });
52
+ return c.json(
53
+ auth.status === 503
54
+ ? { error: "MERCURY_API_SECRET must be set for /api" }
55
+ : { error: "Unauthorized" },
56
+ auth.status,
57
+ );
52
58
  }
53
59
 
54
60
  // Resolve caller identity. A per-turn caller token (minted host-side at
@@ -0,0 +1,28 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Authorize a request against a shared Bearer secret. Fails closed: returns
5
+ * 503 when no secret is configured (an endpoint must never silently run
6
+ * unauthenticated), 401 on a missing or mismatched token, `ok` when valid.
7
+ * Length is checked before `timingSafeEqual` (which throws on unequal buffer
8
+ * lengths); the comparison itself is constant-time.
9
+ *
10
+ * Single implementation for every secret-gated surface: `/api/*`, `/chat`,
11
+ * `/dashboard/*`, `/pre-build-ext-image`.
12
+ */
13
+ export function authorizeApiSecret(
14
+ authHeader: string | undefined,
15
+ secret: string | undefined,
16
+ ): { ok: true } | { ok: false; status: 401 | 503 } {
17
+ if (!secret) return { ok: false, status: 503 };
18
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : "";
19
+ const tokenBuf = Buffer.from(token);
20
+ const secretBuf = Buffer.from(secret);
21
+ if (
22
+ tokenBuf.length !== secretBuf.length ||
23
+ !timingSafeEqual(tokenBuf, secretBuf)
24
+ ) {
25
+ return { ok: false, status: 401 };
26
+ }
27
+ return { ok: true };
28
+ }
@@ -1,10 +1,10 @@
1
- import { timingSafeEqual } from "node:crypto";
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import { Hono } from "hono";
5
4
  import { logger } from "../../logger.js";
6
5
  import { ensureSpaceWorkspace } from "../../storage/memory.js";
7
6
  import type { IngressMessage, MessageAttachment } from "../../types.js";
7
+ import { authorizeApiSecret } from "../auth.js";
8
8
  import { type AutoSpaceConfig, resolveConversation } from "../conversation.js";
9
9
  import { extToMime, mimeToMediaType } from "../media.js";
10
10
  import type { MercuryCoreRuntime } from "../runtime.js";
@@ -44,23 +44,28 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
44
44
  return c.json({ error: "Content-Type must be application/json" }, 415);
45
45
  }
46
46
 
47
- // Validate chat API key when configured
48
- const chatApiKey = core.config.chatApiKey;
49
- let authenticated = false;
50
- if (chatApiKey) {
51
- const authHeader = c.req.header("authorization");
52
- const token = authHeader?.startsWith("Bearer ")
53
- ? authHeader.slice(7)
54
- : undefined;
55
-
56
- if (
57
- !token ||
58
- token.length !== chatApiKey.length ||
59
- !timingSafeEqual(Buffer.from(token), Buffer.from(chatApiKey))
60
- ) {
61
- return c.json({ error: "Unauthorized" }, 401);
62
- }
63
- authenticated = true;
47
+ // Validate the chat API key, falling back to the API secret when no
48
+ // dedicated key is configured — the fallback is another secret, never
49
+ // no-auth. Fails closed: with neither configured, /chat refuses to serve
50
+ // (503) instead of invoking a paid model unauthenticated. `||`, not `??`:
51
+ // a present-but-blank MERCURY_CHAT_API_KEY= in .env yields "", which must
52
+ // fall back to the API secret, not lock /chat at 503.
53
+ const chatApiKey = core.config.chatApiKey || core.config.apiSecret;
54
+ const auth = authorizeApiSecret(c.req.header("authorization"), chatApiKey);
55
+ if (!auth.ok) {
56
+ logger.warn("Chat auth denied", {
57
+ status: auth.status,
58
+ path: c.req.path,
59
+ });
60
+ return c.json(
61
+ auth.status === 503
62
+ ? {
63
+ error:
64
+ "MERCURY_CHAT_API_KEY or MERCURY_API_SECRET must be set for /chat",
65
+ }
66
+ : { error: "Unauthorized" },
67
+ auth.status,
68
+ );
64
69
  }
65
70
 
66
71
  const body = await c.req.json().catch(() => null);
@@ -164,15 +169,14 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
164
169
  }
165
170
  }
166
171
 
167
- if (authenticated) {
168
- // Fires at most once per actual override: after the re-promotion the
169
- // row is admin again, so later requests return an empty list.
170
- for (const id of core.db.seedAdmins(spaceId, [callerId])) {
171
- logger.warn(
172
- "Authenticated chat caller re-promoted: stored role overridden",
173
- { spaceId, callerId: id },
174
- );
175
- }
172
+ // Every request past the guard above is authenticated.
173
+ // Fires at most once per actual override: after the re-promotion the
174
+ // row is admin again, so later requests return an empty list.
175
+ for (const id of core.db.seedAdmins(spaceId, [callerId])) {
176
+ logger.warn(
177
+ "Authenticated chat caller re-promoted: stored role overridden",
178
+ { spaceId, callerId: id },
179
+ );
176
180
  }
177
181
 
178
182
  const ingress: IngressMessage = {
package/src/main.ts CHANGED
@@ -133,6 +133,12 @@ async function main() {
133
133
  port: config.port,
134
134
  });
135
135
 
136
+ if (!config.apiSecret) {
137
+ logger.warn(
138
+ "MERCURY_API_SECRET is not set — /api/*, /dashboard/* and /chat will refuse to serve (503) until it is configured",
139
+ );
140
+ }
141
+
136
142
  // ─── Inner-container API unix socket (gVisor only) ─────────────────────
137
143
  // In runsc mode the outer container leaves docker0, so inner containers can no
138
144
  // longer reach the API over TCP. They reach it via a per-container unix socket
package/src/server.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { timingSafeEqual } from "node:crypto";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { dirname, join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
@@ -8,6 +7,7 @@ import type { WhatsAppBaileysAdapter } from "./adapters/whatsapp.js";
8
7
  import type { AppConfig } from "./config.js";
9
8
  import { resolveProjectPath } from "./config.js";
10
9
  import { createApiApp } from "./core/api.js";
10
+ import { authorizeApiSecret } from "./core/auth.js";
11
11
  import { createChatRoute } from "./core/routes/chat.js";
12
12
  import { createConsoleApp } from "./core/routes/console.js";
13
13
  import { createDashboardRoutes } from "./core/routes/dashboard.js";
@@ -43,30 +43,9 @@ export interface ServerContext {
43
43
  packageRoot: string;
44
44
  }
45
45
 
46
- /**
47
- * Authorize an infra request against the `MERCURY_API_SECRET` Bearer token —
48
- * the same secret enforced by `/api/*` and `/api/console/*`. Returns 503 when no
49
- * secret is configured (a side-effecting endpoint must never silently run
50
- * unauthenticated), 401 on a missing or mismatched token, `ok` when valid.
51
- * Length is checked before `timingSafeEqual` (which throws on unequal buffer
52
- * lengths); the comparison itself is constant-time. Exported for testing.
53
- */
54
- export function authorizeApiSecret(
55
- authHeader: string | undefined,
56
- secret: string | undefined,
57
- ): { ok: true } | { ok: false; status: 401 | 503 } {
58
- if (!secret) return { ok: false, status: 503 };
59
- const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : "";
60
- const tokenBuf = Buffer.from(token);
61
- const secretBuf = Buffer.from(secret);
62
- if (
63
- tokenBuf.length !== secretBuf.length ||
64
- !timingSafeEqual(tokenBuf, secretBuf)
65
- ) {
66
- return { ok: false, status: 401 };
67
- }
68
- return { ok: true };
69
- }
46
+ // Shared fail-closed Bearer-secret guard — lives in core/auth.ts; re-exported
47
+ // so existing importers (tests, tooling) keep working after the move.
48
+ export { authorizeApiSecret };
70
49
 
71
50
  export function createApp(ctx: ServerContext): Hono {
72
51
  const {
@@ -167,19 +146,26 @@ export function createApp(ctx: ServerContext): Hono {
167
146
  packageRoot,
168
147
  });
169
148
 
170
- // Login route — validates token, sets session cookie, redirects to dashboard
149
+ // Login route — validates token, sets session cookie, redirects to dashboard.
150
+ // Fails closed like every other secret-gated surface: no configured secret
151
+ // means 503, never an unauthenticated session.
171
152
  app.get("/dashboard/login", (c) => {
172
- const secret = config.apiSecret;
173
- if (!secret) {
174
- return c.redirect("/dashboard");
175
- }
176
153
  const token = c.req.query("token");
177
- if (
178
- !token ||
179
- token.length !== secret.length ||
180
- !timingSafeEqual(Buffer.from(token), Buffer.from(secret))
181
- ) {
182
- return c.text("Invalid or missing token", 401);
154
+ const auth = authorizeApiSecret(
155
+ token ? `Bearer ${token}` : undefined,
156
+ config.apiSecret,
157
+ );
158
+ if (!auth.ok) {
159
+ logger.warn("Dashboard auth denied", {
160
+ status: auth.status,
161
+ path: c.req.path,
162
+ });
163
+ return c.text(
164
+ auth.status === 503
165
+ ? "MERCURY_API_SECRET must be set for /dashboard"
166
+ : "Invalid or missing token",
167
+ auth.status,
168
+ );
183
169
  }
184
170
  c.header(
185
171
  "Set-Cookie",
@@ -192,27 +178,33 @@ export function createApp(ctx: ServerContext): Hono {
192
178
  // Login route handled above — skip auth
193
179
  if (c.req.path === "/dashboard/login") return next();
194
180
 
195
- const secret = config.apiSecret;
196
- if (secret) {
197
- const authHeader = c.req.header("authorization");
198
- const token = authHeader?.startsWith("Bearer ")
199
- ? authHeader.slice(7)
200
- : undefined;
201
- const cookie = c.req.header("cookie");
202
- const cookieToken = cookie
203
- ?.split(";")
204
- .map((s) => s.trim())
205
- .find((s) => s.startsWith("mercury_token="))
206
- ?.split("=")[1];
207
-
208
- const provided = token || cookieToken;
209
- if (
210
- !provided ||
211
- provided.length !== secret.length ||
212
- !timingSafeEqual(Buffer.from(provided), Buffer.from(secret))
213
- ) {
214
- return c.json({ error: "Unauthorized" }, 401);
215
- }
181
+ const authHeader = c.req.header("authorization");
182
+ const token = authHeader?.startsWith("Bearer ")
183
+ ? authHeader.slice(7)
184
+ : undefined;
185
+ const cookie = c.req.header("cookie");
186
+ const cookieToken = cookie
187
+ ?.split(";")
188
+ .map((s) => s.trim())
189
+ .find((s) => s.startsWith("mercury_token="))
190
+ ?.split("=")[1];
191
+
192
+ const provided = token || cookieToken;
193
+ const auth = authorizeApiSecret(
194
+ provided ? `Bearer ${provided}` : undefined,
195
+ config.apiSecret,
196
+ );
197
+ if (!auth.ok) {
198
+ logger.warn("Dashboard auth denied", {
199
+ status: auth.status,
200
+ path: c.req.path,
201
+ });
202
+ return c.json(
203
+ auth.status === 503
204
+ ? { error: "MERCURY_API_SECRET must be set for /dashboard" }
205
+ : { error: "Unauthorized" },
206
+ auth.status,
207
+ );
216
208
  }
217
209
  await next();
218
210
  });
@@ -266,6 +258,10 @@ export function createApp(ctx: ServerContext): Hono {
266
258
  config.apiSecret,
267
259
  );
268
260
  if (!auth.ok) {
261
+ logger.warn("Pre-build auth denied", {
262
+ status: auth.status,
263
+ path: c.req.path,
264
+ });
269
265
  return c.json(
270
266
  auth.status === 503
271
267
  ? { error: "MERCURY_API_SECRET must be set for /pre-build-ext-image" }