Skip to content

Commit d8022cb

Browse files
committed
fix(gateway,mac): stop stale protocol mismatch reconnect loop, auto-remediate updater jobs, rate-limit server side
- Add PROTOCOL_MISMATCH to ConnectErrorDetailCodes so clients recognize the error type and pause reconnect instead of looping forever - Server now sends code: PROTOCOL_MISMATCH in protocol mismatch error response; GatewayClient includes it in shouldPauseReconnect list - doctor --fix now auto-removes stale ai.openclaw.update.* launchd jobs - Server-side per-IP rate limiter suppresses logs/error frames after 5 protocol mismatches per 60s from the same address
1 parent c2e9091 commit d8022cb

6 files changed

Lines changed: 117 additions & 2 deletions

File tree

src/commands/doctor-platform-notes.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { promisify } from "node:util";
66
import { formatCliCommand } from "../cli/command-format.js";
77
import type { OpenClawConfig } from "../config/types.openclaw.js";
88
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
9-
import { findStaleOpenClawUpdateLaunchdJobs } from "../daemon/launchd.js";
9+
import {
10+
findStaleOpenClawUpdateLaunchdJobs,
11+
removeOpenClawUpdateLaunchdJob,
12+
} from "../daemon/launchd.js";
1013
import { normalizeOptionalString } from "../shared/string-coerce.js";
1114
import { note } from "../terminal/note.js";
1215
import { shortenHomePath } from "../utils.js";
@@ -66,6 +69,54 @@ export async function noteMacStaleOpenClawUpdateLaunchdJobs(deps?: {
6669
(deps?.noteFn ?? note)(lines.join("\n"), "Gateway (macOS)");
6770
}
6871

72+
export async function maybeRemoveStaleOpenClawUpdateLaunchdJobs(deps?: {
73+
platform?: NodeJS.Platform;
74+
shouldRemove?: boolean;
75+
findJobs?: typeof findStaleOpenClawUpdateLaunchdJobs;
76+
removeJob?: typeof removeOpenClawUpdateLaunchdJob;
77+
noteFn?: typeof note;
78+
}): Promise<void> {
79+
const platform = deps?.platform ?? process.platform;
80+
if (platform !== "darwin" || !deps?.shouldRemove) {
81+
return;
82+
}
83+
const jobs = await (deps?.findJobs ?? findStaleOpenClawUpdateLaunchdJobs)().catch(() => []);
84+
if (jobs.length === 0) {
85+
return;
86+
}
87+
const removeJob = deps?.removeJob ?? removeOpenClawUpdateLaunchdJob;
88+
const noteFn = deps?.noteFn ?? note;
89+
const removed: string[] = [];
90+
const failed: string[] = [];
91+
for (const job of jobs) {
92+
const ok = await removeJob(job.label).catch(() => false);
93+
if (ok) {
94+
removed.push(job.label);
95+
} else {
96+
failed.push(job.label);
97+
}
98+
}
99+
const lines: string[] = [];
100+
if (removed.length > 0) {
101+
lines.push(
102+
`- Removed ${removed.length} stale OpenClaw updater launchd job(s):`,
103+
...removed.map((l) => ` - ${l}`),
104+
);
105+
}
106+
if (failed.length > 0) {
107+
lines.push(
108+
`- Failed to remove ${failed.length} stale OpenClaw updater launchd job(s). Remove manually:`,
109+
...failed.map((l) => ` launchctl remove ${l}`),
110+
);
111+
}
112+
if (removed.length > 0) {
113+
lines.push(` ${formatCliCommand("openclaw gateway restart")}`);
114+
}
115+
if (lines.length > 0) {
116+
noteFn(lines.join("\n"), "Gateway (macOS)");
117+
}
118+
}
119+
69120
async function launchctlGetenv(name: string): Promise<string | undefined> {
70121
try {
71122
const result = await execFileAsync("/bin/launchctl", ["getenv", name], { encoding: "utf8" });

src/commands/doctor.fast-path-mocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ vi.mock("./doctor-platform-notes.js", () => ({
3737
noteStartupOptimizationHints: vi.fn(),
3838
noteMacLaunchAgentOverrides: vi.fn().mockResolvedValue(undefined),
3939
noteMacStaleOpenClawUpdateLaunchdJobs: vi.fn().mockResolvedValue(undefined),
40+
maybeRemoveStaleOpenClawUpdateLaunchdJobs: vi.fn().mockResolvedValue(undefined),
4041
noteMacLaunchctlGatewayEnvOverrides: vi.fn().mockResolvedValue(undefined),
4142
}));
4243

src/flows/doctor-health-contributions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise<v
373373
const { maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices } =
374374
await import("../commands/doctor-gateway-services.js");
375375
const {
376+
maybeRemoveStaleOpenClawUpdateLaunchdJobs,
376377
noteMacLaunchAgentOverrides,
377378
noteMacLaunchctlGatewayEnvOverrides,
378379
noteMacStaleOpenClawUpdateLaunchdJobs,
@@ -386,6 +387,9 @@ async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise<v
386387
);
387388
await noteMacLaunchAgentOverrides();
388389
await noteMacStaleOpenClawUpdateLaunchdJobs();
390+
await maybeRemoveStaleOpenClawUpdateLaunchdJobs({
391+
shouldRemove: ctx.prompter.shouldRepair,
392+
});
389393
await noteMacLaunchctlGatewayEnvOverrides(ctx.cfg);
390394
}
391395

src/gateway/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,8 @@ export class GatewayClient {
758758
detailCode === ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH ||
759759
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
760760
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
761-
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
761+
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED ||
762+
detailCode === ConnectErrorDetailCodes.PROTOCOL_MISMATCH
762763
) {
763764
return true;
764765
}

src/gateway/protocol/connect-error-details.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const ConnectErrorDetailCodes = {
2828
DEVICE_AUTH_SIGNATURE_INVALID: "DEVICE_AUTH_SIGNATURE_INVALID",
2929
DEVICE_AUTH_PUBLIC_KEY_INVALID: "DEVICE_AUTH_PUBLIC_KEY_INVALID",
3030
PAIRING_REQUIRED: "PAIRING_REQUIRED",
31+
PROTOCOL_MISMATCH: "PROTOCOL_MISMATCH",
3132
} as const;
3233

3334
export type ConnectErrorDetailCode =

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,58 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
146146

147147
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
148148

149+
const PROTOCOL_MISMATCH_RATE_LIMIT = 5;
150+
const PROTOCOL_MISMATCH_RATE_WINDOW_MS = 60_000;
151+
const PROTOCOL_MISMATCH_PRUNE_INTERVAL_MS = 5 * 60_000;
152+
153+
type ProtocolMismatchEntry = {
154+
count: number;
155+
windowStart: number;
156+
};
157+
158+
const protocolMismatchByAddr = new Map<string, ProtocolMismatchEntry>();
159+
160+
let protocolMismatchPruneTimer: ReturnType<typeof setTimeout> | null = null;
161+
162+
function scheduleProtocolMismatchPrune(): void {
163+
if (protocolMismatchPruneTimer) {
164+
return;
165+
}
166+
protocolMismatchPruneTimer = setTimeout(() => {
167+
protocolMismatchPruneTimer = null;
168+
const now = Date.now();
169+
for (const [addr, entry] of protocolMismatchByAddr) {
170+
if (now - entry.windowStart > PROTOCOL_MISMATCH_RATE_WINDOW_MS * 2) {
171+
protocolMismatchByAddr.delete(addr);
172+
}
173+
}
174+
if (protocolMismatchByAddr.size > 0) {
175+
scheduleProtocolMismatchPrune();
176+
}
177+
}, PROTOCOL_MISMATCH_PRUNE_INTERVAL_MS).unref();
178+
}
179+
180+
function isProtocolMismatchRateLimited(remoteAddr: string | undefined): boolean {
181+
if (!remoteAddr) {
182+
return false;
183+
}
184+
const now = Date.now();
185+
let entry = protocolMismatchByAddr.get(remoteAddr);
186+
if (!entry) {
187+
entry = { count: 0, windowStart: now };
188+
protocolMismatchByAddr.set(remoteAddr, entry);
189+
}
190+
if (now - entry.windowStart > PROTOCOL_MISMATCH_RATE_WINDOW_MS) {
191+
entry.count = 0;
192+
entry.windowStart = now;
193+
}
194+
entry.count++;
195+
if (protocolMismatchByAddr.size === 1) {
196+
scheduleProtocolMismatchPrune();
197+
}
198+
return entry.count > PROTOCOL_MISMATCH_RATE_LIMIT;
199+
}
200+
149201
export type WsOriginCheckMetrics = {
150202
hostHeaderFallbackAccepted: number;
151203
};
@@ -547,11 +599,16 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
547599
expectedProtocol: PROTOCOL_VERSION,
548600
minimumProbeProtocol: MIN_PROBE_PROTOCOL_VERSION,
549601
});
602+
if (isProtocolMismatchRateLimited(remoteAddr)) {
603+
close(1002, "protocol mismatch");
604+
return;
605+
}
550606
logWsControl.warn(
551607
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
552608
);
553609
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "protocol mismatch", {
554610
details: {
611+
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
555612
expectedProtocol: PROTOCOL_VERSION,
556613
minimumProbeProtocol: MIN_PROBE_PROTOCOL_VERSION,
557614
},

0 commit comments

Comments
 (0)