diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 234396082f0..0c7598c5e1e 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -21,6 +21,7 @@ import { rpcReq, startRateLimitedTokenServerWithPairedDeviceToken, startGatewayServer, + startServer, startServerWithClient, TEST_OPERATOR_CLIENT, testState, @@ -142,6 +143,14 @@ export function registerControlUiAndPairingSuite(): void { return { server, ws, port, prevToken, identityPath, identity, client }; }; + const startControlUiServerWithOperatorIdentity = async ( + identityPrefix = "openclaw-device-scope-", + ) => { + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix); + return { server, port, prevToken, identityPath, identity, client }; + }; + const withControlUiGatewayServer = async ( fn: (ctx: { port: number; @@ -163,6 +172,13 @@ export function registerControlUiAndPairingSuite(): void { }); }; + const startControlUiServer = async (token?: string, opts?: Parameters[1]) => { + return await startServer(token, { + ...opts, + controlUiEnabled: true, + }); + }; + const getRequiredPairedMetadata = ( paired: Record>, deviceId: string, @@ -631,9 +647,8 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken, identityPath, identity, client } = - await startServerWithOperatorIdentity(); - ws.close(); + const { server, port, prevToken, identityPath, identity, client } = + await startControlUiServerWithOperatorIdentity(); const wsRemoteRead = await openWs(port, { host: "gateway.example" }); const initialNonce = await readConnectChallengeNonce(wsRemoteRead); @@ -686,7 +701,7 @@ export function registerControlUiAndPairingSuite(): void { test("requires approval for loopback scope upgrades for control ui clients", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identity, identityPath } = await seedApprovedOperatorReadPairing({ identityPrefix: "openclaw-device-token-scope-", clientId: CONTROL_UI_CLIENT.id, @@ -695,8 +710,6 @@ export function registerControlUiAndPairingSuite(): void { platform: CONTROL_UI_CLIENT.platform, }); - ws.close(); - const ws2 = await openWs(port, { origin: originForPort(port) }); const nonce2 = await readConnectChallengeNonce(ws2); const upgraded = await connectReq(ws2, { @@ -730,8 +743,7 @@ export function registerControlUiAndPairingSuite(): void { const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-node-", @@ -901,8 +913,7 @@ export function registerControlUiAndPairingSuite(): void { const reconcileSpy = vi .spyOn(reconcileModule, "reconcileNodePairingOnConnect") .mockRejectedValueOnce(new Error("boom")); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, client } = await createOperatorIdentityFixture( "openclaw-bootstrap-reconcile-fail-", @@ -960,8 +971,7 @@ export function registerControlUiAndPairingSuite(): void { const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-role-upgrade-", @@ -1031,8 +1041,7 @@ export function registerControlUiAndPairingSuite(): void { test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity, client } = await createOperatorIdentityFixture( "openclaw-bootstrap-operator-", @@ -1076,8 +1085,7 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves local-direct node pairing, then queues operator scope approval", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity, client } = await createOperatorIdentityFixture("openclaw-device-scope-"); const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { @@ -1209,11 +1217,9 @@ export function registerControlUiAndPairingSuite(): void { await stripPairedMetadataRolesAndScopes(deviceId); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); let ws2: WebSocket | undefined; try { - ws.close(); - const wsReconnect = await openWs(port); ws2 = wsReconnect; const reconnectNonce = await readConnectChallengeNonce(wsReconnect); @@ -1239,7 +1245,6 @@ export function registerControlUiAndPairingSuite(): void { } finally { await server.close(); restoreGatewayToken(prevToken); - ws.close(); ws2?.close(); } }); @@ -1256,13 +1261,11 @@ export function registerControlUiAndPairingSuite(): void { await stripPairedMetadataRolesAndScopes(identity.deviceId); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); let ws2: WebSocket | undefined; try { const client = { ...TEST_OPERATOR_CLIENT }; - ws.close(); - const wsUpgrade = await openWs(port); ws2 = wsUpgrade; const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); @@ -1290,7 +1293,6 @@ export function registerControlUiAndPairingSuite(): void { expect(repaired?.role).toBe("operator"); expect(repaired?.approvedScopes ?? []).toEqual(expect.arrayContaining(["operator.read"])); } finally { - ws.close(); ws2?.close(); await server.close(); restoreGatewayToken(prevToken); @@ -1337,8 +1339,7 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" }); try { const { identity, identityPath } = @@ -1374,8 +1375,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows gateway backend clients on loopback even with a remote-looking host header", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsRemoteLike = await openWs(port, { host: "gateway.example" }); try { const remoteLikeBackend = await connectReq(wsRemoteLike, { @@ -1391,8 +1391,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows gateway backend clients on loopback with a private host header", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsPrivateHost = await openWs(port, { host: "172.17.0.2:18789" }); try { const remoteLikeBackend = await connectReq(wsPrivateHost, { @@ -1408,8 +1407,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows CLI clients on loopback even when the host header is not private-or-loopback", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsRemoteLike = await openWs(port, { host: "gateway.example" }); try { const remoteCli = await connectReq(wsRemoteLike, { diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index 3b22ec86e00..e57523c11f4 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -15,6 +15,7 @@ import { onceMessage, rpcReq, startGatewayServer, + startServer, startServerWithClient, trackConnectChallengeNonce, testTailscaleWhois, @@ -395,6 +396,7 @@ export { sendRawConnectReq, startGatewayServer, startRateLimitedTokenServerWithPairedDeviceToken, + startServer, startServerWithClient, TEST_OPERATOR_CLIENT, trackConnectChallengeNonce, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index cff85371b1b..4b53d99a88a 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -712,11 +712,7 @@ export async function createGatewaySuiteHarness(opts?: { }; } -export async function startServerWithClient( - token?: string, - opts?: GatewayServerOptions & { wsHeaders?: Record }, -) { - const { wsHeaders, ...gatewayOpts } = opts ?? {}; +export async function startServer(token?: string, opts?: GatewayServerOptions) { let port = await getFreePort(); const envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); const prev = process.env.OPENCLAW_GATEWAY_TOKEN; @@ -735,19 +731,29 @@ export async function startServerWithClient( } const resolvedGatewayOpts: GatewayServerOptions = - fallbackToken && !gatewayOpts.auth + fallbackToken && !opts?.auth ? { - ...gatewayOpts, + ...opts, auth: { mode: "token", token: fallbackToken }, } - : gatewayOpts; + : (opts ?? {}); const started = await startGatewayServerWithRetries({ port, opts: resolvedGatewayOpts }); port = started.port; const server = started.server; + return { server, port, prevToken: prev, envSnapshot }; +} + +export async function startServerWithClient( + token?: string, + opts?: GatewayServerOptions & { wsHeaders?: Record }, +) { + const { wsHeaders, ...gatewayOpts } = opts ?? {}; + const started = await startServer(token, gatewayOpts); + const { server, port, prevToken, envSnapshot } = started; const ws = await openTrackedWebSocket({ port, headers: wsHeaders }); - return { server, ws, port, prevToken: prev, envSnapshot }; + return { server, ws, port, prevToken, envSnapshot }; } export async function startConnectedServerWithClient(