mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
resolveGatewayPort,
|
||||
resolveTailnetHostWithRunner,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
export {
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
runPluginCommandWithTimeout,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function scanStatus(
|
||||
includeChannelSecretTargets: isFullScan ? undefined : false,
|
||||
fetchGitUpdate: isFullScan,
|
||||
includeRegistryUpdate: isFullScan,
|
||||
includeAdvertisedControlUiLinks: true,
|
||||
progress,
|
||||
labels: {
|
||||
loadingConfig: "Loading config…",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user