openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
563 lines (562 loc) • 22.5 kB
JavaScript
import { f as isOperatorScope } from "./operator-scopes-DsrPoRc4.mjs";
import { At as validateDevicePairRejectParams, Ft as validateDeviceTokenRevokeParams, It as validateDeviceTokenRotateParams, Mt as validateDevicePairRenameParams, Nr as validateScopeUpgradeRequest, Ot as validateDevicePairApproveParams, Pr as validateScopeUpgradeWait, jt as validateDevicePairRemoveParams, kt as validateDevicePairListParams } from "./src-l2dWvEsN.mjs";
import { a as roleScopesAllow } from "./operator-scope-compat-Ci6GBcmU.mjs";
import { t as ErrorCodes } from "./gateway-error-details-DIefe7Zy.mjs";
import { f as errorShape } from "./error-codes-Dh56LniQ.mjs";
import { d as resolveOperatorRolePolicy } from "./operator-role-policy-Bsa4SHqC.mjs";
import { t as ConnectErrorDetailCodes } from "./connect-error-details-q_NaMExM.mjs";
import { n as normalizeDeviceAuthScopes } from "./device-auth-DIR2dK9a.mjs";
import { n as holdGatewayPolicyResponse } from "./ws-policy-close-BgtfQbi3.mjs";
import { c as removePairedDevice, d as updatePairedDeviceMetadata, i as listDevicePairing, n as getPendingDevicePairing, s as rejectDevicePairing, t as getPairedDevice, u as requestDevicePairing } from "./device-pairing-Drmq_Czv.mjs";
import { n as approveDevicePairing, r as formatDevicePairingForbiddenMessage } from "./device-pairing-approval-DyoNg7Fe.mjs";
import { r as summarizeDeviceTokens } from "./device-pairing-token-utils-BIebfL9d.mjs";
import { n as revokeDeviceToken, r as rotateDeviceToken } from "./device-pairing-tokens-BD-uMVyw.mjs";
import { t as assertValidParams } from "./validation-Bu27N0nv.mjs";
import { t as GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "./events-D023Lo1g.mjs";
import { n as clearRemovedNodeRuntimeState, s as reconcileRevokedDeviceWorker } from "./node-runtime-state-CdOkHhB3.mjs";
import { a as invalidateNodeWakeState } from "./node-wake-state-CWVR-GCk.mjs";
import { a as requestsNonOperatorDeviceRole, i as pairedDeviceHasNonOperatorRole, n as deniesCrossDeviceManagement, o as resolveDeviceManagementAuthz, r as deniesDeviceTokenRoleManagement, s as resolveDeviceSessionAuthz, t as emitDeviceManagementSecurityEvent } from "./device-management-security-BQTmXF1c.mjs";
//#region src/gateway/server-methods/device-scope-upgrade.ts
const DEVICE_REQUIRED_MESSAGE = "device scope upgrade requires a paired browser identity; reopen the Control UI over HTTPS or localhost, then retry";
function readUpgradeOwner(client) {
const deviceId = client?.connect.device?.id.trim();
const publicKey = client?.connect.device?.publicKey.trim();
return client?.connId && client.connect.role === "operator" && deviceId && publicKey ? {
deviceId,
publicKey
} : null;
}
function respondDeviceRequired(respond) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_REQUIRED_MESSAGE, { details: {
code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
recommendedNextStep: "reopen_control_ui_securely"
} }));
}
/** Live operator scope-upgrade request and identity-bound wait handlers. */
const scopeUpgradeHandlers = {
"device.scopes.requestUpgrade": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateScopeUpgradeRequest, "device.scopes.requestUpgrade", respond)) return;
const owner = readUpgradeOwner(client);
if (!owner) {
respondDeviceRequired(respond);
return;
}
const paired = await getPairedDevice(owner.deviceId);
if (!paired || paired.publicKey !== owner.publicKey) {
respondDeviceRequired(respond);
return;
}
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
if (!requestedScopes.every(isOperatorScope)) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "requested scopes contain an unknown operator scope"));
return;
}
const rolePolicy = resolveOperatorRolePolicy(client, context.getRuntimeConfig());
if (rolePolicy && !roleScopesAllow({
role: "operator",
requestedScopes,
allowedScopes: rolePolicy.scopes
})) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "requested scopes exceed your assigned operator role; ask a gateway administrator to change your role"));
return;
}
const currentScopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
if (!roleScopesAllow({
role: "operator",
requestedScopes: currentScopes,
allowedScopes: requestedScopes
})) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "requested scopes must include the connection's current scopes"));
return;
}
const pairing = await requestDevicePairing({
deviceId: owner.deviceId,
publicKey: owner.publicKey,
displayName: client?.connect.client.displayName,
platform: client?.connect.client.platform,
deviceFamily: client?.connect.client.deviceFamily,
clientId: client?.connect.client.id,
clientMode: client?.connect.client.mode,
browserOrigin: paired.browserOrigin,
role: "operator",
scopes: requestedScopes,
remoteIp: client?.clientIp,
silent: false
});
const coordinator = context.scopeUpgradeCoordinator;
if (!coordinator?.register({
requestId: pairing.request.requestId,
expiresAtMs: pairing.expiresAtMs,
owner,
requestedScopes,
initialToken: paired.tokens?.operator?.token,
initialApprovedAtMs: paired.approvedAtMs
})) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "device scope upgrade is temporarily unavailable", { retryable: true }));
return;
}
const resolvedAt = Date.now();
for (const superseded of pairing.superseded ?? []) {
coordinator.notify(superseded.requestId, "rejected");
context.broadcast("device.pair.resolved", {
requestId: superseded.requestId,
deviceId: superseded.deviceId,
decision: "rejected",
ts: resolvedAt
}, { dropIfSlow: true });
}
if (pairing.created) context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
context.logGateway.warn(`security audit: live device scope upgrade requested device=${owner.deviceId} scopesFrom=${currentScopes.join(",")} scopesTo=${requestedScopes.join(",")}`);
respond(true, { requestId: pairing.request.requestId }, void 0);
},
"device.scopes.waitUpgrade": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateScopeUpgradeWait, "device.scopes.waitUpgrade", respond)) return;
const owner = readUpgradeOwner(client);
if (!owner) {
respondDeviceRequired(respond);
return;
}
const result = await context.scopeUpgradeCoordinator?.wait(params.requestId, owner);
if (!result) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "scope upgrade expired or not found"));
return;
}
if (result.status === "insufficient-scopes") {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "Pairing was approved, but this browser's previously narrowed token still lacks the requested access. Run openclaw dashboard --json on the Gateway host for a fresh one-time owner pairing link. Open its browserUrl in this browser using the same Control UI address and Gateway URL to restore administrator access."));
return;
}
if (result.status === "approved") {
const rolePolicy = resolveOperatorRolePolicy(client, context.getRuntimeConfig());
if (rolePolicy && !roleScopesAllow({
role: "operator",
requestedScopes: result.scopes,
allowedScopes: rolePolicy.scopes
})) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "approved scopes exceed your assigned operator role; ask a gateway administrator to change your role"));
return;
}
}
respond(true, result, void 0);
}
};
//#endregion
//#region src/gateway/server-methods/devices.ts
const DEVICE_PAIR_APPROVAL_DENIED_MESSAGE = "device pairing approval denied";
const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied";
function redactPairedDevice(device, connected) {
const { tokens, approvedScopes: _approvedScopes, ...rest } = device;
return {
...rest,
...connected !== void 0 ? { connected } : {},
tokens: summarizeDeviceTokens(tokens)
};
}
function prepareDeviceTokenMutation(operation, params, options) {
const { deviceId, role } = params;
const { client, context, respond } = options;
const authz = resolveDeviceManagementAuthz(client, deviceId);
const deny = (reason, scope) => {
const noun = operation === "rotate" ? "rotation" : "revocation";
const message = `device token ${noun} denied`;
const suffix = scope ? ` scope=${scope}` : "";
context.logGateway.warn(`${message} device=${deviceId} role=${role} reason=${reason}${suffix}`);
emitDeviceManagementSecurityEvent({
outcome: "denied",
severity: "medium",
policyId: "gateway.device-token",
decision: "deny",
action: `device.token.${noun}_denied`,
authz,
targetDeviceId: deviceId,
controlId: `device.token.${operation}`,
reason,
attributes: { role: role.trim() }
});
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, message));
};
const denial = deniesCrossDeviceManagement(authz) ? "device-ownership-mismatch" : deniesDeviceTokenRoleManagement(authz, role) ? "role-management-requires-admin" : void 0;
if (denial) {
deny(denial);
return;
}
return {
authz,
callerScopes: role.trim() === "operator" ? authz.callerScopes : void 0,
deny
};
}
async function runDevicePairingMutation(params) {
const authz = resolveDeviceManagementAuthz(params.client, params.deviceId);
let reason;
if (deniesCrossDeviceManagement(authz)) reason = "device-ownership-mismatch";
else if (authz.callerDeviceId && !authz.isAdminCaller) {
const paired = await getPairedDevice(authz.normalizedTargetDeviceId);
if (paired && pairedDeviceHasNonOperatorRole(paired)) reason = "role-management-requires-admin";
}
if (!reason) return params.run(authz);
const message = `device pairing ${params.operation === "remove" ? "removal" : "rename"} denied`;
params.context.logGateway.warn(`${message} device=${params.deviceId} reason=${reason}`);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: params.deviceId,
controlId: `device.pair.${params.operation}`,
reason
});
params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, message));
}
function emitDevicePairingDeniedSecurityEvent(params) {
emitDeviceManagementSecurityEvent({
action: "device.pairing.denied",
outcome: "denied",
severity: params.severity ?? "medium",
authz: params.authz,
targetDeviceId: params.targetDeviceId,
policyId: "gateway.device-pairing",
decision: "deny",
controlId: params.controlId,
reason: params.reason
});
}
function emitDevicePairingLifecycleSecurityEvent(params) {
emitDeviceManagementSecurityEvent({
...params,
outcome: "success",
policyId: "gateway.device-pairing",
decision: "allow"
});
}
function emitDeviceTokenLifecycleSecurityEvent(params) {
emitDeviceManagementSecurityEvent({
...params,
outcome: "success",
policyId: "gateway.device-token",
decision: "allow",
attributes: {
role: params.role,
...params.scopeCount !== void 0 ? { scope_count: params.scopeCount } : {}
}
});
}
const deviceHandlers = {
...scopeUpgradeHandlers,
"device.pair.list": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDevicePairListParams, "device.pair.list", respond)) return;
const list = await listDevicePairing();
const authz = resolveDeviceSessionAuthz(client);
let visibleList = list;
if (authz.callerDeviceId && !authz.isAdminCaller) visibleList = {
pending: list.pending.filter((request) => request.deviceId.trim() === authz.callerDeviceId),
paired: list.paired.filter((device) => device.deviceId.trim() === authz.callerDeviceId)
};
respond(true, {
pending: visibleList.pending,
paired: visibleList.paired.map((device) => redactPairedDevice(device, context.hasConnectedClientsForDevice?.(device.deviceId.trim()) ?? false))
}, void 0);
},
"device.pair.approve": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDevicePairApproveParams, "device.pair.approve", respond)) return;
const requestId = params.requestId.trim();
const authz = resolveDeviceSessionAuthz(client);
if (!authz.isAdminCaller) {
const pending = await getPendingDevicePairing(requestId);
if (!pending) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE));
return;
}
const reason = authz.callerDeviceId && pending.deviceId.trim() !== authz.callerDeviceId ? "device-ownership-mismatch" : requestsNonOperatorDeviceRole(pending) ? "role-management-requires-admin" : void 0;
if (reason) {
context.logGateway.warn(`device pairing approval denied request=${requestId} reason=${reason}`);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: pending.deviceId,
controlId: "device.pair.approve",
reason
});
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE));
return;
}
}
const approved = await approveDevicePairing(requestId, { callerScopes: authz.callerScopes });
if (!approved) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
if (approved.status === "forbidden") {
emitDevicePairingDeniedSecurityEvent({
authz,
controlId: "device.pair.approve",
reason: approved.reason
});
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, formatDevicePairingForbiddenMessage(approved)));
return;
}
const normalizedDeviceId = approved.device.deviceId.trim();
context.scopeUpgradeCoordinator?.notify(requestId, "approved");
if (approved.nodePairingGenerationChanged) {
invalidateNodeWakeState(normalizedDeviceId);
context.invalidateClientsForDevice?.(normalizedDeviceId, {
role: "node",
reason: "device-pairing-reapproved"
});
}
context.logGateway.info(`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`);
emitDevicePairingLifecycleSecurityEvent({
action: "device.pairing.approved",
severity: "low",
authz,
targetDeviceId: approved.device.deviceId,
controlId: "device.pair.approve",
attributes: {
role_count: approved.device.roles?.length ?? (approved.device.role ? 1 : 0),
scope_count: approved.device.approvedScopes?.length ?? approved.device.scopes?.length ?? 0
}
});
context.broadcast("device.pair.resolved", {
requestId,
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now()
}, { dropIfSlow: true });
respond(true, {
requestId,
device: redactPairedDevice(approved.device)
}, void 0);
if (approved.nodePairingGenerationChanged) queueMicrotask(() => {
context.disconnectClientsForDevice?.(normalizedDeviceId, { role: "node" });
});
},
"device.pair.reject": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDevicePairRejectParams, "device.pair.reject", respond)) return;
const requestId = params.requestId.trim();
const authz = resolveDeviceSessionAuthz(client);
if (authz.callerDeviceId && !authz.isAdminCaller) {
const pending = await getPendingDevicePairing(requestId);
if (!pending) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_REJECTION_DENIED_MESSAGE));
return;
}
if (pending.deviceId.trim() !== authz.callerDeviceId) {
context.logGateway.warn(`device pairing rejection denied request=${requestId} reason=device-ownership-mismatch`);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: pending.deviceId,
controlId: "device.pair.reject",
reason: "device-ownership-mismatch"
});
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_REJECTION_DENIED_MESSAGE));
return;
}
}
const rejected = await rejectDevicePairing(requestId);
if (!rejected) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.scopeUpgradeCoordinator?.notify(requestId, "rejected");
emitDevicePairingLifecycleSecurityEvent({
action: "device.pairing.rejected",
authz,
targetDeviceId: rejected.deviceId,
controlId: "device.pair.reject",
severity: "low"
});
context.broadcast("device.pair.resolved", {
requestId,
deviceId: rejected.deviceId,
decision: "rejected",
ts: Date.now()
}, { dropIfSlow: true });
respond(true, rejected, void 0);
},
"device.pair.remove": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDevicePairRemoveParams, "device.pair.remove", respond)) return;
const { deviceId } = params;
await runDevicePairingMutation({
operation: "remove",
client,
context,
respond,
deviceId,
run: async (authz) => {
const removed = await removePairedDevice(deviceId);
if (!removed) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId"));
return;
}
clearRemovedNodeRuntimeState({
nodeId: removed.deviceId,
context
});
try {
try {
holdGatewayPolicyResponse(respond);
} finally {
context.invalidateClientsForDevice?.(removed.deviceId, { reason: "device-pair-removed" });
await reconcileRevokedDeviceWorker(context, removed.deviceId);
}
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
emitDevicePairingLifecycleSecurityEvent({
action: "device.pairing.removed",
severity: "medium",
authz,
targetDeviceId: removed.deviceId,
controlId: "device.pair.remove"
});
respond(true, removed, void 0);
} finally {
queueMicrotask(() => {
context.disconnectClientsForDevice?.(removed.deviceId);
});
}
}
});
},
"device.pair.rename": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDevicePairRenameParams, "device.pair.rename", respond)) return;
const { deviceId, label } = params;
const trimmed = label.trim();
if (!trimmed) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "label required"));
return;
}
await runDevicePairingMutation({
operation: "rename",
client,
context,
respond,
deviceId,
run: async (authz) => {
if (!await updatePairedDeviceMetadata(deviceId, { operatorLabel: trimmed })) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId"));
return;
}
context.logGateway.info(`device pairing renamed device=${deviceId} label=${trimmed}`);
emitDevicePairingLifecycleSecurityEvent({
action: "device.pairing.renamed",
severity: "low",
authz,
targetDeviceId: deviceId,
controlId: "device.pair.rename"
});
context.broadcast(GATEWAY_EVENT_DEVICE_PAIR_CHANGED, {}, { dropIfSlow: true });
respond(true, {
deviceId,
label: trimmed
}, void 0);
}
});
},
"device.token.rotate": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDeviceTokenRotateParams, "device.token.rotate", respond)) return;
const { deviceId, role, scopes } = params;
const mutation = prepareDeviceTokenMutation("rotate", params, {
client,
context,
respond
});
if (!mutation) return;
const { authz, callerScopes, deny } = mutation;
const result = await rotateDeviceToken({
deviceId,
role,
scopes,
callerScopes
});
if (!result.ok) {
deny(result.reason, result.scope);
return;
}
const entry = result.entry;
const normalizedDeviceId = deviceId.trim();
context.logGateway.info(`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`);
emitDeviceTokenLifecycleSecurityEvent({
action: "device.token.rotated",
severity: "medium",
authz,
targetDeviceId: deviceId,
controlId: "device.token.rotate",
role: entry.role,
scopeCount: entry.scopes.length
});
if (entry.role === "node") invalidateNodeWakeState(normalizedDeviceId);
try {
holdGatewayPolicyResponse(respond);
} finally {
context.invalidateClientsForDevice?.(normalizedDeviceId, {
role: entry.role,
reason: "device-token-rotated"
});
queueMicrotask(() => {
context.disconnectClientsForDevice?.(normalizedDeviceId, { role: entry.role });
});
}
const deliversTokenInBand = Boolean(authz.callerDeviceId && authz.callerDeviceId === authz.normalizedTargetDeviceId);
respond(true, {
deviceId,
role: entry.role,
...deliversTokenInBand ? { token: entry.token } : {},
scopes: entry.scopes,
rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs,
tokenDelivery: deliversTokenInBand ? "in-band" : "withheld-cross-device"
}, void 0);
},
"device.token.revoke": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateDeviceTokenRevokeParams, "device.token.revoke", respond)) return;
const { deviceId, role } = params;
const mutation = prepareDeviceTokenMutation("revoke", params, {
client,
context,
respond
});
if (!mutation) return;
const { authz, callerScopes, deny } = mutation;
const result = await revokeDeviceToken({
deviceId,
role,
callerScopes
});
if (!result.ok) {
deny(result.reason, result.scope);
return;
}
const entry = result.entry;
const normalizedDeviceId = deviceId.trim();
context.logGateway.info(`device token revoked device=${normalizedDeviceId} role=${entry.role}`);
emitDeviceTokenLifecycleSecurityEvent({
action: "device.token.revoked",
severity: "high",
authz,
targetDeviceId: normalizedDeviceId,
controlId: "device.token.revoke",
role: entry.role
});
try {
try {
holdGatewayPolicyResponse(respond);
} finally {
context.invalidateClientsForDevice?.(normalizedDeviceId, {
role: entry.role,
reason: "device-token-revoked"
});
if (entry.role === "node") {
clearRemovedNodeRuntimeState({
nodeId: normalizedDeviceId,
context
});
await reconcileRevokedDeviceWorker(context, normalizedDeviceId);
}
}
} finally {
queueMicrotask(() => {
context.disconnectClientsForDevice?.(normalizedDeviceId, { role: entry.role });
});
}
respond(true, {
deviceId: normalizedDeviceId,
role: entry.role,
revokedAtMs: entry.revokedAtMs ?? Date.now()
}, void 0);
}
};
//#endregion
export { deviceHandlers };