diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fe3cdaecc..bde99e9da77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai - MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch. - Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc. - Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit. +- Mobile pairing/security: fail closed for internal `/pair` setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek. ## 2026.4.2 diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 2e609c10c4b..045c35d0a5f 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -411,6 +411,7 @@ describe("device-pair /pair qr", () => { const command = registerPairCommand(); const result = await command?.handler( createCommandContext({ + channel: "telegram", args: "cleanup", commandBody: "/pair cleanup", }), @@ -560,6 +561,10 @@ describe("device-pair notify pending formatting", () => { }); describe("device-pair /pair approve", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("rejects internal gateway callers without operator.pairing", async () => { vi.mocked(listDevicePairing).mockResolvedValueOnce({ pending: [ @@ -713,10 +718,6 @@ describe("device-pair /pair approve", () => { ], paired: [], }); - vi.mocked(approveDevicePairing).mockResolvedValueOnce({ - status: "forbidden", - missingScope: "operator.admin", - }); const command = registerPairCommand(); const result = await command.handler( @@ -728,11 +729,9 @@ describe("device-pair /pair approve", () => { }), ); - expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { - callerScopes: [], - }); + expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled(); expect(result).toEqual({ - text: "⚠️ This command requires operator.admin to approve this pairing request.", + text: "⚠️ This command requires operator.pairing for internal gateway callers.", }); }); @@ -773,43 +772,6 @@ describe("device-pair /pair approve", () => { }); }); - it("fails closed for internal gateway callers when scopes are absent", async () => { - vi.mocked(listDevicePairing).mockResolvedValueOnce({ - pending: [ - { - requestId: "req-1", - deviceId: "victim-phone", - publicKey: "victim-public-key", - displayName: "Victim Phone", - platform: "ios", - ts: Date.now(), - }, - ], - paired: [], - }); - vi.mocked(approveDevicePairing).mockImplementationOnce(async () => ({ - status: "forbidden", - missingScope: "operator.admin", - })); - - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: undefined, - }), - ); - - expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { - callerScopes: [], - }); - expect(result).toEqual({ - text: "⚠️ This command requires operator.admin to approve this pairing request.", - }); - }); - it("preserves approvals for non-gateway command surfaces", async () => { vi.mocked(listDevicePairing).mockResolvedValueOnce({ pending: [