Merge branch 'main' into fix/local-model-provider-logos

* origin/main:
  fix(cli): report failed MCP probes to automation (#116664)
  feat(plugins): externalize Cohere and Meta providers (#116662)
  fix(gateway): avoid false port-busy reports behind Tailscale Serve (#116579)
This commit is contained in:
Vincent Koc
2026-07-31 13:08:05 +08:00
46 changed files with 1211 additions and 304 deletions
+1 -1
View File
@@ -602,7 +602,7 @@ Use `--json` for scripts and dashboards. Field sets can grow over time, so consu
}
```
`probe --json` opens a live MCP client session and prints its result directly; unlike `status`/`doctor`, the output has no top-level `path` field. `resources` and `prompts` keys are present only when the server actually advertises that capability (a server without prompts omits the `prompts` key rather than reporting `false`). Use `probe` for reachability and capability proof, not for static config audits.
`probe --json` opens a live MCP client session and prints its result directly; unlike `status`/`doctor`, the output has no top-level `path` field. `resources` and `prompts` keys are present only when the server actually advertises that capability (a server without prompts omits the `prompts` key rather than reporting `false`). The command prints the complete result before exiting nonzero when diagnostics are present or a selected enabled server did not connect, so automation can inspect partial successes. Use `probe` for reachability and capability proof, not for static config audits.
</Accordion>
</AccordionGroup>
+6 -6
View File
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
71 plugins
69 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -73,8 +73,6 @@ Each entry lists the package, distribution route, and description.
- **[clawrouter](/plugins/reference/clawrouter)** (`@openclaw/clawrouter`) - included in OpenClaw. Adds ClawRouter model provider support to OpenClaw.
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/cohere-provider`. OpenClaw Cohere provider plugin.
- **[comfy](/plugins/reference/comfy)** (`@openclaw/comfy-provider`) - included in OpenClaw. Adds ComfyUI model provider support to OpenClaw.
- **[copilot-proxy](/plugins/reference/copilot-proxy)** (`@openclaw/copilot-proxy`) - included in OpenClaw. Adds Copilot Proxy model provider support to OpenClaw.
@@ -119,8 +117,6 @@ Each entry lists the package, distribution route, and description.
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
- **[meta](/plugins/reference/meta)** (`@openclaw/meta-provider`) - included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/meta-provider`. Adds Meta model provider support to OpenClaw.
- **[microsoft](/plugins/reference/microsoft)** (`@openclaw/microsoft-speech`) - included in OpenClaw. Adds text-to-speech provider support.
- **[microsoft-foundry](/plugins/reference/microsoft-foundry)** (`@openclaw/microsoft-foundry`) - included in OpenClaw. Adds Microsoft Foundry model provider support to OpenClaw.
@@ -197,7 +193,7 @@ Each entry lists the package, distribution route, and description.
## Official external packages
74 plugins
76 plugins
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
@@ -225,6 +221,8 @@ Each entry lists the package, distribution route, and description.
- **[codex](/plugins/reference/codex)** (`@openclaw/codex`) - npm; ClawHub. Codex app-server harness and native session catalog.
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - npm; ClawHub: `clawhub:@openclaw/cohere-provider`. OpenClaw Cohere provider plugin.
- **[copilot](/plugins/reference/copilot)** (`@openclaw/copilot`) - npm; ClawHub: `clawhub:@openclaw/copilot`. Registers the GitHub Copilot agent runtime.
- **[deepinfra](/plugins/reference/deepinfra)** (`@openclaw/deepinfra-provider`) - npm; ClawHub: `clawhub:@openclaw/deepinfra-provider`. Adds DeepInfra model provider support to OpenClaw.
@@ -285,6 +283,8 @@ Each entry lists the package, distribution route, and description.
- **[memory-lancedb](/plugins/reference/memory-lancedb)** (`@openclaw/memory-lancedb`) - npm; ClawHub. OpenClaw LanceDB-backed long-term memory plugin with auto-recall, auto-capture, and vector search.
- **[meta](/plugins/reference/meta)** (`@openclaw/meta-provider`) - npm; ClawHub: `clawhub:@openclaw/meta-provider`. Adds Meta model provider support to OpenClaw.
- **[moonshot](/plugins/reference/moonshot)** (`@openclaw/moonshot-provider`) - npm; ClawHub: `clawhub:@openclaw/moonshot-provider`. Adds Moonshot model provider support to OpenClaw.
- **[msteams](/plugins/reference/msteams)** (`@openclaw/msteams`) - npm; ClawHub. OpenClaw Microsoft Teams channel plugin for bot conversations.
+1 -1
View File
@@ -12,7 +12,7 @@ OpenClaw Cohere provider plugin.
## Distribution
- Package: `@openclaw/cohere-provider`
- Install route: included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/cohere-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/cohere-provider`
## Surface
+1 -1
View File
@@ -12,7 +12,7 @@ Adds Meta model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/meta-provider`
- Install route: included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/meta-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/meta-provider`
## Surface
+13 -13
View File
@@ -6,19 +6,19 @@ read_when:
- You need the Cohere API key env var or CLI auth choice
---
[Cohere](https://cohere.com) provides OpenAI-compatible inference through its Compatibility API. OpenClaw bundles the Cohere provider during its externalization transition and also publishes it as an official external plugin.
[Cohere](https://cohere.com) provides OpenAI-compatible inference through its Compatibility API. OpenClaw provides Cohere as an official external plugin.
| Property | Value |
| --------------- | ---------------------------------------------------- |
| Provider id | `cohere` |
| Plugin | bundled during transition; official external package |
| Auth env var | `COHERE_API_KEY` |
| Onboarding flag | `--auth-choice cohere-api-key` |
| Direct CLI flag | `--cohere-api-key <key>` |
| API | OpenAI-compatible (`openai-completions`) |
| Base URL | `https://api.cohere.ai/compatibility/v1` |
| Default model | `cohere/command-a-plus-05-2026` |
| Context window | 128,000 tokens |
| Property | Value |
| --------------- | ---------------------------------------- |
| Provider id | `cohere` |
| Plugin | `@openclaw/cohere-provider` |
| Auth env var | `COHERE_API_KEY` |
| Onboarding flag | `--auth-choice cohere-api-key` |
| Direct CLI flag | `--cohere-api-key <key>` |
| API | OpenAI-compatible (`openai-completions`) |
| Base URL | `https://api.cohere.ai/compatibility/v1` |
| Default model | `cohere/command-a-plus-05-2026` |
| Context window | 128,000 tokens |
## Built-in catalog
@@ -34,7 +34,7 @@ Reasoning-capable Cohere models support two Compatibility API reasoning modes. O
## Get started
1. Cohere ships with current OpenClaw packages. If it is missing, install the external package and restart the Gateway:
1. Install the official plugin and restart the Gateway:
```bash
openclaw plugins install @openclaw/cohere-provider
+9 -3
View File
@@ -7,13 +7,13 @@ read_when:
---
The **Meta API** uses the OpenAI-compatible **Responses API** (`POST /v1/responses`)
for the `muse-spark-1.1` reasoning model. The provider ships as a bundled OpenClaw
plugin.
for the `muse-spark-1.1` reasoning model. OpenClaw provides Meta as an official
external plugin.
| Property | Value |
| ----------------- | ---------------------------------- |
| Provider id | `meta` |
| Plugin | bundled provider |
| Plugin | `@openclaw/meta-provider` |
| Auth env var | `MODEL_API_KEY` |
| Onboarding flag | `--auth-choice meta-api-key` |
| Direct CLI flag | `--meta-api-key <key>` |
@@ -25,6 +25,12 @@ plugin.
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/meta-provider
openclaw gateway restart
```
</Step>
<Step title="Set the API key">
<CodeGroup>
+1 -1
View File
@@ -25,7 +25,7 @@
},
"build": {
"openclawVersion": "2026.7.2",
"bundledDist": true
"bundledDist": false
},
"release": {
"publishToClawHub": true,
+8 -1
View File
@@ -1,6 +1,6 @@
# Meta provider
Bundled OpenClaw provider plugin for the **Meta API** — an OpenAI-compatible
Official OpenClaw provider plugin for the **Meta API** — an OpenAI-compatible
**Responses API** endpoint (`POST /v1/responses`).
- **Base URL:** `https://api.meta.ai/v1`
@@ -15,6 +15,13 @@ Bundled OpenClaw provider plugin for the **Meta API** — an OpenAI-compatible
## Usage
Install the plugin and restart Gateway:
```bash
openclaw plugins install @openclaw/meta-provider
openclaw gateway restart
```
Set the API key and select the model:
```bash
+1 -1
View File
@@ -14,7 +14,7 @@ const PROVIDER_ID = "meta";
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Meta Provider",
description: "Bundled Meta provider plugin",
description: "Meta provider plugin",
manifest,
provider: {
label: "Meta",
+1 -1
View File
@@ -25,7 +25,7 @@
},
"build": {
"openclawVersion": "2026.7.2",
"bundledDist": true
"bundledDist": false
},
"release": {
"publishToClawHub": true,
+2
View File
@@ -249,6 +249,7 @@
"!dist/extensions/buzz/**",
"!dist/extensions/cerebras/**",
"!dist/extensions/chutes/**",
"!dist/extensions/cohere/**",
"!dist/extensions/clickclack/**",
"!dist/extensions/cloudflare-ai-gateway/**",
"!dist/extensions/codex/**",
@@ -281,6 +282,7 @@
"!dist/extensions/longcat/**",
"!dist/extensions/mattermost/**",
"!dist/extensions/memory-lancedb/**",
"!dist/extensions/meta/**",
"!dist/extensions/matrix/**",
"!dist/extensions/moonshot/**",
"!dist/extensions/msteams/**",
+4 -2
View File
@@ -7,7 +7,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginHealthErrorSummary } from "../../gateway/health/types.js";
import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../../gateway/probe-auth.js";
import { probeGateway } from "../../gateway/probe.js";
import { inspectPortUsage, type PortUsage } from "../../infra/ports.js";
import { inspectPortUsage, LOOPBACK_PORT_PROBE_HOSTS, type PortUsage } from "../../infra/ports.js";
import type { GatewayPortHealthSnapshot } from "./restart-health.types.js";
import { allListenersOwnedByRuntimePid } from "./restart-port-ownership.js";
@@ -196,7 +196,9 @@ export async function inspectGatewayPortHealth(params: {
}): Promise<GatewayPortHealthSnapshot> {
let portUsage: PortUsage;
try {
portUsage = await inspectPortUsage(params.port);
portUsage = await inspectPortUsage(params.port, {
probeHosts: LOOPBACK_PORT_PROBE_HOSTS,
});
} catch (err) {
portUsage = {
port: params.port,
@@ -35,7 +35,7 @@ describe("restart health", () => {
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
const snapshot = await waitForGatewayHealthyRestart({
service: { readRuntime } as unknown as GatewayService,
service: { readRuntime, readCommand: vi.fn(async () => null) } as unknown as GatewayService,
port: 18789,
expectedVersion: "2026.4.24",
requireRunningService: true,
@@ -284,6 +284,7 @@ describe("restart health", () => {
readRuntime: vi.fn(async () =>
++runtimeReads >= 27 ? { status: "running", pid: 8000 } : { status: "stopped" },
),
readCommand: vi.fn(async () => null),
} as unknown as GatewayService;
inspectPortUsage.mockImplementation(async () =>
++portInspections >= 27
@@ -5,7 +5,8 @@ import type { PortUsage } from "../../infra/ports.js";
type PortListenerKind = ReturnType<typeof import("../../infra/ports.js").classifyPortListener>;
export const inspectPortUsage = vi.fn<(port: number) => Promise<PortUsage>>();
export const inspectPortUsage =
vi.fn<(port: number, options?: { probeHosts?: readonly string[] }) => Promise<PortUsage>>();
export const monotonicClock = { nowMs: 0 };
export const sleep = vi.fn(async (ms: number) => {
monotonicClock.nowMs += ms;
@@ -21,11 +22,16 @@ export const resolveGatewayProbeAuthSafeWithSecretInputs = vi.fn<
>(async () => ({ auth: {} }));
const hasActiveStartupMigrationLease = vi.fn<(_params?: unknown) => boolean>(() => false);
export const readActiveGatewayLockIdentity = vi.fn();
export const resolveGatewayServiceProbeHosts = vi.fn<
(_params?: unknown) => Promise<readonly string[]>
>(async () => ["127.0.0.1"]);
vi.mock("../../infra/ports.js", () => ({
classifyPortListener: (listener: unknown, port: number) => classifyPortListener(listener, port),
formatPortDiagnostics: vi.fn(() => []),
inspectPortUsage: (port: number) => inspectPortUsage(port),
inspectPortUsage: (port: number, options?: { probeHosts?: readonly string[] }) =>
inspectPortUsage(port, options),
LOOPBACK_PORT_PROBE_HOSTS: ["127.0.0.1"],
}));
vi.mock("../../gateway/probe.js", () => ({
@@ -59,6 +65,10 @@ vi.mock("../../infra/gateway-lock.js", () => ({
previous.startTime === current.startTime,
}));
vi.mock("../../daemon/gateway-service-probe-hosts.js", () => ({
resolveGatewayServiceProbeHosts: (params: unknown) => resolveGatewayServiceProbeHosts(params),
}));
vi.mock("../../utils.js", async () => {
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
return {
@@ -74,6 +84,7 @@ export function makeGatewayService(
): GatewayService {
return {
readRuntime: vi.fn(async () => runtime),
readCommand: vi.fn(async () => null),
} as unknown as GatewayService;
}
@@ -115,6 +126,7 @@ export async function inspectGatewayRestartWithSnapshot(params: {
return inspectGatewayRestart({
service,
port: 18789,
probeHosts: ["127.0.0.1"],
...(params.expectedVersion === undefined ? {} : { expectedVersion: params.expectedVersion }),
...(params.includeUnknownListenersAsStale === undefined
? {}
@@ -213,6 +225,8 @@ export function resetRestartHealthMocks() {
hasActiveStartupMigrationLease.mockReturnValue(false);
readActiveGatewayLockIdentity.mockReset();
readActiveGatewayLockIdentity.mockResolvedValue(undefined);
resolveGatewayServiceProbeHosts.mockReset();
resolveGatewayServiceProbeHosts.mockResolvedValue(["127.0.0.1"]);
}
export function restoreRestartHealthMocks() {
+26
View File
@@ -13,6 +13,7 @@ import {
probeGateway,
readBestEffortConfig,
resetRestartHealthMocks,
resolveGatewayServiceProbeHosts,
resolveGatewayProbeAuthSafeWithSecretInputs,
restoreRestartHealthMocks,
} from "./restart-health.test-helpers.js";
@@ -34,6 +35,27 @@ describe("restart health", () => {
expect(snapshot.healthy).toBe(true);
expect(snapshot.staleGatewayPids).toStrictEqual([]);
expect(inspectPortUsage).toHaveBeenCalledWith(18789, {
probeHosts: ["127.0.0.1"],
});
});
it("uses the configured non-loopback host for restart-health port inspection", async () => {
resolveGatewayServiceProbeHosts.mockResolvedValue(["192.0.2.40"]);
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 7000, commandLine: "openclaw-gateway" }],
hints: [],
});
const service = makeGatewayService({ status: "running", pid: 7000 });
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
await waitForGatewayHealthyRestart({ service, port: 18789, attempts: 1 });
expect(inspectPortUsage).toHaveBeenCalledWith(18789, {
probeHosts: ["192.0.2.40"],
});
});
it("marks non-owned gateway listener pids as stale while runtime is running", async () => {
@@ -297,5 +319,9 @@ describe("restart health", () => {
expect(snapshot.healthy).toBe(true);
expect(probeGateway).not.toHaveBeenCalled();
expect(resolveGatewayServiceProbeHosts).toHaveBeenCalledWith({
env: process.env,
command: null,
});
});
});
+20 -1
View File
@@ -1,5 +1,6 @@
// Restart health probes for gateway service restarts and port listener recovery.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveGatewayServiceProbeHosts } from "../../daemon/gateway-service-probe-hosts.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import type { GatewayService } from "../../daemon/service.js";
import type { PluginHealthErrorSummary } from "../../gateway/health/types.js";
@@ -86,8 +87,15 @@ export async function inspectGatewayRestart(params: {
expectedVersion?: string | null;
includeUnknownListenersAsStale?: boolean;
probeAuth?: GatewayRestartProbeAuth;
probeHosts?: readonly string[];
}): Promise<GatewayRestartSnapshot> {
const env = params.env ?? process.env;
const probeHosts =
params.probeHosts ??
(await resolveGatewayServiceProbeHosts({
env,
command: (await params.service.readCommand?.(env).catch(() => null)) ?? null,
}));
const expectedVersion = normalizeOptionalString(params.expectedVersion);
let reachability: GatewayReachability | null = null;
let activatedPluginErrors: PluginHealthErrorSummary[] = [];
@@ -114,7 +122,9 @@ export async function inspectGatewayRestart(params: {
let portUsage: PortUsage;
try {
portUsage = await inspectPortUsage(params.port);
portUsage = await inspectPortUsage(params.port, {
probeHosts,
});
} catch (err) {
portUsage = {
port: params.port,
@@ -281,6 +291,7 @@ export async function waitForGatewayHealthyRestart(params: {
requireRunningService?: boolean;
supervisorKeepsAlive?: boolean;
isStartupMigrationActive?: typeof hasActiveStartupMigrationLease;
probeHosts?: readonly string[];
}): Promise<GatewayRestartSnapshot> {
const startedAtMs = performance.now();
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
@@ -288,6 +299,12 @@ export async function waitForGatewayHealthyRestart(params: {
const standardDeadlineMs = attempts * delayMs;
const probeAuth = await resolveGatewayRestartProbeAuth(params.env).catch(() => undefined);
const probeHosts =
params.probeHosts ??
(await resolveGatewayServiceProbeHosts({
env: params.env,
command: await params.service.readCommand(params.env ?? process.env).catch(() => null),
}));
let snapshot = await inspectGatewayRestart({
service: params.service,
port: params.port,
@@ -295,6 +312,7 @@ export async function waitForGatewayHealthyRestart(params: {
expectedVersion: params.expectedVersion,
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
probeAuth,
probeHosts,
});
let consecutiveStoppedFreeCount = 0;
@@ -382,6 +400,7 @@ export async function waitForGatewayHealthyRestart(params: {
expectedVersion: params.expectedVersion,
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
probeAuth,
probeHosts,
});
}
}
+40 -12
View File
@@ -47,16 +47,21 @@ type PortUsageTestSummary = {
hints: string[];
};
const inspectPortUsage = vi.fn<(port: number) => Promise<PortUsageTestSummary>>(
async (port: number) => ({
port,
status: "free",
listeners: [],
hints: [],
}),
);
type PortUsageInspectionOptions = { probeHosts?: readonly string[] };
const inspectPortUsage = vi.fn<
(port: number, options?: PortUsageInspectionOptions) => Promise<PortUsageTestSummary>
>(async (port: number) => ({
port,
status: "free",
listeners: [],
hints: [],
}));
const inspectPortUsages = vi.fn<
(ports: readonly number[]) => Promise<Map<number, PortUsageTestSummary>>
(
ports: readonly number[],
options?: { probeHostsByPort?: ReadonlyMap<number, readonly string[]> },
) => Promise<Map<number, PortUsageTestSummary>>
>(
async (ports) =>
new Map(
@@ -226,6 +231,10 @@ vi.mock("../../daemon/service.js", () => ({
vi.mock("../../gateway/net.js", () => ({
resolveGatewayBindHost: (bindMode: string, customBindHost?: string) =>
resolveGatewayBindHost(bindMode, customBindHost),
resolveGatewayRequiredListenHosts: (bindHost: string) =>
/^\d+\.\d+\.\d+\.\d+$/.test(bindHost) && bindHost !== "0.0.0.0" && bindHost !== "127.0.0.1"
? [bindHost, "127.0.0.1"]
: [bindHost],
}));
vi.mock("../../gateway/control-ui-links.js", () => ({
@@ -247,8 +256,12 @@ vi.mock("../../gateway/probe-auth.js", async (importOriginal) => {
vi.mock("../../infra/ports.js", () => ({
inspectPortConnections: (port: number) => inspectPortConnections(port),
inspectPortUsage: (port: number) => inspectPortUsage(port),
inspectPortUsages: (ports: readonly number[]) => inspectPortUsages(ports),
inspectPortUsage: (port: number, options?: PortUsageInspectionOptions) =>
inspectPortUsage(port, options),
inspectPortUsages: (
ports: readonly number[],
options?: { probeHostsByPort?: ReadonlyMap<number, readonly string[]> },
) => inspectPortUsages(ports, options),
formatPortDiagnostics: () => [],
}));
@@ -316,6 +329,10 @@ describe("gatherDaemonStatus", () => {
httpUrl: "https://10.211.55.3:19001/",
wsUrl: "wss://10.211.55.3:19001",
});
resolveGatewayBindHost.mockClear();
resolveGatewayBindHost.mockImplementation(async (bindMode?: string) =>
bindMode === "loopback" ? "127.0.0.1" : "0.0.0.0",
);
resolveGatewayProbeAuthSafeWithSecretInputsCalls.mockClear();
createConfigIOCalls.mockClear();
findStaleOpenClawUpdateLaunchdJobs.mockReset();
@@ -420,7 +437,12 @@ describe("gatherDaemonStatus", () => {
deep: false,
});
expect(inspectPortUsages).toHaveBeenCalledWith([19001, 18789]);
expect(inspectPortUsages).toHaveBeenCalledWith(
[19001, 18789],
expect.objectContaining({
probeHostsByPort: new Map([[19001, ["0.0.0.0"]]]),
}),
);
expect(inspectPortUsage).not.toHaveBeenCalled();
});
@@ -535,6 +557,12 @@ describe("gatherDaemonStatus", () => {
expect(resolveGatewayBindHost).toHaveBeenCalledWith("loopback", undefined);
expect(status.gateway?.bindMode).toBe("loopback");
expect(inspectPortUsages).toHaveBeenCalledWith(
[19001, 18789],
expect.objectContaining({
probeHostsByPort: new Map([[19001, ["127.0.0.1"]]]),
}),
);
});
it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => {
+11 -4
View File
@@ -25,6 +25,7 @@ import { resolveGatewayService } from "../../daemon/service.js";
import { resolveAdvertisedControlUiLinks } from "../../gateway/control-ui-links.js";
import { gatewaySecretInputPathCanWin } from "../../gateway/credentials-secret-inputs.js";
import { trimToUndefined } from "../../gateway/credentials.js";
import { resolveGatewayRequiredListenHosts } from "../../gateway/net.js";
import { resolveGatewayProbeCredentialConfig } from "../../gateway/probe-auth.js";
import {
ALL_GATEWAY_SECRET_INPUT_PATHS,
@@ -495,17 +496,21 @@ function toPortStatusSummary(
async function inspectDaemonPortStatuses(params: {
daemonPort: number;
cliPort: number;
daemonBindHost: string;
}): Promise<{ portStatus?: PortStatusSummary; portCliStatus?: PortStatusSummary }> {
const daemonProbeHosts = resolveGatewayRequiredListenHosts(params.daemonBindHost);
if (params.cliPort === params.daemonPort) {
const portDiagnostics = await inspectPortUsage(params.daemonPort).catch(() => null);
const portDiagnostics = await inspectPortUsage(params.daemonPort, {
probeHosts: daemonProbeHosts,
}).catch(() => null);
return {
portStatus: toPortStatusSummary(portDiagnostics),
portCliStatus: undefined,
};
}
const portDiagnosticsByPort = await inspectPortUsages([params.daemonPort, params.cliPort]).catch(
() => new Map(),
);
const portDiagnosticsByPort = await inspectPortUsages([params.daemonPort, params.cliPort], {
probeHostsByPort: new Map([[params.daemonPort, daemonProbeHosts]]),
}).catch(() => new Map());
return {
portStatus: toPortStatusSummary(portDiagnosticsByPort.get(params.daemonPort) ?? null),
portCliStatus: toPortStatusSummary(portDiagnosticsByPort.get(params.cliPort) ?? null),
@@ -624,6 +629,7 @@ export async function gatherDaemonStatus(
const { portStatus, portCliStatus } = await inspectDaemonPortStatuses({
daemonPort,
cliPort,
daemonBindHost: gateway.bindHost,
});
const establishedClients = await inspectEstablishedGatewayClients({
daemonPort,
@@ -726,6 +732,7 @@ export async function gatherDaemonStatus(
service,
port: daemonPort,
env: serviceEnv,
probeHosts: resolveGatewayRequiredListenHosts(gateway.bindHost),
}),
)
.catch(() => undefined)
+169
View File
@@ -0,0 +1,169 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
async function createTempHome(): Promise<string> {
return tempDirs.make("openclaw-mcp-probe-process-");
}
async function writeConfig(home: string, servers: Record<string, unknown>): Promise<string> {
const configPath = path.join(home, "openclaw.json");
await fs.writeFile(configPath, `${JSON.stringify({ mcp: { servers } })}\n`, "utf8");
return configPath;
}
async function writeProbeServer(filePath: string): Promise<void> {
await fs.writeFile(
filePath,
`let buffer = "";
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function handle(message) {
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
capabilities: { tools: {} },
serverInfo: { name: "probe-process-test", version: "1.0.0" },
},
});
return;
}
if (message.method === "tools/list") {
send({
jsonrpc: "2.0",
id: message.id,
result: { tools: [{ name: "ping", inputSchema: { type: "object" } }] },
});
}
}
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
while (true) {
const newline = buffer.indexOf("\\n");
if (newline < 0) return;
const line = buffer.slice(0, newline).replace(/\\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
});
process.stdin.on("end", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
`,
"utf8",
);
}
function runProbe(home: string, args: string[]) {
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: home,
USERPROFILE: home,
OPENCLAW_CONFIG_PATH: path.join(home, "openclaw.json"),
OPENCLAW_STATE_DIR: path.join(home, "state"),
OPENCLAW_TEST_FAST: "1",
MCP_TEST_ARGS_JSON: JSON.stringify(args),
};
delete env.VITEST;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
const mcpCliUrl = new URL("./mcp-cli.ts", import.meta.url).href;
const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href;
const script = `
import { Command } from "commander";
import { registerMcpCli } from ${JSON.stringify(mcpCliUrl)};
import { runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)};
const program = new Command();
program.exitOverride();
registerMcpCli(program);
await runCliWithExitFinalization({
run: async () => {
await program.parseAsync(JSON.parse(process.env.MCP_TEST_ARGS_JSON), { from: "user" });
},
onError: (error) => { throw error; },
});
`;
return spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", script], {
encoding: "utf8",
env,
maxBuffer: 4 * 1024 * 1024,
timeout: 30_000,
});
}
describe("mcp probe process exit", () => {
it("prints named JSON diagnostics before exiting nonzero", async () => {
const home = await createTempHome();
const configPath = await writeConfig(home, {
broken: { command: path.join(home, "missing-mcp-server") },
});
const result = runProbe(home, ["mcp", "probe", "broken", "--json"]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
const output = JSON.parse(result.stdout) as {
diagnostics: Array<{ message: string; serverName: string }>;
servers: Record<string, unknown>;
};
expect(output.servers).toEqual({});
expect(output.diagnostics).toEqual([expect.objectContaining({ serverName: "broken" })]);
expect(result.stderr).toContain(`MCP probe failed for "broken" in ${configPath}:`);
});
it("preserves mixed partial text output before exiting nonzero", async () => {
const home = await createTempHome();
const serverPath = path.join(home, "probe-server.mjs");
await writeProbeServer(serverPath);
await writeConfig(home, {
healthy: { command: process.execPath, args: [serverPath] },
broken: { command: path.join(home, "missing-mcp-server") },
});
const result = runProbe(home, ["mcp", "probe"]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stdout).toContain("- healthy: 1 tools");
expect(result.stdout).toContain("! broken:");
});
it("fails when an enabled server is omitted without a diagnostic", async () => {
const home = await createTempHome();
await writeConfig(home, { incomplete: {} });
const result = runProbe(home, ["mcp", "probe", "incomplete", "--json"]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({ servers: {}, diagnostics: [] });
expect(result.stderr).toContain('MCP probe did not connect to "incomplete"');
});
it("keeps healthy output successful and ignores disabled entries", async () => {
const home = await createTempHome();
const serverPath = path.join(home, "probe-server.mjs");
await writeProbeServer(serverPath);
await writeConfig(home, {
healthy: { command: process.execPath, args: [serverPath] },
disabled: { enabled: false },
});
const result = runProbe(home, ["mcp", "probe", "--json"]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
diagnostics: [],
servers: { healthy: { tools: 1 } },
});
});
});
+42 -18
View File
@@ -39,6 +39,7 @@ import { defaultRuntime } from "../runtime.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { formatCliCommand } from "./command-format.js";
import { resolveGatewayAuthOptions } from "./gateway-secret-options.js";
import { requestExitAfterOneShotOutput } from "./one-shot-exit.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
function fail(message: string): never {
@@ -558,6 +559,30 @@ function applyMcpProbeInitializeTimeout(server: Record<string, unknown>): Record
};
}
function resolveMcpProbeIssue(params: {
result: ReturnType<typeof formatMcpProbeResult>;
servers: Record<string, Record<string, unknown>>;
path: string;
}): string | undefined {
if (params.result.diagnostics.length > 0) {
const first = expectDefined(params.result.diagnostics[0], "diagnostics entry at 0");
return `MCP probe failed for "${first.serverName}" in ${params.path}: ${first.message}`;
}
for (const [name, server] of Object.entries(params.servers)) {
if (server.enabled !== false && !params.result.servers[name]) {
return `MCP probe did not connect to "${name}" in ${params.path}.`;
}
}
return undefined;
}
function failOnMcpProbeIssues(params: Parameters<typeof resolveMcpProbeIssue>[0]): void {
const probeIssue = resolveMcpProbeIssue(params);
if (probeIssue) {
fail(probeIssue);
}
}
async function probeMcpServersOrFail(params: {
config: OpenClawConfig;
servers: Record<string, Record<string, unknown>>;
@@ -577,15 +602,7 @@ async function probeMcpServersOrFail(params: {
});
try {
const result = formatMcpProbeResult(await runtime.getCatalog());
if (result.diagnostics.length > 0) {
const first = expectDefined(result.diagnostics[0], "diagnostics entry at 0");
fail(`MCP probe failed for "${first.serverName}" in ${params.path}: ${first.message}`);
}
for (const name of Object.keys(params.servers)) {
if (!result.servers[name]) {
fail(`MCP probe did not connect to "${name}" in ${params.path}.`);
}
}
failOnMcpProbeIssues({ result, servers: params.servers, path: params.path });
return result;
} finally {
await runtime.dispose();
@@ -782,16 +799,23 @@ export function registerMcpCli(program: Command) {
const result = formatMcpProbeResult(await runtime.getCatalog());
if (opts.json) {
printJson(result);
return;
} else {
defaultRuntime.log(`MCP probe (${loaded.path}):`);
for (const [serverName, server] of Object.entries(result.servers)) {
defaultRuntime.log(
`- ${serverName}: ${server.tools} tools${server.resources ? ", resources" : ""}${server.prompts ? ", prompts" : ""}`,
);
}
for (const diagnostic of result.diagnostics) {
defaultRuntime.log(`! ${diagnostic.serverName}: ${diagnostic.message}`);
}
}
defaultRuntime.log(`MCP probe (${loaded.path}):`);
for (const [serverName, server] of Object.entries(result.servers)) {
defaultRuntime.log(
`- ${serverName}: ${server.tools} tools${server.resources ? ", resources" : ""}${server.prompts ? ", prompts" : ""}`,
);
}
for (const diagnostic of result.diagnostics) {
defaultRuntime.log(`! ${diagnostic.serverName}: ${diagnostic.message}`);
const probeIssue = resolveMcpProbeIssue({ result, servers, path: loaded.path });
if (probeIssue) {
defaultRuntime.error(probeIssue);
if (!requestExitAfterOneShotOutput(defaultRuntime, 1)) {
defaultRuntime.exit(1);
}
}
} finally {
await runtime.dispose();
+39
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from "./one-shot-exit.js";
@@ -257,4 +258,42 @@ describe("one-shot CLI exit", () => {
});
expect(exit).toHaveBeenCalledWith(0);
});
it("drains large piped stdout before a requested nonzero exit", () => {
const env = { ...process.env };
delete env.VITEST;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href;
const runtimeUrl = new URL("../runtime.ts", import.meta.url).href;
const payloadBytes = 1024 * 1024;
const script = `
import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)};
import { defaultRuntime } from ${JSON.stringify(runtimeUrl)};
await runCliWithExitFinalization({
run: async () => {
process.stdout.write("x".repeat(${payloadBytes}));
requestExitAfterOneShotOutput(defaultRuntime, 7);
},
onError: (error) => { throw error; },
});
`;
const result = spawnSync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", script],
{
encoding: "utf8",
env,
maxBuffer: 2 * payloadBytes,
timeout: 30_000,
},
);
expect(result.error).toBeUndefined();
expect(result.status).toBe(7);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout).toHaveLength(payloadBytes);
});
});
@@ -36,6 +36,7 @@ const findSystemGatewayServices = vi.hoisted(() =>
const buildGatewayRuntimeHints = vi.hoisted(() => vi.fn((): string[] => []));
const formatGatewayRuntimeSummary = vi.hoisted(() => vi.fn((): string | null => null));
const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true));
const resolveGatewayBindHost = vi.hoisted(() => vi.fn(async () => "127.0.0.1"));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
@@ -96,6 +97,12 @@ vi.mock("../daemon/systemd.js", async () => {
};
});
vi.mock("../gateway/net.js", () => ({
resolveGatewayBindHost,
resolveGatewayRequiredListenHosts: (bindHost: string) =>
bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost],
}));
vi.mock("../infra/ports.js", () => ({
inspectPortConnections,
inspectPortUsage,
@@ -170,6 +177,7 @@ describe("maybeRepairGatewayDaemon", () => {
isDefaultInstallIdentity.mockReturnValue(true);
readGatewayRestartHandoffSync.mockReturnValue(null);
findSystemGatewayServices.mockResolvedValue([]);
resolveGatewayBindHost.mockResolvedValue("127.0.0.1");
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "free",
@@ -525,6 +533,10 @@ describe("maybeRepairGatewayDaemon", () => {
await runNonInteractiveRepair();
expect(resolveGatewayBindHost).toHaveBeenCalledWith("loopback", undefined);
expect(inspectPortUsage).toHaveBeenCalledWith(18789, {
probeHosts: ["127.0.0.1"],
});
expect(isExpectedGatewayListeners).toHaveBeenCalledWith(listeners, 18789);
expect(formatPortDiagnostics).not.toHaveBeenCalled();
expect(note.mock.calls.some(([, label]) => label === "Gateway port")).toBe(false);
+8 -1
View File
@@ -19,6 +19,7 @@ import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../gateway/net.js";
import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js";
import {
formatPortDiagnostics,
@@ -309,7 +310,13 @@ export async function maybeRepairGatewayDaemon(params: {
if (params.cfg.gateway?.mode !== "remote") {
const port = resolveGatewayPort(params.cfg, process.env);
const diagnostics = await inspectPortUsage(port);
const bindHost = await resolveGatewayBindHost(
params.cfg.gateway?.bind ?? "loopback",
params.cfg.gateway?.customBindHost,
);
const diagnostics = await inspectPortUsage(port, {
probeHosts: resolveGatewayRequiredListenHosts(bindHost),
});
await maybeReportEstablishedGatewayClients({
cfg: params.cfg,
deep: params.options.deep ?? false,
+13 -2
View File
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })),
inspectPortUsage: vi.fn(async () => null),
resolveGatewayBindHost: vi.fn(async () => "127.0.0.1"),
}));
vi.mock("../../agents/exec-defaults.js", () => ({
@@ -15,7 +17,12 @@ vi.mock("../../config/config.js", () => ({
vi.mock("../../daemon/diagnostics.js", () => ({
readLastGatewayErrorLine: async () => null,
}));
vi.mock("../../infra/ports.js", () => ({ inspectPortUsage: async () => null }));
vi.mock("../../gateway/net.js", () => ({
resolveGatewayBindHost: mocks.resolveGatewayBindHost,
resolveGatewayRequiredListenHosts: (bindHost: string) =>
bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost],
}));
vi.mock("../../infra/ports.js", () => ({ inspectPortUsage: mocks.inspectPortUsage }));
vi.mock("../../infra/restart-sentinel.js", () => ({ readRestartSentinel: async () => null }));
vi.mock("../../plugins/status.js", () => ({ buildPluginCompatibilityNotices: () => [] }));
vi.mock("../../skills/discovery/status.js", () => ({ buildWorkspaceSkillStatus: () => null }));
@@ -39,7 +46,7 @@ import { buildStatusAllReportData } from "./report-data.js";
describe("buildStatusAllReportData", () => {
beforeEach(() => {
mocks.readConfigFileSnapshot.mockClear();
vi.clearAllMocks();
});
it("keeps local config diagnosis non-observing", async () => {
@@ -69,5 +76,9 @@ describe("buildStatusAllReportData", () => {
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledOnce();
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false });
expect(mocks.resolveGatewayBindHost).toHaveBeenCalledWith("loopback", undefined);
expect(mocks.inspectPortUsage).toHaveBeenCalledWith(18789, {
probeHosts: ["127.0.0.1"],
});
});
});
+8 -1
View File
@@ -4,6 +4,7 @@
import { resolveNodeExecEligibility } from "../../agents/exec-defaults.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../../config/config.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../../gateway/net.js";
import { inspectPortUsage } from "../../infra/ports.js";
import { readRestartSentinel } from "../../infra/restart-sentinel.js";
import { buildPluginCompatibilityNotices } from "../../plugins/status.js";
@@ -106,7 +107,13 @@ async function resolveStatusAllLocalDiagnosis(params: {
const sentinel = await readRestartSentinel().catch(() => null);
const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null);
const port = resolveGatewayPort(overview.cfg);
const portUsage = await inspectPortUsage(port).catch(() => null);
const bindHost = await resolveGatewayBindHost(
overview.cfg.gateway?.bind ?? "loopback",
overview.cfg.gateway?.customBindHost,
);
const portUsage = await inspectPortUsage(port, {
probeHosts: resolveGatewayRequiredListenHosts(bindHost),
}).catch(() => null);
params.progress.tick();
const defaultWorkspace =
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
cfg: {} as {
gateway?: {
bind?: "auto" | "custom" | "lan" | "loopback" | "tailnet";
customBindHost?: string;
tailscale?: { mode?: "off" | "serve" | "funnel" };
};
},
createConfigIO: vi.fn(),
defaultGatewayBindMode: vi.fn<(_tailscaleMode?: string) => "loopback" | "auto">(() => "loopback"),
isContainerEnvironment: vi.fn(() => false),
pickPrimaryTailnetIPv4: vi.fn<() => string | undefined>(() => undefined),
}));
vi.mock("../config/io.js", () => ({
createConfigIO: (options: unknown) => mocks.createConfigIO(options),
}));
vi.mock("../gateway/net.js", () => ({
defaultGatewayBindMode: (tailscaleMode?: string) => mocks.defaultGatewayBindMode(tailscaleMode),
resolveGatewayRequiredListenHosts: (bindHost: string) =>
bindHost === "192.0.2.40" || bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost],
}));
vi.mock("../infra/container-environment.js", () => ({
isContainerEnvironment: () => mocks.isContainerEnvironment(),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => mocks.pickPrimaryTailnetIPv4(),
}));
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
describe("resolveGatewayServiceProbeHosts", () => {
beforeEach(() => {
mocks.cfg = {};
mocks.createConfigIO.mockReset();
mocks.createConfigIO.mockImplementation(() => ({
readBestEffortConfig: async () => mocks.cfg,
}));
mocks.defaultGatewayBindMode.mockReset();
mocks.defaultGatewayBindMode.mockReturnValue("loopback");
mocks.isContainerEnvironment.mockReset();
mocks.isContainerEnvironment.mockReturnValue(false);
mocks.pickPrimaryTailnetIPv4.mockReset();
mocks.pickPrimaryTailnetIPv4.mockReturnValue(undefined);
});
it.each([
{
name: "custom",
gateway: { bind: "custom" as const, customBindHost: "192.0.2.40" },
tailnetIPv4: undefined,
expected: ["192.0.2.40", "127.0.0.1"],
},
{
name: "LAN wildcard",
gateway: { bind: "lan" as const },
tailnetIPv4: undefined,
expected: ["0.0.0.0"],
},
{
name: "tailnet",
gateway: { bind: "tailnet" as const },
tailnetIPv4: "100.64.0.40",
expected: ["100.64.0.40", "127.0.0.1"],
},
])(
"returns the configured $name endpoint without a bind-availability probe",
async (testCase) => {
mocks.cfg = { gateway: testCase.gateway };
mocks.pickPrimaryTailnetIPv4.mockReturnValue(testCase.tailnetIPv4);
await expect(
resolveGatewayServiceProbeHosts({
env: { OPENCLAW_STATE_DIR: "/tmp/cli-state" },
command: {
programArguments: ["node", "gateway.js"],
environment: { OPENCLAW_STATE_DIR: "/tmp/service-state" },
},
}),
).resolves.toEqual(testCase.expected);
expect(mocks.createConfigIO).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({ OPENCLAW_STATE_DIR: "/tmp/service-state" }),
pluginValidation: "skip",
suppressFutureVersionWarning: true,
}),
);
},
);
});
+36
View File
@@ -0,0 +1,36 @@
import { createConfigIO } from "../config/io.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { defaultGatewayBindMode, resolveGatewayRequiredListenHosts } from "../gateway/net.js";
import { isContainerEnvironment } from "../infra/container-environment.js";
import { LOOPBACK_PORT_PROBE_HOSTS } from "../infra/ports-probe.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { mergeGatewayServiceEnv } from "./service-env-merge.js";
import type { GatewayServiceCommandConfig, GatewayServiceEnv } from "./service-types.js";
export async function resolveGatewayServiceProbeHosts(params: {
env?: GatewayServiceEnv;
command?: GatewayServiceCommandConfig | null;
}): Promise<readonly string[]> {
const baseEnv = params.env ?? process.env;
const mergedEnv = mergeGatewayServiceEnv(baseEnv, params.command ?? null);
const cfg = await createConfigIO({
env: mergedEnv,
pluginValidation: "skip",
suppressFutureVersionWarning: true,
})
.readBestEffortConfig()
.catch((): OpenClawConfig => ({}));
const bindMode =
cfg.gateway?.bind ?? defaultGatewayBindMode(cfg.gateway?.tailscale?.mode ?? "off");
const bindHost =
bindMode === "lan"
? "0.0.0.0"
: bindMode === "custom"
? cfg.gateway?.customBindHost?.trim() || "0.0.0.0"
: bindMode === "tailnet"
? (pickPrimaryTailnetIPv4() ?? LOOPBACK_PORT_PROBE_HOSTS[0])
: bindMode === "auto" && isContainerEnvironment()
? "0.0.0.0"
: LOOPBACK_PORT_PROBE_HOSTS[0];
return resolveGatewayRequiredListenHosts(bindHost);
}
+56 -9
View File
@@ -103,6 +103,9 @@ const probePortUsage = vi.hoisted(() =>
vi.fn<typeof import("../infra/ports-probe.js").probePortUsage>(async () => "free"),
);
const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."]));
const resolveGatewayServiceProbeHosts = vi.hoisted(() =>
vi.fn<(_params?: unknown) => Promise<readonly string[]>>(async () => ["127.0.0.1"]),
);
const defaultProgramArguments = ["node", "-e", "process.exit(0)"];
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
@@ -405,9 +408,14 @@ vi.mock("../infra/ports.js", () => ({
}));
vi.mock("../infra/ports-probe.js", () => ({
LOOPBACK_PORT_PROBE_HOSTS: ["127.0.0.1"],
probePortUsage,
}));
vi.mock("./gateway-service-probe-hosts.js", () => ({
resolveGatewayServiceProbeHosts: (params: unknown) => resolveGatewayServiceProbeHosts(params),
}));
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
const wrapped = {
@@ -531,6 +539,8 @@ beforeEach(() => {
probePortUsage.mockResolvedValue("free");
formatPortDiagnostics.mockReset();
formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
resolveGatewayServiceProbeHosts.mockReset();
resolveGatewayServiceProbeHosts.mockResolvedValue(["127.0.0.1"]);
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset();
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReturnValue({
ok: true,
@@ -1921,7 +1931,9 @@ describe("launchd install", () => {
await stopLaunchAgent({ env, stdout });
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19003);
expect(inspectPortUsage).toHaveBeenCalledWith(19003);
expect(inspectPortUsage).toHaveBeenCalledWith(19003, {
probeHosts: ["127.0.0.1"],
});
expect(output).toContain("Stopped LaunchAgent");
});
@@ -1940,7 +1952,28 @@ describe("launchd install", () => {
await runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough() });
expect(inspectPortUsage).toHaveBeenCalledTimes(1);
expect(probePortUsage).toHaveBeenCalledWith(19009);
expect(probePortUsage).toHaveBeenCalledWith(19009, ["127.0.0.1"]);
});
it("waits on the configured non-loopback host before reporting the port released", async () => {
const env = {
...createDefaultLaunchdEnv(),
OPENCLAW_GATEWAY_PORT: "19011",
};
resolveGatewayServiceProbeHosts.mockResolvedValue(["192.0.2.40"]);
inspectPortUsage.mockResolvedValueOnce({
port: 19011,
status: "busy",
listeners: [],
hints: [],
});
await runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough() });
expect(inspectPortUsage).toHaveBeenCalledWith(19011, {
probeHosts: ["192.0.2.40"],
});
expect(probePortUsage).toHaveBeenCalledWith(19011, ["192.0.2.40"]);
});
it("keeps waiting until a bind probe explicitly confirms port release", async () => {
@@ -1974,7 +2007,9 @@ describe("launchd install", () => {
await stopLaunchAgent({ env, stdout: new PassThrough() });
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19006);
expect(inspectPortUsage).toHaveBeenCalledWith(19006);
expect(inspectPortUsage).toHaveBeenCalledWith(19006, {
probeHosts: ["127.0.0.1"],
});
});
it("fails stop when the verified gateway port remains busy after cleanup", async () => {
@@ -2003,7 +2038,9 @@ describe("launchd install", () => {
expect(onMutation).toHaveBeenCalledWith({ mode: "bootout" });
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19004);
expect(inspectPortUsage).toHaveBeenCalledWith(19004);
expect(inspectPortUsage).toHaveBeenCalledWith(19004, {
probeHosts: ["127.0.0.1"],
});
expect(output).not.toContain("Stopped LaunchAgent");
});
@@ -2041,7 +2078,9 @@ describe("launchd install", () => {
await stopLaunchAgent({ env, stdout, disable: true });
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19005);
expect(inspectPortUsage).toHaveBeenCalledWith(19005);
expect(inspectPortUsage).toHaveBeenCalledWith(19005, {
probeHosts: ["127.0.0.1"],
});
expect(output).toContain("Stopped LaunchAgent");
});
@@ -2605,7 +2644,9 @@ describe("launchd install", () => {
resolveProtectedPid: expect.any(Function),
}),
);
expect(inspectPortUsage).toHaveBeenCalledWith(19007);
expect(inspectPortUsage).toHaveBeenCalledWith(19007, {
probeHosts: ["127.0.0.1"],
});
});
it("uses the final repeated LaunchAgent port flag for restart stale cleanup", async () => {
@@ -2629,7 +2670,9 @@ describe("launchd install", () => {
resolveProtectedPid: expect.any(Function),
}),
);
expect(inspectPortUsage).toHaveBeenCalledWith(19008);
expect(inspectPortUsage).toHaveBeenCalledWith(19008, {
probeHosts: ["127.0.0.1"],
});
});
it("ignores invalid stored LaunchAgent environment ports for stale cleanup", async () => {
@@ -2696,7 +2739,9 @@ describe("launchd install", () => {
expect.objectContaining({ resolveProtectedPid: expect.any(Function) }),
);
expect(state.cleanupProtectedPids).toEqual([managedPidAfterCleanup]);
expect(inspectPortUsage).toHaveBeenCalledWith(19002);
expect(inspectPortUsage).toHaveBeenCalledWith(19002, {
probeHosts: ["127.0.0.1"],
});
expect(state.launchctlCalls).toEqual([
["print", serviceId],
["print", serviceId],
@@ -2764,7 +2809,9 @@ describe("launchd install", () => {
expect.objectContaining({ resolveProtectedPid: expect.any(Function) }),
);
expect(state.cleanupProtectedPids).toEqual([4242]);
expect(inspectPortUsage).toHaveBeenCalledWith(19002);
expect(inspectPortUsage).toHaveBeenCalledWith(19002, {
probeHosts: ["127.0.0.1"],
});
expect(state.launchctlCalls).toEqual([
["print", serviceId],
["print", serviceId],
+31 -11
View File
@@ -19,6 +19,7 @@ import {
resolveGatewayLaunchAgentLabel,
resolveLegacyGatewayLaunchAgentLabels,
} from "./constants.js";
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
import {
execLaunchctl,
@@ -558,17 +559,29 @@ export async function disableCurrentOpenClawUpdateLaunchdJob(
});
}
async function resolveLaunchAgentGatewayPort(env: GatewayServiceEnv): Promise<number | null> {
async function resolveLaunchAgentGatewayContext(env: GatewayServiceEnv): Promise<{
port: number | null;
probeHosts: readonly string[];
}> {
const command = await readLaunchAgentProgramArguments(env).catch(() => null);
const fromArgs = parseTcpPortFromArgs(command?.programArguments);
if (fromArgs !== null) {
return fromArgs;
return {
port: fromArgs,
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
const fromServiceEnv = parseTcpPort(command?.environment?.OPENCLAW_GATEWAY_PORT ?? "");
if (fromServiceEnv !== null) {
return fromServiceEnv;
return {
port: fromServiceEnv,
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
return parseTcpPort(env.OPENCLAW_GATEWAY_PORT ?? "");
return {
port: parseTcpPort(env.OPENCLAW_GATEWAY_PORT ?? ""),
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
function resolveGuiDomain(): string {
@@ -1040,11 +1053,14 @@ async function waitForLaunchAgentStopped(serviceTarget: string): Promise<LaunchA
return lastUnknown ?? { state: "running" };
}
async function waitForGatewayPortRelease(port: number): Promise<boolean> {
async function waitForGatewayPortRelease(
port: number,
probeHosts: readonly string[],
): Promise<boolean> {
const deadline = Date.now() + LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(Math.min(LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS, deadline - Date.now()));
const status = await probePortUsage(port);
const status = await probePortUsage(port, probeHosts);
if (status === "free") {
return true;
}
@@ -1053,16 +1069,18 @@ async function waitForGatewayPortRelease(port: number): Promise<boolean> {
}
async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise<void> {
const port = await resolveLaunchAgentGatewayPort(env);
const { port, probeHosts } = await resolveLaunchAgentGatewayContext(env);
if (port === null) {
return;
}
cleanStaleGatewayProcessesSync(port);
const diagnostics = await inspectPortUsage(port).catch(() => null);
const diagnostics = await inspectPortUsage(port, {
probeHosts,
}).catch(() => null);
if (diagnostics?.status !== "busy") {
return;
}
if (await waitForGatewayPortRelease(port)) {
if (await waitForGatewayPortRelease(port, probeHosts)) {
return;
}
throw new Error(
@@ -1468,14 +1486,16 @@ export async function restartLaunchAgent({
return { outcome: "scheduled" };
}
const cleanupPort = await resolveLaunchAgentGatewayPort(serviceEnv);
const { port: cleanupPort, probeHosts } = await resolveLaunchAgentGatewayContext(serviceEnv);
if (cleanupPort !== null) {
cleanStaleGatewayProcessesSync(cleanupPort, {
// Resolve after lsof captures its listener snapshot. A KeepAlive respawn
// during enumeration must be protected before candidate filtering/signals.
resolveProtectedPid: () => readLaunchAgentPidForCleanupSync(serviceTarget),
});
const diagnostics = await inspectPortUsage(cleanupPort).catch(() => null);
const diagnostics = await inspectPortUsage(cleanupPort, {
probeHosts,
}).catch(() => null);
if (diagnostics?.status === "busy") {
const runtime = await readLaunchAgentRuntime(serviceEnv);
const managedPid = runtime.pid;
+42 -37
View File
@@ -1,8 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { isGatewayArgv } from "../infra/gateway-process-argv.js";
import { parseTcpPortFromArgs } from "../infra/tcp-port.js";
import { sleep } from "../utils.js";
import { parseCmdScriptCommandLine } from "./cmd-argv.js";
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
import { formatLine } from "./output.js";
import { execSchtasks } from "./schtasks-exec.js";
import {
@@ -15,10 +14,9 @@ import {
isNodeHostArgv,
readWindowsProcessSnapshot,
resolveScheduledTaskCommandPort,
resolveScheduledTaskGatewayListenerPids,
resolveScheduledTaskPort,
resolveScheduledTaskGatewayContext,
resolveScheduledTaskOwnedGatewayPids,
shouldManageGatewayListenerPort,
terminateBusyPortListeners,
terminateGatewayProcessTree,
terminateScheduledTaskGatewayListeners,
terminateScheduledTaskNodeHost,
@@ -87,8 +85,13 @@ async function shouldFallbackScheduledTaskLaunch(params: {
const taskPort = resolveScheduledTaskCommandPort(params.env, command);
const manageGatewayPort = shouldManageGatewayListenerPort(params.env);
if (manageGatewayPort && taskPort) {
const listenerPids = await resolveScheduledTaskGatewayListenerPids(taskPort);
if (listenerPids.length > 0) {
const probeHosts = await resolveGatewayServiceProbeHosts({ env: params.env, command });
const ownedPids = await resolveScheduledTaskOwnedGatewayPids(
params.env,
{ port: taskPort, probeHosts },
command,
);
if (ownedPids.length > 0) {
return true;
}
}
@@ -115,21 +118,19 @@ async function shouldFallbackScheduledTaskLaunch(params: {
if (!taskPort) {
return false;
}
if (!manageGatewayPort) {
return installedArguments?.length
? findInstalledProcessPid(entries, taskPort, installedArguments, isNodeHostArgv) != null
: false;
if (!installedArguments?.length) {
return false;
}
return entries.some((entry) => {
const commandLine = normalizeLowercaseStringOrEmpty(entry.CommandLine ?? "");
if (!commandLine) {
return false;
}
const argv = parseCmdScriptCommandLine(entry.CommandLine ?? "");
return (
isGatewayArgv(argv, { allowGatewayBinary: true }) && parseTcpPortFromArgs(argv) === taskPort
);
});
return (
findInstalledProcessPid(
entries,
taskPort,
installedArguments,
manageGatewayPort
? (argv) => isGatewayArgv(argv, { allowGatewayBinary: true })
: isNodeHostArgv,
) != null
);
};
let previous = await readLaunchObservation();
@@ -282,21 +283,23 @@ export async function stopScheduledTask({
}
reportMutation("schtasks-stop");
const manageGatewayPort = shouldManageGatewayListenerPort(effectiveEnv);
const stopPort = manageGatewayPort ? await resolveScheduledTaskPort(effectiveEnv) : null;
const stopContext = manageGatewayPort
? await resolveScheduledTaskGatewayContext(effectiveEnv)
: null;
const stopPort = stopContext?.port ?? null;
if (manageGatewayPort) {
await terminateScheduledTaskGatewayListeners(effectiveEnv);
await terminateScheduledTaskGatewayListeners(effectiveEnv, stopContext ?? undefined);
} else {
await terminateScheduledTaskNodeHost(effectiveEnv);
}
await terminateInstalledStartupRuntime(effectiveEnv);
if (stopPort) {
const released = await waitForGatewayPortRelease(stopPort);
const probeHosts = stopContext?.probeHosts ?? [];
const released = await waitForGatewayPortRelease(stopPort, 5_000, { probeHosts });
if (!released) {
await terminateBusyPortListeners(stopPort);
const releasedAfterForce = await waitForGatewayPortRelease(stopPort, 2_000);
if (!releasedAfterForce) {
throw new Error(`gateway port ${stopPort} is still busy after stop`);
}
throw new Error(
`gateway port ${stopPort} is still busy after stop; remaining listener ownership could not be verified`,
);
}
}
stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`);
@@ -336,10 +339,13 @@ export async function restartRegisteredScheduledTask(params: {
params.onEndMutation?.();
}
const manageGatewayPort = shouldManageGatewayListenerPort(params.env);
const restartPort = manageGatewayPort ? await resolveScheduledTaskPort(params.env) : null;
const restartContext = manageGatewayPort
? await resolveScheduledTaskGatewayContext(params.env)
: null;
const restartPort = restartContext?.port ?? null;
if (params.mode.kind === "standard") {
if (manageGatewayPort) {
await terminateScheduledTaskGatewayListeners(params.env);
await terminateScheduledTaskGatewayListeners(params.env, restartContext ?? undefined);
} else {
await terminateScheduledTaskNodeHost(params.env);
}
@@ -357,18 +363,17 @@ export async function restartRegisteredScheduledTask(params: {
}
}
if (restartPort) {
const released = await waitForGatewayPortRelease(restartPort);
const probeHosts = restartContext?.probeHosts ?? [];
const released = await waitForGatewayPortRelease(restartPort, 5_000, { probeHosts });
if (!released) {
if (params.mode.kind === "fallback-takeover") {
throw new Error(
`replacement gateway port ${restartPort} is occupied by an unverified process`,
);
}
await terminateBusyPortListeners(restartPort);
const releasedAfterForce = await waitForGatewayPortRelease(restartPort, 2_000);
if (!releasedAfterForce) {
throw new Error(`gateway port ${restartPort} is still busy before restart`);
}
throw new Error(
`gateway port ${restartPort} is still busy before restart; remaining listener ownership could not be verified`,
);
}
}
const activation = await runScheduledTaskOrThrow({
+104 -74
View File
@@ -1,7 +1,6 @@
import { spawnSync } from "node:child_process";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { isGatewayArgv } from "../infra/gateway-process-argv.js";
import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js";
import { inspectPortUsage, type PortListener } from "../infra/ports.js";
import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js";
import {
@@ -12,9 +11,10 @@ import { killProcessTree } from "../process/kill-tree.js";
import { sleep } from "../utils.js";
import { parseCmdScriptCommandLine } from "./cmd-argv.js";
import { NODE_SERVICE_KIND } from "./constants.js";
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
import { readScheduledTaskCommand } from "./schtasks-layout.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import type { GatewayServiceEnv } from "./service-types.js";
import type { GatewayServiceCommandConfig, GatewayServiceEnv } from "./service-types.js";
type WindowsProcessSnapshotEntry = {
ProcessId?: number;
@@ -117,45 +117,19 @@ async function resolveScheduledTaskNodeHostProcess(
return resolveScheduledTaskProcess(env, isNodeHostArgv);
}
async function resolveScheduledTaskGatewayProcess(
env: GatewayServiceEnv,
): Promise<{ pid: number; port: number } | null> {
return resolveScheduledTaskProcess(env, (argv) =>
isGatewayArgv(argv, { allowGatewayBinary: true }),
);
}
export function shouldManageGatewayListenerPort(env: GatewayServiceEnv): boolean {
return normalizeLowercaseStringOrEmpty(env.OPENCLAW_SERVICE_KIND) !== NODE_SERVICE_KIND;
}
export async function resolveScheduledTaskPort(env: GatewayServiceEnv): Promise<number | null> {
return resolveScheduledTaskCommandPort(
env,
await readScheduledTaskCommand(env).catch(() => null),
);
}
export async function resolveScheduledTaskGatewayListenerPids(port: number): Promise<number[]> {
const verified = findVerifiedGatewayListenerPidsOnPortSync(port);
if (verified.length > 0) {
return verified;
}
const diagnostics = await inspectPortUsage(port).catch(() => null);
if (diagnostics?.status !== "busy") {
return [];
}
const matchedGatewayPids = resolveGatewayListenerPids(diagnostics.listeners);
if (matchedGatewayPids.length > 0) {
return matchedGatewayPids;
}
return Array.from(
new Set(
diagnostics.listeners
.map((listener) => listener.pid)
.filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0),
),
);
export async function resolveScheduledTaskGatewayContext(env: GatewayServiceEnv): Promise<{
port: number | null;
probeHosts: readonly string[];
}> {
const command = await readScheduledTaskCommand(env).catch(() => null);
return {
port: resolveScheduledTaskCommandPort(env, command),
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
export function resolveGatewayListenerPids(listeners: PortListener[]): number[] {
@@ -175,6 +149,63 @@ export function resolveGatewayListenerPids(listeners: PortListener[]): number[]
);
}
export async function resolveScheduledTaskOwnedGatewayPids(
env: GatewayServiceEnv,
context?: { port: number | null; probeHosts: readonly string[] },
installedCommand?: GatewayServiceCommandConfig | null,
): Promise<number[]> {
const command =
installedCommand === undefined
? await readScheduledTaskCommand(env).catch(() => null)
: installedCommand;
const installedArguments = command?.programArguments;
if (!installedArguments?.length) {
return [];
}
const resolvedContext = context ?? {
port: resolveScheduledTaskCommandPort(env, command),
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
const port = resolvedContext.port;
if (!port) {
return [];
}
const ownedPids = new Set<number>();
const snapshot = readWindowsProcessSnapshot();
if (process.platform === "win32") {
if (!snapshot) {
return [];
}
const pid = findInstalledProcessPid(snapshot, port, installedArguments, () => true);
if (pid) {
// The task is single-instance; persisted argv identifies its process before it starts listening.
return [pid];
}
// A listener can be dual-stack or belong to another task; Windows control requires CIM argv proof.
return [];
}
// Portable tests use listener command lines because CIM process snapshots exist only on Windows.
const diagnostics = await inspectPortUsage(port, {
probeHosts: resolvedContext.probeHosts,
}).catch(() => null);
if (diagnostics?.status === "busy") {
for (const listener of diagnostics.listeners) {
if (typeof listener.pid !== "number" || !listener.commandLine) {
continue;
}
const argv = parseCmdScriptCommandLine(listener.commandLine);
if (
parseTcpPortFromArgs(argv) === port &&
matchesInstalledProgramArguments(argv, installedArguments)
) {
ownedPids.add(listener.pid);
}
}
}
return Array.from(ownedPids);
}
export async function resolveListenerBackedScheduledTaskRuntime(
env: GatewayServiceEnv,
): Promise<Pick<GatewayServiceRuntime, "status" | "pid" | "detail"> | null> {
@@ -188,24 +219,17 @@ export async function resolveListenerBackedScheduledTaskRuntime(
}
: null;
}
const matched = await resolveScheduledTaskGatewayProcess(env);
if (matched) {
return {
status: "running",
pid: matched.pid,
detail: `Gateway process detected for gateway port ${matched.port}.`,
};
}
const port = await resolveScheduledTaskPort(env);
if (!port) {
return null;
}
const pids = findVerifiedGatewayListenerPidsOnPortSync(port);
const command = await readScheduledTaskCommand(env).catch(() => null);
const context = {
port: resolveScheduledTaskCommandPort(env, command),
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
const pids = await resolveScheduledTaskOwnedGatewayPids(env, context, command);
return pids.length > 0
? {
status: "running",
pid: pids[0],
detail: `Verified gateway listener detected on port ${port} even though schtasks did not report a running task.`,
detail: `Gateway process detected for gateway port ${context.port}.`,
}
: null;
}
@@ -221,15 +245,17 @@ export async function terminateScheduledTaskNodeHost(env: GatewayServiceEnv): Pr
export async function terminateScheduledTaskGatewayListeners(
env: GatewayServiceEnv,
context?: { port: number | null; probeHosts: readonly string[] },
): Promise<number[]> {
if (!shouldManageGatewayListenerPort(env)) {
return [];
}
const port = await resolveScheduledTaskPort(env);
const resolvedContext = context ?? (await resolveScheduledTaskGatewayContext(env));
const port = resolvedContext.port;
if (!port) {
return [];
}
const pids = await resolveScheduledTaskGatewayListenerPids(port);
const pids = await resolveScheduledTaskOwnedGatewayPids(env, resolvedContext);
for (const pid of pids) {
await terminateGatewayProcessTree(pid, 300);
}
@@ -305,10 +331,17 @@ export async function terminateGatewayProcessTree(pid: number, graceMs: number):
}
}
export async function waitForGatewayPortRelease(port: number, timeoutMs = 5_000): Promise<boolean> {
export async function waitForGatewayPortRelease(
port: number,
timeoutMs = 5_000,
options?: { probeHosts?: readonly string[] },
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const diagnostics = await inspectPortUsage(port).catch(() => null);
const diagnostics = await inspectPortUsage(
port,
options?.probeHosts ? { probeHosts: options.probeHosts } : undefined,
).catch(() => null);
if (diagnostics?.status === "free") {
return true;
}
@@ -317,24 +350,6 @@ export async function waitForGatewayPortRelease(port: number, timeoutMs = 5_000)
return false;
}
export async function terminateBusyPortListeners(port: number): Promise<number[]> {
const diagnostics = await inspectPortUsage(port).catch(() => null);
if (diagnostics?.status !== "busy") {
return [];
}
const pids = Array.from(
new Set(
diagnostics.listeners
.map((listener) => listener.pid)
.filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0),
),
);
for (const pid of pids) {
await terminateGatewayProcessTree(pid, 300);
}
return pids;
}
export function readWindowsProcessSnapshot(): WindowsProcessSnapshotEntry[] | null {
if (process.platform !== "win32") {
return null;
@@ -380,7 +395,22 @@ export async function assertReplacementPortAvailableForTakeover(params: {
if (!port) {
throw new Error("Could not verify the replacement Windows Scheduled Task port.");
}
const diagnostics = await inspectPortUsage(port).catch(() => null);
const probeHosts = await resolveGatewayServiceProbeHosts({
env: params.env,
command: {
programArguments: params.programArguments,
...(params.environment
? {
environment: Object.fromEntries(
Object.entries(params.environment).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
}
: {}),
},
});
const diagnostics = await inspectPortUsage(port, { probeHosts }).catch(() => null);
if (!diagnostics) {
throw new Error(`Could not inspect replacement gateway port ${port}.`);
}
+10 -12
View File
@@ -9,6 +9,7 @@ import {
getWindowsPowerShellExePath,
} from "../infra/windows-install-roots.js";
import { sleep } from "../utils.js";
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
import { formatLine } from "./output.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import { execSchtasks } from "./schtasks-exec.js";
@@ -270,17 +271,9 @@ export async function resolveFallbackRuntime(
detail: `Startup-folder login item installed; could not verify the installed process for gateway port ${port}.`,
};
}
} else {
const verifiedPids = findVerifiedGatewayListenerPidsOnPortSync(port);
if (verifiedPids.length > 0) {
return {
status: "running",
pid: verifiedPids[0],
detail: `Startup-folder login item installed; verified gateway listener detected on port ${port}.`,
};
}
}
const diagnostics = await inspectPortUsage(port).catch(() => null);
const probeHosts = await resolveGatewayServiceProbeHosts({ env, command });
const diagnostics = await inspectPortUsage(port, { probeHosts }).catch(() => null);
if (!diagnostics) {
return {
status: "unknown",
@@ -299,7 +292,12 @@ export async function resolveFallbackRuntime(
};
}
const matchedGatewayPids = resolveGatewayListenerPids(diagnostics.listeners);
if (matchedGatewayPids.length > 0) {
const scopedListenerPids = new Set(diagnostics.listeners.map((listener) => listener.pid));
const verifiedGatewayPids = findVerifiedGatewayListenerPidsOnPortSync(port).filter((pid) =>
scopedListenerPids.has(pid),
);
const ownedGatewayPids = matchedGatewayPids.length > 0 ? matchedGatewayPids : verifiedGatewayPids;
if (ownedGatewayPids.length > 0) {
return requireCommandOwnership
? {
status: "unknown",
@@ -307,7 +305,7 @@ export async function resolveFallbackRuntime(
}
: {
status: "running",
pid: matchedGatewayPids[0],
pid: ownedGatewayPids[0],
detail: `Startup-folder login item installed; verified gateway listener detected on port ${port}.`,
};
}
+80 -6
View File
@@ -27,6 +27,7 @@ import {
inspectPortUsage,
killProcessTree,
resetSchtasksBaseMocks,
schtasksCalls,
schtasksResponses,
withWindowsEnv,
writeGatewayScript,
@@ -755,10 +756,18 @@ describe("Windows startup fallback", () => {
const startupEntryPath = await writeStartupFallbackEntry(env);
await writeGatewayScript(env);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
let portInspections = 0;
inspectPortUsage.mockImplementation(async (port) => {
schtasksResponses.length = 0;
schtasksResponses.push({ code: 1, stdout: "", stderr: "restart denied" });
return { port, status: "free", listeners: [], hints: [] };
return portInspections++ === 0
? {
port,
status: "busy",
listeners: [{ pid: 4242, command: "node.exe" }],
hints: [],
}
: { port, status: "free", listeners: [], hints: [] };
});
addStartupFallbackMissingResponses([
{ code: 0, stdout: "", stderr: "" },
@@ -810,7 +819,9 @@ describe("Windows startup fallback", () => {
await installGatewayScheduledTask(env, new PassThrough(), "19433");
expect(inspectPortUsage).toHaveBeenCalledWith(18789);
expect(inspectPortUsage).toHaveBeenCalledWith(18789, {
probeHosts: ["127.0.0.1"],
});
expectGatewayTermination(4242);
await expect(fs.access(startupEntryPath)).rejects.toThrow();
});
@@ -1260,6 +1271,23 @@ describe("Windows startup fallback", () => {
const startupEntryPath = await writeStartupFallbackEntry(hiddenEnv);
await writeGatewayScript(hiddenEnv);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
inspectPortUsage.mockImplementation(async () =>
schtasksCalls.some((call) => call[0] === "/Run")
? {
port: 18789,
status: "busy",
listeners: [
{
pid: 4242,
command: "node.exe",
commandLine:
'"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\steipete\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js" gateway --port 18789',
},
],
hints: [],
}
: { port: 18789, status: "free", listeners: [], hints: [] },
);
addSuccessfulScheduledTaskRestartResponses(
[cleanExitTaskQueryOutput()],
cleanExitTaskQueryOutput(),
@@ -1408,7 +1436,24 @@ describe("Windows startup fallback", () => {
it("does not fall back when a listener appears after the clean task exit", async () => {
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
fastForwardTaskStartWait();
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValueOnce([]).mockReturnValue([4242]);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
let portInspections = 0;
inspectPortUsage.mockImplementation(async (port) =>
portInspections++ === 0
? { port, status: "free", listeners: [], hints: [] }
: {
port,
status: "busy",
listeners: [
{
pid: 4242,
command: "node.exe",
commandLine: "node gateway.js --port 18789",
},
],
hints: [],
},
);
addAcceptedRunCleanExitResponses();
await installGatewayScheduledTask(env);
@@ -1544,18 +1589,47 @@ describe("Windows startup fallback", () => {
});
});
it("reports a fallback-launched gateway as running even when schtasks still says not-yet-run", async () => {
it("does not attribute another gateway listener to the registered task", async () => {
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
await writeGatewayScript(env);
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [
{
pid: 4242,
command: "node.exe",
commandLine:
'"C:\\Program Files\\nodejs\\node.exe" "C:\\other\\dist\\index.js" gateway --port 18789',
},
],
hints: [],
});
spawnSync.mockImplementation((command, args) =>
command === getWindowsPowerShellExePath() &&
Array.isArray(args) &&
args.includes(NODE_PROCESS_QUERY)
? makeSpawnSyncResult({
stdout: JSON.stringify([
{
ProcessId: 4242,
CommandLine:
'"C:\\Program Files\\nodejs\\node.exe" "C:\\other\\dist\\index.js" gateway --port 18789',
},
]),
})
: makeSpawnSyncResult(),
);
schtasksResponses.push(
{ code: 0, stdout: "", stderr: "" },
{ code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" },
);
const runtime = await readScheduledTaskRuntime(env);
expect(runtime.status).toBe("running");
expect(runtime.pid).toBe(4242);
expect(runtime.status).toBe("stopped");
expect(runtime.pid).toBeUndefined();
expect(runtime.state).toBe("Ready");
expect(runtime.lastRunResult).toBe("267011");
});
+28 -19
View File
@@ -60,6 +60,8 @@ const {
} = await import("./schtasks.js");
const GATEWAY_PORT = 18789;
const SUCCESS_RESPONSE = { code: 0, stdout: "", stderr: "" } as const;
const INSTALLED_GATEWAY_COMMAND_LINE =
'"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\steipete\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js" gateway --port 18789';
function pushSuccessfulSchtasksResponses(count: number) {
for (let i = 0; i < count; i += 1) {
@@ -90,6 +92,7 @@ function busyPortUsage(
{
pid,
command: options.command ?? "node.exe",
address: `127.0.0.1:${GATEWAY_PORT}`,
...(options.commandLine ? { commandLine: options.commandLine } : {}),
},
],
@@ -441,20 +444,23 @@ describe("Scheduled Task stop/restart cleanup", () => {
},
);
it("kills lingering verified gateway listeners after schtasks stop", async () => {
it("kills the lingering gateway process owned by the persisted task command", async () => {
await withPreparedGatewayTask(async ({ env, stdout }) => {
const onMutation = vi.fn();
pushSuccessfulSchtasksResponses(3);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
inspectPortUsage
.mockResolvedValueOnce(busyPortUsage(4242))
.mockResolvedValueOnce(busyPortUsage(4242, { commandLine: INSTALLED_GATEWAY_COMMAND_LINE }))
.mockResolvedValueOnce(freePortUsage());
await stopScheduledTask({ env, stdout, onMutation });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT);
expect(findVerifiedGatewayListenerPidsOnPortSync).not.toHaveBeenCalled();
expectGatewayTermination(4242);
expect(inspectPortUsage).toHaveBeenCalledTimes(2);
expect(inspectPortUsage).toHaveBeenCalledWith(GATEWAY_PORT, {
probeHosts: ["127.0.0.1"],
});
expect(onMutation).toHaveBeenCalledWith({ mode: "schtasks-stop" });
});
});
@@ -508,27 +514,27 @@ describe("Scheduled Task stop/restart cleanup", () => {
});
});
it("force-kills remaining busy port listeners when the first stop pass does not free the port", async () => {
it("does not kill an unrelated listener when the owned process leaves another required host busy", async () => {
await withPreparedGatewayTask(async ({ env, stdout }) => {
pushSuccessfulSchtasksResponses(3);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
inspectPortUsage.mockResolvedValueOnce(busyPortUsage(4242));
for (let i = 0; i < 19; i += 1) {
inspectPortUsage.mockResolvedValueOnce(busyPortUsage(4242));
inspectPortUsage.mockResolvedValueOnce(
busyPortUsage(4242, { commandLine: INSTALLED_GATEWAY_COMMAND_LINE }),
);
for (let i = 0; i < 20; i += 1) {
inspectPortUsage.mockResolvedValueOnce(busyPortUsage(5252));
}
inspectPortUsage
.mockResolvedValueOnce(busyPortUsage(5252))
.mockResolvedValueOnce(freePortUsage());
await stopScheduledTask({ env, stdout });
await expect(stopScheduledTask({ env, stdout })).rejects.toThrow(
"remaining listener ownership could not be verified",
);
if (process.platform !== "win32") {
expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 });
expect(killProcessTree).toHaveBeenNthCalledWith(2, 5252, { graceMs: 300 });
expect(killProcessTree).toHaveBeenCalledOnce();
expect(killProcessTree).toHaveBeenCalledWith(4242, { graceMs: 300 });
} else {
expect(killProcessTree).not.toHaveBeenCalled();
}
expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(22);
expect(killProcessTree).not.toHaveBeenCalledWith(5252, { graceMs: 300 });
});
});
@@ -573,22 +579,25 @@ describe("Scheduled Task stop/restart cleanup", () => {
});
});
it("kills lingering verified gateway listeners and waits for port release before restart", async () => {
it("kills the owned gateway process and waits for port release before restart", async () => {
await withPreparedGatewayTask(async ({ env, stdout }) => {
const onMutation = vi.fn();
pushSuccessfulSchtasksResponses(4);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([5151]);
inspectPortUsage
.mockResolvedValueOnce(busyPortUsage(5151))
.mockResolvedValueOnce(busyPortUsage(5151, { commandLine: INSTALLED_GATEWAY_COMMAND_LINE }))
.mockResolvedValueOnce(freePortUsage());
await expect(restartScheduledTask({ env, stdout, onMutation })).resolves.toEqual({
outcome: "completed",
});
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT);
expect(findVerifiedGatewayListenerPidsOnPortSync).not.toHaveBeenCalled();
expectGatewayTermination(5151);
expect(inspectPortUsage).toHaveBeenCalledTimes(2);
expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(inspectPortUsage).toHaveBeenCalledWith(GATEWAY_PORT, {
probeHosts: ["127.0.0.1"],
});
expect(onMutation).toHaveBeenCalledWith({ mode: "schtasks-restart" });
expect(schtasksCalls).toEqual([
["/Query"],
@@ -3,6 +3,7 @@ import { vi } from "vitest";
import {
inspectPortUsage,
killProcessTree,
resolveGatewayServiceProbeHosts,
schtasksCalls,
schtasksResponses,
} from "./schtasks-fixtures.js";
@@ -16,7 +17,12 @@ vi.mock("../schtasks-exec.js", () => ({
}));
vi.mock("../../infra/ports.js", () => ({
inspectPortUsage: (port: number) => inspectPortUsage(port),
inspectPortUsage: (port: number, options?: { probeHosts?: readonly string[] }) =>
inspectPortUsage(port, options),
}));
vi.mock("../gateway-service-probe-hosts.js", () => ({
resolveGatewayServiceProbeHosts: () => resolveGatewayServiceProbeHosts(),
}));
vi.mock("../../process/kill-tree.js", () => ({
+6 -1
View File
@@ -11,7 +11,10 @@ import { resolveTaskScriptPath } from "../schtasks.js";
export const schtasksResponses: Array<{ code: number; stdout: string; stderr: string }> = [];
export const schtasksCalls: string[][] = [];
export const inspectPortUsage: MockFn<(port: number) => Promise<PortUsage>> = vi.fn();
export const inspectPortUsage: MockFn<
(port: number, options?: { probeHosts?: readonly string[] }) => Promise<PortUsage>
> = vi.fn();
export const resolveGatewayServiceProbeHosts: MockFn<() => Promise<readonly string[]>> = vi.fn();
export const killProcessTree: MockFn<typeof killProcessTreeImpl> = vi.fn();
/** Runs a test with Windows-like daemon environment paths and cleans the temp dir. */
@@ -37,6 +40,8 @@ export function resetSchtasksBaseMocks() {
schtasksResponses.length = 0;
schtasksCalls.length = 0;
inspectPortUsage.mockReset();
resolveGatewayServiceProbeHosts.mockReset();
resolveGatewayServiceProbeHosts.mockResolvedValue(["127.0.0.1"]);
killProcessTree.mockReset();
}
+12
View File
@@ -20,6 +20,7 @@ import {
resolveClientIp,
resolveGatewayBindHost,
resolveGatewayListenHosts,
resolveGatewayRequiredListenHosts,
resolveHostName,
} from "./net.js";
@@ -419,6 +420,17 @@ describe("resolveGatewayListenHosts", () => {
});
});
describe("resolveGatewayRequiredListenHosts", () => {
it.each([
["127.0.0.1", ["127.0.0.1"]],
["0.0.0.0", ["0.0.0.0"]],
["::1", ["::1"]],
["100.64.0.1", ["100.64.0.1", "127.0.0.1"]],
])("returns required startup hosts for %s", (host, expected) => {
expect(resolveGatewayRequiredListenHosts(host)).toEqual(expected);
});
});
describe("pickPrimaryLanIPv4", () => {
afterEach(() => {
vi.restoreAllMocks();
+12 -7
View File
@@ -369,14 +369,9 @@ export async function resolveGatewayListenHosts(
bindHost: string,
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
): Promise<string[]> {
const requiredHosts = resolveGatewayRequiredListenHosts(bindHost);
if (bindHost !== "127.0.0.1") {
if (!isValidIPv4(bindHost) || bindHost === "0.0.0.0") {
return [bindHost];
}
// Same-host clients use the canonical loopback URL even when external access is
// pinned to one interface. Startup requires both listeners so a foreign loopback
// process cannot receive credentials intended for the local Gateway.
return [bindHost, "127.0.0.1"];
return requiredHosts;
}
// Windows: uv_tcp_bind6 creates a dual-stack socket (no UV_TCP_IPV6ONLY), which
// also accepts ::ffff:127.0.0.1 connections. Binding both ::1 and 127.0.0.1 on
@@ -391,6 +386,16 @@ export async function resolveGatewayListenHosts(
return [bindHost];
}
/** Returns every address whose bind must succeed for Gateway startup to succeed. */
export function resolveGatewayRequiredListenHosts(bindHost: string): string[] {
if (!isValidIPv4(bindHost) || bindHost === "0.0.0.0" || bindHost === "127.0.0.1") {
return [bindHost];
}
// Same-host clients use the canonical loopback URL even when external access is
// pinned to one interface. Lifecycle checks must therefore cover both listeners.
return [bindHost, "127.0.0.1"];
}
/**
* Validate if a string is a valid IPv4 address.
*
+2 -31
View File
@@ -1,8 +1,8 @@
// Formats port probe results for diagnostics and CLI output.
import net from "node:net";
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { formatCliCommand } from "../cli/command-format.js";
import { parseTcpListenerEndpoint } from "./ports-netstat.js";
import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js";
/** Classifies a listener as OpenClaw Gateway, SSH tunnel, known non-gateway, or unknown. */
@@ -42,35 +42,6 @@ export function classifyPortListener(listener: PortListener, _port: number): Por
return "unknown";
}
function parseListenerAddress(address: string): { host: string; port: number } | null {
const trimmed = address.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/^tcp6?\s+/i, "").replace(/\s*\(listen\)\s*$/i, "");
const bracketMatch = normalized.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracketMatch) {
const port = Number.parseInt(
expectDefined(bracketMatch[2], "bracket match capture group 2"),
10,
);
return Number.isFinite(port)
? { host: normalizeLowercaseStringOrEmpty(bracketMatch[1]), port }
: null;
}
const lastColon = normalized.lastIndexOf(":");
if (lastColon <= 0 || lastColon >= normalized.length - 1) {
return null;
}
const host = normalizeLowercaseStringOrEmpty(normalized.slice(0, lastColon));
const portToken = normalized.slice(lastColon + 1).trim();
if (!/^\d+$/.test(portToken)) {
return null;
}
const port = Number.parseInt(portToken, 10);
return Number.isFinite(port) ? { host, port } : null;
}
// Dual-stack listener output can include IPv4-mapped IPv6 addresses; keep them
// in the IPv6 family so the benign loopback-pair detection stays conservative.
function classifyLoopbackAddressFamily(host: string): "ipv4" | "ipv6" | null {
@@ -107,7 +78,7 @@ function parsePortListeners(
if (typeof pid !== "number" || !Number.isFinite(pid) || typeof listener.address !== "string") {
return null;
}
const address = parseListenerAddress(listener.address);
const address = parseTcpListenerEndpoint(listener.address);
if (!address || address.port !== port) {
return null;
}
+76 -11
View File
@@ -1,4 +1,5 @@
// Inspects gateway port listeners and connection state.
import net from "node:net";
import os from "node:os";
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
@@ -12,7 +13,11 @@ import {
type LsofListenerRecord,
} from "./ports-lsof-listeners.js";
import { resolveLsofCommand } from "./ports-lsof.js";
import { parseTcpEndpoint, parseWindowsNetstatListeners } from "./ports-netstat.js";
import {
parseTcpEndpoint,
parseTcpListenerEndpoint,
parseWindowsNetstatListeners,
} from "./ports-netstat.js";
import { probePortUsage } from "./ports-probe.js";
import type {
PortConnection,
@@ -620,25 +625,38 @@ async function readWindowsEstablishedConnections(
return { connections: result.entries, detail: result.detail, errors: result.errors };
}
export async function inspectPortUsage(port: number): Promise<PortUsage> {
export async function inspectPortUsage(
port: number,
options?: { probeHosts?: readonly string[] },
): Promise<PortUsage> {
const result =
process.platform === "win32" ? await readWindowsListeners(port) : await readUnixListeners(port);
return buildPortUsage(port, result);
return buildPortUsage(port, result, options?.probeHosts);
}
async function buildPortUsage(port: number, result: ListenerReadResult): Promise<PortUsage> {
async function buildPortUsage(
port: number,
result: ListenerReadResult,
probeHosts?: readonly string[],
): Promise<PortUsage> {
const errors: string[] = [];
errors.push(...result.errors);
let listeners = result.listeners;
let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown";
if (listeners.length === 0) {
status = await probePortUsage(port);
}
const status: PortUsageStatus = probeHosts
? await probePortUsage(port, probeHosts)
: listeners.length > 0
? "busy"
: await probePortUsage(port);
if (status !== "busy") {
listeners = [];
} else if (probeHosts) {
listeners = listeners.filter((listener) =>
isListenerRelevantToProbeHosts(listener, port, probeHosts),
);
}
const hints = buildPortHints(listeners, port);
if (status === "busy" && listeners.length === 0) {
// The bind probe is authoritative; filtered diagnostics must never turn busy into free.
hints.push(
"Port is in use but process details are unavailable (install lsof or run as an admin user).",
);
@@ -653,11 +671,51 @@ async function buildPortUsage(port: number, result: ListenerReadResult): Promise
};
}
export async function inspectPortUsages(ports: readonly number[]): Promise<Map<number, PortUsage>> {
function isWildcardTcpHost(host: string): boolean {
return host === "0.0.0.0" || host === "::" || host === "*";
}
function isSameTcpAddressFamily(leftHost: string, rightHost: string): boolean {
const leftFamily = net.isIP(leftHost);
const rightFamily = net.isIP(rightHost);
return leftFamily === 0 || rightFamily === 0 || leftFamily === rightFamily;
}
function isListenerRelevantToProbeHosts(
listener: PortListener,
port: number,
probeHosts: readonly string[],
): boolean {
const endpoint = parseTcpListenerEndpoint(listener.address);
if (!endpoint || endpoint.port !== port) {
return false;
}
return probeHosts.some((probeHost) => {
const normalizedProbeHost = normalizeLowercaseStringOrEmpty(probeHost);
if (isWildcardTcpHost(endpoint.host)) {
return isSameTcpAddressFamily(endpoint.host, normalizedProbeHost);
}
if (isWildcardTcpHost(normalizedProbeHost)) {
return isSameTcpAddressFamily(normalizedProbeHost, endpoint.host);
}
return normalizedProbeHost === endpoint.host;
});
}
export async function inspectPortUsages(
ports: readonly number[],
options?: { probeHostsByPort?: ReadonlyMap<number, readonly string[]> },
): Promise<Map<number, PortUsage>> {
const uniquePorts = Array.from(new Set(ports));
if (process.platform === "win32") {
const entries = await Promise.all(
uniquePorts.map(async (port) => [port, await inspectPortUsage(port)] as const),
uniquePorts.map(async (port) => {
const probeHosts = options?.probeHostsByPort?.get(port);
return [
port,
await inspectPortUsage(port, probeHosts ? { probeHosts } : undefined),
] as const;
}),
);
return new Map(entries);
}
@@ -666,7 +724,14 @@ export async function inspectPortUsages(ports: readonly number[]): Promise<Map<n
const entries = await Promise.all(
uniquePorts.map(
async (port) =>
[port, await buildPortUsage(port, await readUnixListeners(port, snapshot))] as const,
[
port,
await buildPortUsage(
port,
await readUnixListeners(port, snapshot),
options?.probeHostsByPort?.get(port),
),
] as const,
),
);
return new Map(entries);
+12
View File
@@ -40,6 +40,18 @@ export function parseTcpEndpoint(raw: string): { host: string; port: number } |
return { host: normalizeTcpHost(endpoint.slice(0, lastColon)), port };
}
/** Parses the address field emitted for a TCP listener by lsof or netstat. */
export function parseTcpListenerEndpoint(raw: string | undefined): {
host: string;
port: number;
} | null {
const normalized = raw
?.trim()
.replace(/^tcp6?\s+/i, "")
.replace(/\s*\(listen\)\s*$/i, "");
return normalized ? parseTcpEndpoint(normalized) : null;
}
function isWildcardEndpoint(raw: string | undefined): boolean {
const endpoint = raw?.trim();
if (!endpoint || endpoint === "*:*") {
+16 -3
View File
@@ -3,15 +3,21 @@ import net from "node:net";
import { describe, expect, it } from "vitest";
import { probePortUsage, tryListenOnPort } from "./ports-probe.js";
async function withListeningServer(cb: (address: net.AddressInfo) => Promise<void>): Promise<void> {
async function withListeningServer(
cb: (address: net.AddressInfo) => Promise<void>,
host = "127.0.0.1",
): Promise<void> {
const server = net.createServer();
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
server.listen(0, host, () => resolve());
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EPERM") {
if (
(err as NodeJS.ErrnoException).code === "EPERM" ||
(err as NodeJS.ErrnoException).code === "EADDRNOTAVAIL"
) {
return;
}
throw err;
@@ -72,4 +78,11 @@ describe("probePortUsage", () => {
await expect(probePortUsage(address.port)).resolves.toBe("busy");
});
});
it("can scope a probe to a free loopback address when another address owns the port", async () => {
await withListeningServer(async (address) => {
await expect(probePortUsage(address.port)).resolves.toBe("busy");
await expect(probePortUsage(address.port, ["127.0.0.1"])).resolves.toBe("free");
}, "127.0.0.2");
});
});
+7 -3
View File
@@ -4,6 +4,7 @@ import { isErrno } from "./errors.js";
import type { PortUsageStatus } from "./ports-types.js";
const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"];
export const LOOPBACK_PORT_PROBE_HOSTS = ["127.0.0.1"] as const;
/** Opens and closes a temporary listener to verify that a port can be bound. */
export async function tryListenOnPort(params: {
@@ -48,10 +49,13 @@ async function probePortOnHost(port: number, host: string): Promise<PortUsageSta
}
}
/** Checks all supported local address families without resolving listener diagnostics. */
export async function probePortUsage(port: number): Promise<PortUsageStatus> {
/** Checks selected local addresses without resolving listener diagnostics. */
export async function probePortUsage(
port: number,
probeHosts: readonly string[] = PORT_PROBE_HOSTS,
): Promise<PortUsageStatus> {
let sawUnknown = false;
for (const host of PORT_PROBE_HOSTS) {
for (const host of probeHosts) {
const result = await probePortOnHost(port, host);
if (result === "busy") {
return "busy";
+116 -1
View File
@@ -44,7 +44,7 @@ async function listenServer(
});
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EPERM" || code === "EACCES") {
if (code === "EPERM" || code === "EACCES" || code === "EADDRNOTAVAIL") {
return null;
}
throw err;
@@ -134,6 +134,121 @@ describe("ports helpers", () => {
});
describeUnix("inspectPortUsage", () => {
it("keeps only listener rows that can block a scoped bind", async () => {
const server = net.createServer();
const address = await listenServer(server, 0, "127.0.0.1");
if (!address) {
return;
}
const port = address.port;
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
const command = argv[0];
if (typeof command === "string" && command.includes("lsof")) {
return {
stdout:
`p111\ncgateway\nnTCP 127.0.0.1:${port} (LISTEN)\n` +
`p222\ncother\nnTCP 127.0.0.2:${port} (LISTEN)\n`,
stderr: "",
code: 0,
};
}
return { stdout: "", stderr: "", code: 1 };
});
try {
const result = await inspectPortUsage(port, { probeHosts: ["127.0.0.1"] });
expect(result.status).toBe("busy");
expect(result.listeners).toHaveLength(1);
expect(result.listeners[0]).toMatchObject({
pid: 111,
address: `TCP 127.0.0.1:${port} (LISTEN)`,
});
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it.each([
{ probeHost: "127.0.0.1", unrelatedWildcard: "[::]" },
{ probeHost: "::1", unrelatedWildcard: "0.0.0.0" },
])(
"does not attribute an opposite-family wildcard listener to $probeHost",
async ({ probeHost, unrelatedWildcard }) => {
const server = net.createServer();
const address = await listenServer(server, 0, probeHost);
if (!address) {
return;
}
const port = address.port;
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
const command = argv[0];
if (typeof command === "string" && command.includes("lsof")) {
return {
stdout: `p222\ncother\nnTCP ${unrelatedWildcard}:${port} (LISTEN)\n`,
stderr: "",
code: 0,
};
}
return { stdout: "", stderr: "", code: 1 };
});
try {
const result = await inspectPortUsage(port, { probeHosts: [probeHost] });
expect(result.status).toBe("busy");
expect(result.listeners).toEqual([]);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
},
);
it("ignores another interface when inspection is scoped to the gateway loopback", async () => {
const server = net.createServer();
const address = await listenServer(server, 0, "127.0.0.2");
if (!address) {
return;
}
const port = address.port;
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
const command = argv[0];
if (typeof command === "string" && command.includes("lsof")) {
return {
stdout: `p${process.pid}\ncnode\nnTCP 127.0.0.2:${port} (LISTEN)\n`,
stderr: "",
code: 0,
};
}
return { stdout: "", stderr: "", code: 1 };
});
try {
const allInterfaces = await inspectPortUsage(port);
const gatewayLoopback = await inspectPortUsage(port, {
probeHosts: ["127.0.0.1"],
});
expect(allInterfaces.status).toBe("busy");
expect(gatewayLoopback).toMatchObject({
status: "free",
listeners: [],
hints: [],
});
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("reports busy when lsof is missing but loopback listener exists", async () => {
const server = net.createServer();
const address = await listenServer(server, 0, "127.0.0.1");
+2 -1
View File
@@ -6,7 +6,7 @@ import { defaultRuntime } from "../runtime.js";
import { isErrno } from "./errors.js";
import { formatPortDiagnostics } from "./ports-format.js";
import { inspectPortUsage } from "./ports-inspect.js";
import { tryListenOnPort } from "./ports-probe.js";
import { LOOPBACK_PORT_PROBE_HOSTS, tryListenOnPort } from "./ports-probe.js";
import type { PortConnection, PortListener, PortUsage, PortUsageStatus } from "./ports-types.js";
class PortInUseError extends Error {
@@ -95,4 +95,5 @@ export {
isDualStackLoopbackGatewayListeners,
isExpectedGatewayListeners,
} from "./ports-format.js";
export { LOOPBACK_PORT_PROBE_HOSTS };
export { inspectPortConnections, inspectPortUsage, inspectPortUsages } from "./ports-inspect.js";
@@ -339,12 +339,14 @@ describe("bundled plugin build entries", () => {
}
});
it("keeps Cohere bundled through the externalization transition", () => {
it("excludes externalized Cohere and Meta providers from bundled artifacts", () => {
const artifacts = listBundledPluginPackArtifacts();
expect(artifacts).toContain("dist/extensions/cohere/index.js");
expect(artifacts).toContain("dist/extensions/cohere/openclaw.plugin.json");
expect(artifacts).toContain("dist/extensions/cohere/package.json");
for (const pluginId of ["cohere", "meta"]) {
expect(artifacts).not.toContain(`dist/extensions/${pluginId}/index.js`);
expect(artifacts).not.toContain(`dist/extensions/${pluginId}/openclaw.plugin.json`);
expect(artifacts).not.toContain(`dist/extensions/${pluginId}/package.json`);
}
});
it("keeps bundled channel secret contracts on packed top-level sidecars", () => {