Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/server/src/openclaw/GatewayClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer, type WebSocket } from "ws";

import { generateOpenclawDeviceIdentity } from "./deviceAuth.ts";
import { OpenclawGatewayClient } from "./GatewayClient.ts";

const servers = new Set<WebSocketServer>();

type GatewayRequestFrame = {
type?: unknown;
id?: unknown;
method?: unknown;
params?: {
auth?: {
password?: unknown;
token?: unknown;
deviceToken?: unknown;
};
};
};

type GatewayAuthPayload = NonNullable<GatewayRequestFrame["params"]>["auth"];

afterEach(async () => {
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
for (const client of server.clients) {
client.terminate();
}
server.close(() => resolve());
}),
),
);
servers.clear();
});

async function createGatewayServer(
onConnection: (socket: WebSocket) => void,
): Promise<{ url: string }> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
servers.add(server);
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
server.on("connection", onConnection);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected a TCP address for the test websocket server.");
}
return { url: `ws://127.0.0.1:${address.port}` };
}

function sendChallenge(socket: WebSocket): void {
socket.send(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-123", ts: Date.now() },
}),
);
}

describe("OpenclawGatewayClient", () => {
it("retries with auth.password when a gateway rejects token-style shared-secret auth", async () => {
const attemptedAuths: GatewayAuthPayload[] = [];
const gateway = await createGatewayServer((socket) => {
sendChallenge(socket);
socket.on("message", (data) => {
const message = JSON.parse(data.toString()) as GatewayRequestFrame;
if (message.type === "req" && message.method === "connect") {
attemptedAuths.push(message.params?.auth);
if (message.params?.auth?.password === "topsecret") {
socket.send(
JSON.stringify({
type: "res",
id: message.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
}),
);
return;
}

socket.send(
JSON.stringify({
type: "res",
id: message.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized: gateway password missing",
details: {
code: "AUTH_PASSWORD_MISSING",
reason: "provide gateway auth password",
recommendedNextStep: "update_auth_configuration",
},
},
}),
);
}
});
});

const connection = await OpenclawGatewayClient.connect({
url: gateway.url,
identity: generateOpenclawDeviceIdentity(),
sharedSecret: "topsecret",
clientId: "okcode",
clientVersion: "test",
clientPlatform: "macos",
clientMode: "operator",
locale: "en-US",
userAgent: "okcode/test",
role: "operator",
scopes: ["operator.read", "operator.write"],
});

expect(attemptedAuths.some((auth) => auth?.password === "topsecret")).toBe(true);
await connection.client.close();
});
});
47 changes: 37 additions & 10 deletions apps/server/src/openclaw/GatewayClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ function uniqueScopes(scopes: ReadonlyArray<string> | undefined): string[] {
return [...values];
}

function isPasswordAuthError(error: ParsedGatewayError | undefined): boolean {
return (
error?.detailCode === "AUTH_PASSWORD_MISSING" || error?.detailCode === "AUTH_PASSWORD_MISMATCH"
);
}

function closeDetail(code: number | undefined, reason: string | undefined): string {
if (code === undefined) {
return "";
Expand Down Expand Up @@ -292,25 +298,40 @@ export class OpenclawGatewayClient {
typeof this.options.deviceToken === "string" && this.options.deviceToken.length > 0;

try {
return await this.performConnectAttempt("shared");
} catch (error) {
const parsedError =
return await this.performConnectAttempt("sharedToken");
} catch (caughtError) {
let error = caughtError;
let parsedError =
error instanceof OpenclawGatewayClientError ? error.gatewayError : undefined;

if (this.options.sharedSecret !== undefined && isPasswordAuthError(parsedError)) {
await this.closeCurrentSocket();
try {
return await this.performConnectAttempt("sharedPassword");
} catch (passwordError) {
error = passwordError;
parsedError =
passwordError instanceof OpenclawGatewayClientError
? passwordError.gatewayError
: undefined;
}
}

const shouldRetryWithDeviceToken =
canUseStoredDeviceToken &&
parsedError?.canRetryWithDeviceToken === true &&
this.options.sharedSecret !== undefined;
if (!shouldRetryWithDeviceToken) {
throw error;
if (shouldRetryWithDeviceToken) {
await this.closeCurrentSocket();
return await this.performConnectAttempt("deviceToken");
}

await this.closeCurrentSocket();
return await this.performConnectAttempt("deviceToken");
throw error;
}
}

private async performConnectAttempt(
authMode: "shared" | "deviceToken",
authMode: "sharedToken" | "sharedPassword" | "deviceToken",
): Promise<OpenclawGatewayConnectResult> {
await this.openSocket();
const challenge = await this.waitForEvent("connect.challenge");
Expand All @@ -331,7 +352,10 @@ export class OpenclawGatewayClient {
const authToken =
authMode === "deviceToken"
? (this.options.deviceToken ?? "")
: (this.options.sharedSecret ?? "");
: authMode === "sharedToken"
? (this.options.sharedSecret ?? "")
: "";
const authPassword = authMode === "sharedPassword" ? (this.options.sharedSecret ?? "") : "";
const signedDevice = signOpenclawDeviceChallenge(this.options.identity, {
clientId: this.options.clientId,
clientMode: this.options.clientMode,
Expand All @@ -356,7 +380,10 @@ export class OpenclawGatewayClient {
caps: [],
commands: [],
permissions: {},
...(authMode === "shared" && authToken.length > 0 ? { auth: { token: authToken } } : {}),
...(authMode === "sharedToken" && authToken.length > 0 ? { auth: { token: authToken } } : {}),
...(authMode === "sharedPassword" && authPassword.length > 0
? { auth: { password: authPassword } }
: {}),
...(authMode === "deviceToken" && authToken.length > 0
? { auth: { deviceToken: authToken } }
: {}),
Expand Down
70 changes: 70 additions & 0 deletions apps/server/src/openclawGatewayTest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,55 @@ describe("runOpenclawGatewayTest", () => {
expect(typeof connectParams?.device?.signedAt).toBe("number");
});

it("retries with password-style auth when the gateway requires auth.password", async () => {
const attemptedParams: GatewayRequestFrame["params"][] = [];

const gateway = await createGatewayServer((socket) => {
sendChallenge(socket);
socket.on("message", (data) => {
const message = JSON.parse(data.toString()) as GatewayRequestFrame;
if (message.type === "req" && message.method === "connect") {
attemptedParams.push(message.params);
if (message.params?.auth?.password === "topsecret") {
socket.send(
JSON.stringify({
type: "res",
id: message.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
}),
);
return;
}

socket.send(
JSON.stringify({
type: "res",
id: message.id,
ok: false,
error: {
message: "unauthorized: gateway password missing",
details: {
code: "AUTH_PASSWORD_MISSING",
reason: "provide gateway auth password",
recommendedNextStep: "update_auth_configuration",
},
},
}),
);
}
});
});

const result = await runOpenclawGatewayTest({
gatewayUrl: gateway.url,
password: "topsecret",
});

expect(result.success).toBe(true);
expect(attemptedParams.some((params) => params?.auth?.password === "topsecret")).toBe(true);
});

it("reports pairing-required detail codes from the connect handshake", async () => {
const gateway = await createGatewayServer((socket) => {
sendChallenge(socket);
Expand Down Expand Up @@ -194,4 +243,25 @@ describe("runOpenclawGatewayTest", () => {
expect(result.diagnostics?.gatewayRecommendedNextStep).toBe("approve_device");
expect(result.diagnostics?.hints.some((hint) => hint.includes("pairing approval"))).toBe(true);
});

it("adds a shared-secret hint for password-missing handshake errors", () => {
const hints = OpenclawGatewayTestInternals.buildHints(
new URL("wss://vals-mini.example.ts.net"),
{
resolvedAddresses: ["100.90.12.34"],
hostKind: "tailscale",
healthStatus: "pass",
observedNotifications: ["connect.challenge"],
hints: [],
gatewayErrorDetailCode: "AUTH_PASSWORD_MISSING",
},
"Gateway handshake",
"unauthorized: gateway password missing (AUTH_PASSWORD_MISSING)",
false,
);

expect(hints.some((hint) => hint.includes("add the configured secret and test again"))).toBe(
true,
);
});
});
9 changes: 7 additions & 2 deletions apps/server/src/openclawGatewayTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,10 @@ function buildHints(

if (
!sharedSecretProvided &&
(detailCode === "AUTH_TOKEN_MISSING" || errorLower.includes("auth_token_missing"))
(detailCode === "AUTH_TOKEN_MISSING" ||
detailCode === "AUTH_PASSWORD_MISSING" ||
errorLower.includes("auth_token_missing") ||
errorLower.includes("auth_password_missing"))
) {
hints.push(
"No shared secret was provided for this test. If your OpenClaw gateway uses token/password auth, add the configured secret and test again.",
Expand All @@ -344,8 +347,10 @@ function buildHints(
if (
sharedSecretProvided &&
(detailCode === "AUTH_TOKEN_MISMATCH" ||
detailCode === "AUTH_PASSWORD_MISMATCH" ||
detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" ||
errorLower.includes("auth_token_mismatch"))
errorLower.includes("auth_token_mismatch") ||
errorLower.includes("auth_password_mismatch"))
) {
hints.push(
"The gateway rejected the provided auth material. Re-check the configured shared secret and confirm whether this gateway expects token auth, password auth, or a paired device token.",
Expand Down
Loading
Loading