mercury-agent 0.8.9 → 0.8.10
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/docs/configuration.md +24 -0
- package/docs/security/archive/.gitkeep +0 -0
- package/docs/security/audits/.gitkeep +0 -0
- package/package.json +1 -1
- package/resources/templates/mercury.example.yaml +4 -0
- package/src/agent/container-env.ts +137 -0
- package/src/agent/container-runner.ts +10 -42
- package/src/config-file.ts +6 -0
- package/src/config.ts +13 -0
- package/src/main.ts +22 -0
package/docs/configuration.md
CHANGED
|
@@ -41,6 +41,30 @@ Per-space overrides via `mrctl config set context.<key> <value>` always win over
|
|
|
41
41
|
|
|
42
42
|
You may also set a top-level **`model_chain`** array as an alias for `model.chain`.
|
|
43
43
|
|
|
44
|
+
## Container env passthrough (`agent.env_passthrough`)
|
|
45
|
+
|
|
46
|
+
Controls which host `MERCURY_*` variables reach agent containers:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
agent:
|
|
50
|
+
env_passthrough: all # all (default) | claimed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **`all`** — every `MERCURY_*` var except a fixed blocklist is passed into the container with the prefix stripped (`MERCURY_BRAVE_API_KEY` → `BRAVE_API_KEY`). Convenient, but blunt: a secret added to `.env` for one purpose reaches **every space's container**, regardless of who triggered the turn or whether that space has anything to do with it.
|
|
54
|
+
- **`claimed`** — only variables an extension declared via `mercury.env()` are passed, and only when the triggering caller holds that extension's permission. Undeclared variables stay on the host.
|
|
55
|
+
|
|
56
|
+
**Model-provider credentials are exempt** and pass in both modes (`MERCURY_ANTHROPIC_API_KEY`, `MERCURY_ANTHROPIC_OAUTH_TOKEN`, `MERCURY_GEMINI_API_KEY`, `MERCURY_GROQ_API_KEY`, and the rest of the provider list). pi reads them inside the container, and no extension declares them — without the exemption, `claimed` would leave the agent unable to reach any model. They remain subject to the blocklist.
|
|
57
|
+
|
|
58
|
+
Env: `MERCURY_CONTAINER_ENV_PASSTHROUGH`.
|
|
59
|
+
|
|
60
|
+
`claimed` is opt-in because it breaks setups that rely on blind passthrough for anything other than provider keys — API keys consumed by skills (search, TTS, scrapers) and any credential you added by hand. To migrate, declare those in an extension (see [extensions.md](extensions.md)) before switching. At startup with `all`, Mercury logs the vars it is passing that are neither declared nor provider credentials — names only, so a genuine outlier stands out:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Container env passthrough: all — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_BRAVE_API_KEY, MERCURY_BILLING_API_KEY
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
|
|
67
|
+
|
|
44
68
|
## Extension config defaults (`extensions:`)
|
|
45
69
|
|
|
46
70
|
Deployment-wide defaults for extension config keys, applied to **every space** (including auto-created DM spaces) at read time:
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -62,6 +62,10 @@
|
|
|
62
62
|
# image: ghcr.io/avishai-tsabari/mercury-agent:latest
|
|
63
63
|
# container_timeout_ms: 300000
|
|
64
64
|
# container_bwrap_docker_compat: false
|
|
65
|
+
# # Which MERCURY_* vars reach containers. "all" (default) passes every
|
|
66
|
+
# # non-blocked var into every space's container; "claimed" passes only
|
|
67
|
+
# # extension-declared vars, gated by the caller's permission.
|
|
68
|
+
# env_passthrough: all
|
|
65
69
|
|
|
66
70
|
# discord:
|
|
67
71
|
# gateway_duration_ms: 600000
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which host environment variables reach agent containers.
|
|
3
|
+
*
|
|
4
|
+
* Mercury passes `MERCURY_*` vars into containers with the prefix stripped.
|
|
5
|
+
* That is convenient but blunt: a secret added to `.env` for one purpose is
|
|
6
|
+
* exposed to every space's container, whoever triggered the turn. Vars an
|
|
7
|
+
* extension declares via `mercury.env()` avoid this — they are injected in
|
|
8
|
+
* runtime.ts behind the extension's permission gate instead.
|
|
9
|
+
*
|
|
10
|
+
* `containerEnvPassthrough: "claimed"` narrows the blunt path to nothing, so
|
|
11
|
+
* declared vars are the only way in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Vars that must never reach a container, regardless of passthrough mode. */
|
|
15
|
+
export const BLOCKED_ENV_VARS = new Set([
|
|
16
|
+
"MERCURY_API_SECRET",
|
|
17
|
+
// Host-only: signs per-turn caller tokens. Injecting it would let the
|
|
18
|
+
// agent forge a token for any caller, defeating the whole mechanism.
|
|
19
|
+
"MERCURY_CALLER_TOKEN_KEY",
|
|
20
|
+
// Host-only: the inner→outer API socket path is set by code per spawn;
|
|
21
|
+
// never let an agent override which socket mrctl targets.
|
|
22
|
+
"MERCURY_API_SOCKET",
|
|
23
|
+
"MERCURY_CHAT_API_KEY",
|
|
24
|
+
"MERCURY_ADMINS",
|
|
25
|
+
// Host-only: affects `docker run` flags, not the agent process inside the container
|
|
26
|
+
"MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
|
|
27
|
+
// Host-only: selects the OCI runtime for `docker run --runtime`; not meaningful inside the container
|
|
28
|
+
"MERCURY_CONTAINER_RUNTIME",
|
|
29
|
+
// Host-only: resolved volume mountpoint on the host; inner containers don't need it
|
|
30
|
+
"MERCURY_HOST_DATA_DIR",
|
|
31
|
+
"MERCURY_SLACK_BOT_TOKEN",
|
|
32
|
+
"MERCURY_SLACK_SIGNING_SECRET",
|
|
33
|
+
"MERCURY_DISCORD_BOT_TOKEN",
|
|
34
|
+
"MERCURY_DISCORD_GATEWAY_SECRET",
|
|
35
|
+
"MERCURY_TELEGRAM_BOT_TOKEN",
|
|
36
|
+
"MERCURY_TELEGRAM_WEBHOOK_SECRET_TOKEN",
|
|
37
|
+
"MERCURY_TEAMS_APP_ID",
|
|
38
|
+
"MERCURY_TEAMS_APP_PASSWORD",
|
|
39
|
+
"MERCURY_WHATSAPP_AUTH_DIR",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Model-provider credentials. pi reads these inside the container, so they are
|
|
44
|
+
* runtime plumbing rather than user secrets — and no extension declares them,
|
|
45
|
+
* which means "claimed" mode would otherwise leave the agent with no way to
|
|
46
|
+
* reach a model at all. Exempt from the claimed filter for that reason; still
|
|
47
|
+
* subject to BLOCKED_ENV_VARS.
|
|
48
|
+
*
|
|
49
|
+
* Mirrors the provider list in core/routes/dashboard.ts (drift is covered by a
|
|
50
|
+
* test), plus MERCURY_ANTHROPIC_OAUTH_TOKEN, which console provisioning sets
|
|
51
|
+
* instead of an API key.
|
|
52
|
+
*/
|
|
53
|
+
export const MODEL_PROVIDER_ENV_VARS = new Set([
|
|
54
|
+
"MERCURY_AI_GATEWAY_API_KEY",
|
|
55
|
+
"MERCURY_ANTHROPIC_API_KEY",
|
|
56
|
+
"MERCURY_ANTHROPIC_OAUTH_TOKEN",
|
|
57
|
+
"MERCURY_AWS_BEARER_TOKEN_BEDROCK",
|
|
58
|
+
"MERCURY_AZURE_OPENAI_API_KEY",
|
|
59
|
+
"MERCURY_CEREBRAS_API_KEY",
|
|
60
|
+
"MERCURY_DEEPSEEK_API_KEY",
|
|
61
|
+
"MERCURY_GEMINI_API_KEY",
|
|
62
|
+
"MERCURY_GITHUB_COPILOT_OAUTH_TOKEN",
|
|
63
|
+
"MERCURY_GOOGLE_CLOUD_API_KEY",
|
|
64
|
+
"MERCURY_GROQ_API_KEY",
|
|
65
|
+
"MERCURY_HF_TOKEN",
|
|
66
|
+
"MERCURY_KIMI_API_KEY",
|
|
67
|
+
"MERCURY_MINIMAX_API_KEY",
|
|
68
|
+
"MERCURY_MINIMAX_CN_API_KEY",
|
|
69
|
+
"MERCURY_MISTRAL_API_KEY",
|
|
70
|
+
"MERCURY_OPENAI_API_KEY",
|
|
71
|
+
"MERCURY_OPENROUTER_API_KEY",
|
|
72
|
+
"MERCURY_XAI_API_KEY",
|
|
73
|
+
"MERCURY_ZAI_API_KEY",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
export type EnvPassthroughMode = "all" | "claimed";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Host `MERCURY_*` var names that are neither blocked nor claimed by an
|
|
80
|
+
* extension — the ones the blind passthrough carries into every container.
|
|
81
|
+
*
|
|
82
|
+
* Sorted, and names only: callers log these, and the values are the secrets.
|
|
83
|
+
*/
|
|
84
|
+
export function listUnclaimedPassthroughVars(
|
|
85
|
+
env: NodeJS.ProcessEnv,
|
|
86
|
+
claimed: Set<string> | undefined,
|
|
87
|
+
): string[] {
|
|
88
|
+
return Object.keys(env)
|
|
89
|
+
.filter(
|
|
90
|
+
(key) =>
|
|
91
|
+
key.startsWith("MERCURY_") &&
|
|
92
|
+
env[key] !== undefined &&
|
|
93
|
+
!BLOCKED_ENV_VARS.has(key) &&
|
|
94
|
+
!claimed?.has(key),
|
|
95
|
+
)
|
|
96
|
+
.sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Undeclared vars that are not model-provider plumbing — the ones worth
|
|
101
|
+
* surfacing at startup. Provider keys are expected to be here and would only
|
|
102
|
+
* add noise; a var in this list is one nobody scoped to anything.
|
|
103
|
+
*/
|
|
104
|
+
export function listUnexpectedPassthroughVars(
|
|
105
|
+
env: NodeJS.ProcessEnv,
|
|
106
|
+
claimed: Set<string> | undefined,
|
|
107
|
+
): string[] {
|
|
108
|
+
return listUnclaimedPassthroughVars(env, claimed).filter(
|
|
109
|
+
(key) => !MODEL_PROVIDER_ENV_VARS.has(key),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The blind-passthrough pairs for a container spawn, with `MERCURY_` stripped.
|
|
115
|
+
*
|
|
116
|
+
* Extension-declared vars are excluded here on purpose — runtime.ts injects
|
|
117
|
+
* those separately, behind the permission check. In "claimed" mode only
|
|
118
|
+
* model-provider credentials pass, so the agent can still reach a model.
|
|
119
|
+
*/
|
|
120
|
+
export function selectPassthroughEnv(
|
|
121
|
+
env: NodeJS.ProcessEnv,
|
|
122
|
+
claimed: Set<string> | undefined,
|
|
123
|
+
mode: EnvPassthroughMode,
|
|
124
|
+
): Array<{ key: string; value: string }> {
|
|
125
|
+
const names =
|
|
126
|
+
mode === "claimed"
|
|
127
|
+
? listUnclaimedPassthroughVars(env, claimed).filter((key) =>
|
|
128
|
+
MODEL_PROVIDER_ENV_VARS.has(key),
|
|
129
|
+
)
|
|
130
|
+
: listUnclaimedPassthroughVars(env, claimed);
|
|
131
|
+
|
|
132
|
+
return names.map((key) => ({
|
|
133
|
+
key: key.replace("MERCURY_", ""),
|
|
134
|
+
// listUnclaimedPassthroughVars already filtered out undefined values.
|
|
135
|
+
value: env[key] as string,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
INNER_RUN_DIR,
|
|
21
21
|
innerApiSocketPath,
|
|
22
22
|
} from "./api-socket.js";
|
|
23
|
+
import { selectPassthroughEnv } from "./container-env.js";
|
|
23
24
|
import { ContainerError } from "./container-error.js";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -677,48 +678,15 @@ export class AgentContainerRunner {
|
|
|
677
678
|
);
|
|
678
679
|
}
|
|
679
680
|
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
"MERCURY_CHAT_API_KEY",
|
|
690
|
-
"MERCURY_ADMINS",
|
|
691
|
-
// Host-only: affects `docker run` flags, not the agent process inside the container
|
|
692
|
-
"MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
|
|
693
|
-
// Host-only: selects the OCI runtime for `docker run --runtime`; not meaningful inside the container
|
|
694
|
-
"MERCURY_CONTAINER_RUNTIME",
|
|
695
|
-
// Host-only: resolved volume mountpoint on the host; inner containers don't need it
|
|
696
|
-
"MERCURY_HOST_DATA_DIR",
|
|
697
|
-
"MERCURY_SLACK_BOT_TOKEN",
|
|
698
|
-
"MERCURY_SLACK_SIGNING_SECRET",
|
|
699
|
-
"MERCURY_DISCORD_BOT_TOKEN",
|
|
700
|
-
"MERCURY_DISCORD_GATEWAY_SECRET",
|
|
701
|
-
"MERCURY_TELEGRAM_BOT_TOKEN",
|
|
702
|
-
"MERCURY_TELEGRAM_WEBHOOK_SECRET_TOKEN",
|
|
703
|
-
"MERCURY_TEAMS_APP_ID",
|
|
704
|
-
"MERCURY_TEAMS_APP_PASSWORD",
|
|
705
|
-
"MERCURY_WHATSAPP_AUTH_DIR",
|
|
706
|
-
]);
|
|
707
|
-
|
|
708
|
-
// Pass MERCURY_* vars to container with prefix stripped, excluding blocked vars
|
|
709
|
-
const claimed = input.claimedEnvSources;
|
|
710
|
-
const passthroughEnvPairs = Object.entries(process.env)
|
|
711
|
-
.filter(
|
|
712
|
-
(entry): entry is [string, string] =>
|
|
713
|
-
entry[0].startsWith("MERCURY_") &&
|
|
714
|
-
entry[1] !== undefined &&
|
|
715
|
-
!BLOCKED_ENV_VARS.has(entry[0]) &&
|
|
716
|
-
!claimed?.has(entry[0]),
|
|
717
|
-
)
|
|
718
|
-
.map(([key, value]) => ({
|
|
719
|
-
key: key.replace("MERCURY_", ""),
|
|
720
|
-
value: value,
|
|
721
|
-
}));
|
|
681
|
+
// Pass MERCURY_* vars to container with prefix stripped, excluding blocked
|
|
682
|
+
// and extension-claimed vars. In "claimed" mode this yields nothing and
|
|
683
|
+
// extension-declared vars (injected in runtime.ts behind the permission
|
|
684
|
+
// check) are the only way a secret reaches a container.
|
|
685
|
+
const passthroughEnvPairs = selectPassthroughEnv(
|
|
686
|
+
process.env,
|
|
687
|
+
input.claimedEnvSources,
|
|
688
|
+
this.config.containerEnvPassthrough,
|
|
689
|
+
);
|
|
722
690
|
|
|
723
691
|
// Legacy path: older console versions stored the OAuth credential blob in
|
|
724
692
|
// MERCURY_ANTHROPIC_API_KEY instead of MERCURY_ANTHROPIC_OAUTH_TOKEN.
|
package/src/config-file.ts
CHANGED
|
@@ -127,6 +127,7 @@ const mercuryFileSchema = z
|
|
|
127
127
|
.optional(),
|
|
128
128
|
container_bwrap_docker_compat: z.boolean().optional(),
|
|
129
129
|
override_pi_system_prompt: z.boolean().optional(),
|
|
130
|
+
env_passthrough: z.enum(["all", "claimed"]).optional(),
|
|
130
131
|
})
|
|
131
132
|
.strip()
|
|
132
133
|
.optional(),
|
|
@@ -247,6 +248,7 @@ const KNOWN_SECTION_KEYS: Record<string, Set<string>> = {
|
|
|
247
248
|
"container_timeout_ms",
|
|
248
249
|
"container_bwrap_docker_compat",
|
|
249
250
|
"override_pi_system_prompt",
|
|
251
|
+
"env_passthrough",
|
|
250
252
|
]),
|
|
251
253
|
discord: new Set(["gateway_duration_ms"]),
|
|
252
254
|
telegram: new Set(["format_enabled"]),
|
|
@@ -388,6 +390,9 @@ function flattenMercuryFile(f: MercuryFile): RawMercuryConfigInput {
|
|
|
388
390
|
if (f.agent?.override_pi_system_prompt != null) {
|
|
389
391
|
o.overridePiSystemPrompt = f.agent.override_pi_system_prompt;
|
|
390
392
|
}
|
|
393
|
+
if (f.agent?.env_passthrough != null) {
|
|
394
|
+
o.containerEnvPassthrough = f.agent.env_passthrough;
|
|
395
|
+
}
|
|
391
396
|
|
|
392
397
|
if (f.discord?.gateway_duration_ms != null) {
|
|
393
398
|
o.discordGatewayDurationMs = f.discord.gateway_duration_ms;
|
|
@@ -461,6 +466,7 @@ const CAMEL_TO_ENV: Record<string, string> = {
|
|
|
461
466
|
containerNetwork: "MERCURY_CONTAINER_NETWORK",
|
|
462
467
|
containerApiHost: "MERCURY_CONTAINER_API_HOST",
|
|
463
468
|
containerBwrapDockerCompat: "MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
|
|
469
|
+
containerEnvPassthrough: "MERCURY_CONTAINER_ENV_PASSTHROUGH",
|
|
464
470
|
overridePiSystemPrompt: "MERCURY_OVERRIDE_PI_SYSTEM_PROMPT",
|
|
465
471
|
maxConcurrency: "MERCURY_MAX_CONCURRENCY",
|
|
466
472
|
rateLimitPerUser: "MERCURY_RATE_LIMIT_PER_USER",
|
package/src/config.ts
CHANGED
|
@@ -133,6 +133,19 @@ const schema = z.object({
|
|
|
133
133
|
* Requires gVisor installed on the compute node (auto-installed by cloud-init on provisioned nodes).
|
|
134
134
|
*/
|
|
135
135
|
containerRuntime: z.enum(["runc", "runsc"]).default("runc"),
|
|
136
|
+
/**
|
|
137
|
+
* Which MERCURY_* env vars reach agent containers.
|
|
138
|
+
* - "all" (default): every MERCURY_* var except the blocklist is passed with
|
|
139
|
+
* the prefix stripped. Convenient, but a secret added to .env is exposed to
|
|
140
|
+
* every space's container regardless of who triggered the turn.
|
|
141
|
+
* - "claimed": only vars an extension declared via `mercury.env()` are passed,
|
|
142
|
+
* and only when the caller holds that extension's permission. Undeclared
|
|
143
|
+
* vars stay on the host.
|
|
144
|
+
*
|
|
145
|
+
* "claimed" is opt-in because it breaks setups that rely on blind passthrough;
|
|
146
|
+
* declare the vars you need in an extension before switching.
|
|
147
|
+
*/
|
|
148
|
+
containerEnvPassthrough: z.enum(["all", "claimed"]).default("all"),
|
|
136
149
|
/**
|
|
137
150
|
* @deprecated Use MERCURY_CONTAINER_RUNTIME=runsc instead.
|
|
138
151
|
* When true, `docker run` uses looser outer sandbox so bubblewrap can nest (e.g. Docker Desktop).
|
package/src/main.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
apiSocketPath,
|
|
23
23
|
sweepOrphanApiSockets,
|
|
24
24
|
} from "./agent/api-socket.js";
|
|
25
|
+
import { listUnexpectedPassthroughVars } from "./agent/container-env.js";
|
|
25
26
|
import {
|
|
26
27
|
logExtensionCapabilityMismatches,
|
|
27
28
|
logUnknownModelCapabilityWarnings,
|
|
@@ -617,6 +618,27 @@ async function main() {
|
|
|
617
618
|
if (adapters.telegram) {
|
|
618
619
|
logger.info("Telegram enabled (webhook or polling)");
|
|
619
620
|
}
|
|
621
|
+
|
|
622
|
+
// Inventory of what the blind passthrough carries. Names only — the values
|
|
623
|
+
// are the secrets. Logged once per start, not per container: the point is
|
|
624
|
+
// that an unnoticed entry here (a password added to .env for one space) is
|
|
625
|
+
// reaching every space's container, whoever triggered the turn.
|
|
626
|
+
if (config.containerEnvPassthrough === "claimed") {
|
|
627
|
+
logger.info(
|
|
628
|
+
"Container env passthrough: claimed — only extension-declared vars and model-provider credentials reach containers",
|
|
629
|
+
);
|
|
630
|
+
} else {
|
|
631
|
+
const unexpected = listUnexpectedPassthroughVars(
|
|
632
|
+
process.env,
|
|
633
|
+
registry.getClaimedEnvSources(),
|
|
634
|
+
);
|
|
635
|
+
if (unexpected.length > 0) {
|
|
636
|
+
logger.info(
|
|
637
|
+
"Container env passthrough: all — these vars reach every space's container and are scoped to nothing. Declare them in an extension, or set agent.env_passthrough=claimed.",
|
|
638
|
+
{ vars: unexpected.join(", ") },
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
620
642
|
}
|
|
621
643
|
|
|
622
644
|
main().catch((error) => {
|