diff --git a/README.md b/README.md index 51adce7c..e0597a45 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ Query → BM25 FTS ─────┘ }, "smartExtraction": true, "llm": { + "api": "openai-completions", "apiKey": "${OPENAI_API_KEY}", "model": "gpt-4o-mini", "baseURL": "https://api.openai.com/v1" @@ -509,13 +510,21 @@ Any Jina-compatible rerank endpoint also works — set `rerankProvider: "jina"` When `smartExtraction` is enabled (default: `true`), the plugin uses an LLM to intelligently extract and classify memories instead of regex-based triggers. +Sensitive config fields support both `${ENV_VAR}` interpolation and direct Bitwarden Secrets Manager refs via `bws://`: + +- `embedding.apiKey` +- `llm.apiKey` +- `retrieval.rerankApiKey` + | Field | Type | Default | Description | |-------|------|---------|-------------| | `smartExtraction` | boolean | `true` | Enable/disable LLM-powered 6-category extraction | +| `llm.api` | string | `openai-completions` | `openai-completions` uses OpenAI-compatible chat completions; `anthropic-messages` uses Anthropic-compatible `/v1/messages` | | `llm.auth` | string | `api-key` | `api-key` uses `llm.apiKey` / `embedding.apiKey`; `oauth` uses a plugin-scoped OAuth token file by default | | `llm.apiKey` | string | *(falls back to `embedding.apiKey`)* | API key for the LLM provider | | `llm.model` | string | `openai/gpt-oss-120b` | LLM model name | | `llm.baseURL` | string | *(falls back to `embedding.baseURL`)* | LLM API endpoint | +| `llm.anthropicVersion` | string | `2023-06-01` | `anthropic-version` header used when `llm.api = anthropic-messages` | | `llm.oauthProvider` | string | `openai-codex` | OAuth provider id used when `llm.auth` is `oauth` | | `llm.oauthPath` | string | `~/.openclaw/.memory-lancedb-pro/oauth.json` | OAuth token file used when `llm.auth` is `oauth` | | `llm.timeoutMs` | number | `30000` | LLM request timeout in milliseconds | @@ -527,6 +536,7 @@ OAuth `llm` config (use existing Codex / ChatGPT login cache for LLM calls): ```json { "llm": { + "api": "openai-completions", "auth": "oauth", "oauthProvider": "openai-codex", "model": "gpt-5.4", @@ -538,12 +548,27 @@ OAuth `llm` config (use existing Codex / ChatGPT login cache for LLM calls): Notes for `llm.auth: "oauth"`: +- OAuth currently requires `llm.api: "openai-completions"`. - `llm.oauthProvider` is currently `openai-codex`. - OAuth tokens default to `~/.openclaw/.memory-lancedb-pro/oauth.json`. - You can set `llm.oauthPath` if you want to store that file somewhere else. - `auth login` snapshots the previous api-key `llm` config next to the OAuth file, and `auth logout` restores that snapshot when available. - Switching from `api-key` to `oauth` does not automatically carry over `llm.baseURL`. Set it manually in OAuth mode only when you intentionally want a custom ChatGPT/Codex-compatible backend. +Anthropic-compatible `llm` config: +```json +{ + "llm": { + "api": "anthropic-messages", + "apiKey": "bws://YOUR-BWS-SECRET-UUID", + "model": "claude-sonnet-4-5", + "baseURL": "https://api.anthropic.com/v1", + "anthropicVersion": "2023-06-01", + "timeoutMs": 30000 + } +} +``` +
diff --git a/cli.ts b/cli.ts index 99203916..e890d750 100644 --- a/cli.ts +++ b/cli.ts @@ -109,10 +109,12 @@ function resolveConfiguredOauthPath(configPath: string, rawPath: unknown): strin } type RestorableApiKeyLlmConfig = { + api?: "openai-completions" | "anthropic-messages"; auth?: "api-key"; apiKey?: string; model?: string; baseURL?: string; + anthropicVersion?: string; timeoutMs?: number; }; @@ -136,6 +138,9 @@ function extractRestorableApiKeyLlmConfig(value: unknown): RestorableApiKeyLlmCo } const result: RestorableApiKeyLlmConfig = {}; + if (value.api === "openai-completions" || value.api === "anthropic-messages") { + result.api = value.api; + } if (value.auth === "api-key") { result.auth = "api-key"; } @@ -148,6 +153,9 @@ function extractRestorableApiKeyLlmConfig(value: unknown): RestorableApiKeyLlmCo if (typeof value.baseURL === "string") { result.baseURL = value.baseURL; } + if (typeof value.anthropicVersion === "string") { + result.anthropicVersion = value.anthropicVersion; + } if (typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0) { result.timeoutMs = Math.trunc(value.timeoutMs); } @@ -160,6 +168,7 @@ function extractOauthSafeLlmConfig(value: unknown): RestorableApiKeyLlmConfig { } const result: RestorableApiKeyLlmConfig = {}; + result.api = "openai-completions"; if (typeof value.baseURL === "string") { result.baseURL = value.baseURL; } @@ -540,6 +549,7 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void { } pluginConfig.llm = { ...nextLlm, + api: "openai-completions", auth: "oauth", oauthProvider: selectedProvider.providerId, model: oauthModel, @@ -589,6 +599,7 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void { console.log(`Config file: ${configPath}`); console.log(`Plugin: ${pluginId}`); + console.log(`llm.api: ${typeof llm.api === "string" ? llm.api : "openai-completions"}`); console.log(`llm.auth: ${typeof llm.auth === "string" ? llm.auth : "api-key"}`); console.log(`llm.oauthProvider: ${oauthProviderDisplay}`); console.log(`llm.model: ${typeof llm.model === "string" ? llm.model : "openai/gpt-oss-120b"}`); diff --git a/examples/new-session-distill/README.md b/examples/new-session-distill/README.md index 832a042e..2e34a887 100644 --- a/examples/new-session-distill/README.md +++ b/examples/new-session-distill/README.md @@ -4,7 +4,7 @@ This example shows a **non-blocking /new distillation pipeline**: - Trigger: `command:new` (when you type `/new`) - Hook: enqueue a small JSON task file (fast, no LLM calls) -- Worker: a user-level systemd service watches the inbox and runs **Gemini Map-Reduce** over the session JSONL transcript +- Worker: a user-level systemd service watches the inbox and runs **Gemini or Anthropic-compatible Map-Reduce** over the session JSONL transcript - Storage: write high-signal, atomic lessons into LanceDB Pro via `openclaw memory-pro import` - Notify: send a notification message (optional) @@ -13,7 +13,19 @@ Files included: - `worker/lesson-extract-worker.mjs` — Map-Reduce extractor + importer + notifier - `worker/systemd/lesson-extract-worker.service` — user systemd unit -You must provide: -- `GEMINI_API_KEY` in an env file loaded by systemd +You must provide one of: + +- Gemini-native: + - `DISTILL_API=gemini-native` or omit it + - `GEMINI_API_KEY` or `DISTILL_API_KEY` + - optional `GEMINI_MODEL` / `DISTILL_MODEL` +- Anthropic-compatible: + - `DISTILL_API=anthropic-messages` + - `DISTILL_API_KEY` + - `DISTILL_MODEL` + - optional `DISTILL_BASE_URL` (defaults to `https://api.anthropic.com/v1`) + - optional `DISTILL_ANTHROPIC_VERSION` (defaults to `2023-06-01`) + +`DISTILL_API_KEY` and `GEMINI_API_KEY` also accept `bws://` Bitwarden Secrets Manager refs. Install steps are documented in the main repo README. diff --git a/examples/new-session-distill/worker/lesson-extract-worker.mjs b/examples/new-session-distill/worker/lesson-extract-worker.mjs index 11b3da13..23731628 100644 --- a/examples/new-session-distill/worker/lesson-extract-worker.mjs +++ b/examples/new-session-distill/worker/lesson-extract-worker.mjs @@ -15,11 +15,13 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; +import { spawn, execFile as execFileCallback } from "node:child_process"; import readline from "node:readline"; +import { promisify } from "node:util"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const execFile = promisify(execFileCallback); // In your deployment, set LESSON_QUEUE_ROOT to your workspace queue. // By default we assume repo layout similar to OpenClaw-Memory. @@ -31,8 +33,10 @@ const PROCESSING = path.join(QUEUE_ROOT, "processing"); const DONE = path.join(QUEUE_ROOT, "done"); const ERROR = path.join(QUEUE_ROOT, "error"); -const GEMINI_API_KEY = process.env.GEMINI_API_KEY; -const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-3-flash-preview"; +const DISTILL_API = process.env.DISTILL_API || "gemini-native"; +const DISTILL_MODEL = process.env.DISTILL_MODEL || process.env.GEMINI_MODEL || "gemini-3-flash-preview"; +const DISTILL_BASE_URL = process.env.DISTILL_BASE_URL || ""; +const DISTILL_ANTHROPIC_VERSION = process.env.DISTILL_ANTHROPIC_VERSION || "2023-06-01"; const ONCE = process.argv.includes("--once"); @@ -65,6 +69,58 @@ function safeJsonParse(text) { } } +function resolveEnvVars(value) { + return String(value).replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +// NOTE: This duplicates the logic in src/secret-resolver.ts (parseBitwardenSecretRef + +// resolveBitwardenSecret). This worker is a standalone ESM script that cannot import +// the compiled TypeScript, so the logic lives here as well. +// IMPORTANT: If you update either copy, keep the other in sync. +async function resolveMaybeBitwardenSecret(value) { + const resolved = resolveEnvVars(String(value || "").trim()); + if (!resolved) return ""; + if (!resolved.startsWith("bws://")) return resolved; + + const parsed = new URL(resolved); + const secretId = `${parsed.hostname}${parsed.pathname}`.replace(/^\/+/, "").replace(/^secret\//i, ""); + if (!secretId) throw new Error(`Invalid Bitwarden secret reference: ${resolved}`); + + const args = ["secret", "get", secretId, "--output", "json"]; + const accessToken = parsed.searchParams.get("accessToken"); + const configFile = parsed.searchParams.get("configFile"); + const profile = parsed.searchParams.get("profile"); + const serverUrl = parsed.searchParams.get("serverUrl"); + if (accessToken) args.push("--access-token", resolveEnvVars(accessToken)); + if (configFile) args.push("--config-file", resolveEnvVars(configFile)); + if (profile) args.push("--profile", resolveEnvVars(profile)); + if (serverUrl) args.push("--server-url", resolveEnvVars(serverUrl)); + + const { stdout } = await execFile("bws", args, { timeout: 10_000 }); + const payload = JSON.parse(stdout); + if (typeof payload?.value !== "string" || !payload.value.trim()) { + throw new Error(`Bitwarden secret ${secretId} has no value`); + } + return payload.value; +} + +async function resolveDistillApiKey() { + const raw = process.env.DISTILL_API_KEY || process.env.GEMINI_API_KEY || ""; + if (!raw.trim()) { + if (DISTILL_API === "gemini-native") { + throw new Error("DISTILL_API_KEY or GEMINI_API_KEY is not set"); + } + throw new Error("DISTILL_API_KEY is not set"); + } + return resolveMaybeBitwardenSecret(raw); +} + function normalizeText(s) { return (s || "") .trim() @@ -156,9 +212,8 @@ function buildMapPrompt({ lang, chunk }) { } async function geminiGenerateJson(prompt) { - if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set"); - - const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`; + const apiKey = await resolveDistillApiKey(); + const url = `https://generativelanguage.googleapis.com/v1beta/models/${DISTILL_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`; const body = { contents: [{ role: "user", parts: [{ text: prompt }] }], @@ -183,6 +238,56 @@ async function geminiGenerateJson(prompt) { return text; } +function normalizeAnthropicEndpoint(baseURL) { + const trimmed = String(baseURL || "").trim(); + if (!trimmed) return "https://api.anthropic.com/v1/messages"; + if (/\/messages\/?$/i.test(trimmed)) return trimmed; + return `${trimmed.replace(/\/+$/, "")}/messages`; +} + +// TODO: This builds Anthropic requests independently from createAnthropicApiKeyClient +// in src/llm-client.ts. The two implementations may diverge over time (e.g. retry +// logic, model defaults, streaming). Consider unifying into a shared module. +async function anthropicGenerateJson(prompt) { + const apiKey = await resolveDistillApiKey(); + const res = await fetch(normalizeAnthropicEndpoint(DISTILL_BASE_URL), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": apiKey, + "anthropic-version": DISTILL_ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: DISTILL_MODEL, + system: "You extract high-signal technical lessons. Return valid JSON only.", + messages: [{ role: "user", content: prompt }], + max_tokens: 4096, + temperature: 0.2, + }), + }); + + const json = await res.json(); + if (!res.ok) { + throw new Error(`Anthropic error ${res.status}: ${JSON.stringify(json).slice(0, 500)}`); + } + + const text = Array.isArray(json?.content) + ? json.content + .filter((part) => part && part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("") + : ""; + return text; +} + +async function generateJson(prompt) { + if (DISTILL_API === "anthropic-messages") { + return anthropicGenerateJson(prompt); + } + return geminiGenerateJson(prompt); +} + function coerceLessons(obj) { const lessons = Array.isArray(obj?.lessons) ? obj.lessons : []; return lessons @@ -297,7 +402,7 @@ async function processTaskFile(taskPath) { for (let idx = 0; idx < chunks.length; idx++) { const prompt = buildMapPrompt({ lang, chunk: chunks[idx] }); try { - const text = await geminiGenerateJson(prompt); + const text = await generateJson(prompt); const obj = safeJsonParse(text); if (!obj) { mapErrors++; diff --git a/index.ts b/index.ts index b9d9ee68..5cbcd170 100644 --- a/index.ts +++ b/index.ts @@ -73,6 +73,11 @@ import { type AdmissionRejectionAuditEntry, } from "./src/admission-control.js"; import { analyzeIntent, applyCategoryBoost } from "./src/intent-analyzer.js"; +import { + resolveEnvVarsSync, + resolveSecretValue, + resolveSecretValues, +} from "./src/secret-resolver.js"; // ============================================================================ // Configuration & Types @@ -157,10 +162,12 @@ interface PluginConfig { // Smart extraction config smartExtraction?: boolean; llm?: { + api?: "openai-completions" | "anthropic-messages"; auth?: "api-key" | "oauth"; apiKey?: string; model?: string; baseURL?: string; + anthropicVersion?: string; oauthProvider?: string; oauthPath?: string; timeoutMs?: number; @@ -239,21 +246,15 @@ function resolveWorkspaceDirFromContext(context: Record | undef } function resolveEnvVars(value: string): string { - return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { - const envValue = process.env[envVar]; - if (!envValue) { - throw new Error(`Environment variable ${envVar} is not set`); - } - return envValue; - }); + return resolveEnvVarsSync(value); } -function resolveFirstApiKey(apiKey: string | string[]): string { +async function resolveFirstApiKey(apiKey: string | string[]): Promise { const key = Array.isArray(apiKey) ? apiKey[0] : apiKey; if (!key) { throw new Error("embedding.apiKey is empty"); } - return resolveEnvVars(key); + return resolveSecretValue(key); } function resolveOptionalPathWithEnv( @@ -1610,7 +1611,7 @@ const memoryLanceDBProPlugin = { "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, and management CLI", kind: "memory" as const, - register(api: OpenClawPluginApi) { + async register(api: OpenClawPluginApi) { // Parse and validate configuration const config = parsePluginConfig(api.pluginConfig); @@ -1634,9 +1635,20 @@ const memoryLanceDBProPlugin = { // Initialize core components const store = new MemoryStore({ dbPath: resolvedDbPath, vectorDim }); + const resolvedEmbeddingApiKeys = await resolveSecretValues(config.embedding.apiKey); + const resolvedRerankApiKey = typeof config.retrieval?.rerankApiKey === "string" && config.retrieval.rerankApiKey.trim() + ? await resolveSecretValue(config.retrieval.rerankApiKey) + : undefined; + const resolvedRetrievalConfig = config.retrieval + ? { + ...config.retrieval, + ...(resolvedRerankApiKey ? { rerankApiKey: resolvedRerankApiKey } : {}), + } + : undefined; + const embedder = createEmbedder({ provider: "openai-compatible", - apiKey: config.embedding.apiKey, + apiKey: resolvedEmbeddingApiKeys.length === 1 ? resolvedEmbeddingApiKeys[0] : resolvedEmbeddingApiKeys, model: config.embedding.model || "text-embedding-3-small", baseURL: config.embedding.baseURL, dimensions: config.embedding.dimensions, @@ -1660,7 +1672,7 @@ const memoryLanceDBProPlugin = { embedder, { ...DEFAULT_RETRIEVAL_CONFIG, - ...config.retrieval, + ...resolvedRetrievalConfig, }, { decayEngine }, ); @@ -1680,11 +1692,12 @@ const memoryLanceDBProPlugin = { if (config.smartExtraction !== false) { try { const llmAuth = config.llm?.auth || "api-key"; + const llmApi = config.llm?.api || "openai-completions"; const llmApiKey = llmAuth === "oauth" ? undefined : config.llm?.apiKey - ? resolveEnvVars(config.llm.apiKey) - : resolveFirstApiKey(config.embedding.apiKey); + ? await resolveSecretValue(config.llm.apiKey) + : await resolveFirstApiKey(config.embedding.apiKey); const llmBaseURL = llmAuth === "oauth" ? (config.llm?.baseURL ? resolveEnvVars(config.llm.baseURL) : undefined) : config.llm?.baseURL @@ -1700,10 +1713,12 @@ const memoryLanceDBProPlugin = { const llmTimeoutMs = resolveLlmTimeoutMs(config); const llmClient = createLlmClient({ + api: llmApi, auth: llmAuth, apiKey: llmApiKey, model: llmModel, baseURL: llmBaseURL, + anthropicVersion: config.llm?.anthropicVersion, oauthProvider: llmOauthProvider, oauthPath: llmOauthPath, timeoutMs: llmTimeoutMs, @@ -2178,14 +2193,15 @@ const memoryLanceDBProPlugin = { scopeManager, migrator, embedder, - llmClient: smartExtractor ? (() => { + llmClient: smartExtractor ? await (async () => { try { const llmAuth = config.llm?.auth || "api-key"; + const llmApi = config.llm?.api || "openai-completions"; const llmApiKey = llmAuth === "oauth" ? undefined : config.llm?.apiKey - ? resolveEnvVars(config.llm.apiKey) - : resolveFirstApiKey(config.embedding.apiKey); + ? await resolveSecretValue(config.llm.apiKey) + : await resolveFirstApiKey(config.embedding.apiKey); const llmBaseURL = llmAuth === "oauth" ? (config.llm?.baseURL ? resolveEnvVars(config.llm.baseURL) : undefined) : config.llm?.baseURL @@ -2199,10 +2215,12 @@ const memoryLanceDBProPlugin = { : undefined; const llmTimeoutMs = resolveLlmTimeoutMs(config); return createLlmClient({ + api: llmApi, auth: llmAuth, apiKey: llmApiKey, model: config.llm?.model || "openai/gpt-oss-120b", baseURL: llmBaseURL, + anthropicVersion: config.llm?.anthropicVersion, oauthProvider: llmOauthProvider, oauthPath: llmOauthPath, timeoutMs: llmTimeoutMs, diff --git a/openclaw.plugin.json b/openclaw.plugin.json index a2cfb1f5..76375298 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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:// Bitwarden secret references." }, "model": { "type": "string" @@ -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:// Bitwarden secret references." }, "rerankModel": { "type": "string", @@ -666,6 +666,15 @@ "type": "object", "additionalProperties": false, "properties": { + "api": { + "type": "string", + "enum": [ + "openai-completions", + "anthropic-messages" + ], + "default": "openai-completions", + "description": "LLM API format. openai-completions uses OpenAI-compatible chat completions; anthropic-messages uses Anthropic-compatible /v1/messages." + }, "auth": { "type": "string", "enum": [ @@ -676,7 +685,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:// Bitwarden secret references." }, "model": { "type": "string", @@ -685,6 +695,11 @@ "baseURL": { "type": "string" }, + "anthropicVersion": { + "type": "string", + "default": "2023-06-01", + "description": "Anthropic API version header used when llm.api=anthropic-messages." + }, "oauthProvider": { "type": "string", "description": "OAuth provider id for llm.auth=oauth. Currently supported: openai-codex." @@ -842,8 +857,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:// 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:// Bitwarden refs. Use a dummy value for keyless local endpoints." }, "embedding.model": { "label": "Embedding Model", @@ -902,8 +917,13 @@ "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://", + "help": "API key for LLM used by smart memory extraction (defaults to embedding.apiKey if omitted). Supports Bitwarden bws:// refs." + }, + "llm.api": { + "label": "LLM API Format", + "help": "openai-completions for OpenAI-compatible chat endpoints, anthropic-messages for Anthropic-compatible /v1/messages endpoints.", + "advanced": true }, "llm.model": { "label": "LLM Model", @@ -913,7 +933,13 @@ "llm.baseURL": { "label": "LLM Base URL", "placeholder": "https://api.groq.com/openai/v1", - "help": "OpenAI-compatible base URL for LLM (defaults to embedding.baseURL if omitted)", + "help": "Base URL for the configured llm.api format (defaults to embedding.baseURL if omitted for openai-completions)", + "advanced": true + }, + "llm.anthropicVersion": { + "label": "Anthropic Version", + "placeholder": "2023-06-01", + "help": "Anthropic API version header used when llm.api=anthropic-messages", "advanced": true }, "extractMinMessages": { @@ -1062,8 +1088,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://", + "help": "Reranker API key for cross-encoder reranking. Supports Bitwarden bws:// refs.", "advanced": true }, "retrieval.rerankModel": { @@ -1292,7 +1318,7 @@ }, "llm.auth": { "label": "LLM Auth", - "help": "api-key uses llm.apiKey or embedding.apiKey. oauth uses a plugin-scoped OAuth token file by default.", + "help": "api-key uses llm.apiKey or embedding.apiKey. oauth uses a plugin-scoped OAuth token file by default and only supports llm.api=openai-completions.", "advanced": true }, "llm.oauthProvider": { diff --git a/package.json b/package.json index cfd47cd0..8a404819 100644 --- a/package.json +++ b/package.json @@ -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/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/session-summary-before-reset.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" }, diff --git a/src/llm-client.ts b/src/llm-client.ts index 79182ede..b6eaaea2 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -18,6 +18,8 @@ export interface LlmClientConfig { apiKey?: string; model: string; baseURL?: string; + api?: "openai-completions" | "anthropic-messages"; + anthropicVersion?: string; auth?: "api-key" | "oauth"; oauthPath?: string; oauthProvider?: string; @@ -177,6 +179,10 @@ function createApiKeyClient(config: LlmClientConfig, log: (msg: string) => void) throw new Error("LLM api-key mode requires llm.apiKey or embedding.apiKey"); } + if (config.api === "anthropic-messages") { + return createAnthropicApiKeyClient(config, log); + } + const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL, @@ -410,9 +416,138 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void): }; } +function normalizeAnthropicMessagesEndpoint(baseURL?: string): string { + const trimmed = baseURL?.trim(); + if (!trimmed) return "https://api.anthropic.com/v1/messages"; + if (/\/messages\/?$/i.test(trimmed)) return trimmed; + return `${trimmed.replace(/\/+$/, "")}/messages`; +} + +function extractAnthropicText(payload: Record): string | null { + const content = Array.isArray(payload.content) ? payload.content : []; + const text = content + .filter( + (part) => + part && + typeof part === "object" && + (part as Record).type === "text" && + typeof (part as Record).text === "string", + ) + .map((part) => String((part as Record).text)) + .join(""); + return text.trim() || null; +} + +// TODO: anthropicGenerateJson in examples/new-session-distill/worker/lesson-extract-worker.mjs +// builds Anthropic requests independently of this function. The two may diverge over time +// (e.g. retry logic, model defaults, streaming). Consider unifying into a shared module. +function createAnthropicApiKeyClient(config: LlmClientConfig, log: (msg: string) => void): LlmClient { + let lastError: string | null = null; + const endpoint = normalizeAnthropicMessagesEndpoint(config.baseURL); + const anthropicVersion = config.anthropicVersion?.trim() || "2023-06-01"; + + return { + async completeJson(prompt: string, label = "generic"): Promise { + lastError = null; + const { signal, dispose } = createTimeoutSignal(config.timeoutMs); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": config.apiKey!, + "anthropic-version": anthropicVersion, + }, + signal, + body: JSON.stringify({ + model: config.model, + system: + "You are a memory extraction assistant. Always respond with valid JSON only.", + messages: [ + { + role: "user", + content: prompt, + }, + ], + max_tokens: 2048, + temperature: 0.1, + }), + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}: ${bodyText.slice(0, 500)}`); + } + + let raw: string | null = null; + try { + raw = extractAnthropicText(JSON.parse(bodyText) as Record); + } catch (error) { + throw new Error( + `Failed to parse Anthropic response JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!raw) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty Anthropic response content from model ${config.model}`; + log(lastError); + return null; + } + + const jsonStr = extractJsonFromResponse(raw); + if (!jsonStr) { + lastError = + `memory-lancedb-pro: llm-client [${label}] no JSON object found in Anthropic response (chars=${raw.length}, preview=${JSON.stringify(previewText(raw))})`; + log(lastError); + return null; + } + + try { + return JSON.parse(jsonStr) as T; + } catch (err) { + const repairedJsonStr = repairCommonJson(jsonStr); + if (repairedJsonStr !== jsonStr) { + try { + const repaired = JSON.parse(repairedJsonStr) as T; + log( + `memory-lancedb-pro: llm-client [${label}] recovered malformed Anthropic JSON via heuristic repair (jsonChars=${jsonStr.length})`, + ); + return repaired; + } catch (repairErr) { + lastError = + `memory-lancedb-pro: llm-client [${label}] Anthropic JSON.parse failed: ${err instanceof Error ? err.message : String(err)}; repair failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)} (jsonChars=${jsonStr.length}, jsonPreview=${JSON.stringify(previewText(jsonStr))})`; + log(lastError); + return null; + } + } + lastError = + `memory-lancedb-pro: llm-client [${label}] Anthropic JSON.parse failed: ${err instanceof Error ? err.message : String(err)} (jsonChars=${jsonStr.length}, jsonPreview=${JSON.stringify(previewText(jsonStr))})`; + log(lastError); + return null; + } + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] Anthropic request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + log(lastError); + return null; + } finally { + dispose(); + } + }, + getLastError(): string | null { + return lastError; + }, + }; +} + export function createLlmClient(config: LlmClientConfig): LlmClient { const log = config.log ?? (() => {}); if (config.auth === "oauth") { + if (config.api === "anthropic-messages") { + throw new Error("LLM oauth mode only supports llm.api=openai-completions"); + } return createOauthClient(config, log); } return createApiKeyClient(config, log); diff --git a/src/secret-resolver.ts b/src/secret-resolver.ts new file mode 100644 index 00000000..fc807488 --- /dev/null +++ b/src/secret-resolver.ts @@ -0,0 +1,212 @@ +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; + +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 { + const execImpl = options?.execFileImpl ?? execFile; + const env = getEnv(options); + const args = ["secret", "get", ref.id, "--output", "json"]; + if (ref.accessToken) args.push("--access-token", ref.accessToken); + 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, + 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; + try { + parsed = JSON.parse(stdout) as Record; + } 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"]; + if (ref.accessToken) args.push("--access-token", ref.accessToken); + 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, + 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; + try { + parsed = JSON.parse(stdout) as Record; + } 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 { + 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 { + 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)); +} diff --git a/test/llm-api-key-client.test.mjs b/test/llm-api-key-client.test.mjs index 6d7f7518..5cd6552d 100644 --- a/test/llm-api-key-client.test.mjs +++ b/test/llm-api-key-client.test.mjs @@ -65,4 +65,57 @@ describe("LLM api-key client", () => { ]); assert.equal(requestBody.temperature, 0.1); }); + + it("uses Anthropic-compatible messages semantics when llm.api=anthropic-messages", async () => { + let requestHeaders; + let requestBody; + + server = http.createServer(async (req, res) => { + requestHeaders = req.headers; + + let body = ""; + for await (const chunk of req) body += chunk; + requestBody = JSON.parse(body); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + id: "msg_test", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "{\"memories\":[]}", + }, + ], + })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + + const llm = createLlmClient({ + api: "anthropic-messages", + auth: "api-key", + apiKey: "anthropic-test-key", + model: "claude-sonnet-4-5", + baseURL: `http://127.0.0.1:${port}/v1`, + anthropicVersion: "2023-06-01", + timeoutMs: 4321, + }); + + const result = await llm.completeJson("hello", "anthropic-probe"); + assert.deepEqual(result, { memories: [] }); + assert.equal(requestHeaders["x-api-key"], "anthropic-test-key"); + assert.equal(requestHeaders["anthropic-version"], "2023-06-01"); + assert.equal(requestBody.model, "claude-sonnet-4-5"); + assert.equal(requestBody.system, "You are a memory extraction assistant. Always respond with valid JSON only."); + assert.deepEqual(requestBody.messages, [ + { + role: "user", + content: "hello", + }, + ]); + assert.equal(requestBody.max_tokens, 2048); + assert.equal(requestBody.temperature, 0.1); + }); }); diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs index 65e9ec23..3bdc5962 100644 --- a/test/plugin-manifest-regression.mjs +++ b/test/plugin-manifest-regression.mjs @@ -71,6 +71,10 @@ for (const key of [ ); } +assert.ok( + Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "api"), + "configSchema should declare llm.api", +); assert.ok( Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "auth"), "configSchema should declare llm.auth", @@ -83,6 +87,10 @@ assert.ok( Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "oauthProvider"), "configSchema should declare llm.oauthProvider", ); +assert.ok( + Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "anthropicVersion"), + "configSchema should declare llm.anthropicVersion", +); assert.equal( manifest.configSchema.properties.autoRecallMinRepeated.default, @@ -149,7 +157,7 @@ try { }, { services }, ); - plugin.register(api); + await 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"); @@ -171,7 +179,7 @@ try { dimensions: 1536, }, }); - plugin.register(sessionDefaultApi); + await plugin.register(sessionDefaultApi); assert.equal( sessionDefaultApi.hooks["command:new"], undefined, @@ -191,7 +199,7 @@ try { dimensions: 1536, }, }); - plugin.register(sessionEnabledApi); + await plugin.register(sessionEnabledApi); assert.equal( typeof sessionEnabledApi.hooks.before_reset, "function", @@ -263,7 +271,7 @@ try { chunking: false, }, }); - plugin.register(chunkingOffApi); + await plugin.register(chunkingOffApi); const chunkingOffTool = chunkingOffApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", @@ -291,7 +299,7 @@ try { chunking: true, }, }); - plugin.register(chunkingOnApi); + await plugin.register(chunkingOnApi); const chunkingOnTool = chunkingOnApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", @@ -318,7 +326,7 @@ try { dimensions: 4, }, }); - plugin.register(withDimensionsApi); + await plugin.register(withDimensionsApi); const withDimensionsTool = withDimensionsApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", @@ -348,7 +356,7 @@ try { omitDimensions: true, }, }); - plugin.register(omitDimensionsApi); + await plugin.register(omitDimensionsApi); const omitDimensionsTool = omitDimensionsApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", diff --git a/test/recall-text-cleanup.test.mjs b/test/recall-text-cleanup.test.mjs index 3788ccd8..f181a99c 100644 --- a/test/recall-text-cleanup.test.mjs +++ b/test/recall-text-cleanup.test.mjs @@ -415,7 +415,7 @@ describe("recall text cleanup", () => { }, }); - memoryLanceDBProPlugin.register(harness.api); + await memoryLanceDBProPlugin.register(harness.api); const hooks = harness.eventHandlers.get("before_prompt_build") || []; assert.equal(hooks.length, 1, "expected at least one before_prompt_build hook for this config"); @@ -630,7 +630,7 @@ describe("recall text cleanup", () => { }, }); - memoryLanceDBProPlugin.register(harness.api); + await memoryLanceDBProPlugin.register(harness.api); const hooks = harness.eventHandlers.get("before_prompt_build") || []; const [{ handler: autoRecallHook }] = hooks; const output = await autoRecallHook( @@ -688,7 +688,7 @@ describe("recall text cleanup", () => { selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, }, }); - memoryLanceDBProPlugin.register(harness.api); + await memoryLanceDBProPlugin.register(harness.api); const hooks = harness.eventHandlers.get("before_prompt_build") || []; const [{ handler: autoRecallHook }] = hooks; const output = await autoRecallHook( @@ -808,7 +808,7 @@ describe("recall text cleanup", () => { }, }); - memoryLanceDBProPlugin.register(harness.api); + await memoryLanceDBProPlugin.register(harness.api); const hooks = harness.eventHandlers.get("before_prompt_build") || []; assert.equal(hooks.length, 1); @@ -890,7 +890,7 @@ describe("recall text cleanup", () => { }, }); - memoryLanceDBProPlugin.register(harness.api); + await memoryLanceDBProPlugin.register(harness.api); const hooks = harness.eventHandlers.get("before_prompt_build") || []; assert.equal(hooks.length, 1); diff --git a/test/reflection-bypass-hook.test.mjs b/test/reflection-bypass-hook.test.mjs index 87b5a957..798e93e1 100644 --- a/test/reflection-bypass-hook.test.mjs +++ b/test/reflection-bypass-hook.test.mjs @@ -113,7 +113,7 @@ async function invokeReflectionHooks({ workDir, agentId, explicitAgentId = agent pluginConfig, }); - memoryLanceDBProPlugin.register(harness.api); + await memoryLanceDBProPlugin.register(harness.api); const promptHooks = harness.eventHandlers.get("before_prompt_build") || []; diff --git a/test/resolve-env-vars-array.test.mjs b/test/resolve-env-vars-array.test.mjs index 454568b6..1d40282f 100644 --- a/test/resolve-env-vars-array.test.mjs +++ b/test/resolve-env-vars-array.test.mjs @@ -96,7 +96,7 @@ async function withTestEnv(apiKeyConfig, fn) { registerHook(name, handler) { this.hooks[name] = handler; }, }; - plugin.register(api); + await plugin.register(api); await fn(logs); } finally { await new Promise((r) => embeddingServer.close(r)); diff --git a/test/secret-resolver.test.mjs b/test/secret-resolver.test.mjs new file mode 100644 index 00000000..6e17d5c4 --- /dev/null +++ b/test/secret-resolver.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const { + resolveEnvVarsSync, + resolveSecretValue, + resolveSecretValues, +} = jiti("../src/secret-resolver.ts"); + +describe("secret resolver", () => { + it("resolves environment-variable templates synchronously", () => { + const value = resolveEnvVarsSync("${TEST_SECRET_VALUE}", { + TEST_SECRET_VALUE: "resolved", + }); + assert.equal(value, "resolved"); + }); + + it("passes through plain strings without executing bws", async () => { + let called = false; + const value = await resolveSecretValue("plain-value", { + execFileImpl: async () => { + called = true; + return { stdout: "", stderr: "" }; + }, + }); + assert.equal(value, "plain-value"); + assert.equal(called, false); + }); + + it("resolves bws:// refs via the Bitwarden CLI", async () => { + let captured; + const value = await resolveSecretValue( + "bws://49e0d691-11c4-43cc-a3f9-b40e00f83237?profile=${BWS_PROFILE}", + { + env: { BWS_PROFILE: "ops" }, + execFileImpl: async (file, args) => { + captured = { file, args }; + return { + stdout: JSON.stringify({ + id: "49e0d691-11c4-43cc-a3f9-b40e00f83237", + value: "secret-from-bws", + }), + stderr: "", + }; + }, + }, + ); + + assert.equal(value, "secret-from-bws"); + assert.deepEqual(captured, { + file: "bws", + args: [ + "secret", + "get", + "49e0d691-11c4-43cc-a3f9-b40e00f83237", + "--output", + "json", + "--profile", + "ops", + ], + }); + }); + + it("resolves arrays of secret refs", async () => { + const values = await resolveSecretValues( + ["bws://first-secret", "${SECOND_SECRET}"], + { + env: { SECOND_SECRET: "second-value" }, + execFileImpl: async (_file, args) => ({ + stdout: JSON.stringify({ + id: args[2], + value: `${args[2]}-value`, + }), + stderr: "", + }), + }, + ); + + assert.deepEqual(values, ["first-secret-value", "second-value"]); + }); + + it("throws on bws:// URL with no secret ID (empty hostname and path)", async () => { + await assert.rejects( + () => resolveSecretValue("bws:///", {}), + /Invalid Bitwarden secret reference/, + ); + }); + + it("throws on bws:// URL with only a slash path and no hostname", async () => { + await assert.rejects( + () => resolveSecretValue("bws:///secret/", {}), + /Invalid Bitwarden secret reference/, + ); + }); + + it("throws on bws:// URL where secret ID reduces to empty after prefix strip", async () => { + await assert.rejects( + () => resolveSecretValue("bws://secret/", {}), + /Invalid Bitwarden secret reference/, + ); + }); +}); diff --git a/test/smart-extractor-branches.mjs b/test/smart-extractor-branches.mjs index 67830f68..e2d98dc0 100644 --- a/test/smart-extractor-branches.mjs +++ b/test/smart-extractor-branches.mjs @@ -268,7 +268,7 @@ async function runScenario(mode) { `http://127.0.0.1:${port}`, logs, ); - plugin.register(api); + await plugin.register(api); await seedPreference(dbPath); await runAgentEndHook( @@ -450,7 +450,7 @@ async function runMultiRoundScenario() { `http://127.0.0.1:${port}`, logs, ); - plugin.register(api); + await plugin.register(api); const rounds = [ ["最近我在调整饮品偏好。", "我喜欢乌龙茶。", "这条偏好以后都有效。", "请记住。"], @@ -549,7 +549,7 @@ async function runInjectedRecallScenario() { `http://127.0.0.1:${port}`, logs, ); - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api, @@ -643,7 +643,7 @@ async function runPrependedRecallWithUserTextScenario() { `http://127.0.0.1:${port}`, logs, ); - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api, @@ -735,7 +735,7 @@ async function runInboundMetadataWrappedScenario() { `http://127.0.0.1:${port}`, logs, ); - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api, @@ -791,7 +791,7 @@ async function runSessionDeltaScenario() { "http://127.0.0.1:9", logs, ); - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api, @@ -855,7 +855,7 @@ async function runPendingIngressScenario() { "http://127.0.0.1:9", logs, ); - plugin.register(api); + await plugin.register(api); await api.hooks.message_received( { from: "discord:channel:1", content: "@jige_claw_bot 我的饮品偏好是乌龙茶" }, @@ -911,7 +911,7 @@ async function runRememberCommandContextScenario() { "http://127.0.0.1:9", logs, ); - plugin.register(api); + await plugin.register(api); await api.hooks.message_received( { from: "discord:channel:1", content: "@jige_claw_bot 我的饮品偏好是乌龙茶" }, @@ -1036,7 +1036,7 @@ async function runUserMdExclusiveProfileScenario() { enabled: true, }, }; - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api, @@ -1134,7 +1134,7 @@ async function runBoundarySkipKeepsRegexFallbackScenario() { enabled: true, }, }; - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api, @@ -1236,7 +1236,7 @@ async function runInboundMetadataCleanupScenario() { `http://127.0.0.1:${port}`, logs, ); - plugin.register(api); + await plugin.register(api); await runAgentEndHook( api,