mercury-agent 0.4.28 → 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/src/core/api.ts CHANGED
@@ -1,105 +1,125 @@
1
- import { timingSafeEqual } from "node:crypto";
2
- import { Hono } from "hono";
3
- import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
- import { resolveRole } from "./permissions.js";
5
- import {
6
- config,
7
- connections,
8
- control,
9
- conversations,
10
- extensions,
11
- media,
12
- messages,
13
- mutes,
14
- permissions,
15
- prefs,
16
- roles,
17
- spaces,
18
- tasks,
19
- tradestation,
20
- tts,
21
- } from "./routes/index.js";
22
-
23
- function safeCompare(a: string, b: string): boolean {
24
- if (a.length !== b.length) return false;
25
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
26
- }
27
-
28
- // ─── App Factory ──────────────────────────────────────────────────────────
29
-
30
- export function createApiApp(apiCtx: ApiContext): Hono<Env> {
31
- const app = new Hono<Env>();
32
-
33
- // ─── Auth Middleware ────────────────────────────────────────────────────
34
-
35
- app.use("*", async (c, next) => {
36
- // Validate API secret when configured
37
- const secret = apiCtx.config.apiSecret;
38
- if (secret) {
39
- const authHeader = c.req.header("authorization");
40
- const token = authHeader?.startsWith("Bearer ")
41
- ? authHeader.slice(7)
42
- : undefined;
43
-
44
- if (!token || !safeCompare(token, secret)) {
45
- return c.json({ error: "Unauthorized" }, 401);
46
- }
47
- }
48
-
49
- // Parse auth headers
50
- const callerId = c.req.header("x-mercury-caller");
51
- const spaceId = c.req.header("x-mercury-space");
52
-
53
- if (!callerId || !spaceId) {
54
- return c.json(
55
- { error: "Missing X-Mercury-Caller or X-Mercury-Space headers" },
56
- 400,
57
- );
58
- }
59
-
60
- // Resolve role
61
- const seededAdmins = apiCtx.config.admins
62
- ? apiCtx.config.admins
63
- .split(",")
64
- .map((s) => s.trim())
65
- .filter(Boolean)
66
- : [];
67
-
68
- apiCtx.db.ensureSpace(spaceId);
69
- const role = resolveRole(apiCtx.db, spaceId, callerId, seededAdmins);
70
-
71
- // Store in request context
72
- c.set("auth", { callerId, spaceId, role } as AuthContext);
73
- c.set("apiCtx", apiCtx);
74
- await next();
75
- });
76
-
77
- // ─── Mount Routes ───────────────────────────────────────────────────────
78
-
79
- app.route("/", control);
80
- app.route("/tasks", tasks);
81
- app.route("/config", config);
82
- app.route("/prefs", prefs);
83
- app.route("/roles", roles);
84
- app.route("/permissions", permissions);
85
- app.route("/spaces", spaces);
86
- app.route("/conversations", conversations);
87
- app.route("/media", media);
88
- app.route("/messages", messages);
89
- app.route("/mutes", mutes);
90
- app.route("/ext", extensions);
91
- app.route("/connections", connections);
92
- app.route("/tradestation", tradestation);
93
- app.route("/tts", tts);
94
-
95
- // ─── Fallback ───────────────────────────────────────────────────────────
96
-
97
- app.all("*", (c) => {
98
- return c.json({ error: "Not found" }, 404);
99
- });
100
-
101
- return app;
102
- }
103
-
104
- // Re-export types for convenience
105
- export type { ApiContext, AuthContext, Env } from "./api-types.js";
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { Hono } from "hono";
3
+ import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
+ import { verifyCallerToken } from "./caller-token.js";
5
+ import { resolveRole } from "./permissions.js";
6
+ import {
7
+ capability,
8
+ config,
9
+ connections,
10
+ control,
11
+ conversations,
12
+ extensions,
13
+ media,
14
+ messages,
15
+ mutes,
16
+ permissions,
17
+ prefs,
18
+ roles,
19
+ spaces,
20
+ tasks,
21
+ tradestation,
22
+ tts,
23
+ } from "./routes/index.js";
24
+
25
+ function safeCompare(a: string, b: string): boolean {
26
+ if (a.length !== b.length) return false;
27
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
28
+ }
29
+
30
+ // ─── App Factory ──────────────────────────────────────────────────────────
31
+
32
+ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
33
+ const app = new Hono<Env>();
34
+
35
+ // ─── Auth Middleware ────────────────────────────────────────────────────
36
+
37
+ app.use("*", async (c, next) => {
38
+ // Validate API secret when configured
39
+ const secret = apiCtx.config.apiSecret;
40
+ if (secret) {
41
+ const authHeader = c.req.header("authorization");
42
+ const token = authHeader?.startsWith("Bearer ")
43
+ ? authHeader.slice(7)
44
+ : undefined;
45
+
46
+ if (!token || !safeCompare(token, secret)) {
47
+ return c.json({ error: "Unauthorized" }, 401);
48
+ }
49
+ }
50
+
51
+ // Resolve caller identity. A per-turn caller token (minted host-side at
52
+ // container spawn) is authoritative and unspoofable — prefer it over the
53
+ // x-mercury-caller / x-mercury-space headers, which any code holding the
54
+ // shared API_SECRET could forge. Headers remain the fallback for callers
55
+ // that predate tokens (backward compatibility).
56
+ let callerId = c.req.header("x-mercury-caller");
57
+ let spaceId = c.req.header("x-mercury-space");
58
+
59
+ const callerToken = c.req.header("x-mercury-token");
60
+ if (callerToken) {
61
+ const verified = verifyCallerToken(
62
+ callerToken,
63
+ apiCtx.config.callerTokenKey,
64
+ );
65
+ if (!verified) {
66
+ return c.json({ error: "Invalid or expired caller token" }, 401);
67
+ }
68
+ callerId = verified.callerId;
69
+ spaceId = verified.spaceId;
70
+ }
71
+
72
+ if (!callerId || !spaceId) {
73
+ return c.json(
74
+ { error: "Missing X-Mercury-Caller or X-Mercury-Space headers" },
75
+ 400,
76
+ );
77
+ }
78
+
79
+ // Resolve role
80
+ const seededAdmins = apiCtx.config.admins
81
+ ? apiCtx.config.admins
82
+ .split(",")
83
+ .map((s) => s.trim())
84
+ .filter(Boolean)
85
+ : [];
86
+
87
+ apiCtx.db.ensureSpace(spaceId);
88
+ const role = resolveRole(apiCtx.db, spaceId, callerId, seededAdmins);
89
+
90
+ // Store in request context
91
+ c.set("auth", { callerId, spaceId, role } as AuthContext);
92
+ c.set("apiCtx", apiCtx);
93
+ await next();
94
+ });
95
+
96
+ // ─── Mount Routes ───────────────────────────────────────────────────────
97
+
98
+ app.route("/", control);
99
+ app.route("/tasks", tasks);
100
+ app.route("/config", config);
101
+ app.route("/prefs", prefs);
102
+ app.route("/roles", roles);
103
+ app.route("/permissions", permissions);
104
+ app.route("/spaces", spaces);
105
+ app.route("/conversations", conversations);
106
+ app.route("/media", media);
107
+ app.route("/messages", messages);
108
+ app.route("/mutes", mutes);
109
+ app.route("/ext", extensions);
110
+ app.route("/connections", connections);
111
+ app.route("/tradestation", tradestation);
112
+ app.route("/tts", tts);
113
+ app.route("/capability", capability);
114
+
115
+ // ─── Fallback ───────────────────────────────────────────────────────────
116
+
117
+ app.all("*", (c) => {
118
+ return c.json({ error: "Not found" }, 404);
119
+ });
120
+
121
+ return app;
122
+ }
123
+
124
+ // Re-export types for convenience
125
+ export type { ApiContext, AuthContext, Env } from "./api-types.js";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Caller-bound capability token.
3
+ *
4
+ * Mercury's in-container CLIs authenticate to the host control-plane API with a
5
+ * single shared `API_SECRET` and, historically, assert their identity via the
6
+ * spoofable `x-mercury-caller` / `x-mercury-space` headers. Because `API_SECRET`
7
+ * is readable inside the container, any code there could claim to be any caller.
8
+ *
9
+ * This module mints a short-lived, HMAC-signed token at container spawn, bound
10
+ * to `{callerId, spaceId}`. The host verifies it and uses the token payload as
11
+ * the authoritative identity for authorization — so a container cannot forge a
12
+ * different caller.
13
+ *
14
+ * The signing key NEVER enters a container. When no key is configured
15
+ * (`callerTokenKey`), an ephemeral random key is generated once per host process
16
+ * — sufficient because tokens are per-turn and short-lived. A configured key is
17
+ * only needed when minting and verification happen in separate processes.
18
+ */
19
+
20
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
21
+
22
+ export interface CallerTokenClaims {
23
+ callerId: string;
24
+ spaceId: string;
25
+ /** Expiry, unix seconds. */
26
+ exp: number;
27
+ }
28
+
29
+ /** Ephemeral host-only key, generated lazily when no key is configured. */
30
+ let ephemeralKey: Buffer | null = null;
31
+
32
+ function resolveKey(configuredKey?: string): Buffer {
33
+ if (configuredKey && configuredKey.length > 0) {
34
+ return Buffer.from(configuredKey, "utf8");
35
+ }
36
+ if (!ephemeralKey) {
37
+ ephemeralKey = randomBytes(32);
38
+ }
39
+ return ephemeralKey;
40
+ }
41
+
42
+ /** Mint a signed caller token. `configuredKey` falls back to a host ephemeral key. */
43
+ export function mintCallerToken(
44
+ claims: CallerTokenClaims,
45
+ configuredKey?: string,
46
+ ): string {
47
+ const key = resolveKey(configuredKey);
48
+ const payload = Buffer.from(
49
+ JSON.stringify({ c: claims.callerId, s: claims.spaceId, exp: claims.exp }),
50
+ "utf8",
51
+ ).toString("base64url");
52
+ const sig = createHmac("sha256", key).update(payload).digest("base64url");
53
+ return `${payload}.${sig}`;
54
+ }
55
+
56
+ /**
57
+ * Verify a caller token. Returns the bound identity on success, or null if the
58
+ * signature is invalid, the payload is malformed, or the token has expired.
59
+ */
60
+ export function verifyCallerToken(
61
+ token: string,
62
+ configuredKey?: string,
63
+ nowSeconds?: number,
64
+ ): { callerId: string; spaceId: string } | null {
65
+ const dot = token.indexOf(".");
66
+ if (dot <= 0 || dot === token.length - 1) return null;
67
+
68
+ const payload = token.slice(0, dot);
69
+ const sig = token.slice(dot + 1);
70
+
71
+ const key = resolveKey(configuredKey);
72
+ const expected = createHmac("sha256", key)
73
+ .update(payload)
74
+ .digest("base64url");
75
+
76
+ const sigBuf = Buffer.from(sig);
77
+ const expBuf = Buffer.from(expected);
78
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
79
+ return null;
80
+ }
81
+
82
+ let decoded: { c?: unknown; s?: unknown; exp?: unknown };
83
+ try {
84
+ decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ if (
90
+ typeof decoded.c !== "string" ||
91
+ typeof decoded.s !== "string" ||
92
+ typeof decoded.exp !== "number"
93
+ ) {
94
+ return null;
95
+ }
96
+
97
+ const now = nowSeconds ?? Math.floor(Date.now() / 1000);
98
+ if (decoded.exp < now) return null;
99
+
100
+ return { callerId: decoded.c, spaceId: decoded.s };
101
+ }