add vault-backed token lookup for integrations

This commit is contained in:
Haitao Pan 2026-03-12 17:33:29 +08:00
parent f83e43ec50
commit 87d573c528
10 changed files with 435 additions and 48 deletions

View File

@ -15,7 +15,10 @@ type ProbeBody = {
gatewayUrl?: string gatewayUrl?: string
gatewayToken?: string gatewayToken?: string
vaultUrl?: string vaultUrl?: string
vaultNamespace?: string
vaultToken?: string vaultToken?: string
vaultSecretPath?: string
vaultSecretKey?: string
apisixUrl?: string apisixUrl?: string
apisixToken?: string apisixToken?: string
} }
@ -34,13 +37,17 @@ function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : 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 { function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string {
if (!error) { if (!error) {
return 'Failed to probe OpenClaw gateway.' return 'Failed to probe OpenClaw gateway.'
} }
const details = asRecord(error.details) const details = asRecord(error.details)
const detailCode = stringValue(details.code) const detailCode = gatewayErrorCode(error)
if (detailCode === 'PAIRING_REQUIRED') { if (detailCode === 'PAIRING_REQUIRED') {
const requestId = stringValue(details.requestId) const requestId = stringValue(details.requestId)
const reason = stringValue(details.reason) const reason = stringValue(details.reason)
@ -58,9 +65,14 @@ function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClaw
} }
async function probeOpenClaw(body: ProbeBody): Promise<Response> { async function probeOpenClaw(body: ProbeBody): Promise<Response> {
const config = resolveOpenClawGatewayConfig({ const config = await resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl, gatewayUrl: body.gatewayUrl,
gatewayToken: body.gatewayToken, gatewayToken: body.gatewayToken,
vaultUrl: body.vaultUrl,
vaultToken: body.vaultToken,
vaultNamespace: body.vaultNamespace,
vaultSecretPath: body.vaultSecretPath,
vaultSecretKey: body.vaultSecretKey,
}) })
if (!config.gatewayUrl) { if (!config.gatewayUrl) {
@ -95,7 +107,7 @@ async function probeOpenClaw(body: ProbeBody): Promise<Response> {
gatewayUrl: config.gatewayUrl, gatewayUrl: config.gatewayUrl,
tokenSource: config.tokenSource, tokenSource: config.tokenSource,
error: formatGatewayError(gatewayError, client), error: formatGatewayError(gatewayError, client),
code: gatewayError?.code, code: gatewayErrorCode(gatewayError),
details: gatewayError?.details ?? null, details: gatewayError?.details ?? null,
deviceId: client.deviceId || undefined, deviceId: client.deviceId || undefined,
}, },
@ -152,9 +164,13 @@ async function probeVault(body: ProbeBody): Promise<Response> {
} }
async function probeApisix(body: ProbeBody): Promise<Response> { async function probeApisix(body: ProbeBody): Promise<Response> {
const config = resolveApisixProbeConfig({ const config = await resolveApisixProbeConfig({
apisixUrl: body.apisixUrl, apisixUrl: body.apisixUrl,
apisixToken: body.apisixToken, apisixToken: body.apisixToken,
vaultUrl: body.vaultUrl,
vaultToken: body.vaultToken,
vaultNamespace: body.vaultNamespace,
vaultSecretPath: body.vaultSecretPath,
}) })
if (!config.apisixUrl) { if (!config.apisixUrl) {

View File

@ -19,6 +19,11 @@ type BootstrapBody = {
action: 'bootstrap' action: 'bootstrap'
gatewayUrl?: string gatewayUrl?: string
gatewayToken?: string gatewayToken?: string
vaultUrl?: string
vaultNamespace?: string
vaultToken?: string
vaultSecretPath?: string
vaultSecretKey?: string
agentId?: string agentId?: string
sessionKey?: string sessionKey?: string
} }
@ -27,6 +32,11 @@ type SendBody = {
action: 'send' action: 'send'
gatewayUrl?: string gatewayUrl?: string
gatewayToken?: string gatewayToken?: string
vaultUrl?: string
vaultNamespace?: string
vaultToken?: string
vaultSecretPath?: string
vaultSecretKey?: string
agentId?: string agentId?: string
sessionKey?: string sessionKey?: string
message?: string message?: string
@ -62,13 +72,17 @@ function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : 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 { function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string {
if (!error) { if (!error) {
return 'Failed to connect to OpenClaw gateway.' return 'Failed to connect to OpenClaw gateway.'
} }
const details = asRecord(error.details) const details = asRecord(error.details)
const detailCode = stringValue(details.code) const detailCode = gatewayErrorCode(error)
if (detailCode === 'PAIRING_REQUIRED') { if (detailCode === 'PAIRING_REQUIRED') {
const requestId = stringValue(details.requestId) const requestId = stringValue(details.requestId)
const reason = stringValue(details.reason) const reason = stringValue(details.reason)
@ -99,9 +113,14 @@ function resolveSessionKey(params: {
} }
async function handleBootstrap(body: BootstrapBody): Promise<Response> { async function handleBootstrap(body: BootstrapBody): Promise<Response> {
const gateway = resolveOpenClawGatewayConfig({ const gateway = await resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl, gatewayUrl: body.gatewayUrl,
gatewayToken: body.gatewayToken, gatewayToken: body.gatewayToken,
vaultUrl: body.vaultUrl,
vaultToken: body.vaultToken,
vaultNamespace: body.vaultNamespace,
vaultSecretPath: body.vaultSecretPath,
vaultSecretKey: body.vaultSecretKey,
}) })
if (!gateway.gatewayUrl) { if (!gateway.gatewayUrl) {
@ -149,8 +168,8 @@ async function handleBootstrap(body: BootstrapBody): Promise<Response> {
const gatewayError = error instanceof OpenClawGatewayError ? error : null const gatewayError = error instanceof OpenClawGatewayError ? error : null
return jsonError( return jsonError(
formatGatewayError(gatewayError, client), formatGatewayError(gatewayError, client),
gatewayError?.code === 'OFFLINE' ? 503 : 502, gatewayErrorCode(gatewayError) === 'OFFLINE' ? 503 : 502,
gatewayError?.code, gatewayErrorCode(gatewayError),
gatewayError?.details ?? null, gatewayError?.details ?? null,
client.deviceId || undefined, client.deviceId || undefined,
) )
@ -160,9 +179,14 @@ async function handleBootstrap(body: BootstrapBody): Promise<Response> {
} }
async function handleSend(body: SendBody): Promise<Response> { async function handleSend(body: SendBody): Promise<Response> {
const gateway = resolveOpenClawGatewayConfig({ const gateway = await resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl, gatewayUrl: body.gatewayUrl,
gatewayToken: body.gatewayToken, gatewayToken: body.gatewayToken,
vaultUrl: body.vaultUrl,
vaultToken: body.vaultToken,
vaultNamespace: body.vaultNamespace,
vaultSecretPath: body.vaultSecretPath,
vaultSecretKey: body.vaultSecretKey,
}) })
if (!gateway.gatewayUrl) { if (!gateway.gatewayUrl) {

View File

@ -34,6 +34,8 @@ export function AskAIDialog({
vaultUrl: "", vaultUrl: "",
vaultNamespace: "", vaultNamespace: "",
vaultTokenConfigured: false, vaultTokenConfigured: false,
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "", apisixUrl: "",
apisixTokenConfigured: false, apisixTokenConfigured: false,
}; };

View File

@ -37,6 +37,7 @@ import {
type GatewayChatAttachmentPayload, type GatewayChatAttachmentPayload,
type GatewayChatMessage, type GatewayChatMessage,
type GatewaySessionSummary, type GatewaySessionSummary,
type GatewayTokenSource,
type IntegrationDefaults, type IntegrationDefaults,
type OpenClawBootstrapResponse, type OpenClawBootstrapResponse,
type OpenClawStreamEvent, type OpenClawStreamEvent,
@ -211,9 +212,8 @@ export function OpenClawAssistantPane({
const [gatewayHealth, setGatewayHealth] = useState<Record<string, unknown>>( const [gatewayHealth, setGatewayHealth] = useState<Record<string, unknown>>(
{}, {},
); );
const [gatewayTokenSource, setGatewayTokenSource] = useState< const [gatewayTokenSource, setGatewayTokenSource] =
"env" | "request" | "none" useState<GatewayTokenSource>("none");
>("none");
const [mainSessionKey, setMainSessionKey] = useState("main"); const [mainSessionKey, setMainSessionKey] = useState("main");
const [messages, setMessages] = useState<GatewayChatMessage[]>([]); const [messages, setMessages] = useState<GatewayChatMessage[]>([]);
const [sessions, setSessions] = useState<GatewaySessionSummary[]>([]); const [sessions, setSessions] = useState<GatewaySessionSummary[]>([]);
@ -231,6 +231,17 @@ export function OpenClawAssistantPane({
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults); const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl); const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl);
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken); 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 assistantMode = useOpenClawConsoleStore((state) => state.assistantMode);
const thinking = useOpenClawConsoleStore((state) => state.thinking); const thinking = useOpenClawConsoleStore((state) => state.thinking);
const selectedAgentId = useOpenClawConsoleStore( const selectedAgentId = useOpenClawConsoleStore(
@ -313,6 +324,7 @@ export function OpenClawAssistantPane({
), ),
envToken: pickCopy(isChinese, "环境变量令牌", "env token"), envToken: pickCopy(isChinese, "环境变量令牌", "env token"),
sessionToken: pickCopy(isChinese, "会话令牌", "session token"), sessionToken: pickCopy(isChinese, "会话令牌", "session token"),
vaultToken: pickCopy(isChinese, "Vault 令牌", "vault token"),
noToken: pickCopy(isChinese, "无令牌", "no token"), noToken: pickCopy(isChinese, "无令牌", "no token"),
mainAgent: pickCopy(isChinese, "主助手", "Main agent"), mainAgent: pickCopy(isChinese, "主助手", "Main agent"),
reconnect: pickCopy(isChinese, "重新连接", "Reconnect"), reconnect: pickCopy(isChinese, "重新连接", "Reconnect"),
@ -425,6 +437,11 @@ export function OpenClawAssistantPane({
action: "bootstrap", action: "bootstrap",
gatewayUrl: openclawUrl, gatewayUrl: openclawUrl,
gatewayToken: openclawToken, gatewayToken: openclawToken,
vaultUrl,
vaultNamespace,
vaultToken,
vaultSecretPath,
vaultSecretKey,
agentId: nextAgentId ?? selectedAgentId, agentId: nextAgentId ?? selectedAgentId,
sessionKey: nextSessionKey ?? selectedSessionKey, sessionKey: nextSessionKey ?? selectedSessionKey,
}), }),
@ -463,6 +480,11 @@ export function OpenClawAssistantPane({
copy.serverMissing, copy.serverMissing,
openclawToken, openclawToken,
openclawUrl, openclawUrl,
vaultNamespace,
vaultSecretKey,
vaultSecretPath,
vaultToken,
vaultUrl,
selectedAgentId, selectedAgentId,
selectedSessionKey, selectedSessionKey,
setSelectedSessionKey, setSelectedSessionKey,
@ -564,6 +586,11 @@ export function OpenClawAssistantPane({
action: "send", action: "send",
gatewayUrl: openclawUrl, gatewayUrl: openclawUrl,
gatewayToken: openclawToken, gatewayToken: openclawToken,
vaultUrl,
vaultNamespace,
vaultToken,
vaultSecretPath,
vaultSecretKey,
agentId: effectiveAgentId, agentId: effectiveAgentId,
sessionKey: effectiveSessionKey, sessionKey: effectiveSessionKey,
message: prompt, message: prompt,
@ -642,6 +669,11 @@ export function OpenClawAssistantPane({
mainSessionKey, mainSessionKey,
openclawToken, openclawToken,
openclawUrl, openclawUrl,
vaultNamespace,
vaultSecretKey,
vaultSecretPath,
vaultToken,
vaultUrl,
selectedAgentId, selectedAgentId,
selectedSessionKey, selectedSessionKey,
setSelectedAgentId, setSelectedAgentId,
@ -731,6 +763,8 @@ export function OpenClawAssistantPane({
<span className="text-[var(--color-text-subtle)]/60">·</span> <span className="text-[var(--color-text-subtle)]/60">·</span>
{gatewayTokenSource === "env" {gatewayTokenSource === "env"
? copy.envToken ? copy.envToken
: gatewayTokenSource === "vault"
? copy.vaultToken
: gatewayTokenSource === "request" : gatewayTokenSource === "request"
? copy.sessionToken ? copy.sessionToken
: copy.noToken} : copy.noToken}

View File

@ -429,6 +429,9 @@ export function XWorkmateWorkspacePage({
(state) => state.vaultNamespace, (state) => state.vaultNamespace,
); );
const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken); const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken);
const vaultSecretPath = useOpenClawConsoleStore(
(state) => state.vaultSecretPath,
);
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl); const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl);
const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken); const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken);
const assistantMode = useOpenClawConsoleStore((state) => state.assistantMode); const assistantMode = useOpenClawConsoleStore((state) => state.assistantMode);
@ -493,6 +496,33 @@ export function XWorkmateWorkspacePage({
const configuredIntegrationCount = integrationRows.filter( const configuredIntegrationCount = integrationRows.filter(
(item) => item.ok, (item) => item.ok,
).length; ).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<OverviewMetric[]>( const tasksOverview = useMemo<OverviewMetric[]>(
() => [ () => [
@ -520,10 +550,7 @@ export function XWorkmateWorkspacePage({
}, },
{ {
label: pickCopy(isChinese, "失败", "Failed"), label: pickCopy(isChinese, "失败", "Failed"),
value: value: hasOpenClawCredential ? "0" : "pairing",
openclawToken.trim() || defaults.openclawTokenConfigured
? "0"
: "pairing",
caption: pickCopy( caption: pickCopy(
isChinese, isChinese,
"未配置 shared token 时优先走 pairing", "未配置 shared token 时优先走 pairing",
@ -543,10 +570,9 @@ export function XWorkmateWorkspacePage({
}, },
], ],
[ [
defaults.openclawTokenConfigured, hasOpenClawCredential,
isChinese, isChinese,
openclawEndpoint, openclawEndpoint,
openclawToken,
selectedSessionKey, selectedSessionKey,
], ],
); );
@ -618,7 +644,7 @@ export function XWorkmateWorkspacePage({
label: pickCopy(isChinese, "Token 引用", "Token Refs"), label: pickCopy(isChinese, "Token 引用", "Token Refs"),
value: `${ value: `${
[ [
openclawToken.trim() || defaults.openclawTokenConfigured, hasOpenClawCredential,
vaultToken.trim() || defaults.vaultTokenConfigured, vaultToken.trim() || defaults.vaultTokenConfigured,
apisixToken.trim() || defaults.apisixTokenConfigured, apisixToken.trim() || defaults.apisixTokenConfigured,
].filter(Boolean).length ].filter(Boolean).length
@ -655,10 +681,9 @@ export function XWorkmateWorkspacePage({
apisixToken, apisixToken,
configuredIntegrationCount, configuredIntegrationCount,
defaults.apisixTokenConfigured, defaults.apisixTokenConfigured,
defaults.openclawTokenConfigured, hasOpenClawCredential,
defaults.vaultTokenConfigured, defaults.vaultTokenConfigured,
isChinese, isChinese,
openclawToken,
vaultEndpoint, vaultEndpoint,
vaultToken, vaultToken,
], ],
@ -1438,8 +1463,10 @@ export function XWorkmateWorkspacePage({
) )
: pickCopy( : pickCopy(
isChinese, isChinese,
"优先 env / pairing", hasVaultBackedToken ? "优先 Vault / env" : "优先 env / pairing",
"env / pairing preferred", hasVaultBackedToken
? "vault / env preferred"
: "env / pairing preferred",
) )
} }
ok={Boolean((openclawUrl || defaults.openclawUrl).trim())} ok={Boolean((openclawUrl || defaults.openclawUrl).trim())}
@ -1642,13 +1669,13 @@ export function XWorkmateWorkspacePage({
title={pickCopy(isChinese, "当前配置源", "Current config sources")} title={pickCopy(isChinese, "当前配置源", "Current config sources")}
description={pickCopy( description={pickCopy(
isChinese, isChinese,
"区分 env 与 session。", "区分 session / env / vault / pairing。",
"Separate env from session overrides.", "Separate session, env, vault, and pairing sources.",
)} )}
body={[ body={[
`OpenClaw: ${openclawToken.trim() ? "session" : defaults.openclawTokenConfigured ? "env" : "pairing only"}`, `OpenClaw: ${openclawConfigSource}`,
`Vault: ${vaultToken.trim() ? "session" : defaults.vaultTokenConfigured ? "env" : "manual"}`, `Vault: ${vaultConfigSource}`,
`APISIX: ${apisixToken.trim() ? "session" : defaults.apisixTokenConfigured ? "env" : "manual"}`, `APISIX: ${apisixConfigSource}`,
].join(" · ")} ].join(" · ")}
/> />
</div> </div>

View File

@ -2,7 +2,7 @@ export type AssistantMode = 'ask' | 'craft' | 'plan'
export type ThinkingLevel = 'low' | 'medium' | 'high' | 'max' export type ThinkingLevel = 'low' | 'medium' | 'high' | 'max'
export type GatewayTokenSource = 'env' | 'request' | 'none' export type GatewayTokenSource = 'env' | 'request' | 'vault' | 'none'
export type GatewayAgentSummary = { export type GatewayAgentSummary = {
id: string id: string
@ -83,6 +83,8 @@ export type IntegrationDefaults = {
vaultUrl: string vaultUrl: string
vaultNamespace: string vaultNamespace: string
vaultTokenConfigured: boolean vaultTokenConfigured: boolean
vaultSecretPath: string
vaultSecretKey: string
apisixUrl: string apisixUrl: string
apisixTokenConfigured: boolean apisixTokenConfigured: boolean
} }

View File

@ -24,6 +24,9 @@ type ProbeState = {
tokenSource?: string; tokenSource?: string;
body?: string; body?: string;
error?: string; error?: string;
code?: string;
details?: Record<string, unknown> | null;
deviceId?: string;
}; };
type IntegrationsConsoleProps = { type IntegrationsConsoleProps = {
@ -80,12 +83,18 @@ function inputClassName(type: "input" | "textarea" = "input"): string {
.join(" "); .join(" ");
} }
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
const EMPTY_DEFAULTS: IntegrationDefaults = { const EMPTY_DEFAULTS: IntegrationDefaults = {
openclawUrl: "", openclawUrl: "",
openclawTokenConfigured: false, openclawTokenConfigured: false,
vaultUrl: "", vaultUrl: "",
vaultNamespace: "", vaultNamespace: "",
vaultTokenConfigured: false, vaultTokenConfigured: false,
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "", apisixUrl: "",
apisixTokenConfigured: false, apisixTokenConfigured: false,
}; };
@ -115,6 +124,12 @@ export function IntegrationsConsole({
(state) => state.vaultNamespace, (state) => state.vaultNamespace,
); );
const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken); 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 apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl);
const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken); const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken);
const setOpenclawUrl = useOpenClawConsoleStore( const setOpenclawUrl = useOpenClawConsoleStore(
@ -128,6 +143,12 @@ export function IntegrationsConsole({
(state) => state.setVaultNamespace, (state) => state.setVaultNamespace,
); );
const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken); 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 setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl);
const setApisixToken = useOpenClawConsoleStore( const setApisixToken = useOpenClawConsoleStore(
(state) => state.setApisixToken, (state) => state.setApisixToken,
@ -175,6 +196,7 @@ export function IntegrationsConsole({
configured: Boolean(openclawUrl.trim()), configured: Boolean(openclawUrl.trim()),
tokenConfigured: tokenConfigured:
resolvedDefaults.openclawTokenConfigured || resolvedDefaults.openclawTokenConfigured ||
Boolean(vaultSecretPath.trim() && (vaultToken.trim() || resolvedDefaults.vaultTokenConfigured)) ||
Boolean(openclawToken.trim()), Boolean(openclawToken.trim()),
}, },
{ {
@ -201,6 +223,7 @@ export function IntegrationsConsole({
resolvedDefaults.openclawTokenConfigured, resolvedDefaults.openclawTokenConfigured,
resolvedDefaults.vaultTokenConfigured, resolvedDefaults.vaultTokenConfigured,
vaultToken, vaultToken,
vaultSecretPath,
vaultUrl, vaultUrl,
], ],
); );
@ -219,7 +242,10 @@ export function IntegrationsConsole({
gatewayUrl: openclawUrl, gatewayUrl: openclawUrl,
gatewayToken: openclawToken, gatewayToken: openclawToken,
vaultUrl, vaultUrl,
vaultNamespace,
vaultToken, vaultToken,
vaultSecretPath,
vaultSecretKey,
apisixUrl, apisixUrl,
apisixToken, apisixToken,
}), }),
@ -230,10 +256,13 @@ export function IntegrationsConsole({
...current, ...current,
[target]: { [target]: {
ok: Boolean(payload.ok), ok: Boolean(payload.ok),
status: payload.status, status: payload.status ?? response.status,
tokenSource: payload.tokenSource, tokenSource: payload.tokenSource,
body: payload.body, body: payload.body,
error: payload.error, error: payload.error,
code: payload.code,
details: payload.details ?? null,
deviceId: payload.deviceId,
}, },
})); }));
} catch (error) { } catch (error) {
@ -242,6 +271,7 @@ export function IntegrationsConsole({
[target]: { [target]: {
ok: false, ok: false,
error: error instanceof Error ? error.message : "Probe failed.", error: error instanceof Error ? error.message : "Probe failed.",
details: null,
}, },
})); }));
} finally { } finally {
@ -376,17 +406,86 @@ export function IntegrationsConsole({
<span className="text-xs text-[var(--color-text-subtle)]"> <span className="text-xs text-[var(--color-text-subtle)]">
token source:{" "} token source:{" "}
{probeResults.openclaw.tokenSource || {probeResults.openclaw.tokenSource ||
(resolvedDefaults.openclawTokenConfigured ? "env" : "session")} (openclawToken.trim()
? "request"
: resolvedDefaults.openclawTokenConfigured
? "env"
: vaultSecretPath.trim() &&
(vaultToken.trim() || resolvedDefaults.vaultTokenConfigured)
? "vault"
: "none")}
</span> </span>
</div> </div>
{probeResults.openclaw.error ? ( {probeResults.openclaw.error ? (
<div className="rounded-[var(--radius-xl)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-4 py-3 text-sm text-[var(--color-danger-foreground)]"> <div className="space-y-2 rounded-[var(--radius-xl)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-4 py-3 text-sm text-[var(--color-danger-foreground)]">
{probeResults.openclaw.error} <p>{probeResults.openclaw.error}</p>
{probeResults.openclaw.code ? (
<p className="text-xs opacity-80">code: {probeResults.openclaw.code}</p>
) : null}
{probeResults.openclaw.deviceId ? (
<p className="text-xs opacity-80">
deviceId: {probeResults.openclaw.deviceId}
</p>
) : null}
{stringValue(probeResults.openclaw.details?.requestId) ? (
<p className="text-xs opacity-80">
requestId: {stringValue(probeResults.openclaw.details?.requestId)}
</p>
) : null}
{stringValue(probeResults.openclaw.details?.reason) ? (
<p className="text-xs opacity-80">
reason: {stringValue(probeResults.openclaw.details?.reason)}
</p>
) : null}
</div> </div>
) : null} ) : null}
</Card> </Card>
<Card className="space-y-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold text-[var(--color-heading)]">
Vault-backed token lookup
</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
Vault OpenClaw / APISIX token
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<Field
label="Vault Secret Path"
hint="例如 kv/openclaw。留空则不从 Vault secret 读取 token。"
>
<input
type="text"
value={vaultSecretPath}
onChange={(event) => setVaultSecretPath(event.target.value)}
className={inputClassName()}
placeholder="kv/openclaw"
/>
</Field>
<Field
label="Vault Secret Key"
hint="可选。默认会优先尝试服务默认 key再回退到 token。"
>
<input
type="text"
value={vaultSecretKey}
onChange={(event) => setVaultSecretKey(event.target.value)}
className={inputClassName()}
placeholder="token"
/>
</Field>
</div>
</Card>
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<Card className="space-y-5"> <Card className="space-y-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -87,40 +87,182 @@ export function getConsoleIntegrationDefaults(): IntegrationDefaults {
vaultUrl: normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS)), vaultUrl: normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS)),
vaultNamespace: readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '', vaultNamespace: readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '',
vaultTokenConfigured: Boolean(readEnvValue(...VAULT_TOKEN_KEYS)), vaultTokenConfigured: Boolean(readEnvValue(...VAULT_TOKEN_KEYS)),
vaultSecretPath: '',
vaultSecretKey: '',
apisixUrl: normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS)), apisixUrl: normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS)),
apisixTokenConfigured: Boolean(readEnvValue(...APISIX_TOKEN_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<Record<string, unknown>> {
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<string, unknown>
const data = payload.data as Record<string, unknown> | undefined
const kvV2Data = data?.data
if (kvV2Data && typeof kvV2Data === 'object' && !Array.isArray(kvV2Data)) {
return kvV2Data as Record<string, unknown>
}
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 gatewayUrl?: string
gatewayToken?: 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 requestUrl = normalizeWsUrl(overrides?.gatewayUrl)
const envUrl = normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS)) const envUrl = normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS))
const requestToken = overrides?.gatewayToken?.trim() ?? '' const resolvedToken = await resolveVaultBackedToken({
const envToken = readEnvValue(...OPENCLAW_TOKEN_KEYS) ?? '' 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 { return {
gatewayUrl: requestUrl || envUrl, gatewayUrl: requestUrl || envUrl,
gatewayToken: requestToken || envToken, gatewayToken: resolvedToken.token,
tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none', tokenSource: resolvedToken.tokenSource,
} }
} }
export function resolveApisixProbeConfig(overrides?: { export async function resolveApisixProbeConfig(overrides?: {
apisixUrl?: string apisixUrl?: string
apisixToken?: 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 requestUrl = normalizeHttpUrl(overrides?.apisixUrl)
const envUrl = normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS)) const envUrl = normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS))
const requestToken = overrides?.apisixToken?.trim() ?? '' const resolvedToken = await resolveVaultBackedToken({
const envToken = readEnvValue(...APISIX_TOKEN_KEYS) ?? '' 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 { return {
apisixUrl: requestUrl || envUrl, apisixUrl: requestUrl || envUrl,
apisixToken: requestToken || envToken, apisixToken: resolvedToken.token,
tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none', tokenSource: resolvedToken.tokenSource,
} }
} }

View File

@ -14,6 +14,8 @@ type StoredDeviceIdentity = {
const OPENCLAW_STATE_DIR = path.join(process.cwd(), '.console-state', 'openclaw') const OPENCLAW_STATE_DIR = path.join(process.cwd(), '.console-state', 'openclaw')
const DEVICE_IDENTITY_FILE = path.join(OPENCLAW_STATE_DIR, 'gateway-device-identity.json') const DEVICE_IDENTITY_FILE = path.join(OPENCLAW_STATE_DIR, 'gateway-device-identity.json')
let deviceIdentityPromise: Promise<StoredDeviceIdentity> | null = null let deviceIdentityPromise: Promise<StoredDeviceIdentity> | null = null
let memoryDeviceIdentity: StoredDeviceIdentity | null = null
const memoryDeviceTokens = new Map<string, string>()
function asStoredDeviceIdentity(value: unknown): StoredDeviceIdentity | null { function asStoredDeviceIdentity(value: unknown): StoredDeviceIdentity | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
@ -67,12 +69,17 @@ async function writeIdentity(identity: StoredDeviceIdentity): Promise<void> {
} }
export async function loadOrCreateOpenClawDeviceIdentity(): Promise<StoredDeviceIdentity> { export async function loadOrCreateOpenClawDeviceIdentity(): Promise<StoredDeviceIdentity> {
if (memoryDeviceIdentity) {
return memoryDeviceIdentity
}
if (!deviceIdentityPromise) { if (!deviceIdentityPromise) {
deviceIdentityPromise = (async () => { deviceIdentityPromise = (async () => {
try { try {
const raw = await readFile(DEVICE_IDENTITY_FILE, 'utf8') const raw = await readFile(DEVICE_IDENTITY_FILE, 'utf8')
const parsed = asStoredDeviceIdentity(JSON.parse(raw)) const parsed = asStoredDeviceIdentity(JSON.parse(raw))
if (parsed) { if (parsed) {
memoryDeviceIdentity = parsed
return parsed return parsed
} }
} catch { } catch {
@ -94,7 +101,15 @@ export async function loadOrCreateOpenClawDeviceIdentity(): Promise<StoredDevice
createdAtMs: Date.now(), createdAtMs: Date.now(),
} }
await writeIdentity(identity) memoryDeviceIdentity = identity
try {
await writeIdentity(identity)
} catch {
// Serverless/preview runtimes may not allow persistent filesystem writes.
// Keep an in-memory identity so pairing can still proceed for the current instance.
}
return identity return identity
})() })()
} }
@ -111,6 +126,11 @@ export async function loadOpenClawDeviceToken(params: {
deviceId: string deviceId: string
role?: string role?: string
}): Promise<string> { }): Promise<string> {
const memoryToken = memoryDeviceTokens.get(`${params.deviceId}:${params.role?.trim() || 'operator'}`)
if (memoryToken) {
return memoryToken
}
try { try {
const value = await readFile(deviceTokenFile(params.deviceId, params.role), 'utf8') const value = await readFile(deviceTokenFile(params.deviceId, params.role), 'utf8')
return value.trim() return value.trim()
@ -124,14 +144,23 @@ export async function saveOpenClawDeviceToken(params: {
role?: string role?: string
token: string token: string
}): Promise<void> { }): Promise<void> {
await ensureStateDirectory() const key = `${params.deviceId}:${params.role?.trim() || 'operator'}`
await writeFile(deviceTokenFile(params.deviceId, params.role), `${params.token.trim()}\n`, 'utf8') 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: { export async function clearOpenClawDeviceToken(params: {
deviceId: string deviceId: string
role?: string role?: string
}): Promise<void> { }): Promise<void> {
memoryDeviceTokens.delete(`${params.deviceId}:${params.role?.trim() || 'operator'}`)
try { try {
await rm(deviceTokenFile(params.deviceId, params.role), { force: true }) await rm(deviceTokenFile(params.deviceId, params.role), { force: true })
} catch { } catch {

View File

@ -12,6 +12,8 @@ type OpenClawConsoleState = {
vaultUrl: string vaultUrl: string
vaultNamespace: string vaultNamespace: string
vaultToken: string vaultToken: string
vaultSecretPath: string
vaultSecretKey: string
apisixUrl: string apisixUrl: string
apisixToken: string apisixToken: string
assistantMode: AssistantMode assistantMode: AssistantMode
@ -24,6 +26,8 @@ type OpenClawConsoleState = {
setVaultUrl: (value: string) => void setVaultUrl: (value: string) => void
setVaultNamespace: (value: string) => void setVaultNamespace: (value: string) => void
setVaultToken: (value: string) => void setVaultToken: (value: string) => void
setVaultSecretPath: (value: string) => void
setVaultSecretKey: (value: string) => void
setApisixUrl: (value: string) => void setApisixUrl: (value: string) => void
setApisixToken: (value: string) => void setApisixToken: (value: string) => void
setAssistantMode: (value: AssistantMode) => void setAssistantMode: (value: AssistantMode) => void
@ -41,6 +45,8 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
vaultUrl: '', vaultUrl: '',
vaultNamespace: '', vaultNamespace: '',
vaultToken: '', vaultToken: '',
vaultSecretPath: '',
vaultSecretKey: '',
apisixUrl: '', apisixUrl: '',
apisixToken: '', apisixToken: '',
assistantMode: 'ask', assistantMode: 'ask',
@ -54,6 +60,8 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
openclawUrl: current.openclawUrl || defaults.openclawUrl, openclawUrl: current.openclawUrl || defaults.openclawUrl,
vaultUrl: current.vaultUrl || defaults.vaultUrl, vaultUrl: current.vaultUrl || defaults.vaultUrl,
vaultNamespace: current.vaultNamespace || defaults.vaultNamespace, vaultNamespace: current.vaultNamespace || defaults.vaultNamespace,
vaultSecretPath: current.vaultSecretPath || defaults.vaultSecretPath,
vaultSecretKey: current.vaultSecretKey || defaults.vaultSecretKey,
apisixUrl: current.apisixUrl || defaults.apisixUrl, apisixUrl: current.apisixUrl || defaults.apisixUrl,
}) })
}, },
@ -62,6 +70,8 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
setVaultUrl: (vaultUrl) => set({ vaultUrl }), setVaultUrl: (vaultUrl) => set({ vaultUrl }),
setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }), setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }),
setVaultToken: (vaultToken) => set({ vaultToken }), setVaultToken: (vaultToken) => set({ vaultToken }),
setVaultSecretPath: (vaultSecretPath) => set({ vaultSecretPath }),
setVaultSecretKey: (vaultSecretKey) => set({ vaultSecretKey }),
setApisixUrl: (apisixUrl) => set({ apisixUrl }), setApisixUrl: (apisixUrl) => set({ apisixUrl }),
setApisixToken: (apisixToken) => set({ apisixToken }), setApisixToken: (apisixToken) => set({ apisixToken }),
setAssistantMode: (assistantMode) => set({ assistantMode }), setAssistantMode: (assistantMode) => set({ assistantMode }),
@ -78,6 +88,8 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
vaultUrl: state.vaultUrl, vaultUrl: state.vaultUrl,
vaultNamespace: state.vaultNamespace, vaultNamespace: state.vaultNamespace,
vaultToken: state.vaultToken, vaultToken: state.vaultToken,
vaultSecretPath: state.vaultSecretPath,
vaultSecretKey: state.vaultSecretKey,
apisixUrl: state.apisixUrl, apisixUrl: state.apisixUrl,
apisixToken: state.apisixToken, apisixToken: state.apisixToken,
assistantMode: state.assistantMode, assistantMode: state.assistantMode,