diff --git a/src/discord/draft-stream.ts b/src/discord/draft-stream.ts index cfc1871d45a..0281d4c0227 100644 --- a/src/discord/draft-stream.ts +++ b/src/discord/draft-stream.ts @@ -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", }); diff --git a/src/gateway/credential-precedence.parity.test.ts b/src/gateway/credential-precedence.parity.test.ts index b7263d4ff3c..99a893fcb83 100644 --- a/src/gateway/credential-precedence.parity.test.ts +++ b/src/gateway/credential-precedence.parity.test.ts @@ -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(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" }, diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 52b61143dce..83ac99dbc80 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -9,20 +9,57 @@ function cfg(input: Partial): OpenClawConfig { return input as OpenClawConfig; } +type ResolveFromConfigInput = Parameters[0]; +type GatewayConfig = NonNullable; + +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> = {}, +) { + 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> = {}, +) { + 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({ diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 445f9975a8a..fe60d792af0 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -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: () => ({}), - }, -}); diff --git a/src/gateway/http-auth-helpers.test.ts b/src/gateway/http-auth-helpers.test.ts index aa3d83d5ba6..e8c611b7229 100644 --- a/src/gateway/http-auth-helpers.test.ts +++ b/src/gateway/http-auth-helpers.test.ts @@ -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( diff --git a/src/gateway/server-methods.control-plane-rate-limit.test.ts b/src/gateway/server-methods.control-plane-rate-limit.test.ts index 364e817c66a..2b0247b04dd 100644 --- a/src/gateway/server-methods.control-plane-rate-limit.test.ts +++ b/src/gateway/server-methods.control-plane-rate-limit.test.ts @@ -28,20 +28,26 @@ describe("gateway control-plane write rate limit", () => { } as unknown as Parameters[0]["context"]; } + function buildConnect(): NonNullable< + Parameters[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[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", }); diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index c6b54e189e1..9c69c29ff10 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -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", () => { diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 0515c14c18b..bd4364aba75 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -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>["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 () => { diff --git a/src/gateway/server.canvas-auth.test.ts b/src/gateway/server.canvas-auth.test.ts index 02d99ed394b..ab0a7c9d89d 100644 --- a/src/gateway/server.canvas-auth.test.ts +++ b/src/gateway/server.canvas-auth.test.ts @@ -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, 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); }); diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.test.ts index c6976493bda..2588427e3d4 100644 --- a/src/gateway/server.channels.test.ts +++ b/src/gateway/server.channels.test.ts @@ -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); diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 99e3b945e8e..a0b92f3c3aa 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -22,6 +22,7 @@ import { onceMessage, piSdkMock, rpcReq, + startConnectedServerWithClient, startGatewayServer, startServerWithClient, testState, @@ -35,19 +36,18 @@ let server: Awaited>["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 }) => { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index c33ff6f96e5..5ad550eb0ed 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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( diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 0d79144eda8..e923a3bbb3e 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -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 }, +) { + const started = await startServerWithClient(token, opts); + await connectOk(started.ws); + return started; +} + type ConnectResponse = { type: "res"; id: string; diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 780f5636577..39e83c8ad70 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -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, -}); diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 4de1680339b..64e24deab52 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -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; diff --git a/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts b/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts index f9469061911..6703ad7f308 100644 --- a/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts +++ b/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts @@ -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) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - 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?.({ diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index abe3a0cbb13..80e46446af0 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -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) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - 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