From f87f23c483b4c5130f42f2451f0b03e36ea0effe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 22 Sep 2026 02:56:28 -0700 Subject: [PATCH] test(gateway): hold SSH approval until pending assertions finish The concurrent pairing owner can legitimately admit an immediate SSH approval on the first handshake. Keep the probe pending through the rejection and retry-hint assertions, then release it and retain the approval and capability checks. The original full-file order reproduced the CI failure. All 50 focused tests pass after the fixture repair (119.25s wrapper); targeted lint and independent review pass. Runtime logic and deadlines are unchanged. --- .../server.node-pairing-ssh-verify.test.ts | 61 ++++++++++--------- .../ws-connection/connect-device-pairing.ts | 4 +- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/gateway/server.node-pairing-ssh-verify.test.ts b/src/gateway/server.node-pairing-ssh-verify.test.ts index b7c936057839..438319bfd2b2 100644 --- a/src/gateway/server.node-pairing-ssh-verify.test.ts +++ b/src/gateway/server.node-pairing-ssh-verify.test.ts @@ -169,37 +169,42 @@ describeWithLanNodePairingServer("gateway ssh-verified node pairing auto-approve await attemptWithSshVerify({ identityName: "ssh-verify-key-match", run: async ({ lanIp, loaded, connectNode }) => { - probeMock.mockImplementation(async () => ({ - status: "ok", - stdout: `motd noise\n{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`, - })); + const probe = createDeferred(); + probeMock.mockImplementation(() => probe.promise); + try { + const first = await connectNode(); + expect(first.ok).toBe(false); + const details = first.error?.details as PairingRequiredDetails | undefined; + // The node must keep retrying while the detached probe can still land. + expect(details?.recommendedNextStep).toBe("wait_then_retry"); + expect(details?.pauseReconnect).toBe(false); + probe.resolve({ + status: "ok", + stdout: `motd noise\n{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`, + }); - const first = await connectNode(); - expect(first.ok).toBe(false); - const details = first.error?.details as PairingRequiredDetails | undefined; - // The node must keep retrying while the detached probe can still land. - expect(details?.recommendedNextStep).toBe("wait_then_retry"); - expect(details?.pauseReconnect).toBe(false); + const paired = await waitFor(async () => { + const record = await getPairedDevice(loaded.identity.deviceId); + // Wait for the ssh-verified provenance specifically: the approval + // and its read-back must survive the SQLite round-trip. + return record?.approvedVia === "ssh-verified" ? record : null; + }, "ssh-verified device approval"); + expect(paired.approvedVia).toBe("ssh-verified"); + expect(paired.publicKey).toBe(loaded.publicKey); + expect(probeMock).toHaveBeenCalledWith(expect.objectContaining({ host: lanIp })); - const paired = await waitFor(async () => { + const second = await connectNode(); + expect(second.ok).toBe(true); + expect((second.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); + + // The first capability surface rides on the same machine-ownership + // proof: approved without a node.pair prompt. const record = await getPairedDevice(loaded.identity.deviceId); - // Wait for the ssh-verified provenance specifically: the approval - // and its read-back must survive the SQLite round-trip. - return record?.approvedVia === "ssh-verified" ? record : null; - }, "ssh-verified device approval"); - expect(paired.approvedVia).toBe("ssh-verified"); - expect(paired.publicKey).toBe(loaded.publicKey); - expect(probeMock).toHaveBeenCalledWith(expect.objectContaining({ host: lanIp })); - - const second = await connectNode(); - expect(second.ok).toBe(true); - expect((second.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); - - // The first capability surface rides on the same machine-ownership - // proof: approved without a node.pair prompt. - const record = await getPairedDevice(loaded.identity.deviceId); - expect(record?.nodeSurface).toBeDefined(); - expect(record?.pendingNodeSurface).toBeUndefined(); + expect(record?.nodeSurface).toBeDefined(); + expect(record?.pendingNodeSurface).toBeUndefined(); + } finally { + probe.resolve({ status: "timeout" }); + } }, }); }); diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index b423ea994fc0..787d3ef1165c 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -376,8 +376,8 @@ export async function authorizeGatewayConnectDevice( } else if (pairing.created) { requestContext.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true }); } - // SSH verification runs detached: this connection still closes with - // pairing-required, and the node retry loop picks up the approval. + // SSH verification runs detached; the live-record check below can admit + // an approval that finishes before this handshake's final check. const sshVerifyStarted = startGatewayNodePairingSshApproval({ context, state: { ...state, scopes, handoffBootstrapProfile },