mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: harden connect auth flow and exec policy diagnostics
This commit is contained in:
@@ -5,7 +5,11 @@ import { connectGateway } from "./app-gateway.ts";
|
||||
type GatewayClientMock = {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
emitClose: (code: number, reason?: string) => void;
|
||||
emitClose: (info: {
|
||||
code: number;
|
||||
reason?: string;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
}) => void;
|
||||
emitGap: (expected: number, received: number) => void;
|
||||
emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void;
|
||||
};
|
||||
@@ -19,7 +23,11 @@ vi.mock("./gateway.ts", () => {
|
||||
|
||||
constructor(
|
||||
private opts: {
|
||||
onClose?: (info: { code: number; reason: string }) => void;
|
||||
onClose?: (info: {
|
||||
code: number;
|
||||
reason: string;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
}) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
|
||||
},
|
||||
@@ -27,8 +35,12 @@ vi.mock("./gateway.ts", () => {
|
||||
gatewayClientInstances.push({
|
||||
start: this.start,
|
||||
stop: this.stop,
|
||||
emitClose: (code, reason) => {
|
||||
this.opts.onClose?.({ code, reason: reason ?? "" });
|
||||
emitClose: (info) => {
|
||||
this.opts.onClose?.({
|
||||
code: info.code,
|
||||
reason: info.reason ?? "",
|
||||
error: info.error,
|
||||
});
|
||||
},
|
||||
emitGap: (expected, received) => {
|
||||
this.opts.onGap?.({ expected, received });
|
||||
@@ -62,6 +74,7 @@ function createHost() {
|
||||
connected: false,
|
||||
hello: null,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
eventLogBuffer: [],
|
||||
eventLog: [],
|
||||
tab: "overview",
|
||||
@@ -171,10 +184,34 @@ describe("connectGateway", () => {
|
||||
const secondClient = gatewayClientInstances[1];
|
||||
expect(secondClient).toBeDefined();
|
||||
|
||||
firstClient.emitClose(1005);
|
||||
firstClient.emitClose({ code: 1005 });
|
||||
expect(host.lastError).toBeNull();
|
||||
expect(host.lastErrorCode).toBeNull();
|
||||
|
||||
secondClient.emitClose(1005);
|
||||
secondClient.emitClose({ code: 1005 });
|
||||
expect(host.lastError).toBe("disconnected (1005): no reason");
|
||||
expect(host.lastErrorCode).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers structured connect errors over close reason", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const client = gatewayClientInstances[0];
|
||||
expect(client).toBeDefined();
|
||||
|
||||
client.emitClose({
|
||||
code: 4008,
|
||||
reason: "connect failed",
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message:
|
||||
"unauthorized: gateway token mismatch (open the dashboard URL and paste the token in Control UI settings)",
|
||||
details: { code: "AUTH_TOKEN_MISMATCH" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.lastError).toContain("gateway token mismatch");
|
||||
expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,11 @@ import {
|
||||
} from "./controllers/exec-approval.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts";
|
||||
import {
|
||||
resolveGatewayErrorDetailCode,
|
||||
type GatewayEventFrame,
|
||||
type GatewayHelloOk,
|
||||
} from "./gateway.ts";
|
||||
import { GatewayBrowserClient } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { UiSettings } from "./storage.ts";
|
||||
@@ -45,6 +49,7 @@ type GatewayHost = {
|
||||
connected: boolean;
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
onboarding?: boolean;
|
||||
eventLogBuffer: EventLogEntry[];
|
||||
eventLog: EventLogEntry[];
|
||||
@@ -128,6 +133,7 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
|
||||
|
||||
export function connectGateway(host: GatewayHost) {
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
host.hello = null;
|
||||
host.connected = false;
|
||||
host.execApprovalQueue = [];
|
||||
@@ -146,6 +152,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
}
|
||||
host.connected = true;
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
// Reset orphaned chat run state from before disconnect.
|
||||
@@ -160,14 +167,24 @@ export function connectGateway(host: GatewayHost) {
|
||||
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
onClose: ({ code, reason, error }) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
host.connected = false;
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
host.lastErrorCode =
|
||||
resolveGatewayErrorDetailCode(error) ??
|
||||
(typeof error?.code === "string" ? error.code : null);
|
||||
if (code !== 1012) {
|
||||
if (error?.message) {
|
||||
host.lastError = error.message;
|
||||
return;
|
||||
}
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
} else {
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
}
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
@@ -181,6 +198,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
return;
|
||||
}
|
||||
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
|
||||
host.lastErrorCode = null;
|
||||
},
|
||||
});
|
||||
host.client = client;
|
||||
|
||||
@@ -220,6 +220,7 @@ export function renderApp(state: AppViewState) {
|
||||
settings: state.settings,
|
||||
password: state.password,
|
||||
lastError: state.lastError,
|
||||
lastErrorCode: state.lastErrorCode,
|
||||
presenceCount,
|
||||
sessionsCount,
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
|
||||
@@ -46,6 +46,7 @@ export type AppViewState = {
|
||||
themeResolved: "light" | "dark";
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
eventLog: EventLogEntry[];
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
|
||||
@@ -122,6 +122,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() themeResolved: ResolvedTheme = "dark";
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() lastErrorCode: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
private eventLogBuffer: EventLogEntry[] = [];
|
||||
private toolStreamSyncTimer: number | null = null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type GatewayClientMode,
|
||||
type GatewayClientName,
|
||||
} from "../../../src/gateway/protocol/client-info.js";
|
||||
import { readConnectErrorDetailCode } from "../../../src/gateway/protocol/connect-error-details.js";
|
||||
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
|
||||
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts";
|
||||
import { generateUUID } from "./uuid.ts";
|
||||
@@ -25,6 +26,30 @@ export type GatewayResponseFrame = {
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
};
|
||||
|
||||
export type GatewayErrorInfo = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export class GatewayRequestError extends Error {
|
||||
readonly gatewayCode: string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(error: GatewayErrorInfo) {
|
||||
super(error.message);
|
||||
this.name = "GatewayRequestError";
|
||||
this.gatewayCode = error.code;
|
||||
this.details = error.details;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayErrorDetailCode(
|
||||
error: { details?: unknown } | null | undefined,
|
||||
): string | null {
|
||||
return readConnectErrorDetailCode(error?.details);
|
||||
}
|
||||
|
||||
export type GatewayHelloOk = {
|
||||
type: "hello-ok";
|
||||
protocol: number;
|
||||
@@ -55,7 +80,7 @@ export type GatewayBrowserClientOptions = {
|
||||
instanceId?: string;
|
||||
onHello?: (hello: GatewayHelloOk) => void;
|
||||
onEvent?: (evt: GatewayEventFrame) => void;
|
||||
onClose?: (info: { code: number; reason: string }) => void;
|
||||
onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
};
|
||||
|
||||
@@ -71,6 +96,7 @@ export class GatewayBrowserClient {
|
||||
private connectSent = false;
|
||||
private connectTimer: number | null = null;
|
||||
private backoffMs = 800;
|
||||
private pendingConnectError: GatewayErrorInfo | undefined;
|
||||
|
||||
constructor(private opts: GatewayBrowserClientOptions) {}
|
||||
|
||||
@@ -83,6 +109,7 @@ export class GatewayBrowserClient {
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.pendingConnectError = undefined;
|
||||
this.flushPending(new Error("gateway client stopped"));
|
||||
}
|
||||
|
||||
@@ -99,9 +126,11 @@ export class GatewayBrowserClient {
|
||||
this.ws.addEventListener("message", (ev) => this.handleMessage(String(ev.data ?? "")));
|
||||
this.ws.addEventListener("close", (ev) => {
|
||||
const reason = String(ev.reason ?? "");
|
||||
const connectError = this.pendingConnectError;
|
||||
this.pendingConnectError = undefined;
|
||||
this.ws = null;
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
this.opts.onClose?.({ code: ev.code, reason });
|
||||
this.opts.onClose?.({ code: ev.code, reason, error: connectError });
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
this.ws.addEventListener("error", () => {
|
||||
@@ -227,7 +256,16 @@ export class GatewayBrowserClient {
|
||||
this.backoffMs = 800;
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof GatewayRequestError) {
|
||||
this.pendingConnectError = {
|
||||
code: err.gatewayCode,
|
||||
message: err.message,
|
||||
details: err.details,
|
||||
};
|
||||
} else {
|
||||
this.pendingConnectError = undefined;
|
||||
}
|
||||
if (canFallbackToShared && deviceIdentity) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||
}
|
||||
@@ -280,7 +318,13 @@ export class GatewayBrowserClient {
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload);
|
||||
} else {
|
||||
pending.reject(new Error(res.error?.message ?? "request failed"));
|
||||
pending.reject(
|
||||
new GatewayRequestError({
|
||||
code: res.error?.code ?? "UNAVAILABLE",
|
||||
message: res.error?.message ?? "request failed",
|
||||
details: res.error?.details,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
||||
|
||||
/** Whether the overview should show device-pairing guidance for this error. */
|
||||
export function shouldShowPairingHint(connected: boolean, lastError: string | null): boolean {
|
||||
export function shouldShowPairingHint(
|
||||
connected: boolean,
|
||||
lastError: string | null,
|
||||
lastErrorCode?: string | null,
|
||||
): boolean {
|
||||
if (connected || !lastError) {
|
||||
return false;
|
||||
}
|
||||
if (lastErrorCode === ConnectErrorDetailCodes.PAIRING_REQUIRED) {
|
||||
return true;
|
||||
}
|
||||
return lastError.toLowerCase().includes("pairing required");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
||||
import { shouldShowPairingHint } from "./overview-hints.ts";
|
||||
|
||||
describe("shouldShowPairingHint", () => {
|
||||
@@ -25,4 +26,14 @@ describe("shouldShowPairingHint", () => {
|
||||
it("returns false for auth errors", () => {
|
||||
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for structured pairing code", () => {
|
||||
expect(
|
||||
shouldShowPairingHint(
|
||||
false,
|
||||
"disconnected (4008): connect failed",
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
||||
import { t, i18n, type Locale } from "../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||
import type { GatewayHelloOk } from "../gateway.ts";
|
||||
@@ -12,6 +13,7 @@ export type OverviewProps = {
|
||||
settings: UiSettings;
|
||||
password: string;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
presenceCount: number;
|
||||
sessionsCount: number | null;
|
||||
cronEnabled: boolean | null;
|
||||
@@ -40,7 +42,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
const isTrustedProxy = authMode === "trusted-proxy";
|
||||
|
||||
const pairingHint = (() => {
|
||||
if (!shouldShowPairingHint(props.connected, props.lastError)) {
|
||||
if (!shouldShowPairingHint(props.connected, props.lastError, props.lastErrorCode)) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
@@ -72,13 +74,37 @@ export function renderOverview(props: OverviewProps) {
|
||||
return null;
|
||||
}
|
||||
const lower = props.lastError.toLowerCase();
|
||||
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
|
||||
const authRequiredCodes = new Set<string>([
|
||||
ConnectErrorDetailCodes.AUTH_REQUIRED,
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
|
||||
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
|
||||
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
|
||||
]);
|
||||
const authFailureCodes = new Set<string>([
|
||||
...authRequiredCodes,
|
||||
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
|
||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
|
||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
|
||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
|
||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
|
||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
|
||||
]);
|
||||
const authFailed = props.lastErrorCode
|
||||
? authFailureCodes.has(props.lastErrorCode)
|
||||
: lower.includes("unauthorized") || lower.includes("connect failed");
|
||||
if (!authFailed) {
|
||||
return null;
|
||||
}
|
||||
const hasToken = Boolean(props.settings.token.trim());
|
||||
const hasPassword = Boolean(props.password.trim());
|
||||
if (!hasToken && !hasPassword) {
|
||||
const isAuthRequired = props.lastErrorCode
|
||||
? authRequiredCodes.has(props.lastErrorCode)
|
||||
: !hasToken && !hasPassword;
|
||||
if (isAuthRequired) {
|
||||
return html`
|
||||
<div class="muted" style="margin-top: 8px">
|
||||
${t("overview.auth.required")}
|
||||
@@ -125,7 +151,14 @@ export function renderOverview(props: OverviewProps) {
|
||||
return null;
|
||||
}
|
||||
const lower = props.lastError.toLowerCase();
|
||||
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
|
||||
const insecureContextCode =
|
||||
props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
||||
props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED;
|
||||
if (
|
||||
!insecureContextCode &&
|
||||
!lower.includes("secure context") &&
|
||||
!lower.includes("device identity required")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
|
||||
Reference in New Issue
Block a user