Add configurable OpenClaw origin override

This commit is contained in:
Haitao Pan 2026-03-12 18:54:30 +08:00
parent 87d573c528
commit 33862d353b
10 changed files with 151 additions and 39 deletions

View File

@ -13,6 +13,7 @@ export const dynamic = 'force-dynamic'
type ProbeBody = { type ProbeBody = {
target?: 'openclaw' | 'vault' | 'apisix' target?: 'openclaw' | 'vault' | 'apisix'
gatewayUrl?: string gatewayUrl?: string
gatewayOrigin?: string
gatewayToken?: string gatewayToken?: string
vaultUrl?: string vaultUrl?: string
vaultNamespace?: string vaultNamespace?: string
@ -64,7 +65,21 @@ function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClaw
return error.message return error.message
} }
async function probeOpenClaw(body: ProbeBody): Promise<Response> { function resolveGatewayOrigin(override: string | undefined, request: NextRequest): string {
const explicit = override?.trim()
if (explicit) {
return explicit
}
const headerOrigin = request.headers.get('origin')?.trim()
if (headerOrigin) {
return headerOrigin
}
return request.nextUrl.origin
}
async function probeOpenClaw(body: ProbeBody, request: NextRequest): Promise<Response> {
const config = await resolveOpenClawGatewayConfig({ const config = await resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl, gatewayUrl: body.gatewayUrl,
gatewayToken: body.gatewayToken, gatewayToken: body.gatewayToken,
@ -84,6 +99,7 @@ async function probeOpenClaw(body: ProbeBody): Promise<Response> {
try { try {
await client.connect({ await client.connect({
gatewayUrl: config.gatewayUrl, gatewayUrl: config.gatewayUrl,
gatewayOrigin: resolveGatewayOrigin(body.gatewayOrigin, request),
gatewayToken: config.gatewayToken, gatewayToken: config.gatewayToken,
clientLabel: 'console.svc.plus Probe', clientLabel: 'console.svc.plus Probe',
}) })
@ -223,7 +239,7 @@ export async function POST(request: NextRequest): Promise<Response> {
switch (body.target) { switch (body.target) {
case 'openclaw': case 'openclaw':
return probeOpenClaw(body) return probeOpenClaw(body, request)
case 'vault': case 'vault':
return probeVault(body) return probeVault(body)
case 'apisix': case 'apisix':

View File

@ -18,6 +18,7 @@ export const dynamic = 'force-dynamic'
type BootstrapBody = { type BootstrapBody = {
action: 'bootstrap' action: 'bootstrap'
gatewayUrl?: string gatewayUrl?: string
gatewayOrigin?: string
gatewayToken?: string gatewayToken?: string
vaultUrl?: string vaultUrl?: string
vaultNamespace?: string vaultNamespace?: string
@ -31,6 +32,7 @@ type BootstrapBody = {
type SendBody = { type SendBody = {
action: 'send' action: 'send'
gatewayUrl?: string gatewayUrl?: string
gatewayOrigin?: string
gatewayToken?: string gatewayToken?: string
vaultUrl?: string vaultUrl?: string
vaultNamespace?: string vaultNamespace?: string
@ -112,7 +114,21 @@ function resolveSessionKey(params: {
return makeAgentSessionKey(params.agentId?.trim() ?? '', params.mainSessionKey) return makeAgentSessionKey(params.agentId?.trim() ?? '', params.mainSessionKey)
} }
async function handleBootstrap(body: BootstrapBody): Promise<Response> { function resolveGatewayOrigin(override: string | undefined, request: NextRequest): string {
const explicit = override?.trim()
if (explicit) {
return explicit
}
const headerOrigin = request.headers.get('origin')?.trim()
if (headerOrigin) {
return headerOrigin
}
return request.nextUrl.origin
}
async function handleBootstrap(body: BootstrapBody, request: NextRequest): Promise<Response> {
const gateway = await resolveOpenClawGatewayConfig({ const gateway = await resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl, gatewayUrl: body.gatewayUrl,
gatewayToken: body.gatewayToken, gatewayToken: body.gatewayToken,
@ -132,6 +148,7 @@ async function handleBootstrap(body: BootstrapBody): Promise<Response> {
try { try {
const connected = await client.connect({ const connected = await client.connect({
gatewayUrl: gateway.gatewayUrl, gatewayUrl: gateway.gatewayUrl,
gatewayOrigin: resolveGatewayOrigin(body.gatewayOrigin, request),
gatewayToken: gateway.gatewayToken, gatewayToken: gateway.gatewayToken,
}) })
@ -178,7 +195,7 @@ async function handleBootstrap(body: BootstrapBody): Promise<Response> {
} }
} }
async function handleSend(body: SendBody): Promise<Response> { async function handleSend(body: SendBody, request: NextRequest): Promise<Response> {
const gateway = await resolveOpenClawGatewayConfig({ const gateway = await resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl, gatewayUrl: body.gatewayUrl,
gatewayToken: body.gatewayToken, gatewayToken: body.gatewayToken,
@ -258,6 +275,7 @@ async function handleSend(body: SendBody): Promise<Response> {
client = new OpenClawGatewayClient() client = new OpenClawGatewayClient()
const connected = await client.connect({ const connected = await client.connect({
gatewayUrl: gateway.gatewayUrl, gatewayUrl: gateway.gatewayUrl,
gatewayOrigin: resolveGatewayOrigin(body.gatewayOrigin, request),
gatewayToken: gateway.gatewayToken, gatewayToken: gateway.gatewayToken,
}) })
@ -396,11 +414,11 @@ export async function POST(request: NextRequest): Promise<Response> {
} }
if (body.action === 'bootstrap') { if (body.action === 'bootstrap') {
return handleBootstrap(body) return handleBootstrap(body, request)
} }
if (body.action === 'send') { if (body.action === 'send') {
return handleSend(body) return handleSend(body, request)
} }
return jsonError('Unsupported assistant action.', 400, 'UNSUPPORTED_ACTION') return jsonError('Unsupported assistant action.', 400, 'UNSUPPORTED_ACTION')

View File

@ -30,6 +30,7 @@ export function AskAIDialog({
const router = useRouter(); const router = useRouter();
const resolvedDefaults: IntegrationDefaults = defaults ?? { const resolvedDefaults: IntegrationDefaults = defaults ?? {
openclawUrl: "", openclawUrl: "",
openclawOrigin: "",
openclawTokenConfigured: false, openclawTokenConfigured: false,
vaultUrl: "", vaultUrl: "",
vaultNamespace: "", vaultNamespace: "",

View File

@ -230,6 +230,7 @@ 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 openclawOrigin = useOpenClawConsoleStore((state) => state.openclawOrigin);
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken); const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken);
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl); const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
const vaultNamespace = useOpenClawConsoleStore( const vaultNamespace = useOpenClawConsoleStore(
@ -436,6 +437,7 @@ export function OpenClawAssistantPane({
body: JSON.stringify({ body: JSON.stringify({
action: "bootstrap", action: "bootstrap",
gatewayUrl: openclawUrl, gatewayUrl: openclawUrl,
gatewayOrigin: openclawOrigin,
gatewayToken: openclawToken, gatewayToken: openclawToken,
vaultUrl, vaultUrl,
vaultNamespace, vaultNamespace,
@ -479,6 +481,7 @@ export function OpenClawAssistantPane({
copy.connectFailed, copy.connectFailed,
copy.serverMissing, copy.serverMissing,
openclawToken, openclawToken,
openclawOrigin,
openclawUrl, openclawUrl,
vaultNamespace, vaultNamespace,
vaultSecretKey, vaultSecretKey,
@ -585,6 +588,7 @@ export function OpenClawAssistantPane({
body: JSON.stringify({ body: JSON.stringify({
action: "send", action: "send",
gatewayUrl: openclawUrl, gatewayUrl: openclawUrl,
gatewayOrigin: openclawOrigin,
gatewayToken: openclawToken, gatewayToken: openclawToken,
vaultUrl, vaultUrl,
vaultNamespace, vaultNamespace,
@ -668,6 +672,7 @@ export function OpenClawAssistantPane({
isChinese, isChinese,
mainSessionKey, mainSessionKey,
openclawToken, openclawToken,
openclawOrigin,
openclawUrl, openclawUrl,
vaultNamespace, vaultNamespace,
vaultSecretKey, vaultSecretKey,

View File

@ -79,6 +79,7 @@ export type OpenClawStreamEvent =
export type IntegrationDefaults = { export type IntegrationDefaults = {
openclawUrl: string openclawUrl: string
openclawOrigin: string
openclawTokenConfigured: boolean openclawTokenConfigured: boolean
vaultUrl: string vaultUrl: string
vaultNamespace: string vaultNamespace: string

View File

@ -89,6 +89,7 @@ function stringValue(value: unknown): string | undefined {
const EMPTY_DEFAULTS: IntegrationDefaults = { const EMPTY_DEFAULTS: IntegrationDefaults = {
openclawUrl: "", openclawUrl: "",
openclawOrigin: "",
openclawTokenConfigured: false, openclawTokenConfigured: false,
vaultUrl: "", vaultUrl: "",
vaultNamespace: "", vaultNamespace: "",
@ -118,6 +119,7 @@ export function IntegrationsConsole({
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 openclawOrigin = useOpenClawConsoleStore((state) => state.openclawOrigin);
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken); const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken);
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl); const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
const vaultNamespace = useOpenClawConsoleStore( const vaultNamespace = useOpenClawConsoleStore(
@ -135,6 +137,9 @@ export function IntegrationsConsole({
const setOpenclawUrl = useOpenClawConsoleStore( const setOpenclawUrl = useOpenClawConsoleStore(
(state) => state.setOpenclawUrl, (state) => state.setOpenclawUrl,
); );
const setOpenclawOrigin = useOpenClawConsoleStore(
(state) => state.setOpenclawOrigin,
);
const setOpenclawToken = useOpenClawConsoleStore( const setOpenclawToken = useOpenClawConsoleStore(
(state) => state.setOpenclawToken, (state) => state.setOpenclawToken,
); );
@ -240,6 +245,7 @@ export function IntegrationsConsole({
body: JSON.stringify({ body: JSON.stringify({
target, target,
gatewayUrl: openclawUrl, gatewayUrl: openclawUrl,
gatewayOrigin: openclawOrigin,
gatewayToken: openclawToken, gatewayToken: openclawToken,
vaultUrl, vaultUrl,
vaultNamespace, vaultNamespace,
@ -382,6 +388,19 @@ export function IntegrationsConsole({
</Field> </Field>
</div> </div>
<Field
label="Origin Override"
hint="可选。留空时自动使用当前页面 origin例如 https://preview.svc.plus。"
>
<input
type="text"
value={openclawOrigin}
onChange={(event) => setOpenclawOrigin(event.target.value)}
className={inputClassName()}
placeholder="https://preview.svc.plus"
/>
</Field>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<button <button
type="button" type="button"

View File

@ -83,6 +83,7 @@ function normalizeWsUrl(value?: string): string {
export function getConsoleIntegrationDefaults(): IntegrationDefaults { export function getConsoleIntegrationDefaults(): IntegrationDefaults {
return { return {
openclawUrl: normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS)), openclawUrl: normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS)),
openclawOrigin: '',
openclawTokenConfigured: Boolean(readEnvValue(...OPENCLAW_TOKEN_KEYS)), openclawTokenConfigured: Boolean(readEnvValue(...OPENCLAW_TOKEN_KEYS)),
vaultUrl: normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS)), vaultUrl: normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS)),
vaultNamespace: readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '', vaultNamespace: readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '',

View File

@ -1,6 +1,7 @@
import 'server-only' import 'server-only'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'
import { import {
extractMessageText, extractMessageText,
@ -50,6 +51,8 @@ type GatewayEventFrame = {
payload?: unknown payload?: unknown
} }
type GatewaySocket = WebSocket
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
@ -135,13 +138,13 @@ export class OpenClawGatewayError extends Error {
} }
export class OpenClawGatewayClient { export class OpenClawGatewayClient {
private socket: WebSocket | null = null private socket: GatewaySocket | null = null
private currentDeviceId = '' private currentDeviceId = ''
private connectChallengeNonce: string | null = null private connectChallengeNonce: string | null = null
private pending = new Map<string, PendingRequest>() private pending = new Map<string, PendingRequest>()
private listeners = new Set<(event: GatewayEventFrame) => void>() private listeners = new Set<(event: GatewayEventFrame) => void>()
private handleMessageRef = (event: MessageEvent) => { private handleMessageRef = (data: unknown) => {
void this.handleMessage(event) void this.handleMessage(data)
} }
private handleCloseRef = () => { private handleCloseRef = () => {
this.failPending(new OpenClawGatewayError('Gateway connection closed', 'SOCKET_CLOSED')) this.failPending(new OpenClawGatewayError('Gateway connection closed', 'SOCKET_CLOSED'))
@ -152,42 +155,44 @@ export class OpenClawGatewayClient {
async connect(params: { async connect(params: {
gatewayUrl: string gatewayUrl: string
gatewayOrigin?: string
gatewayToken: string gatewayToken: string
clientId?: string clientId?: string
clientMode?: string clientMode?: string
clientLabel?: string clientLabel?: string
}): Promise<{ mainSessionKey: string; deviceId: string }> { }): Promise<{ mainSessionKey: string; deviceId: string }> {
const url = resolveGatewayUrl(params.gatewayUrl) const url = resolveGatewayUrl(params.gatewayUrl)
const socket = new WebSocket(url) const origin = params.gatewayOrigin?.trim()
const socket = new WebSocket(url, {
...(origin
? {
headers: {
Origin: origin,
},
}
: {}),
})
this.socket = socket this.socket = socket
this.connectChallengeNonce = null this.connectChallengeNonce = null
socket.addEventListener('message', this.handleMessageRef) socket.on('message', this.handleMessageRef)
socket.addEventListener('close', this.handleCloseRef) socket.on('close', this.handleCloseRef)
socket.addEventListener('error', this.handleErrorRef) socket.on('error', this.handleErrorRef)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new OpenClawGatewayError('Gateway open timeout', 'OPEN_TIMEOUT')) reject(new OpenClawGatewayError('Gateway open timeout', 'OPEN_TIMEOUT'))
}, 8000) }, 8000)
socket.addEventListener( socket.once('open', () => {
'open', clearTimeout(timeout)
() => { resolve()
clearTimeout(timeout) })
resolve()
},
{ once: true },
)
socket.addEventListener( socket.once('error', () => {
'error', clearTimeout(timeout)
() => { reject(new OpenClawGatewayError('Gateway open failed', 'OPEN_FAILED'))
clearTimeout(timeout) })
reject(new OpenClawGatewayError('Gateway open failed', 'OPEN_FAILED'))
},
{ once: true },
)
}) })
const clientId = params.clientId ?? OPENCLAW_CLIENT_IDS.assistant const clientId = params.clientId ?? OPENCLAW_CLIENT_IDS.assistant
@ -297,7 +302,7 @@ export class OpenClawGatewayClient {
return this.currentDeviceId return this.currentDeviceId
} }
private async waitForConnectChallenge(socket: WebSocket): Promise<string> { private async waitForConnectChallenge(socket: GatewaySocket): Promise<string> {
if (this.connectChallengeNonce) { if (this.connectChallengeNonce) {
return this.connectChallengeNonce return this.connectChallengeNonce
} }
@ -341,12 +346,12 @@ export class OpenClawGatewayClient {
const cleanup = () => { const cleanup = () => {
clearTimeout(timeout) clearTimeout(timeout)
stopListening() stopListening()
socket.removeEventListener('close', onClose) socket.off('close', onClose)
socket.removeEventListener('error', onError) socket.off('error', onError)
} }
socket.addEventListener('close', onClose, { once: true }) socket.once('close', onClose)
socket.addEventListener('error', onError, { once: true }) socket.once('error', onError)
}) })
} }
@ -509,17 +514,17 @@ export class OpenClawGatewayClient {
return return
} }
socket.removeEventListener('message', this.handleMessageRef) socket.off('message', this.handleMessageRef)
socket.removeEventListener('close', this.handleCloseRef) socket.off('close', this.handleCloseRef)
socket.removeEventListener('error', this.handleErrorRef) socket.off('error', this.handleErrorRef)
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close() socket.close()
} }
} }
private async handleMessage(event: MessageEvent): Promise<void> { private async handleMessage(data: unknown): Promise<void> {
const text = toText(event.data) const text = toText(data)
let payload: Record<string, unknown> let payload: Record<string, unknown>
try { try {

View File

@ -8,6 +8,7 @@ import type { AssistantMode, IntegrationDefaults, ThinkingLevel } from '@/lib/op
type OpenClawConsoleState = { type OpenClawConsoleState = {
defaultsLoaded: boolean defaultsLoaded: boolean
openclawUrl: string openclawUrl: string
openclawOrigin: string
openclawToken: string openclawToken: string
vaultUrl: string vaultUrl: string
vaultNamespace: string vaultNamespace: string
@ -22,6 +23,7 @@ type OpenClawConsoleState = {
selectedSessionKey: string selectedSessionKey: string
applyDefaults: (defaults: IntegrationDefaults) => void applyDefaults: (defaults: IntegrationDefaults) => void
setOpenclawUrl: (value: string) => void setOpenclawUrl: (value: string) => void
setOpenclawOrigin: (value: string) => void
setOpenclawToken: (value: string) => void setOpenclawToken: (value: string) => void
setVaultUrl: (value: string) => void setVaultUrl: (value: string) => void
setVaultNamespace: (value: string) => void setVaultNamespace: (value: string) => void
@ -41,6 +43,7 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
(set, get) => ({ (set, get) => ({
defaultsLoaded: false, defaultsLoaded: false,
openclawUrl: '', openclawUrl: '',
openclawOrigin: '',
openclawToken: '', openclawToken: '',
vaultUrl: '', vaultUrl: '',
vaultNamespace: '', vaultNamespace: '',
@ -58,6 +61,7 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
set({ set({
defaultsLoaded: true, defaultsLoaded: true,
openclawUrl: current.openclawUrl || defaults.openclawUrl, openclawUrl: current.openclawUrl || defaults.openclawUrl,
openclawOrigin: current.openclawOrigin || defaults.openclawOrigin,
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, vaultSecretPath: current.vaultSecretPath || defaults.vaultSecretPath,
@ -66,6 +70,7 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
}) })
}, },
setOpenclawUrl: (openclawUrl) => set({ openclawUrl }), setOpenclawUrl: (openclawUrl) => set({ openclawUrl }),
setOpenclawOrigin: (openclawOrigin) => set({ openclawOrigin }),
setOpenclawToken: (openclawToken) => set({ openclawToken }), setOpenclawToken: (openclawToken) => set({ openclawToken }),
setVaultUrl: (vaultUrl) => set({ vaultUrl }), setVaultUrl: (vaultUrl) => set({ vaultUrl }),
setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }), setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }),
@ -84,6 +89,7 @@ export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
storage: createJSONStorage(() => sessionStorage), storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({ partialize: (state) => ({
openclawUrl: state.openclawUrl, openclawUrl: state.openclawUrl,
openclawOrigin: state.openclawOrigin,
openclawToken: state.openclawToken, openclawToken: state.openclawToken,
vaultUrl: state.vaultUrl, vaultUrl: state.vaultUrl,
vaultNamespace: state.vaultNamespace, vaultNamespace: state.vaultNamespace,

40
types/ws.d.ts vendored Normal file
View File

@ -0,0 +1,40 @@
declare module "ws" {
export type MessageData =
| string
| ArrayBuffer
| Buffer
| Buffer[]
| ArrayBufferView
export type ClientOptions = {
headers?: Record<string, string>
}
export default class WebSocket {
static readonly CONNECTING: number
static readonly OPEN: number
static readonly CLOSING: number
static readonly CLOSED: number
readonly readyState: number
constructor(address: string | URL, options?: ClientOptions)
on(event: "open", listener: () => void): this
on(event: "close", listener: () => void): this
on(event: "error", listener: (error: Error) => void): this
on(event: "message", listener: (data: MessageData) => void): this
once(event: "open", listener: () => void): this
once(event: "close", listener: () => void): this
once(event: "error", listener: (error: Error) => void): this
off(event: "open", listener: () => void): this
off(event: "close", listener: () => void): this
off(event: "error", listener: (error: Error) => void): this
off(event: "message", listener: (data: MessageData) => void): this
send(data: string): void
close(): void
}
}