From 87d573c528f7f6ada5dc56a73de98342ce67e5f8 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 12 Mar 2026 17:33:29 +0800 Subject: [PATCH] add vault-backed token lookup for integrations --- src/app/api/integrations/probe/route.ts | 24 ++- src/app/api/openclaw/assistant/route.ts | 34 +++- src/components/AskAIDialog.tsx | 2 + .../openclaw/OpenClawAssistantPane.tsx | 40 ++++- .../xworkmate/XWorkmateWorkspacePage.tsx | 59 +++++-- src/lib/openclaw/types.ts | 4 +- .../components/IntegrationsConsole.tsx | 107 ++++++++++- src/server/consoleIntegrations.ts | 166 ++++++++++++++++-- src/server/openclaw/device-store.ts | 35 +++- src/state/openclawConsoleStore.ts | 12 ++ 10 files changed, 435 insertions(+), 48 deletions(-) diff --git a/src/app/api/integrations/probe/route.ts b/src/app/api/integrations/probe/route.ts index 93e4b71..9598dac 100644 --- a/src/app/api/integrations/probe/route.ts +++ b/src/app/api/integrations/probe/route.ts @@ -15,7 +15,10 @@ type ProbeBody = { gatewayUrl?: string gatewayToken?: string vaultUrl?: string + vaultNamespace?: string vaultToken?: string + vaultSecretPath?: string + vaultSecretKey?: string apisixUrl?: string apisixToken?: string } @@ -34,13 +37,17 @@ function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } +function gatewayErrorCode(error: OpenClawGatewayError | null): string | undefined { + return error?.code ?? stringValue(asRecord(error?.details).code) +} + function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string { if (!error) { return 'Failed to probe OpenClaw gateway.' } const details = asRecord(error.details) - const detailCode = stringValue(details.code) + const detailCode = gatewayErrorCode(error) if (detailCode === 'PAIRING_REQUIRED') { const requestId = stringValue(details.requestId) const reason = stringValue(details.reason) @@ -58,9 +65,14 @@ function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClaw } async function probeOpenClaw(body: ProbeBody): Promise { - const config = resolveOpenClawGatewayConfig({ + const config = await resolveOpenClawGatewayConfig({ gatewayUrl: body.gatewayUrl, gatewayToken: body.gatewayToken, + vaultUrl: body.vaultUrl, + vaultToken: body.vaultToken, + vaultNamespace: body.vaultNamespace, + vaultSecretPath: body.vaultSecretPath, + vaultSecretKey: body.vaultSecretKey, }) if (!config.gatewayUrl) { @@ -95,7 +107,7 @@ async function probeOpenClaw(body: ProbeBody): Promise { gatewayUrl: config.gatewayUrl, tokenSource: config.tokenSource, error: formatGatewayError(gatewayError, client), - code: gatewayError?.code, + code: gatewayErrorCode(gatewayError), details: gatewayError?.details ?? null, deviceId: client.deviceId || undefined, }, @@ -152,9 +164,13 @@ async function probeVault(body: ProbeBody): Promise { } async function probeApisix(body: ProbeBody): Promise { - const config = resolveApisixProbeConfig({ + const config = await resolveApisixProbeConfig({ apisixUrl: body.apisixUrl, apisixToken: body.apisixToken, + vaultUrl: body.vaultUrl, + vaultToken: body.vaultToken, + vaultNamespace: body.vaultNamespace, + vaultSecretPath: body.vaultSecretPath, }) if (!config.apisixUrl) { diff --git a/src/app/api/openclaw/assistant/route.ts b/src/app/api/openclaw/assistant/route.ts index 9e25015..399b72e 100644 --- a/src/app/api/openclaw/assistant/route.ts +++ b/src/app/api/openclaw/assistant/route.ts @@ -19,6 +19,11 @@ type BootstrapBody = { action: 'bootstrap' gatewayUrl?: string gatewayToken?: string + vaultUrl?: string + vaultNamespace?: string + vaultToken?: string + vaultSecretPath?: string + vaultSecretKey?: string agentId?: string sessionKey?: string } @@ -27,6 +32,11 @@ type SendBody = { action: 'send' gatewayUrl?: string gatewayToken?: string + vaultUrl?: string + vaultNamespace?: string + vaultToken?: string + vaultSecretPath?: string + vaultSecretKey?: string agentId?: string sessionKey?: string message?: string @@ -62,13 +72,17 @@ function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } +function gatewayErrorCode(error: OpenClawGatewayError | null): string | undefined { + return error?.code ?? stringValue(asRecord(error?.details).code) +} + function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string { if (!error) { return 'Failed to connect to OpenClaw gateway.' } const details = asRecord(error.details) - const detailCode = stringValue(details.code) + const detailCode = gatewayErrorCode(error) if (detailCode === 'PAIRING_REQUIRED') { const requestId = stringValue(details.requestId) const reason = stringValue(details.reason) @@ -99,9 +113,14 @@ function resolveSessionKey(params: { } async function handleBootstrap(body: BootstrapBody): Promise { - const gateway = resolveOpenClawGatewayConfig({ + const gateway = await resolveOpenClawGatewayConfig({ gatewayUrl: body.gatewayUrl, gatewayToken: body.gatewayToken, + vaultUrl: body.vaultUrl, + vaultToken: body.vaultToken, + vaultNamespace: body.vaultNamespace, + vaultSecretPath: body.vaultSecretPath, + vaultSecretKey: body.vaultSecretKey, }) if (!gateway.gatewayUrl) { @@ -149,8 +168,8 @@ async function handleBootstrap(body: BootstrapBody): Promise { const gatewayError = error instanceof OpenClawGatewayError ? error : null return jsonError( formatGatewayError(gatewayError, client), - gatewayError?.code === 'OFFLINE' ? 503 : 502, - gatewayError?.code, + gatewayErrorCode(gatewayError) === 'OFFLINE' ? 503 : 502, + gatewayErrorCode(gatewayError), gatewayError?.details ?? null, client.deviceId || undefined, ) @@ -160,9 +179,14 @@ async function handleBootstrap(body: BootstrapBody): Promise { } async function handleSend(body: SendBody): Promise { - const gateway = resolveOpenClawGatewayConfig({ + const gateway = await resolveOpenClawGatewayConfig({ gatewayUrl: body.gatewayUrl, gatewayToken: body.gatewayToken, + vaultUrl: body.vaultUrl, + vaultToken: body.vaultToken, + vaultNamespace: body.vaultNamespace, + vaultSecretPath: body.vaultSecretPath, + vaultSecretKey: body.vaultSecretKey, }) if (!gateway.gatewayUrl) { diff --git a/src/components/AskAIDialog.tsx b/src/components/AskAIDialog.tsx index 5e7e9c6..c55cfdd 100644 --- a/src/components/AskAIDialog.tsx +++ b/src/components/AskAIDialog.tsx @@ -34,6 +34,8 @@ export function AskAIDialog({ vaultUrl: "", vaultNamespace: "", vaultTokenConfigured: false, + vaultSecretPath: "", + vaultSecretKey: "", apisixUrl: "", apisixTokenConfigured: false, }; diff --git a/src/components/openclaw/OpenClawAssistantPane.tsx b/src/components/openclaw/OpenClawAssistantPane.tsx index 676feec..f5d78af 100644 --- a/src/components/openclaw/OpenClawAssistantPane.tsx +++ b/src/components/openclaw/OpenClawAssistantPane.tsx @@ -37,6 +37,7 @@ import { type GatewayChatAttachmentPayload, type GatewayChatMessage, type GatewaySessionSummary, + type GatewayTokenSource, type IntegrationDefaults, type OpenClawBootstrapResponse, type OpenClawStreamEvent, @@ -211,9 +212,8 @@ export function OpenClawAssistantPane({ const [gatewayHealth, setGatewayHealth] = useState>( {}, ); - const [gatewayTokenSource, setGatewayTokenSource] = useState< - "env" | "request" | "none" - >("none"); + const [gatewayTokenSource, setGatewayTokenSource] = + useState("none"); const [mainSessionKey, setMainSessionKey] = useState("main"); const [messages, setMessages] = useState([]); const [sessions, setSessions] = useState([]); @@ -231,6 +231,17 @@ export function OpenClawAssistantPane({ const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults); const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl); const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken); + const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl); + const vaultNamespace = useOpenClawConsoleStore( + (state) => state.vaultNamespace, + ); + const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken); + const vaultSecretPath = useOpenClawConsoleStore( + (state) => state.vaultSecretPath, + ); + const vaultSecretKey = useOpenClawConsoleStore( + (state) => state.vaultSecretKey, + ); const assistantMode = useOpenClawConsoleStore((state) => state.assistantMode); const thinking = useOpenClawConsoleStore((state) => state.thinking); const selectedAgentId = useOpenClawConsoleStore( @@ -313,6 +324,7 @@ export function OpenClawAssistantPane({ ), envToken: pickCopy(isChinese, "环境变量令牌", "env token"), sessionToken: pickCopy(isChinese, "会话令牌", "session token"), + vaultToken: pickCopy(isChinese, "Vault 令牌", "vault token"), noToken: pickCopy(isChinese, "无令牌", "no token"), mainAgent: pickCopy(isChinese, "主助手", "Main agent"), reconnect: pickCopy(isChinese, "重新连接", "Reconnect"), @@ -425,6 +437,11 @@ export function OpenClawAssistantPane({ action: "bootstrap", gatewayUrl: openclawUrl, gatewayToken: openclawToken, + vaultUrl, + vaultNamespace, + vaultToken, + vaultSecretPath, + vaultSecretKey, agentId: nextAgentId ?? selectedAgentId, sessionKey: nextSessionKey ?? selectedSessionKey, }), @@ -463,6 +480,11 @@ export function OpenClawAssistantPane({ copy.serverMissing, openclawToken, openclawUrl, + vaultNamespace, + vaultSecretKey, + vaultSecretPath, + vaultToken, + vaultUrl, selectedAgentId, selectedSessionKey, setSelectedSessionKey, @@ -564,6 +586,11 @@ export function OpenClawAssistantPane({ action: "send", gatewayUrl: openclawUrl, gatewayToken: openclawToken, + vaultUrl, + vaultNamespace, + vaultToken, + vaultSecretPath, + vaultSecretKey, agentId: effectiveAgentId, sessionKey: effectiveSessionKey, message: prompt, @@ -642,6 +669,11 @@ export function OpenClawAssistantPane({ mainSessionKey, openclawToken, openclawUrl, + vaultNamespace, + vaultSecretKey, + vaultSecretPath, + vaultToken, + vaultUrl, selectedAgentId, selectedSessionKey, setSelectedAgentId, @@ -731,6 +763,8 @@ export function OpenClawAssistantPane({ · {gatewayTokenSource === "env" ? copy.envToken + : gatewayTokenSource === "vault" + ? copy.vaultToken : gatewayTokenSource === "request" ? copy.sessionToken : copy.noToken} diff --git a/src/components/xworkmate/XWorkmateWorkspacePage.tsx b/src/components/xworkmate/XWorkmateWorkspacePage.tsx index 476a329..d982cda 100644 --- a/src/components/xworkmate/XWorkmateWorkspacePage.tsx +++ b/src/components/xworkmate/XWorkmateWorkspacePage.tsx @@ -429,6 +429,9 @@ export function XWorkmateWorkspacePage({ (state) => state.vaultNamespace, ); const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken); + const vaultSecretPath = useOpenClawConsoleStore( + (state) => state.vaultSecretPath, + ); const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl); const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken); const assistantMode = useOpenClawConsoleStore((state) => state.assistantMode); @@ -493,6 +496,33 @@ export function XWorkmateWorkspacePage({ const configuredIntegrationCount = integrationRows.filter( (item) => item.ok, ).length; + const hasVaultBackedToken = Boolean( + vaultSecretPath.trim() && (vaultToken.trim() || defaults.vaultTokenConfigured), + ); + const hasOpenClawCredential = Boolean( + openclawToken.trim() || defaults.openclawTokenConfigured || hasVaultBackedToken, + ); + const openclawConfigSource = openclawToken.trim() + ? "session" + : defaults.openclawTokenConfigured + ? "env" + : hasVaultBackedToken + ? "vault" + : "pairing only"; + const vaultConfigSource = vaultToken.trim() + ? "session" + : defaults.vaultTokenConfigured + ? "env" + : vaultSecretPath.trim() + ? "vault path" + : "manual"; + const apisixConfigSource = apisixToken.trim() + ? "session" + : defaults.apisixTokenConfigured + ? "env" + : vaultSecretPath.trim() + ? "vault path" + : "manual"; const tasksOverview = useMemo( () => [ @@ -520,10 +550,7 @@ export function XWorkmateWorkspacePage({ }, { label: pickCopy(isChinese, "失败", "Failed"), - value: - openclawToken.trim() || defaults.openclawTokenConfigured - ? "0" - : "pairing", + value: hasOpenClawCredential ? "0" : "pairing", caption: pickCopy( isChinese, "未配置 shared token 时优先走 pairing", @@ -543,10 +570,9 @@ export function XWorkmateWorkspacePage({ }, ], [ - defaults.openclawTokenConfigured, + hasOpenClawCredential, isChinese, openclawEndpoint, - openclawToken, selectedSessionKey, ], ); @@ -618,7 +644,7 @@ export function XWorkmateWorkspacePage({ label: pickCopy(isChinese, "Token 引用", "Token Refs"), value: `${ [ - openclawToken.trim() || defaults.openclawTokenConfigured, + hasOpenClawCredential, vaultToken.trim() || defaults.vaultTokenConfigured, apisixToken.trim() || defaults.apisixTokenConfigured, ].filter(Boolean).length @@ -655,10 +681,9 @@ export function XWorkmateWorkspacePage({ apisixToken, configuredIntegrationCount, defaults.apisixTokenConfigured, - defaults.openclawTokenConfigured, + hasOpenClawCredential, defaults.vaultTokenConfigured, isChinese, - openclawToken, vaultEndpoint, vaultToken, ], @@ -1438,8 +1463,10 @@ export function XWorkmateWorkspacePage({ ) : pickCopy( isChinese, - "优先 env / pairing", - "env / pairing preferred", + hasVaultBackedToken ? "优先 Vault / env" : "优先 env / pairing", + hasVaultBackedToken + ? "vault / env preferred" + : "env / pairing preferred", ) } ok={Boolean((openclawUrl || defaults.openclawUrl).trim())} @@ -1642,13 +1669,13 @@ export function XWorkmateWorkspacePage({ title={pickCopy(isChinese, "当前配置源", "Current config sources")} description={pickCopy( isChinese, - "区分 env 与 session。", - "Separate env from session overrides.", + "区分 session / env / vault / pairing。", + "Separate session, env, vault, and pairing sources.", )} body={[ - `OpenClaw: ${openclawToken.trim() ? "session" : defaults.openclawTokenConfigured ? "env" : "pairing only"}`, - `Vault: ${vaultToken.trim() ? "session" : defaults.vaultTokenConfigured ? "env" : "manual"}`, - `APISIX: ${apisixToken.trim() ? "session" : defaults.apisixTokenConfigured ? "env" : "manual"}`, + `OpenClaw: ${openclawConfigSource}`, + `Vault: ${vaultConfigSource}`, + `APISIX: ${apisixConfigSource}`, ].join(" · ")} /> diff --git a/src/lib/openclaw/types.ts b/src/lib/openclaw/types.ts index 11d30e2..9089e5a 100644 --- a/src/lib/openclaw/types.ts +++ b/src/lib/openclaw/types.ts @@ -2,7 +2,7 @@ export type AssistantMode = 'ask' | 'craft' | 'plan' export type ThinkingLevel = 'low' | 'medium' | 'high' | 'max' -export type GatewayTokenSource = 'env' | 'request' | 'none' +export type GatewayTokenSource = 'env' | 'request' | 'vault' | 'none' export type GatewayAgentSummary = { id: string @@ -83,6 +83,8 @@ export type IntegrationDefaults = { vaultUrl: string vaultNamespace: string vaultTokenConfigured: boolean + vaultSecretPath: string + vaultSecretKey: string apisixUrl: string apisixTokenConfigured: boolean } diff --git a/src/modules/extensions/builtin/user-center/components/IntegrationsConsole.tsx b/src/modules/extensions/builtin/user-center/components/IntegrationsConsole.tsx index a50b61f..669fabe 100644 --- a/src/modules/extensions/builtin/user-center/components/IntegrationsConsole.tsx +++ b/src/modules/extensions/builtin/user-center/components/IntegrationsConsole.tsx @@ -24,6 +24,9 @@ type ProbeState = { tokenSource?: string; body?: string; error?: string; + code?: string; + details?: Record | null; + deviceId?: string; }; type IntegrationsConsoleProps = { @@ -80,12 +83,18 @@ function inputClassName(type: "input" | "textarea" = "input"): string { .join(" "); } +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + const EMPTY_DEFAULTS: IntegrationDefaults = { openclawUrl: "", openclawTokenConfigured: false, vaultUrl: "", vaultNamespace: "", vaultTokenConfigured: false, + vaultSecretPath: "", + vaultSecretKey: "", apisixUrl: "", apisixTokenConfigured: false, }; @@ -115,6 +124,12 @@ export function IntegrationsConsole({ (state) => state.vaultNamespace, ); const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken); + const vaultSecretPath = useOpenClawConsoleStore( + (state) => state.vaultSecretPath, + ); + const vaultSecretKey = useOpenClawConsoleStore( + (state) => state.vaultSecretKey, + ); const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl); const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken); const setOpenclawUrl = useOpenClawConsoleStore( @@ -128,6 +143,12 @@ export function IntegrationsConsole({ (state) => state.setVaultNamespace, ); const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken); + const setVaultSecretPath = useOpenClawConsoleStore( + (state) => state.setVaultSecretPath, + ); + const setVaultSecretKey = useOpenClawConsoleStore( + (state) => state.setVaultSecretKey, + ); const setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl); const setApisixToken = useOpenClawConsoleStore( (state) => state.setApisixToken, @@ -175,6 +196,7 @@ export function IntegrationsConsole({ configured: Boolean(openclawUrl.trim()), tokenConfigured: resolvedDefaults.openclawTokenConfigured || + Boolean(vaultSecretPath.trim() && (vaultToken.trim() || resolvedDefaults.vaultTokenConfigured)) || Boolean(openclawToken.trim()), }, { @@ -201,6 +223,7 @@ export function IntegrationsConsole({ resolvedDefaults.openclawTokenConfigured, resolvedDefaults.vaultTokenConfigured, vaultToken, + vaultSecretPath, vaultUrl, ], ); @@ -219,7 +242,10 @@ export function IntegrationsConsole({ gatewayUrl: openclawUrl, gatewayToken: openclawToken, vaultUrl, + vaultNamespace, vaultToken, + vaultSecretPath, + vaultSecretKey, apisixUrl, apisixToken, }), @@ -230,10 +256,13 @@ export function IntegrationsConsole({ ...current, [target]: { ok: Boolean(payload.ok), - status: payload.status, + status: payload.status ?? response.status, tokenSource: payload.tokenSource, body: payload.body, error: payload.error, + code: payload.code, + details: payload.details ?? null, + deviceId: payload.deviceId, }, })); } catch (error) { @@ -242,6 +271,7 @@ export function IntegrationsConsole({ [target]: { ok: false, error: error instanceof Error ? error.message : "Probe failed.", + details: null, }, })); } finally { @@ -376,17 +406,86 @@ export function IntegrationsConsole({ token source:{" "} {probeResults.openclaw.tokenSource || - (resolvedDefaults.openclawTokenConfigured ? "env" : "session")} + (openclawToken.trim() + ? "request" + : resolvedDefaults.openclawTokenConfigured + ? "env" + : vaultSecretPath.trim() && + (vaultToken.trim() || resolvedDefaults.vaultTokenConfigured) + ? "vault" + : "none")} {probeResults.openclaw.error ? ( -
- {probeResults.openclaw.error} +
+

{probeResults.openclaw.error}

+ {probeResults.openclaw.code ? ( +

code: {probeResults.openclaw.code}

+ ) : null} + {probeResults.openclaw.deviceId ? ( +

+ deviceId: {probeResults.openclaw.deviceId} +

+ ) : null} + {stringValue(probeResults.openclaw.details?.requestId) ? ( +

+ requestId: {stringValue(probeResults.openclaw.details?.requestId)} +

+ ) : null} + {stringValue(probeResults.openclaw.details?.reason) ? ( +

+ reason: {stringValue(probeResults.openclaw.details?.reason)} +

+ ) : null}
) : null} + +
+
+ +
+
+

+ Vault-backed token lookup +

+

+ 统一从 Vault 读取 OpenClaw / APISIX 等 token。当前会话值仍然优先。 +

+
+
+ +
+ + setVaultSecretPath(event.target.value)} + className={inputClassName()} + placeholder="kv/openclaw" + /> + + + + setVaultSecretKey(event.target.value)} + className={inputClassName()} + placeholder="token" + /> + +
+
+
diff --git a/src/server/consoleIntegrations.ts b/src/server/consoleIntegrations.ts index b36b253..0519cec 100644 --- a/src/server/consoleIntegrations.ts +++ b/src/server/consoleIntegrations.ts @@ -87,40 +87,182 @@ export function getConsoleIntegrationDefaults(): IntegrationDefaults { vaultUrl: normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS)), vaultNamespace: readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '', vaultTokenConfigured: Boolean(readEnvValue(...VAULT_TOKEN_KEYS)), + vaultSecretPath: '', + vaultSecretKey: '', apisixUrl: normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS)), apisixTokenConfigured: Boolean(readEnvValue(...APISIX_TOKEN_KEYS)), } } -export function resolveOpenClawGatewayConfig(overrides?: { +function normalizeSecretPath(value?: string): string { + return value?.trim().replace(/^\/+|\/+$/g, '') ?? '' +} + +function buildVaultReadUrl(baseUrl: string, secretPath: string): string { + const normalizedBase = normalizeHttpUrl(baseUrl) + const normalizedPath = normalizeSecretPath(secretPath) + + if (!normalizedBase || !normalizedPath) { + return '' + } + + if (normalizedPath.startsWith('v1/')) { + return `${normalizedBase}/${normalizedPath}` + } + + const segments = normalizedPath.split('/').filter(Boolean) + if (segments.length === 1) { + return `${normalizedBase}/v1/kv/data/${segments[0]}` + } + + const [mount, ...rest] = segments + if (rest[0] === 'data') { + return `${normalizedBase}/v1/${segments.join('/')}` + } + + return `${normalizedBase}/v1/${mount}/data/${rest.join('/')}` +} + +async function readVaultSecret(params: { + vaultUrl: string + vaultToken: string + vaultNamespace?: string + secretPath: string +}): Promise> { + const url = buildVaultReadUrl(params.vaultUrl, params.secretPath) + if (!url || !params.vaultToken.trim()) { + return {} + } + + const response = await fetch(url, { + method: 'GET', + headers: { + 'X-Vault-Token': params.vaultToken.trim(), + ...(params.vaultNamespace?.trim() + ? { + 'X-Vault-Namespace': params.vaultNamespace.trim(), + } + : {}), + }, + cache: 'no-store', + }) + + if (!response.ok) { + return {} + } + + const payload = (await response.json()) as Record + const data = payload.data as Record | undefined + const kvV2Data = data?.data + + if (kvV2Data && typeof kvV2Data === 'object' && !Array.isArray(kvV2Data)) { + return kvV2Data as Record + } + + return data && typeof data === 'object' && !Array.isArray(data) ? data : {} +} + +async function resolveVaultBackedToken(params: { + requestToken?: string + envToken?: string + vaultUrl?: string + vaultToken?: string + vaultNamespace?: string + vaultSecretPath?: string + vaultSecretKey?: string + fallbackKeys: string[] +}): Promise<{ token: string; tokenSource: 'env' | 'request' | 'vault' | 'none' }> { + const requestToken = params.requestToken?.trim() ?? '' + if (requestToken) { + return { token: requestToken, tokenSource: 'request' } + } + + const envToken = params.envToken?.trim() ?? '' + if (envToken) { + return { token: envToken, tokenSource: 'env' } + } + + const secretPath = normalizeSecretPath(params.vaultSecretPath) + if (!secretPath) { + return { token: '', tokenSource: 'none' } + } + + const secret = await readVaultSecret({ + vaultUrl: params.vaultUrl ?? '', + vaultToken: params.vaultToken ?? '', + vaultNamespace: params.vaultNamespace, + secretPath, + }) + + const candidateKeys = [ + params.vaultSecretKey?.trim(), + ...params.fallbackKeys, + 'token', + ].filter((value): value is string => Boolean(value && value.trim().length > 0)) + + for (const key of candidateKeys) { + const value = secret[key] + if (typeof value === 'string' && value.trim().length > 0) { + return { token: value.trim(), tokenSource: 'vault' } + } + } + + return { token: '', tokenSource: 'none' } +} + +export async function resolveOpenClawGatewayConfig(overrides?: { gatewayUrl?: string gatewayToken?: string -}): { gatewayUrl: string; gatewayToken: string; tokenSource: 'env' | 'request' | 'none' } { + vaultUrl?: string + vaultToken?: string + vaultNamespace?: string + vaultSecretPath?: string + vaultSecretKey?: string +}): Promise<{ gatewayUrl: string; gatewayToken: string; tokenSource: 'env' | 'request' | 'vault' | 'none' }> { const requestUrl = normalizeWsUrl(overrides?.gatewayUrl) const envUrl = normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS)) - const requestToken = overrides?.gatewayToken?.trim() ?? '' - const envToken = readEnvValue(...OPENCLAW_TOKEN_KEYS) ?? '' + const resolvedToken = await resolveVaultBackedToken({ + requestToken: overrides?.gatewayToken, + envToken: readEnvValue(...OPENCLAW_TOKEN_KEYS) ?? '', + vaultUrl: overrides?.vaultUrl ?? readEnvValue(...VAULT_URL_KEYS) ?? '', + vaultToken: overrides?.vaultToken ?? readEnvValue(...VAULT_TOKEN_KEYS) ?? '', + vaultNamespace: overrides?.vaultNamespace ?? readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '', + vaultSecretPath: overrides?.vaultSecretPath, + vaultSecretKey: overrides?.vaultSecretKey, + fallbackKeys: ['OPENCLAW_GATEWAY_TOKEN'], + }) return { gatewayUrl: requestUrl || envUrl, - gatewayToken: requestToken || envToken, - tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none', + gatewayToken: resolvedToken.token, + tokenSource: resolvedToken.tokenSource, } } -export function resolveApisixProbeConfig(overrides?: { +export async function resolveApisixProbeConfig(overrides?: { apisixUrl?: string apisixToken?: string -}): { apisixUrl: string; apisixToken: string; tokenSource: 'env' | 'request' | 'none' } { + vaultUrl?: string + vaultToken?: string + vaultNamespace?: string + vaultSecretPath?: string +}): Promise<{ apisixUrl: string; apisixToken: string; tokenSource: 'env' | 'request' | 'vault' | 'none' }> { const requestUrl = normalizeHttpUrl(overrides?.apisixUrl) const envUrl = normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS)) - const requestToken = overrides?.apisixToken?.trim() ?? '' - const envToken = readEnvValue(...APISIX_TOKEN_KEYS) ?? '' + const resolvedToken = await resolveVaultBackedToken({ + requestToken: overrides?.apisixToken, + envToken: readEnvValue(...APISIX_TOKEN_KEYS) ?? '', + vaultUrl: overrides?.vaultUrl ?? readEnvValue(...VAULT_URL_KEYS) ?? '', + vaultToken: overrides?.vaultToken ?? readEnvValue(...VAULT_TOKEN_KEYS) ?? '', + vaultNamespace: overrides?.vaultNamespace ?? readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '', + vaultSecretPath: overrides?.vaultSecretPath, + fallbackKeys: ['AI_GATEWAY_ACCESS_TOKEN', 'APISIX_AI_GATEWAY_TOKEN'], + }) return { apisixUrl: requestUrl || envUrl, - apisixToken: requestToken || envToken, - tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none', + apisixToken: resolvedToken.token, + tokenSource: resolvedToken.tokenSource, } } diff --git a/src/server/openclaw/device-store.ts b/src/server/openclaw/device-store.ts index 28d73a3..2dbc9ec 100644 --- a/src/server/openclaw/device-store.ts +++ b/src/server/openclaw/device-store.ts @@ -14,6 +14,8 @@ type StoredDeviceIdentity = { const OPENCLAW_STATE_DIR = path.join(process.cwd(), '.console-state', 'openclaw') const DEVICE_IDENTITY_FILE = path.join(OPENCLAW_STATE_DIR, 'gateway-device-identity.json') let deviceIdentityPromise: Promise | null = null +let memoryDeviceIdentity: StoredDeviceIdentity | null = null +const memoryDeviceTokens = new Map() function asStoredDeviceIdentity(value: unknown): StoredDeviceIdentity | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -67,12 +69,17 @@ async function writeIdentity(identity: StoredDeviceIdentity): Promise { } export async function loadOrCreateOpenClawDeviceIdentity(): Promise { + if (memoryDeviceIdentity) { + return memoryDeviceIdentity + } + if (!deviceIdentityPromise) { deviceIdentityPromise = (async () => { try { const raw = await readFile(DEVICE_IDENTITY_FILE, 'utf8') const parsed = asStoredDeviceIdentity(JSON.parse(raw)) if (parsed) { + memoryDeviceIdentity = parsed return parsed } } catch { @@ -94,7 +101,15 @@ export async function loadOrCreateOpenClawDeviceIdentity(): Promise { + const memoryToken = memoryDeviceTokens.get(`${params.deviceId}:${params.role?.trim() || 'operator'}`) + if (memoryToken) { + return memoryToken + } + try { const value = await readFile(deviceTokenFile(params.deviceId, params.role), 'utf8') return value.trim() @@ -124,14 +144,23 @@ export async function saveOpenClawDeviceToken(params: { role?: string token: string }): Promise { - await ensureStateDirectory() - await writeFile(deviceTokenFile(params.deviceId, params.role), `${params.token.trim()}\n`, 'utf8') + const key = `${params.deviceId}:${params.role?.trim() || 'operator'}` + memoryDeviceTokens.set(key, params.token.trim()) + + try { + await ensureStateDirectory() + await writeFile(deviceTokenFile(params.deviceId, params.role), `${params.token.trim()}\n`, 'utf8') + } catch { + // Keep the token in memory when the runtime does not permit filesystem writes. + } } export async function clearOpenClawDeviceToken(params: { deviceId: string role?: string }): Promise { + memoryDeviceTokens.delete(`${params.deviceId}:${params.role?.trim() || 'operator'}`) + try { await rm(deviceTokenFile(params.deviceId, params.role), { force: true }) } catch { diff --git a/src/state/openclawConsoleStore.ts b/src/state/openclawConsoleStore.ts index 6244dc5..8b2073f 100644 --- a/src/state/openclawConsoleStore.ts +++ b/src/state/openclawConsoleStore.ts @@ -12,6 +12,8 @@ type OpenClawConsoleState = { vaultUrl: string vaultNamespace: string vaultToken: string + vaultSecretPath: string + vaultSecretKey: string apisixUrl: string apisixToken: string assistantMode: AssistantMode @@ -24,6 +26,8 @@ type OpenClawConsoleState = { setVaultUrl: (value: string) => void setVaultNamespace: (value: string) => void setVaultToken: (value: string) => void + setVaultSecretPath: (value: string) => void + setVaultSecretKey: (value: string) => void setApisixUrl: (value: string) => void setApisixToken: (value: string) => void setAssistantMode: (value: AssistantMode) => void @@ -41,6 +45,8 @@ export const useOpenClawConsoleStore = create()( vaultUrl: '', vaultNamespace: '', vaultToken: '', + vaultSecretPath: '', + vaultSecretKey: '', apisixUrl: '', apisixToken: '', assistantMode: 'ask', @@ -54,6 +60,8 @@ export const useOpenClawConsoleStore = create()( openclawUrl: current.openclawUrl || defaults.openclawUrl, vaultUrl: current.vaultUrl || defaults.vaultUrl, vaultNamespace: current.vaultNamespace || defaults.vaultNamespace, + vaultSecretPath: current.vaultSecretPath || defaults.vaultSecretPath, + vaultSecretKey: current.vaultSecretKey || defaults.vaultSecretKey, apisixUrl: current.apisixUrl || defaults.apisixUrl, }) }, @@ -62,6 +70,8 @@ export const useOpenClawConsoleStore = create()( setVaultUrl: (vaultUrl) => set({ vaultUrl }), setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }), setVaultToken: (vaultToken) => set({ vaultToken }), + setVaultSecretPath: (vaultSecretPath) => set({ vaultSecretPath }), + setVaultSecretKey: (vaultSecretKey) => set({ vaultSecretKey }), setApisixUrl: (apisixUrl) => set({ apisixUrl }), setApisixToken: (apisixToken) => set({ apisixToken }), setAssistantMode: (assistantMode) => set({ assistantMode }), @@ -78,6 +88,8 @@ export const useOpenClawConsoleStore = create()( vaultUrl: state.vaultUrl, vaultNamespace: state.vaultNamespace, vaultToken: state.vaultToken, + vaultSecretPath: state.vaultSecretPath, + vaultSecretKey: state.vaultSecretKey, apisixUrl: state.apisixUrl, apisixToken: state.apisixToken, assistantMode: state.assistantMode,