mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
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.
This commit is contained in:
@@ -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<NodeIdentityProbeResult>();
|
||||
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" });
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user