Files
openclaw/packages/gateway-protocol/src/connect-error-details.test.ts
Peter Steinberger d92ebbaf72 feat(gateway): reach a Gateway behind an identity-aware proxy from the CLI (#125700)
* feat(gateway): reach a Gateway behind an identity-aware proxy from the CLI

Operator CLI surfaces (tui, attach, call, probe, onboarding, and configure) can present configured gateway.remote.edgeAuth headers to an identity-aware proxy. Headers are origin-bound, WSS-only, never follow redirects, cannot set transport-owned headers, and are redacted. Identity-proxy upgrade rejections are classified and remediated instead of being reported as an unreachable gateway.

* test(gateway): cover config-aware probe calls

Keep status probe expectations aligned with the resolved configuration forwarded for origin-bound edge-auth resolution.

* fix(gateway): preserve edge auth across wizard saves and enforce wss before secret resolution

Preserve gateway.remote.edgeAuth only when the configured Gateway scope is unchanged, and reject non-WSS targets before resolving any edge-auth SecretInput.
2026-08-18 03:29:56 -07:00

485 lines
16 KiB
TypeScript

// Gateway Protocol tests cover connect error details behavior.
import { describe, expect, it } from "vitest";
import {
buildPairingConnectCloseReason,
buildPairingConnectErrorDetails,
buildPairingConnectErrorMessage,
classifyGatewayConnectFailure,
ConnectErrorDetailCodes,
describePairingConnectRequirement,
formatConnectErrorMessage,
formatConnectPairingRequiredMessage,
normalizePairingConnectRequestId,
readConnectErrorDetailCode,
readControlUiBuildMismatchId,
readConnectErrorRecoveryAdvice,
readConnectPairingRequiredMessage,
readPairingConnectErrorDetails,
resolveAuthConnectErrorDetailCode,
} from "./connect-error-details.js";
/**
* Connect error detail regressions for Gateway/WebSocket clients.
*
* These tests pin structured auth/pairing details, human-readable fallback
* formatting, and request-id sanitization because these strings surface in
* control UI reconnect flows and device pairing diagnostics.
*/
describe("readConnectErrorDetailCode", () => {
it("reads structured detail codes", () => {
expect(readConnectErrorDetailCode({ code: "AUTH_TOKEN_MISMATCH" })).toBe("AUTH_TOKEN_MISMATCH");
});
it("returns trimmed detail codes when payload padding is present", () => {
expect(readConnectErrorDetailCode({ code: " AUTH_TOKEN_MISMATCH " })).toBe(
"AUTH_TOKEN_MISMATCH",
);
expect(readConnectErrorDetailCode({ code: "\tPAIRING_REQUIRED\n" })).toBe("PAIRING_REQUIRED");
});
it("returns null for invalid detail payloads", () => {
expect(readConnectErrorDetailCode(null)).toBeNull();
expect(readConnectErrorDetailCode("AUTH_TOKEN_MISMATCH")).toBeNull();
});
});
describe("readControlUiBuildMismatchId", () => {
it.each([
ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
])("returns a bounded reload target for %s", (code) => {
expect(
readControlUiBuildMismatchId({
code,
gatewayBuildId: "gateway-build",
reloadRequired: true,
}),
).toBe("gateway-build");
});
it.each([
{},
{ code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH },
{
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
gatewayBuildId: "x".repeat(97),
reloadRequired: true,
},
{
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
gatewayBuildId: "gateway-build",
reloadRequired: false,
},
])("rejects malformed details", (details) => {
expect(readControlUiBuildMismatchId(details)).toBeNull();
});
});
describe("readConnectErrorRecoveryAdvice", () => {
it("reads retry advice fields when present", () => {
expect(
readConnectErrorRecoveryAdvice({
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_device_token",
}),
).toEqual({
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_device_token",
});
});
it("returns empty advice for invalid payloads", () => {
expect(readConnectErrorRecoveryAdvice(null)).toStrictEqual({});
expect(readConnectErrorRecoveryAdvice("x")).toStrictEqual({});
expect(readConnectErrorRecoveryAdvice({ canRetryWithDeviceToken: "yes" })).toEqual({});
expect(
readConnectErrorRecoveryAdvice({
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_magic",
}),
).toEqual({ canRetryWithDeviceToken: true, recommendedNextStep: undefined });
});
});
describe("classifyGatewayConnectFailure", () => {
it.each([
{
name: "structured pairing upgrade",
input: {
details: { code: "PAIRING_REQUIRED", reason: "scope-upgrade", requestId: "req-123" },
message: "connect failed",
},
kind: "pairing-required",
message: "scope upgrade pending approval (requestId: req-123)",
remediation: "openclaw devices approve --latest",
},
{
name: "structured device identity requirement",
input: { details: { code: "DEVICE_IDENTITY_REQUIRED" }, message: "connect failed" },
kind: "device-identity-required",
message: "connect failed",
remediation: undefined,
},
{
name: "structured scope mismatch",
input: { details: { code: "AUTH_SCOPE_MISMATCH" }, message: "scope rejected" },
kind: "scope-mismatch",
message: "scope rejected",
remediation: "openclaw devices list",
},
{
name: "structured authentication rate limit",
input: { details: { code: "AUTH_RATE_LIMITED" }, message: "connect failed" },
kind: "rate-limited",
message: "connect failed",
remediation: "temporary authentication lockout",
},
{
name: "shared token mismatch",
input: { details: { code: "AUTH_TOKEN_MISMATCH" }, message: "gateway token mismatch" },
kind: "auth-rejected",
message: "gateway token mismatch",
remediation: "gateway.remote.token",
},
{
name: "device token mismatch",
input: {
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
message: "device token mismatch",
},
kind: "auth-rejected",
message: "device token mismatch",
remediation: "openclaw devices rotate --device <deviceId> --role operator",
},
{
name: "other structured auth rejection",
input: { details: { code: "AUTH_PASSWORD_MISMATCH" }, message: "password mismatch" },
kind: "auth-rejected",
message: "password mismatch",
remediation: undefined,
},
{
name: "legacy pairing reason",
input: { reason: "gateway closed (1008): pairing required" },
kind: "pairing-required",
message: "gateway closed (1008): pairing required",
remediation: "openclaw devices approve --latest",
},
{
name: "legacy pairing reason behind a generic message",
input: {
message: "connect failed",
reason: "gateway closed (1008): pairing required",
},
kind: "pairing-required",
message: "connect failed",
remediation: "openclaw devices approve --latest",
},
{
name: "legacy device identity reason behind a generic message",
input: {
message: "connect failed",
reason: "gateway closed (1008): device identity required",
},
kind: "device-identity-required",
message: "connect failed",
remediation: undefined,
},
{
name: "legacy scope mismatch reason behind a generic message",
input: { message: "connect failed", reason: "scope mismatch" },
kind: "scope-mismatch",
message: "connect failed",
remediation: "openclaw devices list",
},
{
name: "legacy device token reason behind a generic message",
input: { message: "connect failed", reason: "device token mismatch" },
kind: "auth-rejected",
message: "connect failed",
remediation: "openclaw devices rotate --device <deviceId> --role operator",
},
{
name: "legacy shared token reason behind a generic message",
input: { message: "connect failed", reason: "gateway token mismatch" },
kind: "auth-rejected",
message: "connect failed",
remediation: "gateway.remote.token",
},
{
name: "legacy gateway close reason behind a generic message",
input: { message: "connect failed", reason: "gateway closed (1008): auth failed" },
kind: "gateway-rejected",
message: "connect failed",
remediation: undefined,
},
{
name: "legacy gateway close",
input: { message: "gateway closed (1008): auth failed" },
kind: "gateway-rejected",
message: "gateway closed (1008): auth failed",
remediation: undefined,
},
{
name: "legacy authentication rate limit",
input: {
reason: "unauthorized: too many failed authentication attempts (retry later)",
},
kind: "rate-limited",
message: "unauthorized: too many failed authentication attempts (retry later)",
remediation: "temporary authentication lockout",
},
{
name: "generic retry hint without the authentication lockout phrase",
input: { message: "connect failed; retry later" },
kind: "unreachable",
message: "connect failed; retry later",
remediation: undefined,
},
{
name: "identity proxy redirect rejection",
input: {
details: { reason: "websocket-upgrade-rejected", httpStatus: 302 },
message: "gateway rejected websocket upgrade (HTTP 302)",
},
kind: "identity-proxy",
message: "gateway rejected websocket upgrade (HTTP 302)",
remediation: "gateway.remote.edgeAuth",
},
{
name: "identity proxy forbidden rejection",
input: {
details: { reason: "websocket-upgrade-rejected", httpStatus: 403 },
message: "gateway rejected websocket upgrade (HTTP 403)",
},
kind: "identity-proxy",
message: "gateway rejected websocket upgrade (HTTP 403)",
remediation: "identity-aware proxy",
},
{
name: "unreachable endpoint",
input: { message: "connect ECONNREFUSED 127.0.0.1:18789" },
kind: "unreachable",
message: "connect ECONNREFUSED 127.0.0.1:18789",
remediation: undefined,
},
])("classifies $name", ({ input, kind, message, remediation }) => {
const result = classifyGatewayConnectFailure(input);
expect(result.kind).toBe(kind);
expect(result.userMessage).toBe(message);
if (remediation) {
expect(result.remediation).toContain(remediation);
} else {
expect(result.remediation).toBeUndefined();
}
if (kind === "pairing-required") {
expect(result.remediation).toContain("--url");
expect(result.remediation).toContain("--token/--password");
}
});
it("adds a Cloudflare hint only for Cloudflare Access redirect hosts", () => {
const cloudflare = classifyGatewayConnectFailure({
details: {
reason: "websocket-upgrade-rejected",
httpStatus: 302,
location: "https://team.cloudflareaccess.com/cdn-cgi/access/login?token=***",
},
});
const generic = classifyGatewayConnectFailure({
details: {
reason: "websocket-upgrade-rejected",
httpStatus: 302,
location: "https://login.example/authorize",
},
});
expect(cloudflare.kind).toBe("identity-proxy");
expect(cloudflare.kind).not.toBe("unreachable");
expect(cloudflare.remediation).toContain("Cloudflare Access");
expect(generic.remediation).not.toContain("Cloudflare");
});
});
describe("resolveAuthConnectErrorDetailCode", () => {
it("maps device token scope mismatches to a dedicated auth detail", () => {
expect(resolveAuthConnectErrorDetailCode("scope_mismatch")).toBe("AUTH_SCOPE_MISMATCH");
});
it("keeps trusted-proxy identity rejection distinct from generic unauthorized auth", () => {
expect(
resolveAuthConnectErrorDetailCode("trusted_proxy_missing_header_cf-access-jwt-assertion"),
).toBe("AUTH_IDENTITY_HEADER_REQUIRED");
});
it("keeps non-header trusted-proxy rejection generic", () => {
expect(resolveAuthConnectErrorDetailCode("trusted_proxy_local_interface_check_failed")).toBe(
"AUTH_UNAUTHORIZED",
);
});
});
describe("pairing connect details", () => {
it("builds reason-specific pairing messages", () => {
expect(buildPairingConnectErrorMessage("scope-upgrade")).toBe(
"pairing required: device is asking for more scopes than currently approved",
);
expect(describePairingConnectRequirement("not-paired")).toBe("device is not approved yet");
});
it("builds structured pairing details with remediation", () => {
expect(
buildPairingConnectErrorDetails({
reason: "not-paired",
requestId: "req-123",
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
}),
).toEqual({
code: "PAIRING_REQUIRED",
reason: "not-paired",
requestId: "req-123",
remediationHint: "Approve this device from the pending pairing requests.",
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
});
});
it("reads pairing details and backfills missing remediation hints", () => {
expect(
readPairingConnectErrorDetails({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
requestId: "req-456",
}),
).toEqual({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
requestId: "req-456",
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
});
});
it("includes request ids in close reasons when available", () => {
expect(
buildPairingConnectCloseReason({
reason: "role-upgrade",
requestId: "req-789",
}),
).toBe(
"pairing required: device is asking for a higher role than currently approved (requestId: req-789)",
);
});
it("drops request ids that do not match the allowlist", () => {
expect(normalizePairingConnectRequestId("req-123")).toBe("req-123");
expect(normalizePairingConnectRequestId("req-123;rm -rf /")).toBeUndefined();
expect(
readPairingConnectErrorDetails({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
requestId: "req-123;rm -rf /",
}),
).toEqual({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
});
});
it("formats upgrade rejections with the request id", () => {
expect(
formatConnectPairingRequiredMessage({
code: "PAIRING_REQUIRED",
requestId: "req-123",
reason: "scope-upgrade",
}),
).toBe("scope upgrade pending approval (requestId: req-123)");
});
it("parses surfaced pairing-required messages", () => {
expect(
readConnectPairingRequiredMessage("scope upgrade pending approval (requestId: req-123)"),
).toEqual({
requestId: "req-123",
reason: "scope-upgrade",
});
expect(
readConnectPairingRequiredMessage(
"scope upgrade pending approval (requestId: req-123;rm -rf /)",
),
).toEqual({
reason: "scope-upgrade",
});
});
it("prefers pairing detail formatting over the generic message", () => {
expect(
formatConnectErrorMessage({
message: "pairing required",
details: {
code: "PAIRING_REQUIRED",
requestId: "req-123",
reason: "scope-upgrade",
},
}),
).toBe("scope upgrade pending approval (requestId: req-123)");
});
it("reads pairing details when detail code has surrounding whitespace", () => {
expect(
readPairingConnectErrorDetails({
code: " PAIRING_REQUIRED ",
reason: "scope-upgrade",
requestId: "req-456",
}),
).toEqual({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
requestId: "req-456",
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
});
});
it("formats connect errors when padded detail codes are present", () => {
expect(
formatConnectErrorMessage({
message: "pairing required",
details: {
code: " PAIRING_REQUIRED ",
requestId: "req-123",
reason: "scope-upgrade",
},
}),
).toBe("scope upgrade pending approval (requestId: req-123)");
expect(
formatConnectErrorMessage({
message: "protocol mismatch",
details: {
code: "\tPROTOCOL_MISMATCH\n",
clientMinProtocol: 5,
clientMaxProtocol: 5,
expectedProtocol: 4,
},
}),
).toBe("protocol mismatch: Control UI v5, Gateway v4");
});
it("formats protocol mismatch details with both client and gateway versions", () => {
expect(
formatConnectErrorMessage({
message: "protocol mismatch",
details: {
code: "PROTOCOL_MISMATCH",
clientMinProtocol: 5,
clientMaxProtocol: 5,
expectedProtocol: 4,
minimumProbeProtocol: 4,
},
}),
).toBe("protocol mismatch: Control UI v5, Gateway v4, probe min v4");
});
});