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 +2 -2
- package/package.json +1 -1
- package/src/cli/mercury.ts +16 -1
- package/src/config.ts +9 -2
- package/src/core/api.ts +23 -17
- package/src/core/auth.ts +28 -0
- package/src/core/routes/chat.ts +31 -27
- package/src/main.ts +6 -0
- package/src/server.ts +53 -57
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/*`
|
|
365
|
-
| `MERCURY_CHAT_API_KEY` | Optional
|
|
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
package/src/cli/mercury.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
package/src/core/auth.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/routes/chat.ts
CHANGED
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
) {
|
|
182
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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" }
|