From d38dfacb447b24229a5e56e41efe1e3f4d6d00ca Mon Sep 17 00:00:00 2001 From: autodev Date: Fri, 14 Aug 2026 06:45:18 +0800 Subject: [PATCH 1/2] fix(config): resolve masked apiKey from env on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge persists config.yaml with API keys masked to __memos_secret__ via maskSecrets() and strips empty secrets from patches via stripEmptySecrets(), but nothing re-reads the real value back. On daemon restart, loadConfig() treats the mask as the literal API key, every LLM call fails auth, and the bridge restart-loops with lastOkAt: null and skill.crystallize stuck. Make resolveConfig() (the single choke point for both disk-loaded and in-memory patched configs) walk SECRET_FIELD_PATHS after pruneUnknown and before deepMerge: - ${VAR} references resolve from process.env when the name matches the allowlist ^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$; other names emit a warning and stay untouched. - __memos_secret__ / empty apiKey leaves fall back to LLM_API_KEY (or EMBEDDING_API_KEY for embedding.apiKey), then to OPENCODE_GO_API_KEY / OPENCODE_ZEN_API_KEY for LLM-class fields only. Embedding never inherits an LLM provider's key. - Hub tokens (hub.teamToken, hub.userToken) require an explicit ${VAR} — no path-based env convention. - Real values pass through unchanged; the caller's raw config object is never mutated (resolution runs on the pruneUnknown copy). Read-side only: on-disk write stays masked, so the security posture of maskSecrets() is preserved. Adds 10 unit tests under tests/unit/config/resolve-secret-env.test.ts covering ${VAR} expansion, mask sentinel resolution, empty-string fallback, per-path env conventions (embedding vs LLM channel isolation), hub token ${VAR} path, allowlist enforcement, non-mutation of the raw config, and negative cases (no env → mask retained; unset ${VAR} → literal preserved; real values untouched). Fixes #2245 --- apps/memos-local-plugin/core/config/index.ts | 83 +++++++++++++- .../unit/config/resolve-secret-env.test.ts | 103 ++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 6d529d960..1c7b8c5d3 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -18,7 +18,7 @@ import { MemosError } from "../../agent-contract/errors.js"; import type { ResolvedHome } from "./paths.js"; import { resolveHome } from "./paths.js"; import { ConfigSchema, type ResolvedConfig } from "./schema.js"; -import { DEFAULT_CONFIG, effectiveViewerPort } from "./defaults.js"; +import { DEFAULT_CONFIG, SECRET_FIELD_PATHS, effectiveViewerPort } from "./defaults.js"; import { migrateHermesViewerPort } from "./migrations.js"; import { parseYaml } from "./yaml.js"; @@ -72,9 +72,40 @@ export async function loadConfig(home: ResolvedHome, agent?: string): Promise use process.env[NAME]. Only allowlisted + // names are expanded (`^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$`); + // anything else emits a warning and is left untouched. + // 2. Value is the mask sentinel `__memos_secret__` or empty string + // -> use the env var inferred from the field path + // (llm.apiKey -> LLM_API_KEY, then OPENCODE_GO_API_KEY / + // OPENCODE_ZEN_API_KEY fallbacks for the opencode-go/zen + // providers). The generic fallbacks only apply to LLM-class + // fields — embedding.apiKey is never handed an LLM provider's key. + // 3. Otherwise leave the value untouched. + // + // The mask itself is never used as a credential, and the on-disk write + // stays masked (security preserved); this is read-side only. + resolveSecretEnv(cleaned, warnings); const merged = deepMerge(DEFAULT_CONFIG as Record, cleaned); stripUnsupportedEmbeddingDimensions(merged); const viewerPort = effectiveViewerPort(agent); @@ -104,6 +135,56 @@ export function resolveConfig(raw: unknown, warnings?: string[], agent?: string) // ─── helpers ──────────────────────────────────────────────────────────────── +/** Env var names accepted in `${NAME}` config references. */ +const ENV_REF_ALLOWLIST = /^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$/; + +/** + * Replace masked / empty / `${VAR}` secret leaves in `cleaned` (a freshly + * built, non-shared object — see `pruneUnknown`) with values from the + * environment. The caller's raw config object is never written to. + */ +function resolveSecretEnv(cleaned: Record, warnings?: string[]): void { + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: unknown = cleaned; + for (let i = 0; i < keys.length - 1; i++) { + if (!isPlainObject(cursor)) break; + cursor = (cursor as Record)[keys[i]!]; + } + if (!isPlainObject(cursor)) continue; + const leaf = keys[keys.length - 1]!; + const val = (cursor as Record)[leaf]; + if (typeof val !== "string") continue; + + let envName: string | null = null; + let genericFallbacks = false; + if (val.startsWith("${") && val.endsWith("}")) { + const name = val.slice(2, -1); + if (!ENV_REF_ALLOWLIST.test(name)) { + warnings?.push( + `config: leaving '${dotted}' as '${val}' — env name '${name}' is not allowlisted ` + + `(expected ^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$)` + ); + continue; + } + envName = name; + } else if (val === "__memos_secret__" || val === "") { + if (leaf !== "apiKey") continue; + const isEmbedding = keys[keys.length - 2] === "embedding"; + envName = isEmbedding ? "EMBEDDING_API_KEY" : "LLM_API_KEY"; + genericFallbacks = !isEmbedding; + } + if (!envName) continue; + + const envVal = + process.env[envName] ?? + (genericFallbacks + ? (process.env.OPENCODE_GO_API_KEY ?? process.env.OPENCODE_ZEN_API_KEY) + : undefined); + if (envVal) (cursor as Record)[leaf] = envVal; + } +} + function formatErr(e: ValueError): string { return `${e.path || ""}: ${e.message}`; } diff --git a/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts new file mode 100644 index 000000000..0a7652289 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveConfig } from "../../../core/config/index.js"; +import { SECRET_FIELD_PATHS } from "../../../core/config/defaults.js"; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("resolveConfig secret env fallback", () => { + it("expands allowlisted ${ENV_VAR} references in secret fields", () => { + process.env.MY_LLM_API_KEY = "sk-env-expanded"; + const cfg = resolveConfig({ llm: { apiKey: "${MY_LLM_API_KEY}" } }); + expect(cfg.llm.apiKey).toBe("sk-env-expanded"); + }); + + it("resolves the __memos_secret__ mask sentinel from env", () => { + process.env.OPENCODE_GO_API_KEY = "sk-mask-resolved"; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }); + expect(cfg.llm.apiKey).toBe("sk-mask-resolved"); + }); + + it("resolves empty string secret fields from env", () => { + process.env.OPENCODE_ZEN_API_KEY = "sk-empty-resolved"; + const cfg = resolveConfig({ llm: { apiKey: "" } }); + expect(cfg.llm.apiKey).toBe("sk-empty-resolved"); + }); + + it("uses per-path env conventions — embedding gets EMBEDDING_API_KEY, never an LLM key", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.EMBEDDING_API_KEY = "sk-embed"; + const raw: Record = {}; + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor = raw; + for (let i = 0; i < keys.length - 1; i++) { + cursor[keys[i]!] = cursor[keys[i]!] ?? {}; + cursor = cursor[keys[i]!] as Record; + } + cursor[keys[keys.length - 1]!] = "__memos_secret__"; + } + const cfg = resolveConfig(raw); + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: unknown = cfg; + for (const k of keys) { + cursor = (cursor as Record)[k]; + } + if (dotted === "embedding.apiKey") { + expect(cursor).toBe("sk-embed"); + } else if (dotted.endsWith("apiKey")) { + expect(cursor).toBe("sk-llm"); + } else { + expect(cursor).toBe("__memos_secret__"); + } + } + }); + + it("resolves hub tokens via explicit ${VAR} references", () => { + process.env.HUB_TEAM_TOKEN = "sk-hub-token"; + const cfg = resolveConfig({ hub: { teamToken: "${HUB_TEAM_TOKEN}" } }); + expect(cfg.hub.teamToken).toBe("sk-hub-token"); + }); + + it("does not fall back to generic keys when an explicit ${VAR} is unset", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + const cfg = resolveConfig({ llm: { apiKey: "${MY_LLM_API_KEY}" } }); + expect(cfg.llm.apiKey).toBe("${MY_LLM_API_KEY}"); + }); + + it("warns and skips expansion for non-allowlisted ${VAR} names", () => { + process.env.HOME = "/home/test"; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "${HOME}" } }, warnings); + expect(cfg.llm.apiKey).toBe("${HOME}"); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("not allowlisted"); + }); + + it("leaves real (non-placeholder) values untouched", () => { + const cfg = resolveConfig({ llm: { apiKey: "sk-real-value" } }); + expect(cfg.llm.apiKey).toBe("sk-real-value"); + }); + + it("leaves placeholders untouched when no env var is set", () => { + delete process.env.LLM_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_ZEN_API_KEY; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }); + expect(cfg.llm.apiKey).toBe("__memos_secret__"); + }); + + it("never mutates the caller's raw config object", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + const raw = { llm: { apiKey: "__memos_secret__" } }; + const cfg = resolveConfig(raw); + expect(cfg.llm.apiKey).toBe("sk-llm"); + expect(raw.llm.apiKey).toBe("__memos_secret__"); + }); +}); From 1f28c8c57ee29a880ad2ca0bfa9754f860e89cbd Mon Sep 17 00:00:00 2001 From: autodev Date: Fri, 14 Aug 2026 07:00:05 +0800 Subject: [PATCH 2/2] fix(config): apply OCR review to secret env resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 4 findings from the open-code-review pass on PR #2246: 1. hub.teamToken / hub.userToken are now resolved from the environment when masked with __memos_secret__ or written as empty strings. The previous `if (leaf !== "apiKey") continue` short-circuit silently perpetuated the original bug for hub tokens. 2. Emit a warning when a secret leaf references an env var that is not set (both the explicit ${VAR} form and the mask/empty form). Without this, a user who writes `apiKey: ${MY_API_KEY}` and forgets to export MY_API_KEY sees auth failures with no actionable log line. 3. Restrict the OPENCODE_GO_API_KEY / OPENCODE_ZEN_API_KEY generic fallback to the primary llm.apiKey. Per-component overrides (l3Llm.apiKey, skillEvolver.apiKey) and non-LLM secrets (embedding.apiKey, hub.*Token) must never silently borrow an unrelated provider's key — that causes cross-provider auth failures and unexpected billing when those components are pointed at a different provider than the shared llm settings. 4. Use an explicit `traversalOk` flag when walking SECRET_FIELD_PATHS so a partial traversal cannot leave `cursor` pointing at a shallower valid intermediate node that would then pass the isPlainObject check and cause `leaf` to be looked up on the wrong object. Today every entry is 2 levels deep so the bug is latent, but the flag makes the intent explicit and future-proofs against deeper paths being added. Env var derivation for masked/empty leaves now uses a camel→SNAKE transform on the last two path segments so every SECRET_FIELD_PATHS entry is resolvable by convention: embedding.apiKey → EMBEDDING_API_KEY llm.apiKey → LLM_API_KEY l3Llm.apiKey → L3_LLM_API_KEY skillEvolver.apiKey → SKILL_EVOLVER_API_KEY hub.teamToken → HUB_TEAM_TOKEN hub.userToken → HUB_USER_TOKEN Tests updated to cover the new hub-token resolution, the tightened fallback scope, and both warning cases. All 76 config tests pass; tsc --noEmit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/memos-local-plugin/core/config/index.ts | 81 ++++++++++++++++--- .../unit/config/resolve-secret-env.test.ts | 81 +++++++++++++++++-- 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 1c7b8c5d3..e188c2abb 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -96,13 +96,20 @@ export function resolveConfig(raw: unknown, warnings?: string[], agent?: string) // names are expanded (`^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$`); // anything else emits a warning and is left untouched. // 2. Value is the mask sentinel `__memos_secret__` or empty string - // -> use the env var inferred from the field path - // (llm.apiKey -> LLM_API_KEY, then OPENCODE_GO_API_KEY / - // OPENCODE_ZEN_API_KEY fallbacks for the opencode-go/zen - // providers). The generic fallbacks only apply to LLM-class - // fields — embedding.apiKey is never handed an LLM provider's key. + // -> derive the env var from the field path itself + // (llm.apiKey -> LLM_API_KEY, hub.teamToken -> HUB_TEAM_TOKEN, + // skillEvolver.apiKey -> SKILL_EVOLVER_API_KEY, …). The generic + // OPENCODE_GO/ZEN fallback is applied ONLY to the primary + // `llm.apiKey`; per-component overrides (l3Llm, skillEvolver) + // and non-LLM secrets (embedding, hub tokens) never borrow an + // unrelated provider's key — that would cause cross-provider + // auth failures or unexpected billing on the wrong account. // 3. Otherwise leave the value untouched. // + // Any secret leaf that references an env var that is not set emits a + // warning so the operator gets an actionable log message instead of + // silent auth failures on the next LLM call. + // // The mask itself is never used as a credential, and the on-disk write // stays masked (security preserved); this is read-side only. resolveSecretEnv(cleaned, warnings); @@ -147,11 +154,21 @@ function resolveSecretEnv(cleaned: Record, warnings?: string[]) for (const dotted of SECRET_FIELD_PATHS) { const keys = dotted.split("."); let cursor: unknown = cleaned; + // Explicit flag: `break` alone leaves `cursor` pointing at the last + // valid value, which for paths deeper than 2 levels could accidentally + // pass the `isPlainObject(cursor)` check below and index `leaf` on the + // wrong node. Today every SECRET_FIELD_PATHS entry is only 2 levels + // deep, but keeping the flag makes the intent explicit and future- + // proofs against deeper paths being added. + let traversalOk = true; for (let i = 0; i < keys.length - 1; i++) { - if (!isPlainObject(cursor)) break; + if (!isPlainObject(cursor)) { + traversalOk = false; + break; + } cursor = (cursor as Record)[keys[i]!]; } - if (!isPlainObject(cursor)) continue; + if (!traversalOk || !isPlainObject(cursor)) continue; const leaf = keys[keys.length - 1]!; const val = (cursor as Record)[leaf]; if (typeof val !== "string") continue; @@ -169,10 +186,25 @@ function resolveSecretEnv(cleaned: Record, warnings?: string[]) } envName = name; } else if (val === "__memos_secret__" || val === "") { - if (leaf !== "apiKey") continue; - const isEmbedding = keys[keys.length - 2] === "embedding"; - envName = isEmbedding ? "EMBEDDING_API_KEY" : "LLM_API_KEY"; - genericFallbacks = !isEmbedding; + // Derive env var name from the field path itself so every entry + // in SECRET_FIELD_PATHS is resolvable, not just the ones whose + // leaf is `apiKey`: + // embedding.apiKey → EMBEDDING_API_KEY + // llm.apiKey → LLM_API_KEY + // l3Llm.apiKey → L3_LLM_API_KEY + // skillEvolver.apiKey → SKILL_EVOLVER_API_KEY + // hub.teamToken → HUB_TEAM_TOKEN + // hub.userToken → HUB_USER_TOKEN + const parent = keys[keys.length - 2] ?? ""; + envName = `${camelToUpperSnake(parent)}_${camelToUpperSnake(leaf)}`; + // OPENCODE_GO_API_KEY / OPENCODE_ZEN_API_KEY are only meaningful + // for the primary `llm.apiKey`. Per-component overrides + // (l3Llm.apiKey, skillEvolver.apiKey) and non-LLM secrets + // (embedding.apiKey, hub.*Token) must not silently borrow an + // unrelated provider's key — doing so causes cross-provider auth + // failures and unexpected billing on the wrong account when the + // component is configured for a different provider entirely. + genericFallbacks = parent === "llm" && leaf === "apiKey"; } if (!envName) continue; @@ -181,10 +213,35 @@ function resolveSecretEnv(cleaned: Record, warnings?: string[]) (genericFallbacks ? (process.env.OPENCODE_GO_API_KEY ?? process.env.OPENCODE_ZEN_API_KEY) : undefined); - if (envVal) (cursor as Record)[leaf] = envVal; + if (envVal) { + (cursor as Record)[leaf] = envVal; + } else { + // Explicit-reference case: the user asked for env expansion but + // the target is unset. Mask/empty case: we walked the path-based + // convention and nothing was set. Both perpetuate the original + // bug (silent auth failure on next LLM call) unless we log it. + warnings?.push( + `config: '${dotted}' references env var '${envName}' but it is not set — ` + + `field left as placeholder and auth will fail on the next call` + ); + } } } +/** + * camelCase → UPPER_SNAKE_CASE for deriving env var names from config + * field paths. Only inserts an underscore at a lowercase/digit → uppercase + * boundary so acronyms and digit runs stay intact: + * apiKey → API_KEY + * teamToken → TEAM_TOKEN + * userToken → USER_TOKEN + * l3Llm → L3_LLM + * skillEvolver → SKILL_EVOLVER + */ +function camelToUpperSnake(s: string): string { + return s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); +} + function formatErr(e: ValueError): string { return `${e.path || ""}: ${e.message}`; } diff --git a/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts index 0a7652289..9054010a2 100644 --- a/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts @@ -28,9 +28,16 @@ describe("resolveConfig secret env fallback", () => { expect(cfg.llm.apiKey).toBe("sk-empty-resolved"); }); - it("uses per-path env conventions — embedding gets EMBEDDING_API_KEY, never an LLM key", () => { + it("uses per-path env conventions — every secret path resolves from its own env var", () => { + // OPENCODE_GO_API_KEY is only the generic fallback for the *primary* + // llm.apiKey — l3Llm / skillEvolver / hub / embedding all get their + // own path-derived env var and never silently borrow the LLM key. process.env.OPENCODE_GO_API_KEY = "sk-llm"; process.env.EMBEDDING_API_KEY = "sk-embed"; + process.env.L3_LLM_API_KEY = "sk-l3"; + process.env.SKILL_EVOLVER_API_KEY = "sk-skill"; + process.env.HUB_TEAM_TOKEN = "sk-team"; + process.env.HUB_USER_TOKEN = "sk-user"; const raw: Record = {}; for (const dotted of SECRET_FIELD_PATHS) { const keys = dotted.split("."); @@ -42,22 +49,80 @@ describe("resolveConfig secret env fallback", () => { cursor[keys[keys.length - 1]!] = "__memos_secret__"; } const cfg = resolveConfig(raw); + const expected: Record = { + "embedding.apiKey": "sk-embed", + "llm.apiKey": "sk-llm", + "l3Llm.apiKey": "sk-l3", + "skillEvolver.apiKey": "sk-skill", + "hub.teamToken": "sk-team", + "hub.userToken": "sk-user", + }; for (const dotted of SECRET_FIELD_PATHS) { const keys = dotted.split("."); let cursor: unknown = cfg; for (const k of keys) { cursor = (cursor as Record)[k]; } - if (dotted === "embedding.apiKey") { - expect(cursor).toBe("sk-embed"); - } else if (dotted.endsWith("apiKey")) { - expect(cursor).toBe("sk-llm"); - } else { - expect(cursor).toBe("__memos_secret__"); - } + expect(cursor).toBe(expected[dotted]); } }); + it("resolves masked hub tokens from HUB_TEAM_TOKEN / HUB_USER_TOKEN", () => { + // Regression: previously the mask/empty path only ran when `leaf === + // 'apiKey'`, so hub.teamToken / hub.userToken masked by + // maskSecrets() were silently left unresolved and hub auth failed + // exactly like the LLM auth bug in #2245. + process.env.HUB_TEAM_TOKEN = "sk-team-mask"; + process.env.HUB_USER_TOKEN = "sk-user-empty"; + const cfg = resolveConfig({ + hub: { teamToken: "__memos_secret__", userToken: "" }, + }); + expect(cfg.hub.teamToken).toBe("sk-team-mask"); + expect(cfg.hub.userToken).toBe("sk-user-empty"); + }); + + it("does not fall back to OPENCODE_GO/ZEN for l3Llm.apiKey", () => { + // Per-component overrides must not silently inherit the primary + // provider's key: l3-llm and skill-evolver are frequently pointed + // at a different provider than the shared llm settings. + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + delete process.env.L3_LLM_API_KEY; + const cfg = resolveConfig({ l3Llm: { apiKey: "__memos_secret__" } }); + expect(cfg.l3Llm.apiKey).toBe("__memos_secret__"); + }); + + it("does not fall back to OPENCODE_GO/ZEN for skillEvolver.apiKey", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + delete process.env.SKILL_EVOLVER_API_KEY; + const cfg = resolveConfig({ skillEvolver: { apiKey: "__memos_secret__" } }); + expect(cfg.skillEvolver.apiKey).toBe("__memos_secret__"); + }); + + it("warns when an explicit ${VAR} reference cannot be resolved", () => { + // Without a warning the user sees auth failures with no actionable + // hint; the whole point of the read-side resolver is to make config + // → env misconfiguration debuggable. + delete process.env.MISSING_LLM_API_KEY; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "${MISSING_LLM_API_KEY}" } }, warnings); + expect(cfg.llm.apiKey).toBe("${MISSING_LLM_API_KEY}"); + expect(warnings.some((w) => w.includes("MISSING_LLM_API_KEY") && w.includes("not set"))).toBe( + true, + ); + }); + + it("warns when a masked apiKey has no backing env var", () => { + delete process.env.LLM_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_ZEN_API_KEY; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }, warnings); + expect(cfg.llm.apiKey).toBe("__memos_secret__"); + expect(warnings.some((w) => w.includes("llm.apiKey") && w.includes("not set"))).toBe(true); + }); + it("resolves hub tokens via explicit ${VAR} references", () => { process.env.HUB_TEAM_TOKEN = "sk-hub-token"; const cfg = resolveConfig({ hub: { teamToken: "${HUB_TEAM_TOKEN}" } });