mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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 } },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Tailnet(Tailscale IP)",
|
||||
bindTailnetHint: "通过 Tailscale 访问",
|
||||
bindTailnetHint: "可通过 Tailscale 和本机环回地址访问",
|
||||
existingPasswordConfirm: "使用已有 Gateway 密码({password})?",
|
||||
existingTokenConfirm: "使用已有 Gateway 令牌({token})?",
|
||||
passwordPrompt: "Gateway 密码",
|
||||
|
||||
@@ -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: "Tailnet(Tailscale 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" },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user