Limit paired-device pairing actions to the caller device (#69375)

* fix(pairing): restrict paired-device pairing actions

* fix(pairing): close device authz review gaps

* docs(changelog): note device-pair scoping for non-admin paired devices (#69375)

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-20 11:50:39 -07:00
committed by GitHub
parent 82e6501f89
commit 5a12f30441
7 changed files with 528 additions and 19 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Context engine/plugins: stop rejecting third-party context engines whose `info.id` differs from the registered plugin slot id. The strict-match contract added in 2026.4.14 broke `lossless-claw` and other plugins whose internal engine id does not equal the slot id they are registered under, producing repeated `info.id must match registered id` lane failures on every turn. Fixes #66601. (#66678) Thanks @GodsBoy.
- Agents/compaction: rename embedded Pi compaction lifecycle events to `compaction_start` / `compaction_end` so OpenClaw stays aligned with `pi-coding-agent` 0.66.1 event naming. (#67713) Thanks @mpz4life.
- Security/dotenv: block all `OPENCLAW_*` keys from untrusted workspace `.env` files so workspace-local env loading fails closed for new runtime-control variables instead of silently inheriting them. (#473)
- Gateway/device pairing: restrict non-admin paired-device sessions (device-token auth) to their own pairing list, approve, and reject actions so a paired device cannot enumerate other devices or approve/reject pairing requests authored by another device. Admin and shared-secret operator sessions retain full visibility. (#69375) Thanks @eleqtrizit.
## 2026.4.20

View File

@@ -3,13 +3,21 @@ import { deviceHandlers } from "./devices.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const {
approveDevicePairingMock,
getPairedDeviceMock,
getPendingDevicePairingMock,
listDevicePairingMock,
removePairedDeviceMock,
rejectDevicePairingMock,
revokeDeviceTokenMock,
rotateDeviceTokenMock,
} = vi.hoisted(() => ({
approveDevicePairingMock: vi.fn(),
getPairedDeviceMock: vi.fn(),
getPendingDevicePairingMock: vi.fn(),
listDevicePairingMock: vi.fn(),
removePairedDeviceMock: vi.fn(),
rejectDevicePairingMock: vi.fn(),
revokeDeviceTokenMock: vi.fn(),
rotateDeviceTokenMock: vi.fn(),
}));
@@ -20,15 +28,26 @@ vi.mock("../../infra/device-pairing.js", async () => {
);
return {
...actual,
approveDevicePairing: approveDevicePairingMock,
getPairedDevice: getPairedDeviceMock,
getPendingDevicePairing: getPendingDevicePairingMock,
listDevicePairing: listDevicePairingMock,
removePairedDevice: removePairedDeviceMock,
rejectDevicePairing: rejectDevicePairingMock,
revokeDeviceToken: revokeDeviceTokenMock,
rotateDeviceToken: rotateDeviceTokenMock,
};
});
function createClient(scopes: string[], deviceId?: string) {
function createClient(
scopes: string[],
deviceId?: string,
opts?: {
isDeviceTokenAuth?: boolean;
},
) {
return {
...(opts?.isDeviceTokenAuth !== undefined ? { isDeviceTokenAuth: opts.isDeviceTokenAuth } : {}),
connect: {
scopes,
...(deviceId ? { device: { id: deviceId } } : {}),
@@ -48,6 +67,7 @@ function createOptions(
isWebchatConnect: () => false,
respond: vi.fn(),
context: {
broadcast: vi.fn(),
disconnectClientsForDevice: vi.fn(),
logGateway: {
debug: vi.fn(),
@@ -129,7 +149,7 @@ describe("deviceHandlers", () => {
const opts = createOptions(
"device.pair.remove",
{ deviceId: "device-2" },
{ client: createClient(["operator.pairing"], "device-1") },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.pair.remove"](opts);
@@ -147,7 +167,7 @@ describe("deviceHandlers", () => {
const opts = createOptions(
"device.pair.remove",
{ deviceId: " device-1 " },
{ client: createClient(["operator.pairing"], "device-1") },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.pair.remove"](opts);
@@ -189,7 +209,7 @@ describe("deviceHandlers", () => {
const opts = createOptions(
"device.token.revoke",
{ deviceId: "device-2", role: "operator" },
{ client: createClient(["operator.admin"], "device-1") },
{ client: createClient(["operator.admin"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.token.revoke"](opts);
@@ -210,7 +230,7 @@ describe("deviceHandlers", () => {
const opts = createOptions(
"device.token.revoke",
{ deviceId: " device-1 ", role: "operator" },
{ client: createClient(["operator.pairing"], "device-1") },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.token.revoke"](opts);
@@ -279,7 +299,7 @@ describe("deviceHandlers", () => {
role: "operator",
scopes: ["operator.pairing"],
},
{ client: createClient(["operator.pairing"], "device-1") },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.token.rotate"](opts);
@@ -346,4 +366,324 @@ describe("deviceHandlers", () => {
expect.objectContaining({ message: "unknown deviceId/role" }),
);
});
it("filters pairing list to the caller device for non-admin device sessions", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [
{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
{ requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
],
paired: [
{
deviceId: "device-1",
publicKey: "pk-1",
approvedAtMs: 100,
createdAtMs: 50,
},
{
deviceId: "device-2",
publicKey: "pk-2",
approvedAtMs: 200,
createdAtMs: 60,
},
],
});
const opts = createOptions(
"device.pair.list",
{},
{
client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }),
},
);
await deviceHandlers["device.pair.list"](opts);
expect(opts.respond).toHaveBeenCalledWith(
true,
{
pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 }],
paired: [
{
deviceId: "device-1",
publicKey: "pk-1",
approvedAtMs: 100,
createdAtMs: 50,
tokens: undefined,
},
],
},
undefined,
);
});
it("preserves the full pairing list for admin device sessions", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [
{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
{ requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
],
paired: [
{ deviceId: "device-1", publicKey: "pk-1", approvedAtMs: 100, createdAtMs: 50 },
{ deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 },
],
});
const opts = createOptions(
"device.pair.list",
{},
{
client: createClient(["operator.pairing", "operator.admin"], "device-1", {
isDeviceTokenAuth: true,
}),
},
);
await deviceHandlers["device.pair.list"](opts);
expect(opts.respond).toHaveBeenCalledWith(
true,
{
pending: [
{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
{ requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
],
paired: [
{
deviceId: "device-1",
publicKey: "pk-1",
approvedAtMs: 100,
createdAtMs: 50,
tokens: undefined,
},
{
deviceId: "device-2",
publicKey: "pk-2",
approvedAtMs: 200,
createdAtMs: 60,
tokens: undefined,
},
],
},
undefined,
);
});
it("preserves the full pairing list for non-device operator sessions", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 }],
paired: [{ deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 }],
});
const opts = createOptions(
"device.pair.list",
{},
{
client: createClient(["operator.pairing"]),
},
);
await deviceHandlers["device.pair.list"](opts);
expect(opts.respond).toHaveBeenCalledWith(
true,
{
pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 }],
paired: [
{
deviceId: "device-2",
publicKey: "pk-2",
approvedAtMs: 200,
createdAtMs: 60,
tokens: undefined,
},
],
},
undefined,
);
});
it("preserves the full pairing list for shared-auth sessions carrying a device identity", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [
{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
{ requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
],
paired: [{ deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 }],
});
const opts = createOptions(
"device.pair.list",
{},
{
client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: false }),
},
);
await deviceHandlers["device.pair.list"](opts);
expect(opts.respond).toHaveBeenCalledWith(
true,
{
pending: [
{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
{ requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
],
paired: [
{
deviceId: "device-2",
publicKey: "pk-2",
approvedAtMs: 200,
createdAtMs: 60,
tokens: undefined,
},
],
},
undefined,
);
});
it("rejects approving another device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-2",
deviceId: "device-2",
publicKey: "pk-2",
ts: 100,
});
const opts = createOptions(
"device.pair.approve",
{ requestId: "req-2" },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.pair.approve"](opts);
expect(approveDevicePairingMock).not.toHaveBeenCalled();
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "device pairing approval denied" }),
);
});
it("allows approving the caller device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-1",
deviceId: " device-1 ",
publicKey: "pk-1",
ts: 100,
});
approveDevicePairingMock.mockResolvedValue({
status: "approved",
requestId: "req-1",
device: {
deviceId: "device-1",
publicKey: "pk-1",
approvedAtMs: 100,
createdAtMs: 50,
},
});
const opts = createOptions(
"device.pair.approve",
{ requestId: "req-1" },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
await deviceHandlers["device.pair.approve"](opts);
expect(approveDevicePairingMock).toHaveBeenCalledWith("req-1", {
callerScopes: ["operator.pairing"],
});
expect(opts.respond).toHaveBeenCalledWith(
true,
{
requestId: "req-1",
device: {
deviceId: "device-1",
publicKey: "pk-1",
approvedAtMs: 100,
createdAtMs: 50,
tokens: undefined,
},
},
undefined,
);
});
it("rejects rejecting another device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-2",
deviceId: "device-2",
publicKey: "pk-2",
ts: 100,
});
const opts = createOptions(
"device.pair.reject",
{ requestId: "req-2" },
{
client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }),
},
);
await deviceHandlers["device.pair.reject"](opts);
expect(rejectDevicePairingMock).not.toHaveBeenCalled();
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "device pairing rejection denied" }),
);
});
it("allows rejecting the caller device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-1",
deviceId: " device-1 ",
publicKey: "pk-1",
ts: 100,
});
rejectDevicePairingMock.mockResolvedValue({
requestId: "req-1",
deviceId: "device-1",
rejectedAtMs: 123,
});
const opts = createOptions(
"device.pair.reject",
{ requestId: "req-1" },
{
client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }),
},
);
await deviceHandlers["device.pair.reject"](opts);
expect(rejectDevicePairingMock).toHaveBeenCalledWith("req-1");
expect(opts.respond).toHaveBeenCalledWith(
true,
{ requestId: "req-1", deviceId: "device-1", rejectedAtMs: 123 },
undefined,
);
});
it("allows admins to reject another device", async () => {
rejectDevicePairingMock.mockResolvedValue({
requestId: "req-2",
deviceId: "device-2",
rejectedAtMs: 456,
});
const opts = createOptions(
"device.pair.reject",
{ requestId: "req-2" },
{
client: createClient(["operator.pairing", "operator.admin"], "device-1", {
isDeviceTokenAuth: true,
}),
},
);
await deviceHandlers["device.pair.reject"](opts);
expect(rejectDevicePairingMock).toHaveBeenCalledWith("req-2");
expect(opts.respond).toHaveBeenCalledWith(
true,
{ requestId: "req-2", deviceId: "device-2", rejectedAtMs: 456 },
undefined,
);
});
});

View File

@@ -2,6 +2,7 @@ import {
approveDevicePairing,
formatDevicePairingForbiddenMessage,
getPairedDevice,
getPendingDevicePairing,
listApprovedPairedDeviceRoles,
listDevicePairing,
removePairedDevice,
@@ -34,13 +35,19 @@ type DeviceTokenRotateTarget = {
normalizedRole: string;
};
type DeviceManagementAuthz = {
type DeviceSessionAuthz = {
callerDeviceId: string | null;
callerScopes: string[];
isAdminCaller: boolean;
};
type DeviceManagementAuthz = DeviceSessionAuthz & {
normalizedTargetDeviceId: string;
};
const DEVICE_PAIR_APPROVAL_DENIED_MESSAGE = "device pairing approval denied";
const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied";
function redactPairedDevice(
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
) {
@@ -91,17 +98,23 @@ function resolveDeviceManagementAuthz(
client: GatewayClient | null,
targetDeviceId: string,
): DeviceManagementAuthz {
return {
...resolveDeviceSessionAuthz(client),
normalizedTargetDeviceId: targetDeviceId.trim(),
};
}
function resolveDeviceSessionAuthz(client: GatewayClient | null): DeviceSessionAuthz {
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const rawCallerDeviceId = client?.connect?.device?.id;
const callerDeviceId =
typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim()
client?.isDeviceTokenAuth && typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim()
? rawCallerDeviceId.trim()
: null;
return {
callerDeviceId,
callerScopes,
isAdminCaller: callerScopes.includes("operator.admin"),
normalizedTargetDeviceId: targetDeviceId.trim(),
};
}
@@ -114,7 +127,7 @@ function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean {
}
export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond }) => {
"device.pair.list": async ({ params, respond, client }) => {
if (!validateDevicePairListParams(params)) {
respond(
false,
@@ -129,11 +142,21 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
const list = await listDevicePairing();
const authz = resolveDeviceSessionAuthz(client);
const visibleList =
authz.callerDeviceId && !authz.isAdminCaller
? {
pending: list.pending.filter(
(request) => request.deviceId.trim() === authz.callerDeviceId,
),
paired: list.paired.filter((device) => device.deviceId.trim() === authz.callerDeviceId),
}
: list;
respond(
true,
{
pending: list.pending,
paired: list.paired.map((device) => redactPairedDevice(device)),
pending: visibleList.pending,
paired: visibleList.paired.map((device) => redactPairedDevice(device)),
},
undefined,
);
@@ -153,8 +176,30 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
const { requestId } = params as { requestId: string };
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const approved = await approveDevicePairing(requestId, { callerScopes });
const authz = resolveDeviceSessionAuthz(client);
if (authz.callerDeviceId && !authz.isAdminCaller) {
const pending = await getPendingDevicePairing(requestId);
if (!pending) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE),
);
return;
}
if (pending.deviceId.trim() !== authz.callerDeviceId) {
context.logGateway.warn(
`device pairing approval denied request=${requestId} reason=device-ownership-mismatch`,
);
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE),
);
return;
}
}
const approved = await approveDevicePairing(requestId, { callerScopes: authz.callerScopes });
if (!approved) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
@@ -182,7 +227,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
);
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
},
"device.pair.reject": async ({ params, respond, context }) => {
"device.pair.reject": async ({ params, respond, context, client }) => {
if (!validateDevicePairRejectParams(params)) {
respond(
false,
@@ -197,6 +242,29 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
const { requestId } = params as { requestId: string };
const authz = resolveDeviceSessionAuthz(client);
if (authz.callerDeviceId && !authz.isAdminCaller) {
const pending = await getPendingDevicePairing(requestId);
if (!pending) {
respond(
false,
undefined,
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`,
);
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_REJECTION_DENIED_MESSAGE),
);
return;
}
}
const rejected = await rejectDevicePairing(requestId);
if (!rejected) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));

View File

@@ -23,6 +23,7 @@ export type GatewayClient = {
canvasHostUrl?: string;
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
isDeviceTokenAuth?: boolean;
internal?: {
allowModelOverride?: boolean;
};

View File

@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import {
getPairedDevice,
getPendingDevicePairing,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
issueOperatorToken,
@@ -26,13 +30,13 @@ describe("gateway device.pair.approve caller scope guard", () => {
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
const pending = loadDeviceIdentity("approve-target");
const approverIdentity = loadDeviceIdentity("approve-attacker");
let pairingWs: WebSocket | undefined;
try {
const request = await requestDevicePairing({
deviceId: pending.identity.deviceId,
publicKey: pending.publicKey,
deviceId: approverIdentity.identity.deviceId,
publicKey: approverIdentity.publicKey,
role: "operator",
scopes: ["operator.admin"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
@@ -53,6 +57,53 @@ describe("gateway device.pair.approve caller scope guard", () => {
expect(approve.ok).toBe(false);
expect(approve.error?.message).toBe("missing scope: operator.admin");
const paired = await getPairedDevice(approverIdentity.identity.deviceId);
expect(paired).not.toBeNull();
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
} finally {
pairingWs?.close();
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
test("rejects approving another device from a non-admin paired-device session", async () => {
const started = await startServerWithClient("secret");
const approver = await issueOperatorToken({
name: "approve-cross-device-attacker",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
const pending = loadDeviceIdentity("approve-cross-device-target");
let pairingWs: WebSocket | undefined;
try {
const request = await requestDevicePairing({
deviceId: pending.identity.deviceId,
publicKey: pending.publicKey,
role: "operator",
scopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
pairingWs = await openTrackedWs(started.port);
await connectOk(pairingWs, {
skipDefaultAuth: true,
deviceToken: approver.token,
deviceIdentityPath: approver.identityPath,
scopes: ["operator.pairing"],
});
const approve = await rpcReq(pairingWs, "device.pair.approve", {
requestId: request.request.requestId,
});
expect(approve.ok).toBe(false);
expect(approve.error?.message).toBe("device pairing approval denied");
const paired = await getPairedDevice(pending.identity.deviceId);
expect(paired).toBeNull();
} finally {
@@ -62,4 +113,50 @@ describe("gateway device.pair.approve caller scope guard", () => {
started.envSnapshot.restore();
}
});
test("rejects rejecting another device from a non-admin paired-device session", async () => {
const started = await startServerWithClient("secret");
const attacker = await issueOperatorToken({
name: "reject-cross-device-attacker",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
const pending = loadDeviceIdentity("reject-cross-device-target");
let pairingWs: WebSocket | undefined;
try {
const request = await requestDevicePairing({
deviceId: pending.identity.deviceId,
publicKey: pending.publicKey,
role: "operator",
scopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
pairingWs = await openTrackedWs(started.port);
await connectOk(pairingWs, {
skipDefaultAuth: true,
deviceToken: attacker.token,
deviceIdentityPath: attacker.identityPath,
scopes: ["operator.pairing"],
});
const reject = await rpcReq(pairingWs, "device.pair.reject", {
requestId: request.request.requestId,
});
expect(reject.ok).toBe(false);
expect(reject.error?.message).toBe("device pairing rejection denied");
const stillPending = await getPendingDevicePairing(request.request.requestId);
expect(stillPending).not.toBeNull();
} finally {
pairingWs?.close();
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
});

View File

@@ -1303,6 +1303,7 @@ export function attachGatewayWsMessageHandler(params: {
socket,
connect: connectParams,
connId,
isDeviceTokenAuth: authMethod === "device-token",
usesSharedGatewayAuth,
sharedGatewaySessionGeneration,
presenceKey,

View File

@@ -5,6 +5,7 @@ export type GatewayWsClient = {
socket: WebSocket;
connect: ConnectParams;
connId: string;
isDeviceTokenAuth?: boolean;
usesSharedGatewayAuth: boolean;
sharedGatewaySessionGeneration?: string;
presenceKey?: string;