refactor(test): dedupe gateway and web scaffolding

This commit is contained in:
Peter Steinberger
2026-02-22 20:02:05 +00:00
parent 5e8b1f5ac8
commit 2dcb244985
17 changed files with 375 additions and 498 deletions

View File

@@ -105,18 +105,23 @@ export function createDiscordDraftStream(params: {
}
};
const readMessageId = () => streamMessageId;
const clearMessageId = () => {
streamMessageId = undefined;
};
const isValidStreamMessageId = (value: unknown): value is string => typeof value === "string";
const deleteStreamMessage = async (messageId: string) => {
await rest.delete(Routes.channelMessage(channelId, messageId));
};
const { loop, update, stop, clear } = createFinalizableDraftLifecycle({
throttleMs,
state: streamState,
sendOrEditStreamMessage,
readMessageId: () => streamMessageId,
clearMessageId: () => {
streamMessageId = undefined;
},
isValidMessageId: (value): value is string => typeof value === "string",
deleteMessage: async (messageId) => {
await rest.delete(Routes.channelMessage(channelId, messageId));
},
readMessageId,
clearMessageId,
isValidMessageId: isValidStreamMessageId,
deleteMessage: deleteStreamMessage,
warn: params.warn,
warnPrefix: "discord stream preview cleanup failed",
});

View File

@@ -19,6 +19,24 @@ type TestCase = {
expected: ExpectedCredentialSet;
};
const gatewayEnv = {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv;
function makeRemoteGatewayConfig(remote: { token?: string; password?: string }): OpenClawConfig {
return {
gateway: {
mode: "remote",
remote,
auth: {
token: "local-token",
password: "local-password",
},
},
} as OpenClawConfig;
}
function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
const keys = [
"OPENCLAW_GATEWAY_TOKEN",
@@ -76,23 +94,11 @@ describe("gateway credential precedence parity", () => {
},
{
name: "remote mode with remote token configured",
cfg: {
gateway: {
mode: "remote",
remote: {
token: "remote-token",
password: "remote-password",
},
auth: {
token: "local-token",
password: "local-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
cfg: makeRemoteGatewayConfig({
token: "remote-token",
password: "remote-password",
}),
env: gatewayEnv,
expected: {
call: { token: "remote-token", password: "env-password" },
probe: { token: "remote-token", password: "env-password" },
@@ -102,22 +108,10 @@ describe("gateway credential precedence parity", () => {
},
{
name: "remote mode without remote token keeps remote probe/status strict",
cfg: {
gateway: {
mode: "remote",
remote: {
password: "remote-password",
},
auth: {
token: "local-token",
password: "local-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
cfg: makeRemoteGatewayConfig({
password: "remote-password",
}),
env: gatewayEnv,
expected: {
call: { token: "env-token", password: "env-password" },
probe: { token: undefined, password: "env-password" },

View File

@@ -9,20 +9,57 @@ function cfg(input: Partial<OpenClawConfig>): OpenClawConfig {
return input as OpenClawConfig;
}
type ResolveFromConfigInput = Parameters<typeof resolveGatewayCredentialsFromConfig>[0];
type GatewayConfig = NonNullable<OpenClawConfig["gateway"]>;
const DEFAULT_GATEWAY_AUTH = { token: "config-token", password: "config-password" };
const DEFAULT_REMOTE_AUTH = { token: "remote-token", password: "remote-password" };
const DEFAULT_GATEWAY_ENV = {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv;
function resolveGatewayCredentialsFor(
gateway: GatewayConfig,
overrides: Partial<Omit<ResolveFromConfigInput, "cfg" | "env">> = {},
) {
return resolveGatewayCredentialsFromConfig({
cfg: cfg({ gateway }),
env: DEFAULT_GATEWAY_ENV,
...overrides,
});
}
function expectEnvGatewayCredentials(resolved: { token?: string; password?: string }) {
expect(resolved).toEqual({
token: "env-token",
password: "env-password",
});
}
function resolveRemoteModeWithRemoteCredentials(
overrides: Partial<Omit<ResolveFromConfigInput, "cfg" | "env">> = {},
) {
return resolveGatewayCredentialsFor(
{
mode: "remote",
remote: DEFAULT_REMOTE_AUTH,
auth: DEFAULT_GATEWAY_AUTH,
},
overrides,
);
}
describe("resolveGatewayCredentialsFromConfig", () => {
it("prefers explicit credentials over config and environment", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
explicitAuth: { token: "explicit-token", password: "explicit-password" },
});
const resolved = resolveGatewayCredentialsFor(
{
auth: DEFAULT_GATEWAY_AUTH,
},
{
explicitAuth: { token: "explicit-token", password: "explicit-password" },
},
);
expect(resolved).toEqual({
token: "explicit-token",
password: "explicit-password",
@@ -30,54 +67,27 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
it("returns empty credentials when url override is used without explicit auth", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
urlOverride: "wss://example.com",
});
const resolved = resolveGatewayCredentialsFor(
{
auth: DEFAULT_GATEWAY_AUTH,
},
{
urlOverride: "wss://example.com",
},
);
expect(resolved).toEqual({});
});
it("uses local-mode environment values before local config", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
});
expect(resolved).toEqual({
token: "env-token",
password: "env-password",
const resolved = resolveGatewayCredentialsFor({
mode: "local",
auth: DEFAULT_GATEWAY_AUTH,
});
expectEnvGatewayCredentials(resolved);
});
it("uses remote-mode remote credentials before env and local config", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "remote",
remote: { token: "remote-token", password: "remote-password" },
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
});
const resolved = resolveRemoteModeWithRemoteCredentials();
expect(resolved).toEqual({
token: "remote-token",
password: "env-password",
@@ -85,38 +95,16 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
it("falls back to env/config when remote mode omits remote credentials", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "remote",
remote: {},
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
});
expect(resolved).toEqual({
token: "env-token",
password: "env-password",
const resolved = resolveGatewayCredentialsFor({
mode: "remote",
remote: {},
auth: DEFAULT_GATEWAY_AUTH,
});
expectEnvGatewayCredentials(resolved);
});
it("supports env-first password override in remote mode for gateway call path", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "remote",
remote: { token: "remote-token", password: "remote-password" },
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
const resolved = resolveRemoteModeWithRemoteCredentials({
remotePasswordPrecedence: "env-first",
});
expect(resolved).toEqual({
@@ -125,6 +113,34 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
});
it("supports env-first token precedence in remote mode", () => {
const resolved = resolveRemoteModeWithRemoteCredentials({
remoteTokenPrecedence: "env-first",
remotePasswordPrecedence: "remote-first",
});
expect(resolved).toEqual({
token: "env-token",
password: "remote-password",
});
});
it("supports remote-only password fallback for strict remote override call sites", () => {
const resolved = resolveGatewayCredentialsFor(
{
mode: "remote",
remote: { token: "remote-token" },
auth: DEFAULT_GATEWAY_AUTH,
},
{
remotePasswordFallback: "remote-only",
},
);
expect(resolved).toEqual({
token: "remote-token",
password: undefined,
});
});
it("supports remote-only token fallback for strict remote override call sites", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({

View File

@@ -1,9 +1,8 @@
import type { IncomingMessage } from "node:http";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createMSTeamsTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
import {
extractHookToken,
@@ -130,7 +129,7 @@ describe("gateway hooks helpers", () => {
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({ aliases: ["teams"] }),
plugin: createMSTeamsTestPlugin({ aliases: ["teams"] }),
},
]),
);
@@ -308,20 +307,3 @@ describe("gateway hooks helpers", () => {
});
const emptyRegistry = createTestRegistry([]);
const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: params.aliases,
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
});

View File

@@ -20,6 +20,19 @@ const { sendGatewayAuthFailure } = await import("./http-common.js");
const { getBearerToken } = await import("./http-utils.js");
describe("authorizeGatewayBearerRequestOrReply", () => {
const bearerAuth = {
mode: "token",
token: "secret",
password: undefined,
allowTailscale: true,
} satisfies ResolvedGatewayAuth;
const makeAuthorizeParams = () => ({
req: {} as IncomingMessage,
res: {} as ServerResponse,
auth: bearerAuth,
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -31,16 +44,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => {
reason: "token_missing",
});
const ok = await authorizeGatewayBearerRequestOrReply({
req: {} as IncomingMessage,
res: {} as ServerResponse,
auth: {
mode: "token",
token: "secret",
password: undefined,
allowTailscale: true,
} satisfies ResolvedGatewayAuth,
});
const ok = await authorizeGatewayBearerRequestOrReply(makeAuthorizeParams());
expect(ok).toBe(false);
expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith(
@@ -55,16 +59,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => {
vi.mocked(getBearerToken).mockReturnValue("abc");
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true, method: "token" });
const ok = await authorizeGatewayBearerRequestOrReply({
req: {} as IncomingMessage,
res: {} as ServerResponse,
auth: {
mode: "token",
token: "secret",
password: undefined,
allowTailscale: true,
} satisfies ResolvedGatewayAuth,
});
const ok = await authorizeGatewayBearerRequestOrReply(makeAuthorizeParams());
expect(ok).toBe(true);
expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith(

View File

@@ -28,20 +28,26 @@ describe("gateway control-plane write rate limit", () => {
} as unknown as Parameters<typeof handleGatewayRequest>[0]["context"];
}
function buildConnect(): NonNullable<
Parameters<typeof handleGatewayRequest>[0]["client"]
>["connect"] {
return {
role: "operator",
scopes: ["operator.admin"],
client: {
id: "openclaw-control-ui",
version: "1.0.0",
platform: "darwin",
mode: "ui",
},
minProtocol: 1,
maxProtocol: 1,
};
}
function buildClient() {
return {
connect: {
role: "operator",
scopes: ["operator.admin"],
client: {
id: "openclaw-control-ui",
version: "1.0.0",
platform: "darwin",
mode: "ui",
},
minProtocol: 1,
maxProtocol: 1,
},
connect: buildConnect(),
connId: "conn-1",
clientIp: "10.0.0.5",
} as Parameters<typeof handleGatewayRequest>[0]["client"];
@@ -127,18 +133,7 @@ describe("gateway control-plane write rate limit", () => {
it("uses connId fallback when both device and client IP are unknown", () => {
const key = resolveControlPlaneRateLimitKey({
connect: {
role: "operator",
scopes: ["operator.admin"],
client: {
id: "openclaw-control-ui",
version: "1.0.0",
platform: "darwin",
mode: "ui",
},
minProtocol: 1,
maxProtocol: 1,
},
connect: buildConnect(),
connId: "conn-fallback",
});
expect(key).toBe("unknown-device|unknown-ip|conn=conn-fallback");
@@ -146,18 +141,7 @@ describe("gateway control-plane write rate limit", () => {
it("keeps device/IP-based key when identity is present", () => {
const key = resolveControlPlaneRateLimitKey({
connect: {
role: "operator",
scopes: ["operator.admin"],
client: {
id: "openclaw-control-ui",
version: "1.0.0",
platform: "darwin",
mode: "ui",
},
minProtocol: 1,
maxProtocol: 1,
},
connect: buildConnect(),
connId: "conn-fallback",
clientIp: "10.0.0.10",
});

View File

@@ -126,6 +126,13 @@ const createStubChannelPlugin = (params: {
},
});
const defaultDirectChannelEntries = [
{ id: "telegram", label: "Telegram" },
{ id: "discord", label: "Discord" },
{ id: "slack", label: "Slack" },
{ id: "signal", label: "Signal" },
] as const;
const defaultRegistry = createRegistry([
{
pluginId: "whatsapp",
@@ -141,26 +148,11 @@ const defaultRegistry = createRegistry([
},
}),
},
{
pluginId: "telegram",
...defaultDirectChannelEntries.map((entry) => ({
pluginId: entry.id,
source: "test",
plugin: createStubChannelPlugin({ id: "telegram", label: "Telegram" }),
},
{
pluginId: "discord",
source: "test",
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
},
{
pluginId: "slack",
source: "test",
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
},
{
pluginId: "signal",
source: "test",
plugin: createStubChannelPlugin({ id: "signal", label: "Signal" }),
},
plugin: createStubChannelPlugin({ id: entry.id, label: entry.label }),
})),
]);
describe("gateway server agent", () => {

View File

@@ -11,11 +11,12 @@ import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
import { createRegistry } from "./server.e2e-registry-helpers.js";
import {
agentCommand,
connectWebchatClient,
connectOk,
connectWebchatClient,
installGatewayTestHooks,
onceMessage,
rpcReq,
startConnectedServerWithClient,
startServerWithClient,
testState,
trackConnectChallengeNonce,
@@ -30,11 +31,10 @@ let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
const started = await startConnectedServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {

View File

@@ -174,155 +174,138 @@ async function withCanvasGatewayHarness(params: {
}
describe("gateway canvas host auth", () => {
test("authorizes canvas HTTP/WS via node-scoped capability and rejects misuse", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
const tokenResolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
const withLoopbackTrustedProxy = async (run: () => Promise<void>, prefix?: string) => {
await withTempConfig({
cfg: {
gateway: {
trustedProxies: ["127.0.0.1"],
},
},
prefix: "openclaw-canvas-auth-test-",
run: async () => {
await withCanvasGatewayHarness({
resolvedAuth,
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener, clients }) => {
const host = "127.0.0.1";
const operatorOnlyCapability = "operator-only";
const expiredNodeCapability = "expired-node";
const activeNodeCapability = "active-node";
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
const activeWsPath = scopedCanvasPath(activeNodeCapability, CANVAS_WS_PATH);
...(prefix ? { prefix } : {}),
run,
});
};
const unauthCanvas = await fetch(`http://${host}:${listener.port}${CANVAS_HOST_PATH}/`);
expect(unauthCanvas.status).toBe(401);
test("authorizes canvas HTTP/WS via node-scoped capability and rejects misuse", async () => {
await withLoopbackTrustedProxy(async () => {
await withCanvasGatewayHarness({
resolvedAuth: tokenResolvedAuth,
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener, clients }) => {
const host = "127.0.0.1";
const operatorOnlyCapability = "operator-only";
const expiredNodeCapability = "expired-node";
const activeNodeCapability = "active-node";
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
const activeWsPath = scopedCanvasPath(activeNodeCapability, CANVAS_WS_PATH);
const malformedScoped = await fetch(
`http://${host}:${listener.port}${CANVAS_CAPABILITY_PATH_PREFIX}/broken`,
);
expect(malformedScoped.status).toBe(401);
const unauthCanvas = await fetch(`http://${host}:${listener.port}${CANVAS_HOST_PATH}/`);
expect(unauthCanvas.status).toBe(401);
clients.add(
makeWsClient({
connId: "c-operator",
clientIp: "192.168.1.10",
role: "operator",
mode: "backend",
canvasCapability: operatorOnlyCapability,
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
}),
);
const malformedScoped = await fetch(
`http://${host}:${listener.port}${CANVAS_CAPABILITY_PATH_PREFIX}/broken`,
);
expect(malformedScoped.status).toBe(401);
const operatorCapabilityBlocked = await fetch(
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
);
expect(operatorCapabilityBlocked.status).toBe(401);
clients.add(
makeWsClient({
connId: "c-operator",
clientIp: "192.168.1.10",
role: "operator",
mode: "backend",
canvasCapability: operatorOnlyCapability,
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
}),
);
clients.add(
makeWsClient({
connId: "c-expired-node",
clientIp: "192.168.1.20",
role: "node",
mode: "node",
canvasCapability: expiredNodeCapability,
canvasCapabilityExpiresAtMs: Date.now() - 1,
}),
);
const operatorCapabilityBlocked = await fetch(
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
);
expect(operatorCapabilityBlocked.status).toBe(401);
const expiredCapabilityBlocked = await fetch(
`http://${host}:${listener.port}${scopedCanvasPath(expiredNodeCapability, `${CANVAS_HOST_PATH}/`)}`,
);
expect(expiredCapabilityBlocked.status).toBe(401);
const activeNodeClient = makeWsClient({
connId: "c-active-node",
clientIp: "192.168.1.30",
clients.add(
makeWsClient({
connId: "c-expired-node",
clientIp: "192.168.1.20",
role: "node",
mode: "node",
canvasCapability: activeNodeCapability,
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
});
clients.add(activeNodeClient);
canvasCapability: expiredNodeCapability,
canvasCapabilityExpiresAtMs: Date.now() - 1,
}),
);
const scopedCanvas = await fetch(`http://${host}:${listener.port}${activeCanvasPath}`);
expect(scopedCanvas.status).toBe(200);
expect(await scopedCanvas.text()).toBe("ok");
const expiredCapabilityBlocked = await fetch(
`http://${host}:${listener.port}${scopedCanvasPath(expiredNodeCapability, `${CANVAS_HOST_PATH}/`)}`,
);
expect(expiredCapabilityBlocked.status).toBe(401);
const scopedA2ui = await fetch(
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
);
expect(scopedA2ui.status).toBe(200);
const activeNodeClient = makeWsClient({
connId: "c-active-node",
clientIp: "192.168.1.30",
role: "node",
mode: "node",
canvasCapability: activeNodeCapability,
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
});
clients.add(activeNodeClient);
await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`);
const scopedCanvas = await fetch(`http://${host}:${listener.port}${activeCanvasPath}`);
expect(scopedCanvas.status).toBe(200);
expect(await scopedCanvas.text()).toBe("ok");
clients.delete(activeNodeClient);
const scopedA2ui = await fetch(
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
);
expect(scopedA2ui.status).toBe(200);
const disconnectedNodeBlocked = await fetch(
`http://${host}:${listener.port}${activeCanvasPath}`,
);
expect(disconnectedNodeBlocked.status).toBe(401);
await expectWsRejected(`ws://${host}:${listener.port}${activeWsPath}`, {});
},
});
},
});
await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`);
clients.delete(activeNodeClient);
const disconnectedNodeBlocked = await fetch(
`http://${host}:${listener.port}${activeCanvasPath}`,
);
expect(disconnectedNodeBlocked.status).toBe(401);
await expectWsRejected(`ws://${host}:${listener.port}${activeWsPath}`, {});
},
});
}, "openclaw-canvas-auth-test-");
}, 60_000);
test("denies canvas auth when trusted proxy omits forwarded client headers", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withLoopbackTrustedProxy(async () => {
await withCanvasGatewayHarness({
resolvedAuth: tokenResolvedAuth,
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener, clients }) => {
clients.add(
makeWsClient({
connId: "c-loopback-node",
clientIp: "127.0.0.1",
role: "node",
mode: "node",
canvasCapability: "unused",
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
}),
);
await withTempConfig({
cfg: {
gateway: {
trustedProxies: ["127.0.0.1"],
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
expect(res.status).toBe(401);
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {});
},
},
run: async () => {
await withCanvasGatewayHarness({
resolvedAuth,
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener, clients }) => {
clients.add(
makeWsClient({
connId: "c-loopback-node",
clientIp: "127.0.0.1",
role: "node",
mode: "node",
canvasCapability: "unused",
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
}),
);
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
expect(res.status).toBe(401);
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {});
},
});
},
});
});
}, 60_000);
test("accepts capability-scoped paths over IPv6 loopback", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: {
gateway: {
@@ -332,21 +315,9 @@ describe("gateway canvas host auth", () => {
run: async () => {
try {
await withCanvasGatewayHarness({
resolvedAuth,
resolvedAuth: tokenResolvedAuth,
listenHost: "::1",
handleHttpRequest: async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (
url.pathname !== CANVAS_HOST_PATH &&
!url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)
) {
return false;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("ok");
return true;
},
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener, clients }) => {
const capability = "ipv6-node";
clients.add(
@@ -380,54 +351,36 @@ describe("gateway canvas host auth", () => {
}, 60_000);
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withLoopbackTrustedProxy(async () => {
const rateLimiter = createAuthRateLimiter({
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: false,
});
await withCanvasGatewayHarness({
resolvedAuth: tokenResolvedAuth,
rateLimiter,
handleHttpRequest: async () => false,
run: async ({ listener }) => {
const headers = {
authorization: "Bearer wrong",
"x-forwarded-for": "203.0.113.99",
};
const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
headers,
});
expect(first.status).toBe(401);
await withTempConfig({
cfg: {
gateway: {
trustedProxies: ["127.0.0.1"],
const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
headers,
});
expect(second.status).toBe(429);
expect(second.headers.get("retry-after")).toBeTruthy();
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, headers, 429);
},
},
run: async () => {
const rateLimiter = createAuthRateLimiter({
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: false,
});
await withCanvasGatewayHarness({
resolvedAuth,
rateLimiter,
handleHttpRequest: async () => false,
run: async ({ listener }) => {
const headers = {
authorization: "Bearer wrong",
"x-forwarded-for": "203.0.113.99",
};
const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
headers,
});
expect(first.status).toBe(401);
const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
headers,
});
expect(second.status).toBe(429);
expect(second.headers.get("retry-after")).toBeTruthy();
await expectWsRejected(
`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`,
headers,
429,
);
},
});
},
});
});
}, 60_000);
});

View File

@@ -1,8 +1,7 @@
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js";
import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
import { createRegistry } from "./server.e2e-registry-helpers.js";
import {
connectOk,
@@ -16,34 +15,6 @@ let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
installGatewayTestHooks({ scope: "suite" });
const registryState = vi.hoisted(() => ({
registry: {
plugins: [],
tools: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
} as unknown as PluginRegistry,
}));
vi.mock("./server-plugins.js", async () => {
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
pluginRegistry: registryState.registry,
gatewayMethods: params.baseMethods ?? [],
};
},
};
});
const createStubChannelPlugin = (params: {
id: ChannelPlugin["id"];
label: string;
@@ -131,11 +102,6 @@ afterAll(async () => {
await server.close();
});
function setRegistry(registry: PluginRegistry) {
registryState.registry = registry;
setActivePluginRegistry(registry);
}
describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);

View File

@@ -22,6 +22,7 @@ import {
onceMessage,
piSdkMock,
rpcReq,
startConnectedServerWithClient,
startGatewayServer,
startServerWithClient,
testState,
@@ -35,19 +36,18 @@ let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
beforeAll(async () => {
const started = await startConnectedServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
});
const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ deps, to, text }) => {

View File

@@ -30,6 +30,15 @@ function createSymlinkOrSkip(targetPath: string, linkPath: string): boolean {
}
}
function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig {
return {
session: { mainKey: "main" },
agents: {
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
},
} as OpenClawConfig;
}
describe("gateway session utils", () => {
test("capArrayByJsonBytes trims from the front", () => {
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
@@ -243,12 +252,7 @@ describe("gateway session utils", () => {
return;
}
const cfg = {
session: { mainKey: "main" },
agents: {
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
},
} as OpenClawConfig;
const cfg = createSingleAgentAvatarConfig(workspace);
const result = listAgentsForGateway(cfg);
expect(result.agents[0]?.identity?.avatarUrl).toBeUndefined();
@@ -265,12 +269,7 @@ describe("gateway session utils", () => {
return;
}
const cfg = {
session: { mainKey: "main" },
agents: {
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
},
} as OpenClawConfig;
const cfg = createSingleAgentAvatarConfig(workspace);
const result = listAgentsForGateway(cfg);
expect(result.agents[0]?.identity?.avatarUrl).toBe(

View File

@@ -404,6 +404,15 @@ export async function startServerWithClient(
return { server, ws, port, prevToken: prev, envSnapshot };
}
export async function startConnectedServerWithClient(
token?: string,
opts?: GatewayServerOptions & { wsHeaders?: Record<string, string> },
) {
const started = await startServerWithClient(token, opts);
await connectOk(started.ws);
return started;
}
type ConnectResponse = {
type: "res";
id: string;

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { sendMessage, sendPoll } from "./message.js";
@@ -37,7 +37,7 @@ describe("sendMessage channel normalization", () => {
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
plugin: createMSTeamsTestPlugin({
outbound: createMSTeamsOutbound(),
aliases: ["teams"],
}),
@@ -131,7 +131,7 @@ describe("sendPoll channel normalization", () => {
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
plugin: createMSTeamsTestPlugin({
aliases: ["teams"],
outbound: createMSTeamsOutbound({ includePoll: true }),
}),
@@ -249,24 +249,3 @@ const createMattermostLikePlugin = (opts: {
sendMedia: async () => ({ channel: "mattermost", messageId: "m2" }),
},
});
const createMSTeamsPlugin = (params: {
aliases?: string[];
outbound: ChannelOutboundAdapter;
}): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: params.aliases,
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: params.outbound,
});

View File

@@ -72,6 +72,21 @@ export const createMSTeamsTestPluginBase = (): Pick<
};
};
export const createMSTeamsTestPlugin = (params?: {
aliases?: string[];
outbound?: ChannelOutboundAdapter;
}): ChannelPlugin => {
const base = createMSTeamsTestPluginBase();
return {
...base,
meta: {
...base.meta,
...(params?.aliases ? { aliases: params.aliases } : {}),
},
...(params?.outbound ? { outbound: params.outbound } : {}),
};
};
export const createOutboundTestPlugin = (params: {
id: ChannelId;
outbound: ChannelOutboundAdapter;

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import { setLoggerOverride } from "../logging.js";
import {
createWebListenerFactoryCapture,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
} from "./auto-reply.test-harness.js";
@@ -60,18 +61,11 @@ describe("web auto-reply monitor logging", () => {
const logPath = `/tmp/openclaw-log-test-${crypto.randomUUID()}.log`;
setLoggerOverride({ level: "trace", file: logPath });
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const capture = createWebListenerFactoryCapture();
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
await monitorWebChannel(false, listenerFactory as never, false, resolver as never);
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver as never);
const capturedOnMessage = capture.getOnMessage();
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({

View File

@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
createWebListenerFactoryCapture,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
makeSessionStore,
@@ -250,15 +251,7 @@ describe("web auto-reply", () => {
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const capture = createWebListenerFactoryCapture();
setLoadConfigMock(() => ({
agents: {
@@ -269,7 +262,8 @@ describe("web auto-reply", () => {
session: { store: store.storePath },
}));
await monitorWebChannel(false, listenerFactory as never, false, resolver);
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver);
const capturedOnMessage = capture.getOnMessage();
expect(capturedOnMessage).toBeDefined();
// Two messages from the same sender with fixed timestamps