fix: sanitize pairing recovery requestId hints (#24771) (thanks @markmusson)

This commit is contained in:
Peter Steinberger
2026-02-24 03:53:29 +00:00
parent b902d5ade0
commit 69a541c3f0
3 changed files with 54 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 };
}

View File

@@ -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();