fix(gateway): preserve local access for specific binds (#98479)

* fix: prefer loopback for local tailnet dashboard

* fix(gateway): preserve local access for specific binds

---------

Co-authored-by: haruaiclone-droid <281899875+haruaiclone-droid@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
haruai
2026-07-11 11:18:47 +09:00
committed by GitHub
parent e6671d9de0
commit c2301d8e53
35 changed files with 813 additions and 131 deletions
+4 -4
View File
@@ -167,10 +167,10 @@ fail closed before QR/setup-code issuance.
For `gateway.bind=lan` setup URLs, OpenClaw detects persistent Tailscale Serve
HTTPS roots that proxy the active Gateway's loopback port and advertises them
alongside the LAN route. Specific-interface `custom` and `tailnet` binds do not
receive that fallback because a loopback Serve proxy cannot reach those
listeners. The iOS app probes the advertised routes in order and saves the first
reachable endpoint.
alongside the LAN route. The setup command adds this fallback only
for `lan`; `custom` and `tailnet` keep their explicitly advertised routes. The
iOS app probes the advertised routes in order and saves the first reachable
endpoint.
### Approve a node device
+2
View File
@@ -23,6 +23,8 @@ Notes:
- Resolves configured `gateway.auth.token` SecretRefs when possible.
- Follows `gateway.tls.enabled`: TLS-enabled gateways print/open `https://` Control UI URLs and connect over `wss://`.
- For `lan` or a wildcard `custom` bind, same-host launches always use loopback because a wildcard is not a browser destination. Plaintext `tailnet` and `custom` binds also use `127.0.0.1` so the browser has a secure context; TLS-enabled specific hosts keep the configured address so certificate names match.
- Before delivering an authenticated loopback URL for a specific-interface bind, the command probes the configured interface and verifies that it and `127.0.0.1` are owned by the same Gateway process. Ambiguous listener ownership fails closed with status guidance.
- For SecretRef-managed tokens (resolved or unresolved), the printed/copied/opened URL never includes the token, so external secrets do not leak into terminal output, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is SecretRef-managed but unresolved, the command prints a non-tokenized URL and remediation guidance instead of an invalid token placeholder.
- If clipboard/browser delivery fails for a token-authenticated URL, the command logs a safe manual-auth hint naming `OPENCLAW_GATEWAY_TOKEN`, `gateway.auth.token`, and the URL fragment key `token`, without printing the token value.
+1 -1
View File
@@ -100,7 +100,7 @@ openclaw gateway run # equivalent, explicit form
`--claude-cli-logs` is a deprecated alias for `--cli-backend-logs`.
For `--bind custom`, set `gateway.customBindHost` to an IPv4 address; the Gateway falls back to `0.0.0.0` if that address is unavailable. IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway.
For `--bind custom`, set `gateway.customBindHost` to an IPv4 address. Any address other than `127.0.0.1` or `0.0.0.0` also requires `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Wildcard `0.0.0.0` does not add a separate required alias. IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway.
## Restart the Gateway
+1 -1
View File
@@ -53,7 +53,7 @@ Pairing-mutation scopes and `operator.admin` still require a separate approved o
Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs: use Tailscale Serve/Funnel or a `wss://` gateway URL for those. Private LAN addresses and `.local` Bonjour hosts remain supported over plain `ws://`.
When the selected Gateway URL comes from `gateway.bind=lan`, OpenClaw also checks persistent `tailscale serve status --json` routes. Any HTTPS Serve root that proxies the active Gateway's loopback port is included as a fallback. Specific-interface `custom` and `tailnet` binds do not receive that fallback because a loopback Serve proxy cannot reach those listeners. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients.
When the selected Gateway URL comes from `gateway.bind=lan`, OpenClaw also checks persistent `tailscale serve status --json` routes. Any HTTPS Serve root that proxies the active Gateway's loopback port is included as a fallback. The QR command adds this fallback only for `lan`; `custom` and `tailnet` keep their explicitly advertised routes. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients.
With `--remote`, one of `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel` is required.
+1 -1
View File
@@ -590,7 +590,7 @@ See [Inferred commitments](/concepts/commitments).
- `mode`: `local` (run gateway) or `remote` (connect to remote gateway). Gateway refuses to start unless `local`.
- `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`.
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IPv4 when available, otherwise loopback), or `custom` (one IPv4 address). A resolved `tailnet` address and any `custom` address other than `127.0.0.1` or `0.0.0.0` require `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Non-loopback exposure remains limited to the selected interface.
- **Legacy bind aliases**: use bind mode values in `gateway.bind` (`auto`, `loopback`, `lan`, `tailnet`, `custom`), not host aliases (`0.0.0.0`, `127.0.0.1`, `localhost`, `::`, `::1`).
- **Docker note**: the default `loopback` bind listens on `127.0.0.1` inside the container. With Docker bridge networking (`-p 18789:18789`), traffic arrives on `eth0`, so the gateway is unreachable. Use `--network host`, or set `bind: "lan"` (or `bind: "custom"` with `customBindHost: "0.0.0.0"`) to listen on all interfaces.
- **Auth**: required by default. Non-loopback binds require gateway auth. In practice that means a shared token/password or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. Onboarding wizard generates a token by default.
+1 -1
View File
@@ -581,7 +581,7 @@ Gateway auth is required by default - with no valid auth path configured, the Ga
Pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and Tailnet `*.ts.net` gateway URLs; for other trusted private-DNS names, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass (process environment only, not an `openclaw.json` key). Mobile pairing and Android manual/scanned gateway routes are stricter: cleartext only for loopback, while private-LAN, link-local, `.local`, and dotless hostnames must use TLS unless you explicitly opt into the trusted private-network cleartext path.
Device pairing is auto-approved for direct local loopback connects (plus a narrow backend/container-local self-connect path for trusted shared-secret helper flows); Tailnet and LAN connects, including same-host tailnet binds, are treated as remote and still need approval. Forwarded-header evidence on a loopback request disqualifies loopback locality; metadata-upgrade auto-approval is scoped narrowly. See [Gateway pairing](/gateway/pairing).
Device pairing is auto-approved for direct local loopback connects (plus a narrow backend/container-local self-connect path for trusted shared-secret helper flows); Tailnet and LAN connects, including same-host connections to a tailnet address, are treated as remote and still need approval. A resolved `tailnet` address or `custom` address other than `127.0.0.1` or `0.0.0.0` adds a separate `127.0.0.1` listener; only connections to that local listener receive loopback semantics. Forwarded-header evidence on a loopback request disqualifies loopback locality; metadata-upgrade auto-approval is scoped narrowly. See [Gateway pairing](/gateway/pairing).
Auth modes:
+3 -3
View File
@@ -67,7 +67,7 @@ Connect from another Tailnet device:
- WebSocket: `ws://<tailscale-ip>:18789`
<Note>
Loopback (`http://127.0.0.1:18789`) will **not** work in this mode.
When a bindable Tailnet IPv4 is present, the Gateway also requires `http://127.0.0.1:18789` for authenticated same-host clients. If no Tailnet address is available at startup, it falls back to loopback only; restart after Tailscale becomes available to add direct Tailnet access. Neither path adds LAN or public exposure.
</Note>
### Public internet (Funnel + shared password)
@@ -121,8 +121,8 @@ Scope of the bypass:
- `gateway.tailscale.serviceName` applies only to Serve mode and is passed to `tailscale serve --service=<name>`. The value must use Tailscale's `svc:<dns-label>` format, for example `svc:openclaw`. Tailscale requires Service hosts to be tagged nodes, and the Service may need admin-console approval before Serve can publish it.
- `gateway.tailscale.resetOnExit` undoes `tailscale serve`/`tailscale funnel` configuration on shutdown.
- `gateway.tailscale.preserveFunnel: true` keeps an externally configured `tailscale funnel` route alive across gateway restarts. With `mode: "serve"`, OpenClaw checks `tailscale funnel status` before re-applying Serve and skips it when a Funnel route already covers the gateway port. The OpenClaw-managed Funnel password-only policy is unchanged.
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
- `gateway.bind: "auto"` prefers loopback; use `tailnet` for Tailnet-only binding.
- `gateway.bind: "tailnet"` uses a direct Tailnet bind (no HTTPS, no Serve/Funnel) plus required local `127.0.0.1` when a Tailnet IPv4 is available; otherwise it falls back to loopback only.
- `gateway.bind: "auto"` prefers loopback; use `tailnet` to limit network exposure to the Tailnet while retaining same-host loopback access.
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over the same Gateway WS endpoint, so Serve works for node access too.
### Tailscale prerequisites and limits
+8
View File
@@ -92,6 +92,14 @@ The tmux pane runs the raw watcher:
node scripts/watch-node.mjs gateway --force
```
Stop an installed Gateway service before watching the same port:
```bash
pnpm openclaw gateway stop
```
The watcher's `--force` clears the current listener, but it does not disable a supervised service. A launchd, systemd, or Scheduled Task service can otherwise respawn and replace the watched Gateway.
Foreground mode without tmux:
```bash
+4 -4
View File
@@ -1220,12 +1220,12 @@ Model Q&A - defaults, selection, aliases, switching, failover, auth profiles - l
</Accordion>
<Accordion title="I set gateway.bind tailnet but it cannot bind and nothing listens">
`tailnet` bind picks a Tailscale IP from your network interfaces (100.64.0.0/10). If the machine is not on Tailscale (or the interface is down), there is nothing to bind to.
<Accordion title="I set gateway.bind tailnet but it listens only on loopback">
`tailnet` bind picks a Tailscale IP from your network interfaces (100.64.0.0/10). If the machine is not on Tailscale (or the interface is down), the Gateway falls back to loopback instead of exposing another network interface.
Fix: start Tailscale on that host, or switch to `gateway.bind: "loopback"` / `"lan"`.
Fix: start Tailscale on that host and restart the Gateway, or switch explicitly to `gateway.bind: "loopback"` / `"lan"`.
`tailnet` is explicit; `auto` prefers loopback. Use `gateway.bind: "tailnet"` for a tailnet-only bind.
`tailnet` is explicit; `auto` prefers loopback. Use `gateway.bind: "tailnet"` to limit non-loopback exposure to the Tailnet while retaining the required same-host `127.0.0.1` listener.
</Accordion>
+4
View File
@@ -1327,6 +1327,7 @@ const shouldSkipWatchRuntimeSync = (deps, requirement) =>
!hasMissingRequiredRuntimePostBuildOutput(deps);
const isGatewayClientCommand = (args) =>
args[0] === "dashboard" ||
(args[0] === "gateway" && (args[1] === "call" || args[1] === "status")) ||
(args[0] === "agent" && !args.includes("--local"));
@@ -1507,6 +1508,9 @@ export async function runNodeMain(params = {}) {
}
const buildExitCode = await withRunNodeBuildLock(deps, async () => {
if (shouldFastPathExistingDistForGatewayClient(deps)) {
return 0;
}
const lockedBuildRequirement = resolveBuildRequirement(deps);
if (!lockedBuildRequirement.shouldBuild) {
const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps);
+5 -4
View File
@@ -275,16 +275,17 @@ export async function forceFreePortAndWait(
let killed: PortProcess[] = [];
let useFuserFallback = false;
if (!(await isPortBusy(port))) {
return { killed, waitedMs: 0, escalatedToSigkill: false };
}
try {
killed = forceFreePort(port);
} catch (err) {
if (!isRecoverableLsofError(err)) {
throw err;
}
// Keep --force usable on minimal systems when the bind probe can confirm
// the port is free; otherwise use fuser to cover listeners lsof cannot inspect.
if (!(await isPortBusy(port))) {
return { killed, waitedMs: 0, escalatedToSigkill: false };
}
useFuserFallback = true;
killed = killPortWithFuser(port, "SIGTERM");
}
+46 -2
View File
@@ -86,8 +86,13 @@ describe("gateway --force helpers", () => {
expect(listPortListeners(18789)).toStrictEqual([]);
});
it("skips lsof when the port is already bindable", async () => {
it("returns without cleanup when lsof and the bind probe find no listener", async () => {
probePortUsageMock.mockResolvedValue("free");
(execFileSync as unknown as Mock).mockImplementation(() => {
const err = new Error("no matches") as NodeJS.ErrnoException & { status?: number };
err.status = 1;
throw err;
});
const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 });
@@ -96,7 +101,46 @@ describe("gateway --force helpers", () => {
waitedMs: 0,
escalatedToSigkill: false,
});
expect(execFileSync).not.toHaveBeenCalled();
expect(execFileSync).toHaveBeenCalledOnce();
expect(probePortUsageMock).toHaveBeenCalledWith(18789);
});
it("kills an interface-specific listener even when the bind probe would report free", async () => {
probePortUsageMock.mockResolvedValue("free");
(execFileSync as unknown as Mock)
.mockReturnValueOnce(["p42", "cnode", ""].join("\n"))
.mockReturnValue("");
const killMock = vi.fn();
process.kill = killMock;
const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 });
expect(killMock).toHaveBeenCalledWith(42, "SIGTERM");
expect(result).toEqual({
killed: [{ pid: 42, command: "node" }],
waitedMs: 0,
escalatedToSigkill: false,
});
expect(probePortUsageMock).not.toHaveBeenCalled();
});
it("returns without fuser when lsof is unavailable and the bind probe reports free", async () => {
probePortUsageMock.mockResolvedValue("free");
(execFileSync as unknown as Mock).mockImplementation(() => {
const err = new Error("not found") as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
});
const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 });
expect(result).toEqual({
killed: [],
waitedMs: 0,
escalatedToSigkill: false,
});
expect(execFileSync).toHaveBeenCalledOnce();
expect(probePortUsageMock).toHaveBeenCalledWith(18789);
});
it("fails closed when lsof has a malformed PID and fuser cannot identify one", async () => {
+2 -2
View File
@@ -70,7 +70,7 @@ export async function promptGatewayConfig(
{
value: "tailnet",
label: "Tailnet (Tailscale IP)",
hint: "Bind to your Tailscale IP only (100.x.x.x)",
hint: "Bind to your Tailscale IP plus local loopback",
},
{
value: "auto",
@@ -85,7 +85,7 @@ export async function promptGatewayConfig(
{
value: "custom",
label: "Custom IP",
hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable",
hint: "Specific IPv4s also bind 127.0.0.1",
},
],
}),
+175 -19
View File
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
resolveGatewayPort: vi.fn(),
resolveControlUiLinks: vi.fn(),
copyToClipboard: vi.fn(),
openUrl: vi.fn(),
inspectPortUsage: vi.fn(),
ensureGatewayReadyForOperation: vi.fn(),
}));
@@ -19,7 +21,7 @@ vi.mock("../config/config.js", () => ({
vi.mock("./onboard-helpers.js", () => ({
resolveControlUiLinks: mocks.resolveControlUiLinks,
detectBrowserOpenSupport: vi.fn(),
openUrl: vi.fn(),
openUrl: mocks.openUrl,
formatControlUiSshHint: vi.fn(() => "ssh hint"),
}));
@@ -27,6 +29,10 @@ vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: mocks.copyToClipboard,
}));
vi.mock("../infra/ports-inspect.js", () => ({
inspectPortUsage: mocks.inspectPortUsage,
}));
vi.mock("./gateway-readiness.js", () => ({
ensureGatewayReadyForOperation: mocks.ensureGatewayReadyForOperation,
}));
@@ -37,28 +43,37 @@ const runtime = {
exit: vi.fn(),
};
function mockSnapshot(params?: {
type SnapshotParams = {
token?: string;
bind?: GatewayBindMode;
customBindHost?: string;
}) {
tlsEnabled?: boolean;
};
function createSnapshot(params?: SnapshotParams) {
const token = params?.token ?? "abc123";
mocks.readConfigFileSnapshot.mockResolvedValue({
const gateway = {
auth: { token },
bind: params?.bind,
customBindHost: params?.customBindHost,
...(params?.tlsEnabled === undefined ? {} : { tls: { enabled: params.tlsEnabled } }),
};
return {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {
gateway: {
auth: { token },
bind: params?.bind,
customBindHost: params?.customBindHost,
},
gateway,
},
issues: [],
legacyIssues: [],
});
};
}
function mockSnapshot(params?: SnapshotParams) {
mocks.readConfigFileSnapshot.mockResolvedValue(createSnapshot(params));
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.resolveControlUiLinks.mockReturnValue({
httpUrl: "http://127.0.0.1:18789/",
@@ -67,12 +82,42 @@ function mockSnapshot(params?: {
mocks.copyToClipboard.mockResolvedValue(true);
}
function mockSpecificDashboardLinks(params: {
bind: "custom" | "tailnet";
host: string;
tlsEnabled?: boolean;
}) {
const scheme = params.tlsEnabled ? { http: "https", ws: "wss" } : { http: "http", ws: "ws" };
mocks.resolveControlUiLinks.mockImplementation(({ bind }: { bind: GatewayBindMode }) =>
bind === params.bind
? {
httpUrl: `${scheme.http}://${params.host}:18789/`,
wsUrl: `${scheme.ws}://${params.host}:18789`,
}
: { httpUrl: "http://127.0.0.1:18789/", wsUrl: "ws://127.0.0.1:18789" },
);
}
function mockAliasOwnership(host: string, loopbackPid = 4242) {
mocks.inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [
{ pid: 4242, commandLine: "openclaw-gateway", address: `${host}:18789` },
{ pid: loopbackPid, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
],
hints: [],
});
}
describe("dashboardCommand bind selection", () => {
beforeEach(() => {
mocks.readConfigFileSnapshot.mockClear();
mocks.resolveGatewayPort.mockClear();
mocks.resolveControlUiLinks.mockClear();
mocks.copyToClipboard.mockClear();
mocks.openUrl.mockClear();
mocks.inspectPortUsage.mockReset();
mocks.ensureGatewayReadyForOperation.mockReset();
mocks.ensureGatewayReadyForOperation.mockResolvedValue({
ready: true,
@@ -101,31 +146,142 @@ describe("dashboardCommand bind selection", () => {
});
});
it("preserves custom bind mode", async () => {
mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" });
it("maps a TLS-enabled wildcard custom bind to loopback", async () => {
mockSnapshot({ bind: "custom", customBindHost: "0.0.0.0", tlsEnabled: true });
await dashboardCommand(runtime, { noOpen: true });
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
port: 18789,
bind: "custom",
customBindHost: "10.0.0.5",
bind: "loopback",
customBindHost: "0.0.0.0",
basePath: undefined,
tlsEnabled: false,
tlsEnabled: true,
});
});
it("preserves tailnet bind mode", async () => {
mockSnapshot({ bind: "tailnet" });
it.each([
{ bind: "custom" as const, host: "10.0.0.5", customBindHost: "10.0.0.5" },
{ bind: "tailnet" as const, host: "100.64.0.1", customBindHost: undefined },
])("maps plain-http $bind bind to a verified loopback URL", async (params) => {
mockSnapshot({ bind: params.bind, customBindHost: params.customBindHost });
mockSpecificDashboardLinks(params);
mockAliasOwnership(params.host);
await dashboardCommand(runtime, { noOpen: true });
expect(mocks.ensureGatewayReadyForOperation).toHaveBeenCalledWith(
expect.objectContaining({
probeUrl: `ws://${params.host}:18789`,
readyWhenReachable: true,
}),
);
expect(mocks.ensureGatewayReadyForOperation.mock.invocationCallOrder[0]).toBeLessThan(
mocks.inspectPortUsage.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
port: 18789,
bind: "tailnet",
customBindHost: undefined,
bind: "loopback",
customBindHost: params.customBindHost,
basePath: undefined,
tlsEnabled: false,
});
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
});
it("refuses an authenticated loopback URL owned by a different process", async () => {
mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" });
mockSpecificDashboardLinks({ bind: "custom", host: "10.0.0.5" });
mockAliasOwnership("10.0.0.5", 4343);
await dashboardCommand(runtime, { noOpen: true });
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("refusing to copy or open an authenticated URL"),
);
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:"));
expect(mocks.openUrl).not.toHaveBeenCalled();
});
it("refuses URL delivery when loopback ownership inspection fails", async () => {
mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" });
mockSpecificDashboardLinks({ bind: "custom", host: "10.0.0.5" });
mocks.inspectPortUsage.mockRejectedValue(new Error("inspection unavailable"));
await dashboardCommand(runtime);
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(mocks.openUrl).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:"));
});
it("re-probes a changed endpoint after recovery before URL delivery", async () => {
mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" });
mocks.readConfigFileSnapshot
.mockResolvedValueOnce(createSnapshot({ bind: "custom", customBindHost: "10.0.0.5" }))
.mockResolvedValueOnce(createSnapshot({ bind: "custom", customBindHost: "10.0.0.6" }));
mocks.resolveControlUiLinks.mockImplementation(
({ bind, customBindHost }: { bind: GatewayBindMode; customBindHost?: string }) =>
bind === "custom"
? {
httpUrl: `http://${customBindHost}:18789/`,
wsUrl: `ws://${customBindHost}:18789`,
}
: { httpUrl: "http://127.0.0.1:18789/", wsUrl: "ws://127.0.0.1:18789" },
);
mocks.ensureGatewayReadyForOperation
.mockResolvedValueOnce({ ready: true, status: {}, recovered: true })
.mockResolvedValueOnce({
ready: false,
status: {},
reason: "Gateway probe failed",
recoverable: false,
});
await dashboardCommand(runtime, { noOpen: true, yes: true });
expect(mocks.ensureGatewayReadyForOperation).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ probeUrl: "ws://10.0.0.5:18789" }),
);
expect(mocks.ensureGatewayReadyForOperation).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
probeUrl: "ws://10.0.0.6:18789",
readyWhenReachable: true,
allowInstall: false,
interactive: false,
}),
);
expect(mocks.inspectPortUsage).not.toHaveBeenCalled();
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:"));
});
it.each([
{ bind: "custom" as const, host: "10.0.0.5", customBindHost: "10.0.0.5" },
{ bind: "tailnet" as const, host: "100.64.0.1", customBindHost: undefined },
])("preserves a specific $bind bind when TLS provides a secure context", async (params) => {
mockSnapshot({
bind: params.bind,
customBindHost: params.customBindHost,
tlsEnabled: true,
});
mockSpecificDashboardLinks({ ...params, tlsEnabled: true });
await dashboardCommand(runtime, { noOpen: true });
expect(mocks.ensureGatewayReadyForOperation).toHaveBeenCalledWith(
expect.objectContaining({ probeUrl: `wss://${params.host}:18789` }),
);
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
port: 18789,
bind: params.bind,
customBindHost: params.customBindHost,
basePath: undefined,
tlsEnabled: true,
});
expect(mocks.inspectPortUsage).not.toHaveBeenCalled();
});
});
+95 -9
View File
@@ -2,6 +2,8 @@
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
import { copyToClipboard } from "../infra/clipboard.js";
import { isSameProcessSpecificIpv4WithLoopbackListeners } from "../infra/ports-format.js";
import { inspectPortUsage } from "../infra/ports-inspect.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { ensureGatewayReadyForOperation } from "./gateway-readiness.js";
@@ -31,15 +33,45 @@ async function resolveDashboardTarget() {
});
const token = resolvedToken.token ?? "";
// LAN URLs fail secure-context checks in browsers.
// Coerce only lan->loopback and preserve other bind modes.
const links = resolveControlUiLinks({
const tlsEnabled = cfg.gateway?.tls?.enabled === true;
// A wildcard LAN address is not a browser destination, while plain HTTP on a
// specific interface fails secure-context checks. Same-host launches use loopback;
// TLS keeps specific hosts so certificate names continue to match.
const customBindIsWildcard = bind === "custom" && customBindHost?.trim() === "0.0.0.0";
const dashboardBind =
bind === "lan" ||
customBindIsWildcard ||
(!tlsEnabled && (bind === "tailnet" || bind === "custom"))
? "loopback"
: bind;
const configuredLinks = resolveControlUiLinks({
port,
bind: bind === "lan" ? "loopback" : bind,
bind,
customBindHost,
basePath,
tlsEnabled: cfg.gateway?.tls?.enabled === true,
tlsEnabled,
});
const links =
dashboardBind === bind
? configuredLinks
: resolveControlUiLinks({
port,
bind: dashboardBind,
customBindHost,
basePath,
tlsEnabled,
});
const loopbackAliasHost = (() => {
if (dashboardBind !== "loopback" || (bind !== "tailnet" && bind !== "custom")) {
return undefined;
}
try {
const host = new URL(configuredLinks.wsUrl).hostname;
return host === "127.0.0.1" || host === "0.0.0.0" ? undefined : host;
} catch {
return undefined;
}
})();
// Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args.
const includeTokenInUrl = token.length > 0 && !resolvedToken.secretRefConfigured;
// Prefer URL fragment to avoid leaking auth tokens via query params.
@@ -55,27 +87,81 @@ async function resolveDashboardTarget() {
token,
includeTokenInUrl,
dashboardUrl,
probeUrl: loopbackAliasHost ? configuredLinks.wsUrl : links.wsUrl,
loopbackAliasHost,
};
}
async function hasVerifiedLoopbackAlias(
target: Awaited<ReturnType<typeof resolveDashboardTarget>>,
): Promise<boolean> {
const expectedHost = target.loopbackAliasHost;
if (!expectedHost) {
return true;
}
const portUsage = await inspectPortUsage(target.port).catch(() => undefined);
// The configured-address probe establishes Gateway identity. This local PID check only proves
// that the process also owns the loopback endpoint before credentials are delivered there.
return Boolean(
portUsage &&
isSameProcessSpecificIpv4WithLoopbackListeners(portUsage.listeners, target.port, expectedHost),
);
}
async function ensureDashboardTargetReady(params: {
target: Awaited<ReturnType<typeof resolveDashboardTarget>>;
runtime: RuntimeEnv;
yes?: boolean;
allowRecovery?: boolean;
}) {
return ensureGatewayReadyForOperation({
runtime: params.runtime,
operation: "open the dashboard",
yes: params.yes,
probeUrl: params.target.probeUrl,
// First-time CLI probes intentionally lack paired operator scope. Gateway
// handshake evidence plus the same-PID alias check below proves the target.
readyWhenReachable: true,
...(params.allowRecovery === false ? { allowInstall: false, interactive: false } : {}),
});
}
/** Open or print the Control UI dashboard URL after ensuring the Gateway is reachable. */
export async function dashboardCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DashboardOptions = {},
) {
const initialTarget = await resolveDashboardTarget();
const readiness = await ensureGatewayReadyForOperation({
const readiness = await ensureDashboardTargetReady({
target: initialTarget,
runtime,
operation: "open the dashboard",
yes: options.yes,
probeUrl: initialTarget.links.wsUrl,
readyWhenReachable: true,
});
if (!readiness.ready) {
return;
}
const target = readiness.recovered ? await resolveDashboardTarget() : initialTarget;
const recoveryChangedProbe = target.probeUrl !== initialTarget.probeUrl;
if (readiness.recovered && recoveryChangedProbe) {
// Recovery may install or start against a changed config. Prove the final
// endpoint without triggering a second lifecycle action before URL delivery.
const finalReadiness = await ensureDashboardTargetReady({
target,
runtime,
allowRecovery: false,
});
if (!finalReadiness.ready) {
return;
}
}
if (!(await hasVerifiedLoopbackAlias(target))) {
runtime.error(
"Dashboard loopback listener could not be verified as the configured Gateway; refusing to copy or open an authenticated URL.",
);
runtime.log("Restart the Gateway, then run `openclaw gateway status --deep` for details.");
return;
}
const { port, basePath, links, resolvedToken, token, includeTokenInUrl, dashboardUrl } = target;
runtime.log(`Dashboard URL: ${links.httpUrl}`);
+2 -2
View File
@@ -98,9 +98,9 @@ export const FIELD_HELP: Record<string, string> = {
"gateway.mode":
'Gateway operation mode: "local" runs channels and agent runtime on this host, while "remote" connects through remote transport. Keep "local" unless you intentionally run a split remote gateway topology.',
"gateway.bind":
'Network bind profile: "auto", "lan", "loopback", "custom", or "tailnet" to control interface exposure. Keep "loopback" or "auto" for safest local operation unless external clients must connect.',
'Network bind profile: "auto", "lan", "loopback", "custom", or "tailnet" to control interface exposure. Keep "loopback" for local-only operation; "auto" can expose all interfaces.',
"gateway.customBindHost":
"Explicit bind host/IP used when gateway.bind is set to custom for manual interface targeting. Use a precise address and avoid wildcard binds unless external exposure is required.",
"IPv4 address used for a custom bind. Specific IPv4s also require the same Gateway port on 127.0.0.1; avoid 0.0.0.0 unless all-interface exposure is required.",
"gateway.controlUi":
"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.",
"gateway.controlUi.enabled":
+2 -2
View File
@@ -517,8 +517,8 @@ export type GatewayConfig = {
* - auto: Loopback (127.0.0.1) if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (all interfaces, no fallback, current BYOH path is IPv4-only)
* - loopback: 127.0.0.1 (local-only)
* - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback
* - custom: User-specified IPv4 address, fallback to 0.0.0.0 if unavailable (requires customBindHost)
* - tailnet: Tailnet IPv4 plus 127.0.0.1 if available, else loopback only
* - custom: User-specified IPv4 address (requires customBindHost); specific IPv4s also bind 127.0.0.1
* IPv6-only BYOH is not natively supported on this path today. Use an IPv4 sidecar or proxy.
* Default: loopback (127.0.0.1).
*/
+24
View File
@@ -357,6 +357,22 @@ describe("resolveGatewayListenHosts", () => {
},
expected: ["0.0.0.0"],
},
{
name: "IPv6 host passthrough",
host: "::1",
canBindToHost: async () => {
throw new Error("should not be called");
},
expected: ["::1"],
},
{
name: "specific non-loopback host with loopback alias available",
host: "100.64.0.1",
canBindToHost: async () => {
throw new Error("should not be called");
},
expected: ["100.64.0.1", "127.0.0.1"],
},
{
name: "loopback with IPv6 available",
host: "127.0.0.1",
@@ -385,6 +401,14 @@ describe("resolveGatewayListenHosts", () => {
expect(canBindToHost).not.toHaveBeenCalled();
});
it("still adds the IPv4 loopback alias for a specific host on Windows", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const canBindToHost = vi.fn().mockResolvedValue(true);
const hosts = await resolveGatewayListenHosts("100.64.0.1", { canBindToHost });
expect(hosts).toEqual(["100.64.0.1", "127.0.0.1"]);
expect(canBindToHost).not.toHaveBeenCalled();
});
it("still includes ::1 on non-Windows when IPv6 is bindable", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const canBindToHost = vi.fn().mockResolvedValue(true);
+9 -3
View File
@@ -245,7 +245,7 @@ export {
* - lan: always 0.0.0.0 (no fallback)
* - tailnet: Tailnet IPv4 if available, else loopback
* - auto: 0.0.0.0 inside containers (Docker/Podman/K8s); loopback otherwise
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable
* - custom: User-specified IPv4; unavailable values resolve to 0.0.0.0 for caller validation
*
* @returns The bind address to use (never null)
*/
@@ -287,7 +287,7 @@ export async function resolveGatewayBindHost(
if (isValidIPv4(host) && (await canBindToHost(host))) {
return host;
}
// Custom IP failed → fall back to LAN
// Runtime startup rejects this fallback; status/display callers remain best-effort.
return "0.0.0.0";
}
@@ -354,7 +354,13 @@ export async function resolveGatewayListenHosts(
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
): Promise<string[]> {
if (bindHost !== "127.0.0.1") {
return [bindHost];
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"];
}
// 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
@@ -174,6 +174,12 @@ describe("resolveGatewayRuntimeConfig", () => {
host: "0.0.0.0",
expectedMessage: "gateway bind=loopback resolved to non-loopback host",
},
{
name: "tailnet binding that falls through to wildcard",
cfg: { gateway: { bind: "tailnet" as const, auth: TOKEN_AUTH } },
host: "0.0.0.0",
expectedMessage: "gateway bind=tailnet could not resolve a Tailscale or loopback address",
},
{
name: "custom bind without customBindHost",
cfg: { gateway: { bind: "custom" as const, auth: TOKEN_AUTH } },
+5
View File
@@ -73,6 +73,11 @@ export async function resolveGatewayRuntimeConfig(params: {
`gateway bind=loopback resolved to non-loopback host ${bindHost}; refusing fallback to a network bind`,
);
}
if (bindMode === "tailnet" && bindHost === "0.0.0.0") {
throw new Error(
"gateway bind=tailnet could not resolve a Tailscale or loopback address; refusing wildcard fallback",
);
}
if (bindMode === "custom") {
const configuredCustomBindHost = customBindHost?.trim();
if (!configuredCustomBindHost) {
+65 -1
View File
@@ -1,7 +1,7 @@
/**
* Gateway runtime state construction tests.
*/
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import {
getActivePluginChannelRegistry,
@@ -18,6 +18,22 @@ import {
} from "../plugins/runtime.js";
import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-state.js";
const mocks = vi.hoisted(() => ({
listenGatewayHttpServer: vi.fn(
async (_params: { bindHost: string; retryEaddrinuse?: boolean }) => {},
),
resolveGatewayListenHosts: vi.fn(async (_bindHost: string) => ["127.0.0.1"]),
}));
vi.mock("./server/http-listen.js", () => ({
listenGatewayHttpServer: mocks.listenGatewayHttpServer,
}));
vi.mock("./net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./net.js")>();
return { ...actual, resolveGatewayListenHosts: mocks.resolveGatewayListenHosts };
});
function createRegistryWithRoute(path: string) {
const registry = createEmptyPluginRegistry();
registry.httpRoutes.push({
@@ -32,6 +48,13 @@ function createRegistryWithRoute(path: string) {
}
describe("createGatewayRuntimeState", () => {
beforeEach(() => {
mocks.listenGatewayHttpServer.mockReset();
mocks.listenGatewayHttpServer.mockResolvedValue(undefined);
mocks.resolveGatewayListenHosts.mockReset();
mocks.resolveGatewayListenHosts.mockResolvedValue(["127.0.0.1"]);
});
afterEach(() => {
releasePinnedPluginHttpRouteRegistry();
releasePinnedPluginChannelRegistry();
@@ -60,4 +83,45 @@ describe("createGatewayRuntimeState", () => {
expect(getActivePluginSessionExtensionRegistry()).toBe(startupRegistry);
expect(getActivePluginChannelRegistry()).toBe(startupRegistry);
});
it("fails startup when the required IPv4 loopback alias cannot bind", async () => {
const warn = vi.fn();
mocks.resolveGatewayListenHosts.mockResolvedValue(["100.64.0.1", "127.0.0.1"]);
mocks.listenGatewayHttpServer.mockImplementation(async ({ bindHost }) => {
if (bindHost === "127.0.0.1") {
throw new Error("loopback occupied");
}
});
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
bindHost: "100.64.0.1",
log: { info: () => {}, warn },
});
await expect(runtimeState.startListening()).rejects.toThrow("loopback occupied");
await expect(runtimeState.startListening()).rejects.toThrow("loopback occupied");
expect(mocks.listenGatewayHttpServer).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ bindHost: "127.0.0.1", retryEaddrinuse: false }),
);
expect(mocks.listenGatewayHttpServer).toHaveBeenCalledTimes(1);
expect(runtimeState.httpBindHosts).toEqual([]);
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("failed to bind loopback alias"));
});
it("keeps the optional IPv6 loopback alias non-fatal", async () => {
const warn = vi.fn();
mocks.resolveGatewayListenHosts.mockResolvedValue(["127.0.0.1", "::1"]);
mocks.listenGatewayHttpServer.mockImplementation(async ({ bindHost }) => {
if (bindHost === "::1") {
throw new Error("IPv6 unavailable");
}
});
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
log: { info: () => {}, warn },
});
await expect(runtimeState.startListening()).resolves.toBeUndefined();
expect(runtimeState.httpBindHosts).toEqual(["127.0.0.1"]);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed to bind loopback alias ::1"));
});
});
+22 -11
View File
@@ -306,23 +306,38 @@ export async function createGatewayRuntimeState(params: {
await startListeningPromise;
return;
}
// Listening is idempotent for callers racing startup; reset the promise only on failure so
// a transient bind error can be retried after the caller handles it.
// Listening is idempotent for callers racing startup. A failure is terminal for this runtime
// state; the startup owner tears down every partially bound HTTP/WS server before retrying.
startListeningPromise = (async () => {
for (const [index, host] of bindHosts.entries()) {
const requiredAlias =
params.bindHost !== "127.0.0.1" && bindHosts.includes("127.0.0.1")
? "127.0.0.1"
: undefined;
// Claim the trusted local endpoint before exposing the selected interface. This prevents
// another loopback listener from receiving credentials while startup is still resolving.
const listenOrder = requiredAlias
? [requiredAlias, ...bindHosts.filter((host) => host !== requiredAlias)]
: bindHosts;
const boundHosts = new Set<string>();
for (const host of listenOrder) {
const index = bindHosts.indexOf(host);
const server = httpServers[index];
if (!server) {
throw new Error(`Missing gateway HTTP server for bind host ${host}`);
}
// Specific IPv4 modes rely on this canonical local endpoint for authenticated
// helpers. A collision must fail startup instead of sending credentials to it.
const requiredLoopbackAlias = host === requiredAlias;
try {
await listenGatewayHttpServer({
httpServer: server,
bindHost: host,
port: params.port,
retryEaddrinuse: !requiredLoopbackAlias,
});
httpBindHosts.push(host);
boundHosts.add(host);
} catch (err) {
if (host === bindHosts[0]) {
if (host === bindHosts[0] || requiredLoopbackAlias) {
throw err;
}
params.log.warn(
@@ -330,16 +345,12 @@ export async function createGatewayRuntimeState(params: {
);
}
}
httpBindHosts.push(...bindHosts.filter((host) => boundHosts.has(host)));
if (httpBindHosts.length === 0) {
throw new Error("Gateway HTTP server failed to start");
}
})();
try {
await startListeningPromise;
} catch (err) {
startListeningPromise = null;
throw err;
}
await startListeningPromise;
};
const agentRunSeq = new Map<string, number>();
const dedupe = new Map<string, DedupeEntry>();
+1 -1
View File
@@ -466,7 +466,7 @@ export type GatewayServerOptions = {
* Bind address policy for the Gateway WebSocket/HTTP server.
* - loopback: 127.0.0.1
* - lan: 0.0.0.0
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
* - tailnet: bind to the Tailscale IPv4 address (100.64.0.0/10) and local 127.0.0.1
* - auto: prefer loopback, else LAN
*/
bind?: import("../config/config.js").GatewayBindMode;
@@ -2,6 +2,7 @@
// gateway reports its listen step as ready.
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import { tryListenOnPort } from "../infra/ports-probe.js";
import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js";
import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-state.js";
@@ -89,4 +90,49 @@ describe("gateway startup websocket readiness", () => {
}
}
});
it("serves a specific IPv4 bind and its required loopback alias", async () => {
const previousMinimal = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "0";
let server: GatewayServerForTest | undefined;
const clients: WebSocket[] = [];
try {
const port = await getFreePort();
server = await startGatewayServer(port, {
host: "127.0.0.2",
auth: { mode: "none" },
});
clients.push(
await connectWebSocket(`ws://127.0.0.1:${port}`),
await connectWebSocket(`ws://127.0.0.2:${port}`),
);
} finally {
await Promise.all(clients.map(async (client) => await disconnectWebSocket(client)));
if (server) {
await server.close();
}
if (previousMinimal === undefined) {
delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
} else {
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimal;
}
}
});
it("releases the loopback alias when the selected bind fails", async () => {
const port = await getFreePort();
await expect(
startGatewayServer(port, {
bind: "lan",
host: "192.0.2.1",
auth: { mode: "token", token: "test-token" },
}),
).rejects.toThrow("failed to bind gateway socket");
await expect(
tryListenOnPort({ host: "127.0.0.1", port, exclusive: true }),
).resolves.toBeUndefined();
});
});
+20
View File
@@ -82,6 +82,26 @@ describe("listenGatewayHttpServer", () => {
expect(fake.closeCalls).toBe(20);
});
it("fails immediately when EADDRINUSE retries are disabled", async () => {
sleepMock.mockClear();
const fake = createFakeHttpServer([
{ kind: "error", code: "EADDRINUSE" },
{ kind: "listening" },
]);
await expect(
listenGatewayHttpServer({
httpServer: fake as unknown as HttpServer,
bindHost: "127.0.0.1",
port: 18789,
retryEaddrinuse: false,
}),
).rejects.toBeInstanceOf(GatewayLockError);
expect(fake.closeCalls).toBe(0);
expect(sleepMock).not.toHaveBeenCalled();
});
it("wraps non-EADDRINUSE errors as GatewayLockError", async () => {
sleepMock.mockClear();
const fake = createFakeHttpServer([{ kind: "error", code: "EACCES" }]);
+5 -3
View File
@@ -21,10 +21,12 @@ export async function listenGatewayHttpServer(params: {
httpServer: HttpServer;
bindHost: string;
port: number;
retryEaddrinuse?: boolean;
}) {
const { httpServer, bindHost, port } = params;
const { httpServer, bindHost, port, retryEaddrinuse = true } = params;
const maxRetries = retryEaddrinuse ? EADDRINUSE_MAX_RETRIES : 0;
for (const attempt of Array.from({ length: EADDRINUSE_MAX_RETRIES + 1 }, (_, index) => index)) {
for (const attempt of Array.from({ length: maxRetries + 1 }, (_, index) => index)) {
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
@@ -42,7 +44,7 @@ export async function listenGatewayHttpServer(params: {
return; // bound successfully
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EADDRINUSE" && attempt < EADDRINUSE_MAX_RETRIES) {
if (code === "EADDRINUSE" && attempt < maxRetries) {
// Port may still be in TIME_WAIT after a recent process exit; retry.
await closeServerQuietly(httpServer);
await sleep(EADDRINUSE_RETRY_INTERVAL_MS);
@@ -11,6 +11,7 @@ type GatewayRuntimeStateParams = Parameters<typeof createGatewayRuntimeState>[0]
/** Creates a minimal gateway runtime state with optional plugin registry fixture. */
export async function createGatewayRuntimeStateForTest(
pluginRegistry: GatewayRuntimeStateParams["pluginRegistry"] = createEmptyPluginRegistry(),
overrides: Partial<GatewayRuntimeStateParams> = {},
) {
return await createGatewayRuntimeState({
cfg: {},
@@ -30,5 +31,6 @@ export async function createGatewayRuntimeStateForTest(
log: { info: () => {}, warn: () => {} },
logHooks: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never,
logPlugins: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never,
...overrides,
});
}
+71
View File
@@ -8,6 +8,7 @@ import {
formatPortListener,
isDualStackLoopbackGatewayListeners,
isExpectedGatewayListeners,
isSameProcessSpecificIpv4WithLoopbackListeners,
isSingleExpectedGatewayListener,
} from "./ports-format.js";
@@ -86,6 +87,76 @@ describe("ports-format", () => {
expect(buildPortHints(listeners, 18789)).toEqual([]);
});
it("treats a single-process specific IPv4 plus loopback alias as benign", () => {
const listeners = [
{ pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" },
{ pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
];
expect(isExpectedGatewayListeners(listeners, 18789)).toBe(true);
expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "100.64.0.1")).toBe(
true,
);
expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "10.0.0.5")).toBe(
false,
);
expect(buildPortHints(listeners, 18789)).toEqual([]);
});
it("checks exact alias ownership without relying on process display metadata", () => {
const listeners = [
{ pid: 4242, commandLine: "opaque-wrapper", address: "100.64.0.1:18789" },
{ pid: 4242, commandLine: "opaque-wrapper", address: "127.0.0.1:18789" },
];
expect(isExpectedGatewayListeners(listeners, 18789)).toBe(false);
expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "100.64.0.1")).toBe(
true,
);
});
it.each([
[
"mixed process ids",
[
{ pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" },
{ pid: 4243, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
],
],
[
"an IPv6 selected address",
[
{ pid: 4242, commandLine: "openclaw-gateway", address: "[fd7a:115c:a1e0::1]:18789" },
{ pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
],
],
[
"a missing loopback alias",
[{ pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" }],
],
[
"missing process metadata",
[
{ commandLine: "openclaw-gateway", address: "100.64.0.1:18789" },
{ commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
],
],
[
"an extra listener",
[
{ pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" },
{ pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
{ pid: 4242, commandLine: "openclaw-gateway", address: "[::1]:18789" },
],
],
])("rejects specific-address ownership with %s", (_label, listeners) => {
expect(isExpectedGatewayListeners(listeners, 18789)).toBe(false);
expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "100.64.0.1")).toBe(
false,
);
expect(buildPortHints(listeners, 18789)).toContain(gatewayAlreadyRunningHint);
});
it.each([
"127.0.0.1:18789",
"[::1]:18789",
+82 -42
View File
@@ -1,4 +1,5 @@
// Formats port probe results for diagnostics and CLI output.
import net from "node:net";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { formatCliCommand } from "../cli/command-format.js";
import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js";
@@ -90,28 +91,44 @@ function isExpectedGatewayBindAddress(host: string): boolean {
return classifyLoopbackAddressFamily(host) !== null || isWildcardAddress(host);
}
type ParsedGatewayListener = { pid: number; host: string };
function parsePortListeners(
listeners: PortListener[],
port: number,
): ParsedGatewayListener[] | null {
const parsedListeners: ParsedGatewayListener[] = [];
for (const listener of listeners) {
const pid = listener.pid;
if (typeof pid !== "number" || !Number.isFinite(pid) || typeof listener.address !== "string") {
return null;
}
const address = parseListenerAddress(listener.address);
if (!address || address.port !== port) {
return null;
}
parsedListeners.push({ pid, host: address.host });
}
return parsedListeners;
}
function parseGatewayListeners(
listeners: PortListener[],
port: number,
): ParsedGatewayListener[] | null {
if (listeners.some((listener) => classifyPortListener(listener, port) !== "gateway")) {
return null;
}
return parsePortListeners(listeners, port);
}
/** Returns true for one Gateway listener bound to an expected loopback or wildcard address. */
export function isSingleExpectedGatewayListener(listeners: PortListener[], port: number): boolean {
if (listeners.length !== 1) {
return false;
}
const [listener] = listeners;
if (!listener || classifyPortListener(listener, port) !== "gateway") {
return false;
}
const pid = listener.pid;
if (typeof pid !== "number" || !Number.isFinite(pid)) {
return false;
}
if (typeof listener.address !== "string") {
return false;
}
const parsedAddress = parseListenerAddress(listener.address);
return Boolean(
parsedAddress &&
parsedAddress.port === port &&
isExpectedGatewayBindAddress(parsedAddress.host),
);
const parsed = parseGatewayListeners(listeners, port);
return Boolean(parsed?.[0] && isExpectedGatewayBindAddress(parsed[0].host));
}
/** Returns true for one Gateway process represented by separate IPv4 and IPv6 loopback rows. */
@@ -122,38 +139,61 @@ export function isDualStackLoopbackGatewayListeners(
if (listeners.length < 2) {
return false;
}
const pids = new Set<number>();
const families = new Set<"ipv4" | "ipv6">();
for (const listener of listeners) {
if (classifyPortListener(listener, port) !== "gateway") {
return false;
}
const pid = listener.pid;
if (typeof pid !== "number" || !Number.isFinite(pid)) {
return false;
}
pids.add(pid);
if (typeof listener.address !== "string") {
return false;
}
const parsedAddress = parseListenerAddress(listener.address);
if (!parsedAddress || parsedAddress.port !== port) {
return false;
}
const family = classifyLoopbackAddressFamily(parsedAddress.host);
if (!family) {
return false;
}
families.add(family);
const parsed = parseGatewayListeners(listeners, port);
if (!parsed) {
return false;
}
return pids.size === 1 && families.has("ipv4") && families.has("ipv6");
const pids = new Set(parsed.map(({ pid }) => pid));
const families = new Set(parsed.map(({ host }) => classifyLoopbackAddressFamily(host)));
return pids.size === 1 && !families.has(null) && families.has("ipv4") && families.has("ipv6");
}
function parsedListenersOwnSpecificIpv4WithLoopback(parsed: ParsedGatewayListener[]): boolean {
if (new Set(parsed.map(({ pid }) => pid)).size !== 1) {
return false;
}
const hosts = new Set(parsed.map(({ host }) => host));
const specificHosts = [...hosts].filter(
(host) => host !== "127.0.0.1" && net.isIP(host) === 4 && !isWildcardAddress(host),
);
return hosts.has("127.0.0.1") && specificHosts.length > 0;
}
/** Checks one Gateway PID owns both an exact IPv4 interface and canonical loopback. */
function isSpecificIpv4WithLoopbackGatewayListeners(
listeners: PortListener[],
port: number,
): boolean {
if (listeners.length !== 2) {
return false;
}
const parsed = parseGatewayListeners(listeners, port);
return Boolean(parsed && parsedListenersOwnSpecificIpv4WithLoopback(parsed));
}
/** Checks one PID owns an expected IPv4 interface and canonical loopback. */
export function isSameProcessSpecificIpv4WithLoopbackListeners(
listeners: PortListener[],
port: number,
expectedSpecificHost: string,
): boolean {
if (listeners.length !== 2) {
return false;
}
const parsed = parsePortListeners(listeners, port);
return Boolean(
parsed &&
parsedListenersOwnSpecificIpv4WithLoopback(parsed) &&
parsed.some(({ host }) => host === expectedSpecificHost),
);
}
/** Returns true when listener rows describe a benign Gateway bind pattern. */
export function isExpectedGatewayListeners(listeners: PortListener[], port: number): boolean {
return (
isSingleExpectedGatewayListener(listeners, port) ||
isDualStackLoopbackGatewayListeners(listeners, port)
isDualStackLoopbackGatewayListeners(listeners, port) ||
isSpecificIpv4WithLoopbackGatewayListeners(listeners, port)
);
}
+89 -9
View File
@@ -180,10 +180,6 @@ function statusCommandSpawn() {
return [process.execPath, "openclaw.mjs", "status"];
}
function gatewayCallStatusCommandSpawn() {
return [process.execPath, "openclaw.mjs", "gateway", "call", "status", "--json"];
}
function resolvePath(tmp: string, relativePath: string) {
return path.join(tmp, relativePath);
}
@@ -321,8 +317,9 @@ async function runStatusCommand(params: {
});
}
async function runGatewayCallStatusCommand(params: {
async function runGatewayClientCommand(params: {
tmp: string;
args: string[];
spawn: (cmd: string, args: string[]) => ReturnType<typeof createExitedProcess>;
spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string };
env?: Record<string, string>;
@@ -333,7 +330,7 @@ async function runGatewayCallStatusCommand(params: {
}) {
return await runNodeMain({
cwd: params.tmp,
args: ["gateway", "call", "status", "--json"],
args: params.args,
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
@@ -1913,7 +1910,12 @@ describe("run-node script", () => {
});
});
it("does not rebuild for gateway client calls against an existing dirty dist", async () => {
it.each([
{ label: "gateway RPC", args: ["gateway", "call", "status", "--json"] },
{ label: "gateway status", args: ["gateway", "status", "--json"] },
{ label: "remote agent", args: ["agent", "--message", "hello"] },
{ label: "dashboard", args: ["dashboard", "--no-open", "--yes"] },
])("does not rebuild for $label calls against an existing dirty dist", async ({ args }) => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {
files: {
@@ -1935,15 +1937,93 @@ describe("run-node script", () => {
gitHead: "abc123\n",
gitStatus: ` M ${ROOT_SRC}\n`,
});
const exitCode = await runGatewayCallStatusCommand({
const exitCode = await runGatewayClientCommand({
tmp,
args,
spawn,
spawnSync,
runRuntimePostBuild,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([gatewayCallStatusCommandSpawn()]);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", ...args]]);
expect(runRuntimePostBuild).not.toHaveBeenCalled();
});
});
it("rechecks a dirty dashboard client after waiting for an active build", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {
files: {
[ROOT_SRC]: "export const value = 1;\n",
[RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n',
},
buildPaths: [
ROOT_SRC,
ROOT_TSCONFIG,
ROOT_PACKAGE,
DIST_ENTRY,
BUILD_STAMP,
RUNTIME_POSTBUILD_STAMP,
],
});
await fs.rm(resolvePath(tmp, BUILD_STAMP));
await fs.rm(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP));
const lockProcess = Object.assign(createFakeProcess(), {
kill: vi.fn(() => true),
}) as unknown as NodeJS.Process;
const releaseLock = await acquireRunNodeBuildLock({
cwd: tmp,
args: ["gateway"],
env: { OPENCLAW_RUNNER_LOG: "0" },
fs: fsSync,
process: lockProcess,
stderr: { write: () => true } as unknown as NodeJS.WriteStream,
});
let markWaiting!: () => void;
const waitingForLock = new Promise<void>((resolve) => {
markWaiting = resolve;
});
const stderr = {
write: (chunk: string | Buffer) => {
if (String(chunk).includes("Waiting for TypeScript/runtime artifact lock")) {
markWaiting();
}
return true;
},
} as unknown as NodeJS.WriteStream;
const runRuntimePostBuild = vi.fn();
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
gitHead: "abc123\n",
gitStatus: ` M ${ROOT_SRC}\n`,
});
const clientRun = runNodeMain({
cwd: tmp,
args: ["dashboard", "--no-open", "--yes"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "1",
OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1",
},
spawn,
spawnSync,
process: lockProcess,
stderr,
runRuntimePostBuild,
execPath: process.execPath,
platform: process.platform,
});
await waitingForLock;
await fs.writeFile(resolvePath(tmp, BUILD_STAMP), '{"head":"abc123"}\n', "utf-8");
await fs.writeFile(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP), '{"head":"abc123"}\n', "utf-8");
releaseLock();
await expect(clientRun).resolves.toBe(0);
expect(spawnCalls).toEqual([
[process.execPath, "openclaw.mjs", "dashboard", "--no-open", "--yes"],
]);
expect(runRuntimePostBuild).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -71,14 +71,14 @@ export const en = {
bindAuto: "Auto (Loopback -> LAN)",
bindAutoHint: "Try loopback first",
bindCustom: "Custom IP",
bindCustomHint: "Bind to one local address",
bindCustomHint: "Specific IPv4s also bind 127.0.0.1",
bindCustomIp: "Custom IP address",
bindLan: "LAN (0.0.0.0)",
bindLanHint: "Reachable on your local network",
bindLoopback: "Loopback (127.0.0.1)",
bindLoopbackHint: "This machine only",
bindTailnet: "Tailnet (Tailscale IP)",
bindTailnetHint: "Reachable over Tailscale",
bindTailnetHint: "Reachable over Tailscale and local loopback",
existingPasswordConfirm: "Use existing gateway password ({password})?",
existingTokenConfirm: "Use existing gateway token ({token})?",
passwordPrompt: "Gateway password",
+2 -2
View File
@@ -70,14 +70,14 @@ export const zh_CN = {
bindAuto: "自动(Loopback -> LAN",
bindAutoHint: "先尝试 loopback",
bindCustom: "自定义 IP",
bindCustomHint: "绑定到一个本机地址",
bindCustomHint: "指定 IPv4 也会绑定 127.0.0.1",
bindCustomIp: "自定义 IP 地址",
bindLan: "LAN (0.0.0.0)",
bindLanHint: "局域网可访问",
bindLoopback: "Loopback (127.0.0.1)",
bindLoopbackHint: "仅本机可访问",
bindTailnet: "TailnetTailscale IP",
bindTailnetHint: "通过 Tailscale 访问",
bindTailnetHint: "通过 Tailscale 和本机环回地址访问",
existingPasswordConfirm: "使用已有 Gateway 密码({password})?",
existingTokenConfirm: "使用已有 Gateway 令牌({token})?",
passwordPrompt: "Gateway 密码",
+2 -2
View File
@@ -70,14 +70,14 @@ export const zh_TW = {
bindAuto: "自動(Loopback -> LAN",
bindAutoHint: "先嘗試 loopback",
bindCustom: "自訂 IP",
bindCustomHint: "綁定到一個本機位址",
bindCustomHint: "指定 IPv4 也會綁定 127.0.0.1",
bindCustomIp: "自訂 IP 位址",
bindLan: "LAN (0.0.0.0)",
bindLanHint: "區域網路可存取",
bindLoopback: "Loopback (127.0.0.1)",
bindLoopbackHint: "僅本機可存取",
bindTailnet: "TailnetTailscale IP",
bindTailnetHint: "透過 Tailscale 存取",
bindTailnetHint: "透過 Tailscale 和本機迴路位址存取",
existingPasswordConfirm: "使用既有 Gateway 密碼({password})?",
existingTokenConfirm: "使用既有 Gateway 權杖({token})?",
passwordPrompt: "Gateway 密碼",
@@ -8,6 +8,10 @@ const commandsLightEntries = [
source: "src/commands/dashboard.links.ts",
test: "src/commands/dashboard.links.test.ts",
},
{
source: "src/commands/dashboard.ts",
test: "src/commands/dashboard.test.ts",
},
{ test: "src/commands/daemon-install-helpers.test.ts" },
{ source: "src/commands/doctor-browser.ts", test: "src/commands/doctor-browser.test.ts" },
{