mirror of https://github.com/openclaw/openclaw.git
Revert "fix(gateway): bound silent local pairing scopes"
This reverts commit 7f1b159c03.
This commit is contained in:
parent
a62193c09e
commit
1703bdcaf6
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue