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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -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
- // Env vars that should never be passed to containers
681
- const BLOCKED_ENV_VARS = new Set([
682
- "MERCURY_API_SECRET",
683
- // Host-only: signs per-turn caller tokens. Injecting it would let the
684
- // agent forge a token for any caller, defeating the whole mechanism.
685
- "MERCURY_CALLER_TOKEN_KEY",
686
- // Host-only: the inner→outer API socket path is set by code per spawn;
687
- // never let an agent override which socket mrctl targets.
688
- "MERCURY_API_SOCKET",
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.
@@ -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) => {