diff --git a/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts b/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts new file mode 100644 index 000000000..ca2a2a931 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts @@ -0,0 +1,153 @@ +import {afterEach, describe, expect, test} from "bun:test"; +import {mkdtempSync, mkdirSync, writeFileSync} from "fs"; +import {tmpdir} from "os"; +import {join} from "path"; +import {parseApiKeyLine, resolveApiKey} from "./api-key"; +import Mem0Plugin from "./opencode-mem0"; + +const originalKey = process.env.MEM0_API_KEY; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalTelemetry = process.env.MEM0_TELEMETRY; +const originalFetch = globalThis.fetch; +const testCleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (testCleanups.length > 0) { + await testCleanups.pop()?.(); + } + if (originalKey === undefined) delete process.env.MEM0_API_KEY; + else process.env.MEM0_API_KEY = originalKey; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + globalThis.fetch = originalFetch; +}); + +function home(): string { + return mkdtempSync(join(tmpdir(), "mem0-api-key-")); +} + +function pluginContext(logs: unknown[]) { + return { + client: {app: {log: async (entry: unknown) => logs.push(entry)}}, + $: () => ({quiet: async () => ({stdout: ""})}), + } as any; +} + +function stubFetch(): void { + globalThis.fetch = (async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = + url.includes("/v1/ping/") + ? {status: "ok", userEmail: "plugin-test@mem0.dev"} + : url.includes("/v1/projects/") + ? {customCategories: []} + : {}; + return new Response(JSON.stringify(body), { + status: 200, + headers: {"content-type": "application/json"}, + }); + }) as typeof fetch; +} + +function captureDeferredPluginCleanup(): () => Promise { + const baseline = new Set(process.listeners("beforeExit")); + return async () => { + await Promise.resolve(); + for (const listener of process.listeners("beforeExit")) { + if (!baseline.has(listener)) { + process.off("beforeExit", listener as (...args: any[]) => void); + } + } + }; +} + +describe("parseApiKeyLine", () => { + test("accepts literal export and assignment forms", () => { + expect(parseApiKeyLine('export MEM0_API_KEY="m0-quoted" # comment')).toBe("m0-quoted"); + expect(parseApiKeyLine("MEM0_API_KEY=m0-literal")).toBe("m0-literal"); + }); + + test("rejects unrelated, empty, and executable-looking values", () => { + expect(parseApiKeyLine("OTHER_KEY=value")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=$MEM0_API_KEY")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=$(cat secret)")).toBeUndefined(); + }); +}); + +describe("resolveApiKey", () => { + test("explicit environment value wins over profiles", () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY=profile\n"); + expect(resolveApiKey({MEM0_API_KEY: " explicit "}, dir)).toBe("explicit"); + }); + + test("uses the first valid allowlisted profile", () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY=$UNSET\n"); + writeFileSync(join(dir, ".bashrc"), "export MEM0_API_KEY='m0-from-bashrc'\n"); + writeFileSync(join(dir, ".profile"), "MEM0_API_KEY=late\n"); + expect(resolveApiKey({}, dir)).toBe("m0-from-bashrc"); + }); + + test("continues after an unreadable or absent earlier profile", () => { + const dir = home(); + mkdirSync(join(dir, ".zshrc")); + writeFileSync(join(dir, ".profile"), "MEM0_API_KEY=m0-later\n"); + expect(resolveApiKey({}, dir)).toBe("m0-later"); + }); + + test("ignores unsupported files and invalid assignments", () => { + const dir = home(); + writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n"); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY= # empty\nMEM0_API_KEY=$(unsafe)\n"); + expect(resolveApiKey({}, dir)).toBe(""); + }); + + test("keeps the missing-key guard when no source yields a key", async () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY= # empty\nMEM0_API_KEY=$UNSET\n"); + delete process.env.MEM0_API_KEY; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.MEM0_TELEMETRY = "false"; + stubFetch(); + testCleanups.push(captureDeferredPluginCleanup()); + + const logs: unknown[] = []; + const plugin = await Mem0Plugin(pluginContext(logs)); + + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ + body: { + level: "error", + message: "MEM0_API_KEY environment variable not set. Get one at https://app.mem0.ai/dashboard/api-keys", + }, + }); + expect(plugin).toEqual({}); + }); + + test("recovers the issue's shell-profile startup path", async () => { + const dir = home(); + // Problem 2 in issue #6003: Desktop has no process key, but .zshrc does. + writeFileSync(join(dir, ".zshrc"), 'export MEM0_API_KEY="m0-from-profile"\n'); + delete process.env.MEM0_API_KEY; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.MEM0_TELEMETRY = "false"; + stubFetch(); + testCleanups.push(captureDeferredPluginCleanup()); + + const logs: unknown[] = []; + const plugin = await Mem0Plugin(pluginContext(logs)); + + expect(logs).toHaveLength(0); + expect(Object.keys(plugin)).toContain("chat.message"); + expect(plugin).toHaveProperty("tool"); + }); +}); diff --git a/integrations/mem0-plugin/.opencode-plugin/api-key.ts b/integrations/mem0-plugin/.opencode-plugin/api-key.ts new file mode 100644 index 000000000..b3eae9543 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/api-key.ts @@ -0,0 +1,30 @@ +import {readFileSync} from "fs"; +import {homedir} from "os"; +import {join} from "path"; + +const PROFILE_FILES = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"]; + +export function parseApiKeyLine(line: string): string | undefined { + const match = line.match(/^\s*(?:export\s+)?MEM0_API_KEY=(.*)$/); + if (!match) return undefined; + + const value = match[1].replace(/#.*$/, "").trim().replace(/^("|')(.*)\1$/, "$2").trim(); + return value && !value.startsWith("$") ? value : undefined; +} + +export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = homedir()): string { + const explicit = env.MEM0_API_KEY?.trim(); + if (explicit) return explicit; + + for (const profile of PROFILE_FILES) { + try { + for (const line of readFileSync(join(homeDir, profile), "utf8").split(/\r?\n/)) { + const key = parseApiKeyLine(line); + if (key) return key; + } + } catch { + } + } + + return ""; +} diff --git a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts index 97369ed5c..d5f2acb30 100644 --- a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts +++ b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts @@ -25,6 +25,7 @@ import { } from "./dream"; import {asScope, scopeSearchFilters, scopeWriteParams, resolveDefaultScope, SCOPE_GUIDANCE, type Scope} from "./scope"; import {parseProjectFromRemote} from "./project"; +import {resolveApiKey} from "./api-key"; async function getUserId(): Promise { if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID; @@ -258,7 +259,7 @@ function extractUserText(input: any, output: any): string { const Mem0Plugin: Plugin = async (ctx) => { const {$, client} = ctx; - const apiKey = process.env.MEM0_API_KEY; + const apiKey = resolveApiKey(); if (!apiKey) { try {