mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-23 23:22:32 +00:00
Pairing: forward caller scopes during approval (#55950)
* Pairing: require caller scopes on approvals * Gateway: reject forbidden silent pairing results
This commit is contained in:
@@ -510,7 +510,43 @@ describe("device-pair /pair approve", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
|
||||
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
|
||||
callerScopes: ["operator.write", "operator.pairing"],
|
||||
});
|
||||
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
|
||||
});
|
||||
|
||||
it("rejects approvals above the caller scopes", async () => {
|
||||
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
||||
pending: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "victim-phone",
|
||||
publicKey: "victim-public-key",
|
||||
displayName: "Victim Phone",
|
||||
platform: "ios",
|
||||
ts: Date.now(),
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.admin",
|
||||
});
|
||||
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "approve latest",
|
||||
commandBody: "/pair approve latest",
|
||||
gatewayClientScopes: ["operator.write", "operator.pairing"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "⚠️ Cannot approve a request requiring operator.admin.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -611,10 +611,15 @@ export default definePluginEntry({
|
||||
if (!pending) {
|
||||
return { text: "Pairing request not found." };
|
||||
}
|
||||
const approved = await approveDevicePairing(pending.requestId);
|
||||
const approved = await approveDevicePairing(pending.requestId, {
|
||||
callerScopes: gatewayClientScopes ?? [],
|
||||
});
|
||||
if (!approved) {
|
||||
return { text: "Pairing request not found." };
|
||||
}
|
||||
if (approved.status === "forbidden") {
|
||||
return { text: `⚠️ Cannot approve a request requiring ${approved.missingScope}.` };
|
||||
}
|
||||
const label = approved.device.displayName?.trim() || approved.device.deviceId;
|
||||
const platform = approved.device.platform?.trim();
|
||||
const platformLabel = platform ? ` (${platform})` : "";
|
||||
|
||||
@@ -280,7 +280,9 @@ describe("devices cli local fallback", () => {
|
||||
|
||||
await runDevicesApprove(["--latest"]);
|
||||
|
||||
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest");
|
||||
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest", {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
|
||||
});
|
||||
|
||||
@@ -159,10 +159,17 @@ async function approvePairingWithFallback(
|
||||
if (opts.json !== true) {
|
||||
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
|
||||
}
|
||||
const approved = await approveDevicePairing(requestId);
|
||||
const approved = await approveDevicePairing(requestId, {
|
||||
// Local CLI fallback already assumes direct machine access; treat it as an
|
||||
// explicit admin approval path instead of relying on missing caller scopes.
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
if (!approved) {
|
||||
return null;
|
||||
}
|
||||
if (approved.status === "forbidden") {
|
||||
throw new Error(`missing scope: ${approved.missingScope}`, { cause: error });
|
||||
}
|
||||
return {
|
||||
requestId,
|
||||
device: redactLocalPairedDevice(approved.device),
|
||||
|
||||
@@ -54,7 +54,9 @@ export async function pairDeviceIdentity(params: {
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
});
|
||||
await approveDevicePairing(request.request.requestId);
|
||||
await approveDevicePairing(request.request.requestId, {
|
||||
callerScopes: params.scopes,
|
||||
});
|
||||
return loaded;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,9 @@ describe("gateway auth compatibility baseline", () => {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
await approveDevicePairing(pending.request.requestId);
|
||||
await approveDevicePairing(pending.request.requestId, {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
|
||||
@@ -201,7 +201,9 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
displayName: params.displayName,
|
||||
platform: params.platform,
|
||||
});
|
||||
await approveDevicePairing(seeded.request.requestId);
|
||||
await approveDevicePairing(seeded.request.requestId, {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
return { identityPath, identity: { deviceId: identity.deviceId } };
|
||||
};
|
||||
|
||||
@@ -761,7 +763,9 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
if (!pendingForTestDevice[0]) {
|
||||
throw new Error("expected pending pairing request");
|
||||
}
|
||||
await approveDevicePairing(pendingForTestDevice[0].requestId);
|
||||
await approveDevicePairing(pendingForTestDevice[0].requestId, {
|
||||
callerScopes: pendingForTestDevice[0].scopes ?? ["operator.admin"],
|
||||
});
|
||||
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
@@ -843,7 +847,9 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
displayName: "legacy-test",
|
||||
platform: "test",
|
||||
});
|
||||
await approveDevicePairing(pending.request.requestId);
|
||||
await approveDevicePairing(pending.request.requestId, {
|
||||
callerScopes: pending.request.scopes ?? ["operator.admin"],
|
||||
});
|
||||
|
||||
await stripPairedMetadataRolesAndScopes(deviceId);
|
||||
|
||||
|
||||
@@ -212,7 +212,9 @@ async function approvePendingPairingIfNeeded() {
|
||||
const pending = list.pending.at(0);
|
||||
expect(pending?.requestId).toBeDefined();
|
||||
if (pending?.requestId) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
await approveDevicePairing(pending.requestId, {
|
||||
callerScopes: pending.scopes ?? ["operator.admin"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,9 @@ describe("node.invoke approval bypass", () => {
|
||||
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const list = await listDevicePairing();
|
||||
for (const pending of list.pending) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
await approveDevicePairing(pending.requestId, {
|
||||
callerScopes: pending.scopes ?? ["operator.admin"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ const approveAllPendingPairings = async () => {
|
||||
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const list = await listDevicePairing();
|
||||
for (const pending of list.pending) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
await approveDevicePairing(pending.requestId, {
|
||||
callerScopes: pending.scopes ?? ["operator.admin"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -94,7 +94,9 @@ beforeAll(async () => {
|
||||
scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"],
|
||||
silent: false,
|
||||
});
|
||||
await approveDevicePairing(pending.request.requestId);
|
||||
await approveDevicePairing(pending.request.requestId, {
|
||||
callerScopes: pending.request.scopes ?? ["operator.admin"],
|
||||
});
|
||||
server = await startGatewayServer(gatewayPort);
|
||||
});
|
||||
|
||||
|
||||
@@ -806,8 +806,10 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return replacementPending?.requestId;
|
||||
};
|
||||
if (pairing.request.silent === true) {
|
||||
approved = await approveDevicePairing(pairing.request.requestId);
|
||||
if (approved) {
|
||||
approved = await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: scopes,
|
||||
});
|
||||
if (approved?.status === "approved") {
|
||||
logGateway.info(
|
||||
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||
);
|
||||
@@ -839,7 +841,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
// Re-resolve: another connection may have superseded/approved the request since we created it
|
||||
recoveryRequestId = await resolveLivePendingRequestId();
|
||||
if (!(pairing.request.silent === true && (approved || resolvedByConcurrentApproval))) {
|
||||
if (
|
||||
!(
|
||||
pairing.request.silent === true &&
|
||||
(approved?.status === "approved" || resolvedByConcurrentApproval)
|
||||
)
|
||||
) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("pairing-required", {
|
||||
deviceId: device.id,
|
||||
|
||||
@@ -28,7 +28,7 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) {
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
await approveDevicePairing(request.request.requestId, baseDir);
|
||||
await approveDevicePairing(request.request.requestId, { callerScopes: scopes }, baseDir);
|
||||
}
|
||||
|
||||
async function setupOperatorToken(scopes: string[]) {
|
||||
@@ -158,7 +158,11 @@ describe("device pairing tokens", () => {
|
||||
expect(list.pending).toHaveLength(1);
|
||||
expect(list.pending[0]?.requestId).toBe(second.request.requestId);
|
||||
|
||||
await approveDevicePairing(second.request.requestId, baseDir);
|
||||
await approveDevicePairing(
|
||||
second.request.requestId,
|
||||
{ callerScopes: ["operator.read", "operator.write"] },
|
||||
baseDir,
|
||||
);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"]));
|
||||
@@ -234,13 +238,50 @@ describe("device pairing tokens", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
|
||||
await approveDevicePairing(first.request.requestId, baseDir);
|
||||
await approveDevicePairing(
|
||||
first.request.requestId,
|
||||
{ callerScopes: ["operator.read"] },
|
||||
baseDir,
|
||||
);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.scopes).toEqual(["operator.read"]);
|
||||
expect(paired?.approvedScopes).toEqual(["operator.read"]);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
});
|
||||
|
||||
test("fails closed for operator approvals when caller scopes are omitted", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(approveDevicePairing(request.request.requestId, baseDir)).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
approveDevicePairing(
|
||||
request.request.requestId,
|
||||
{
|
||||
callerScopes: ["operator.admin"],
|
||||
},
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: "approved",
|
||||
requestId: request.request.requestId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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"]);
|
||||
@@ -289,7 +330,11 @@ describe("device pairing tokens", () => {
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
await approveDevicePairing(repair.request.requestId, baseDir);
|
||||
await approveDevicePairing(
|
||||
repair.request.requestId,
|
||||
{ callerScopes: ["operator.admin"] },
|
||||
baseDir,
|
||||
);
|
||||
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.scopes).toEqual(["operator.admin"]);
|
||||
|
||||
@@ -85,11 +85,6 @@ export type ApproveDevicePairingResult =
|
||||
| { status: "forbidden"; missingScope: string }
|
||||
| null;
|
||||
|
||||
type ApprovedDevicePairingResult = Extract<
|
||||
NonNullable<ApproveDevicePairingResult>,
|
||||
{ status: "approved" }
|
||||
>;
|
||||
|
||||
type DevicePairingStateFile = {
|
||||
pendingById: Record<string, DevicePairingPendingRequest>;
|
||||
pairedByDeviceId: Record<string, PairedDevice>;
|
||||
@@ -445,7 +440,7 @@ export async function requestDevicePairing(
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<ApprovedDevicePairingResult | null>;
|
||||
): Promise<ApproveDevicePairingResult>;
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
options: { callerScopes?: readonly string[] },
|
||||
@@ -468,10 +463,16 @@ export async function approveDevicePairing(
|
||||
return null;
|
||||
}
|
||||
const approvalRole = resolvePendingApprovalRole(pending);
|
||||
if (approvalRole && options?.callerScopes) {
|
||||
if (approvalRole) {
|
||||
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
|
||||
scope.startsWith(OPERATOR_SCOPE_PREFIX),
|
||||
);
|
||||
if (!options?.callerScopes) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
missingScope: requestedOperatorScopes[0] ?? "callerScopes-required",
|
||||
};
|
||||
}
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: approvalRole,
|
||||
requestedScopes: requestedOperatorScopes,
|
||||
|
||||
Reference in New Issue
Block a user