fix: prevent DenchClaw gateway from hijacking OpenClaw's port 18789

The bootstrap port selection logic tried OpenClaw's default port (18789)
before the DenchClaw range (19001+). If OpenClaw was temporarily down
during bootstrap, DenchClaw would claim 18789 and persist it to config
and the LaunchAgent plist, killing OpenClaw on every subsequent restart.

- Remove the DEFAULT_GATEWAY_PORT (18789) branch from bootstrap; always
  start from DENCHCLAW_GATEWAY_PORT_START (19001)
- Read previously persisted config port and reuse it (unless it is 18789)
- Extract isPersistedPortAcceptable guard to reject corrupted 18789 state
- Add DENCHCLAW_DEFAULT_GATEWAY_PORT constant and make resolveGatewayPort
  profile-aware so the "dench" profile falls back to 19001
- Fix hardcoded 18789 fallback in web-runtime-command.ts
This commit is contained in:
kumarabhirup 2026-03-05 10:46:03 -08:00
parent b5557fb6dd
commit 4bcd47b848
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
3 changed files with 129 additions and 19 deletions

View File

@ -1,5 +1,5 @@
import { spawn, type StdioOptions } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { confirm, isCancel, spinner } from "@clack/prompts";
@ -20,7 +20,6 @@ import {
import { seedWorkspaceFromAssets, type WorkspaceSeedResult } from "./workspace-seed.js";
const DEFAULT_DENCHCLAW_PROFILE = "dench";
const DEFAULT_GATEWAY_PORT = 18789;
const DENCHCLAW_GATEWAY_PORT_START = 19001;
const MAX_PORT_SCAN_ATTEMPTS = 100;
const DEFAULT_BOOTSTRAP_ROLLOUT_STAGE = "default";
@ -44,7 +43,8 @@ export type BootstrapCheck = {
| "state-isolation"
| "daemon-label"
| "rollout-stage"
| "cutover-gates";
| "cutover-gates"
| "posthog-analytics";
status: BootstrapCheckStatus;
detail: string;
remediation?: string;
@ -303,6 +303,37 @@ async function findAvailablePort(
return undefined;
}
/**
* Port 18789 belongs to the host OpenClaw installation. A persisted config
* that drifted to that value (e.g. bootstrap ran while OpenClaw was down)
* must be rejected to prevent service hijack on launchd restart.
*/
export function isPersistedPortAcceptable(port: number | undefined): port is number {
return typeof port === "number" && port > 0 && port !== 18789;
}
export function readExistingGatewayPort(stateDir: string): number | undefined {
for (const name of ["openclaw.json", "config.json"]) {
try {
const raw = JSON.parse(readFileSync(path.join(stateDir, name), "utf-8")) as {
gateway?: { port?: unknown };
};
const port =
typeof raw.gateway?.port === "number"
? raw.gateway.port
: typeof raw.gateway?.port === "string"
? Number.parseInt(raw.gateway.port, 10)
: undefined;
if (typeof port === "number" && Number.isFinite(port) && port > 0) {
return port;
}
} catch {
// Config file missing or malformed — try next candidate.
}
}
return undefined;
}
function normalizeBootstrapRolloutStage(raw: string | undefined): BootstrapRolloutStage {
const normalized = raw?.trim().toLowerCase();
if (normalized === "internal" || normalized === "beta" || normalized === "default") {
@ -355,6 +386,48 @@ function resolveGatewayLaunchAgentLabel(profile: string): string {
return `ai.openclaw.${normalized}`;
}
async function installBundledPlugins(params: {
openclawCommand: string;
profile: string;
stateDir: string;
posthogKey: string;
}): Promise<boolean> {
try {
const pluginSrc = path.join(resolveCliPackageRoot(), "extensions", "posthog-analytics");
if (!existsSync(pluginSrc)) return false;
const pluginDest = path.join(params.stateDir, "extensions", "posthog-analytics");
mkdirSync(path.dirname(pluginDest), { recursive: true });
cpSync(pluginSrc, pluginDest, { recursive: true, force: true });
if (params.posthogKey) {
await runOpenClawOrThrow({
openclawCommand: params.openclawCommand,
args: [
"--profile", params.profile,
"config", "set",
"plugins.entries.posthog-analytics.enabled", "true",
],
timeoutMs: 30_000,
errorMessage: "Failed to enable posthog-analytics plugin.",
});
await runOpenClawOrThrow({
openclawCommand: params.openclawCommand,
args: [
"--profile", params.profile,
"config", "set",
"plugins.entries.posthog-analytics.config.apiKey", params.posthogKey,
],
timeoutMs: 30_000,
errorMessage: "Failed to set posthog-analytics API key.",
});
}
return true;
} catch {
return false;
}
}
async function ensureGatewayModeLocal(openclawCommand: string, profile: string): Promise<void> {
const result = await runOpenClaw(
openclawCommand,
@ -1257,6 +1330,7 @@ export function buildBootstrapDiagnostics(params: {
legacyFallbackEnabled: boolean;
stateDir?: string;
env?: NodeJS.ProcessEnv;
posthogPluginInstalled?: boolean;
}): BootstrapDiagnostics {
const env = params.env ?? process.env;
const checks: BootstrapCheck[] = [];
@ -1395,6 +1469,18 @@ export function buildBootstrapDiagnostics(params: {
),
);
if (params.posthogPluginInstalled != null) {
checks.push(
createCheck(
"posthog-analytics",
params.posthogPluginInstalled ? "pass" : "warn",
params.posthogPluginInstalled
? "PostHog analytics plugin installed."
: "PostHog analytics plugin not installed (POSTHOG_KEY missing or extension not bundled).",
),
);
}
return {
rolloutStage: params.rolloutStage,
legacyFallbackEnabled: params.legacyFallbackEnabled,
@ -1528,35 +1614,45 @@ export async function bootstrapCommand(
});
}
// Determine gateway port: use explicit override, or find available port
// Determine gateway port: use explicit override, honour previously persisted
// port, or find an available one in the DenchClaw range (19001+).
// NEVER claim OpenClaw's default port (18789) — that belongs to the host
// OpenClaw installation and sharing it causes port-hijack on restart.
const explicitPort = parseOptionalPort(opts.gatewayPort);
let gatewayPort: number;
let portAutoAssigned = false;
if (explicitPort) {
gatewayPort = explicitPort;
} else if (await isPortAvailable(DEFAULT_GATEWAY_PORT)) {
gatewayPort = DEFAULT_GATEWAY_PORT;
} else {
// Default port is taken, find an available one starting from DenchClaw range
const availablePort = await findAvailablePort(
DENCHCLAW_GATEWAY_PORT_START,
MAX_PORT_SCAN_ATTEMPTS,
);
if (!availablePort) {
throw new Error(
`Could not find an available gateway port between ${DENCHCLAW_GATEWAY_PORT_START} and ${DENCHCLAW_GATEWAY_PORT_START + MAX_PORT_SCAN_ATTEMPTS}. ` +
`Please specify a port explicitly with --gateway-port.`,
const existingPort = readExistingGatewayPort(stateDir);
if (
isPersistedPortAcceptable(existingPort) &&
(await isPortAvailable(existingPort))
) {
gatewayPort = existingPort;
} else if (await isPortAvailable(DENCHCLAW_GATEWAY_PORT_START)) {
gatewayPort = DENCHCLAW_GATEWAY_PORT_START;
} else {
const availablePort = await findAvailablePort(
DENCHCLAW_GATEWAY_PORT_START + 1,
MAX_PORT_SCAN_ATTEMPTS,
);
if (!availablePort) {
throw new Error(
`Could not find an available gateway port between ${DENCHCLAW_GATEWAY_PORT_START} and ${DENCHCLAW_GATEWAY_PORT_START + MAX_PORT_SCAN_ATTEMPTS}. ` +
`Please specify a port explicitly with --gateway-port.`,
);
}
gatewayPort = availablePort;
portAutoAssigned = true;
}
gatewayPort = availablePort;
portAutoAssigned = true;
}
if (portAutoAssigned && !opts.json) {
runtime.log(
theme.muted(
`Default gateway port ${DEFAULT_GATEWAY_PORT} is in use. Using auto-assigned port ${gatewayPort}.`,
`Default gateway port ${DENCHCLAW_GATEWAY_PORT_START} is in use. Using auto-assigned port ${gatewayPort}.`,
),
);
}
@ -1606,6 +1702,13 @@ export async function bootstrapCommand(
packageRoot,
});
const posthogPluginInstalled = await installBundledPlugins({
openclawCommand,
profile,
stateDir,
posthogKey: process.env.POSTHOG_KEY || "",
});
const postOnboardSpinner = !opts.json ? spinner() : null;
postOnboardSpinner?.start("Finalizing configuration…");
@ -1674,6 +1777,7 @@ export async function bootstrapCommand(
rolloutStage,
legacyFallbackEnabled,
stateDir,
posthogPluginInstalled,
});
const shouldOpen = !opts.noOpen && !opts.json;

View File

@ -6,6 +6,7 @@ import { confirm, isCancel, spinner } from "@clack/prompts";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { stylePromptMessage } from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { DENCHCLAW_DEFAULT_GATEWAY_PORT } from "../config/paths.js";
import { VERSION } from "../version.js";
import { applyCliProfileEnv } from "./profile.js";
import {
@ -230,7 +231,7 @@ function resolveGatewayPort(stateDir: string): number {
return port;
}
}
return 18789;
return DENCHCLAW_DEFAULT_GATEWAY_PORT;
}
function readConfigGatewayPort(configPath: string): number | undefined {

View File

@ -199,6 +199,7 @@ export function resolveDefaultConfigCandidates(
}
export const DEFAULT_GATEWAY_PORT = 18789;
export const DENCHCLAW_DEFAULT_GATEWAY_PORT = 19001;
/**
* Gateway lock directory (ephemeral).
@ -255,5 +256,9 @@ export function resolveGatewayPort(
return configPort;
}
}
const profile = env.OPENCLAW_PROFILE?.trim();
if (profile === "dench") {
return DENCHCLAW_DEFAULT_GATEWAY_PORT;
}
return DEFAULT_GATEWAY_PORT;
}