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