mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 11:41:08 +00:00
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { loadConfig } from "../config/config.js";
|
|
import {
|
|
hasEffectivePairedDeviceRole,
|
|
listDevicePairing,
|
|
type DeviceAuthToken,
|
|
type PairedDevice,
|
|
} from "../infra/device-pairing.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
|
|
import {
|
|
clearApnsRegistrationIfCurrent,
|
|
loadApnsRegistration,
|
|
resolveApnsAuthConfigFromEnv,
|
|
resolveApnsRelayConfigFromEnv,
|
|
sendApnsExecApprovalAlert,
|
|
sendApnsExecApprovalResolvedWake,
|
|
shouldClearStoredApnsRegistration,
|
|
type ApnsAuthConfig,
|
|
type ApnsRegistration,
|
|
type ApnsRelayConfig,
|
|
} from "../infra/push-apns.js";
|
|
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
|
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
|
|
|
const APPROVALS_SCOPE = "operator.approvals";
|
|
const OPERATOR_ROLE = "operator";
|
|
|
|
type GatewayLikeLogger = {
|
|
debug?: (message: string) => void;
|
|
warn?: (message: string) => void;
|
|
error?: (message: string) => void;
|
|
};
|
|
|
|
type DeliveryTarget = {
|
|
nodeId: string;
|
|
registration: ApnsRegistration;
|
|
};
|
|
|
|
type DeliveryPlan = {
|
|
targets: DeliveryTarget[];
|
|
directAuth?: ApnsAuthConfig;
|
|
relayConfig?: ApnsRelayConfig;
|
|
};
|
|
|
|
type ApprovalDeliveryState = {
|
|
nodeIds: string[];
|
|
requestPushPromise: Promise<{ attempted: number; delivered: number }>;
|
|
};
|
|
|
|
function isIosPlatform(platform: string | undefined): boolean {
|
|
const normalized = normalizeOptionalLowercaseString(platform) ?? "";
|
|
return normalized.startsWith("ios") || normalized.startsWith("ipados");
|
|
}
|
|
|
|
function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | null {
|
|
const operatorToken = device.tokens?.[OPERATOR_ROLE];
|
|
if (!operatorToken || operatorToken.revokedAtMs) {
|
|
return null;
|
|
}
|
|
return operatorToken;
|
|
}
|
|
|
|
function canApproveExecRequests(device: PairedDevice): boolean {
|
|
const operatorToken = resolveActiveOperatorToken(device);
|
|
if (!operatorToken) {
|
|
return false;
|
|
}
|
|
return roleScopesAllow({
|
|
role: OPERATOR_ROLE,
|
|
requestedScopes: [APPROVALS_SCOPE],
|
|
allowedScopes: operatorToken.scopes,
|
|
});
|
|
}
|
|
|
|
function shouldTargetDevice(params: {
|
|
device: PairedDevice;
|
|
requireApprovalScope: boolean;
|
|
}): boolean {
|
|
if (!isIosPlatform(params.device.platform)) {
|
|
return false;
|
|
}
|
|
if (!hasEffectivePairedDeviceRole(params.device, OPERATOR_ROLE)) {
|
|
return false;
|
|
}
|
|
if (!params.requireApprovalScope) {
|
|
return true;
|
|
}
|
|
return canApproveExecRequests(params.device);
|
|
}
|
|
|
|
async function loadRegisteredTargets(params: {
|
|
deviceIds: readonly string[];
|
|
}): Promise<DeliveryTarget[]> {
|
|
const targets = await Promise.all(
|
|
params.deviceIds.map(async (nodeId) => {
|
|
const registration = await loadApnsRegistration(nodeId);
|
|
return registration ? { nodeId, registration } : null;
|
|
}),
|
|
);
|
|
return targets.filter((target): target is DeliveryTarget => target !== null);
|
|
}
|
|
|
|
async function resolvePairedTargets(params: {
|
|
requireApprovalScope: boolean;
|
|
}): Promise<DeliveryTarget[]> {
|
|
const pairing = await listDevicePairing();
|
|
const deviceIds = pairing.paired
|
|
.filter((device) =>
|
|
shouldTargetDevice({ device, requireApprovalScope: params.requireApprovalScope }),
|
|
)
|
|
.map((device) => device.deviceId);
|
|
return await loadRegisteredTargets({ deviceIds });
|
|
}
|
|
|
|
async function resolveDeliveryPlan(params: {
|
|
requireApprovalScope: boolean;
|
|
explicitNodeIds?: readonly string[];
|
|
log: GatewayLikeLogger;
|
|
}): Promise<DeliveryPlan> {
|
|
const targets = params.explicitNodeIds?.length
|
|
? await loadRegisteredTargets({ deviceIds: params.explicitNodeIds })
|
|
: await resolvePairedTargets({ requireApprovalScope: params.requireApprovalScope });
|
|
if (targets.length === 0) {
|
|
return { targets: [] };
|
|
}
|
|
|
|
const needsDirect = targets.some((target) => target.registration.transport === "direct");
|
|
const needsRelay = targets.some((target) => target.registration.transport === "relay");
|
|
|
|
let directAuth: ApnsAuthConfig | undefined;
|
|
if (needsDirect) {
|
|
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
|
if (auth.ok) {
|
|
directAuth = auth.value;
|
|
} else {
|
|
params.log.warn?.(`exec approvals: iOS direct APNs auth unavailable: ${auth.error}`);
|
|
}
|
|
}
|
|
|
|
let relayConfig: ApnsRelayConfig | undefined;
|
|
if (needsRelay) {
|
|
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
|
|
if (relay.ok) {
|
|
relayConfig = relay.value;
|
|
} else {
|
|
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
targets: targets.filter((target) =>
|
|
target.registration.transport === "direct" ? Boolean(directAuth) : Boolean(relayConfig),
|
|
),
|
|
directAuth,
|
|
relayConfig,
|
|
};
|
|
}
|
|
|
|
async function clearStaleApnsRegistrationIfNeeded(params: {
|
|
nodeId: string;
|
|
registration: ApnsRegistration;
|
|
result: { status: number; reason?: string };
|
|
}): Promise<void> {
|
|
if (
|
|
shouldClearStoredApnsRegistration({
|
|
registration: params.registration,
|
|
result: params.result,
|
|
})
|
|
) {
|
|
await clearApnsRegistrationIfCurrent({
|
|
nodeId: params.nodeId,
|
|
registration: params.registration,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function sendRequestedPushes(params: {
|
|
request: ExecApprovalRequest;
|
|
plan: DeliveryPlan;
|
|
log: GatewayLikeLogger;
|
|
}): Promise<{ attempted: number; delivered: number }> {
|
|
const results = await Promise.allSettled(
|
|
params.plan.targets.map(async (target) => {
|
|
const result =
|
|
target.registration.transport === "direct"
|
|
? await sendApnsExecApprovalAlert({
|
|
registration: target.registration,
|
|
nodeId: target.nodeId,
|
|
approvalId: params.request.id,
|
|
auth: params.plan.directAuth!,
|
|
})
|
|
: await sendApnsExecApprovalAlert({
|
|
registration: target.registration,
|
|
nodeId: target.nodeId,
|
|
approvalId: params.request.id,
|
|
relayConfig: params.plan.relayConfig!,
|
|
});
|
|
await clearStaleApnsRegistrationIfNeeded({
|
|
nodeId: target.nodeId,
|
|
registration: target.registration,
|
|
result,
|
|
});
|
|
if (!result.ok) {
|
|
params.log.warn?.(
|
|
`exec approvals: iOS request push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
|
|
);
|
|
}
|
|
return { nodeId: target.nodeId, ok: result.ok };
|
|
}),
|
|
);
|
|
for (const result of results) {
|
|
if (result.status === "rejected") {
|
|
const message = formatErrorMessage(result.reason);
|
|
params.log.warn?.(`exec approvals: iOS request push threw error: ${message}`);
|
|
}
|
|
}
|
|
return {
|
|
attempted: params.plan.targets.length,
|
|
delivered: results.filter((result) => result.status === "fulfilled" && result.value.ok).length,
|
|
};
|
|
}
|
|
|
|
async function sendResolvedPushes(params: {
|
|
approvalId: string;
|
|
plan: DeliveryPlan;
|
|
log: GatewayLikeLogger;
|
|
}): Promise<void> {
|
|
await Promise.allSettled(
|
|
params.plan.targets.map(async (target) => {
|
|
const result =
|
|
target.registration.transport === "direct"
|
|
? await sendApnsExecApprovalResolvedWake({
|
|
registration: target.registration,
|
|
nodeId: target.nodeId,
|
|
approvalId: params.approvalId,
|
|
auth: params.plan.directAuth!,
|
|
})
|
|
: await sendApnsExecApprovalResolvedWake({
|
|
registration: target.registration,
|
|
nodeId: target.nodeId,
|
|
approvalId: params.approvalId,
|
|
relayConfig: params.plan.relayConfig!,
|
|
});
|
|
await clearStaleApnsRegistrationIfNeeded({
|
|
nodeId: target.nodeId,
|
|
registration: target.registration,
|
|
result,
|
|
});
|
|
if (!result.ok) {
|
|
params.log.warn?.(
|
|
`exec approvals: iOS cleanup push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) {
|
|
const approvalDeliveriesById = new Map<string, ApprovalDeliveryState>();
|
|
const pendingDeliveryStateById = new Map<string, Promise<ApprovalDeliveryState | null>>();
|
|
|
|
return {
|
|
async handleRequested(request: ExecApprovalRequest): Promise<boolean> {
|
|
const deliveryStatePromise = (async (): Promise<ApprovalDeliveryState | null> => {
|
|
const plan = await resolveDeliveryPlan({
|
|
requireApprovalScope: true,
|
|
log: params.log,
|
|
});
|
|
if (plan.targets.length === 0) {
|
|
approvalDeliveriesById.delete(request.id);
|
|
return null;
|
|
}
|
|
|
|
const deliveryState: ApprovalDeliveryState = {
|
|
nodeIds: plan.targets.map((target) => target.nodeId),
|
|
requestPushPromise: sendRequestedPushes({ request, plan, log: params.log }).catch(
|
|
(err) => {
|
|
const message = formatErrorMessage(err);
|
|
params.log.error?.(`exec approvals: iOS request push failed: ${message}`);
|
|
return { attempted: plan.targets.length, delivered: 0 };
|
|
},
|
|
),
|
|
};
|
|
approvalDeliveriesById.set(request.id, deliveryState);
|
|
return deliveryState;
|
|
})();
|
|
pendingDeliveryStateById.set(request.id, deliveryStatePromise);
|
|
|
|
const deliveryState = await deliveryStatePromise;
|
|
if (pendingDeliveryStateById.get(request.id) === deliveryStatePromise) {
|
|
pendingDeliveryStateById.delete(request.id);
|
|
}
|
|
if (!deliveryState) {
|
|
return false;
|
|
}
|
|
|
|
const { attempted, delivered } = await deliveryState.requestPushPromise;
|
|
if (attempted > 0 && delivered === 0) {
|
|
params.log.warn?.(
|
|
`exec approvals: iOS request push reached no devices approvalId=${request.id} attempted=${attempted}`,
|
|
);
|
|
if (
|
|
approvalDeliveriesById.get(request.id)?.requestPushPromise ===
|
|
deliveryState.requestPushPromise
|
|
) {
|
|
approvalDeliveriesById.delete(request.id);
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
|
|
const deliveryState =
|
|
approvalDeliveriesById.get(resolved.id) ??
|
|
(await pendingDeliveryStateById.get(resolved.id));
|
|
approvalDeliveriesById.delete(resolved.id);
|
|
pendingDeliveryStateById.delete(resolved.id);
|
|
if (!deliveryState?.nodeIds.length) {
|
|
params.log.debug?.(
|
|
`exec approvals: iOS cleanup push skipped approvalId=${resolved.id} reason=missing-targets`,
|
|
);
|
|
return;
|
|
}
|
|
await deliveryState.requestPushPromise;
|
|
const plan = await resolveDeliveryPlan({
|
|
requireApprovalScope: false,
|
|
explicitNodeIds: deliveryState.nodeIds,
|
|
log: params.log,
|
|
});
|
|
if (plan.targets.length === 0) {
|
|
return;
|
|
}
|
|
await sendResolvedPushes({
|
|
approvalId: resolved.id,
|
|
plan,
|
|
log: params.log,
|
|
});
|
|
},
|
|
|
|
async handleExpired(request: ExecApprovalRequest): Promise<void> {
|
|
const deliveryState =
|
|
approvalDeliveriesById.get(request.id) ?? (await pendingDeliveryStateById.get(request.id));
|
|
approvalDeliveriesById.delete(request.id);
|
|
pendingDeliveryStateById.delete(request.id);
|
|
if (!deliveryState?.nodeIds.length) {
|
|
params.log.debug?.(
|
|
`exec approvals: iOS cleanup push skipped approvalId=${request.id} reason=missing-targets`,
|
|
);
|
|
return;
|
|
}
|
|
await deliveryState.requestPushPromise;
|
|
const plan = await resolveDeliveryPlan({
|
|
requireApprovalScope: false,
|
|
explicitNodeIds: deliveryState.nodeIds,
|
|
log: params.log,
|
|
});
|
|
if (plan.targets.length === 0) {
|
|
return;
|
|
}
|
|
await sendResolvedPushes({
|
|
approvalId: request.id,
|
|
plan,
|
|
log: params.log,
|
|
});
|
|
},
|
|
};
|
|
}
|