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
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<Response> {
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<Response> {
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<Response> {
}
async function probeApisix(body: ProbeBody): Promise<Response> {
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) {

View File

@ -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<Response> {
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<Response> {
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<Response> {
}
async function handleSend(body: SendBody): Promise<Response> {
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) {

View File

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

View File

@ -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<Record<string, unknown>>(
{},
);
const [gatewayTokenSource, setGatewayTokenSource] = useState<
"env" | "request" | "none"
>("none");
const [gatewayTokenSource, setGatewayTokenSource] =
useState<GatewayTokenSource>("none");
const [mainSessionKey, setMainSessionKey] = useState("main");
const [messages, setMessages] = useState<GatewayChatMessage[]>([]);
const [sessions, setSessions] = useState<GatewaySessionSummary[]>([]);
@ -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({
<span className="text-[var(--color-text-subtle)]/60">·</span>
{gatewayTokenSource === "env"
? copy.envToken
: gatewayTokenSource === "vault"
? copy.vaultToken
: gatewayTokenSource === "request"
? copy.sessionToken
: copy.noToken}

View File

@ -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<OverviewMetric[]>(
() => [
@ -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(" · ")}
/>
</div>

View File

@ -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
}

View File

@ -24,6 +24,9 @@ type ProbeState = {
tokenSource?: string;
body?: string;
error?: string;
code?: string;
details?: Record<string, unknown> | 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({
<span className="text-xs text-[var(--color-text-subtle)]">
token source:{" "}
{probeResults.openclaw.tokenSource ||
(resolvedDefaults.openclawTokenConfigured ? "env" : "session")}
(openclawToken.trim()
? "request"
: resolvedDefaults.openclawTokenConfigured
? "env"
: vaultSecretPath.trim() &&
(vaultToken.trim() || resolvedDefaults.vaultTokenConfigured)
? "vault"
: "none")}
</span>
</div>
{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)]">
{probeResults.openclaw.error}
<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)]">
<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>
) : null}
</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">
<Card className="space-y-5">
<div className="flex items-center gap-3">

View File

@ -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<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
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,
}
}

View File

@ -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<StoredDeviceIdentity> | null = null
let memoryDeviceIdentity: StoredDeviceIdentity | null = null
const memoryDeviceTokens = new Map<string, string>()
function asStoredDeviceIdentity(value: unknown): StoredDeviceIdentity | null {
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> {
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<StoredDevice
createdAtMs: Date.now(),
}
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
})()
}
@ -111,6 +126,11 @@ export async function loadOpenClawDeviceToken(params: {
deviceId: string
role?: string
}): Promise<string> {
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<void> {
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<void> {
memoryDeviceTokens.delete(`${params.deviceId}:${params.role?.trim() || 'operator'}`)
try {
await rm(deviceTokenFile(params.deviceId, params.role), { force: true })
} catch {

View File

@ -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<OpenClawConsoleState>()(
vaultUrl: '',
vaultNamespace: '',
vaultToken: '',
vaultSecretPath: '',
vaultSecretKey: '',
apisixUrl: '',
apisixToken: '',
assistantMode: 'ask',
@ -54,6 +60,8 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
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<OpenClawConsoleState>()(
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<OpenClawConsoleState>()(
vaultUrl: state.vaultUrl,
vaultNamespace: state.vaultNamespace,
vaultToken: state.vaultToken,
vaultSecretPath: state.vaultSecretPath,
vaultSecretKey: state.vaultSecretKey,
apisixUrl: state.apisixUrl,
apisixToken: state.apisixToken,
assistantMode: state.assistantMode,