fix: advertise route-aware LAN Control UI links (#98482)

* Route LAN pairing URLs by default route

* Advertise route-aware LAN Control UI links

* Fix route-aware LAN test mocks

* Narrow advertised LAN SDK export
This commit is contained in:
Josh Avant
2026-07-01 04:02:12 -05:00
committed by GitHub
parent 39898fe91b
commit ba5244c189
35 changed files with 926 additions and 61 deletions
+1 -1
View File
@@ -547,7 +547,7 @@ releases.
| `plugin-sdk/lazy-runtime` | Lazy runtime helpers | `createLazyRuntimeModule`, `createLazyRuntimeMethod`, `createLazyRuntimeMethodBinder`, `createLazyRuntimeNamedExport`, `createLazyRuntimeSurface` |
| `plugin-sdk/process-runtime` | Process helpers | Shared exec helpers |
| `plugin-sdk/cli-runtime` | CLI runtime helpers | Command formatting, waits, version helpers |
| `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client, event-loop-ready start helper, and channel-status patch helpers |
| `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client, event-loop-ready start helper, advertised LAN host resolution, and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Deprecated config compatibility shim | Prefer `config-contracts`, `plugin-config-runtime`, `runtime-config-snapshot`, and `config-mutation` |
| `plugin-sdk/telegram-command-config` | Telegram command helpers | Fallback-stable Telegram command validation helpers when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers, and structured approval display path formatting |
+1 -1
View File
@@ -232,7 +232,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/cli-runtime` | CLI formatting, wait, version, argument-invocation, and lazy command-group helpers |
| `plugin-sdk/qa-live-transport-scenarios` | Shared live transport QA scenario ids, baseline coverage helpers, and scenario-selection helper |
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, and channel-status patch helpers |
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers |
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
| `plugin-sdk/plugin-config-runtime` | Runtime plugin-config lookup helpers such as `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` |
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |
+1
View File
@@ -14,6 +14,7 @@ export {
resolveGatewayPort,
resolveTailnetHostWithRunner,
} from "openclaw/plugin-sdk/core";
export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime";
export {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
+36
View File
@@ -42,6 +42,7 @@ vi.mock("./api.js", () => {
renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl,
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
resolveAdvertisedLanHost: vi.fn(async () => null),
resolveGatewayBindUrl: vi.fn(),
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
resolveTailnetHostWithRunner: vi.fn(),
@@ -59,6 +60,7 @@ vi.mock("./notify.js", () => ({
import {
approveDevicePairing,
listDevicePairing,
resolveAdvertisedLanHost,
resolveGatewayBindUrl,
resolveTailnetHostWithRunner,
} from "./api.js";
@@ -860,6 +862,40 @@ describe("device-pair /pair default setup code", () => {
expect(requireText(result)).toContain("Gateway: ws://openclaw.local:18789");
});
it("uses the advertised LAN helper for bind-derived setup urls", async () => {
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("10.211.55.3");
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
url: `ws://${params.pickLanHost()}:18789`,
source: "gateway.bind=lan",
}));
const command = registerPairCommand({
config: {
gateway: {
bind: "lan",
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: undefined,
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
}),
);
expect(resolveAdvertisedLanHost).toHaveBeenCalledTimes(1);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
expect(requireText(result)).toContain("Gateway: ws://10.211.55.3:18789");
});
it("rejects public cleartext setup urls before issuing setup codes", async () => {
const command = registerPairCommand({
pluginConfig: {
+4 -6
View File
@@ -332,10 +332,6 @@ function pickMatchingIPv4(predicate: (address: string) => boolean): string | nul
return null;
}
function pickLanIPv4(): string | null {
return pickMatchingIPv4(isPrivateIPv4);
}
function pickTailnetIPv4(): string | null {
return pickMatchingIPv4(isTailnetIPv4);
}
@@ -396,7 +392,8 @@ function resolveRequiredAuthLabel(
}
async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const { resolveGatewayBindUrl, resolveGatewayPort } = await loadDevicePairApiModule();
const { resolveAdvertisedLanHost, resolveGatewayBindUrl, resolveGatewayPort } =
await loadDevicePairApiModule();
const cfg = api.config;
const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig;
const scheme = resolveScheme(cfg);
@@ -430,13 +427,14 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
return { url: remoteUrl, source: "gateway.remote.url" };
}
const advertisedLanHost = cfg.gateway?.bind === "lan" ? await resolveAdvertisedLanHost() : null;
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: pickTailnetIPv4,
pickLanHost: pickLanIPv4,
pickLanHost: () => advertisedLanHost,
});
if (bindResult) {
return bindResult;
+2 -2
View File
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10404),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5222),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10405),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5223),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3261,
+17
View File
@@ -87,6 +87,10 @@ const serviceReadCommand = vi.fn<
const resolveGatewayBindHost = vi.fn(
async (_bindMode?: string, _customBindHost?: string) => "0.0.0.0",
);
const resolveAdvertisedControlUiLinks = vi.fn(async (_opts?: unknown) => ({
httpUrl: "https://10.211.55.3:19001/",
wsUrl: "wss://10.211.55.3:19001",
}));
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.9");
const resolveGatewayPort = vi.fn((_cfg?: unknown, _env?: unknown) => 18789);
const resolveStateDir = vi.fn(
@@ -185,6 +189,10 @@ vi.mock("../../gateway/net.js", () => ({
resolveGatewayBindHost(bindMode, customBindHost),
}));
vi.mock("../../gateway/control-ui-links.js", () => ({
resolveAdvertisedControlUiLinks: (opts?: unknown) => resolveAdvertisedControlUiLinks(opts),
}));
vi.mock("../../gateway/probe-auth.js", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
@@ -259,6 +267,11 @@ describe("gatherDaemonStatus", () => {
deleteTestEnvValue("DAEMON_GATEWAY_TOKEN");
deleteTestEnvValue("DAEMON_GATEWAY_PASSWORD");
callGatewayStatusProbe.mockClear();
resolveAdvertisedControlUiLinks.mockClear();
resolveAdvertisedControlUiLinks.mockResolvedValue({
httpUrl: "https://10.211.55.3:19001/",
wsUrl: "wss://10.211.55.3:19001",
});
resolveGatewayProbeAuthSafeWithSecretInputsCalls.mockClear();
createConfigIOCalls.mockClear();
findStaleOpenClawUpdateLaunchdJobs.mockReset();
@@ -308,6 +321,10 @@ describe("gatherDaemonStatus", () => {
expect(probeInput.tlsFingerprint).toBe("sha256:11:22:33:44");
expect(probeInput.token).toBe("daemon-token");
expect(status.gateway?.probeUrl).toBe("wss://127.0.0.1:19001");
expect(status.gateway?.controlUiLinks).toEqual({
httpUrl: "https://10.211.55.3:19001/",
wsUrl: "wss://10.211.55.3:19001",
});
expect(status.gateway?.tlsEnabled).toBe(true);
expect(status.gateway?.version).toBe("2026.5.6");
expect(status.rpc?.url).toBe("wss://127.0.0.1:19001");
+13
View File
@@ -21,6 +21,7 @@ import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { resolveAdvertisedControlUiLinks } from "../../gateway/control-ui-links.js";
import { gatewaySecretInputPathCanWin } from "../../gateway/credentials-secret-inputs.js";
import { trimToUndefined } from "../../gateway/credentials.js";
import { resolveGatewayProbeCredentialConfig } from "../../gateway/probe-auth.js";
@@ -73,6 +74,7 @@ type GatewayStatusSummary = {
port: number;
portSource: "service args" | "env/config";
probeUrl: string;
controlUiLinks?: { httpUrl: string; wsUrl: string };
probeNote?: string;
version?: string | null;
};
@@ -431,6 +433,16 @@ async function resolveGatewayStatusSummary(params: {
const tlsEnabled = params.daemonCfg.gateway?.tls?.enabled === true;
const scheme = tlsEnabled ? "wss" : "ws";
const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`;
const controlUiLinks =
params.daemonCfg.gateway?.controlUi?.enabled === false
? undefined
: await resolveAdvertisedControlUiLinks({
port: daemonPort,
bind: bindMode,
customBindHost,
basePath: params.daemonCfg.gateway?.controlUi?.basePath,
tlsEnabled,
});
let probeNote =
!probeUrlOverride && bindMode === "lan"
? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.`
@@ -449,6 +461,7 @@ async function resolveGatewayStatusSummary(params: {
port: daemonPort,
portSource,
probeUrl,
...(controlUiLinks ? { controlUiLinks } : {}),
...(probeNote ? { probeNote } : {}),
},
daemonPort,
+44
View File
@@ -526,6 +526,50 @@ describe("printDaemonStatus", () => {
});
});
it("prints gathered advertised dashboard links without recomputing them", () => {
printDaemonStatus(
{
service: {
label: "LaunchAgent",
loaded: true,
loadedText: "loaded",
notLoadedText: "not loaded",
},
config: {
cli: {
path: "/tmp/openclaw-cli/openclaw.json",
exists: true,
valid: true,
},
daemon: {
path: "/tmp/openclaw-daemon/openclaw.json",
exists: true,
valid: true,
controlUi: { basePath: "/ui" },
},
mismatch: true,
},
gateway: {
bindMode: "lan",
bindHost: "0.0.0.0",
port: 19001,
portSource: "service args",
probeUrl: "wss://127.0.0.1:19001",
tlsEnabled: true,
controlUiLinks: {
httpUrl: "https://10.211.55.3:19001/ui/",
wsUrl: "wss://10.211.55.3:19001/ui",
},
},
extraServices: [],
},
{ json: false },
);
expect(runtime.log).toHaveBeenCalledWith("Dashboard: https://10.211.55.3:19001/ui/");
expect(resolveControlUiLinksMock).not.toHaveBeenCalled();
});
it("prints deep config warnings", () => {
printDaemonStatus(
{
+9 -7
View File
@@ -209,13 +209,15 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d
if (!controlUiEnabled) {
defaultRuntime.log(`${label("Dashboard:")} ${warnText("disabled")}`);
} else {
const links = resolveControlUiLinks({
port: status.gateway.port,
bind: status.gateway.bindMode,
customBindHost: status.gateway.customBindHost,
basePath: status.config?.daemon?.controlUi?.basePath,
tlsEnabled: status.gateway.tlsEnabled === true,
});
const links =
status.gateway.controlUiLinks ??
resolveControlUiLinks({
port: status.gateway.port,
bind: status.gateway.bindMode,
customBindHost: status.gateway.customBindHost,
basePath: status.config?.daemon?.controlUi?.basePath,
tlsEnabled: status.gateway.tlsEnabled === true,
});
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`);
}
if (status.gateway.probeNote) {
+47 -6
View File
@@ -33,7 +33,9 @@ const mocks = vi.hoisted(() => {
printWizardHeader: vi.fn(),
probeGatewayReachable: vi.fn(),
waitForGatewayReachable: vi.fn(),
resolveAdvertisedControlUiLinks: vi.fn(),
resolveControlUiLinks: vi.fn(),
resolveLocalControlUiProbeLinks: vi.fn(),
summarizeExistingConfig: vi.fn(),
promptAuthConfig: vi.fn(),
promptGatewayConfig: vi.fn(),
@@ -104,7 +106,9 @@ vi.mock("./onboard-helpers.js", () => ({
guardCancel: <T>(value: T) => value,
printWizardHeader: mocks.printWizardHeader,
probeGatewayReachable: mocks.probeGatewayReachable,
resolveAdvertisedControlUiLinks: mocks.resolveAdvertisedControlUiLinks,
resolveControlUiLinks: mocks.resolveControlUiLinks,
resolveLocalControlUiProbeLinks: mocks.resolveLocalControlUiProbeLinks,
summarizeExistingConfig: mocks.summarizeExistingConfig,
waitForGatewayReachable: mocks.waitForGatewayReachable,
}));
@@ -220,6 +224,14 @@ function setupBaseWizardState(config: OpenClawConfig = {}) {
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.resolveLocalControlUiProbeLinks.mockReturnValue({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
mocks.resolveAdvertisedControlUiLinks.mockResolvedValue({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({
intro: vi.fn(async () => {}),
@@ -349,12 +361,7 @@ describe("runConfigureWizard", () => {
},
},
});
queueWizardPrompts({
select: ["local", "__continue"],
confirm: [],
});
await runConfigureWizard({ command: "configure" }, createRuntime());
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
const probeRequests = mocks.probeGatewayReachable.mock.calls.map(([request]) =>
requireRecord(request, "probe request"),
@@ -368,6 +375,40 @@ describe("runConfigureWizard", () => {
expect(remoteProbe?.timeoutMs).toBe(300);
});
it("advertises LAN Control UI links while probing the local gateway", async () => {
setupBaseWizardState({
gateway: {
mode: "local",
bind: "lan",
auth: { token: "token" },
},
});
mocks.resolveAdvertisedControlUiLinks.mockResolvedValueOnce({
httpUrl: "http://10.211.55.3:18789/",
wsUrl: "ws://10.211.55.3:18789",
});
mocks.resolveLocalControlUiProbeLinks.mockReturnValueOnce({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
expect(mocks.resolveAdvertisedControlUiLinks).toHaveBeenCalledWith(
expect.objectContaining({ bind: "lan", port: 18789 }),
);
expect(mocks.probeGatewayReachable).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Web UI: http://10.211.55.3:18789/"),
"Control UI",
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway WS: ws://10.211.55.3:18789"),
"Control UI",
);
});
it("exits with code 1 when configure wizard is cancelled", async () => {
const runtime = createRuntime();
setupBaseWizardState();
+15 -7
View File
@@ -52,7 +52,8 @@ import {
ensureWorkspaceAndSessions,
guardCancel,
probeGatewayReachable,
resolveControlUiLinks,
resolveAdvertisedControlUiLinks,
resolveLocalControlUiProbeLinks,
summarizeExistingConfig,
waitForGatewayReachable,
} from "./onboard-helpers.js";
@@ -122,7 +123,7 @@ async function runGatewayHealthCheck(params: {
runtime: RuntimeEnv;
port: number;
}): Promise<void> {
const localLinks = resolveControlUiLinks({
const localLinks = resolveLocalControlUiProbeLinks({
bind: params.cfg.gateway?.bind ?? "loopback",
port: params.port,
customBindHost: params.cfg.gateway?.customBindHost,
@@ -832,7 +833,14 @@ export async function runConfigureWizard(
}
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
const displayLinks = await resolveAdvertisedControlUiLinks({
bind,
port: gatewayPort,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: nextConfig.gateway?.controlUi?.basePath,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
const probeLinks = resolveLocalControlUiProbeLinks({
bind,
port: gatewayPort,
customBindHost: nextConfig.gateway?.customBindHost,
@@ -862,13 +870,13 @@ export async function runConfigureWizard(
}));
let gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
url: probeLinks.wsUrl,
token,
password: newPassword,
});
if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) {
gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
url: probeLinks.wsUrl,
token,
password: oldPassword,
});
@@ -879,8 +887,8 @@ export async function runConfigureWizard(
note(
[
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
`Web UI: ${displayLinks.httpUrl}`,
`Gateway WS: ${displayLinks.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui",
].join("\n"),
+30
View File
@@ -15,7 +15,9 @@ import {
openUrl,
probeGatewayReachable,
resolveBrowserOpenCommand,
resolveAdvertisedControlUiLinks,
resolveControlUiLinks,
resolveLocalControlUiProbeLinks,
summarizeExistingConfig,
validateGatewayPasswordInput,
waitForGatewayReachable,
@@ -36,6 +38,7 @@ const mocks = vi.hoisted(() => ({
killed: false,
})),
pickPrimaryTailnetIPv4: vi.fn<() => string | undefined>(() => undefined),
resolveAdvertisedLanHost: vi.fn<() => Promise<string | null>>(async () => null),
probeGateway: vi.fn(),
}));
@@ -51,6 +54,10 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
}));
vi.mock("../infra/advertised-lan-host.js", () => ({
resolveAdvertisedLanHost: mocks.resolveAdvertisedLanHost,
}));
vi.mock("../gateway/probe.js", () => ({
probeGateway: mocks.probeGateway,
}));
@@ -557,6 +564,29 @@ describe("resolveControlUiLinks", () => {
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
it("uses route-aware advertised LAN host for display links", async () => {
mocks.resolveAdvertisedLanHost.mockResolvedValueOnce("10.211.55.3");
const links = await resolveAdvertisedControlUiLinks({
port: 18789,
bind: "lan",
});
expect(links.httpUrl).toBe("http://10.211.55.3:18789/");
expect(links.wsUrl).toBe("ws://10.211.55.3:18789");
});
it("keeps co-located LAN probes on loopback", () => {
const links = resolveLocalControlUiProbeLinks({
port: 18789,
bind: "lan",
});
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
expect(mocks.resolveAdvertisedLanHost).not.toHaveBeenCalled();
});
});
describe("normalizeGatewayTokenInput", () => {
+6 -2
View File
@@ -23,7 +23,11 @@ import { resolveConfigPath } from "../config/paths.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveControlUiLinks } from "../gateway/control-ui-links.js";
import {
resolveAdvertisedControlUiLinks,
resolveControlUiLinks,
resolveLocalControlUiProbeLinks,
} from "../gateway/control-ui-links.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { probeGateway } from "../gateway/probe.js";
import {
@@ -41,7 +45,7 @@ export { randomToken } from "./random-token.js";
export { detectBinary };
export { detectBrowserOpenSupport, openUrl, resolveBrowserOpenCommand };
export { resolveControlUiLinks };
export { resolveAdvertisedControlUiLinks, resolveControlUiLinks, resolveLocalControlUiProbeLinks };
/** Handles Clack cancellation by exiting through the runtime. */
export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
@@ -135,6 +135,10 @@ vi.mock("./onboard-helpers.js", () => {
httpUrl: `http://127.0.0.1:${port}`,
wsUrl: `ws://127.0.0.1:${port}`,
}),
resolveLocalControlUiProbeLinks: ({ port }: { port: number }) => ({
httpUrl: `http://127.0.0.1:${port}`,
wsUrl: `ws://127.0.0.1:${port}`,
}),
waitForGatewayReachable: (params: {
url: string;
token?: string;
@@ -17,7 +17,7 @@ import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
ensureWorkspaceAndSessions,
resolveControlUiLinks,
resolveLocalControlUiProbeLinks,
waitForGatewayReachable,
} from "../onboard-helpers.js";
import { enableDefaultOnboardingInternalHooks } from "../onboard-hooks.js";
@@ -299,7 +299,7 @@ export async function runNonInteractiveLocalSetup(params: {
if (!opts.skipHealth) {
const { healthCommand } = await import("../health.js");
const links = resolveControlUiLinks({
const links = resolveLocalControlUiProbeLinks({
bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom" | "tailnet",
port: gatewayResult.port,
customBindHost: nextConfig.gateway?.customBindHost,
+1
View File
@@ -24,6 +24,7 @@ export async function statusAllCommand(
runtime,
// status --all can afford gateway overrides so channel summaries reflect live runtime state.
useGatewayCallOverridesForChannelsStatus: true,
includeAdvertisedControlUiLinks: true,
progress,
labels: {
loadingConfig: "Loading config…",
+32
View File
@@ -244,6 +244,38 @@ describe("status-all format", () => {
});
});
it("prefers advertised Control UI links for dashboard values", () => {
expect(
buildStatusGatewaySurfaceValues({
cfg: { gateway: { bind: "lan" } },
advertisedControlUiLinks: {
httpUrl: "http://10.211.55.3:18789/",
wsUrl: "ws://10.211.55.3:18789",
},
gatewayMode: "local",
remoteUrlMissing: false,
gatewayConnection: {
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 12, error: null },
gatewayProbeAuth: { token: "tok" },
gatewaySelf: null,
gatewayService: {
label: "LaunchAgent",
installed: true,
loadedText: "loaded",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
},
}).dashboardUrl,
).toBe("http://10.211.55.3:18789/");
});
it("prefers node-only gateway values when present", () => {
expect(
buildStatusGatewaySurfaceValues({
+7 -1
View File
@@ -242,6 +242,7 @@ export function buildStatusOverviewSurfaceRows(params: {
tailscaleMode: string;
tailscaleDns?: string | null;
tailscaleHttpsUrl?: string | null;
advertisedControlUiLinks?: { httpUrl: string; wsUrl: string };
tailscaleBackendState?: string | null;
includeBackendStateWhenOff?: boolean;
includeBackendStateWhenOn?: boolean;
@@ -278,6 +279,9 @@ export function buildStatusOverviewSurfaceRows(params: {
const { dashboardUrl, gatewayValue, gatewaySelfValue, gatewayServiceValue, nodeServiceValue } =
buildStatusGatewaySurfaceValues({
cfg: params.cfg,
...(params.advertisedControlUiLinks
? { advertisedControlUiLinks: params.advertisedControlUiLinks }
: {}),
gatewayMode: params.gatewayMode,
remoteUrlMissing: params.remoteUrlMissing,
gatewayConnection: params.gatewayConnection,
@@ -401,6 +405,7 @@ export function buildGatewayStatusSummaryParts(params: {
/** Builds gateway/dashboard/service values for overview rows. */
export function buildStatusGatewaySurfaceValues(params: {
cfg: Pick<OpenClawConfig, "gateway">;
advertisedControlUiLinks?: { httpUrl: string; wsUrl: string };
gatewayMode: "local" | "remote";
remoteUrlMissing: boolean;
gatewayConnection: StatusGatewayConnection;
@@ -444,7 +449,8 @@ export function buildStatusGatewaySurfaceValues(params: {
: ""
}${gatewaySelfValue ? ` · ${gatewaySelfValue}` : ""}`;
return {
dashboardUrl: resolveStatusDashboardUrl({ cfg: params.cfg }),
dashboardUrl:
params.advertisedControlUiLinks?.httpUrl ?? resolveStatusDashboardUrl({ cfg: params.cfg }),
gatewayValue,
gatewaySelfValue,
gatewayServiceValue: formatStatusServiceValue({
+18 -1
View File
@@ -56,6 +56,7 @@ export type StatusOverviewSurface = {
tailscaleMode: string;
tailscaleDns?: string | null;
tailscaleHttpsUrl?: string | null;
advertisedControlUiLinks?: { httpUrl: string; wsUrl: string };
gatewayMode: "local" | "remote";
remoteUrlMissing: boolean;
gatewayConnection: StatusGatewayConnection;
@@ -78,6 +79,7 @@ export function buildStatusOverviewSurfaceFromScan(params: {
| "tailscaleMode"
| "tailscaleDns"
| "tailscaleHttpsUrl"
| "advertisedControlUiLinks"
| "gatewayMode"
| "remoteUrlMissing"
| "gatewayConnection"
@@ -97,6 +99,9 @@ export function buildStatusOverviewSurfaceFromScan(params: {
tailscaleMode: params.scan.tailscaleMode,
tailscaleDns: params.scan.tailscaleDns,
tailscaleHttpsUrl: params.scan.tailscaleHttpsUrl,
...(params.scan.advertisedControlUiLinks
? { advertisedControlUiLinks: params.scan.advertisedControlUiLinks }
: {}),
gatewayMode: params.scan.gatewayMode,
remoteUrlMissing: params.scan.remoteUrlMissing,
gatewayConnection: params.scan.gatewayConnection,
@@ -115,7 +120,13 @@ export function buildStatusOverviewSurfaceFromScan(params: {
export function buildStatusOverviewSurfaceFromOverview(params: {
overview: Pick<
StatusScanOverviewResult,
"cfg" | "update" | "tailscaleMode" | "tailscaleDns" | "tailscaleHttpsUrl" | "gatewaySnapshot"
| "cfg"
| "update"
| "tailscaleMode"
| "tailscaleDns"
| "tailscaleHttpsUrl"
| "advertisedControlUiLinks"
| "gatewaySnapshot"
>;
gatewayService: StatusServiceSummary;
nodeService: StatusServiceSummary;
@@ -127,6 +138,9 @@ export function buildStatusOverviewSurfaceFromOverview(params: {
tailscaleMode: params.overview.tailscaleMode,
tailscaleDns: params.overview.tailscaleDns,
tailscaleHttpsUrl: params.overview.tailscaleHttpsUrl,
...(params.overview.advertisedControlUiLinks
? { advertisedControlUiLinks: params.overview.advertisedControlUiLinks }
: {}),
gatewayMode: params.overview.gatewaySnapshot.gatewayMode,
remoteUrlMissing: params.overview.gatewaySnapshot.remoteUrlMissing,
gatewayConnection: params.overview.gatewaySnapshot.gatewayConnection,
@@ -166,6 +180,9 @@ export function buildStatusOverviewRowsFromSurface(params: {
tailscaleMode: params.surface.tailscaleMode,
tailscaleDns: params.surface.tailscaleDns,
tailscaleHttpsUrl: params.surface.tailscaleHttpsUrl,
...(params.surface.advertisedControlUiLinks
? { advertisedControlUiLinks: params.surface.advertisedControlUiLinks }
: {}),
tailscaleBackendState: params.tailscaleBackendState,
includeBackendStateWhenOff: params.includeBackendStateWhenOff,
includeBackendStateWhenOn: params.includeBackendStateWhenOn,
+2
View File
@@ -156,6 +156,7 @@ export async function statusCommand(
tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
advertisedControlUiLinks,
update,
gatewayConnection,
remoteUrlMissing,
@@ -301,6 +302,7 @@ export async function statusCommand(
tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
...(advertisedControlUiLinks ? { advertisedControlUiLinks } : {}),
gatewayMode,
remoteUrlMissing,
gatewayConnection,
+3
View File
@@ -52,6 +52,9 @@ export async function executeStatusScanFromOverview(params: {
tailscaleMode: params.overview.tailscaleMode,
tailscaleDns: params.overview.tailscaleDns,
tailscaleHttpsUrl: params.overview.tailscaleHttpsUrl,
...(params.overview.advertisedControlUiLinks
? { advertisedControlUiLinks: params.overview.advertisedControlUiLinks }
: {}),
update: params.overview.update,
gatewaySnapshot: params.overview.gatewaySnapshot,
channelIssues: params.channelIssues,
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
resolveCommandConfigWithSecrets: vi.fn(),
getStatusCommandSecretTargetIds: vi.fn(),
readBestEffortConfigSnapshot: vi.fn(),
resolveGatewayPort: vi.fn(),
resolveOsSummary: vi.fn(),
createStatusScanCoreBootstrap: vi.fn(),
callGateway: vi.fn(),
@@ -28,6 +29,7 @@ vi.mock("../cli/command-secret-targets.js", () => ({
vi.mock("../config/config.js", () => ({
readBestEffortConfigSnapshot: mocks.readBestEffortConfigSnapshot,
resolveGatewayPort: mocks.resolveGatewayPort,
}));
vi.mock("../infra/os-summary.js", () => ({
+22
View File
@@ -34,6 +34,9 @@ const channelPluginIdsModuleLoader = createLazyImportLoader(
() => import("../plugins/channel-plugin-ids.js"),
);
const configModuleLoader = createLazyImportLoader(() => import("../config/config.js"));
const controlUiLinksModuleLoader = createLazyImportLoader(
() => import("../gateway/control-ui-links.js"),
);
const commandConfigResolutionModuleLoader = createLazyImportLoader(
() => import("../cli/command-config-resolution.js"),
);
@@ -73,6 +76,10 @@ function loadConfigModule() {
return configModuleLoader.load();
}
function loadControlUiLinksModule() {
return controlUiLinksModuleLoader.load();
}
function loadCommandConfigResolutionModule() {
return commandConfigResolutionModuleLoader.load();
}
@@ -116,6 +123,7 @@ export type StatusScanOverviewResult = {
tailscaleMode: string;
tailscaleDns: string | null;
tailscaleHttpsUrl: string | null;
advertisedControlUiLinks?: { httpUrl: string; wsUrl: string };
update: UpdateCheckResult;
gatewaySnapshot: Pick<
GatewayProbeSnapshot,
@@ -158,6 +166,7 @@ export async function collectStatusScanOverview(params: {
useGatewayCallOverridesForChannelsStatus?: boolean;
includeChannelSecretTargets?: boolean;
skipConfigPluginValidation?: boolean;
includeAdvertisedControlUiLinks?: boolean;
progress?: {
setLabel(label: string): void;
tick(): void;
@@ -267,6 +276,18 @@ export async function collectStatusScanOverview(params: {
params.progress?.tick();
const tailscaleHttpsUrl = await bootstrap.resolveTailscaleHttpsUrl();
const advertisedControlUiLinks =
params.includeAdvertisedControlUiLinks === true && cfg.gateway?.controlUi?.enabled !== false
? await loadControlUiLinksModule().then(async ({ resolveAdvertisedControlUiLinks }) =>
resolveAdvertisedControlUiLinks({
port: (await loadConfigModule()).resolveGatewayPort(cfg),
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
basePath: cfg.gateway?.controlUi?.basePath,
tlsEnabled: cfg.gateway?.tls?.enabled === true,
}),
)
: undefined;
const includeChannelsData = params.includeChannelsData !== false;
const includeLiveChannelStatus = params.includeLiveChannelStatus !== false;
const { channelsStatus, channelIssues, channels } = includeChannelsData
@@ -327,6 +348,7 @@ export async function collectStatusScanOverview(params: {
tailscaleMode: bootstrap.tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
...(advertisedControlUiLinks ? { advertisedControlUiLinks } : {}),
update,
gatewaySnapshot,
channelsStatus,
+5
View File
@@ -24,6 +24,7 @@ export type StatusScanResult = {
tailscaleMode: string;
tailscaleDns: string | null;
tailscaleHttpsUrl: string | null;
advertisedControlUiLinks?: { httpUrl: string; wsUrl: string };
update: UpdateCheckResult;
gatewayConnection: GatewayProbeSnapshot["gatewayConnection"];
remoteUrlMissing: boolean;
@@ -54,6 +55,7 @@ export function buildStatusScanResult(params: {
tailscaleMode: string;
tailscaleDns: string | null;
tailscaleHttpsUrl: string | null;
advertisedControlUiLinks?: { httpUrl: string; wsUrl: string };
update: UpdateCheckResult;
gatewaySnapshot: Pick<
GatewayProbeSnapshot,
@@ -82,6 +84,9 @@ export function buildStatusScanResult(params: {
tailscaleMode: params.tailscaleMode,
tailscaleDns: params.tailscaleDns,
tailscaleHttpsUrl: params.tailscaleHttpsUrl,
...(params.advertisedControlUiLinks
? { advertisedControlUiLinks: params.advertisedControlUiLinks }
: {}),
update: params.update,
gatewayConnection: params.gatewaySnapshot.gatewayConnection,
remoteUrlMissing: params.gatewaySnapshot.remoteUrlMissing,
+3
View File
@@ -9,6 +9,7 @@ type ResolveConfigPathMock = Mock<() => string>;
type StatusScanSharedMocks = {
resolveConfigPath: ResolveConfigPathMock;
resolveGatewayPort: Mock<(cfg?: OpenClawConfig) => number>;
hasConfiguredChannels: UnknownMock;
hasConfiguredChannelsForReadOnlyScope: UnknownMock;
readBestEffortConfig: UnknownMock;
@@ -28,6 +29,7 @@ type StatusScanSharedMocks = {
export function createStatusScanSharedMocks(configPathLabel: string): StatusScanSharedMocks {
return {
resolveConfigPath: vi.fn(() => `/tmp/openclaw-${configPathLabel}-missing-${process.pid}.json`),
resolveGatewayPort: vi.fn((cfg?: OpenClawConfig) => cfg?.gateway?.port ?? 18789),
hasConfiguredChannels: vi.fn(),
hasConfiguredChannelsForReadOnlyScope: vi.fn(),
readBestEffortConfig: vi.fn(),
@@ -217,6 +219,7 @@ export async function loadStatusScanModuleForTest(
const config = await mocks.readBestEffortConfig();
return { config, sourceConfig: config };
},
resolveGatewayPort: mocks.resolveGatewayPort,
}));
vi.doMock("../cli/command-secret-targets.js", () => ({
getStatusCommandSecretTargetIds,
+1
View File
@@ -64,6 +64,7 @@ export async function scanStatus(
includeChannelSecretTargets: isFullScan ? undefined : false,
fetchGitUpdate: isFullScan,
includeRegistryUpdate: isFullScan,
includeAdvertisedControlUiLinks: true,
progress,
labels: {
loadingConfig: "Loading config…",
+33 -4
View File
@@ -1,4 +1,6 @@
// Control UI link builder for local, LAN, tailnet, and custom gateway binds.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveAdvertisedLanHost } from "../infra/advertised-lan-host.js";
import {
inspectBestEffortPrimaryTailnetIPv4,
pickBestEffortPrimaryLanIPv4,
@@ -6,19 +8,26 @@ import {
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { isValidIPv4 } from "./net.js";
/** Resolve the advertised HTTP and websocket URLs for the Control UI. */
export function resolveControlUiLinks(params: {
type ControlUiLinkParams = {
port: number;
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
customBindHost?: string;
basePath?: string;
tlsEnabled?: boolean;
}): { httpUrl: string; wsUrl: string } {
};
type ControlUiLinks = { httpUrl: string; wsUrl: string };
/** Resolve the advertised HTTP and websocket URLs for the Control UI. */
export function resolveControlUiLinks(
params: ControlUiLinkParams & { advertisedLanHost?: string | null },
): ControlUiLinks {
// Current BYOH truth: lan, tailnet, and custom bind resolve through IPv4-only helpers.
// IPv6-only hosts need an IPv4 sidecar or proxy in front of the Gateway.
const port = params.port;
const bind = params.bind ?? "loopback";
const customBindHost = params.customBindHost?.trim();
const advertisedLanHost = normalizeOptionalString(params.advertisedLanHost);
const { tailnetIPv4 } = inspectBestEffortPrimaryTailnetIPv4();
const host = (() => {
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
@@ -28,7 +37,7 @@ export function resolveControlUiLinks(params: {
return tailnetIPv4 ?? "127.0.0.1";
}
if (bind === "lan") {
return pickBestEffortPrimaryLanIPv4() ?? "127.0.0.1";
return advertisedLanHost ?? pickBestEffortPrimaryLanIPv4() ?? "127.0.0.1";
}
return "127.0.0.1";
})();
@@ -42,3 +51,23 @@ export function resolveControlUiLinks(params: {
wsUrl: `${wsScheme}://${host}:${port}${wsPath}`,
};
}
/** Resolve Control UI URLs meant for display to nearby devices. */
export async function resolveAdvertisedControlUiLinks(
params: ControlUiLinkParams,
): Promise<ControlUiLinks> {
const advertisedLanHost =
params.bind === "lan" ? await resolveAdvertisedLanHost().catch(() => null) : null;
return resolveControlUiLinks({
...params,
advertisedLanHost,
});
}
/** Resolve Control UI URLs for co-located readiness probes and health checks. */
export function resolveLocalControlUiProbeLinks(params: ControlUiLinkParams): ControlUiLinks {
return resolveControlUiLinks({
...params,
bind: params.bind === "lan" ? "loopback" : params.bind,
});
}
+151
View File
@@ -0,0 +1,151 @@
// Tests route-aware LAN advertisement host selection.
import { describe, expect, it, vi } from "vitest";
import {
listAdvertisedLanHostCandidates,
parseLinuxDefaultRouteHints,
parseMacOsDefaultRouteHints,
parseWindowsDefaultRouteHints,
resolveAdvertisedLanHost,
selectAdvertisedLanHost,
type AdvertisedLanHostCommandRunner,
} from "./advertised-lan-host.js";
import type { NetworkInterfacesSnapshot } from "./network-interfaces.js";
function ipv4(address: string, family: "IPv4" | 4 = "IPv4") {
return {
address,
family,
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: `${address}/24`,
};
}
function createRouteRunner(stdout: string, code = 0): AdvertisedLanHostCommandRunner {
return vi.fn(async () => ({
code,
stdout,
stderr: "",
}));
}
describe("advertised LAN host", () => {
it("lists only private IPv4 candidates in OS order", () => {
expect(
listAdvertisedLanHostCandidates({
tailscale0: [ipv4("100.64.0.9")],
bridge: [ipv4("10.37.129.4")],
ethernet: [ipv4("10.211.55.3", 4)],
wifi: [ipv4("192.168.1.20")],
} as NetworkInterfacesSnapshot),
).toEqual([
{ interfaceName: "bridge", address: "10.37.129.4", order: 0 },
{ interfaceName: "ethernet", address: "10.211.55.3", order: 1 },
{ interfaceName: "wifi", address: "192.168.1.20", order: 2 },
]);
});
it("prefers the default-route interface over the first private interface", () => {
expect(
selectAdvertisedLanHost(
[
{ interfaceName: "Ethernet", address: "10.37.129.4", order: 0 },
{ interfaceName: "Ethernet 2", address: "10.211.55.3", order: 1 },
],
[{ interfaceName: "Ethernet 2" }],
),
).toBe("10.211.55.3");
});
it("falls back to the original private-interface order when route hints do not match", () => {
expect(
selectAdvertisedLanHost(
[
{ interfaceName: "Ethernet", address: "10.37.129.4", order: 0 },
{ interfaceName: "Ethernet 2", address: "10.211.55.3", order: 1 },
],
[{ interfaceName: "Tailscale" }],
),
).toBe("10.37.129.4");
});
it("parses Windows default routes from Get-NetRoute JSON", () => {
expect(
parseWindowsDefaultRouteHints(
'[{"InterfaceAlias":"Ethernet 2","RouteMetric":0},{"InterfaceAlias":"Ethernet","RouteMetric":256}]',
),
).toEqual([{ interfaceName: "ethernet 2" }, { interfaceName: "ethernet" }]);
});
it("sorts Windows default routes by effective metric", () => {
expect(
parseWindowsDefaultRouteHints(
JSON.stringify([
{ InterfaceAlias: "Ethernet", RouteMetric: 1, InterfaceMetric: 1000 },
{ InterfaceAlias: "Ethernet 2", RouteMetric: 100, InterfaceMetric: 1 },
]),
),
).toEqual([{ interfaceName: "ethernet 2" }, { interfaceName: "ethernet" }]);
});
it("parses macOS and Linux default route interfaces", () => {
expect(parseMacOsDefaultRouteHints(" route to: default\ninterface: en9\n")).toEqual([
{ interfaceName: "en9" },
]);
expect(
parseLinuxDefaultRouteHints(
"default via 192.168.1.1 dev wlan0 proto dhcp metric 600\ndefault via 10.0.0.1 dev eth0 metric 1000",
),
).toEqual([{ interfaceName: "wlan0" }, { interfaceName: "eth0" }]);
});
it("uses the Windows default-route alias for the advertised host", async () => {
const runner = createRouteRunner(
'{"InterfaceAlias":"Ethernet 2","RouteMetric":0,"InterfaceMetric":25}',
);
await expect(
resolveAdvertisedLanHost({
platform: "win32",
runCommandWithTimeout: runner,
networkInterfaces: () =>
({
Ethernet: [ipv4("10.37.129.4")],
"Ethernet 2": [ipv4("10.211.55.3")],
}) as NetworkInterfacesSnapshot,
}),
).resolves.toBe("10.211.55.3");
expect(runner).toHaveBeenCalledWith(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
expect.stringContaining("Get-NetRoute"),
],
{ timeoutMs: 3_000, maxOutputBytes: 16 * 1024 },
);
});
it("fails open to first private IPv4 when route probing times out", async () => {
const runner: AdvertisedLanHostCommandRunner = vi.fn(async () => ({
code: null,
stdout: "",
stderr: "",
}));
await expect(
resolveAdvertisedLanHost({
platform: "win32",
runCommandWithTimeout: runner,
networkInterfaces: () =>
({
Ethernet: [ipv4("10.37.129.4")],
"Ethernet 2": [ipv4("10.211.55.3")],
}) as NetworkInterfacesSnapshot,
}),
).resolves.toBe("10.37.129.4");
});
});
+246
View File
@@ -0,0 +1,246 @@
// Resolves the LAN host OpenClaw should advertise to nearby devices.
import { isRfc1918Ipv4Address } from "@openclaw/net-policy/ip";
import { runCommandWithTimeout as defaultRunCommandWithTimeout } from "../process/exec.js";
import {
listExternalInterfaceAddresses,
safeNetworkInterfaces,
type NetworkInterfacesSnapshot,
} from "./network-interfaces.js";
const DEFAULT_ROUTE_HINT_TIMEOUT_MS = 3_000;
const DEFAULT_ROUTE_HINT_OUTPUT_BYTES = 16 * 1024;
const WINDOWS_DEFAULT_ROUTE_COMMAND =
"Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' | " +
"Select-Object -Property InterfaceAlias,InterfaceIndex,NextHop,RouteMetric,InterfaceMetric,DestinationPrefix | " +
"ConvertTo-Json -Compress";
export type AdvertisedLanHostCandidate = {
interfaceName: string;
address: string;
order: number;
};
export type AdvertisedLanRouteHint = {
interfaceName: string;
};
export type AdvertisedLanHostCommandResult = {
code: number | null;
stdout: string;
stderr?: string;
};
export type AdvertisedLanHostCommandRunner = (
argv: string[],
opts: { timeoutMs: number; maxOutputBytes?: number },
) => Promise<AdvertisedLanHostCommandResult>;
export type ResolveAdvertisedLanHostOptions = {
networkInterfaces?: () => NetworkInterfacesSnapshot;
runCommandWithTimeout?: AdvertisedLanHostCommandRunner;
platform?: NodeJS.Platform;
timeoutMs?: number;
};
type WindowsRouteRow = {
InterfaceAlias?: unknown;
InterfaceMetric?: unknown;
RouteMetric?: unknown;
};
type RankedWindowsRouteRow = {
interfaceName: string;
effectiveMetric: number;
routeMetric: number;
interfaceMetric: number;
order: number;
};
function normalizeInterfaceName(name: unknown): string {
return typeof name === "string" ? name.trim().toLowerCase() : "";
}
function normalizeMetric(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
export function listAdvertisedLanHostCandidates(
snapshot: NetworkInterfacesSnapshot | undefined,
): AdvertisedLanHostCandidate[] {
return listExternalInterfaceAddresses(snapshot, "IPv4")
.filter((entry) => isRfc1918Ipv4Address(entry.address))
.map((entry, order) => ({
interfaceName: entry.name,
address: entry.address,
order,
}));
}
export function selectAdvertisedLanHost(
candidates: AdvertisedLanHostCandidate[],
routeHints: AdvertisedLanRouteHint[] = [],
): string | null {
if (candidates.length === 0) {
return null;
}
for (const hint of routeHints) {
const hintedName = normalizeInterfaceName(hint.interfaceName);
if (!hintedName) {
continue;
}
const routed = candidates.find(
(candidate) => normalizeInterfaceName(candidate.interfaceName) === hintedName,
);
if (routed) {
return routed.address;
}
}
return candidates[0]?.address ?? null;
}
export function parseWindowsDefaultRouteHints(stdout: string): AdvertisedLanRouteHint[] {
const trimmed = stdout.trim();
if (!trimmed) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return [];
}
const rankedRows: RankedWindowsRouteRow[] = [];
const rows = Array.isArray(parsed) ? parsed : [parsed];
for (const [order, row] of rows.entries()) {
if (!row || typeof row !== "object") {
continue;
}
const route = row as WindowsRouteRow;
const interfaceName = normalizeInterfaceName(route.InterfaceAlias);
if (interfaceName) {
const routeMetric = normalizeMetric(route.RouteMetric);
const interfaceMetric = normalizeMetric(route.InterfaceMetric);
rankedRows.push({
interfaceName,
effectiveMetric: routeMetric + interfaceMetric,
routeMetric,
interfaceMetric,
order,
});
}
}
rankedRows.sort(
(a, b) =>
a.effectiveMetric - b.effectiveMetric ||
a.routeMetric - b.routeMetric ||
a.interfaceMetric - b.interfaceMetric ||
a.order - b.order,
);
return rankedRows.map((row) => ({ interfaceName: row.interfaceName }));
}
export function parseMacOsDefaultRouteHints(stdout: string): AdvertisedLanRouteHint[] {
const match = /^\s*interface:\s*(\S+)/m.exec(stdout);
return match?.[1] ? [{ interfaceName: match[1] }] : [];
}
export function parseLinuxDefaultRouteHints(stdout: string): AdvertisedLanRouteHint[] {
const hints: AdvertisedLanRouteHint[] = [];
for (const line of stdout.split(/\r?\n/)) {
if (!line.startsWith("default ")) {
continue;
}
const match = /\bdev\s+(\S+)/.exec(line);
if (match?.[1]) {
hints.push({ interfaceName: match[1] });
}
}
return hints;
}
async function runRouteHintCommand(
runCommandWithTimeout: AdvertisedLanHostCommandRunner,
argv: string[],
timeoutMs: number,
): Promise<string | null> {
try {
const result = await runCommandWithTimeout(argv, {
timeoutMs,
maxOutputBytes: DEFAULT_ROUTE_HINT_OUTPUT_BYTES,
});
return result.code === 0 ? result.stdout : null;
} catch {
return null;
}
}
async function resolveDefaultRouteHints(params: {
platform: NodeJS.Platform;
runCommandWithTimeout: AdvertisedLanHostCommandRunner;
timeoutMs: number;
}): Promise<AdvertisedLanRouteHint[]> {
if (params.platform === "win32") {
const stdout = await runRouteHintCommand(
params.runCommandWithTimeout,
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
WINDOWS_DEFAULT_ROUTE_COMMAND,
],
params.timeoutMs,
);
return stdout ? parseWindowsDefaultRouteHints(stdout) : [];
}
if (params.platform === "darwin") {
const stdout = await runRouteHintCommand(
params.runCommandWithTimeout,
["route", "-n", "get", "default"],
params.timeoutMs,
);
return stdout ? parseMacOsDefaultRouteHints(stdout) : [];
}
if (params.platform === "linux") {
const stdout = await runRouteHintCommand(
params.runCommandWithTimeout,
["ip", "-4", "route", "show", "default"],
params.timeoutMs,
);
return stdout ? parseLinuxDefaultRouteHints(stdout) : [];
}
return [];
}
export async function resolveAdvertisedLanHost(
options: ResolveAdvertisedLanHostOptions = {},
): Promise<string | null> {
const candidates = listAdvertisedLanHostCandidates(
safeNetworkInterfaces(options.networkInterfaces),
);
if (candidates.length === 0) {
return null;
}
const routeHints = await resolveDefaultRouteHints({
platform: options.platform ?? process.platform,
runCommandWithTimeout: options.runCommandWithTimeout ?? defaultRunCommandWithTimeout,
timeoutMs: options.timeoutMs ?? DEFAULT_ROUTE_HINT_TIMEOUT_MS,
});
return selectAdvertisedLanHost(candidates, routeHints);
}
+71
View File
@@ -67,6 +67,28 @@ describe("pairing setup code", () => {
}));
}
function createNoRouteRunner() {
return vi.fn(async () => ({
code: 1,
stdout: "",
stderr: "",
}));
}
function createDefaultRouteRunner(interfaceName: string) {
const stdout =
process.platform === "win32"
? JSON.stringify({ InterfaceAlias: interfaceName })
: process.platform === "linux"
? `default via 10.211.55.1 dev ${interfaceName} proto dhcp metric 100\n`
: ` route to: default\ninterface: ${interfaceName}\n`;
return vi.fn(async () => ({
code: 0,
stdout,
stderr: "",
}));
}
function createIpv4NetworkInterfaces(
address: string,
): ReturnType<NonNullable<NonNullable<ResolveSetupOptions>["networkInterfaces"]>> {
@@ -551,6 +573,7 @@ describe("pairing setup code", () => {
});
it("allows lan bind cleartext setup urls for mobile pairing", async () => {
const runCommandWithTimeout = createNoRouteRunner();
await expectResolvedSetupSuccessCase({
config: {
gateway: {
@@ -560,12 +583,60 @@ describe("pairing setup code", () => {
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"),
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
expected: {
authLabel: "password",
url: "ws://192.168.1.20:18789",
urlSource: "gateway.bind=lan",
},
runCommandWithTimeout,
expectedRunCommandCalls: 1,
});
});
it("advertises the routed LAN interface instead of the first private interface", async () => {
const runCommandWithTimeout = createDefaultRouteRunner("en1");
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "lan",
auth: { mode: "password", password: "secret" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () =>
({
bridge100: [
{
address: "10.37.129.4",
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: "10.37.129.4/24",
},
],
en1: [
{
address: "10.211.55.3",
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: "10.211.55.3/24",
},
],
}) as ReturnType<NonNullable<NonNullable<ResolveSetupOptions>["networkInterfaces"]>>,
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
expected: {
authLabel: "password",
url: "ws://10.211.55.3:18789",
urlSource: "gateway.bind=lan",
},
runCommandWithTimeout,
expectedRunCommandCalls: 1,
});
});
+10 -12
View File
@@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../config/types.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import { materializeGatewayAuthSecretRefs } from "../gateway/auth-config-utils.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { resolveAdvertisedLanHost } from "../infra/advertised-lan-host.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import {
pickMatchingExternalInterfaceAddress,
@@ -42,7 +43,7 @@ export type PairingSetupCommandResult = {
export type PairingSetupCommandRunner = (
argv: string[],
opts: { timeoutMs: number },
opts: { timeoutMs: number; maxOutputBytes?: number },
) => Promise<PairingSetupCommandResult>;
export type ResolvePairingSetupOptions = {
@@ -218,10 +219,6 @@ function resolveScheme(
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function isPrivateIPv4(address: string): boolean {
return isRfc1918Ipv4Address(address);
}
function isTailnetIPv4(address: string): boolean {
return isCarrierGradeNatIpv4Address(address);
}
@@ -238,12 +235,6 @@ function pickIPv4Matching(
);
}
function pickLanIPv4(
networkInterfaces: () => ReturnType<typeof os.networkInterfaces>,
): string | null {
return pickIPv4Matching(networkInterfaces, isPrivateIPv4);
}
function pickTailnetIPv4(
networkInterfaces: () => ReturnType<typeof os.networkInterfaces>,
): string | null {
@@ -349,13 +340,20 @@ async function resolveGatewayUrl(
return { url: remoteUrl, source: "gateway.remote.url" };
}
const advertisedLanHost =
cfg.gateway?.bind === "lan"
? await resolveAdvertisedLanHost({
networkInterfaces: opts.networkInterfaces,
runCommandWithTimeout: opts.runCommandWithTimeout,
})
: null;
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: () => pickTailnetIPv4(opts.networkInterfaces),
pickLanHost: () => pickLanIPv4(opts.networkInterfaces),
pickLanHost: () => advertisedLanHost,
});
if (bindResult) {
return bindResult;
+4
View File
@@ -4,6 +4,10 @@ export * from "../gateway/channel-status-patches.js";
export { addGatewayClientOptions, callGatewayFromCli } from "../cli/gateway-rpc.js";
export type { GatewayRpcOpts } from "../cli/gateway-rpc.js";
export { isLoopbackHost } from "../gateway/net.js";
export async function resolveAdvertisedLanHost(): Promise<string | null> {
const runtime = await import("../infra/advertised-lan-host.js");
return await runtime.resolveAdvertisedLanHost();
}
export { resolveHostedPluginSurfaceUrl } from "../gateway/hosted-plugin-surface-url.js";
export type { HostedPluginSurfaceUrlParams } from "../gateway/hosted-plugin-surface-url.js";
export {
+66
View File
@@ -14,6 +14,18 @@ const probeGatewayReachable = vi.hoisted(() =>
const waitForGatewayReachable = vi.hoisted(() =>
vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })),
);
const resolveAdvertisedControlUiLinks = vi.hoisted(() =>
vi.fn(async () => ({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
})),
);
const resolveLocalControlUiProbeLinks = vi.hoisted(() =>
vi.fn(() => ({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
})),
);
const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {}));
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
const buildGatewayInstallPlan = vi.hoisted(() =>
@@ -79,10 +91,12 @@ vi.mock("../commands/onboard-helpers.js", () => ({
formatControlUiSshHint: vi.fn(() => "ssh hint"),
openUrl: vi.fn(async () => false),
probeGatewayReachable,
resolveAdvertisedControlUiLinks,
resolveControlUiLinks: vi.fn(() => ({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
})),
resolveLocalControlUiProbeLinks,
waitForGatewayReachable,
}));
@@ -323,6 +337,16 @@ describe("finalizeSetupWizard", () => {
probeGatewayReachable.mockResolvedValue({ ok: false, detail: "offline" });
waitForGatewayReachable.mockReset();
waitForGatewayReachable.mockResolvedValue({ ok: true });
resolveAdvertisedControlUiLinks.mockReset();
resolveAdvertisedControlUiLinks.mockResolvedValue({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
});
resolveLocalControlUiProbeLinks.mockReset();
resolveLocalControlUiProbeLinks.mockReturnValue({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
});
setupWizardShellCompletion.mockClear();
healthCommand.mockReset();
healthCommand.mockResolvedValue(undefined);
@@ -439,6 +463,48 @@ describe("finalizeSetupWizard", () => {
);
});
it("advertises LAN Control UI links while probing the local gateway", async () => {
resolveAdvertisedControlUiLinks.mockResolvedValueOnce({
httpUrl: "http://10.211.55.3:18789/",
wsUrl: "ws://10.211.55.3:18789",
});
resolveLocalControlUiProbeLinks.mockReturnValue({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
const prompter = createLaterPrompter();
const args = createAdvancedFinalizeArgs({
nextConfig: {
gateway: {
bind: "lan",
},
},
prompter,
});
await finalizeSetupWizard({
...args,
opts: {
...args.opts,
skipHealth: false,
skipUi: false,
},
settings: {
...args.settings,
bind: "lan",
},
});
expect(resolveAdvertisedControlUiLinks).toHaveBeenCalledWith(
expect.objectContaining({ bind: "lan", port: 18789 }),
);
expect(waitForGatewayReachable).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
);
expectNoteContains(prompter, "http://10.211.55.3:18789/", "Control UI");
expectNoteContains(prompter, "ws://10.211.55.3:18789", "Control UI");
});
it("bounds the bootstrap hatch TUI run timeout", async () => {
vi.spyOn(fs, "access").mockResolvedValueOnce(undefined);
const select = vi.fn(async (params: { message: string }) => {
+17 -9
View File
@@ -24,7 +24,8 @@ import {
openUrl,
probeGatewayReachable,
waitForGatewayReachable,
resolveControlUiLinks,
resolveAdvertisedControlUiLinks,
resolveLocalControlUiProbeLinks,
} from "../commands/onboard-helpers.js";
import type { OnboardOptions } from "../commands/onboard-types.js";
import type { GatewayAuthConfig } from "../config/types.gateway.js";
@@ -417,7 +418,7 @@ export async function finalizeSetupWizard(
try {
if (!opts.skipHealth) {
const probeLinks = resolveControlUiLinks({
const probeLinks = resolveLocalControlUiProbeLinks({
bind: nextConfig.gateway?.bind ?? "loopback",
port: settings.port,
customBindHost: nextConfig.gateway?.customBindHost,
@@ -525,7 +526,14 @@ export async function finalizeSetupWizard(
const controlUiBasePath =
nextConfig.gateway?.controlUi?.basePath ?? baseConfig.gateway?.controlUi?.basePath;
const links = resolveControlUiLinks({
const displayLinks = await resolveAdvertisedControlUiLinks({
bind: settings.bind,
port: settings.port,
customBindHost: settings.customBindHost,
basePath: controlUiBasePath,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
const probeLinks = resolveLocalControlUiProbeLinks({
bind: settings.bind,
port: settings.port,
customBindHost: settings.customBindHost,
@@ -534,11 +542,11 @@ export async function finalizeSetupWizard(
});
const authedUrl =
settings.authMode === "token" && settings.gatewayToken && !suppressGatewayTokenOutput
? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
: links.httpUrl;
? `${displayLinks.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
: displayLinks.httpUrl;
if (opts.skipHealth || !gatewayProbe.ok) {
gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
url: probeLinks.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : "",
});
@@ -560,11 +568,11 @@ export async function finalizeSetupWizard(
await prompter.note(
[
t("wizard.finalize.webUiUrl", { url: links.httpUrl }),
t("wizard.finalize.webUiUrl", { url: displayLinks.httpUrl }),
settings.authMode === "token" && settings.gatewayToken && !suppressGatewayTokenOutput
? t("wizard.finalize.webUiWithTokenUrl", { url: authedUrl })
: undefined,
t("wizard.finalize.gatewayWsUrl", { url: links.wsUrl }),
t("wizard.finalize.gatewayWsUrl", { url: displayLinks.wsUrl }),
gatewayStatusLine,
t("wizard.finalize.controlUiDocs"),
]
@@ -811,7 +819,7 @@ export async function finalizeSetupWizard(
: undefined,
timeoutMs: HATCH_TUI_TIMEOUT_MS,
},
gatewayProbe.ok ? { gatewayUrl: links.wsUrl, authSource: "config" } : {},
gatewayProbe.ok ? { gatewayUrl: displayLinks.wsUrl, authSource: "config" } : {},
);
} finally {
restoreTerminalState("post-setup tui", { resumeStdinIfPaused: false });