From 69a541c3f0e1cbe1f49c224761e76368d74b4f83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:53:29 +0000 Subject: [PATCH] fix: sanitize pairing recovery requestId hints (#24771) (thanks @markmusson) --- CHANGELOG.md | 1 + src/commands/status.command.ts | 14 +++++++++++- src/commands/status.test.ts | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7851591e0f..d8d550448df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index d21ae16f176..a613f0896ee 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -41,6 +41,17 @@ function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; }): { requestId: string | null } | null { + const sanitizeRequestId = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + // Keep CLI guidance injection-safe: allow only compact id characters. + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trimmed)) { + return null; + } + return trimmed; + }; const source = [params.error, params.closeReason] .filter((part) => typeof part === "string" && part.trim().length > 0) .join(" "); @@ -48,7 +59,8 @@ function resolvePairingRecoveryContext(params: { return null; } const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); - const requestId = requestIdMatch && requestIdMatch[1] ? requestIdMatch[1].trim() : ""; + const requestId = + requestIdMatch && requestIdMatch[1] ? sanitizeRequestId(requestIdMatch[1]) : null; return { requestId: requestId || null }; } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 8092469f588..4532acb3ea2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -525,6 +525,46 @@ describe("statusCommand", () => { expect(joined).toContain("devices list"); }); + it("does not render unsafe requestId content into approval command hints", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123;rm -rf /)", + close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-123;rm -rf /"); + expect(joined).toContain("devices approve --latest"); + }); + + it("extracts requestId from close reason when error text omits it", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "pairing required (requestId: req-close-456)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("devices approve req-close-456"); + }); + it("includes sessions across agents in JSON output", async () => { const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation();