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
366 changes: 209 additions & 157 deletions index.ts

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"minItems": 1
}
],
"description": "Single API key or array of keys for round-robin rotation"
"description": "Single API key or array of keys for round-robin rotation. Supports ${ENV_VAR} and bws://<secret-id> Bitwarden secret references."
},
"model": {
"type": "string"
Expand Down Expand Up @@ -307,7 +307,7 @@
},
"rerankApiKey": {
"type": "string",
"description": "API key for reranker service (enables cross-encoder reranking)"
"description": "API key for reranker service (enables cross-encoder reranking). Supports ${ENV_VAR} and bws://<secret-id> Bitwarden secret references."
},
"rerankModel": {
"type": "string",
Expand Down Expand Up @@ -676,7 +676,8 @@
"description": "LLM authentication mode. oauth uses the local Codex/ChatGPT login cache instead of llm.apiKey."
},
"apiKey": {
"type": "string"
"type": "string",
"description": "LLM API key. Supports ${ENV_VAR} and bws://<secret-id> Bitwarden secret references."
},
"model": {
"type": "string",
Expand Down Expand Up @@ -842,8 +843,8 @@
"embedding.apiKey": {
"label": "API Key(s)",
"sensitive": true,
"placeholder": "sk-proj-... or [\"key1\", \"key2\"] for rotation",
"help": "Single API key or array of keys for round-robin rotation with automatic failover on rate limits (or use ${OPENAI_API_KEY}; use a dummy value for keyless local endpoints)"
"placeholder": "sk-proj-... or bws://<secret-id> or [\"key1\", \"key2\"]",
"help": "Single API key or array of keys for round-robin rotation with automatic failover on rate limits. Supports ${OPENAI_API_KEY} and bws://<secret-id> Bitwarden refs. Use a dummy value for keyless local endpoints."
},
"embedding.model": {
"label": "Embedding Model",
Expand Down Expand Up @@ -902,8 +903,8 @@
"llm.apiKey": {
"label": "LLM API Key",
"sensitive": true,
"placeholder": "sk-... or ${GROQ_API_KEY}",
"help": "API key for LLM used by smart memory extraction (defaults to embedding.apiKey if omitted)"
"placeholder": "sk-... or ${GROQ_API_KEY} or bws://<secret-id>",
"help": "API key for LLM used by smart memory extraction (defaults to embedding.apiKey if omitted). Supports Bitwarden bws:// refs."
},
"llm.model": {
"label": "LLM Model",
Expand Down Expand Up @@ -1062,8 +1063,8 @@
"retrieval.rerankApiKey": {
"label": "Reranker API Key",
"sensitive": true,
"placeholder": "jina_... / sk-... / pcsk_...",
"help": "Reranker API key for cross-encoder reranking",
"placeholder": "jina_... / sk-... / pcsk_... / bws://<secret-id>",
"help": "Reranker API key for cross-encoder reranking. Supports Bitwarden bws:// refs.",
"advanced": true
},
"retrieval.rerankModel": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
]
},
"scripts": {
"test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs",
"test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/secret-resolver.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs",
"test:openclaw-host": "node test/openclaw-host-functional.mjs",
"version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json"
},
Expand Down
4 changes: 3 additions & 1 deletion src/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,9 @@ export class Embedder {
constructor(config: EmbeddingConfig & { chunking?: boolean }) {
// Normalize apiKey to array and resolve environment variables
const apiKeys = Array.isArray(config.apiKey) ? config.apiKey : [config.apiKey];
const resolvedKeys = apiKeys.map(k => resolveEnvVars(k));
// Skip env-var expansion for keys that have already been resolved
// (e.g. pre-resolved Bitwarden secrets that contain no ${} placeholders).
const resolvedKeys = apiKeys.map(k => k.includes("\${") ? resolveEnvVars(k) : k);

this._model = config.model;
this._baseURL = config.baseURL;
Expand Down
214 changes: 214 additions & 0 deletions src/secret-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { execFile as execFileCallback, execFileSync } from "node:child_process";
import { promisify } from "node:util";

const execFile = promisify(execFileCallback);

export type SecretExecFileResult = {
stdout: string;
stderr: string;
};

export type SecretExecFile = (
file: string,
args: string[],
options?: {
env?: NodeJS.ProcessEnv;
timeout?: number;
},
) => Promise<SecretExecFileResult>;

export type SecretResolverOptions = {
env?: NodeJS.ProcessEnv;
execFileImpl?: SecretExecFile;
timeoutMs?: number;
};

export type SecretResolverSyncOptions = {
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
};

type BitwardenSecretRef = {
id: string;
accessToken?: string;
configFile?: string;
profile?: string;
serverUrl?: string;
};

function getEnv(options?: SecretResolverOptions): NodeJS.ProcessEnv {
return options?.env ?? process.env;
}

export function resolveEnvVarsSync(value: string, env: NodeJS.ProcessEnv = process.env): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = env[envVar];
if (!envValue) {
throw new Error(`Environment variable ${envVar} is not set`);
}
return envValue;
});
}

function parseBitwardenSecretRef(value: string, env: NodeJS.ProcessEnv): BitwardenSecretRef | null {
const trimmed = value.trim();
if (!/^bws:\/\//i.test(trimmed)) return null;

const parsed = new URL(trimmed);
const rawId = `${parsed.hostname}${parsed.pathname}`.replace(/^\/+/, "");
const normalizedId = rawId.replace(/^secret\//i, "");
if (!normalizedId) {
throw new Error(`Invalid Bitwarden secret reference: ${value}`);
}

const accessTokenRaw = parsed.searchParams.get("accessToken");
const configFileRaw = parsed.searchParams.get("configFile");
const profileRaw = parsed.searchParams.get("profile");
const serverUrlRaw = parsed.searchParams.get("serverUrl");

return {
id: normalizedId,
accessToken: accessTokenRaw ? resolveEnvVarsSync(accessTokenRaw, env) : undefined,
configFile: configFileRaw ? resolveEnvVarsSync(configFileRaw, env) : undefined,
profile: profileRaw ? resolveEnvVarsSync(profileRaw, env) : undefined,
serverUrl: serverUrlRaw ? resolveEnvVarsSync(serverUrlRaw, env) : undefined,
};
}

async function resolveBitwardenSecret(
ref: BitwardenSecretRef,
options?: SecretResolverOptions,
): Promise<string> {
const execImpl = options?.execFileImpl ?? execFile;
const env = getEnv(options);
const args = ["secret", "get", ref.id, "--output", "json"];
// Pass access token via env var to avoid exposure in process listings.
const childEnv = ref.accessToken ? { ...env, BWS_ACCESS_TOKEN: ref.accessToken } : env;
if (ref.configFile) args.push("--config-file", ref.configFile);
if (ref.profile) args.push("--profile", ref.profile);
if (ref.serverUrl) args.push("--server-url", ref.serverUrl);

let stdout = "";
let stderr = "";
try {
const result = await execImpl("bws", args, {
env: childEnv,
timeout: options?.timeoutMs ?? 10_000,
});
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to resolve Bitwarden secret ${ref.id} via bws secret get: ${msg}`,
);
}

let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(stdout) as Record<string, unknown>;
} catch (error) {
throw new Error(
`Bitwarden secret ${ref.id} did not return valid JSON: ${error instanceof Error ? error.message : String(error)}; stderr=${stderr.trim() || "(none)"}`,
);
}

const value =
typeof parsed.value === "string"
? parsed.value
: typeof parsed.note === "string"
? parsed.note
: null;
if (!value || !value.trim()) {
throw new Error(`Bitwarden secret ${ref.id} has no value`);
}
return value;
}

function resolveBitwardenSecretSync(
ref: BitwardenSecretRef,
options?: SecretResolverSyncOptions,
): string {
const env = options?.env ?? process.env;
const args = ["secret", "get", ref.id, "--output", "json"];
// Pass access token via env var to avoid exposure in process listings.
const childEnv = ref.accessToken ? { ...env, BWS_ACCESS_TOKEN: ref.accessToken } : env;
if (ref.configFile) args.push("--config-file", ref.configFile);
if (ref.profile) args.push("--profile", ref.profile);
if (ref.serverUrl) args.push("--server-url", ref.serverUrl);

let stdout = "";
try {
stdout = execFileSync("bws", args, {
env: childEnv,
timeout: options?.timeoutMs ?? 10_000,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to resolve Bitwarden secret ${ref.id} via bws secret get: ${msg}`);
}

let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(stdout) as Record<string, unknown>;
} catch (error) {
throw new Error(
`Bitwarden secret ${ref.id} did not return valid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}

const value =
typeof parsed.value === "string"
? parsed.value
: typeof parsed.note === "string"
? parsed.note
: null;
if (!value || !value.trim()) {
throw new Error(`Bitwarden secret ${ref.id} has no value`);
}
return value;
}

export async function resolveSecretValue(
value: string,
options?: SecretResolverOptions,
): Promise<string> {
const env = getEnv(options);
const envResolved = resolveEnvVarsSync(value, env);
const bitwardenRef = parseBitwardenSecretRef(envResolved, env);
if (!bitwardenRef) {
return envResolved;
}
return resolveBitwardenSecret(bitwardenRef, options);
}

export function resolveSecretValueSync(
value: string,
options?: SecretResolverSyncOptions,
): string {
const env = options?.env ?? process.env;
const envResolved = resolveEnvVarsSync(value, env);
const bitwardenRef = parseBitwardenSecretRef(envResolved, env);
if (!bitwardenRef) {
return envResolved;
}
return resolveBitwardenSecretSync(bitwardenRef, options);
}

export async function resolveSecretValues(
value: string | string[],
options?: SecretResolverOptions,
): Promise<string[]> {
const values = Array.isArray(value) ? value : [value];
return Promise.all(values.map((entry) => resolveSecretValue(entry, options)));
}

export function resolveSecretValuesSync(
value: string | string[],
options?: SecretResolverSyncOptions,
): string[] {
const values = Array.isArray(value) ? value : [value];
return values.map((entry) => resolveSecretValueSync(entry, options));
}
17 changes: 10 additions & 7 deletions test/plugin-manifest-regression.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ try {
{
dbPath: path.join(workDir, "db"),
autoRecall: false,
selfImprovement: { enabled: false },
embedding: {
provider: "openai-compatible",
apiKey: "dummy",
Expand All @@ -149,7 +150,7 @@ try {
},
{ services },
);
plugin.register(api);
await plugin.register(api);
assert.equal(services.length, 1, "plugin should register its background service");
assert.equal(typeof api.hooks.agent_end, "function", "autoCapture should remain enabled by default");
assert.equal(api.hooks["command:new"], undefined, "sessionMemory should stay disabled by default");
Expand All @@ -162,6 +163,7 @@ try {
dbPath: path.join(workDir, "db-session-default"),
autoCapture: false,
autoRecall: false,
selfImprovement: { enabled: false },
sessionMemory: {},
embedding: {
provider: "openai-compatible",
Expand All @@ -171,7 +173,7 @@ try {
dimensions: 1536,
},
});
plugin.register(sessionDefaultApi);
await plugin.register(sessionDefaultApi);
assert.equal(
sessionDefaultApi.hooks["command:new"],
undefined,
Expand All @@ -182,6 +184,7 @@ try {
dbPath: path.join(workDir, "db-session-enabled"),
autoCapture: false,
autoRecall: false,
selfImprovement: { enabled: false },
sessionMemory: { enabled: true },
embedding: {
provider: "openai-compatible",
Expand All @@ -191,7 +194,7 @@ try {
dimensions: 1536,
},
});
plugin.register(sessionEnabledApi);
await plugin.register(sessionEnabledApi);
assert.equal(
typeof sessionEnabledApi.hooks.before_reset,
"function",
Expand Down Expand Up @@ -263,7 +266,7 @@ try {
chunking: false,
},
});
plugin.register(chunkingOffApi);
await plugin.register(chunkingOffApi);
const chunkingOffTool = chunkingOffApi.toolFactories.memory_store({
agentId: "main",
sessionKey: "agent:main:test",
Expand Down Expand Up @@ -291,7 +294,7 @@ try {
chunking: true,
},
});
plugin.register(chunkingOnApi);
await plugin.register(chunkingOnApi);
const chunkingOnTool = chunkingOnApi.toolFactories.memory_store({
agentId: "main",
sessionKey: "agent:main:test",
Expand All @@ -318,7 +321,7 @@ try {
dimensions: 4,
},
});
plugin.register(withDimensionsApi);
await plugin.register(withDimensionsApi);
const withDimensionsTool = withDimensionsApi.toolFactories.memory_store({
agentId: "main",
sessionKey: "agent:main:test",
Expand Down Expand Up @@ -348,7 +351,7 @@ try {
omitDimensions: true,
},
});
plugin.register(omitDimensionsApi);
await plugin.register(omitDimensionsApi);
const omitDimensionsTool = omitDimensionsApi.toolFactories.memory_store({
agentId: "main",
sessionKey: "agent:main:test",
Expand Down
Loading