feat(gateway): add trusted-proxy auth mode (#15940)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 279d4b304f
Co-authored-by: nickytonline <833231+nickytonline@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
Nick Taylor
2026-02-14 06:32:17 -05:00
committed by GitHub
parent 3a330e681b
commit 1fb52b4d7b
28 changed files with 1867 additions and 92 deletions

View File

@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { connectGateway } from "./app-gateway.ts";
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
emitClose: (code: number, reason?: string) => void;
emitGap: (expected: number, received: number) => void;
emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void;
};
const gatewayClientInstances: GatewayClientMock[] = [];
vi.mock("./gateway.ts", () => {
class GatewayBrowserClient {
readonly start = vi.fn();
readonly stop = vi.fn();
constructor(
private opts: {
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
},
) {
gatewayClientInstances.push({
start: this.start,
stop: this.stop,
emitClose: (code, reason) => {
this.opts.onClose?.({ code, reason: reason ?? "" });
},
emitGap: (expected, received) => {
this.opts.onGap?.({ expected, received });
},
emitEvent: (evt) => {
this.opts.onEvent?.(evt);
},
});
}
}
return { GatewayBrowserClient };
});
function createHost() {
return {
settings: {
gatewayUrl: "ws://127.0.0.1:18789",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
password: "",
client: null,
connected: false,
hello: null,
lastError: null,
eventLogBuffer: [],
eventLog: [],
tab: "overview",
presenceEntries: [],
presenceError: null,
presenceStatus: null,
agentsLoading: false,
agentsList: null,
agentsError: null,
debugHealth: null,
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAgentId: null,
sessionKey: "main",
chatRunId: null,
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
} as unknown as Parameters<typeof connectGateway>[0];
}
describe("connectGateway", () => {
beforeEach(() => {
gatewayClientInstances.length = 0;
});
it("ignores stale client onGap callbacks after reconnect", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitGap(10, 13);
expect(host.lastError).toBeNull();
secondClient.emitGap(20, 24);
expect(host.lastError).toBe(
"event gap detected (expected seq 20, got 24); refresh recommended",
);
});
it("ignores stale client onEvent callbacks after reconnect", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitEvent({ event: "presence", payload: { presence: [{ host: "stale" }] } });
expect(host.eventLogBuffer).toHaveLength(0);
secondClient.emitEvent({ event: "presence", payload: { presence: [{ host: "active" }] } });
expect(host.eventLogBuffer).toHaveLength(1);
expect(host.eventLogBuffer[0]?.event).toBe("presence");
});
it("ignores stale client onClose callbacks after reconnect", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitClose(1005);
expect(host.lastError).toBeNull();
secondClient.emitClose(1005);
expect(host.lastError).toBe("disconnected (1005): no reason");
});
});

View File

@@ -122,14 +122,17 @@ export function connectGateway(host: GatewayHost) {
host.execApprovalQueue = [];
host.execApprovalError = null;
host.client?.stop();
host.client = new GatewayBrowserClient({
const previousClient = host.client;
const client = new GatewayBrowserClient({
url: host.settings.gatewayUrl,
token: host.settings.token.trim() ? host.settings.token : undefined,
password: host.password.trim() ? host.password : undefined,
clientName: "openclaw-control-ui",
mode: "webchat",
onHello: (hello) => {
if (host.client !== client) {
return;
}
host.connected = true;
host.lastError = null;
host.hello = hello;
@@ -147,18 +150,31 @@ export function connectGateway(host: GatewayHost) {
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
if (host.client !== client) {
return;
}
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
}
},
onEvent: (evt) => handleGatewayEvent(host, evt),
onEvent: (evt) => {
if (host.client !== client) {
return;
}
handleGatewayEvent(host, evt);
},
onGap: ({ expected, received }) => {
if (host.client !== client) {
return;
}
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
},
});
host.client.start();
host.client = client;
previousClient?.stop();
client.start();
}
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {

View File

@@ -24,10 +24,16 @@ export type OverviewProps = {
export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| {
uptimeMs?: number;
policy?: { tickIntervalMs?: number };
authMode?: "none" | "token" | "password" | "trusted-proxy";
}
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy";
const authHint = (() => {
if (props.connected || !props.lastError) {
return null;
@@ -136,29 +142,35 @@ export function renderOverview(props: OverviewProps) {
placeholder="ws://100.x.y.z:18789"
/>
</label>
<label class="field">
<span>Gateway Token</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
</label>
<label class="field">
<span>Password (not stored)</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
</label>
${
isTrustedProxy
? ""
: html`
<label class="field">
<span>Gateway Token</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
</label>
<label class="field">
<span>Password (not stored)</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
</label>
`
}
<label class="field">
<span>Default Session Key</span>
<input
@@ -173,7 +185,7 @@ export function renderOverview(props: OverviewProps) {
<div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
<span class="muted">Click Connect to apply connection changes.</span>
<span class="muted">${isTrustedProxy ? "Authenticated via trusted proxy." : "Click Connect to apply connection changes."}</span>
</div>
</div>