Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion apps/memos-local-plugin/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -72,9 +72,40 @@ export async function loadConfig(home: ResolvedHome, agent?: string): Promise<Lo
/**
* Merge an arbitrary raw object over `DEFAULT_CONFIG` and validate. Used in
* tests and by `writer.ts`. `warnings` is mutated in place if provided.
*
* The `raw` argument is never mutated — the secret resolution below runs on a
* freshly built copy, so callers may pass shared or cached objects safely.
*/
export function resolveConfig(raw: unknown, warnings?: string[], agent?: string): ResolvedConfig {
const cleaned = pruneUnknown(raw, DEFAULT_CONFIG, "", warnings);
// Resolve masked/placeholder secret values from the environment before
// merging. `maskSecrets()` (pipeline/memory-core.ts) rewrites every
// SECRET_FIELD_PATHS leaf to `__memos_secret__` before the config is
// persisted or surfaced, and `stripEmptySecrets()` drops empty leaves
// from patches. But nothing re-reads the real value back: when the
// daemon restarts and `loadConfig()` parses the YAML, the placeholder
// is treated as the literal API key, so every LLM call fails auth and
// the bridge loops on restart with `lastOkAt: null` and crystallize
// stuck. Same problem if a user writes `apiKey: ""` or an explicit
// `${ENV_VAR}` reference and expects expansion (the writer's
// `resolveConfig` is the single choke point for both disk and
// in-memory patch paths, so resolving here covers both).
//
// Resolution rules (first match wins):
// 1. Value is `${NAME}` -> 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<string, unknown>, cleaned);
stripUnsupportedEmbeddingDimensions(merged);
const viewerPort = effectiveViewerPort(agent);
Expand Down Expand Up @@ -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<string, unknown>, 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<string, unknown>)[keys[i]!];
}
if (!isPlainObject(cursor)) continue;
const leaf = keys[keys.length - 1]!;
const val = (cursor as Record<string, unknown>)[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<string, unknown>)[leaf] = envVal;
}
}

function formatErr(e: ValueError): string {
return `${e.path || "<root>"}: ${e.message}`;
}
Expand Down
106 changes: 106 additions & 0 deletions apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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<string, unknown>;
}
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<string, unknown>)[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__");
});
});