diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts
index 72c6ed0d2..b3ec46872 100644
--- a/electron/gateway/manager.ts
+++ b/electron/gateway/manager.ts
@@ -826,15 +826,12 @@ export class GatewayManager extends EventEmitter {
reject(err);
};
- this.ws.on('open', async () => {
- logger.debug('Gateway WebSocket opened, sending connect handshake');
-
- // Re-fetch token here before generating payload just in case it updated while connecting
+ // Sends the connect frame using the server-issued challenge nonce.
+ const sendConnectHandshake = async (challengeNonce: string) => {
+ logger.debug('Sending connect handshake with challenge nonce');
+
const currentToken = await getSetting('gatewayToken');
-
- // Send proper connect handshake as required by OpenClaw Gateway protocol
- // The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
- // Since 2026.2.15, scopes are only granted when a signed device identity is included.
+
connectId = `connect-${Date.now()}`;
const role = 'operator';
const scopes = ['operator.admin'];
@@ -844,7 +841,7 @@ export class GatewayManager extends EventEmitter {
const device = (() => {
if (!this.deviceIdentity) return undefined;
-
+
const payload = buildDeviceAuthPayload({
deviceId: this.deviceIdentity.deviceId,
clientId,
@@ -853,6 +850,7 @@ export class GatewayManager extends EventEmitter {
scopes,
signedAtMs,
token: currentToken ?? null,
+ nonce: challengeNonce,
});
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
return {
@@ -860,6 +858,7 @@ export class GatewayManager extends EventEmitter {
publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
+ nonce: challengeNonce,
};
})();
@@ -886,10 +885,9 @@ export class GatewayManager extends EventEmitter {
device,
},
};
-
+
this.ws?.send(JSON.stringify(connectFrame));
-
- // Store pending connect request
+
const requestTimeout = setTimeout(() => {
if (!handshakeComplete) {
logger.error('Gateway connect handshake timed out');
@@ -917,11 +915,35 @@ export class GatewayManager extends EventEmitter {
},
timeout: requestTimeout,
});
+ };
+
+ this.ws.on('open', () => {
+ logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
});
+ let challengeReceived = false;
+
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
+
+ // Intercept the connect.challenge event before the general handler
+ if (
+ !challengeReceived &&
+ typeof message === 'object' && message !== null &&
+ message.type === 'event' && message.event === 'connect.challenge'
+ ) {
+ challengeReceived = true;
+ const nonce = message.payload?.nonce as string | undefined;
+ if (!nonce) {
+ rejectOnce(new Error('Gateway connect.challenge missing nonce'));
+ return;
+ }
+ logger.debug('Received connect.challenge, sending handshake');
+ sendConnectHandshake(nonce);
+ return;
+ }
+
this.handleMessage(message);
} catch (error) {
logger.debug('Failed to parse Gateway WebSocket message:', error);
diff --git a/electron/main/updater.ts b/electron/main/updater.ts
index 51578d038..62584c27f 100644
--- a/electron/main/updater.ts
+++ b/electron/main/updater.ts
@@ -42,6 +42,11 @@ function detectChannel(version: string): string {
export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' };
+ private autoInstallTimer: NodeJS.Timeout | null = null;
+ private autoInstallCountdown = 0;
+
+ /** Delay (in seconds) before auto-installing a downloaded update. */
+ private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
constructor() {
super();
@@ -120,6 +125,10 @@ export class AppUpdater extends EventEmitter {
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
+
+ if (autoUpdater.autoDownload) {
+ this.startAutoInstallCountdown();
+ }
});
autoUpdater.on('error', (error: Error) => {
@@ -200,6 +209,41 @@ export class AppUpdater extends EventEmitter {
autoUpdater.quitAndInstall();
}
+ /**
+ * Start a countdown that auto-installs the downloaded update.
+ * Sends `update:auto-install-countdown` events to the renderer each second.
+ */
+ private startAutoInstallCountdown(): void {
+ this.clearAutoInstallTimer();
+ this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
+ this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
+
+ this.autoInstallTimer = setInterval(() => {
+ this.autoInstallCountdown--;
+ this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
+
+ if (this.autoInstallCountdown <= 0) {
+ this.clearAutoInstallTimer();
+ this.quitAndInstall();
+ }
+ }, 1000);
+ }
+
+ /**
+ * Cancel a running auto-install countdown.
+ */
+ cancelAutoInstall(): void {
+ this.clearAutoInstallTimer();
+ this.sendToRenderer('update:auto-install-countdown', { seconds: -1, cancelled: true });
+ }
+
+ private clearAutoInstallTimer(): void {
+ if (this.autoInstallTimer) {
+ clearInterval(this.autoInstallTimer);
+ this.autoInstallTimer = null;
+ }
+ }
+
/**
* Set update channel (stable, beta, dev)
*/
@@ -280,6 +324,12 @@ export function registerUpdateHandlers(
return { success: true };
});
+ // Cancel pending auto-install countdown
+ ipcMain.handle('update:cancelAutoInstall', () => {
+ updater.cancelAutoInstall();
+ return { success: true };
+ });
+
}
// Export singleton instance
diff --git a/electron/preload/index.ts b/electron/preload/index.ts
index 98a2e39c6..f4a6ab0f1 100644
--- a/electron/preload/index.ts
+++ b/electron/preload/index.ts
@@ -59,6 +59,7 @@ const electronAPI = {
'update:install',
'update:setChannel',
'update:setAutoDownload',
+ 'update:cancelAutoInstall',
// Env
'env:getConfig',
'env:setApiKey',
@@ -160,6 +161,7 @@ const electronAPI = {
'update:progress',
'update:downloaded',
'update:error',
+ 'update:auto-install-countdown',
'cron:updated',
];
@@ -199,6 +201,7 @@ const electronAPI = {
'update:progress',
'update:downloaded',
'update:error',
+ 'update:auto-install-countdown',
];
if (validChannels.includes(channel)) {
diff --git a/src/components/settings/UpdateSettings.tsx b/src/components/settings/UpdateSettings.tsx
index b3ee9b05a..291578298 100644
--- a/src/components/settings/UpdateSettings.tsx
+++ b/src/components/settings/UpdateSettings.tsx
@@ -3,7 +3,7 @@
* Displays update status and allows manual update checking/installation
*/
import { useEffect, useCallback } from 'react';
-import { Download, RefreshCw, Loader2, Rocket } from 'lucide-react';
+import { Download, RefreshCw, Loader2, Rocket, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useUpdateStore } from '@/stores/update';
@@ -26,10 +26,12 @@ export function UpdateSettings() {
progress,
error,
isInitialized,
+ autoInstallCountdown,
init,
checkForUpdates,
downloadUpdate,
installUpdate,
+ cancelAutoInstall,
clearError,
} = useUpdateStore();
@@ -60,6 +62,9 @@ export function UpdateSettings() {
};
const renderStatusText = () => {
+ if (status === 'downloaded' && autoInstallCountdown != null && autoInstallCountdown >= 0) {
+ return t('updates.status.autoInstalling', { seconds: autoInstallCountdown });
+ }
switch (status) {
case 'checking':
return t('updates.status.checking');
@@ -102,6 +107,14 @@ export function UpdateSettings() {
);
case 'downloaded':
+ if (autoInstallCountdown != null && autoInstallCountdown >= 0) {
+ return (
+
+ );
+ }
return (