Skip to content

Commit eae8774

Browse files
committed
fix(gateway): auto-approve loopback scope-upgrade pairs
1 parent 844d84a commit eae8774

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

src/gateway/server.auth.e2e.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ describe("gateway server auth/connect", () => {
903903
}
904904
});
905905

906-
test("requires pairing for scope upgrades", async () => {
906+
test("requires pairing for scope upgrades from non-local host", async () => {
907907
const { mkdtemp } = await import("node:fs/promises");
908908
const { tmpdir } = await import("node:os");
909909
const { join } = await import("node:path");
@@ -953,7 +953,9 @@ describe("gateway server auth/connect", () => {
953953

954954
ws.close();
955955

956-
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
956+
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`, {
957+
headers: { host: "gateway.public.example" },
958+
});
957959
await new Promise<void>((resolve) => ws2.once("open", resolve));
958960
const res = await connectReq(ws2, {
959961
token: "secret",
@@ -984,6 +986,75 @@ describe("gateway server auth/connect", () => {
984986
restoreGatewayToken(prevToken);
985987
});
986988

989+
test("allows loopback paired-device scope upgrades without interactive pairing", async () => {
990+
const { mkdtemp } = await import("node:fs/promises");
991+
const { tmpdir } = await import("node:os");
992+
const { join } = await import("node:path");
993+
const { buildDeviceAuthPayload } = await import("./device-auth.js");
994+
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
995+
await import("../infra/device-identity.js");
996+
const { getPairedDevice } = await import("../infra/device-pairing.js");
997+
const { server, ws, port, prevToken } = await startServerWithClient("secret");
998+
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-loopback-"));
999+
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
1000+
const client = {
1001+
id: GATEWAY_CLIENT_NAMES.TEST,
1002+
version: "1.0.0",
1003+
platform: "test",
1004+
mode: GATEWAY_CLIENT_MODES.TEST,
1005+
};
1006+
const buildDevice = (scopes: string[]) => {
1007+
const signedAtMs = Date.now();
1008+
const payload = buildDeviceAuthPayload({
1009+
deviceId: identity.deviceId,
1010+
clientId: client.id,
1011+
clientMode: client.mode,
1012+
role: "operator",
1013+
scopes,
1014+
signedAtMs,
1015+
token: "secret",
1016+
});
1017+
return {
1018+
id: identity.deviceId,
1019+
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
1020+
signature: signDevicePayload(identity.privateKeyPem, payload),
1021+
signedAt: signedAtMs,
1022+
};
1023+
};
1024+
1025+
const initial = await connectReq(ws, {
1026+
token: "secret",
1027+
scopes: ["operator.read"],
1028+
client,
1029+
device: buildDevice(["operator.read"]),
1030+
});
1031+
if (!initial.ok) {
1032+
await approvePendingPairingIfNeeded();
1033+
}
1034+
1035+
let paired = await getPairedDevice(identity.deviceId);
1036+
expect(paired?.scopes).toContain("operator.read");
1037+
1038+
ws.close();
1039+
1040+
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
1041+
await new Promise<void>((resolve) => ws2.once("open", resolve));
1042+
const upgrade = await connectReq(ws2, {
1043+
token: "secret",
1044+
scopes: ["operator.admin"],
1045+
client,
1046+
device: buildDevice(["operator.admin"]),
1047+
});
1048+
expect(upgrade.ok).toBe(true);
1049+
1050+
paired = await getPairedDevice(identity.deviceId);
1051+
expect(paired?.scopes).toContain("operator.admin");
1052+
1053+
ws2.close();
1054+
await server.close();
1055+
restoreGatewayToken(prevToken);
1056+
});
1057+
9871058
test("single approval captures pending node and operator roles for the same device", async () => {
9881059
const { mkdtemp } = await import("node:fs/promises");
9891060
const { tmpdir } = await import("node:os");

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export function attachGatewayWsMessageHandler(params: {
152152
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
153153
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
154154
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
155+
const isLocalLoopbackClient = isLoopbackAddress(remoteAddr);
155156
const reportedClientIp =
156157
isLocalClient || hasUntrustedProxyHeaders
157158
? undefined
@@ -654,6 +655,8 @@ export function attachGatewayWsMessageHandler(params: {
654655
const requirePairing = async (
655656
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
656657
) => {
658+
const isSilentPairing =
659+
isLocalLoopbackClient || (isLocalClient && reason === "not-paired");
657660
const pairing = await requestDevicePairing({
658661
deviceId: device.id,
659662
publicKey: devicePublicKey,
@@ -664,7 +667,7 @@ export function attachGatewayWsMessageHandler(params: {
664667
role,
665668
scopes,
666669
remoteIp: reportedClientIp,
667-
silent: isLocalClient && reason === "not-paired",
670+
silent: isSilentPairing,
668671
});
669672
const context = buildRequestContext();
670673
if (pairing.request.silent === true) {

0 commit comments

Comments
 (0)