diff --git a/apps/macos/Sources/OpenClaw/AppProfile.swift b/apps/macos/Sources/OpenClaw/AppProfile.swift index e7ed225009df..7826a29eb563 100644 --- a/apps/macos/Sources/OpenClaw/AppProfile.swift +++ b/apps/macos/Sources/OpenClaw/AppProfile.swift @@ -82,6 +82,8 @@ struct AppProfile: Equatable, Sendable { var defaultGatewayPort: Int { guard let name else { return 18789 } + // Keep byte-for-byte aligned with src/config/paths.ts resolveGatewayPort so the app and CLI + // connect to the same profile Gateway. var hash: UInt32 = 2_166_136_261 for byte in name.utf8 { hash = (hash ^ UInt32(byte)) &* 16_777_619 diff --git a/extensions/diffs/src/url.ts b/extensions/diffs/src/url.ts index 6bf3ab7c1034..03e39437b714 100644 --- a/extensions/diffs/src/url.ts +++ b/extensions/diffs/src/url.ts @@ -3,8 +3,8 @@ import { resolveGatewayPublicOrigin, type OpenClawConfig, } from "openclaw/plugin-sdk/config-contracts"; +import { resolveGatewayPort } from "openclaw/plugin-sdk/core"; -const DEFAULT_GATEWAY_PORT = 18789; type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl"; export function buildViewerUrl(params: { @@ -55,8 +55,7 @@ export function normalizeViewerBaseUrl( function resolveGatewayBaseUrl(config: OpenClawConfig): string { const scheme = config.gateway?.tls?.enabled ? "https" : "http"; - const port = - typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT; + const port = resolveGatewayPort(config); const customHost = config.gateway?.customBindHost?.trim(); if (config.gateway?.bind === "custom" && customHost) { diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index f70583cf0885..666873851fc8 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -1,6 +1,7 @@ // Mattermost plugin module implements interactions behavior. import { createHmac } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveGatewayPort } from "openclaw/plugin-sdk/core"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { normalizeOptionalString, @@ -134,7 +135,7 @@ export function computeInteractionCallbackUrl( if (callbackBaseUrl) { return `${normalizeCallbackBaseUrl(callbackBaseUrl)}${path}`; } - const port = typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789; + const port = resolveGatewayPort(cfg); let host = cfg?.gateway?.customBindHost && !isWildcardBindHost(cfg.gateway.customBindHost) ? cfg.gateway.customBindHost.trim() diff --git a/extensions/mattermost/src/mattermost/monitor-slash.test.ts b/extensions/mattermost/src/mattermost/monitor-slash.test.ts index a054290b45f1..f6c0f52325a6 100644 --- a/extensions/mattermost/src/mattermost/monitor-slash.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-slash.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const listSkillCommandsForAgents = vi.hoisted(() => vi.fn()); -const parseTcpPort = vi.hoisted(() => vi.fn()); const fetchMattermostUserTeams = vi.hoisted(() => vi.fn()); const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value)); const isSlashCommandsEnabled = vi.hoisted(() => vi.fn()); @@ -13,7 +12,6 @@ const activateSlashCommands = vi.hoisted(() => vi.fn()); vi.mock("./runtime-api.js", () => ({ listSkillCommandsForAgents, - parseTcpPort, })); vi.mock("./client.js", async () => { @@ -60,7 +58,6 @@ describe("mattermost monitor slash", () => { beforeEach(() => { listSkillCommandsForAgents.mockReset(); - parseTcpPort.mockReset(); fetchMattermostUserTeams.mockReset(); normalizeMattermostBaseUrl.mockClear(); isSlashCommandsEnabled.mockReset(); @@ -95,7 +92,6 @@ describe("mattermost monitor slash", () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18888"); resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: true }); isSlashCommandsEnabled.mockReturnValue(true); - parseTcpPort.mockReturnValue(18888); fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]); resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash"); listSkillCommandsForAgents.mockReturnValue([ @@ -171,7 +167,6 @@ describe("mattermost monitor slash", () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "65536"); resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false }); isSlashCommandsEnabled.mockReturnValue(true); - parseTcpPort.mockReturnValue(null); fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]); resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash"); registerSlashCommands.mockResolvedValue([{ token: "token-1", trigger: "ping" }]); @@ -185,7 +180,6 @@ describe("mattermost monitor slash", () => { botUserId: "bot-user", }); - expect(parseTcpPort).toHaveBeenCalledWith("65536"); expect(resolveCallbackUrl).toHaveBeenCalledWith( expect.objectContaining({ gatewayPort: 18789 }), ); @@ -194,7 +188,6 @@ describe("mattermost monitor slash", () => { it("warns on loopback callback urls and reports partial team failures", async () => { resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false }); isSlashCommandsEnabled.mockReturnValue(true); - parseTcpPort.mockReturnValue(null); fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]); resolveCallbackUrl.mockReturnValue("http://127.0.0.1:18789/slash"); registerSlashCommands diff --git a/extensions/mattermost/src/mattermost/monitor-slash.ts b/extensions/mattermost/src/mattermost/monitor-slash.ts index ddce3ac511fe..3c52369bd5af 100644 --- a/extensions/mattermost/src/mattermost/monitor-slash.ts +++ b/extensions/mattermost/src/mattermost/monitor-slash.ts @@ -1,4 +1,5 @@ // Mattermost plugin module implements monitor slash behavior. +import { resolveGatewayPort } from "openclaw/plugin-sdk/core"; import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; import type { ResolvedMattermostAccount } from "./accounts.js"; import { @@ -6,12 +7,7 @@ import { normalizeMattermostBaseUrl, type MattermostClient, } from "./client.js"; -import { - listSkillCommandsForAgents, - parseTcpPort, - type OpenClawConfig, - type RuntimeEnv, -} from "./runtime-api.js"; +import { listSkillCommandsForAgents, type OpenClawConfig, type RuntimeEnv } from "./runtime-api.js"; import { DEFAULT_COMMAND_SPECS, isSlashCommandsEnabled, @@ -150,11 +146,9 @@ export async function registerMattermostMonitorSlashCommands(params: { try { const teams = await fetchMattermostUserTeams(params.client, params.botUserId); - const envPort = parseTcpPort(process.env.OPENCLAW_GATEWAY_PORT); - const slashGatewayPort = envPort ?? params.cfg.gateway?.port ?? 18789; const slashCallbackUrl = resolveCallbackUrl({ config: slashConfig, - gatewayPort: slashGatewayPort, + gatewayPort: resolveGatewayPort(params.cfg), gatewayHost: params.cfg.gateway?.customBindHost ?? undefined, }); diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts index fb1453dc87e4..37350c7c5924 100644 --- a/extensions/mattermost/src/mattermost/runtime-api.ts +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -37,4 +37,3 @@ export { readRequestBodyWithLimit, } from "openclaw/plugin-sdk/webhook-ingress"; export { isTrustedProxyAddress, resolveClientIp } from "openclaw/plugin-sdk/core"; -export { parseTcpPort } from "openclaw/plugin-sdk/number-runtime"; diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 4e8f1246f3c5..9a09f31f84c7 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -322,10 +322,36 @@ describe("oauth paths", () => { describe("gateway port resolution", () => { it("prefers numeric env values over config", () => { expect( - resolveGatewayPort({ gateway: { port: 19002 } }, envWith({ OPENCLAW_GATEWAY_PORT: "19001" })), + resolveGatewayPort( + { gateway: { port: 19002 } }, + envWith({ OPENCLAW_GATEWAY_PORT: "19001", OPENCLAW_PROFILE: "work" }), + ), ).toBe(19001); + expect( + resolveGatewayPort({ gateway: { port: 19002 } }, envWith({ OPENCLAW_PROFILE: "work" })), + ).toBe(19002); }); + it.each([ + { profile: "ct2", expected: 45696 }, + { profile: "p1402", expected: 55636 }, + { profile: "p2380", expected: 55636 }, + ])("derives the byte-exact profile port for $profile", ({ profile, expected }) => { + const port = resolveGatewayPort({}, envWith({ OPENCLAW_PROFILE: profile })); + expect(port).toBe(expected); + expect(port).toBeGreaterThanOrEqual(20000); + expect(port).toBeLessThan(60000); + }); + + it.each([undefined, "default", "Default", "../escape"])( + "keeps the default port for profile %j", + (profile) => { + expect(resolveGatewayPort({}, envWith({ OPENCLAW_PROFILE: profile }))).toBe( + DEFAULT_GATEWAY_PORT, + ); + }, + ); + it("accepts Compose-style IPv4 host publish values from env", () => { expect( resolveGatewayPort( diff --git a/src/config/paths.ts b/src/config/paths.ts index f7ffea7d893f..655e5919fa30 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveProfileStateDir } from "../cli/profile-utils.js"; +import { normalizeProfileName, resolveProfileStateDir } from "../cli/profile-utils.js"; import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js"; import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; import { parseTcpPort } from "../infra/tcp-port.js"; @@ -468,5 +468,15 @@ export function resolveGatewayPort( return configPort; } } - return DEFAULT_GATEWAY_PORT; + const profile = normalizeProfileName(env.OPENCLAW_PROFILE); + if (!profile) { + return DEFAULT_GATEWAY_PORT; + } + // Keep byte-for-byte aligned with AppProfile.defaultGatewayPort in + // apps/macos/Sources/OpenClaw/AppProfile.swift so both surfaces connect to the same Gateway. + let hash = 2_166_136_261; + for (const byte of Buffer.from(profile, "utf8")) { + hash = Math.imul(hash ^ byte, 16_777_619) >>> 0; + } + return 20_000 + (hash % 40_000); }