diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 6d529d960..53c9ab378 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,64 @@ 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("}")) { + // Explicit ${VAR} reference — resolve exactly that variable and + // nothing else. + 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 === "") { + // Masked/empty API key — infer the env var from the field path. + // Only apiKey fields have a convention (OPENAI_API_KEY, etc.); + // hub tokens (teamToken/userToken) have no env convention, so + // they must be set explicitly via ${VAR} or the UI. + if (leaf !== "apiKey") continue; + const isEmbedding = keys[keys.length - 2] === "embedding"; + envName = isEmbedding ? "EMBEDDING_API_KEY" : "LLM_API_KEY"; + // Generic fallbacks exist for LLM-class keys only; an embedding + // key must never be silently populated with an LLM provider's 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..762138db3 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts @@ -0,0 +1,106 @@ +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") { + // Embedding keys use their own convention and must not fall back + // to an LLM provider's key. + expect(cursor).toBe("sk-embed"); + } else if (dotted.endsWith("apiKey")) { + expect(cursor).toBe("sk-llm"); + } else { + // hub tokens have no env convention — they stay masked. + 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__"); + }); +});