mercury-agent 0.5.0 → 0.5.2
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 +452 -451
- package/container/Dockerfile +127 -127
- package/container/Dockerfile.base +109 -109
- package/container/Dockerfile.power +17 -17
- package/container/agent-package.json +8 -8
- package/container/build.sh +54 -54
- package/docs/ROADMAP.md +3 -4
- package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +71 -0
- package/docs/auth/dashboard.md +28 -28
- package/docs/auth/overview.md +109 -109
- package/docs/auth/whatsapp.md +173 -173
- package/docs/authoring-profiles.md +174 -174
- package/docs/configuration.md +54 -54
- package/docs/container-lifecycle.md +349 -349
- package/docs/{bugs/bwrap-privileged-linux-docker.md → debug/major/2026-07-02-bwrap-privileged-linux-docker.md} +21 -1
- package/docs/{bugs/e2big-prompt-delivery-fix.md → debug/major/2026-07-02-e2big-prompt-delivery-fix.md} +27 -1
- package/docs/{bugs/registry-pull-no-local-fallback.md → debug/major/2026-07-02-registry-pull-no-local-fallback.md} +27 -1
- package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +72 -0
- package/docs/deployment.md +199 -199
- package/docs/extensions.md +375 -375
- package/docs/graceful-shutdown.md +62 -62
- package/docs/kb-distillation.md +77 -77
- package/docs/media/overview.md +140 -140
- package/docs/media/whatsapp.md +171 -171
- package/docs/memory.md +137 -137
- package/docs/permissions.md +217 -217
- package/docs/pipeline.md +228 -228
- package/docs/prd-chat-memory.md +76 -76
- package/docs/prd-config-load.md +82 -82
- package/docs/rate-limiting.md +229 -229
- package/docs/runbooks/publish-checklist.md +9 -1
- package/docs/scheduler.md +288 -288
- package/docs/setup-discord.md +100 -100
- package/docs/setup-slack.md +119 -119
- package/docs/setup-whatsapp.md +94 -94
- package/docs/subagents.md +166 -166
- package/docs/web-search.md +62 -62
- package/examples/extensions/README.md +12 -12
- package/examples/extensions/charts/index.ts +13 -13
- package/examples/extensions/charts/skill/SKILL.md +98 -98
- package/examples/extensions/gws/README.md +52 -52
- package/examples/extensions/gws/skill/SKILL.md +57 -57
- package/examples/extensions/gws/skill/references/calendar.md +101 -101
- package/examples/extensions/gws/skill/references/docs.md +65 -65
- package/examples/extensions/gws/skill/references/drive.md +79 -79
- package/examples/extensions/gws/skill/references/gmail.md +85 -85
- package/examples/extensions/gws/skill/references/sheets.md +60 -60
- package/examples/extensions/napkin/skill/SKILL.md +728 -728
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -30
- package/examples/extensions/pdf/skill/SKILL.md +314 -314
- package/examples/extensions/pdf/skill/forms.md +294 -294
- package/examples/extensions/pdf/skill/reference.md +611 -611
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -65
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -11
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -33
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -37
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -122
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -115
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -98
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -107
- package/examples/extensions/permission-guard/index.ts +65 -65
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -224
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -69
- package/examples/extensions/pinchtab/skill/references/api.md +297 -297
- package/examples/extensions/pinchtab/skill/references/env.md +45 -45
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -107
- package/examples/extensions/tradestation/host/refresh.ts +102 -102
- package/examples/extensions/tradestation/index.ts +153 -153
- package/examples/extensions/tradestation/skill/SKILL.md +67 -67
- package/examples/extensions/voice-synth/index.ts +94 -94
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -38
- package/examples/extensions/voice-transcribe/requirements.txt +8 -8
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -179
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -53
- package/examples/extensions/yahoo-mail/cli/package.json +13 -13
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -78
- package/package.json +106 -106
- package/resources/agents/explore.md +50 -50
- package/resources/agents/worker.md +24 -24
- package/resources/connection-env-vars.json +25 -25
- package/resources/pi-extensions/subagent/agents.ts +126 -126
- package/resources/pi-extensions/subagent/index.ts +964 -964
- package/resources/profiles/coding/AGENTS.md +43 -43
- package/resources/profiles/coding/mercury-profile.yaml +15 -15
- package/resources/profiles/general/AGENTS.md +31 -31
- package/resources/profiles/general/mercury-profile.yaml +15 -15
- package/resources/profiles/research/AGENTS.md +40 -40
- package/resources/profiles/research/mercury-profile.yaml +15 -15
- package/resources/skills/config/SKILL.md +25 -25
- package/resources/skills/context/SKILL.md +33 -33
- package/resources/skills/conversation-recap/SKILL.md +19 -19
- package/resources/skills/mutes/SKILL.md +31 -31
- package/resources/skills/permissions/SKILL.md +19 -19
- package/resources/skills/preferences/SKILL.md +31 -31
- package/resources/skills/recall/SKILL.md +24 -24
- package/resources/skills/roles/SKILL.md +18 -18
- package/resources/skills/spaces/SKILL.md +18 -18
- package/resources/skills/tasks/SKILL.md +45 -45
- package/resources/templates/AGENTS.md +157 -157
- package/resources/templates/env.template +38 -38
- package/resources/templates/mercury.example.yaml +99 -99
- package/src/agent/container-entry.ts +1 -1
- package/src/agent/container-runner.ts +1345 -1346
- package/src/cli/mercury.ts +39 -7
- package/src/cli/mrctl.ts +636 -636
- package/src/config-file.ts +540 -540
- package/src/config.ts +339 -339
- package/src/core/api.ts +125 -125
- package/src/core/caller-token.ts +101 -101
- package/src/core/permissions.ts +228 -228
- package/src/core/profiles.ts +271 -271
- package/src/core/routes/capability.ts +70 -70
- package/src/core/routes/index.ts +15 -15
- package/src/core/runtime.ts +1530 -1530
- package/src/dashboard/index.html +729 -729
- package/src/extensions/api.ts +273 -273
- package/src/extensions/loader.ts +286 -286
- package/src/extensions/types.ts +517 -517
- package/src/main.ts +605 -605
- package/docs/pending-updates/applicative-profiles.md +0 -15
- package/docs/pending-updates/caller-bound-capability-token.md +0 -15
- package/docs/pending-updates/dm-auto-space.md +0 -15
- /package/docs/archive/{2026-07-01-applicative-profiles.md → applicative-profiles/2026-07-01-applicative-profiles.md} +0 -0
- /package/docs/archive/{2026-07-01-caller-bound-capability-token.md → applicative-profiles/2026-07-01-caller-bound-capability-token.md} +0 -0
- /package/docs/archive/{2026-07-01-dm-auto-space.md → applicative-profiles/2026-07-01-dm-auto-space.md} +0 -0
package/src/core/api.ts
CHANGED
|
@@ -1,125 +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 { 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";
|
|
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";
|
package/src/core/caller-token.ts
CHANGED
|
@@ -1,101 +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
|
-
}
|
|
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
|
+
}
|