From fc666cf42ad0ff9c765b477a08b665da8d9a01d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 13:51:24 +0100 Subject: [PATCH 01/11] test(qa): allow slower gateway rpc startup retries --- extensions/qa-lab/src/gateway-child.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 0243ca58256..a41dc1ef011 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -42,6 +42,7 @@ import type { QaTransportAdapter } from "./qa-transport.js"; export type { QaCliBackendAuthMode } from "./providers/env.js"; const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5; +const QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS = 60_000; const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([ "OPENCLAW_QA_CONVEX_SECRET_CI", "OPENCLAW_QA_CONVEX_SECRET_MAINTAINER", @@ -684,7 +685,7 @@ export async function startQaGatewayChild(params: { baseUrl, logs, child: attemptChild, - timeoutMs: 15_000, + timeoutMs: QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS, }); } } From dce35b90fe8fa09ea51a666195b7645c260d6cbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 13:53:50 +0100 Subject: [PATCH 02/11] test(release): wait longer for dashboard smoke --- scripts/openclaw-cross-os-release-checks.ts | 6 ++++-- test/scripts/openclaw-cross-os-release-checks.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index e095964a5cb..4ac181a51f7 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -58,6 +58,8 @@ const OMITTED_QA_EXTENSION_PREFIXES = [ "dist/extensions/qa-lab/", "dist/extensions/qa-matrix/", ]; +export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000; +export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000; if (isMainModule()) { try { @@ -2463,7 +2465,7 @@ function parseAgentPayloadTexts(stdout) { async function runDashboardSmoke(params) { const dashboardUrl = `http://127.0.0.1:${params.lane.gatewayPort}/`; const logStream = createWriteStream(params.logPath, { flags: "a" }); - const deadline = Date.now() + 30_000; + const deadline = Date.now() + CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS; let attempt = 0; try { while (Date.now() < deadline) { @@ -2471,7 +2473,7 @@ async function runDashboardSmoke(params) { logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`); try { const response = await fetch(dashboardUrl, { - signal: AbortSignal.timeout(5_000), + signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS), }); const html = await response.text(); if ( diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index cb132ab9bef..9477eb7caa8 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -12,6 +12,8 @@ import { canConnectToLoopbackPort, buildDiscordSmokeGuildsConfig, buildRealUpdateEnv, + CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS, + CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS, isImmutableReleaseRef, looksLikeReleaseVersionRef, normalizeRequestedRef, @@ -39,6 +41,11 @@ import { } from "../../scripts/openclaw-cross-os-release-checks.ts"; describe("scripts/openclaw-cross-os-release-checks", () => { + it("keeps dashboard smoke patient enough for cold packaged gateway startup", () => { + expect(CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS).toBeGreaterThanOrEqual(120_000); + expect(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(10_000); + }); + it("accepts OK agent output from the captured log when stdout is empty", () => { const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-output-")); try { From 631552c554095b0c520376db9131dac0f5427f94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 14:14:06 +0100 Subject: [PATCH 03/11] perf: speed up dispatch-from-config tests --- .../reply/dispatch-from-config.test.ts | 116 +++++++++++++++++- src/auto-reply/reply/dispatch-from-config.ts | 44 ++++--- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index ec58c5c9888..22cc2cc944a 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -150,6 +150,107 @@ const replyMediaPathMocks = vi.hoisted(() => ({ const runtimePluginMocks = vi.hoisted(() => ({ ensureRuntimePluginsLoaded: vi.fn(), })); +const conversationBindingMocks = vi.hoisted(() => { + type BindingMsgContext = { + OriginatingChannel?: string | null; + Surface?: string | null; + Provider?: string | null; + AccountId?: string | null; + MessageThreadId?: string | number | null; + ThreadParentId?: string | null; + SenderId?: string | null; + SessionKey?: string | null; + ParentSessionKey?: string | null; + OriginatingTo?: string | null; + To?: string | null; + From?: string | null; + NativeChannelId?: string | null; + }; + type BindingConfig = { + channels?: Record; + }; + + const normalizeText = (value: string | number | null | undefined) => + typeof value === "number" ? `${value}` : (value ?? "").trim(); + const normalizeChannel = (value: string | null | undefined) => normalizeText(value).toLowerCase(); + const resolveChannel = (ctx: BindingMsgContext, commandChannel?: string | null) => + normalizeChannel(ctx.OriginatingChannel ?? commandChannel ?? ctx.Surface ?? ctx.Provider); + const resolveAccountId = (ctx: BindingMsgContext, cfg: BindingConfig, channel: string) => + normalizeText(ctx.AccountId) || + normalizeText(cfg.channels?.[channel]?.defaultAccount) || + "default"; + const resolveTarget = (channel: string, value: string | null | undefined) => { + const target = normalizeText(value); + if (!target) { + return undefined; + } + const channelPrefix = `${channel}:`; + return target.toLowerCase().startsWith(channelPrefix) + ? target.slice(channelPrefix.length) + : target; + }; + const resolveThreadId = (ctx: BindingMsgContext) => + normalizeText(ctx.MessageThreadId) || undefined; + + const resolveConversationBindingContextFromMessage = vi.fn( + (params: { cfg: BindingConfig; ctx: BindingMsgContext }) => { + const channel = resolveChannel(params.ctx); + if (!channel) { + return null; + } + const threadId = resolveThreadId(params.ctx); + const baseConversationId = + resolveTarget(channel, params.ctx.OriginatingTo) ?? resolveTarget(channel, params.ctx.To); + const conversationId = threadId ?? baseConversationId; + if (!conversationId) { + return null; + } + const parentConversationId = + threadId && baseConversationId && baseConversationId !== threadId + ? baseConversationId + : resolveTarget(channel, params.ctx.ThreadParentId); + return { + channel, + accountId: resolveAccountId(params.ctx, params.cfg, channel), + conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + ...(threadId ? { threadId } : {}), + }; + }, + ); + + return { + resolveConversationBindingAccountIdFromMessage: (params: { + ctx: BindingMsgContext; + cfg: BindingConfig; + commandChannel?: string | null; + }) => + resolveAccountId(params.ctx, params.cfg, resolveChannel(params.ctx, params.commandChannel)), + resolveConversationBindingChannelFromMessage: ( + ctx: BindingMsgContext, + commandChannel?: string | null, + ) => resolveChannel(ctx, commandChannel), + resolveConversationBindingContextFromAcpCommand: (params: { + cfg: BindingConfig; + ctx: BindingMsgContext; + command?: { to?: string | null; senderId?: string | null }; + sessionKey?: string | null; + parentSessionKey?: string | null; + }) => + resolveConversationBindingContextFromMessage({ + cfg: params.cfg, + ctx: { + ...params.ctx, + SenderId: params.command?.senderId ?? params.ctx.SenderId, + SessionKey: params.sessionKey ?? params.ctx.SessionKey, + ParentSessionKey: params.parentSessionKey ?? params.ctx.ParentSessionKey, + To: params.command?.to ?? params.ctx.To, + }, + }), + resolveConversationBindingContextFromMessage, + resolveConversationBindingThreadIdFromMessage: (ctx: BindingMsgContext) => resolveThreadId(ctx), + }; +}); const threadInfoMocks = vi.hoisted(() => ({ parseSessionThreadInfo: vi.fn< (sessionKey: string | undefined) => { @@ -345,6 +446,18 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ vi.mock("../../agents/runtime-plugins.js", () => ({ ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, })); +vi.mock("./conversation-binding-input.js", () => ({ + resolveConversationBindingAccountIdFromMessage: + conversationBindingMocks.resolveConversationBindingAccountIdFromMessage, + resolveConversationBindingChannelFromMessage: + conversationBindingMocks.resolveConversationBindingChannelFromMessage, + resolveConversationBindingContextFromAcpCommand: + conversationBindingMocks.resolveConversationBindingContextFromAcpCommand, + resolveConversationBindingContextFromMessage: + conversationBindingMocks.resolveConversationBindingContextFromMessage, + resolveConversationBindingThreadIdFromMessage: + conversationBindingMocks.resolveConversationBindingThreadIdFromMessage, +})); vi.mock("../../tts/status-config.js", () => ({ resolveStatusTtsSnapshot: () => ({ autoMode: "always", @@ -771,7 +884,8 @@ describe("dispatchReplyFromConfig", () => { OriginatingTo: undefined, }); - const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + const replyResolver = async () => + ({ text: "hi", mediaUrl: "https://example.test/reply.png" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index e89b7d6841f..9e48a73367d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -432,26 +432,38 @@ export async function dispatchReplyFromConfig( }); const routeReplyTo = replyRoute.to; const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface; - const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime(); - const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ - cfg, - sessionKey: acpDispatchSessionKey, - workspaceDir, - messageProvider: deliveryChannel, - accountId: replyRoute.accountId, - groupId, - groupChannel: ctx.GroupChannel, - groupSpace: ctx.GroupSpace, - requesterSenderId: ctx.SenderId, - requesterSenderName: ctx.SenderName, - requesterSenderUsername: ctx.SenderUsername, - requesterSenderE164: ctx.SenderE164, - }); + let normalizeReplyMediaPaths: + | ReturnType< + (typeof import("./reply-media-paths.runtime.js"))["createReplyMediaPathNormalizer"] + > + | undefined; + const getNormalizeReplyMediaPaths = async () => { + if (normalizeReplyMediaPaths) { + return normalizeReplyMediaPaths; + } + const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime(); + normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ + cfg, + sessionKey: acpDispatchSessionKey, + workspaceDir, + messageProvider: deliveryChannel, + accountId: replyRoute.accountId, + groupId, + groupChannel: ctx.GroupChannel, + groupSpace: ctx.GroupSpace, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, + }); + return normalizeReplyMediaPaths; + }; const normalizeReplyMediaPayload = async (payload: ReplyPayload): Promise => { if (!resolveSendableOutboundReplyParts(payload).hasMedia) { return payload; } - return await normalizeReplyMediaPaths(payload); + const normalizeReplyMediaPayloadPaths = await getNormalizeReplyMediaPaths(); + return await normalizeReplyMediaPayloadPaths(payload); }; const routeReplyToOriginating = async ( From ddc2036956031f812485d9b6cde0c70a9d699f58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 12:11:08 +0100 Subject: [PATCH 04/11] fix: stabilize Parallels plugin smoke paths --- CHANGELOG.md | 1 + extensions/bonjour/src/advertiser.test.ts | 10 +++++++ extensions/bonjour/src/advertiser.ts | 32 ++++++++++++----------- scripts/e2e/parallels-linux-smoke.sh | 14 ++++++++-- scripts/e2e/parallels-npm-update-smoke.sh | 2 +- src/plugins/installed-plugin-index.ts | 3 +++ 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c60adbbfcd..de08cba1708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex. - Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex. - Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. - Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc. diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 9f13037c7ad..7b1de8be9ed 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -490,6 +490,8 @@ describe("gateway bonjour advertiser", () => { const stateRef = { value: "announcing" }; const events: string[] = []; + const cleanupException = vi.fn(); + const cleanupRejection = vi.fn(); let advertiseCount = 0; const destroy = vi.fn().mockImplementation(async () => { events.push("destroy"); @@ -505,6 +507,8 @@ describe("gateway bonjour advertiser", () => { return Promise.resolve(); }); mockCiaoService({ advertise, destroy, stateRef }); + registerUncaughtExceptionHandler.mockImplementation(() => cleanupException); + registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection); const started = await startAdvertiser({ gatewayPort: 18789, @@ -513,6 +517,8 @@ describe("gateway bonjour advertiser", () => { expect(createService).toHaveBeenCalledTimes(1); expect(advertise).toHaveBeenCalledTimes(1); + expect(registerUncaughtExceptionHandler).toHaveBeenCalledTimes(1); + expect(registerUnhandledRejectionHandler).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(15_000); @@ -521,11 +527,15 @@ describe("gateway bonjour advertiser", () => { expect(advertise).toHaveBeenCalledTimes(2); expect(destroy).toHaveBeenCalledTimes(1); expect(shutdown).not.toHaveBeenCalled(); + expect(cleanupException).not.toHaveBeenCalled(); + expect(cleanupRejection).not.toHaveBeenCalled(); expect(events).toEqual(["advertise:1", "destroy", "advertise:2"]); await started.stop(); expect(destroy).toHaveBeenCalledTimes(2); expect(shutdown).toHaveBeenCalledTimes(1); + expect(cleanupException).toHaveBeenCalledTimes(1); + expect(cleanupRejection).toHaveBeenCalledTimes(1); }); it("treats probing-to-announcing churn as one unhealthy window", async () => { diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index 752e3a21028..0db1fcbed7f 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -50,8 +50,6 @@ type CiaoModule = { type BonjourCycle = { responder: BonjourResponder; services: Array<{ label: string; svc: BonjourService }>; - cleanupUncaughtException?: () => void; - cleanupUnhandledRejection?: () => void; }; type ServiceStateTracker = { @@ -179,6 +177,18 @@ export async function startGatewayBonjourAdvertiser( const { getResponder, Protocol } = await loadCiaoModule(); const restoreConsoleLog = installCiaoConsoleNoiseFilter(); let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined; + let cleanupUnhandledRejection: (() => void) | undefined; + let cleanupUncaughtException: (() => void) | undefined; + let processHandlersCleaned = false; + + function cleanupProcessHandlers() { + if (processHandlersCleaned) { + return; + } + processHandlersCleaned = true; + cleanupUncaughtException?.(); + cleanupUnhandledRejection?.(); + } const handleCiaoProcessError = (reason: unknown): boolean => { const classification = classifyCiaoProcessError(reason); @@ -196,6 +206,8 @@ export async function startGatewayBonjourAdvertiser( } return true; }; + cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError); + cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError); try { const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || "openclaw"; @@ -259,16 +271,7 @@ export async function startGatewayBonjourAdvertiser( svc: gateway as unknown as BonjourService, }); - const cleanupUnhandledRejection = - services.length > 0 && deps.registerUnhandledRejectionHandler - ? deps.registerUnhandledRejectionHandler(handleCiaoProcessError) - : undefined; - const cleanupUncaughtException = - services.length > 0 && deps.registerUncaughtExceptionHandler - ? deps.registerUncaughtExceptionHandler(handleCiaoProcessError) - : undefined; - - return { responder, services, cleanupUncaughtException, cleanupUnhandledRejection }; + return { responder, services }; } async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) { @@ -288,9 +291,6 @@ export async function startGatewayBonjourAdvertiser( } } catch { /* ignore */ - } finally { - cycle.cleanupUncaughtException?.(); - cycle.cleanupUnhandledRejection?.(); } } @@ -483,10 +483,12 @@ export async function startGatewayBonjourAdvertiser( } await stopCycle(cycle, { shutdownResponder: true }); restoreConsoleLog(); + cleanupProcessHandlers(); }, }; } catch (err) { restoreConsoleLog(); + cleanupProcessHandlers(); throw err; } } diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 4b0712a24be..2d4866048ad 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -42,6 +42,7 @@ TIMEOUT_ONBOARD_S=180 TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S:-300}" TIMEOUT_GATEWAY_S=240 PHASE_STALE_WARN_S=60 +DISABLE_BONJOUR_FOR_GATEWAY=0 FRESH_MAIN_STATUS="skip" FRESH_MAIN_VERSION="skip" @@ -230,6 +231,11 @@ esac API_KEY_VALUE="${!API_KEY_ENV:-}" [[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required" +case "${OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR:-}" in + 1|true|TRUE|yes|YES|on|ON) + DISABLE_BONJOUR_FOR_GATEWAY=1 + ;; +esac resolve_vm_name() { local json requested explicit @@ -725,12 +731,16 @@ EOF } start_gateway_background() { - local cmd api_key_value_q + local cmd api_key_value_q bonjour_env api_key_value_q="$(shell_quote "$API_KEY_VALUE")" + bonjour_env="" + if [[ "$DISABLE_BONJOUR_FOR_GATEWAY" -eq 1 ]]; then + bonjour_env=" OPENCLAW_DISABLE_BONJOUR=1" + fi cmd="$(cat </dev/null 2>&1 || true rm -f /tmp/openclaw-parallels-linux-gateway.log -setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null & +setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json${bonjour_env} ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null & EOF )" guest_exec bash -lc "$cmd" diff --git a/scripts/e2e/parallels-npm-update-smoke.sh b/scripts/e2e/parallels-npm-update-smoke.sh index 7dfb84e3335..d37e8376f65 100755 --- a/scripts/e2e/parallels-npm-update-smoke.sh +++ b/scripts/e2e/parallels-npm-update-smoke.sh @@ -1944,7 +1944,7 @@ if platform_enabled windows; then fi if platform_enabled linux; then - bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \ + OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR=1 bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ --model "$MODEL_ID" \ diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index e8b4f4cc40f..5c98dd9d553 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -610,6 +610,9 @@ function buildInstalledPluginIndex( if (record.setupSource) { indexRecord.setupSource = record.setupSource; } + if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) { + indexRecord.syntheticAuthRefs = record.syntheticAuthRefs; + } if (candidate?.packageName) { indexRecord.packageName = candidate.packageName; } From 73affb491a70f9baa235d07386247a8bddc8abdc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 12:39:38 +0100 Subject: [PATCH 05/11] fix: bound dev update cleanup --- scripts/e2e/parallels-macos-smoke.sh | 1 + scripts/e2e/parallels-windows-smoke.sh | 3 +- src/infra/update-runner.test.ts | 100 ++++++++++++++++++++++++- src/infra/update-runner.ts | 23 +++--- 4 files changed, 114 insertions(+), 13 deletions(-) diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index 9659ed3eac9..9068463873a 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -1149,6 +1149,7 @@ run_dev_channel_update() { rm -rf $(shell_quote "$update_root") export PATH=$(shell_quote "$bootstrap_bin:$GUEST_EXEC_PATH") /usr/bin/env NODE_OPTIONS=--max-old-space-size=4096 \ + OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \ $GUEST_NODE_BIN $GUEST_OPENCLAW_ENTRY update --channel dev --yes --json EOF )" "$update_log" "$update_done" "$TIMEOUT_UPDATE_DEV_S" "$update_runner" diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index 63088e20709..8af4617696f 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -2398,8 +2398,9 @@ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue EOF )" + stop_gateway guest_run_openclaw "$API_KEY_ENV" "$API_KEY_VALUE" \ - agent --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json + agent --local --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json } capture_latest_ref_failure() { diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 769051dec31..4a3d5cb6ec8 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -710,6 +710,7 @@ describe("runGatewayUpdate", () => { it("does not fail a good windows dev preflight only because worktree cleanup hit long paths", async () => { await setupGitPackageManagerFixture(); const calls: string[] = []; + const cleanupTimeouts: Array = []; const upstreamSha = "upstream123"; const doctorNodePath = await resolveStableNodePath(process.execPath); const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; @@ -718,7 +719,7 @@ describe("runGatewayUpdate", () => { try { const runCommand = async ( argv: string[], - _options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, ) => { const key = argv.join(" "); calls.push(key); @@ -772,6 +773,7 @@ describe("runGatewayUpdate", () => { key.startsWith(`git -C ${tempDir} worktree remove --force `) && preflightPrefixPattern.test(key) ) { + cleanupTimeouts.push(options?.timeoutMs); return { stdout: "", stderr: "error: failed to delete worktree: Filename too long", @@ -798,6 +800,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("ok"); const cleanupStep = result.steps.find((step) => step.name === "preflight cleanup"); expect(cleanupStep?.exitCode).toBe(0); + expect(cleanupTimeouts[0]).toBeLessThanOrEqual(60_000); expect(cleanupStep?.stderrTail ?? "").toContain( "windows fallback cleanup removed preflight tree", ); @@ -806,6 +809,101 @@ describe("runGatewayUpdate", () => { } }); + it("falls back when dev preflight worktree cleanup times out", async () => { + await setupGitPackageManagerFixture(); + const calls: string[] = []; + const cleanupTimeouts: Array = []; + const upstreamSha = "upstream123"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + calls.push(key); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) { + return { stdout: "origin/main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse @{upstream}`) { + return { stdout: upstreamSha, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) { + return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 }; + } + if (key === "pnpm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) && + key.endsWith(` ${upstreamSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + preflightPrefixPattern.test(key) && + key.includes(" checkout --detach ") && + key.endsWith(upstreamSha) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") { + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force `) && + preflightPrefixPattern.test(key) + ) { + cleanupTimeouts.push(options?.timeoutMs); + return { + stdout: "", + stderr: "Command timed out after 60000ms", + code: null, + }; + } + if (key === `git -C ${tempDir} worktree prune`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rebase ${upstreamSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev" }); + + expect(result.status).toBe("ok"); + const cleanupStep = result.steps.find((step) => step.name === "preflight cleanup"); + expect(cleanupStep?.exitCode).toBe(0); + expect(cleanupTimeouts[0]).toBeLessThanOrEqual(60_000); + expect(cleanupStep?.stderrTail ?? "").toContain("fallback cleanup removed preflight tree"); + }); + it("adds heap headroom to windows pnpm build steps during dev updates", async () => { await setupGitPackageManagerFixture(); const upstreamSha = "upstream123"; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index ac8da655674..2623382b8b9 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -138,6 +138,7 @@ const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); const PREFLIGHT_TEMP_PREFIX = process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-"; const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree"; +const PREFLIGHT_CLEANUP_TIMEOUT_MS = 60_000; const WINDOWS_PREFLIGHT_BASE_DIR = "ocu"; const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096; @@ -215,10 +216,7 @@ async function removePathRecursive(target: string) { .catch(() => {}); } -async function repairWindowsPreflightCleanup(worktreeDir: string, preflightRoot: string) { - if (process.platform !== "win32") { - return false; - } +async function repairPreflightCleanup(worktreeDir: string, preflightRoot: string) { try { await fs.rm(worktreeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); await fs.rm(preflightRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); @@ -938,22 +936,25 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< break; } } finally { - const removeStep = await runStep( - step( + const removeStep = await runStep({ + ...step( "preflight cleanup", ["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir], gitRoot, ), - ); + timeoutMs: Math.min(timeoutMs, PREFLIGHT_CLEANUP_TIMEOUT_MS), + }); if ( removeStep.exitCode !== 0 && - (await repairWindowsPreflightCleanup(worktreeDir, preflightRoot)) + (await repairPreflightCleanup(worktreeDir, preflightRoot)) ) { removeStep.exitCode = 0; + const fallbackMessage = + process.platform === "win32" + ? "windows fallback cleanup removed preflight tree" + : "fallback cleanup removed preflight tree"; removeStep.stderrTail = trimLogTail( - [removeStep.stderrTail, "windows fallback cleanup removed preflight tree"] - .filter(Boolean) - .join("\n"), + [removeStep.stderrTail, fallbackMessage].filter(Boolean).join("\n"), MAX_LOG_CHARS, ); } From 7e51866d235295c1338906fc35687d70a104567e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 12:44:01 +0100 Subject: [PATCH 06/11] fix: sync Parallels Linux clock --- scripts/e2e/parallels-linux-smoke.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 2d4866048ad..da66da93526 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -466,7 +466,18 @@ restore_snapshot() { wait_for_guest_ready || die "guest did not become ready in $VM_NAME" } +sync_guest_clock() { + local host_now + host_now="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + guest_exec date -u -s "$host_now" >/dev/null + guest_exec hwclock --systohc >/dev/null 2>&1 || true + guest_exec timedatectl set-ntp true >/dev/null 2>&1 || true + guest_exec systemctl restart systemd-timesyncd >/dev/null 2>&1 || true + guest_exec date -u +} + bootstrap_guest() { + sync_guest_clock guest_exec apt-get -o Acquire::Check-Date=false update guest_exec apt-get install -y curl ca-certificates } From 79ad635515eecafbd510e853a44e31af45bd88bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 12:46:58 +0100 Subject: [PATCH 07/11] fix: pass Linux clock sync as epoch --- scripts/e2e/parallels-linux-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index da66da93526..d56a3a96d27 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -468,7 +468,7 @@ restore_snapshot() { sync_guest_clock() { local host_now - host_now="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + host_now="@$(date -u '+%s')" guest_exec date -u -s "$host_now" >/dev/null guest_exec hwclock --systohc >/dev/null 2>&1 || true guest_exec timedatectl set-ntp true >/dev/null 2>&1 || true From a87edd732d369a79308038c620e810469de58532 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 16:12:41 +0100 Subject: [PATCH 08/11] fix: harden Windows Parallels smoke --- scripts/e2e/parallels-windows-smoke.sh | 199 ++++++++++++++++++++++++- scripts/install.ps1 | 4 +- test/scripts/install-ps1.test.ts | 38 +++++ 3 files changed, 232 insertions(+), 9 deletions(-) diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index 8af4617696f..ddd56da0377 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -614,6 +614,150 @@ EOF )" } +guest_run_agent_turn_process() { + local env_name_q env_value_q runner_basename runner_script_path runner_url runner_url_q + local runner_name stdout_name stderr_name done_name + local start_seconds poll_deadline startup_checked state_rc log_rc done_rc + local agent_combined done_status launcher_state + env_name_q="$(ps_single_quote "$API_KEY_ENV")" + env_value_q="$(ps_single_quote "$API_KEY_VALUE")" + runner_basename="openclaw-parallels-agent-runner-$RANDOM-$RANDOM.ps1" + runner_script_path="$MAIN_TGZ_DIR/$runner_basename" + runner_url="http://$HOST_IP:$HOST_PORT/$runner_basename" + runner_url_q="$(ps_single_quote "$runner_url")" + runner_name="openclaw-parallels-agent-$RANDOM-$RANDOM.ps1" + stdout_name="openclaw-parallels-agent-$RANDOM-$RANDOM.out.log" + stderr_name="openclaw-parallels-agent-$RANDOM-$RANDOM.err.log" + done_name="openclaw-parallels-agent-$RANDOM-$RANDOM.done" + start_seconds="$SECONDS" + poll_deadline=$((SECONDS + TIMEOUT_AGENT_S + 60)) + startup_checked=0 + + cat >"$runner_script_path" <<'EOF' +param( + [string]$StdoutPath, + [string]$StderrPath, + [string]$DonePath, + [string]$EnvName, + [string]$EnvValue +) +$ErrorActionPreference = 'Continue' +try { + if ($EnvName -ne '') { + Set-Item -Path ('Env:' + $EnvName) -Value $EnvValue + } + $node = Join-Path $env:ProgramFiles 'nodejs\node.exe' + if (-not (Test-Path $node)) { + $node = 'node' + } + $entry = Join-Path $env:APPDATA 'npm\node_modules\openclaw\openclaw.mjs' + & $node $entry agent --local --agent main --session-id 'parallels-windows-smoke' --message 'Reply with exact ASCII text OK only.' --json > $StdoutPath 2> $StderrPath + Set-Content -Path $DonePath -Value ([string]$LASTEXITCODE) + exit $LASTEXITCODE +} catch { + $_ | Out-String | Set-Content -Path $StderrPath + Set-Content -Path $DonePath -Value '1' + exit 1 +} +EOF + + guest_powershell_poll 20 "$(cat <