mercury-agent 0.8.8 → 0.8.10-beta.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.
@@ -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_CLALIT_PASSWORD
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.8",
3
+ "version": "0.8.10-beta.0",
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).
@@ -415,42 +415,148 @@ function extImageRepo(agentId: string | undefined): string {
415
415
  return agentId ? `mercury-agent-ext-${agentId}` : "mercury-agent-ext";
416
416
  }
417
417
 
418
+ /**
419
+ * Runs a `docker` subcommand and returns stdout. Throws on non-zero exit,
420
+ * with the error message carrying the tail of stderr for diagnosis.
421
+ *
422
+ * Injected into the prune helpers so their logic (container reaping, flag
423
+ * fallback, failure logging) is unit-testable without a real Docker daemon.
424
+ */
425
+ export type DockerRun = (args: string[], timeoutMs?: number) => string;
426
+
427
+ const realDockerRun: DockerRun = (args, timeoutMs = 30_000) => {
428
+ try {
429
+ return execFileSync("docker", args, {
430
+ encoding: "utf8",
431
+ timeout: timeoutMs,
432
+ stdio: ["ignore", "pipe", "pipe"],
433
+ });
434
+ } catch (err) {
435
+ // execFileSync throws an Error with `.stderr` populated (we piped it).
436
+ // Fold stderr into the message so callers logging `err.message` see why.
437
+ const stderr =
438
+ err && typeof err === "object" && "stderr" in err
439
+ ? String((err as { stderr: unknown }).stderr ?? "").trim()
440
+ : "";
441
+ const base = err instanceof Error ? err.message : String(err);
442
+ throw new Error(stderr ? `${base}: ${stderr}` : base);
443
+ }
444
+ };
445
+
446
+ const errMsg = (err: unknown): string =>
447
+ err instanceof Error ? err.message : String(err);
448
+
418
449
  /**
419
450
  * Remove all derived images for this agent except the one with `keepHash`.
420
- * Images still in use by a running container are skipped silently.
451
+ *
452
+ * A stale image can be pinned by a leftover *stopped* container (e.g. one that
453
+ * survived an abnormal exit where `--rm` never reaped it). When the first
454
+ * `docker rmi` fails we remove the stopped containers referencing that image
455
+ * (plain `docker rm`, which never touches a *running* container) and retry.
456
+ * If the image is still pinned — a container is genuinely running on it — we
457
+ * `log.warn` and move on rather than swallowing the failure silently, so the
458
+ * accumulation is visible in logs.
421
459
  */
422
- function pruneStaleExtImages(
460
+ export function pruneStaleExtImages(
423
461
  keepHash: string,
424
462
  repo: string,
425
463
  log: Logger,
464
+ run: DockerRun = realDockerRun,
426
465
  ): void {
466
+ let tags: string[];
427
467
  try {
428
- const out = execFileSync(
429
- "docker",
430
- ["images", repo, "--format", "{{.Tag}}"],
431
- { encoding: "utf8", timeout: 30_000 },
432
- );
433
- const tags = out
468
+ const out = run(["images", repo, "--format", "{{.Tag}}"]);
469
+ tags = out
434
470
  .split("\n")
435
471
  .map((t) => t.trim())
436
472
  .filter((t) => t && t !== "<none>");
437
- for (const tag of tags) {
438
- if (tag === keepHash) continue;
439
- try {
440
- execFileSync("docker", ["rmi", `${repo}:${tag}`], {
441
- encoding: "utf8",
442
- timeout: 30_000,
443
- stdio: ["ignore", "pipe", "pipe"],
444
- });
445
- log.info(`Pruned stale ext image ${repo}:${tag}`);
446
- } catch {
447
- // Image still in use by a container — skip silently
473
+ } catch (err) {
474
+ log.warn(`Could not list ext images for pruning: ${errMsg(err)}`);
475
+ return;
476
+ }
477
+
478
+ for (const tag of tags) {
479
+ if (tag === keepHash) continue;
480
+ const image = `${repo}:${tag}`;
481
+ try {
482
+ run(["rmi", image]);
483
+ log.info(`Pruned stale ext image ${image}`);
484
+ continue;
485
+ } catch {
486
+ // Likely pinned by a leftover container — reap stopped ones and retry.
487
+ }
488
+ try {
489
+ const ids = run(["ps", "-aq", "--filter", `ancestor=${image}`])
490
+ .split("\n")
491
+ .map((s) => s.trim())
492
+ .filter(Boolean);
493
+ if (ids.length > 0) {
494
+ // Plain `rm` (no -f): removes stopped containers, errors on running
495
+ // ones without killing them. Best-effort — ignore its failure and
496
+ // let the retry `rmi` be the source of truth.
497
+ try {
498
+ run(["rm", ...ids]);
499
+ } catch {
500
+ // Some referenced container is still running; it stays alive.
501
+ }
448
502
  }
503
+ run(["rmi", image]);
504
+ log.info(
505
+ `Pruned stale ext image ${image} (after reaping ${ids.length} stopped container(s))`,
506
+ );
507
+ } catch (err) {
508
+ log.warn(
509
+ `Could not prune stale ext image ${image} (still referenced by a running container?): ${errMsg(err)}`,
510
+ );
511
+ }
512
+ }
513
+ }
514
+
515
+ /** Default BuildKit cache size to retain after a successful build. */
516
+ const DEFAULT_BUILD_CACHE_RESERVED = "10GB";
517
+
518
+ /**
519
+ * Bound the BuildKit build cache after a successful build so it doesn't grow
520
+ * without limit (observed reaching 27 GB in the field). Best-effort and
521
+ * non-fatal: any failure is logged and the build result is unaffected.
522
+ *
523
+ * Retained size is configurable via `MERCURY_BUILD_CACHE_RESERVED` (a docker
524
+ * size string like `10GB`); set it to `off`/`0` to disable pruning entirely.
525
+ *
526
+ * Docker renamed the retention flag: modern Docker (buildx) uses
527
+ * `--reserved-space`, older releases used `--keep-storage`. We try the modern
528
+ * flag first and fall back to the legacy one if it's unrecognised.
529
+ */
530
+ export function pruneBuildCache(
531
+ log: Logger,
532
+ run: DockerRun = realDockerRun,
533
+ ): void {
534
+ const configured = process.env.MERCURY_BUILD_CACHE_RESERVED?.trim();
535
+ const reserved = configured || DEFAULT_BUILD_CACHE_RESERVED;
536
+ if (reserved === "0" || reserved.toLowerCase() === "off") {
537
+ log.debug("Build cache prune disabled (MERCURY_BUILD_CACHE_RESERVED=off)");
538
+ return;
539
+ }
540
+
541
+ const flags = ["--reserved-space", "--keep-storage"];
542
+ for (let i = 0; i < flags.length; i++) {
543
+ try {
544
+ run(["builder", "prune", "-f", `${flags[i]}=${reserved}`], 120_000);
545
+ log.info(`Pruned BuildKit build cache (${flags[i]}=${reserved})`);
546
+ return;
547
+ } catch (err) {
548
+ const message = errMsg(err);
549
+ // Docker/cobra emits "unknown flag: --x" / "unknown shorthand flag" when
550
+ // a flag isn't recognised. Match only those — a genuine value/daemon
551
+ // error ("invalid argument …", "failed to prune …") must NOT trigger the
552
+ // legacy retry, so it surfaces as a real warning instead of being masked.
553
+ const unknownFlag = /unknown (flag|shorthand)/i.test(message);
554
+ // Only fall through to the legacy flag when the modern one is
555
+ // unrecognised; any other failure is real and shouldn't be masked.
556
+ if (unknownFlag && i < flags.length - 1) continue;
557
+ log.warn(`Could not prune build cache: ${message}`);
558
+ return;
449
559
  }
450
- } catch (err) {
451
- log.warn(
452
- `Could not list ext images for pruning: ${err instanceof Error ? err.message : String(err)}`,
453
- );
454
560
  }
455
561
  }
456
562
 
@@ -596,7 +702,16 @@ export async function ensureDerivedImage(
596
702
  const durationMs = Date.now() - startTime;
597
703
 
598
704
  log.info(`Built derived agent image ${derivedTag}`, { durationMs });
599
- pruneStaleExtImages(hash, repo, log);
705
+ // Post-build disk hygiene is strictly best-effort: the build already
706
+ // succeeded, so a prune failure must never turn this into the base-image
707
+ // fallback path. Both helpers are internally guarded; this outer catch is
708
+ // belt-and-suspenders so a future edit inside them can't break the success.
709
+ try {
710
+ pruneStaleExtImages(hash, repo, log);
711
+ pruneBuildCache(log);
712
+ } catch (pruneErr) {
713
+ log.warn(`Post-build prune failed (non-fatal): ${String(pruneErr)}`);
714
+ }
600
715
  return derivedTag;
601
716
  } catch (err: unknown) {
602
717
  const stderr =
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) => {