diff --git a/electron/gateway/connect-frame.ts b/electron/gateway/connect-frame.ts new file mode 100644 index 00000000..2e4cdf08 --- /dev/null +++ b/electron/gateway/connect-frame.ts @@ -0,0 +1,112 @@ +/** + * Build the connect handshake frame for the OpenClaw Gateway protocol. + * + * Extracted from GatewayManager.connect() so it can be unit-tested + * independently of WebSocket / Electron plumbing. + */ +import { + buildDeviceAuthPayload, + signDevicePayload, + publicKeyRawBase64UrlFromPem, + type DeviceIdentity, +} from '../utils/device-identity'; + +export interface ConnectFrameOptions { + /** Challenge nonce issued by the server */ + challengeNonce: string; + /** Auth token to send (local or remote) */ + token: string; + /** Unique request id */ + connectId: string; + /** Device identity (may be null if not loaded) */ + deviceIdentity: DeviceIdentity | null; + /** Whether the target gateway is on a remote host */ + isRemote: boolean; +} + +export interface ConnectFrame { + type: 'req'; + id: string; + method: 'connect'; + params: { + minProtocol: number; + maxProtocol: number; + client: { + id: string; + displayName: string; + version: string; + platform: string; + mode: string; + }; + auth: { token: string }; + caps: string[]; + role: string; + scopes: string[]; + device?: { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce: string; + }; + }; +} + +export function buildConnectFrame(opts: ConnectFrameOptions): ConnectFrame { + const role = 'operator'; + const scopes = ['operator.admin']; + const signedAtMs = Date.now(); + const clientId = 'gateway-client'; + const clientMode = 'ui'; + + // For remote gateways, omit the device identity entirely. + // The remote gateway hasn't paired with this device and would + // reject the handshake with "pairing required". + const device = (() => { + if (opts.isRemote) return undefined; + if (!opts.deviceIdentity) return undefined; + + const payload = buildDeviceAuthPayload({ + deviceId: opts.deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: opts.token ?? null, + nonce: opts.challengeNonce, + }); + const signature = signDevicePayload(opts.deviceIdentity.privateKeyPem, payload); + return { + id: opts.deviceIdentity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(opts.deviceIdentity.publicKeyPem), + signature, + signedAt: signedAtMs, + nonce: opts.challengeNonce, + }; + })(); + + return { + type: 'req', + id: opts.connectId, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: clientId, + displayName: 'ClawX', + version: '0.1.0', + platform: process.platform, + mode: clientMode, + }, + auth: { + token: opts.token, + }, + caps: [], + role, + scopes, + device, + }, + }; +} diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 8cc9ea51..432babd5 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -23,11 +23,9 @@ import { getUvMirrorEnv } from '../utils/uv-env'; import { isPythonReady, setupManagedPython } from '../utils/uv-setup'; import { loadOrCreateDeviceIdentity, - signDevicePayload, - publicKeyRawBase64UrlFromPem, - buildDeviceAuthPayload, type DeviceIdentity, } from '../utils/device-identity'; +import { buildConnectFrame } from './connect-frame'; import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth'; import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; @@ -46,6 +44,7 @@ import { */ export interface GatewayStatus { state: GatewayLifecycleState; + host: string; port: number; pid?: number; uptime?: number; @@ -203,7 +202,7 @@ export class GatewayManager extends EventEmitter { private processExitCode: number | null = null; // set by exit event, replaces exitCode/signalCode private ownsProcess = false; private ws: WebSocket | null = null; - private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY }; + private status: GatewayStatus = { state: 'stopped', host: 'localhost', port: PORTS.OPENCLAW_GATEWAY }; private reconnectTimer: NodeJS.Timeout | null = null; private pingInterval: NodeJS.Timeout | null = null; private healthCheckInterval: NodeJS.Timeout | null = null; @@ -347,6 +346,14 @@ export class GatewayManager extends EventEmitter { }); } + /** + * Check if the configured host is remote (not localhost) + */ + private isRemoteHost(): boolean { + const h = this.status.host; + return h !== 'localhost' && h !== '127.0.0.1' && h !== '::1'; + } + /** * Get current Gateway status */ @@ -377,7 +384,16 @@ export class GatewayManager extends EventEmitter { this.startLock = true; const startEpoch = this.bumpLifecycleEpoch('start'); - logger.info(`Gateway start requested (port=${this.status.port})`); + + // Read host from persisted settings before each start attempt. + try { + const configuredHost = await getSetting('gatewayHost'); + if (configuredHost) { + this.status.host = configuredHost; + } + } catch { /* keep current host */ } + + logger.info(`Gateway start requested (host=${this.status.host}, port=${this.status.port})`); this.lastSpawnSummary = null; this.shouldReconnect = true; @@ -396,18 +412,21 @@ export class GatewayManager extends EventEmitter { this.setStatus({ state: 'starting', reconnectAttempts: 0 }); let configRepairAttempted = false; - // Check if Python environment is ready (self-healing) asynchronously. - // Fire-and-forget: only needs to run once, not on every retry. - void isPythonReady().then(pythonReady => { - if (!pythonReady) { - logger.info('Python environment missing or incomplete, attempting background repair...'); - void setupManagedPython().catch(err => { - logger.error('Background Python repair failed:', err); - }); - } - }).catch(err => { - logger.error('Failed to check Python environment:', err); - }); + // Only check/repair local Python environment for local gateways. + if (!this.isRemoteHost()) { + // Check if Python environment is ready (self-healing) asynchronously. + // Fire-and-forget: only needs to run once, not on every retry. + void isPythonReady().then(pythonReady => { + if (!pythonReady) { + logger.info('Python environment missing or incomplete, attempting background repair...'); + void setupManagedPython().catch(err => { + logger.error('Background Python repair failed:', err); + }); + } + }).catch(err => { + logger.error('Failed to check Python environment:', err); + }); + } try { let startAttempts = 0; @@ -418,7 +437,19 @@ export class GatewayManager extends EventEmitter { this.assertLifecycleEpoch(startEpoch, 'start'); this.recentStartupStderrLines = []; try { - // Check if Gateway is already running + // Remote host: skip local process management, just connect. + if (this.isRemoteHost()) { + logger.info(`Connecting to remote Gateway at ${this.status.host}:${this.status.port}`); + await this.connect(this.status.port); + this.assertLifecycleEpoch(startEpoch, 'start/connect-remote'); + this.ownsProcess = false; + this.setStatus({ pid: undefined }); + this.startHealthCheck(); + logger.debug('Remote Gateway connected successfully'); + return; + } + + // Local host: check if Gateway is already running logger.debug('Checking for existing Gateway...'); const existing = await this.findExistingGateway(); this.assertLifecycleEpoch(startEpoch, 'start/find-existing'); @@ -454,7 +485,7 @@ export class GatewayManager extends EventEmitter { if (error instanceof LifecycleSupersededError) { throw error; } - if (shouldAttemptConfigAutoRepair(error, this.recentStartupStderrLines, configRepairAttempted)) { + if (!this.isRemoteHost() && shouldAttemptConfigAutoRepair(error, this.recentStartupStderrLines, configRepairAttempted)) { configRepairAttempted = true; logger.warn( 'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry' @@ -888,7 +919,7 @@ export class GatewayManager extends EventEmitter { // Try a quick WebSocket connection to check if gateway is listening return await new Promise<{ port: number, externalToken?: string } | null>((resolve) => { - const testWs = new WebSocket(`ws://localhost:${port}/ws`); + const testWs = new WebSocket(`ws://${this.status.host}:${port}/ws`); const timeout = setTimeout(() => { testWs.close(); resolve(null); @@ -1241,7 +1272,7 @@ export class GatewayManager extends EventEmitter { try { const ready = await new Promise((resolve) => { - const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`); + const testWs = new WebSocket(`ws://${this.status.host}:${this.status.port}/ws`); const timeout = setTimeout(() => { testWs.close(); resolve(false); @@ -1282,11 +1313,11 @@ export class GatewayManager extends EventEmitter { * Connect WebSocket to Gateway */ private async connect(port: number, _externalToken?: string): Promise { - logger.debug(`Connecting Gateway WebSocket (ws://localhost:${port}/ws)`); + logger.debug(`Connecting Gateway WebSocket (ws://${this.status.host}:${port}/ws)`); return new Promise((resolve, reject) => { // WebSocket URL (token will be sent in connect handshake, not URL) - const wsUrl = `ws://localhost:${port}/ws`; + const wsUrl = `ws://${this.status.host}:${port}/ws`; this.ws = new WebSocket(wsUrl); let handshakeComplete = false; @@ -1333,61 +1364,20 @@ export class GatewayManager extends EventEmitter { const sendConnectHandshake = async (challengeNonce: string) => { logger.debug('Sending connect handshake with challenge nonce'); - const currentToken = await getSetting('gatewayToken'); + // Use remote token when connecting to a non-local gateway. + const currentToken = this.isRemoteHost() + ? (await getSetting('gatewayRemoteToken') || await getSetting('gatewayToken')) + : await getSetting('gatewayToken'); connectId = `connect-${Date.now()}`; - const role = 'operator'; - const scopes = ['operator.admin']; - const signedAtMs = Date.now(); - const clientId = 'gateway-client'; - const clientMode = 'ui'; - - const device = (() => { - if (!this.deviceIdentity) return undefined; - - const payload = buildDeviceAuthPayload({ - deviceId: this.deviceIdentity.deviceId, - clientId, - clientMode, - role, - scopes, - signedAtMs, - token: currentToken ?? null, - nonce: challengeNonce, - }); - const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload); - return { - id: this.deviceIdentity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem), - signature, - signedAt: signedAtMs, - nonce: challengeNonce, - }; - })(); - - const connectFrame = { - type: 'req', - id: connectId, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: clientId, - displayName: 'ClawX', - version: '0.1.0', - platform: process.platform, - mode: clientMode, - }, - auth: { - token: currentToken, - }, - caps: [], - role, - scopes, - device, - }, - }; + + const connectFrame = buildConnectFrame({ + challengeNonce, + token: currentToken, + connectId, + deviceIdentity: this.deviceIdentity, + isRemote: this.isRemoteHost(), + }); this.ws?.send(JSON.stringify(connectFrame)); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 8722fa16..48ef2244 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -628,9 +628,10 @@ function registerGatewayHandlers( try { const status = gatewayManager.getStatus(); const token = await getSetting('gatewayToken'); + const host = status.host || '127.0.0.1'; const port = status.port || 18789; // Pass token as query param - Control UI will store it in localStorage - const url = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`; + const url = `http://${host}:${port}/?token=${encodeURIComponent(token)}`; return { success: true, url, port, token }; } catch (error) { return { success: false, error: String(error) }; diff --git a/electron/utils/store.ts b/electron/utils/store.ts index a28fb9af..46e57d3d 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -28,8 +28,10 @@ export interface AppSettings { // Gateway gatewayAutoStart: boolean; + gatewayHost: string; gatewayPort: number; gatewayToken: string; + gatewayRemoteToken: string; proxyEnabled: boolean; proxyServer: string; proxyHttpServer: string; @@ -65,8 +67,10 @@ const defaults: AppSettings = { // Gateway gatewayAutoStart: true, + gatewayHost: 'localhost', gatewayPort: 18789, gatewayToken: generateToken(), + gatewayRemoteToken: '', proxyEnabled: false, proxyServer: '', proxyHttpServer: '', diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index db452924..6f817004 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -97,6 +97,15 @@ "title": "Gateway", "description": "OpenClaw Gateway settings", "status": "Status", + "host": "Host", + "hostDesc": "Gateway hostname or Tailscale IP (for remote OpenClaw). Use localhost for local Gateway.", + "hostSaved": "Gateway host saved", + "hostSaveFailed": "Failed to save gateway host", + "hostRestartNote": "Changing host reconnects the Gateway client.", + "remoteToken": "Remote Gateway Token", + "remoteTokenDesc": "Auth token from the remote OpenClaw instance (~/.openclaw/openclaw.json → gateway.auth.token).", + "remoteTokenSaved": "Remote token saved", + "remoteTokenSaveFailed": "Failed to save remote token", "port": "Port", "logs": "Logs", "appLogs": "Application Logs", diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index dfc10d87..a6b56008 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -45,6 +45,10 @@ export function Settings() { setLanguage, gatewayAutoStart, setGatewayAutoStart, + gatewayHost, + setGatewayHost, + gatewayRemoteToken, + setGatewayRemoteToken, proxyEnabled, proxyServer, proxyHttpServer, @@ -71,6 +75,10 @@ export function Settings() { const [controlUiInfo, setControlUiInfo] = useState(null); const [openclawCliCommand, setOpenclawCliCommand] = useState(''); const [openclawCliError, setOpenclawCliError] = useState(null); + const [gatewayHostDraft, setGatewayHostDraft] = useState('localhost'); + const [savingHost, setSavingHost] = useState(false); + const [remoteTokenDraft, setRemoteTokenDraft] = useState(''); + const [savingRemoteToken, setSavingRemoteToken] = useState(false); const [proxyServerDraft, setProxyServerDraft] = useState(''); const [proxyHttpServerDraft, setProxyHttpServerDraft] = useState(''); const [proxyHttpsServerDraft, setProxyHttpsServerDraft] = useState(''); @@ -203,6 +211,14 @@ export function Settings() { return () => { unsubscribe?.(); }; }, []); + useEffect(() => { + setGatewayHostDraft(gatewayHost || 'localhost'); + }, [gatewayHost]); + + useEffect(() => { + setRemoteTokenDraft(gatewayRemoteToken || ''); + }, [gatewayRemoteToken]); + useEffect(() => { setProxyEnabledDraft(proxyEnabled); }, [proxyEnabled]); @@ -227,6 +243,38 @@ export function Settings() { setProxyBypassRulesDraft(proxyBypassRules); }, [proxyBypassRules]); + const handleSaveGatewayHost = async () => { + setSavingHost(true); + try { + const normalized = gatewayHostDraft.trim() || 'localhost'; + await window.electron.ipcRenderer.invoke('settings:set', 'gatewayHost', normalized); + setGatewayHost(normalized); + toast.success(t('gateway.hostSaved')); + // Restart gateway so it reconnects to the new host + await restartGateway(); + } catch (error) { + toast.error(`${t('gateway.hostSaveFailed')}: ${String(error)}`); + } finally { + setSavingHost(false); + } + }; + + const handleSaveRemoteToken = async () => { + setSavingRemoteToken(true); + try { + const normalized = remoteTokenDraft.trim(); + await window.electron.ipcRenderer.invoke('settings:set', 'gatewayRemoteToken', normalized); + setGatewayRemoteToken(normalized); + toast.success(t('gateway.remoteTokenSaved')); + // Restart gateway so it reconnects with the new token + await restartGateway(); + } catch (error) { + toast.error(`${t('gateway.remoteTokenSaveFailed')}: ${String(error)}`); + } finally { + setSavingRemoteToken(false); + } + }; + const handleSaveProxySettings = async () => { setSavingProxy(true); try { @@ -395,6 +443,63 @@ export function Settings() { +
+ +
+ setGatewayHostDraft(event.target.value)} + placeholder="localhost" + /> + +
+

+ {t('gateway.hostDesc')} +

+ {gatewayHostDraft !== 'localhost' && gatewayHostDraft !== '127.0.0.1' && gatewayHostDraft !== '::1' && ( +

+ {t('gateway.hostRestartNote')} +

+ )} +
+ + {gatewayHostDraft !== 'localhost' && gatewayHostDraft !== '127.0.0.1' && gatewayHostDraft !== '::1' && ( +
+ +
+ setRemoteTokenDraft(event.target.value)} + placeholder="clawx-... or openclaw-..." + className="font-mono" + /> + +
+

+ {t('gateway.remoteTokenDesc')} +

+
+ )} + + +
diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 1ee8c03f..679322fd 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -33,6 +33,7 @@ interface GatewayState { export const useGatewayStore = create((set, get) => ({ status: { state: 'stopped', + host: 'localhost', port: 18789, }, health: null, diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 2aefc081..90a28a9c 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -18,7 +18,9 @@ interface SettingsState { // Gateway gatewayAutoStart: boolean; + gatewayHost: string; gatewayPort: number; + gatewayRemoteToken: string; proxyEnabled: boolean; proxyServer: string; proxyHttpServer: string; @@ -45,7 +47,9 @@ interface SettingsState { setStartMinimized: (value: boolean) => void; setLaunchAtStartup: (value: boolean) => void; setGatewayAutoStart: (value: boolean) => void; + setGatewayHost: (host: string) => void; setGatewayPort: (port: number) => void; + setGatewayRemoteToken: (token: string) => void; setProxyEnabled: (value: boolean) => void; setProxyServer: (value: string) => void; setProxyHttpServer: (value: string) => void; @@ -72,7 +76,9 @@ const defaultSettings = { startMinimized: false, launchAtStartup: false, gatewayAutoStart: true, + gatewayHost: 'localhost', gatewayPort: 18789, + gatewayRemoteToken: '', proxyEnabled: false, proxyServer: '', proxyHttpServer: '', @@ -110,7 +116,9 @@ export const useSettingsStore = create()( setStartMinimized: (startMinimized) => set({ startMinimized }), setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }), setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, + setGatewayHost: (gatewayHost) => { set({ gatewayHost }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayHost', gatewayHost).catch(() => {}); }, setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); }, + setGatewayRemoteToken: (gatewayRemoteToken) => { set({ gatewayRemoteToken }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayRemoteToken', gatewayRemoteToken).catch(() => {}); }, setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }), setProxyServer: (proxyServer) => set({ proxyServer }), setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }), diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 3decd24a..dbf21219 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -8,6 +8,7 @@ */ export interface GatewayStatus { state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; + host: string; port: number; pid?: number; uptime?: number; diff --git a/tests/unit/remote-gateway.test.ts b/tests/unit/remote-gateway.test.ts new file mode 100644 index 00000000..d419b582 --- /dev/null +++ b/tests/unit/remote-gateway.test.ts @@ -0,0 +1,98 @@ +/** + * Remote Gateway Connection Tests + * + * Verifies that the connect handshake frame is built correctly for + * remote gateways (e.g. Tailscale connections): + * - Device identity must be OMITTED to avoid "pairing required" errors + * - Remote token must be used instead of local token + * - Frame structure must be valid for token-only auth + */ +import { describe, it, expect } from 'vitest'; +import { buildConnectFrame } from '@electron/gateway/connect-frame'; +import { + loadOrCreateDeviceIdentity, +} from '@electron/utils/device-identity'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { mkdtempSync } from 'fs'; + +// Create a real device identity for testing (uses crypto key generation) +async function createTestDeviceIdentity() { + const tmpDir = mkdtempSync(join(tmpdir(), 'clawx-test-')); + const identityPath = join(tmpDir, 'test-device-identity.json'); + return await loadOrCreateDeviceIdentity(identityPath); +} + +describe('Remote Gateway connect frame', () => { + it('omits device identity when isRemote is true', async () => { + const deviceIdentity = await createTestDeviceIdentity(); + + const frame = buildConnectFrame({ + challengeNonce: 'test-nonce-123', + token: 'remote-token-abc', + connectId: 'connect-1', + deviceIdentity, + isRemote: true, + }); + + // Device must be undefined for remote connections + expect(frame.params.device).toBeUndefined(); + // Token must be the remote token + expect(frame.params.auth.token).toBe('remote-token-abc'); + // Basic frame structure + expect(frame.type).toBe('req'); + expect(frame.method).toBe('connect'); + expect(frame.params.role).toBe('operator'); + expect(frame.params.scopes).toContain('operator.admin'); + }); + + it('includes device identity when isRemote is false', async () => { + const deviceIdentity = await createTestDeviceIdentity(); + + const frame = buildConnectFrame({ + challengeNonce: 'test-nonce-456', + token: 'local-token-xyz', + connectId: 'connect-2', + deviceIdentity, + isRemote: false, + }); + + // Device must be present for local connections + expect(frame.params.device).toBeDefined(); + expect(frame.params.device!.id).toBe(deviceIdentity.deviceId); + expect(frame.params.device!.nonce).toBe('test-nonce-456'); + expect(frame.params.device!.publicKey).toBeTruthy(); + expect(frame.params.device!.signature).toBeTruthy(); + // Token must be the local token + expect(frame.params.auth.token).toBe('local-token-xyz'); + }); + + it('omits device identity when deviceIdentity is null (local)', () => { + const frame = buildConnectFrame({ + challengeNonce: 'test-nonce-789', + token: 'some-token', + connectId: 'connect-3', + deviceIdentity: null, + isRemote: false, + }); + + expect(frame.params.device).toBeUndefined(); + expect(frame.params.auth.token).toBe('some-token'); + }); + + it('uses correct protocol version and client metadata', () => { + const frame = buildConnectFrame({ + challengeNonce: 'nonce', + token: 'token', + connectId: 'id', + deviceIdentity: null, + isRemote: true, + }); + + expect(frame.params.minProtocol).toBe(3); + expect(frame.params.maxProtocol).toBe(3); + expect(frame.params.client.displayName).toBe('ClawX'); + expect(frame.params.client.mode).toBe('ui'); + expect(frame.params.caps).toEqual([]); + }); +});