fix(gateway): stop pointing local CLI token mismatches at gateway.remote (#125488)

A local-mode gateway (gateway.mode=local, no gateway.remote block) sent
CLI clients "set gateway.remote.token to match gateway.auth.token" on a
wrong-token connect. That hint is a dead end for local connections since
they resolve credentials from gateway.auth.token directly. The connect
pipeline already records isLocalClient at admission time; thread it into
the auth-failure message so local CLI hints point at gateway.auth.token
(or pairing) while remote CLI hints keep the gateway.remote.* guidance.
This commit is contained in:
Peter Steinberger
2026-08-17 18:22:22 -07:00
committed by GitHub
parent bb1ce58514
commit 0e7f3209f2
3 changed files with 53 additions and 3 deletions
@@ -37,6 +37,48 @@ describe("formatGatewayAuthFailureMessage", () => {
expect(truncateCloseReason(message)).toBe(message);
});
it("points local CLI token mismatches at gateway.auth.token, not gateway.remote", () => {
const message = formatGatewayAuthFailureMessage({
authMode: "token",
authProvided: "token",
reason: "token_mismatch",
client: { id: GATEWAY_CLIENT_IDS.CLI, mode: GATEWAY_CLIENT_MODES.CLI },
isLocalClient: true,
});
expect(message).toBe(
"unauthorized: gateway token mismatch (use this gateway's gateway.auth.token or pair the device)",
);
expect(message).not.toContain("gateway.remote");
expect(truncateCloseReason(message)).toBe(message);
});
it("keeps the gateway.remote.token hint for remote CLI token mismatches", () => {
expect(
formatGatewayAuthFailureMessage({
authMode: "token",
authProvided: "token",
reason: "token_mismatch",
client: { id: GATEWAY_CLIENT_IDS.CLI, mode: GATEWAY_CLIENT_MODES.CLI },
isLocalClient: false,
}),
).toBe(
"unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)",
);
});
it("points local CLI password mismatches at gateway.auth.password", () => {
expect(
formatGatewayAuthFailureMessage({
authMode: "password",
authProvided: "password",
reason: "password_mismatch",
client: { id: GATEWAY_CLIENT_IDS.CLI, mode: GATEWAY_CLIENT_MODES.CLI },
isLocalClient: true,
}),
).toBe("unauthorized: gateway password mismatch (use this gateway's gateway.auth.password)");
});
it("tells rejected node hosts how to diagnose identity-header auth", () => {
expect(
formatGatewayAuthFailureMessage({
@@ -18,8 +18,9 @@ export function formatGatewayAuthFailureMessage(params: {
authProvided: AuthProvidedKind;
reason?: string;
client?: { id?: string | null; mode?: string | null };
isLocalClient?: boolean;
}): string {
const { authMode, authProvided, reason, client } = params;
const { authMode, authProvided, reason, client, isLocalClient } = params;
const isCli = isGatewayCliClient(client);
const isControlUi = isOperatorUiClient(client);
const isWebchat = isWebchatClient(client);
@@ -29,13 +30,19 @@ export function formatGatewayAuthFailureMessage(params: {
const uiHint = "open the dashboard URL and paste the token in Control UI settings";
const missingUiTokenHint =
"paste in Control UI settings or openclaw doctor --generate-gateway-token; restart";
// Local CLI clients share this gateway's config and have no gateway.remote
// block; pointing them at gateway.remote.* would be a dead end.
const tokenHint = isCli
? "set gateway.remote.token to match gateway.auth.token"
? isLocalClient
? "use this gateway's gateway.auth.token or pair the device"
: "set gateway.remote.token to match gateway.auth.token"
: isControlUi || isWebchat
? uiHint
: "provide gateway auth token";
const passwordHint = isCli
? "set gateway.remote.password to match gateway.auth.password"
? isLocalClient
? "use this gateway's gateway.auth.password"
: "set gateway.remote.password to match gateway.auth.password"
: isControlUi || isWebchat
? "enter the password in Control UI settings"
: "provide gateway auth password";
@@ -196,6 +196,7 @@ async function authenticateGatewayConnectCore(
authProvided,
reason: failedAuth.reason,
client: connectParams.client,
isLocalClient,
});
const authLogDecision = shouldLimitMissingCredentialAuthLog({
reason: failedAuth.reason,