diff --git a/CHANGELOG.md b/CHANGELOG.md index 249c8dbec27..a51a3c1d3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,6 @@ Docs: https://docs.openclaw.ai ### Fixes - Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120) Thanks @eleqtrizit and @pgondhi987. -- Gateway/pairing: keep silent local first-pair compatibility, but seed new operator device tokens from a bounded server-owned baseline instead of persisting requested admin scopes, so shared-auth localhost connects cannot silently self-upgrade into paired admin. Thanks @smaeljaish771. - Providers/OpenAI: make GPT-5 and Codex runs act sooner with lower-verbosity defaults, visible progress during tool work, and a one-shot retry when a turn only narrates the plan instead of taking action. - Providers/OpenAI and reply delivery: preserve native `reasoning.effort: "none"` and strict schemas where supported, add GPT-5.4 assistant `phase` metadata across replay and the Gateway `/v1/responses` layer, and keep commentary buffered until `final_answer` so web chat, session previews, embedded replies, and Telegram partials stop leaking planning text. Fixes #59150, #59643, #61282. - Telegram: fix current-model checks in the model picker, HTML-format non-default `/model` confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and `file_id` preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index c762cd3ae5f..09f5f718d00 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -540,9 +540,6 @@ implemented in `src/gateway/server-methods/*.ts`. - Pairing auto-approval is centered on direct local loopback connects. - OpenClaw also has a narrow backend/container-local self-connect path for trusted shared-secret helper flows. -- Local auto-approval for operator devices seeds a bounded per-device token - baseline instead of persisting arbitrary requested operator scopes. A shared- - secret session may still be broader than the silently issued device token. - Same-host tailnet or LAN connects are still treated as remote for pairing and require approval. - All WS clients must include `device` identity during `connect` (operator + node). diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index e6ecec3a384..7bbe27c35ac 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import * as devicePairingModule from "../infra/device-pairing.js"; -import { getPairedDevice, LOCAL_SILENT_OPERATOR_SCOPES } from "../infra/device-pairing.js"; +import { getPairedDevice } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { issueOperatorToken, @@ -19,50 +19,6 @@ import { installGatewayTestHooks({ scope: "suite" }); describe("gateway silent scope-upgrade reconnect", () => { - test("first local shared-auth pairing keeps the session alive but bounds the issued device token", async () => { - const started = await startServerWithClient("secret"); - const loaded = loadDeviceIdentity("silent-local-first-pair-bounded"); - let ws: WebSocket | undefined; - - try { - ws = await openTrackedWs(started.port); - const res = await connectReq(ws, { - token: "secret", - deviceIdentityPath: loaded.identityPath, - }); - expect(res.ok).toBe(true); - - const payload = res.payload as - | { - type?: string; - auth?: { deviceToken?: string; scopes?: string[] }; - snapshot?: { - configPath?: string; - stateDir?: string; - authMode?: string; - }; - } - | undefined; - expect(payload?.type).toBe("hello-ok"); - expect(payload?.auth?.scopes).toEqual([...LOCAL_SILENT_OPERATOR_SCOPES]); - expect(typeof payload?.auth?.deviceToken).toBe("string"); - expect(typeof payload?.snapshot?.configPath).toBe("string"); - expect((payload?.snapshot?.configPath ?? "").length).toBeGreaterThan(0); - expect(typeof payload?.snapshot?.stateDir).toBe("string"); - expect((payload?.snapshot?.stateDir ?? "").length).toBeGreaterThan(0); - expect(payload?.snapshot?.authMode).toBe("token"); - - const paired = await getPairedDevice(loaded.identity.deviceId); - expect(paired?.approvedScopes).toEqual([...LOCAL_SILENT_OPERATOR_SCOPES]); - expect(paired?.tokens?.operator?.scopes).toEqual([...LOCAL_SILENT_OPERATOR_SCOPES]); - } finally { - ws?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } - }); - test("does not silently widen a read-scoped paired device to admin on shared-auth reconnect", async () => { const started = await startServerWithClient("secret"); const paired = await issueOperatorToken({ @@ -195,17 +151,25 @@ describe("gateway silent scope-upgrade reconnect", () => { const loaded = loadDeviceIdentity("silent-reconnect-race"); let ws: WebSocket | undefined; - const approveOriginal = devicePairingModule.approveSilentLocalOperatorDevicePairing; + const approveOriginal = devicePairingModule.approveDevicePairing; let simulatedRace = false; - const forwardApprove = async (requestId: string) => await approveOriginal(requestId); + const forwardApprove = async (requestId: string, optionsOrBaseDir?: unknown) => { + if (optionsOrBaseDir && typeof optionsOrBaseDir === "object") { + return await approveOriginal( + requestId, + optionsOrBaseDir as { callerScopes?: readonly string[] }, + ); + } + return await approveOriginal(requestId); + }; const approveSpy = vi - .spyOn(devicePairingModule, "approveSilentLocalOperatorDevicePairing") - .mockImplementation(async (requestId: string) => { + .spyOn(devicePairingModule, "approveDevicePairing") + .mockImplementation(async (requestId: string, optionsOrBaseDir?: unknown) => { if (simulatedRace) { - return await forwardApprove(requestId); + return await forwardApprove(requestId, optionsOrBaseDir); } simulatedRace = true; - await forwardApprove(requestId); + await forwardApprove(requestId, optionsOrBaseDir); return null; }); @@ -235,7 +199,7 @@ describe("gateway silent scope-upgrade reconnect", () => { let ws: WebSocket | undefined; const approveSpy = vi - .spyOn(devicePairingModule, "approveSilentLocalOperatorDevicePairing") + .spyOn(devicePairingModule, "approveDevicePairing") .mockImplementation(async (requestId: string) => { await devicePairingModule.rejectDevicePairing(requestId); return null; @@ -248,7 +212,6 @@ describe("gateway silent scope-upgrade reconnect", () => { (obj) => obj.type === "event" && obj.event === "device.pair.requested", 300, ); - const requestedEventTimeout = expect(requestedEvent).rejects.toThrow("timeout"); ws = await openTrackedWs(started.port); const res = await connectReq(ws, { @@ -261,7 +224,7 @@ describe("gateway silent scope-upgrade reconnect", () => { expect( (res.error?.details as { requestId?: unknown; code?: string } | undefined)?.requestId, ).toBeUndefined(); - await requestedEventTimeout; + await expect(requestedEvent).rejects.toThrow("timeout"); const pending = await devicePairingModule.listDevicePairing(); expect(pending.pending).toEqual([]); @@ -281,7 +244,7 @@ describe("gateway silent scope-upgrade reconnect", () => { let replacementRequestId = ""; const approveSpy = vi - .spyOn(devicePairingModule, "approveSilentLocalOperatorDevicePairing") + .spyOn(devicePairingModule, "approveDevicePairing") .mockImplementation(async (_requestId: string) => { const replacement = await devicePairingModule.requestDevicePairing({ deviceId: loaded.identity.deviceId, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index ca0bf3d2a45..f7a9fc0f402 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -15,12 +15,10 @@ import { } from "../../../infra/device-identity.js"; import { approveBootstrapDevicePairing, - approveSilentLocalOperatorDevicePairing, - type ApproveDevicePairingResult, + approveDevicePairing, ensureDeviceToken, getPairedDevice, hasEffectivePairedDeviceRole, - LOCAL_SILENT_OPERATOR_SCOPES, listDevicePairing, listEffectivePairedDeviceRoles, requestDevicePairing, @@ -758,7 +756,6 @@ export function attachGatewayWsMessageHandler(params: { trustedProxyAuthOk, resolvedAuth.mode, ); - let issuedDeviceTokenScopes = scopes; if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { @@ -833,14 +830,6 @@ export function attachGatewayWsMessageHandler(params: { allowedScopes: pairedScopes, }); }; - const pairingStateAllowsSilentLocalSession = ( - pairedCandidate: Awaited>, - ): boolean => { - if (!pairedCandidate || pairedCandidate.publicKey !== devicePublicKey) { - return false; - } - return hasEffectivePairedDeviceRole(pairedCandidate, role); - }; if ( boundBootstrapProfile === null && authMethod === "bootstrap-token" && @@ -890,7 +879,7 @@ export function attachGatewayWsMessageHandler(params: { : allowSilentLocalPairing || allowSilentBootstrapPairing, }); const context = buildRequestContext(); - let approved: ApproveDevicePairingResult | undefined; + let approved: Awaited> | undefined; let resolvedByConcurrentApproval = false; let recoveryRequestId: string | undefined = pairing.request.requestId; const resolveLivePendingRequestId = async (): Promise => { @@ -913,11 +902,10 @@ export function attachGatewayWsMessageHandler(params: { pairing.request.requestId, bootstrapProfileForSilentApproval, ) - : await approveSilentLocalOperatorDevicePairing(pairing.request.requestId); + : await approveDevicePairing(pairing.request.requestId, { + callerScopes: scopes, + }); if (approved?.status === "approved") { - if (!bootstrapProfileForSilentApproval && role === "operator") { - issuedDeviceTokenScopes = [...LOCAL_SILENT_OPERATOR_SCOPES]; - } if (bootstrapProfileForSilentApproval) { handoffBootstrapProfile = bootstrapProfileForSilentApproval; } @@ -935,13 +923,9 @@ export function attachGatewayWsMessageHandler(params: { { dropIfSlow: true }, ); } else { - const pairedCandidate = await getPairedDevice(device.id); - resolvedByConcurrentApproval = - !bootstrapProfileForSilentApproval && - reason === "not-paired" && - role === "operator" - ? pairingStateAllowsSilentLocalSession(pairedCandidate) - : pairingStateAllowsRequestedAccess(pairedCandidate); + resolvedByConcurrentApproval = pairingStateAllowsRequestedAccess( + await getPairedDevice(device.id), + ); let requestStillPending = false; if (!resolvedByConcurrentApproval) { recoveryRequestId = await resolveLivePendingRequestId(); @@ -1078,7 +1062,7 @@ export function attachGatewayWsMessageHandler(params: { } const deviceToken = device - ? await ensureDeviceToken({ deviceId: device.id, role, scopes: issuedDeviceTokenScopes }) + ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) : null; const bootstrapDeviceTokens: Array<{ deviceToken: string; diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index c8e03bc6f41..4aad232ae68 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -7,12 +7,10 @@ import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device- import { approveBootstrapDevicePairing, approveDevicePairing, - approveSilentLocalOperatorDevicePairing, clearDevicePairing, ensureDeviceToken, getPairedDevice, hasEffectivePairedDeviceRole, - LOCAL_SILENT_OPERATOR_SCOPES, listEffectivePairedDeviceRoles, listDevicePairing, removePairedDevice, @@ -433,28 +431,6 @@ describe("device pairing tokens", () => { ); }); - test("silent local operator pairing seeds a bounded non-admin token baseline", async () => { - const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); - const request = await requestDevicePairing( - { - deviceId: "device-local-1", - publicKey: "public-key-local-1", - role: "operator", - scopes: ["operator.admin"], - silent: true, - }, - baseDir, - ); - - await expect( - approveSilentLocalOperatorDevicePairing(request.request.requestId, baseDir), - ).resolves.toEqual(expect.objectContaining({ status: "approved" })); - - const paired = await getPairedDevice("device-local-1", baseDir); - expect(paired?.approvedScopes).toEqual([...LOCAL_SILENT_OPERATOR_SCOPES]); - expect(paired?.tokens?.operator?.scopes).toEqual([...LOCAL_SILENT_OPERATOR_SCOPES]); - }); - test("generates base64url device tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.admin"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 24b0a971222..28d40bca9fb 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -90,14 +90,6 @@ export type ApproveDevicePairingResult = | { status: "forbidden"; missingScope: string } | null; -export const LOCAL_SILENT_OPERATOR_SCOPES = [ - "operator.approvals", - "operator.pairing", - "operator.read", - "operator.talk.secrets", - "operator.write", -] as const; - type DevicePairingStateFile = { pendingById: Record; pairedByDeviceId: Record; @@ -604,88 +596,6 @@ export async function approveDevicePairing( }); } -async function approveProfileDevicePairing(params: { - requestId: string; - approvedRoles: readonly string[]; - approvedScopes: readonly string[]; - baseDir?: string; -}): Promise { - const approvedRoles = mergeRoles(Array.from(params.approvedRoles)) ?? []; - const approvedScopes = normalizeDeviceAuthScopes(Array.from(params.approvedScopes)); - return await withLock(async () => { - const state = await loadState(params.baseDir); - const pending = state.pendingById[params.requestId]; - if (!pending) { - return null; - } - const requestedRoles = resolveRequestedRoles(pending); - const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role)); - if (missingRole) { - return { status: "forbidden", missingScope: missingRole }; - } - // Silent local pairing is compatibility UX, not scope approval. Persist the - // server-owned profile and ignore any broader operator scopes in the request. - const now = Date.now(); - const existing = state.pairedByDeviceId[pending.deviceId]; - const roles = mergeRoles( - existing?.roles, - existing?.role, - pending.roles, - pending.role, - approvedRoles, - ); - const nextApprovedScopes = mergeScopes( - existing?.approvedScopes ?? existing?.scopes, - approvedScopes, - ); - const tokens = existing?.tokens ? { ...existing.tokens } : {}; - for (const roleForToken of approvedRoles) { - const existingToken = tokens[roleForToken]; - tokens[roleForToken] = buildDeviceAuthToken({ - role: roleForToken, - scopes: resolveRoleScopedDeviceTokenScopes(roleForToken, nextApprovedScopes), - existing: existingToken, - now, - ...(existingToken ? { rotatedAtMs: now } : {}), - }); - } - - const device: PairedDevice = { - deviceId: pending.deviceId, - publicKey: pending.publicKey, - displayName: pending.displayName, - platform: pending.platform, - deviceFamily: pending.deviceFamily, - clientId: pending.clientId, - clientMode: pending.clientMode, - role: pending.role, - roles, - scopes: nextApprovedScopes, - approvedScopes: nextApprovedScopes, - remoteIp: pending.remoteIp, - tokens, - createdAtMs: existing?.createdAtMs ?? now, - approvedAtMs: now, - }; - delete state.pendingById[params.requestId]; - state.pairedByDeviceId[device.deviceId] = device; - await persistState(state, params.baseDir); - return { status: "approved", requestId: params.requestId, device }; - }); -} - -export async function approveSilentLocalOperatorDevicePairing( - requestId: string, - baseDir?: string, -): Promise { - return await approveProfileDevicePairing({ - requestId, - approvedRoles: [OPERATOR_ROLE], - approvedScopes: LOCAL_SILENT_OPERATOR_SCOPES, - baseDir, - }); -} - export async function approveBootstrapDevicePairing( requestId: string, bootstrapProfile: DeviceBootstrapProfile,