@monster-orange/den 0.2.2 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.2.3] - 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **`secret_env`: secrets resolved by Den into a stack's or a service's env**
12
+ (R16), so a consumer no longer wraps `<provider> run -- <cli> restart
13
+ <repo>` by hand, and a plain restart no longer drops a key. In a manifest,
14
+ `compose.secret_env` (for the docker compose process: the compose file's
15
+ `${VAR}` or pass-through `environment:` entries) and
16
+ `services.<svc>.secret_env` (for that service's own up/restart command)
17
+ map an env var to a fully qualified ref, `<vault>/<item>/<field>`. For
18
+ Doppler that is `<project>/<config>/<key>`; for 1Password vault / item /
19
+ field; for `env` the field is the env var to read. Existing refs (the
20
+ engine's named secrets, test users) are unchanged. Resolved through the
21
+ configured `secrets.provider` at `up` and `restart` (also the in-place
22
+ restart of one compose service), all or nothing, before anything stops or
23
+ starts. A missing key fails that target, exit 1, naming the key, ref and
24
+ provider. Values go only into the spawned process's env: never into the
25
+ compose override, logs, activity records, MCP output or messages. A dry
26
+ run (`-n`) never asks the provider; it lists the key names it would
27
+ inject. A CLI-tabs start of a service with `secret_env` runs as a daemon,
28
+ so the values stay out of the tab's shell. `validate` rejects a ref that
29
+ is not exactly three non-empty parts, and a key that is not an env var
30
+ name. See "Secrets in a target's env" in `docs/distributions.md`.
31
+
32
+ ### Fixed
33
+
34
+ - **`<cli> mcp connect` for Claude Code failed whenever the server has env**
35
+ ("Invalid environment variable format: <name>"): `claude mcp add`'s `-e`
36
+ takes several values, so the server name after the env pairs was read as
37
+ one more. The name now comes right after `add`.
38
+ - **The up/restart secrets check said the backend was "not ready" when it
39
+ was signed in with no default scope.** `secret_env` refs name their own
40
+ scope, so they still resolve; it now says "signed in, no default scope
41
+ set" instead of warning.
42
+
43
+ ### Migrations
44
+
45
+ - None required.
46
+
7
47
  ## [0.2.2] - 2026-11-04
8
48
 
9
49
  ### Added
@@ -213,6 +213,57 @@ ones (`⇄ hybrid`).
213
213
  production_envs: [prod] # default: [prod, production]
214
214
  ```
215
215
 
216
+ ## Secrets in a target's env
217
+
218
+ `secret_env` puts secrets from the configured secrets provider
219
+ (`secrets.provider`: `doppler`, `1password` or `env`) into the env of what
220
+ Den starts, instead of wrapping `<provider> run -- <cli> restart <repo>` by
221
+ hand:
222
+
223
+ ```yaml
224
+ # .acme.yml
225
+ compose:
226
+ secret_env: # the docker compose process's env, at stack up / restart
227
+ OPENAI_API_KEY: shop/dev_personal/OPENAI_API_KEY
228
+ services:
229
+ worker:
230
+ secret_env: # the env of this service's own up / restart command
231
+ STRIPE_KEY: Engineering/Stripe Test/secret key
232
+ ```
233
+
234
+ Each value is a fully qualified ref, `<vault>/<item>/<field>`:
235
+
236
+ | Provider | `a/b/c` means |
237
+ | ----------- | -------------------------------------------------------------------------- |
238
+ | `doppler` | `--project a --config b`, key `c` (`secrets.doppler` is not consulted) |
239
+ | `1password` | vault `a`, item `b`, field `c` |
240
+ | `env` | the env var `c` of the process running Den (`secrets.keys` is not applied) |
241
+
242
+ A `compose.secret_env` key reaches a container only through the compose
243
+ file: `${OPENAI_API_KEY}` in it, or a pass-through `environment:
244
+ [OPENAI_API_KEY]`.
245
+
246
+ - **When.** `up` and `restart`, including `restart <repo>/<service>` of one
247
+ compose service (`stack up`, `stack restart`, `stack restart --service`),
248
+ from the CLI, the web UI and MCP. Every key is resolved before anything
249
+ stops or starts.
250
+ - **Missing.** A key the provider cannot resolve (unset, empty, no access)
251
+ fails that target, exit 1, naming the key, the ref and the provider. The
252
+ rest of the set is not used: nothing starts with a partial set, and a
253
+ restart leaves the old instance running.
254
+ - **Where values go.** Only into the spawned process's env (the docker
255
+ compose process, or the service's command). Never into the generated
256
+ compose override, logs, activity records, MCP output, dry runs or
257
+ messages. A CLI-tabs (`cli_tabs`) start of a service with `secret_env`
258
+ runs as a daemon, since a tab would put them in its shell history.
259
+ - **Dry run.** `-n`/`--dry-run` never asks the provider. It lists the key
260
+ names it would inject: `shop: would inject secret_env compose:
261
+ OPENAI_API_KEY (names only; not resolved in a dry run)`.
262
+ - **Validation.** `<cli> validate` rejects a ref that is not exactly three
263
+ non-empty parts and a key that is not an env var name
264
+ (`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
265
+ `secret_env` too.
266
+
216
267
  ## den.yml: the product profile
217
268
 
218
269
  Every runtime name derives from `product.name`. Set only what you want to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,6 +72,17 @@ async function main(): Promise<number> {
72
72
  const r = await provider.preflight();
73
73
  if (r.ok) return 0;
74
74
  if (gate && !cfg.required) {
75
+ // Signed in but no default scope: secret_env refs name their own
76
+ // (<project>/<config>/<key>), so only the engine's own secrets lack one.
77
+ if (await provider.isReady()) {
78
+ console.error(
79
+ chalk.gray(
80
+ ` ${label}: signed in, no default scope set (secret_env refs still resolve;` +
81
+ ` test logins and the saved sudo password need one: \`make ${product().cli}-setup-secrets\`).`,
82
+ ),
83
+ );
84
+ return 0;
85
+ }
75
86
  console.error(
76
87
  chalk.yellow(
77
88
  `⚠️ ${label} is not ready — continuing.\n` +
@@ -27,6 +27,15 @@ import {
27
27
  shellQuote,
28
28
  } from "../terminal/tabs.js";
29
29
  import { readCatalog } from "./catalog.js";
30
+ import { getSecretsProvider } from "../secrets/index.js";
31
+ import {
32
+ assertSecretEnvSpec,
33
+ resolveSecretEnv,
34
+ secretEnvKeys,
35
+ type SecretEnvSpec,
36
+ } from "../secrets/secret-env.js";
37
+ import type { SecretsProvider } from "../secrets/types.js";
38
+ import { composeCommands } from "../stacks/manifest-commands.js";
30
39
  import { buildServiceEnv } from "./service-env.js";
31
40
  import {
32
41
  devOpContainerNames,
@@ -1069,6 +1078,12 @@ export interface RunCommandOptions {
1069
1078
  * the detach window is invisible to fail-fast callers.
1070
1079
  */
1071
1080
  onServiceExit?: (exitCode: number) => void;
1081
+ /**
1082
+ * R16: the service's resolved `secret_env` (see prepareServiceSecretEnv),
1083
+ * set only in the spawned process's env. An up/restart of a service that
1084
+ * declares secret_env is refused unless every key is here.
1085
+ */
1086
+ secretEnv?: Record<string, string>;
1072
1087
  }
1073
1088
 
1074
1089
  export interface RunCommandResult {
@@ -1135,6 +1150,108 @@ function repoStartedFallback(
1135
1150
  };
1136
1151
  }
1137
1152
 
1153
+ // ── secret_env (R16) ─────────────────────────────────────────────────────────
1154
+
1155
+ /** Commands that start a service process, and so get its secret_env. */
1156
+ function startsService(command: string): boolean {
1157
+ return command === "up" || command === "restart";
1158
+ }
1159
+
1160
+ /** A service's declared `services.<svc>.secret_env` (validated), or undefined. */
1161
+ function serviceSecretEnvSpec(
1162
+ resolved: ResolvedService,
1163
+ ): SecretEnvSpec | undefined {
1164
+ const raw = readManifest(resolved.repoPath)?.services?.[resolved.subsystem]
1165
+ ?.secret_env;
1166
+ return assertSecretEnvSpec(
1167
+ raw,
1168
+ `${resolved.address}: services.${resolved.subsystem}.secret_env`,
1169
+ );
1170
+ }
1171
+
1172
+ /**
1173
+ * The address whose command a run of `command` on `address` actually spawns
1174
+ * (a service with no up of its own runs its repo's up), or null when none.
1175
+ */
1176
+ function spawningAddress(
1177
+ address: string,
1178
+ command: string,
1179
+ workspace?: string,
1180
+ ): string | null {
1181
+ const p = previewServiceCommand(address, command, workspace);
1182
+ return p.kind === "run" || p.kind === "via-repo" ? p.address : null;
1183
+ }
1184
+
1185
+ /**
1186
+ * The secret_env key NAMES a run of `command` on `address` would inject,
1187
+ * for a dry run or status: never a value, and the provider is never asked.
1188
+ * `service` is the service's own secret_env (set in its command's env);
1189
+ * `compose` is the repo's compose.secret_env, which Den's synthesized stack
1190
+ * command resolves for the docker compose process.
1191
+ */
1192
+ export function previewServiceSecretEnv(
1193
+ address: string,
1194
+ command: string,
1195
+ workspace?: string,
1196
+ ): { address: string; service: string[]; compose: string[] } | null {
1197
+ if (!startsService(command)) return null;
1198
+ const target = spawningAddress(address, command, workspace);
1199
+ const resolved = target ? resolveService(target, workspace) : null;
1200
+ if (!resolved) return null;
1201
+ const manifest = readManifest(resolved.repoPath);
1202
+ const service = Object.keys(
1203
+ manifest?.services?.[resolved.subsystem]?.secret_env ?? {},
1204
+ ).sort();
1205
+ const declared = manifest?.services?.[resolved.subsystem]?.commands?.local;
1206
+ const synthesized =
1207
+ manifest?.compose && !declared?.[command]
1208
+ ? composeCommands(
1209
+ manifest,
1210
+ resolved.subsystem,
1211
+ resolved.repoName,
1212
+ workspace ?? "default",
1213
+ )[command]
1214
+ : undefined;
1215
+ const compose = synthesized
1216
+ ? Object.keys(manifest?.compose?.secret_env ?? {}).sort()
1217
+ : [];
1218
+ if (!service.length && !compose.length) return null;
1219
+ return { address: resolved.address, service, compose };
1220
+ }
1221
+
1222
+ export type ServiceSecretEnvResult =
1223
+ | { ok: true; secretEnv: Record<string, string> }
1224
+ | { ok: false; message: string };
1225
+
1226
+ /**
1227
+ * Resolve the `secret_env` of the service a run of `command` on `address`
1228
+ * spawns, for runServiceCommand's `secretEnv`. All or nothing; the provider
1229
+ * is only asked when the service declares secret_env, and only for up and
1230
+ * restart. Messages name keys, refs and the provider, never values.
1231
+ */
1232
+ export async function prepareServiceSecretEnv(
1233
+ address: string,
1234
+ command: string,
1235
+ workspace?: string,
1236
+ provider: () => SecretsProvider = getSecretsProvider,
1237
+ ): Promise<ServiceSecretEnvResult> {
1238
+ if (!startsService(command)) return { ok: true, secretEnv: {} };
1239
+ const target = spawningAddress(address, command, workspace);
1240
+ const resolved = target ? resolveService(target, workspace) : null;
1241
+ if (!resolved) return { ok: true, secretEnv: {} };
1242
+ let spec: SecretEnvSpec | undefined;
1243
+ try {
1244
+ spec = serviceSecretEnvSpec(resolved);
1245
+ } catch (e) {
1246
+ return { ok: false, message: (e as Error).message };
1247
+ }
1248
+ if (!secretEnvKeys(spec).length) return { ok: true, secretEnv: {} };
1249
+ const r = await resolveSecretEnv(spec, provider(), resolved.address);
1250
+ return r.ok
1251
+ ? { ok: true, secretEnv: r.env }
1252
+ : { ok: false, message: r.message };
1253
+ }
1254
+
1138
1255
  /** What a run of `command` on `address` would do, without running it. */
1139
1256
  export type ServiceCommandPreview =
1140
1257
  | { kind: "run"; address: string }
@@ -1516,6 +1633,41 @@ export function runServiceCommand(
1516
1633
  onLine,
1517
1634
  );
1518
1635
 
1636
+ // R16: a service that declares secret_env never starts without every key
1637
+ // (a caller that did not resolve them is refused, not run without them).
1638
+ let secretEnv: Record<string, string> = {};
1639
+ if (startsService(command)) {
1640
+ let declared: string[];
1641
+ try {
1642
+ declared = secretEnvKeys(serviceSecretEnvSpec(resolved));
1643
+ } catch (e) {
1644
+ onLine(`Error: ${(e as Error).message}`);
1645
+ closeAsync(1);
1646
+ return dummy();
1647
+ }
1648
+ const missing = declared.filter((k) => !options.secretEnv?.[k]);
1649
+ if (missing.length) {
1650
+ onLine(
1651
+ `Error: ${resolved.address} declares secret_env ${missing.join(", ")}, which were not resolved; not starting it`,
1652
+ );
1653
+ closeAsync(1);
1654
+ return dummy();
1655
+ }
1656
+ secretEnv = Object.fromEntries(
1657
+ declared.map((k) => [k, options.secretEnv?.[k] as string]),
1658
+ );
1659
+ }
1660
+ const hasSecrets = Object.keys(secretEnv).length > 0;
1661
+ // Secrets go only in the spawned process's env. A tab would need them in
1662
+ // its command line (and so its shell history), so such a start runs as a
1663
+ // daemon instead.
1664
+ const runEnv: NodeJS.ProcessEnv = { ...env, ...secretEnv };
1665
+ if (hasSecrets && runtime === "tab" && startsService(command))
1666
+ onLine(
1667
+ `${resolved.address} has secret_env: running as a daemon, not in a tab, to keep the values out of the tab's shell`,
1668
+ );
1669
+ const useTab = runtime === "tab" && !hasSecrets;
1670
+
1519
1671
  // Manifest downs that are just `opctl container down <name>` must not be
1520
1672
  // spawned raw: opctl re-elevates with `sudo -E` and fails on sudoers without
1521
1673
  // SETENV even when there is nothing to stop — which is why files-api/api
@@ -1571,7 +1723,7 @@ export function runServiceCommand(
1571
1723
  // HITL command — inherit the full TTY; onLine is not called.
1572
1724
  const proc = spawnInteractive(
1573
1725
  cmdString,
1574
- { ...process.env, ...env },
1726
+ { ...process.env, ...runEnv },
1575
1727
  onClose,
1576
1728
  repoPath,
1577
1729
  );
@@ -1621,11 +1773,11 @@ export function runServiceCommand(
1621
1773
  }
1622
1774
  };
1623
1775
 
1624
- if (runtime === "tab" && command === "up") {
1776
+ if (useTab && command === "up") {
1625
1777
  return openCommandInTab(cmdString);
1626
1778
  }
1627
1779
 
1628
- if (runtime === "tab" && command === "restart") {
1780
+ if (useTab && command === "restart") {
1629
1781
  closeExistingTab();
1630
1782
  if (requestedCmdDef) return openCommandInTab(cmdString);
1631
1783
 
@@ -1743,7 +1895,7 @@ export function runServiceCommand(
1743
1895
  const proc = spawnStreaming(
1744
1896
  cmdString,
1745
1897
  [],
1746
- { ...process.env, ...env },
1898
+ { ...process.env, ...runEnv },
1747
1899
  onLine,
1748
1900
  wrappedOnClose,
1749
1901
  repoPath,
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { z } from "zod";
11
11
  import { journeysAppSchema } from "../../pillars/journeys/app-config.js";
12
+ import { SECRET_ENV_KEY_RE, secretEnvRefError } from "../secrets/secret-env.js";
12
13
 
13
14
  export const CURRENT_SCHEMA_VERSION = 1;
14
15
  export const KNOWN_VERSIONS = [1] as const;
@@ -69,6 +70,16 @@ const logSource = z.union([
69
70
  }),
70
71
  ]);
71
72
 
73
+ // secret_env (R16): ENV_NAME → "<vault>/<item>/<field>", resolved through the
74
+ // secrets provider at up/restart and passed only in the process env.
75
+ const secretEnvConfig = z.record(
76
+ z.string().regex(SECRET_ENV_KEY_RE, "must be a valid env var name"),
77
+ z.string().superRefine((ref, ctx) => {
78
+ const err = secretEnvRefError(ref);
79
+ if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
80
+ }),
81
+ );
82
+
72
83
  // ── Service management config ─────────────────────────────────────────────────
73
84
  //
74
85
  // Keys for commands/logs/health are environment names (local, dev, …).
@@ -105,6 +116,7 @@ const serviceTarget = z
105
116
  const serviceManagementConfig = z.object({
106
117
  runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
107
118
  target: serviceTarget.optional(),
119
+ secret_env: secretEnvConfig.optional(),
108
120
  depends_on: z.array(dependsOnEntry).optional(),
109
121
  commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
110
122
  logs: z.record(z.string(), logSource).optional(),
@@ -280,6 +292,9 @@ const composeConfig = z.object({
280
292
  z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
281
293
  )
282
294
  .optional(),
295
+ // Env vars resolved from the secrets provider into the docker compose
296
+ // process's env at up/restart. Never written to the override.
297
+ secret_env: secretEnvConfig.optional(),
283
298
  });
284
299
 
285
300
  // ── v1 manifest schema ────────────────────────────────────────────────────────
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Project/config come from `secrets.doppler.{project,config}` in
6
6
  * config/den.yml, else from the directory's `doppler setup` scope,
7
- * else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN.
7
+ * else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN. A scoped ref
8
+ * (a `secret_env` value, `<project>/<config>/<key>`) names its own.
8
9
  */
9
10
  import { execFile, spawn } from "child_process";
10
11
  import { promisify } from "util";
@@ -29,8 +30,12 @@ export class DopplerProvider implements SecretsProvider {
29
30
  this.timeout = opts.timeoutMs ?? 30_000;
30
31
  }
31
32
 
32
- /** `--project/--config` flags when configured explicitly. */
33
- scopeArgs(): string[] {
33
+ /**
34
+ * `--project/--config` flags: a scoped ref's own vault/item, else the
35
+ * configured ones (when set explicitly).
36
+ */
37
+ scopeArgs(ref?: SecretRef): string[] {
38
+ if (ref?.scoped) return ["--project", ref.vault, "--config", ref.item];
34
39
  const a: string[] = [];
35
40
  if (this.opts.project) a.push("--project", this.opts.project);
36
41
  if (this.opts.config) a.push("--config", this.opts.config);
@@ -56,7 +61,7 @@ export class DopplerProvider implements SecretsProvider {
56
61
  const key = this.keyFor(ref);
57
62
  const { stdout } = await execFileAsync(
58
63
  "doppler",
59
- ["secrets", "get", key, "--plain", ...this.scopeArgs()],
64
+ ["secrets", "get", key, "--plain", ...this.scopeArgs(ref)],
60
65
  { timeout: this.timeout },
61
66
  );
62
67
  return String(stdout).trim();
@@ -77,7 +82,7 @@ export class DopplerProvider implements SecretsProvider {
77
82
  await new Promise<void>((resolve, reject) => {
78
83
  const child = spawn(
79
84
  "doppler",
80
- ["secrets", "set", key, "--silent", ...this.scopeArgs()],
85
+ ["secrets", "set", key, "--silent", ...this.scopeArgs(ref)],
81
86
  { stdio: ["pipe", "ignore", "pipe"] },
82
87
  );
83
88
  let stderr = "";
@@ -26,6 +26,7 @@ export { flatKeyFor, toEnvName, refPath } from "./keys.js";
26
26
  export { DopplerProvider } from "./doppler.js";
27
27
  export { EnvProvider } from "./env.js";
28
28
  export { OnePasswordProvider } from "./onepassword.js";
29
+ export * from "./secret-env.js";
29
30
 
30
31
  export interface SecretsConfig {
31
32
  provider?: SecretsProviderName;
@@ -16,6 +16,7 @@ export function refPath(ref: SecretRef): string {
16
16
 
17
17
  /**
18
18
  * Resolve the flat key a key/value backend should use for `ref`:
19
+ * 0. a scoped ref's field, as is (it is fully qualified: no overrides);
19
20
  * 1. `ref.key` when the caller pinned one;
20
21
  * 2. the `secrets.keys` override map (`"vault/item/field": KEY`);
21
22
  * 3. the field itself when it already looks like an env var (API keys);
@@ -25,6 +26,7 @@ export function flatKeyFor(
25
26
  ref: SecretRef,
26
27
  overrides: Record<string, string> = {},
27
28
  ): string {
29
+ if (ref.scoped) return ref.field;
28
30
  if (ref.key) return ref.key;
29
31
  const mapped = overrides[refPath(ref)];
30
32
  if (mapped) return mapped;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `secret_env` (R16): environment variables whose values Den resolves from
3
+ * the configured secrets provider when it starts or restarts a target.
4
+ *
5
+ * compose:
6
+ * secret_env:
7
+ * OPENAI_API_KEY: proj/dev_personal/OPENAI_API_KEY
8
+ * services:
9
+ * api:
10
+ * secret_env:
11
+ * SOME_KEY: vault/item/field
12
+ *
13
+ * Each value is a fully qualified `a/b/c` path, parsed into a SCOPED
14
+ * {@link SecretRef} (see its `scoped` field for what each provider does with
15
+ * it). Values only ever travel in the spawned process's env: never into the
16
+ * compose override, logs, activity records, MCP output, dry runs or errors.
17
+ * Everything here that produces text names keys and refs, never values.
18
+ *
19
+ * This module only imports types, so the manifest schema can use the parser.
20
+ */
21
+ import type { SecretRef, SecretsProvider } from "./types.js";
22
+
23
+ /** A `secret_env` block: env var name → `a/b/c` ref. */
24
+ export type SecretEnvSpec = Record<string, string>;
25
+
26
+ /** Env var names Den will set (POSIX-portable). */
27
+ export const SECRET_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
28
+
29
+ /** Why `raw` is not a valid secret_env ref, or null when it is. */
30
+ export function secretEnvRefError(raw: unknown): string | null {
31
+ if (typeof raw !== "string")
32
+ return 'must be a string "<vault>/<item>/<field>"';
33
+ const parts = raw.split("/");
34
+ if (
35
+ parts.length !== 3 ||
36
+ parts.some((p) => p.trim() === "" || p !== p.trim())
37
+ )
38
+ return `"${raw}" must be exactly three non-empty parts, "<vault>/<item>/<field>" (Doppler: <project>/<config>/<key>)`;
39
+ return null;
40
+ }
41
+
42
+ /** Parse an `a/b/c` ref into a scoped SecretRef. Throws when malformed. */
43
+ export function parseSecretEnvRef(raw: string): SecretRef {
44
+ const err = secretEnvRefError(raw);
45
+ if (err) throw new Error(`secret_env ref ${err}`);
46
+ const [vault, item, field] = raw.split("/");
47
+ return { vault, item, field, scoped: true };
48
+ }
49
+
50
+ /**
51
+ * Problems with a whole `secret_env` block, as `KEY: message` strings ([]
52
+ * when it is valid). `where` prefixes each message.
53
+ */
54
+ export function secretEnvSpecErrors(spec: unknown, where: string): string[] {
55
+ if (spec === undefined) return [];
56
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec))
57
+ return [`${where} must be a map of ENV_NAME: "<vault>/<item>/<field>"`];
58
+ const out: string[] = [];
59
+ for (const [k, v] of Object.entries(spec)) {
60
+ if (!SECRET_ENV_KEY_RE.test(k))
61
+ out.push(`${where}.${k}: "${k}" is not a valid env var name`);
62
+ const err = secretEnvRefError(v);
63
+ if (err) out.push(`${where}.${k}: ref ${err}`);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** The validated block, or throws listing every problem. */
69
+ export function assertSecretEnvSpec(
70
+ spec: unknown,
71
+ where: string,
72
+ ): SecretEnvSpec | undefined {
73
+ if (spec === undefined) return undefined;
74
+ const errors = secretEnvSpecErrors(spec, where);
75
+ if (errors.length) throw new Error(errors.join("\n"));
76
+ return { ...(spec as SecretEnvSpec) };
77
+ }
78
+
79
+ /** One key that could not be resolved. Never carries a value. */
80
+ export interface SecretEnvMissing {
81
+ key: string;
82
+ ref: string;
83
+ }
84
+
85
+ export type SecretEnvResolution =
86
+ | { ok: true; env: Record<string, string> }
87
+ | { ok: false; missing: SecretEnvMissing[]; message: string };
88
+
89
+ /**
90
+ * The failure text for unresolved keys: key, ref and provider, never a value
91
+ * and never the provider's own error output (which could echo one).
92
+ */
93
+ export function describeSecretEnvMissing(
94
+ target: string,
95
+ missing: SecretEnvMissing[],
96
+ provider: string,
97
+ ): string {
98
+ const lines = missing.map(
99
+ (m) => ` ${m.key} (ref ${m.ref}, provider ${provider}): not resolved`,
100
+ );
101
+ return (
102
+ `${target}: ${missing.length} secret_env key(s) could not be resolved; not starting it:\n` +
103
+ lines.join("\n")
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Resolve every key of `spec` through `provider`. All or nothing: when any
109
+ * key is missing (unset, empty, unreadable) the result is a failure naming
110
+ * each missing key, and no partial env is returned.
111
+ */
112
+ export async function resolveSecretEnv(
113
+ spec: SecretEnvSpec | undefined,
114
+ provider: SecretsProvider,
115
+ target: string,
116
+ ): Promise<SecretEnvResolution> {
117
+ const entries = Object.entries(spec ?? {});
118
+ if (!entries.length) return { ok: true, env: {} };
119
+ const results = await Promise.all(
120
+ entries.map(async ([key, raw]) => {
121
+ if (secretEnvRefError(raw)) return { key, raw, value: null };
122
+ let value: string | null = null;
123
+ try {
124
+ value = await provider.tryGet(parseSecretEnvRef(raw));
125
+ } catch {
126
+ value = null;
127
+ }
128
+ return { key, raw, value };
129
+ }),
130
+ );
131
+ const missing = results
132
+ .filter((r) => !r.value)
133
+ .map((r) => ({ key: r.key, ref: String(r.raw) }));
134
+ if (missing.length)
135
+ return {
136
+ ok: false,
137
+ missing,
138
+ message: describeSecretEnvMissing(target, missing, provider.name),
139
+ };
140
+ return {
141
+ ok: true,
142
+ env: Object.fromEntries(results.map((r) => [r.key, r.value as string])),
143
+ };
144
+ }
145
+
146
+ /** The keys of a block, sorted: what a dry run or status may show. */
147
+ export function secretEnvKeys(spec: SecretEnvSpec | undefined): string[] {
148
+ return Object.keys(spec ?? {}).sort();
149
+ }
@@ -22,6 +22,16 @@ export interface SecretRef {
22
22
  * `flatKeyFor`.
23
23
  */
24
24
  key?: string;
25
+ /**
26
+ * Set on a fully qualified ref (a `secret_env` value, `a/b/c`): every part
27
+ * names a real location and no provider config fills it in.
28
+ * - Doppler: vault = `--project`, item = `--config`, field = the key
29
+ * (`secrets.doppler` and `secrets.keys` are not consulted).
30
+ * - 1Password: vault / item / field as for any ref.
31
+ * - env: field is the env var name (`secrets.keys` is not consulted).
32
+ * Unscoped refs (the default) keep the provider's configured scope.
33
+ */
34
+ scoped?: true;
25
35
  }
26
36
 
27
37
  export type SecretsProviderName = "doppler" | "1password" | "env";
@@ -17,6 +17,7 @@ import {
17
17
  } from "../environment-manager/workspaces.js";
18
18
  import { readManifest } from "../manifest/read-manifest.js";
19
19
  import type { ManifestComposeConfig } from "../types/index.js";
20
+ import { assertSecretEnvSpec } from "../secrets/secret-env.js";
20
21
  import { assertStackName, parseTarget } from "./render.js";
21
22
  import type { StackConfig } from "./types.js";
22
23
 
@@ -41,6 +42,10 @@ function toStack(
41
42
  const routes = raw.routes ?? {};
42
43
  Object.values(routes).forEach(parseTarget);
43
44
  const files = list(raw.files, `${where}.files`);
45
+ const secretEnv =
46
+ "secret_env" in raw
47
+ ? assertSecretEnvSpec(raw.secret_env, `${where}.secret_env`)
48
+ : undefined;
44
49
  return {
45
50
  name,
46
51
  repo,
@@ -69,6 +74,7 @@ function toStack(
69
74
  ),
70
75
  }
71
76
  : {}),
77
+ ...(secretEnv && Object.keys(secretEnv).length ? { secretEnv } : {}),
72
78
  };
73
79
  }
74
80
 
@@ -16,6 +16,12 @@ import {
16
16
  renderStackRoutes,
17
17
  routedNetworks,
18
18
  } from "./render.js";
19
+ import {
20
+ resolveSecretEnv,
21
+ secretEnvKeys,
22
+ type SecretEnvResolution,
23
+ } from "../secrets/secret-env.js";
24
+ import type { SecretsProvider } from "../secrets/types.js";
19
25
  import type { ResolvedProject, StackConfig } from "./types.js";
20
26
 
21
27
  function run(
@@ -117,6 +123,35 @@ function composeEnv(ctx: StackContext, s: StackConfig): NodeJS.ProcessEnv {
117
123
  };
118
124
  }
119
125
 
126
+ /**
127
+ * Resolve a stack's `secret_env` through `provider` (all or nothing). Call
128
+ * it before anything starts or stops, and hand the env to stackUp /
129
+ * stackRestartService. Nothing in the result's message carries a value.
130
+ */
131
+ export function resolveStackSecretEnv(
132
+ s: StackConfig,
133
+ provider: SecretsProvider,
134
+ ): Promise<SecretEnvResolution> {
135
+ return resolveSecretEnv(s.secretEnv, provider, `stack ${s.name}`);
136
+ }
137
+
138
+ /**
139
+ * The resolved secrets for a compose process that starts containers. Fails
140
+ * closed: a stack that declares `secret_env` is never started without every
141
+ * key (that is how a plain restart used to drop one).
142
+ */
143
+ function secretComposeEnv(
144
+ s: StackConfig,
145
+ secretEnv: Record<string, string> | undefined,
146
+ ): Record<string, string> {
147
+ const missing = secretEnvKeys(s.secretEnv).filter((k) => !secretEnv?.[k]);
148
+ if (missing.length)
149
+ throw new Error(
150
+ `stack ${s.name}: secret_env ${missing.join(", ")} not resolved; not starting it`,
151
+ );
152
+ return secretEnv ?? {};
153
+ }
154
+
120
155
  function baseArgs(ctx: StackContext, s: StackConfig): string[] {
121
156
  return [
122
157
  "compose",
@@ -168,8 +203,15 @@ function running(container: string): boolean {
168
203
  export function stackUp(
169
204
  ctx: StackContext,
170
205
  s: StackConfig,
171
- opts: { build?: boolean } = {},
206
+ opts: {
207
+ build?: boolean;
208
+ /** The stack's resolved `secret_env` (see resolveStackSecretEnv). */
209
+ secretEnv?: Record<string, string>;
210
+ } = {},
172
211
  ) {
212
+ const secrets = secretComposeEnv(s, opts.secretEnv);
213
+ // `compose config` runs without them: its JSON (which feeds the override)
214
+ // must not carry a secret.
173
215
  const resolved = resolve(ctx, s);
174
216
  // Provider side of each link: its referenced services join a private network.
175
217
  const needs = providerServices(s);
@@ -200,7 +242,11 @@ export function stackUp(
200
242
  ...(opts.build ? ["--build"] : []),
201
243
  "--remove-orphans",
202
244
  ],
203
- { cwd: repoDir(s), env: composeEnv(ctx, s), inherit: true },
245
+ {
246
+ cwd: repoDir(s),
247
+ env: { ...composeEnv(ctx, s), ...secrets },
248
+ inherit: true,
249
+ },
204
250
  );
205
251
  // This stack as a provider: consumers already running keep their link
206
252
  // network, but a restarted provider's new containers must rejoin it.
@@ -274,14 +320,16 @@ export function stackRestartService(
274
320
  ctx: StackContext,
275
321
  s: StackConfig,
276
322
  service: string,
323
+ opts: { secretEnv?: Record<string, string> } = {},
277
324
  ): void {
325
+ const secrets = secretComposeEnv(s, opts.secretEnv);
278
326
  const override = path.join(stackDir(ctx, s), "override.yml");
279
327
  const args = fs.existsSync(override)
280
328
  ? overrideArgs(ctx, s)
281
329
  : baseArgs(ctx, s);
282
330
  must("docker", [...args, "restart", service], {
283
331
  cwd: repoDir(s),
284
- env: composeEnv(ctx, s),
332
+ env: { ...composeEnv(ctx, s), ...secrets },
285
333
  inherit: true,
286
334
  });
287
335
  }
@@ -10,6 +10,8 @@
10
10
  * uses: [ragstack] # start first + private link network
11
11
  * env: # compose ${VAR} substitution
12
12
  * RAG_PROXY_TARGET: "http://{ragstack.ragstack-server}:80"
13
+ * secret_env: # from the secrets provider, at up/restart
14
+ * OPENAI_API_KEY: proj/dev/OPENAI_API_KEY
13
15
  *
14
16
  * `{stack.service}` expands to that service's container name.
15
17
  */
@@ -36,6 +38,12 @@ export interface StackConfig {
36
38
  * `${VAR}` substitution) cannot change.
37
39
  */
38
40
  serviceEnv?: Record<string, Record<string, string>>;
41
+ /**
42
+ * `secret_env` (R16): env var → `<vault>/<item>/<field>` ref. Resolved
43
+ * through the secrets provider by `stack up` / `stack restart` and passed
44
+ * only in the docker compose process's env, never in the override file.
45
+ */
46
+ secretEnv?: Record<string, string>;
39
47
  }
40
48
 
41
49
  /** What `docker compose config --format json` tells us about a service. */
@@ -276,6 +276,12 @@ export interface ServiceManagementConfig {
276
276
  runs?: Partial<Record<Environment, ServiceRunsValue>>;
277
277
  /** Where a remote service runs: the env, an account label, a URL to probe. */
278
278
  target?: ServiceTargetConfig;
279
+ /**
280
+ * R16: env var → `<vault>/<item>/<field>` ref, resolved through the
281
+ * secrets provider when this service's up/restart command runs, and set
282
+ * only in that process's env (see src/shared/secrets/secret-env.ts).
283
+ */
284
+ secret_env?: Record<string, string>;
279
285
  }
280
286
 
281
287
  export type ServiceRunsValue = "local" | "remote";
@@ -572,6 +578,15 @@ export interface ManifestComposeConfig {
572
578
  profiles?: string[];
573
579
  /** service → replacement healthcheck `test` (written to den's override, not the repo). */
574
580
  healthchecks?: Record<string, string[]>;
581
+ /** service → env vars set on that service in the override (wins by key). */
582
+ service_env?: Record<string, Record<string, string | number | boolean>>;
583
+ /**
584
+ * R16: env var → `<vault>/<item>/<field>` ref, resolved through the
585
+ * secrets provider at stack up/restart and set only in the docker compose
586
+ * process's env (for the compose file's `${VAR}` / pass-through
587
+ * `environment:` entries). Never written to the override.
588
+ */
589
+ secret_env?: Record<string, string>;
575
590
  }
576
591
 
577
592
  export interface MigrationResult {
@@ -99,6 +99,8 @@ import { waitForReady } from "../../shared/environment-manager/readiness.js";
99
99
  import {
100
100
  canonicalServiceAddress,
101
101
  previewServiceCommand,
102
+ previewServiceSecretEnv,
103
+ prepareServiceSecretEnv,
102
104
  resolveService,
103
105
  type ServiceCommandPreview,
104
106
  } from "../../shared/environment-manager/service-runner.js";
@@ -1453,6 +1455,20 @@ class DenCLI {
1453
1455
  console.log(chalk.gray(` ${p.note}`));
1454
1456
  if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1455
1457
  }
1458
+ // R16: key names only. A dry run never asks the secrets provider.
1459
+ for (const a of new Set(runs)) {
1460
+ const sec = previewServiceSecretEnv(a, command, workspace);
1461
+ if (!sec) continue;
1462
+ const parts = [
1463
+ ...(sec.service.length ? [sec.service.join(", ")] : []),
1464
+ ...(sec.compose.length ? [`compose: ${sec.compose.join(", ")}`] : []),
1465
+ ];
1466
+ console.log(
1467
+ chalk.gray(
1468
+ ` ${sec.address}: would inject secret_env ${parts.join("; ")} (names only; not resolved in a dry run)`,
1469
+ ),
1470
+ );
1471
+ }
1456
1472
  if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1457
1473
  console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1458
1474
  return;
@@ -1590,7 +1606,22 @@ class DenCLI {
1590
1606
  }
1591
1607
  }
1592
1608
 
1609
+ // R16: resolve the service's secret_env before anything spawns. A
1610
+ // missing key fails this target; nothing starts with a partial set.
1611
+ const secrets = await prepareServiceSecretEnv(
1612
+ address,
1613
+ command,
1614
+ workspace,
1615
+ );
1616
+
1593
1617
  const started = await new Promise<boolean>((resolve) => {
1618
+ if (!secrets.ok) {
1619
+ console.log(chalk.red(` ✗ ${command} ${chalk.bold(address)}`));
1620
+ for (const l of secrets.message.split("\n"))
1621
+ console.log(chalk.gray(` ${l}`));
1622
+ resolve(false);
1623
+ return;
1624
+ }
1594
1625
  const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
1595
1626
  let detachTimer: ReturnType<typeof setTimeout> | null = null;
1596
1627
  let settled = false;
@@ -1672,6 +1703,7 @@ class DenCLI {
1672
1703
  runtime,
1673
1704
  workspace,
1674
1705
  force: command === "down" ? force : false,
1706
+ secretEnv: secrets.secretEnv,
1675
1707
  onLine: (line) => {
1676
1708
  spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1677
1709
  const trimmed = line.trim();
@@ -8,8 +8,11 @@
8
8
  import chalk from "chalk";
9
9
  import { routeUrl } from "../../shared/edge/render.js";
10
10
  import { edgeUp } from "../../shared/edge/runtime.js";
11
+ import { getSecretsProvider } from "../../shared/secrets/index.js";
12
+ import type { SecretsProvider } from "../../shared/secrets/types.js";
11
13
  import { projectName, startOrder } from "../../shared/stacks/render.js";
12
14
  import {
15
+ resolveStackSecretEnv,
13
16
  stackDown,
14
17
  stackHealth,
15
18
  stackLogs,
@@ -18,6 +21,7 @@ import {
18
21
  stackUp,
19
22
  } from "../../shared/stacks/runtime.js";
20
23
  import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
24
+ import type { StackConfig } from "../../shared/stacks/types.js";
21
25
 
22
26
  function serviceFlag(args: string[]): { service?: string; rest: string[] } {
23
27
  const rest: string[] = [];
@@ -29,6 +33,29 @@ function serviceFlag(args: string[]): { service?: string; rest: string[] } {
29
33
  return { service, rest };
30
34
  }
31
35
 
36
+ /**
37
+ * Every named stack's `secret_env`, resolved before anything starts or
38
+ * stops: one missing key fails the whole command with nothing changed.
39
+ * The provider is only asked when some stack declares secret_env.
40
+ */
41
+ export async function resolveStacksSecretEnv(
42
+ targets: StackConfig[],
43
+ provider: () => SecretsProvider = getSecretsProvider,
44
+ ): Promise<Map<string, Record<string, string>>> {
45
+ const out = new Map<string, Record<string, string>>();
46
+ const needing = targets.filter((s) => Object.keys(s.secretEnv ?? {}).length);
47
+ if (!needing.length) return out;
48
+ const p = provider();
49
+ const failures: string[] = [];
50
+ for (const s of needing) {
51
+ const r = await resolveStackSecretEnv(s, p);
52
+ if (r.ok) out.set(s.name, r.env);
53
+ else failures.push(r.message);
54
+ }
55
+ if (failures.length) throw new Error(failures.join("\n"));
56
+ return out;
57
+ }
58
+
32
59
  export async function cmdStack(argv: string[]): Promise<void> {
33
60
  const { service, rest } = serviceFlag(argv);
34
61
  const [sub = "ls", ...args] = rest;
@@ -62,21 +89,32 @@ export async function cmdStack(argv: string[]): Promise<void> {
62
89
  return;
63
90
  case "up": {
64
91
  // Re-render the edge so its cert covers every stack, then start the stacks.
92
+ const order = startOrder(pick(), stacks);
93
+ const secrets = await resolveStacksSecretEnv(order.map((n) => stacks[n]));
65
94
  await edgeUp(edge);
66
- for (const n of startOrder(pick(), stacks)) {
95
+ for (const n of order) {
67
96
  console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
68
- stackUp(ctx, stacks[n], { build: args.includes("--build") });
97
+ stackUp(ctx, stacks[n], {
98
+ build: args.includes("--build"),
99
+ secretEnv: secrets.get(n),
100
+ });
69
101
  for (const route of Object.keys(stacks[n].routes))
70
102
  console.log(chalk.green(` ✓ ${routeUrl(`${route}.${n}`, edge)}`));
71
103
  }
72
104
  return;
73
105
  }
74
- case "restart":
106
+ case "restart": {
107
+ // Resolved before the down: a missing secret leaves the stack running.
108
+ const secrets = await resolveStacksSecretEnv(
109
+ pick().map((n) => stacks[n]),
110
+ );
75
111
  if (service) {
76
112
  // One compose service, in place: no edge re-render, no recreate.
77
113
  for (const n of pick()) {
78
114
  console.log(chalk.cyan(`↻ ${n} ${service}`));
79
- stackRestartService(ctx, stacks[n], service);
115
+ stackRestartService(ctx, stacks[n], service, {
116
+ secretEnv: secrets.get(n),
117
+ });
80
118
  }
81
119
  return;
82
120
  }
@@ -84,9 +122,13 @@ export async function cmdStack(argv: string[]): Promise<void> {
84
122
  for (const n of pick()) {
85
123
  console.log(chalk.cyan(`↻ ${n}`));
86
124
  stackDown(ctx, stacks[n]);
87
- stackUp(ctx, stacks[n], { build: args.includes("--build") });
125
+ stackUp(ctx, stacks[n], {
126
+ build: args.includes("--build"),
127
+ secretEnv: secrets.get(n),
128
+ });
88
129
  }
89
130
  return;
131
+ }
90
132
  case "health": {
91
133
  let ok = true;
92
134
  for (const n of pick()) {
@@ -107,10 +107,12 @@ export const MCP_CLIENTS: readonly McpClient[] = [
107
107
  "claude",
108
108
  "mcp",
109
109
  "add",
110
+ // The name first: claude's -e takes several values, so a name
111
+ // after the env pairs is read as one more pair.
112
+ s.name,
110
113
  "--scope",
111
114
  "user",
112
115
  ...envFlags("-e", s.env),
113
- s.name,
114
116
  "--",
115
117
  s.command,
116
118
  ...s.args,
@@ -68,6 +68,7 @@ import {
68
68
  } from "../../shared/environment-manager/health.js";
69
69
  import {
70
70
  runServiceCommand,
71
+ prepareServiceSecretEnv,
71
72
  resolveService,
72
73
  listDaemons,
73
74
  unregisterDaemon,
@@ -1477,6 +1478,8 @@ async function handleRequest(
1477
1478
  {
1478
1479
  run: runServiceCommand,
1479
1480
  health: (r) => checkServiceHealth(r, "local"),
1481
+ secretEnv: (address, cmd, workspace) =>
1482
+ prepareServiceSecretEnv(address, cmd, workspace),
1480
1483
  },
1481
1484
  );
1482
1485
  return;
@@ -21,6 +21,7 @@ import type { ResolvedService } from "../../shared/types/index.js";
21
21
  import type {
22
22
  RunCommandOptions,
23
23
  RunCommandResult,
24
+ ServiceSecretEnvResult,
24
25
  } from "../../shared/environment-manager/service-runner.js";
25
26
  import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
26
27
 
@@ -34,6 +35,16 @@ export function isLifecycleExecCmd(c: string): c is LifecycleExecCmd {
34
35
  export interface LifecycleExecDeps {
35
36
  run: (o: RunCommandOptions) => RunCommandResult;
36
37
  health: (r: ResolvedService) => Promise<ServiceHealthResult>;
38
+ /**
39
+ * R16: resolve the service's secret_env (prepareServiceSecretEnv). Unset,
40
+ * nothing is resolved, and the runner refuses to start a service that
41
+ * declares secret_env.
42
+ */
43
+ secretEnv?: (
44
+ address: string,
45
+ cmd: LifecycleExecCmd,
46
+ workspace?: string,
47
+ ) => Promise<ServiceSecretEnvResult>;
37
48
  /** Health poll interval after a detached start (default 1s). */
38
49
  pollMs?: number;
39
50
  /** Stop polling after this long (default 120s). */
@@ -106,12 +117,24 @@ export async function streamLifecycleExec(
106
117
  }
107
118
  }
108
119
 
120
+ // R16: all of the service's secret_env, or nothing starts. The message
121
+ // names keys, refs and the provider, never a value.
122
+ const secrets = deps.secretEnv
123
+ ? await deps.secretEnv(resolved.address, cmd, o.workspace)
124
+ : { ok: true as const, secretEnv: undefined };
125
+ if (!secrets.ok) {
126
+ if (!clientGone) emit({ type: "error", text: secrets.message });
127
+ finish(1, true);
128
+ return;
129
+ }
130
+
109
131
  const result = deps.run({
110
132
  address: resolved.address,
111
133
  command: cmd,
112
134
  workspace: o.workspace,
113
135
  runtime: "daemon",
114
136
  env: o.env,
137
+ secretEnv: secrets.secretEnv,
115
138
  onLine: (text) => {
116
139
  if (!clientGone) emit({ type: "line", text });
117
140
  },
@@ -44,6 +44,7 @@ import {
44
44
  checkServiceHealth,
45
45
  claimHealthyService,
46
46
  runServiceCommand,
47
+ prepareServiceSecretEnv,
47
48
  resolveService,
48
49
  hasRunningOpctlContainers,
49
50
  releaseWorkspaceDaemons,
@@ -1411,6 +1412,10 @@ export async function handleWorkspaceRoutes(
1411
1412
  const startTime = Date.now();
1412
1413
  let overallExit = 0;
1413
1414
  let ranCount = 0;
1415
+ // R16: each started service's resolved secret_env, by position in
1416
+ // `ordered`. Resolved before a restart's down, so a missing key leaves
1417
+ // the service running; the values only go into the runner's spawn env.
1418
+ const preparedSecrets = new Map<number, Record<string, string>>();
1414
1419
 
1415
1420
  // Run services sequentially in dependency order
1416
1421
  const runNext = (i: number): void => {
@@ -1508,6 +1513,32 @@ export async function handleWorkspaceRoutes(
1508
1513
  }
1509
1514
  }
1510
1515
 
1516
+ if ((cmd === "up" || cmd === "restart") && !preparedSecrets.has(i)) {
1517
+ void (async () => {
1518
+ // Phase 2 of a restart is a true `up`, so resolve for that.
1519
+ const r = await prepareServiceSecretEnv(address, "up", ws.name);
1520
+ if (cancelled) return;
1521
+ if (!r.ok) {
1522
+ ranCount++;
1523
+ overallExit = 1;
1524
+ emit({ type: "label", text: address });
1525
+ emit({ type: "error", text: r.message });
1526
+ emit({
1527
+ type: "svc-done",
1528
+ address,
1529
+ exitCode: 1,
1530
+ repoName: svcRepoName,
1531
+ subsystem: svcSubsystem,
1532
+ });
1533
+ runNext(i + 1);
1534
+ return;
1535
+ }
1536
+ preparedSecrets.set(i, r.secretEnv);
1537
+ runNext(i);
1538
+ })();
1539
+ return;
1540
+ }
1541
+
1511
1542
  ranCount++;
1512
1543
 
1513
1544
  emit({ type: "label", text: address });
@@ -1540,6 +1571,7 @@ export async function handleWorkspaceRoutes(
1540
1571
  // the UI just hides the command they came here to watch.
1541
1572
  runtime: "daemon",
1542
1573
  env: getSudoEnv(),
1574
+ secretEnv: preparedSecrets.get(i),
1543
1575
  onLine: (line) => emit({ type: "line", text: line }),
1544
1576
  onClose: (exitCode) => {
1545
1577
  // Release outer-scope refs so the disconnect handler no longer