diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 90ac5d20bd57..f9e3b19be5ad 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -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. diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 8ceb6ceaab9d..4f1de3af46b3 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -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. diff --git a/docs/plugins/reference/cohere.md b/docs/plugins/reference/cohere.md index 2260d317283d..151e1b7aa9e1 100644 --- a/docs/plugins/reference/cohere.md +++ b/docs/plugins/reference/cohere.md @@ -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 diff --git a/docs/plugins/reference/meta.md b/docs/plugins/reference/meta.md index 8a3388e1f4a0..9324d97c2f28 100644 --- a/docs/plugins/reference/meta.md +++ b/docs/plugins/reference/meta.md @@ -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 diff --git a/docs/providers/cohere.md b/docs/providers/cohere.md index 8fe2bbdca009..b0387b83c3f0 100644 --- a/docs/providers/cohere.md +++ b/docs/providers/cohere.md @@ -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 ` | -| 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 ` | +| 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 diff --git a/docs/providers/meta.md b/docs/providers/meta.md index 939cd99df882..2667a826ae49 100644 --- a/docs/providers/meta.md +++ b/docs/providers/meta.md @@ -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 ` | @@ -25,6 +25,12 @@ plugin. ## Getting started + + ```bash + openclaw plugins install @openclaw/meta-provider + openclaw gateway restart + ``` + diff --git a/extensions/cohere/package.json b/extensions/cohere/package.json index 37e3b91cfaeb..d08d747ba896 100644 --- a/extensions/cohere/package.json +++ b/extensions/cohere/package.json @@ -25,7 +25,7 @@ }, "build": { "openclawVersion": "2026.7.2", - "bundledDist": true + "bundledDist": false }, "release": { "publishToClawHub": true, diff --git a/extensions/meta/README.md b/extensions/meta/README.md index 1efc3b466244..6441f3b14368 100644 --- a/extensions/meta/README.md +++ b/extensions/meta/README.md @@ -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 diff --git a/extensions/meta/index.ts b/extensions/meta/index.ts index f33377f8bf2e..f03f1012eb2d 100644 --- a/extensions/meta/index.ts +++ b/extensions/meta/index.ts @@ -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", diff --git a/extensions/meta/package.json b/extensions/meta/package.json index 470cd77c5684..4044a79655c9 100644 --- a/extensions/meta/package.json +++ b/extensions/meta/package.json @@ -25,7 +25,7 @@ }, "build": { "openclawVersion": "2026.7.2", - "bundledDist": true + "bundledDist": false }, "release": { "publishToClawHub": true, diff --git a/package.json b/package.json index 6215ab7b0553..8b5844c0d814 100644 --- a/package.json +++ b/package.json @@ -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/**", diff --git a/src/cli/daemon-cli/restart-health-probe.ts b/src/cli/daemon-cli/restart-health-probe.ts index a5193ebc0fc7..670ac24dd056 100644 --- a/src/cli/daemon-cli/restart-health-probe.ts +++ b/src/cli/daemon-cli/restart-health-probe.ts @@ -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 { 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, diff --git a/src/cli/daemon-cli/restart-health-wait.test.ts b/src/cli/daemon-cli/restart-health-wait.test.ts index d7f1ff25003b..64138aba1d54 100644 --- a/src/cli/daemon-cli/restart-health-wait.test.ts +++ b/src/cli/daemon-cli/restart-health-wait.test.ts @@ -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 diff --git a/src/cli/daemon-cli/restart-health.test-helpers.ts b/src/cli/daemon-cli/restart-health.test-helpers.ts index 70e7cc8567f3..838d5ba5f7b8 100644 --- a/src/cli/daemon-cli/restart-health.test-helpers.ts +++ b/src/cli/daemon-cli/restart-health.test-helpers.ts @@ -5,7 +5,8 @@ import type { PortUsage } from "../../infra/ports.js"; type PortListenerKind = ReturnType; -export const inspectPortUsage = vi.fn<(port: number) => Promise>(); +export const inspectPortUsage = + vi.fn<(port: number, options?: { probeHosts?: readonly string[] }) => Promise>(); 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 +>(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("../../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() { diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index fc3101d1bde3..c1b26d477485 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -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, + }); }); }); diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index 4ca9beb1b3da..5ddeb7649349 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -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 { 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 { 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, }); } } diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index b1fca8d405c9..91474f315563 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -47,16 +47,21 @@ type PortUsageTestSummary = { hints: string[]; }; -const inspectPortUsage = vi.fn<(port: number) => Promise>( - async (port: number) => ({ - port, - status: "free", - listeners: [], - hints: [], - }), -); +type PortUsageInspectionOptions = { probeHosts?: readonly string[] }; + +const inspectPortUsage = vi.fn< + (port: number, options?: PortUsageInspectionOptions) => Promise +>(async (port: number) => ({ + port, + status: "free", + listeners: [], + hints: [], +})); const inspectPortUsages = vi.fn< - (ports: readonly number[]) => Promise> + ( + ports: readonly number[], + options?: { probeHostsByPort?: ReadonlyMap }, + ) => Promise> >( 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 }, + ) => 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 () => { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 54a74d172b30..23b3f68b9fc6 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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) diff --git a/src/cli/mcp-cli.probe-exit.process.test.ts b/src/cli/mcp-cli.probe-exit.process.test.ts new file mode 100644 index 000000000000..7fe074979bb0 --- /dev/null +++ b/src/cli/mcp-cli.probe-exit.process.test.ts @@ -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 { + return tempDirs.make("openclaw-mcp-probe-process-"); +} + +async function writeConfig(home: string, servers: Record): Promise { + 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 { + 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; + }; + 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 } }, + }); + }); +}); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts index b54f55ce3ef9..4507d7571612 100644 --- a/src/cli/mcp-cli.ts +++ b/src/cli/mcp-cli.ts @@ -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): Record }; } +function resolveMcpProbeIssue(params: { + result: ReturnType; + servers: Record>; + 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[0]): void { + const probeIssue = resolveMcpProbeIssue(params); + if (probeIssue) { + fail(probeIssue); + } +} + async function probeMcpServersOrFail(params: { config: OpenClawConfig; servers: Record>; @@ -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(); diff --git a/src/cli/one-shot-exit.test.ts b/src/cli/one-shot-exit.test.ts index 2628fd5ea76e..74d541a92951 100644 --- a/src/cli/one-shot-exit.test.ts +++ b/src/cli/one-shot-exit.test.ts @@ -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); + }); }); diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 52f8cf0be74d..0f68db732ced 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -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("../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); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 1a78bcce37ee..582f3102829f 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -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, diff --git a/src/commands/status-all/report-data.test.ts b/src/commands/status-all/report-data.test.ts index 008d445b533e..3fcbbfd9b3b3 100644 --- a/src/commands/status-all/report-data.test.ts +++ b/src/commands/status-all/report-data.test.ts @@ -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"], + }); }); }); diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts index 7539ea56222f..6e713bc5f953 100644 --- a/src/commands/status-all/report-data.ts +++ b/src/commands/status-all/report-data.ts @@ -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 = diff --git a/src/daemon/gateway-service-probe-hosts.test.ts b/src/daemon/gateway-service-probe-hosts.test.ts new file mode 100644 index 000000000000..dbcf8b23596c --- /dev/null +++ b/src/daemon/gateway-service-probe-hosts.test.ts @@ -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, + }), + ); + }, + ); +}); diff --git a/src/daemon/gateway-service-probe-hosts.ts b/src/daemon/gateway-service-probe-hosts.ts new file mode 100644 index 000000000000..bc2a2962d591 --- /dev/null +++ b/src/daemon/gateway-service-probe-hosts.ts @@ -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 { + 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); +} diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index cfe33b4f1c1a..5279341af9c0 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -103,6 +103,9 @@ const probePortUsage = vi.hoisted(() => vi.fn(async () => "free"), ); const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."])); +const resolveGatewayServiceProbeHosts = vi.hoisted(() => + vi.fn<(_params?: unknown) => Promise>(async () => ["127.0.0.1"]), +); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; function countMatching(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("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], diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index c7a373b53268..88ba511a38ef 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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 { +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 { +async function waitForGatewayPortRelease( + port: number, + probeHosts: readonly string[], +): Promise { 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 { } async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise { - 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; diff --git a/src/daemon/schtasks-control.ts b/src/daemon/schtasks-control.ts index 82e69784f610..b586f0097734 100644 --- a/src/daemon/schtasks-control.ts +++ b/src/daemon/schtasks-control.ts @@ -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({ diff --git a/src/daemon/schtasks-process.ts b/src/daemon/schtasks-process.ts index 48fe086f4edc..361eb2d8d629 100644 --- a/src/daemon/schtasks-process.ts +++ b/src/daemon/schtasks-process.ts @@ -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 { - return resolveScheduledTaskCommandPort( - env, - await readScheduledTaskCommand(env).catch(() => null), - ); -} - -export async function resolveScheduledTaskGatewayListenerPids(port: number): Promise { - 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 { + 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(); + 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 | 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 { 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 { +export async function waitForGatewayPortRelease( + port: number, + timeoutMs = 5_000, + options?: { probeHosts?: readonly string[] }, +): Promise { 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 { - 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}.`); } diff --git a/src/daemon/schtasks-runtime.ts b/src/daemon/schtasks-runtime.ts index fbee56db6998..605ac6d0e2e6 100644 --- a/src/daemon/schtasks-runtime.ts +++ b/src/daemon/schtasks-runtime.ts @@ -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}.`, }; } diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 9ecd8ffacae8..ea6d9c59ba04 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -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"); }); diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index d38dceff4945..80ca1db84bcd 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -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"], diff --git a/src/daemon/test-helpers/schtasks-base-mocks.ts b/src/daemon/test-helpers/schtasks-base-mocks.ts index 216928a85fb7..04889822660c 100644 --- a/src/daemon/test-helpers/schtasks-base-mocks.ts +++ b/src/daemon/test-helpers/schtasks-base-mocks.ts @@ -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", () => ({ diff --git a/src/daemon/test-helpers/schtasks-fixtures.ts b/src/daemon/test-helpers/schtasks-fixtures.ts index d65f870b6b30..5b21650db6b8 100644 --- a/src/daemon/test-helpers/schtasks-fixtures.ts +++ b/src/daemon/test-helpers/schtasks-fixtures.ts @@ -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> = vi.fn(); +export const inspectPortUsage: MockFn< + (port: number, options?: { probeHosts?: readonly string[] }) => Promise +> = vi.fn(); +export const resolveGatewayServiceProbeHosts: MockFn<() => Promise> = vi.fn(); export const killProcessTree: MockFn = 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(); } diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 603e7b093070..761bef408039 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -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(); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 8af7d4a2c4df..d923b75cf4a8 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -369,14 +369,9 @@ export async function resolveGatewayListenHosts( bindHost: string, opts?: { canBindToHost?: (host: string) => Promise }, ): Promise { + 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. * diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index 8f7f714692d9..3c5354f1d42a 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -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; } diff --git a/src/infra/ports-inspect.ts b/src/infra/ports-inspect.ts index ecf047b0f9db..0d5c58fbca36 100644 --- a/src/infra/ports-inspect.ts +++ b/src/infra/ports-inspect.ts @@ -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 { +export async function inspectPortUsage( + port: number, + options?: { probeHosts?: readonly string[] }, +): Promise { 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 { +async function buildPortUsage( + port: number, + result: ListenerReadResult, + probeHosts?: readonly string[], +): Promise { 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> { +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 }, +): Promise> { 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 - [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); diff --git a/src/infra/ports-netstat.ts b/src/infra/ports-netstat.ts index b7399c82fc8d..9d94c128e2c7 100644 --- a/src/infra/ports-netstat.ts +++ b/src/infra/ports-netstat.ts @@ -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 === "*:*") { diff --git a/src/infra/ports-probe.test.ts b/src/infra/ports-probe.test.ts index 54685b576b00..0398675121b9 100644 --- a/src/infra/ports-probe.test.ts +++ b/src/infra/ports-probe.test.ts @@ -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): Promise { +async function withListeningServer( + cb: (address: net.AddressInfo) => Promise, + host = "127.0.0.1", +): Promise { const server = net.createServer(); try { await new Promise((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"); + }); }); diff --git a/src/infra/ports-probe.ts b/src/infra/ports-probe.ts index 6284f8c83289..272167c667c3 100644 --- a/src/infra/ports-probe.ts +++ b/src/infra/ports-probe.ts @@ -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 { +/** Checks selected local addresses without resolving listener diagnostics. */ +export async function probePortUsage( + port: number, + probeHosts: readonly string[] = PORT_PROBE_HOSTS, +): Promise { 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"; diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 820dbe9cc4d2..2f4f0916a38c 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -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((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((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((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"); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index 9e8b90467cba..744d9981b0a2 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -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"; diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index ae25a16272b5..d26ae9eae92c 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -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", () => {