Revert "fix(gateway): bound silent local pairing scopes"

This reverts commit 7f1b159c03.
This commit is contained in:
Peter Steinberger 2026-04-05 23:09:29 +01:00
parent a62193c09e
commit 1703bdcaf6
No known key found for this signature in database
6 changed files with 27 additions and 198 deletions

View File

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

View File

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

View File

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

View File

@ -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<ReturnType<typeof getPairedDevice>>,
): 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<ReturnType<typeof approveDevicePairing>> | undefined;
let resolvedByConcurrentApproval = false;
let recoveryRequestId: string | undefined = pairing.request.requestId;
const resolveLivePendingRequestId = async (): Promise<string | undefined> => {
@ -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;

View File

@ -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"]);

View File

@ -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<string, DevicePairingPendingRequest>;
pairedByDeviceId: Record<string, PairedDevice>;
@ -604,88 +596,6 @@ export async function approveDevicePairing(
});
}
async function approveProfileDevicePairing(params: {
requestId: string;
approvedRoles: readonly string[];
approvedScopes: readonly string[];
baseDir?: string;
}): Promise<ApproveDevicePairingResult> {
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<ApproveDevicePairingResult> {
return await approveProfileDevicePairing({
requestId,
approvedRoles: [OPERATOR_ROLE],
approvedScopes: LOCAL_SILENT_OPERATOR_SCOPES,
baseDir,
});
}
export async function approveBootstrapDevicePairing(
requestId: string,
bootstrapProfile: DeviceBootstrapProfile,