diff --git a/apps/web/app/api/profiles/route.test.ts b/apps/web/app/api/profiles/route.test.ts index 8ab5d8d7bda..a7b928a5a4e 100644 --- a/apps/web/app/api/profiles/route.test.ts +++ b/apps/web/app/api/profiles/route.test.ts @@ -63,7 +63,7 @@ describe("profiles API", () => { mockReadFile.mockImplementation((p) => { const s = String(p); if (s.includes("openclaw.json")) { - return JSON.stringify({ gateway: { mode: "local", port: 18789 } }) as never; + return JSON.stringify({ gateway: { mode: "local", port: 19001 } }) as never; } return "" as never; }); @@ -83,7 +83,7 @@ describe("profiles API", () => { name: "work", stateDir: STATE_DIR, isActive: true, - gateway: { mode: "local", port: 18789, url: "ws://127.0.0.1:18789" }, + gateway: { mode: "local", port: 19001, url: "ws://127.0.0.1:19001" }, }); }); diff --git a/apps/web/lib/agent-runner.test.ts b/apps/web/lib/agent-runner.test.ts index 61e10e2e839..fe3d29f0eb0 100644 --- a/apps/web/lib/agent-runner.test.ts +++ b/apps/web/lib/agent-runner.test.ts @@ -199,7 +199,7 @@ describe("agent-runner", () => { describe("buildConnectParams", () => { it("uses a client.id that the Gateway actually accepts (prevents connect rejection)", async () => { const { buildConnectParams } = await import("./agent-runner.js"); - const params = buildConnectParams({ url: "ws://127.0.0.1:18789" }) as { + const params = buildConnectParams({ url: "ws://127.0.0.1:19001" }) as { client: { id: string; mode: string }; }; expect(VALID_GATEWAY_CLIENT_IDS.has(params.client.id)).toBe(true); @@ -207,7 +207,7 @@ describe("agent-runner", () => { it("uses a client.mode the Gateway accepts (prevents schema validation failure)", async () => { const { buildConnectParams } = await import("./agent-runner.js"); - const params = buildConnectParams({ url: "ws://127.0.0.1:18789" }) as { + const params = buildConnectParams({ url: "ws://127.0.0.1:19001" }) as { client: { id: string; mode: string }; }; expect(VALID_GATEWAY_CLIENT_MODES.has(params.client.mode)).toBe(true); @@ -216,7 +216,7 @@ describe("agent-runner", () => { it("includes auth.token when settings have a token", async () => { const { buildConnectParams } = await import("./agent-runner.js"); const params = buildConnectParams({ - url: "ws://127.0.0.1:18789", + url: "ws://127.0.0.1:19001", token: "secret-token", }) as { auth?: { token?: string; password?: string } }; expect(params.auth?.token).toBe("secret-token"); @@ -225,7 +225,7 @@ describe("agent-runner", () => { it("includes auth.password when settings have a password", async () => { const { buildConnectParams } = await import("./agent-runner.js"); const params = buildConnectParams({ - url: "ws://127.0.0.1:18789", + url: "ws://127.0.0.1:19001", password: "secret-pass", }) as { auth?: { token?: string; password?: string } }; expect(params.auth?.password).toBe("secret-pass"); @@ -233,7 +233,7 @@ describe("agent-runner", () => { it("omits auth when no token or password is set", async () => { const { buildConnectParams } = await import("./agent-runner.js"); - const params = buildConnectParams({ url: "ws://127.0.0.1:18789" }) as { + const params = buildConnectParams({ url: "ws://127.0.0.1:19001" }) as { auth?: unknown; }; expect(params.auth).toBeUndefined(); @@ -241,7 +241,7 @@ describe("agent-runner", () => { it("requests protocol version 3 (current Gateway protocol)", async () => { const { buildConnectParams } = await import("./agent-runner.js"); - const params = buildConnectParams({ url: "ws://127.0.0.1:18789" }) as { + const params = buildConnectParams({ url: "ws://127.0.0.1:19001" }) as { minProtocol: number; maxProtocol: number; }; @@ -251,7 +251,7 @@ describe("agent-runner", () => { it("uses backend mode so sessions.patch is allowed", async () => { const { buildConnectParams } = await import("./agent-runner.js"); - const params = buildConnectParams({ url: "ws://127.0.0.1:18789" }) as { + const params = buildConnectParams({ url: "ws://127.0.0.1:19001" }) as { client: { mode: string }; }; expect(params.client.mode).toBe("backend"); @@ -259,7 +259,7 @@ describe("agent-runner", () => { it("advertises tool-events capability for tool stream parity", async () => { const { buildConnectParams } = await import("./agent-runner.js"); - const params = buildConnectParams({ url: "ws://127.0.0.1:18789" }) as { + const params = buildConnectParams({ url: "ws://127.0.0.1:19001" }) as { caps?: string[]; }; expect(Array.isArray(params.caps)).toBe(true); diff --git a/src/cli/bootstrap-external.test.ts b/src/cli/bootstrap-external.test.ts index e11d44a32df..57940564a65 100644 --- a/src/cli/bootstrap-external.test.ts +++ b/src/cli/bootstrap-external.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { buildBootstrapDiagnostics, checkAgentAuth, + isPersistedPortAcceptable, + readExistingGatewayPort, resolveBootstrapRolloutStage, isLegacyFallbackEnabled, type BootstrapDiagnostics, @@ -67,8 +69,8 @@ describe("bootstrap-external diagnostics", () => { profile: "dench", openClawCliAvailable: true, openClawVersion: "2026.3.1", - gatewayPort: 18789, - gatewayUrl: "ws://127.0.0.1:18789", + gatewayPort: 19001, + gatewayUrl: "ws://127.0.0.1:19001", gatewayProbe: { ok: true as const }, webPort: 3100, webReachable: true, @@ -293,3 +295,114 @@ describe("bootstrap-external rollout env helpers", () => { expect(isLegacyFallbackEnabled({})).toBe(false); }); }); + +describe("readExistingGatewayPort", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = createTempStateDir(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("reads numeric port from openclaw.json (normal config path)", () => { + writeConfig(stateDir, { gateway: { port: 19001 } }); + expect(readExistingGatewayPort(stateDir)).toBe(19001); + }); + + it("falls back to config.json when openclaw.json is absent (legacy config support)", () => { + writeFileSync( + path.join(stateDir, "config.json"), + JSON.stringify({ gateway: { port: 19005 } }), + ); + expect(readExistingGatewayPort(stateDir)).toBe(19005); + }); + + it("prefers openclaw.json over config.json when both exist (config precedence)", () => { + writeConfig(stateDir, { gateway: { port: 19001 } }); + writeFileSync( + path.join(stateDir, "config.json"), + JSON.stringify({ gateway: { port: 19099 } }), + ); + expect(readExistingGatewayPort(stateDir)).toBe(19001); + }); + + it("returns undefined when no config files exist (fresh install)", () => { + expect(readExistingGatewayPort(stateDir)).toBeUndefined(); + }); + + it("returns undefined when config has no gateway section (incomplete config)", () => { + writeConfig(stateDir, { agents: {} }); + expect(readExistingGatewayPort(stateDir)).toBeUndefined(); + }); + + it("parses string port values (handles config.set serialization)", () => { + writeConfig(stateDir, { gateway: { port: "19001" } }); + expect(readExistingGatewayPort(stateDir)).toBe(19001); + }); + + it("rejects zero and negative ports (invalid port values)", () => { + writeConfig(stateDir, { gateway: { port: 0 } }); + expect(readExistingGatewayPort(stateDir)).toBeUndefined(); + + writeConfig(stateDir, { gateway: { port: -1 } }); + expect(readExistingGatewayPort(stateDir)).toBeUndefined(); + }); + + it("returns undefined for malformed JSON (handles corrupt config gracefully)", () => { + writeFileSync(path.join(stateDir, "openclaw.json"), "not valid json{{{"); + expect(readExistingGatewayPort(stateDir)).toBeUndefined(); + }); + + it("returns 18789 when config has it (reader does not filter; caller must guard)", () => { + writeConfig(stateDir, { gateway: { port: 18789 } }); + expect(readExistingGatewayPort(stateDir)).toBe(18789); + }); +}); + +describe("isPersistedPortAcceptable", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = createTempStateDir(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("rejects 18789 (prevents OpenClaw port hijack on launchd restart)", () => { + expect(isPersistedPortAcceptable(18789)).toBe(false); + }); + + it("accepts DenchClaw's own port range (normal operation)", () => { + expect(isPersistedPortAcceptable(19001)).toBe(true); + expect(isPersistedPortAcceptable(19002)).toBe(true); + expect(isPersistedPortAcceptable(19100)).toBe(true); + }); + + it("rejects undefined (no persisted port to reuse)", () => { + expect(isPersistedPortAcceptable(undefined)).toBe(false); + }); + + it("rejects zero and negative values (invalid ports)", () => { + expect(isPersistedPortAcceptable(0)).toBe(false); + expect(isPersistedPortAcceptable(-1)).toBe(false); + }); + + it("rejects corrupted 18789 from config (end-to-end: read + guard prevents port hijack)", () => { + writeConfig(stateDir, { gateway: { port: 18789 } }); + const port = readExistingGatewayPort(stateDir); + expect(port).toBe(18789); + expect(isPersistedPortAcceptable(port)).toBe(false); + }); + + it("accepts valid 19001 from config (end-to-end: read + guard allows DenchClaw port)", () => { + writeConfig(stateDir, { gateway: { port: 19001 } }); + const port = readExistingGatewayPort(stateDir); + expect(port).toBe(19001); + expect(isPersistedPortAcceptable(port)).toBe(true); + }); +}); diff --git a/src/cli/web-runtime-command.test.ts b/src/cli/web-runtime-command.test.ts index 498c862ff59..4c0c6fd228c 100644 --- a/src/cli/web-runtime-command.test.ts +++ b/src/cli/web-runtime-command.test.ts @@ -28,7 +28,7 @@ const webRuntimeMocks = vi.hoisted(() => ({ deployedAt: "2026-01-01T00:00:00.000Z", sourceStandaloneServer: "/tmp/server.js", lastPort: 3100, - lastGatewayPort: 18789, + lastGatewayPort: 19001, })), resolveCliPackageRoot: vi.fn(() => "/tmp/pkg"), resolveManagedWebRuntimeServerPath: vi.fn(() => "/tmp/.openclaw-dench/web-runtime/app/server.js"), @@ -143,7 +143,7 @@ describe("updateWebRuntimeCommand", () => { deployedAt: "2026-01-01T00:00:00.000Z", sourceStandaloneServer: "/tmp/server.js", lastPort: 3100, - lastGatewayPort: 18789, + lastGatewayPort: 19001, })); webRuntimeMocks.startManagedWebRuntime.mockReset(); webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({ @@ -325,12 +325,38 @@ describe("startWebRuntimeCommand", () => { expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, - gatewayPort: 18789, + gatewayPort: 19001, }); expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(summary.started).toBe(true); }); + + it("falls back to DenchClaw port 19001 when manifest has no lastGatewayPort (prevents 18789 hijack)", async () => { + webRuntimeMocks.readManagedWebRuntimeManifest.mockReturnValue({ + schemaVersion: 1, + deployedDenchVersion: "2.1.0", + deployedAt: "2026-01-01T00:00:00.000Z", + sourceStandaloneServer: "/tmp/server.js", + lastPort: 3100, + }); + const runtime = runtimeStub(); + await startWebRuntimeCommand({ webPort: "3100" }, runtime); + + expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith( + expect.objectContaining({ gatewayPort: 19001 }), + ); + }); + + it("falls back to DenchClaw port 19001 when manifest is null (fresh install, prevents 18789 hijack)", async () => { + webRuntimeMocks.readManagedWebRuntimeManifest.mockReturnValue(null); + const runtime = runtimeStub(); + await startWebRuntimeCommand({ webPort: "3100" }, runtime); + + expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith( + expect.objectContaining({ gatewayPort: 19001 }), + ); + }); }); describe("restartWebRuntimeCommand", () => { @@ -378,7 +404,7 @@ describe("restartWebRuntimeCommand", () => { expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, - gatewayPort: 18789, + gatewayPort: 19001, }); expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled(); expect(summary.started).toBe(true); diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts new file mode 100644 index 00000000000..ada552fea43 --- /dev/null +++ b/src/config/paths.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + resolveGatewayPort, + DEFAULT_GATEWAY_PORT, + DENCHCLAW_DEFAULT_GATEWAY_PORT, +} from "./paths.js"; + +describe("resolveGatewayPort", () => { + it("returns DenchClaw port when profile is dench and no config/env override (prevents OpenClaw port hijack)", () => { + const port = resolveGatewayPort(undefined, { OPENCLAW_PROFILE: "dench" }); + expect(port).toBe(19001); + expect(port).not.toBe(DEFAULT_GATEWAY_PORT); + }); + + it("returns OpenClaw default when no profile is set (preserves host gateway default)", () => { + expect(resolveGatewayPort(undefined, {})).toBe(18789); + }); + + it("env OPENCLAW_GATEWAY_PORT overrides profile-based default (supports runtime override)", () => { + expect( + resolveGatewayPort(undefined, { + OPENCLAW_PROFILE: "dench", + OPENCLAW_GATEWAY_PORT: "19500", + }), + ).toBe(19500); + }); + + it("legacy env CLAWDBOT_GATEWAY_PORT is still honoured (backwards compatibility)", () => { + expect( + resolveGatewayPort(undefined, { CLAWDBOT_GATEWAY_PORT: "19500" }), + ).toBe(19500); + }); + + it("config port overrides profile-based default (honours persisted config)", () => { + expect( + resolveGatewayPort( + { gateway: { port: 19005 } }, + { OPENCLAW_PROFILE: "dench" }, + ), + ).toBe(19005); + }); + + it("env var takes precedence over config port (explicit runtime override wins)", () => { + expect( + resolveGatewayPort( + { gateway: { port: 19005 } }, + { OPENCLAW_GATEWAY_PORT: "19500" }, + ), + ).toBe(19500); + }); + + it("ignores non-numeric env values and falls through to profile default (malformed input)", () => { + expect( + resolveGatewayPort(undefined, { + OPENCLAW_PROFILE: "dench", + OPENCLAW_GATEWAY_PORT: "not-a-number", + }), + ).toBe(DENCHCLAW_DEFAULT_GATEWAY_PORT); + }); + + it("ignores zero and negative config ports (invalid config)", () => { + expect(resolveGatewayPort({ gateway: { port: 0 } }, { OPENCLAW_PROFILE: "dench" })).toBe( + DENCHCLAW_DEFAULT_GATEWAY_PORT, + ); + expect(resolveGatewayPort({ gateway: { port: -1 } }, { OPENCLAW_PROFILE: "dench" })).toBe( + DENCHCLAW_DEFAULT_GATEWAY_PORT, + ); + }); + + it("treats whitespace-only env as absent (trims before parsing)", () => { + expect( + resolveGatewayPort(undefined, { + OPENCLAW_PROFILE: "dench", + OPENCLAW_GATEWAY_PORT: " ", + }), + ).toBe(DENCHCLAW_DEFAULT_GATEWAY_PORT); + }); + + it("undefined config falls through to profile/global default", () => { + expect(resolveGatewayPort(undefined, {})).toBe(DEFAULT_GATEWAY_PORT); + expect(resolveGatewayPort({}, {})).toBe(DEFAULT_GATEWAY_PORT); + expect(resolveGatewayPort({ gateway: {} }, { OPENCLAW_PROFILE: "dench" })).toBe( + DENCHCLAW_DEFAULT_GATEWAY_PORT, + ); + }); +}); + +describe("port constants", () => { + it("DenchClaw default port is distinct from OpenClaw default (prevents port collision)", () => { + expect(DENCHCLAW_DEFAULT_GATEWAY_PORT).not.toBe(DEFAULT_GATEWAY_PORT); + expect(DENCHCLAW_DEFAULT_GATEWAY_PORT).toBe(19001); + expect(DEFAULT_GATEWAY_PORT).toBe(18789); + }); +});