mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
cef071582e
* feat(gateway): add live device scope upgrades * feat(ui): add limited-access upgrade flow * fix(protocol): refresh Swift scope upgrade models * perf(ui): lazy-load device scope upgrades * fix(ci): complete scope upgrade generated surfaces * perf(ui): lazy-load GitHub link hovercards * fix(ui): keep admin repair guidance focusable * fix(ui): gate and refresh scope upgrade banner * refactor(ui): keep gateway client within line budget * fix(ci): align rebased scope upgrade checks * fix(ui): resolve scope upgrade in browser tests * fix(gateway): honor refreshed scope upgrade deadline * fix(gateway): honor refreshed scope upgrade deadline * fix(gateway): coalesce scope upgrade waiters * fix(ui): gate scope upgrade actions * chore(plugin-sdk): refresh rebased API baseline * fix(scope-upgrade): return canonical request ids * fix(ui): preserve gateway event type binding * fix(protocol): generate scope upgrade result models * fix(ui): preserve scope upgrade recovery guidance * chore(plugin-sdk): refresh rebased API baseline * test(ui): avoid scope upgrade navigation race * docs(control-ui): clarify scope upgrade approver * test(gateway): align appended method counts * chore(plugin-sdk): refresh rebased API baseline * refactor(ui): keep place picker within line budget * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * fix(gateway): preserve scope-upgrade browser origin
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
|
|
import {
|
|
ErrorCodes,
|
|
errorShape,
|
|
validateScopeUpgradeRequest,
|
|
validateScopeUpgradeWait,
|
|
} from "../../../packages/gateway-protocol/src/index.js";
|
|
import { getPairedDevice, requestDevicePairing } from "../../infra/device-pairing.js";
|
|
import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js";
|
|
import { roleScopesAllow } from "../../shared/operator-scope-compat.js";
|
|
import { isOperatorScope } from "../operator-scopes.js";
|
|
import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js";
|
|
import { assertValidParams } from "./validation.js";
|
|
|
|
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: GatewayClient | null): {
|
|
deviceId: string;
|
|
publicKey: string;
|
|
} | null {
|
|
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: RespondFn): void {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
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. */
|
|
export const scopeUpgradeHandlers: GatewayRequestHandlers = {
|
|
"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 as { scopes: string[] }).scopes);
|
|
if (!requestedScopes.every(isOperatorScope)) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(
|
|
ErrorCodes.INVALID_REQUEST,
|
|
"requested scopes contain an unknown operator scope",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const currentScopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
|
|
if (
|
|
!roleScopesAllow({
|
|
role: "operator",
|
|
requestedScopes: currentScopes,
|
|
allowedScopes: requestedScopes,
|
|
})
|
|
) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
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,
|
|
undefined,
|
|
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 }, undefined);
|
|
},
|
|
|
|
"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 requestId = (params as { requestId: string }).requestId;
|
|
const result = await context.scopeUpgradeCoordinator?.wait(requestId, owner);
|
|
if (!result) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, "scope upgrade expired or not found"),
|
|
);
|
|
return;
|
|
}
|
|
respond(true, result, undefined);
|
|
},
|
|
};
|