diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md
index 32f45127ea6..93983dd7ad2 100644
--- a/docs/gateway/doctor.md
+++ b/docs/gateway/doctor.md
@@ -333,6 +333,9 @@ That stages grounded durable candidates into the short-term dreaming store while
Doctor detects legacy gateway services (launchd/systemd/schtasks) and offers to remove them and install the OpenClaw service using the current gateway port. It can also scan for extra gateway-like services and print cleanup hints. Profile-named OpenClaw gateway services are considered first-class and are not flagged as "extra."
+
+ On Linux, if the user-level gateway service is missing but a system-level OpenClaw gateway service exists, doctor does not install a second user-level service automatically. Inspect with `openclaw gateway status --deep` or `openclaw doctor --deep`, then remove the duplicate or set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when a system supervisor owns the gateway lifecycle.
+
When a Matrix channel account has a pending or actionable legacy state migration, doctor (in `--fix` / `--repair` mode) creates a pre-migration snapshot and then runs the best-effort migration steps: legacy Matrix state migration and legacy encrypted-state preparation. Both steps are non-fatal; errors are logged and startup continues. In read-only mode (`openclaw doctor` without `--fix`) this check is skipped entirely.
@@ -439,6 +442,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
- Doctor detects managed `.env`/SecretRef-backed service environment values that older LaunchAgent, systemd, or Windows Scheduled Task installs embedded inline and rewrites the service metadata so those values load from the runtime source instead of the supervisor definition.
+ - Doctor detects when the service command still pins an old `--port` after `gateway.port` changes and rewrites the service metadata to the current port.
- If token auth requires a token and the configured token SecretRef is unresolved, doctor blocks the install/repair path with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, doctor blocks install/repair until mode is set explicitly.
- For Linux user-systemd units, doctor token drift checks now include both `Environment=` and `EnvironmentFile=` sources when comparing service auth metadata.
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index d527be86dd8..407adebb69a 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -112,6 +112,8 @@ All of these run on the main Gateway port and use the same trusted operator auth
| Gateway port | `--port` → `OPENCLAW_GATEWAY_PORT` → `gateway.port` → `18789` |
| Bind mode | CLI/override → `gateway.bind` → `loopback` |
+Installed gateway services record the resolved `--port` in supervisor metadata. After changing `gateway.port`, run `openclaw doctor --fix` or `openclaw gateway install --force` so launchd/systemd/schtasks starts the process on the new port.
+
Gateway startup uses the same effective port and bind when it seeds local
Control UI origins for non-loopback binds. For example, `--bind lan --port 3000`
seeds `http://localhost:3000` and `http://127.0.0.1:3000` before runtime
@@ -323,6 +325,8 @@ Use the same service body as the user unit, but install it under
`/etc/systemd/system/openclaw-gateway[-].service` and adjust
`ExecStart=` if your `openclaw` binary lives elsewhere.
+Do not also let `openclaw doctor --fix` install a user-level gateway service for the same profile/port. Doctor refuses that automatic install when it finds a system-level OpenClaw gateway service; use `OPENCLAW_SERVICE_REPAIR_POLICY=external` when the system unit owns the lifecycle.
+
diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 32e669ffe2e..88be26f9d0d 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -286,6 +286,8 @@ Look for:
- `refusing to bind gateway ... without auth` → non-loopback bind without a valid gateway auth path (token/password, or trusted-proxy where configured).
- `another gateway instance is already listening` / `EADDRINUSE` → port conflict.
- `Other gateway-like services detected (best effort)` → stale or parallel launchd/systemd/schtasks units exist. Most setups should keep one gateway per machine; if you do need more than one, isolate ports + config/state/workspace. See [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host).
+ - `System-level OpenClaw gateway service detected` from doctor → a systemd system unit exists while the user-level service is missing. Remove or disable the duplicate before allowing doctor to install a user service, or set `OPENCLAW_SERVICE_REPAIR_POLICY=external` if the system unit is the intended supervisor.
+ - `Gateway service port does not match current gateway config` → the installed supervisor still pins the old `--port`. Run `openclaw doctor --fix` or `openclaw gateway install --force`, then restart the gateway service.
diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts
index 9fae857742d..37034f55c87 100644
--- a/src/commands/doctor-gateway-daemon-flow.test.ts
+++ b/src/commands/doctor-gateway-daemon-flow.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ExtraGatewayService } from "../daemon/inspect.js";
import * as launchd from "../daemon/launchd.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createDoctorPrompter } from "./doctor-prompter.js";
@@ -17,6 +18,9 @@ const sleep = vi.hoisted(() => vi.fn(async () => {}));
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
const inspectPortUsage = vi.hoisted(() => vi.fn());
const readLastGatewayErrorLine = vi.hoisted(() => vi.fn(async () => null));
+const findSystemGatewayServices = vi.hoisted(() =>
+ vi.fn<() => Promise>(async () => []),
+);
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual("../config/config.js");
@@ -47,6 +51,10 @@ vi.mock("../daemon/launchd.js", async () => {
};
});
+vi.mock("../daemon/inspect.js", () => ({
+ findSystemGatewayServices,
+}));
+
vi.mock("../daemon/service.js", async () => {
const actual =
await vi.importActual("../daemon/service.js");
@@ -126,6 +134,7 @@ describe("maybeRepairGatewayDaemon", () => {
service.isLoaded.mockResolvedValue(true);
service.readRuntime.mockResolvedValue({ status: "running" });
service.restart.mockResolvedValue({ outcome: "completed" });
+ findSystemGatewayServices.mockResolvedValue([]);
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "free",
@@ -268,6 +277,31 @@ describe("maybeRepairGatewayDaemon", () => {
expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
});
+ it("skips gateway service install when a system OpenClaw gateway service exists", async () => {
+ setPlatform("linux");
+ service.isLoaded.mockResolvedValue(false);
+ findSystemGatewayServices.mockResolvedValue([
+ {
+ platform: "linux",
+ label: "openclaw-gateway.service",
+ detail: "unit: /etc/systemd/system/openclaw-gateway.service",
+ scope: "system",
+ marker: "openclaw",
+ legacy: false,
+ },
+ ]);
+
+ await runAutoRepair();
+
+ expect(findSystemGatewayServices).toHaveBeenCalledTimes(1);
+ expect(service.install).not.toHaveBeenCalled();
+ expect(service.restart).not.toHaveBeenCalled();
+ expect(note).toHaveBeenCalledWith(
+ expect.stringContaining("System-level OpenClaw gateway service detected"),
+ "Gateway",
+ );
+ });
+
it("skips gateway service start when service repair policy is external", async () => {
setPlatform("linux");
service.readRuntime.mockResolvedValue({ status: "stopped" });
diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts
index dade4659c74..fa0d4d922d8 100644
--- a/src/commands/doctor-gateway-daemon-flow.ts
+++ b/src/commands/doctor-gateway-daemon-flow.ts
@@ -6,6 +6,7 @@ import {
resolveNodeLaunchAgentLabel,
} from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
+import { findSystemGatewayServices, type ExtraGatewayService } from "../daemon/inspect.js";
import {
isLaunchAgentLoaded,
launchAgentPlistExists,
@@ -32,6 +33,7 @@ import {
EXTERNAL_SERVICE_REPAIR_NOTE,
isServiceRepairExternallyManaged,
resolveServiceRepairPolicy,
+ SERVICE_REPAIR_POLICY_ENV,
} from "./doctor-service-repair-policy.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
import { formatHealthCheckFailure } from "./health-format.js";
@@ -91,6 +93,16 @@ async function maybeRepairLaunchAgentBootstrap(params: {
return true;
}
+function renderBlockingSystemGatewayServices(services: ExtraGatewayService[]): string {
+ return [
+ "System-level OpenClaw gateway service detected while the user gateway service is not installed.",
+ ...services.map((svc) => `- ${svc.label} (${svc.detail})`),
+ "OpenClaw will not install a second user-level gateway service automatically.",
+ "Run `openclaw gateway status --deep` or `openclaw doctor --deep` to inspect duplicate services.",
+ `Set ${SERVICE_REPAIR_POLICY_ENV}=external if a system supervisor owns the gateway lifecycle.`,
+ ].join("\n");
+}
+
export async function maybeRepairGatewayDaemon(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
@@ -171,6 +183,13 @@ export async function maybeRepairGatewayDaemon(params: {
}
note("Gateway service not installed.", "Gateway");
if (params.cfg.gateway?.mode !== "remote") {
+ if (process.platform === "linux") {
+ const systemGatewayServices = await findSystemGatewayServices();
+ if (systemGatewayServices.length > 0) {
+ note(renderBlockingSystemGatewayServices(systemGatewayServices), "Gateway");
+ return;
+ }
+ }
if (serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
return;
diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts
index c9c232ebb62..3af58045e61 100644
--- a/src/commands/doctor-gateway-services.test.ts
+++ b/src/commands/doctor-gateway-services.test.ts
@@ -69,6 +69,7 @@ vi.mock("../daemon/service-audit.js", () => ({
SERVICE_AUDIT_CODES: {
gatewayEntrypointMismatch: testServiceAuditCodes.gatewayEntrypointMismatch,
gatewayManagedEnvEmbedded: testServiceAuditCodes.gatewayManagedEnvEmbedded,
+ gatewayPortMismatch: testServiceAuditCodes.gatewayPortMismatch,
gatewayProxyEnvEmbedded: testServiceAuditCodes.gatewayProxyEnvEmbedded,
gatewayTokenMismatch: testServiceAuditCodes.gatewayTokenMismatch,
},
@@ -230,6 +231,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
fsMocks.realpath.mockImplementation(async (value: string) => value);
+ mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.resolveGatewayAuthTokenForService.mockImplementation(async (cfg: OpenClawConfig, env) => {
const configToken =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : undefined;
@@ -322,6 +324,44 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(mocks.install).toHaveBeenCalledTimes(1);
});
+ it("repairs gateway services whose pinned port differs from current config", async () => {
+ mocks.resolveGatewayPort.mockReturnValue(18888);
+ mocks.readCommand.mockResolvedValue({
+ programArguments: gatewayProgramArguments,
+ environment: {},
+ });
+ mocks.buildGatewayInstallPlan.mockResolvedValue({
+ programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18888"],
+ workingDirectory: "/tmp",
+ environment: {},
+ });
+ mocks.auditGatewayServiceConfig.mockResolvedValue({
+ ok: false,
+ issues: [
+ {
+ code: "gateway-port-mismatch",
+ message: "Gateway service port does not match current gateway config.",
+ detail: "18789 -> 18888",
+ level: "recommended",
+ },
+ ],
+ });
+ mocks.install.mockResolvedValue(undefined);
+
+ await runRepair({ gateway: { port: 18888 } });
+
+ expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ expectedPort: 18888,
+ }),
+ );
+ expect(mocks.install).toHaveBeenCalledWith(
+ expect.objectContaining({
+ programArguments: expect.arrayContaining(["18888"]),
+ }),
+ );
+ });
+
it("repairs gateway services with embedded proxy environment values", async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts
index 8fc696a2913..b4c8b8bf3f0 100644
--- a/src/commands/doctor-gateway-services.ts
+++ b/src/commands/doctor-gateway-services.ts
@@ -318,6 +318,7 @@ export async function maybeRepairGatewayServiceConfig(
command,
expectedGatewayToken,
expectedManagedServiceEnvKeys,
+ expectedPort: port,
});
const serviceToken = readEmbeddedGatewayToken(command);
if (tokenRefConfigured && serviceToken) {
diff --git a/src/commands/doctor-service-audit.test-helpers.ts b/src/commands/doctor-service-audit.test-helpers.ts
index 7a017348b48..c4aca110696 100644
--- a/src/commands/doctor-service-audit.test-helpers.ts
+++ b/src/commands/doctor-service-audit.test-helpers.ts
@@ -5,6 +5,7 @@ import { normalizeOptionalString } from "../shared/string-coerce.js";
export const testServiceAuditCodes = {
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayManagedEnvEmbedded: "gateway-managed-env-embedded",
+ gatewayPortMismatch: "gateway-port-mismatch",
gatewayProxyEnvEmbedded: "gateway-proxy-env-embedded",
gatewayTokenMismatch: "gateway-token-mismatch",
} as const;
diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts
index bbaa235f7de..8945d8dd1d2 100644
--- a/src/daemon/inspect.ts
+++ b/src/daemon/inspect.ts
@@ -245,12 +245,13 @@ async function scanLaunchdDir(params: {
async function scanSystemdDir(params: {
dir: string;
scope: "user" | "system";
+ includeManagedOpenClaw?: boolean;
}): Promise {
const results: ExtraGatewayService[] = [];
const candidates = await collectServiceFiles({
dir: params.dir,
extension: ".service",
- isIgnoredName: isIgnoredSystemdName,
+ isIgnoredName: params.includeManagedOpenClaw ? () => false : isIgnoredSystemdName,
});
for (const { entry, name, fullPath, contents } of candidates) {
@@ -258,7 +259,11 @@ async function scanSystemdDir(params: {
if (!marker) {
continue;
}
- if (marker === "openclaw" && isOpenClawGatewaySystemdService(name, contents)) {
+ if (
+ !params.includeManagedOpenClaw &&
+ marker === "openclaw" &&
+ isOpenClawGatewaySystemdService(name, contents)
+ ) {
continue;
}
results.push({
@@ -274,6 +279,29 @@ async function scanSystemdDir(params: {
return results;
}
+export async function findSystemGatewayServices(): Promise {
+ if (process.platform !== "linux") {
+ return [];
+ }
+
+ const results: ExtraGatewayService[] = [];
+ try {
+ for (const dir of ["/etc/systemd/system", "/usr/lib/systemd/system", "/lib/systemd/system"]) {
+ results.push(
+ ...(await scanSystemdDir({
+ dir,
+ scope: "system",
+ includeManagedOpenClaw: true,
+ })),
+ );
+ }
+ } catch {
+ return [];
+ }
+
+ return results;
+}
+
type ScheduledTaskInfo = {
name: string;
taskToRun?: string;
diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts
index 55741e3aad3..8c197654312 100644
--- a/src/daemon/service-audit.test.ts
+++ b/src/daemon/service-audit.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
auditGatewayServiceConfig,
checkTokenDrift,
+ readGatewayServiceCommandPort,
SERVICE_AUDIT_CODES,
} from "./service-audit.js";
import { buildMinimalServicePath } from "./service-env.js";
@@ -157,6 +158,54 @@ describe("auditGatewayServiceConfig", () => {
).toBe(false);
});
+ it("reads gateway service ports from split and equals-form arguments", () => {
+ expect(
+ readGatewayServiceCommandPort(["/usr/bin/node", "entry.js", "gateway", "--port", "18888"]),
+ ).toBe(18888);
+ expect(
+ readGatewayServiceCommandPort(["/usr/bin/node", "entry.js", "gateway", "--port=18889"]),
+ ).toBe(18889);
+ expect(readGatewayServiceCommandPort(["/usr/bin/node", "entry.js", "gateway"])).toBe(undefined);
+ expect(
+ readGatewayServiceCommandPort(["/usr/bin/node", "entry.js", "gateway", "--port=0"]),
+ ).toBe(undefined);
+ });
+
+ it("flags gateway service port drift from the expected config port", async () => {
+ const audit = await auditGatewayServiceConfig({
+ env: { HOME: "/tmp" },
+ platform: "win32",
+ expectedPort: 18888,
+ command: {
+ programArguments: ["/usr/bin/node", "entry.js", "gateway", "--port", "18789"],
+ environment: {},
+ },
+ });
+
+ const issue = audit.issues.find(
+ (entry) => entry.code === SERVICE_AUDIT_CODES.gatewayPortMismatch,
+ );
+ expect(issue).toMatchObject({
+ message: "Gateway service port does not match current gateway config.",
+ detail: "18789 -> 18888",
+ level: "recommended",
+ });
+ });
+
+ it("accepts gateway service ports that match the expected config port", async () => {
+ const audit = await auditGatewayServiceConfig({
+ env: { HOME: "/tmp" },
+ platform: "win32",
+ expectedPort: 18888,
+ command: {
+ programArguments: ["/usr/bin/node", "entry.js", "gateway", "--port=18888"],
+ environment: {},
+ },
+ });
+
+ expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayPortMismatch)).toBe(false);
+ });
+
it("flags gateway token mismatch when service token is stale", async () => {
const audit = await createGatewayAudit({
expectedGatewayToken: "new-token",
diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts
index 80ec6ba09ad..8e4506283ed 100644
--- a/src/daemon/service-audit.ts
+++ b/src/daemon/service-audit.ts
@@ -50,6 +50,7 @@ export const SERVICE_AUDIT_CODES = {
gatewayPathNonMinimal: "gateway-path-nonminimal",
gatewayTokenEmbedded: "gateway-token-embedded",
gatewayManagedEnvEmbedded: "gateway-managed-env-embedded",
+ gatewayPortMismatch: "gateway-port-mismatch",
gatewayProxyEnvEmbedded: "gateway-proxy-env-embedded",
gatewayTokenMismatch: "gateway-token-mismatch",
gatewayRuntimeBun: "gateway-runtime-bun",
@@ -219,6 +220,52 @@ function auditGatewayCommand(programArguments: string[] | undefined, issues: Ser
}
}
+function parseGatewayPortArg(value: string | undefined): number | undefined {
+ const parsed = Number.parseInt(value ?? "", 10);
+ return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : undefined;
+}
+
+export function readGatewayServiceCommandPort(programArguments?: string[]): number | undefined {
+ if (!programArguments || programArguments.length === 0) {
+ return undefined;
+ }
+ for (let index = 0; index < programArguments.length; index += 1) {
+ const arg = programArguments[index];
+ if (arg === "--port") {
+ return parseGatewayPortArg(programArguments[index + 1]);
+ }
+ if (arg.startsWith("--port=")) {
+ return parseGatewayPortArg(arg.slice("--port=".length));
+ }
+ }
+ return undefined;
+}
+
+function auditGatewayServicePort(params: {
+ programArguments: string[] | undefined;
+ issues: ServiceConfigIssue[];
+ expectedPort?: number;
+}) {
+ if (
+ typeof params.expectedPort !== "number" ||
+ !Number.isSafeInteger(params.expectedPort) ||
+ params.expectedPort <= 0 ||
+ params.expectedPort > 65535
+ ) {
+ return;
+ }
+ const servicePort = readGatewayServiceCommandPort(params.programArguments);
+ if (servicePort === undefined || servicePort === params.expectedPort) {
+ return;
+ }
+ params.issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayPortMismatch,
+ message: "Gateway service port does not match current gateway config.",
+ detail: `${servicePort} -> ${params.expectedPort}`,
+ level: "recommended",
+ });
+}
+
function auditGatewayToken(
command: GatewayServiceCommand,
issues: ServiceConfigIssue[],
@@ -521,11 +568,17 @@ export async function auditGatewayServiceConfig(params: {
platform?: NodeJS.Platform;
expectedGatewayToken?: string;
expectedManagedServiceEnvKeys?: Iterable;
+ expectedPort?: number;
}): Promise {
const issues: ServiceConfigIssue[] = [];
const platform = params.platform ?? process.platform;
auditGatewayCommand(params.command?.programArguments, issues);
+ auditGatewayServicePort({
+ programArguments: params.command?.programArguments,
+ issues,
+ expectedPort: params.expectedPort,
+ });
auditManagedServiceEnvironment(params.command, issues, params.expectedManagedServiceEnvKeys);
auditProxyServiceEnvironment(params.command, issues);
auditGatewayToken(params.command, issues, params.expectedGatewayToken);