Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions electron/gateway/connect-frame.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
142 changes: 66 additions & 76 deletions electron/gateway/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -46,6 +44,7 @@ import {
*/
export interface GatewayStatus {
state: GatewayLifecycleState;
host: string;
port: number;
pid?: number;
uptime?: number;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1241,7 +1272,7 @@ export class GatewayManager extends EventEmitter {

try {
const ready = await new Promise<boolean>((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);
Expand Down Expand Up @@ -1282,11 +1313,11 @@ export class GatewayManager extends EventEmitter {
* Connect WebSocket to Gateway
*/
private async connect(port: number, _externalToken?: string): Promise<void> {
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;
Expand Down Expand Up @@ -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));

Expand Down
3 changes: 2 additions & 1 deletion electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down
4 changes: 4 additions & 0 deletions electron/utils/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export interface AppSettings {

// Gateway
gatewayAutoStart: boolean;
gatewayHost: string;
gatewayPort: number;
gatewayToken: string;
gatewayRemoteToken: string;
proxyEnabled: boolean;
proxyServer: string;
proxyHttpServer: string;
Expand Down Expand Up @@ -65,8 +67,10 @@ const defaults: AppSettings = {

// Gateway
gatewayAutoStart: true,
gatewayHost: 'localhost',
gatewayPort: 18789,
gatewayToken: generateToken(),
gatewayRemoteToken: '',
proxyEnabled: false,
proxyServer: '',
proxyHttpServer: '',
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading