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
2 changes: 1 addition & 1 deletion dist/index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 10 additions & 5 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

20 changes: 13 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { mkdirSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";

// Module-level flag: emit hardcoded-key warning only once per process lifetime
let _apiKeyWarningEmitted = false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Module-level _apiKeyWarningEmitted suppresses warnings across independent registrations

The _apiKeyWarningEmitted flag at src/index.ts:32 is module-scoped, meaning once set by the first register() call with a hardcoded key, ALL subsequent registrations — even for different agents or owners with different hardcoded keys — will silently skip the warning. This is intentional per the commit message (suppress subagent noise), but it means a genuinely misconfigured second registration in the same process would not be warned about. In practice this is unlikely since multi-registration typically uses the same config, but worth noting as a behavioral trade-off.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


Comment on lines +31 to +33
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new module-level _apiKeyWarningEmitted changes behavior to only warn once per process lifetime. This is not mentioned in the PR description and can suppress warnings if the plugin is re-registered later with a hardcoded key (or in multi-tenant scenarios). If this is intentional, consider calling it out in the PR description and/or scoping the flag to a plugin instance/config so each distinct registration can still emit the warning when appropriate.

Copilot uses AI. Check for mistakes.
// Dynamic require for node:sqlite (available in Node 22+, avoids TS import issues)
let NodeDatabaseSync: any;
try {
Expand Down Expand Up @@ -87,6 +90,10 @@ function resolveEnv(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] ?? "");
}

function isEnvInterpolation(value: unknown): value is string {
return typeof value === "string" && /^\$\{[^}]+\}$/.test(value.trim());
}
Comment on lines +93 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: isEnvInterpolation only matches full-string interpolation, not partial

The regex /^\$\{[^}]+\}$/ at src/index.ts:94 requires the entire trimmed string to be a single ${...} token. Partial interpolation like "prefix-${KEY}" or "${KEY1}-${KEY2}" would NOT match, causing the hardcoded-key warning to fire. This is reasonable behavior — partial interpolation of API keys is atypical and arguably a misconfiguration worth flagging — but it's worth noting that resolveEnv() at src/index.ts:89-91 supports partial/multiple interpolations via its global .replace() regex.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


function parseConfig(raw: unknown): EvaMemoryConfig {
const defaults: EvaMemoryConfig = {
cortexUrl: "http://localhost:8000",
Expand Down Expand Up @@ -1012,9 +1019,13 @@ const cortexPlugin = {
},

register(api: OpenClawPluginApi) {
const rawConfig = api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
? api.pluginConfig as Record<string, unknown>
: null;
const cfg = parseConfig(api.pluginConfig);

if (cfg.apiKey && !cfg.apiKey.startsWith("${")) {

if (cfg.apiKey && !isEnvInterpolation(rawConfig?.apiKey) && !_apiKeyWarningEmitted) {
_apiKeyWarningEmitted = true;
api.logger.warn("cortex: API key appears to be hardcoded in config. Consider using environment variable: apiKey: '${CORTEX_API_KEY}'");
}

Expand All @@ -1040,11 +1051,6 @@ const cortexPlugin = {
api.logger.info("cortex: node:sqlite not available — local cache disabled");
}

// Security: warn if API key is hardcoded in config instead of env var
if (cfg.apiKey && !cfg.apiKey.startsWith("${")) {
api.logger.warn("cortex: API key appears to be hardcoded in config. Consider using environment variable: apiKey: '${CORTEX_API_KEY}'");
}

api.logger.info(
`cortex: registered (cortex=${cfg.cortexUrl}, owner=${cfg.ownerId}, recall=${cfg.autoRecall}, capture=${cfg.autoCapture}, shadow=${cfg.shadowMode})`,
);
Expand Down
43 changes: 43 additions & 0 deletions tests/api-key-warning.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';

async function loadPlugin() {
const url = pathToFileURL(new URL('../dist/index.js', import.meta.url).pathname);
Comment on lines +3 to +6
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadPlugin() builds a file URL via pathToFileURL(new URL(...).pathname). Using .pathname can break on Windows (leading /C:/...) and loses URL encoding info. Prefer using the new URL('../dist/index.js', import.meta.url) result directly (or pathToFileURL(fileURLToPath(...)) if you need a path) so the test is portable across platforms.

Suggested change
import { pathToFileURL } from 'node:url';
async function loadPlugin() {
const url = pathToFileURL(new URL('../dist/index.js', import.meta.url).pathname);
async function loadPlugin() {
const url = new URL('../dist/index.js', import.meta.url);

Copilot uses AI. Check for mistakes.
url.searchParams.set('t', `${Date.now()}-${Math.random()}`);
const mod = await import(url.href);
return mod.default?.default ?? mod.default ?? mod;
}
Comment on lines +5 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Test cache busting may not isolate CJS module state, but tests pass regardless

The loadPlugin() function at tests/api-key-warning.test.mjs:6-9 appends a random query parameter to bust the ESM import cache. However, for CJS modules loaded via import(), Node.js may still resolve to the same require cache entry, sharing the module-level _apiKeyWarningEmitted state across test invocations. This doesn't cause test failures because: Test 1 (env-var config) never sets the flag (the isEnvInterpolation check short-circuits), so Test 2 (hardcoded config) still sees _apiKeyWarningEmitted === false and correctly fires the warning. The tests are order-independent for the same reason. However, if someone adds a test with a hardcoded key before the 'does not warn' test, and the cache busting doesn't work, the shared flag state could cause unexpected interactions.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


function makeApi(pluginConfig, logger) {
const base = { pluginConfig, logger };
return new Proxy(base, {
get(target, prop) {
if (prop in target) return target[prop];
return () => {};
},
});
}

test('does not warn when apiKey is provided via ${ENV_VAR} interpolation', async () => {
process.env.CORTEX_API_KEY = 'secret-from-env';
const warnings = [];
const plugin = await loadPlugin();
plugin.register(makeApi(
{ apiKey: '${CORTEX_API_KEY}', cortexUrl: 'http://localhost:8000' },
{ warn: (msg) => warnings.push(msg), info() {} },
));

assert.equal(warnings.some((msg) => msg.includes('hardcoded in config')), false);
Comment on lines +23 to +31
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test sets process.env.CORTEX_API_KEY but never restores it. That can leak state into other tests when the suite grows or when tests are executed in a shared process. Save the previous value and restore it in a finally block or via test(..., (t) => { t.after(() => ...) })/afterEach.

Suggested change
process.env.CORTEX_API_KEY = 'secret-from-env';
const warnings = [];
const plugin = await loadPlugin();
plugin.register(makeApi(
{ apiKey: '${CORTEX_API_KEY}', cortexUrl: 'http://localhost:8000' },
{ warn: (msg) => warnings.push(msg), info() {} },
));
assert.equal(warnings.some((msg) => msg.includes('hardcoded in config')), false);
const previousApiKey = process.env.CORTEX_API_KEY;
process.env.CORTEX_API_KEY = 'secret-from-env';
try {
const warnings = [];
const plugin = await loadPlugin();
plugin.register(makeApi(
{ apiKey: '${CORTEX_API_KEY}', cortexUrl: 'http://localhost:8000' },
{ warn: (msg) => warnings.push(msg), info() {} },
));
assert.equal(warnings.some((msg) => msg.includes('hardcoded in config')), false);
} finally {
if (previousApiKey === undefined) {
delete process.env.CORTEX_API_KEY;
} else {
process.env.CORTEX_API_KEY = previousApiKey;
}
}

Copilot uses AI. Check for mistakes.
});

test('warns when apiKey is hardcoded directly in plugin config', async () => {
const warnings = [];
const plugin = await loadPlugin();
plugin.register(makeApi(
{ apiKey: 'plain-secret', cortexUrl: 'http://localhost:8000' },
{ warn: (msg) => warnings.push(msg), info() {} },
));

assert.equal(warnings.some((msg) => msg.includes('hardcoded in config')), true);
});
Loading