fix(opencode-plugin): recover MEM0_API_KEY from shell profiles (#6404)
This commit is contained in:
@@ -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> | 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<void> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "./dream";
|
} from "./dream";
|
||||||
import {asScope, scopeSearchFilters, scopeWriteParams, resolveDefaultScope, SCOPE_GUIDANCE, type Scope} from "./scope";
|
import {asScope, scopeSearchFilters, scopeWriteParams, resolveDefaultScope, SCOPE_GUIDANCE, type Scope} from "./scope";
|
||||||
import {parseProjectFromRemote} from "./project";
|
import {parseProjectFromRemote} from "./project";
|
||||||
|
import {resolveApiKey} from "./api-key";
|
||||||
|
|
||||||
async function getUserId(): Promise<string> {
|
async function getUserId(): Promise<string> {
|
||||||
if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID;
|
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 Mem0Plugin: Plugin = async (ctx) => {
|
||||||
const {$, client} = ctx;
|
const {$, client} = ctx;
|
||||||
|
|
||||||
const apiKey = process.env.MEM0_API_KEY;
|
const apiKey = resolveApiKey();
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user