Files
openclaw/src/gateway/server-methods/devices.ts

407 lines
12 KiB
TypeScript

import {
approveDevicePairing,
getPairedDevice,
listApprovedPairedDeviceRoles,
listDevicePairing,
removePairedDevice,
type DeviceAuthToken,
type RotateDeviceTokenDenyReason,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
summarizeDeviceTokens,
} from "../../infra/device-pairing.js";
import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js";
import { resolveMissingRequestedScope } from "../../shared/operator-scope-compat.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateDevicePairApproveParams,
validateDevicePairListParams,
validateDevicePairRemoveParams,
validateDevicePairRejectParams,
validateDeviceTokenRevokeParams,
validateDeviceTokenRotateParams,
} from "../protocol/index.js";
import type { GatewayClient, GatewayRequestHandlers } from "./types.js";
const DEVICE_TOKEN_ROTATION_DENIED_MESSAGE = "device token rotation denied";
type DeviceTokenRotateTarget = {
pairedDevice: NonNullable<Awaited<ReturnType<typeof getPairedDevice>>>;
normalizedRole: string;
};
type DeviceManagementAuthz = {
callerDeviceId: string | null;
callerScopes: string[];
isAdminCaller: boolean;
normalizedTargetDeviceId: string;
};
function redactPairedDevice(
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
) {
const { tokens, approvedScopes: _approvedScopes, ...rest } = device;
return {
...rest,
tokens: summarizeDeviceTokens(tokens),
};
}
function logDeviceTokenRotationDenied(params: {
log: { warn: (message: string) => void };
deviceId: string;
role: string;
reason:
| RotateDeviceTokenDenyReason
| "caller-missing-scope"
| "unknown-device-or-role"
| "device-ownership-mismatch";
scope?: string | null;
}) {
const suffix = params.scope ? ` scope=${params.scope}` : "";
params.log.warn(
`device token rotation denied device=${params.deviceId} role=${params.role} reason=${params.reason}${suffix}`,
);
}
async function loadDeviceTokenRotateTarget(params: {
deviceId: string;
role: string;
log: { warn: (message: string) => void };
}): Promise<DeviceTokenRotateTarget | null> {
const normalizedRole = params.role.trim();
const pairedDevice = await getPairedDevice(params.deviceId);
if (!pairedDevice || !listApprovedPairedDeviceRoles(pairedDevice).includes(normalizedRole)) {
logDeviceTokenRotationDenied({
log: params.log,
deviceId: params.deviceId,
role: params.role,
reason: "unknown-device-or-role",
});
return null;
}
return { pairedDevice, normalizedRole };
}
function resolveDeviceManagementAuthz(
client: GatewayClient | null,
targetDeviceId: string,
): DeviceManagementAuthz {
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const rawCallerDeviceId = client?.connect?.device?.id;
const callerDeviceId =
typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim()
? rawCallerDeviceId.trim()
: null;
return {
callerDeviceId,
callerScopes,
isAdminCaller: callerScopes.includes("operator.admin"),
normalizedTargetDeviceId: targetDeviceId.trim(),
};
}
function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean {
return Boolean(
authz.callerDeviceId &&
authz.callerDeviceId !== authz.normalizedTargetDeviceId &&
!authz.isAdminCaller,
);
}
export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond }) => {
if (!validateDevicePairListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.list params: ${formatValidationErrors(
validateDevicePairListParams.errors,
)}`,
),
);
return;
}
const list = await listDevicePairing();
respond(
true,
{
pending: list.pending,
paired: list.paired.map((device) => redactPairedDevice(device)),
},
undefined,
);
},
"device.pair.approve": async ({ params, respond, context, client }) => {
if (!validateDevicePairApproveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.approve params: ${formatValidationErrors(
validateDevicePairApproveParams.errors,
)}`,
),
);
return;
}
const { requestId } = params as { requestId: string };
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const approved = await approveDevicePairing(requestId, { callerScopes });
if (!approved) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
if (approved.status === "forbidden") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`),
);
return;
}
context.logGateway.info(
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
context.broadcast(
"device.pair.resolved",
{
requestId,
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now(),
},
{ dropIfSlow: true },
);
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
},
"device.pair.reject": async ({ params, respond, context }) => {
if (!validateDevicePairRejectParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.reject params: ${formatValidationErrors(
validateDevicePairRejectParams.errors,
)}`,
),
);
return;
}
const { requestId } = params as { requestId: string };
const rejected = await rejectDevicePairing(requestId);
if (!rejected) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.broadcast(
"device.pair.resolved",
{
requestId,
deviceId: rejected.deviceId,
decision: "rejected",
ts: Date.now(),
},
{ dropIfSlow: true },
);
respond(true, rejected, undefined);
},
"device.pair.remove": async ({ params, respond, context, client }) => {
if (!validateDevicePairRemoveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.remove params: ${formatValidationErrors(
validateDevicePairRemoveParams.errors,
)}`,
),
);
return;
}
const { deviceId } = params as { deviceId: string };
const authz = resolveDeviceManagementAuthz(client, deviceId);
if (deniesCrossDeviceManagement(authz)) {
context.logGateway.warn(
`device pairing removal denied device=${deviceId} reason=device-ownership-mismatch`,
);
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "device pairing removal denied"),
);
return;
}
const removed = await removePairedDevice(deviceId);
if (!removed) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId"));
return;
}
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
respond(true, removed, undefined);
queueMicrotask(() => {
context.disconnectClientsForDevice?.(removed.deviceId);
});
},
"device.token.rotate": async ({ params, respond, context, client }) => {
if (!validateDeviceTokenRotateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.token.rotate params: ${formatValidationErrors(
validateDeviceTokenRotateParams.errors,
)}`,
),
);
return;
}
const { deviceId, role, scopes } = params as {
deviceId: string;
role: string;
scopes?: string[];
};
const authz = resolveDeviceManagementAuthz(client, deviceId);
if (deniesCrossDeviceManagement(authz)) {
logDeviceTokenRotationDenied({
log: context.logGateway,
deviceId,
role,
reason: "device-ownership-mismatch",
});
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
);
return;
}
const rotateTarget = await loadDeviceTokenRotateTarget({
deviceId,
role,
log: context.logGateway,
});
if (!rotateTarget) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
);
return;
}
const { pairedDevice, normalizedRole } = rotateTarget;
const requestedScopes = normalizeDeviceAuthScopes(
scopes ?? pairedDevice.tokens?.[normalizedRole]?.scopes ?? pairedDevice.scopes,
);
const missingScope = resolveMissingRequestedScope({
role,
requestedScopes,
allowedScopes: authz.callerScopes,
});
if (missingScope) {
logDeviceTokenRotationDenied({
log: context.logGateway,
deviceId,
role,
reason: "caller-missing-scope",
scope: missingScope,
});
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
);
return;
}
const rotated = await rotateDeviceToken({ deviceId, role, scopes });
if (!rotated.ok) {
logDeviceTokenRotationDenied({
log: context.logGateway,
deviceId,
role,
reason: rotated.reason,
});
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
);
return;
}
const entry = rotated.entry;
context.logGateway.info(
`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`,
);
respond(
true,
{
deviceId,
role: entry.role,
token: entry.token,
scopes: entry.scopes,
rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs,
},
undefined,
);
queueMicrotask(() => {
context.disconnectClientsForDevice?.(deviceId.trim(), { role: entry.role });
});
},
"device.token.revoke": async ({ params, respond, context, client }) => {
if (!validateDeviceTokenRevokeParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.token.revoke params: ${formatValidationErrors(
validateDeviceTokenRevokeParams.errors,
)}`,
),
);
return;
}
const { deviceId, role } = params as { deviceId: string; role: string };
const authz = resolveDeviceManagementAuthz(client, deviceId);
if (deniesCrossDeviceManagement(authz)) {
context.logGateway.warn(
`device token revocation denied device=${deviceId} role=${role} reason=device-ownership-mismatch`,
);
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "device token revocation denied"),
);
return;
}
const entry = await revokeDeviceToken({ deviceId, role });
if (!entry) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
const normalizedDeviceId = deviceId.trim();
context.logGateway.info(`device token revoked device=${normalizedDeviceId} role=${entry.role}`);
respond(
true,
{
deviceId: normalizedDeviceId,
role: entry.role,
revokedAtMs: entry.revokedAtMs ?? Date.now(),
},
undefined,
);
queueMicrotask(() => {
context.disconnectClientsForDevice?.(normalizedDeviceId, { role: entry.role });
});
},
};