-
Notifications
You must be signed in to change notification settings - Fork 3.1k
refactor(cli): extract dashboard delivery chain into contract/health/recover modules #2398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, it, expect } from "vitest"; | ||
| import { buildChain, buildControlUiUrls } from "../../dist/lib/dashboard-contract"; | ||
|
|
||
| describe("buildChain", () => { | ||
| it("returns default loopback chain with no arguments", () => { | ||
| const c = buildChain(); | ||
| expect(c).toMatchObject({ | ||
| accessUrl: "http://127.0.0.1:18789", forwardTarget: "18789", | ||
| healthEndpoint: "/health", port: 18789, bindAddress: "127.0.0.1", | ||
| }); | ||
| expect(c.corsOrigins).toEqual(["http://127.0.0.1:18789"]); | ||
| }); | ||
|
|
||
| it("preserves custom port from loopback URL", () => { | ||
| const c = buildChain({ chatUiUrl: "http://127.0.0.1:19000" }); | ||
| expect(c.port).toBe(19000); | ||
| expect(c.forwardTarget).toBe("19000"); | ||
| }); | ||
|
|
||
| it("binds to 0.0.0.0 for non-loopback URL and includes both CORS origins", () => { | ||
| const c = buildChain({ chatUiUrl: "https://my-brev-host.example.com:18789" }); | ||
| expect(c.forwardTarget).toBe("0.0.0.0:18789"); | ||
| expect(c.bindAddress).toBe("0.0.0.0"); | ||
| expect(c.corsOrigins[0]).toBe("http://127.0.0.1:18789"); | ||
| expect(c.corsOrigins).toContain("https://my-brev-host.example.com:18789"); | ||
| }); | ||
|
|
||
| it("uses WSL host address and binds to 0.0.0.0", () => { | ||
| const c = buildChain({ isWsl: true, wslHostAddress: "172.24.240.1" }); | ||
| expect(c.forwardTarget).toBe("0.0.0.0:18789"); | ||
| expect(c.accessUrl).toBe("http://172.24.240.1:18789"); | ||
| expect(c.corsOrigins).toContain("http://172.24.240.1:18789"); | ||
| }); | ||
|
|
||
| it("respects explicit port override", () => { | ||
| expect(buildChain({ port: 19000 }).port).toBe(19000); | ||
| }); | ||
|
|
||
| it("treats empty/invalid chatUiUrl as default without throwing", () => { | ||
| expect(buildChain({ chatUiUrl: "" }).port).toBe(18789); | ||
| expect(buildChain({ chatUiUrl: "not-a-url" }).port).toBe(18789); | ||
| }); | ||
|
|
||
| it("returns port-only forward for IPv6 and localhost", () => { | ||
| expect(buildChain({ chatUiUrl: "http://[::1]:18789" }).forwardTarget).toBe("18789"); | ||
| expect(buildChain({ chatUiUrl: "http://localhost:18789" }).forwardTarget).toBe("18789"); | ||
| }); | ||
|
|
||
| it("canonicalizes schemeless non-loopback URLs", () => { | ||
| const c = buildChain({ chatUiUrl: "remote-host:18789" }); | ||
| expect(c.accessUrl).toBe("http://remote-host:18789"); | ||
| expect(c.forwardTarget).toBe("0.0.0.0:18789"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("buildControlUiUrls", () => { | ||
| it("builds URL with encoded token hash", () => { | ||
| expect(buildControlUiUrls("my-token")).toEqual(["http://127.0.0.1:18789/#token=my-token"]); | ||
| }); | ||
|
|
||
| it("builds URL without token", () => { | ||
| expect(buildControlUiUrls(null)).toEqual(["http://127.0.0.1:18789/"]); | ||
| }); | ||
|
|
||
| it("includes non-loopback chatUiUrl as second entry", () => { | ||
| const urls = buildControlUiUrls("tok", 18789, "https://my-dashboard.example.com"); | ||
| expect(urls).toHaveLength(2); | ||
| expect(urls[1]).toContain("my-dashboard.example.com"); | ||
| }); | ||
|
|
||
| it("deduplicates and ignores non-http/empty chatUiUrl", () => { | ||
| expect(buildControlUiUrls(null, 18789, "http://127.0.0.1:18789")).toHaveLength(1); | ||
| expect(buildControlUiUrls("tok", 18789, "ftp://x.com")).toHaveLength(1); | ||
| expect(buildControlUiUrls("tok", 18789, " ")).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("uses configured port", () => { | ||
| expect(buildControlUiUrls("t", 19000)).toEqual(["http://127.0.0.1:19000/#token=t"]); | ||
| }); | ||
|
|
||
| it("encodes special characters in tokens", () => { | ||
| const urls = buildControlUiUrls("a=b&c"); | ||
| expect(urls[0]).toContain("#token=a%3Db%26c"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /** | ||
| * Dashboard Delivery Contract — single source of truth for dashboard config. | ||
| * Pure functions — no I/O, no process.env reads. | ||
| */ | ||
|
|
||
| import { DASHBOARD_PORT } from "./ports"; | ||
| import { isLoopbackHostname } from "./url-utils"; | ||
|
|
||
| export interface PlatformHints { | ||
| chatUiUrl?: string; | ||
| port?: number; | ||
| isWsl?: boolean; | ||
| wslHostAddress?: string | null; | ||
| } | ||
|
|
||
| export interface DashboardDeliveryChain { | ||
| accessUrl: string; | ||
| corsOrigins: string[]; | ||
| forwardTarget: string; | ||
| healthEndpoint: string; | ||
| port: number; | ||
| bindAddress: string; | ||
| } | ||
|
|
||
| function ensureScheme(raw: string): string { | ||
| return /^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`; | ||
| } | ||
|
|
||
| function resolvePort(chatUiUrl: string, defaultPort: number): number { | ||
| const raw = String(chatUiUrl || "").trim(); | ||
| if (!raw) return defaultPort; | ||
| try { | ||
| const parsed = new URL(ensureScheme(raw)); | ||
| return parsed.port ? Number(parsed.port) : defaultPort; | ||
| } catch { | ||
| const m = raw.match(/:(\d{2,5})(?:[/?#]|$)/); | ||
| return m ? Number(m[1]) : defaultPort; | ||
| } | ||
| } | ||
|
|
||
| function isLoopbackUrl(chatUiUrl: string): boolean { | ||
| const raw = String(chatUiUrl || "").trim(); | ||
| if (!raw) return true; | ||
| try { | ||
| return isLoopbackHostname(new URL(ensureScheme(raw)).hostname); | ||
| } catch { | ||
| return /localhost|::1|127(?:\.\d{1,3}){3}/i.test(raw); | ||
| } | ||
| } | ||
|
|
||
| /** Build the complete dashboard delivery chain from platform hints. */ | ||
| export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { | ||
| const h = hints || {}; | ||
| const chatUiUrl = String(h.chatUiUrl || "").trim(); | ||
| const rawPort = h.port ?? resolvePort(chatUiUrl, DASHBOARD_PORT); | ||
| const port = Number.isFinite(rawPort) && rawPort >= 1 && rawPort <= 65535 ? rawPort : DASHBOARD_PORT; | ||
| const hasNonLoopbackUrl = chatUiUrl !== "" && !isLoopbackUrl(chatUiUrl); | ||
|
|
||
| let accessUrl: string; | ||
| if (hasNonLoopbackUrl) { | ||
| accessUrl = ensureScheme(chatUiUrl); | ||
| } else if (h.isWsl && h.wslHostAddress) { | ||
| accessUrl = `http://${h.wslHostAddress}:${port}`; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } else { | ||
| accessUrl = `http://127.0.0.1:${port}`; | ||
| } | ||
|
|
||
| const forwardTarget = h.isWsl || hasNonLoopbackUrl ? `0.0.0.0:${port}` : String(port); | ||
| const bindAddress = forwardTarget.includes(":") ? "0.0.0.0" : "127.0.0.1"; | ||
| const loopbackOrigin = `http://127.0.0.1:${port}`; | ||
| const accessOrigin = (() => { try { return new URL(accessUrl).origin; } catch { return null; } })(); | ||
| const corsOrigins = accessOrigin && accessOrigin !== loopbackOrigin | ||
| ? [loopbackOrigin, accessOrigin] : [loopbackOrigin]; | ||
|
|
||
| return { accessUrl, corsOrigins, forwardTarget, healthEndpoint: "/health", port, bindAddress }; | ||
| } | ||
|
|
||
| /** Build the list of control UI URLs. Callers pass chatUiUrl explicitly. */ | ||
| export function buildControlUiUrls( | ||
| token: string | null = null, | ||
| port: number = DASHBOARD_PORT, | ||
| chatUiUrl?: string, | ||
| ): string[] { | ||
| const hash = token ? `#token=${encodeURIComponent(token)}` : ""; | ||
| const baseUrl = `http://127.0.0.1:${port}`; | ||
| const urls = [`${baseUrl}/${hash}`]; | ||
| const chatUi = (chatUiUrl || "").trim().replace(/\/$/, ""); | ||
| if (chatUi && /^https?:\/\//i.test(chatUi) && chatUi !== baseUrl) { | ||
| urls.push(`${chatUi}/${hash}`); | ||
| } | ||
| return [...new Set(urls)]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,51 @@ | ||||||||||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||||||||||
| // SPDX-License-Identifier: Apache-2.0 | ||||||||||
|
|
||||||||||
| import { describe, it, expect } from "vitest"; | ||||||||||
| import { verifyDashboardChain } from "../../dist/lib/dashboard-health"; | ||||||||||
| import { buildChain } from "../../dist/lib/dashboard-contract"; | ||||||||||
|
Comment on lines
+5
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Import the source modules instead of These assertions are tied to whatever was last built into 🔧 Proposed fix-import { verifyDashboardChain } from "../../dist/lib/dashboard-health";
-import { buildChain } from "../../dist/lib/dashboard-contract";
+import { verifyDashboardChain } from "./dashboard-health";
+import { buildChain } from "./dashboard-contract";📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| const chain = buildChain(); | ||||||||||
|
|
||||||||||
| function deps(overrides = {}) { | ||||||||||
| return { | ||||||||||
| executeSandboxCommand: () => ({ status: 0, stdout: "200" }), | ||||||||||
| captureForwardList: () => "my-sandbox 127.0.0.1 18789 12345 running", | ||||||||||
| downloadSandboxConfig: () => ({ gateway: { controlUi: { allowedOrigins: ["http://127.0.0.1:18789"] } } }), | ||||||||||
| ...overrides, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| describe("verifyDashboardChain", () => { | ||||||||||
| it("reports healthy when all links ok", () => { | ||||||||||
| const r = verifyDashboardChain("my-sandbox", chain, deps()); | ||||||||||
| expect(r.healthy).toBe(true); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("treats 401 as alive (fixes #2342)", () => { | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ executeSandboxCommand: () => ({ status: 0, stdout: "401" }) })).links.gateway.ok).toBe(true); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("reports gateway down on 000 or null", () => { | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ executeSandboxCommand: () => ({ status: 0, stdout: "000" }) })).links.gateway.ok).toBe(false); | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ executeSandboxCommand: () => null })).links.gateway.ok).toBe(false); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("reports forward missing or conflicting", () => { | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ captureForwardList: () => null })).links.forward.ok).toBe(false); | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ captureForwardList: () => "other 127.0.0.1 18789 1 running" })).links.forward.detail).toContain("other"); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("reports CORS issues", () => { | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ downloadSandboxConfig: () => null })).links.cors.ok).toBe(false); | ||||||||||
| expect(verifyDashboardChain("my-sandbox", chain, deps({ downloadSandboxConfig: () => ({ gateway: { controlUi: { allowedOrigins: [] } } }) })).links.cors.ok).toBe(false); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("concatenates all failures in diagnosis", () => { | ||||||||||
| const r = verifyDashboardChain("my-sandbox", chain, deps({ executeSandboxCommand: () => ({ status: 0, stdout: "000" }), captureForwardList: () => null, downloadSandboxConfig: () => null })); | ||||||||||
| expect(r.healthy).toBe(false); | ||||||||||
| expect(r.diagnosis).toContain("gateway"); | ||||||||||
| expect(r.diagnosis).toContain("forward"); | ||||||||||
| expect(r.diagnosis).toContain("cors"); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /** | ||
| * Dashboard chain health verification — checks all links of the delivery | ||
| * chain and produces a per-link diagnosis. All deps injected. | ||
| */ | ||
|
|
||
| import type { DashboardDeliveryChain } from "./dashboard-contract"; | ||
|
|
||
| export interface DashboardHealthDeps { | ||
| executeSandboxCommand: (name: string, script: string) => { status: number; stdout: string } | null; | ||
| captureForwardList: () => string | null; | ||
| downloadSandboxConfig: (name: string) => { gateway?: { controlUi?: { allowedOrigins?: string[] } } } | null; | ||
| } | ||
|
|
||
| export interface LinkStatus { ok: boolean; detail: string } | ||
|
|
||
| export interface ChainStatus { | ||
| healthy: boolean; | ||
| links: { gateway: LinkStatus; forward: LinkStatus; cors: LinkStatus }; | ||
| diagnosis: string; | ||
| } | ||
|
|
||
| const ALIVE_CODES = new Set(["200", "401"]); | ||
|
|
||
| function verifyGateway(name: string, chain: DashboardDeliveryChain, deps: DashboardHealthDeps): LinkStatus { | ||
| const result = deps.executeSandboxCommand(name, | ||
| `curl -so /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${chain.port}${chain.healthEndpoint} 2>/dev/null || echo 000`); | ||
| if (!result) return { ok: false, detail: "sandbox unreachable" }; | ||
| const s = result.stdout.trim(); | ||
| return ALIVE_CODES.has(s) ? { ok: true, detail: `HTTP ${s}` } : { ok: false, detail: `HTTP ${s}` }; | ||
| } | ||
|
|
||
| function verifyForward(name: string, chain: DashboardDeliveryChain, deps: DashboardHealthDeps): LinkStatus { | ||
| const output = deps.captureForwardList(); | ||
| if (!output) return { ok: false, detail: `no forward for port ${chain.port}` }; | ||
| const portStr = String(chain.port); | ||
| // openshell forward list columns: SANDBOX BIND PORT PID STATUS | ||
| for (const line of output.split("\n")) { | ||
| const p = line.trim().split(/\s+/); | ||
| if (p[2] === portStr) { | ||
| if (p[0] !== name) return { ok: false, detail: `port ${portStr} owned by ${p[0]}` }; | ||
| const status = (p[4] ?? "").toLowerCase(); | ||
| if (status && status !== "running") return { ok: false, detail: `forward ${status} (PID ${p[3] ?? "?"})` }; | ||
| return { ok: true, detail: `PID ${p[3] ?? "?"} on ${p[1] ?? "?"}` }; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| return { ok: false, detail: `no forward for port ${chain.port}` }; | ||
| } | ||
|
|
||
| function verifyCors(name: string, chain: DashboardDeliveryChain, deps: DashboardHealthDeps): LinkStatus { | ||
| const config = deps.downloadSandboxConfig(name); | ||
| if (!config) return { ok: false, detail: "could not download openclaw.json" }; | ||
| const origins = config.gateway?.controlUi?.allowedOrigins ?? []; | ||
| let accessOrigin: string | null; | ||
| try { accessOrigin = new URL(chain.accessUrl).origin; } catch { return { ok: false, detail: "bad accessUrl" }; } | ||
| return origins.includes(accessOrigin) | ||
| ? { ok: true, detail: `allowedOrigins includes ${accessOrigin}` } | ||
| : { ok: false, detail: `missing ${accessOrigin} in allowedOrigins` }; | ||
|
Comment on lines
+52
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate the full CORS contract, not just
Suggested fix function verifyCors(name: string, chain: DashboardDeliveryChain, deps: DashboardHealthDeps): LinkStatus {
const config = deps.downloadSandboxConfig(name);
if (!config) return { ok: false, detail: "could not download openclaw.json" };
const origins = config.gateway?.controlUi?.allowedOrigins ?? [];
- let accessOrigin: string | null;
- try { accessOrigin = new URL(chain.accessUrl).origin; } catch { return { ok: false, detail: "bad accessUrl" }; }
- return origins.includes(accessOrigin)
- ? { ok: true, detail: `allowedOrigins includes ${accessOrigin}` }
- : { ok: false, detail: `missing ${accessOrigin} in allowedOrigins` };
+ const missing = chain.corsOrigins.filter((origin) => !origins.includes(origin));
+ return missing.length === 0
+ ? { ok: true, detail: `allowedOrigins includes ${chain.corsOrigins.join(", ")}` }
+ : { ok: false, detail: `missing ${missing.join(", ")} in allowedOrigins` };
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| export function verifyDashboardChain(name: string, chain: DashboardDeliveryChain, deps: DashboardHealthDeps): ChainStatus { | ||
| const gateway = verifyGateway(name, chain, deps); | ||
| const forward = verifyForward(name, chain, deps); | ||
| const cors = verifyCors(name, chain, deps); | ||
| const links = { gateway, forward, cors }; | ||
| const healthy = gateway.ok && forward.ok && cors.ok; | ||
| const diagnosis = Object.entries(links).filter(([, l]) => !l.ok).map(([n, l]) => `${n}: ${l.detail}`).join("; "); | ||
| return { healthy, links, diagnosis }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { recoverDashboardChain } from "../../dist/lib/dashboard-recover"; | ||
| import { buildChain } from "../../dist/lib/dashboard-contract"; | ||
|
|
||
| const chain = buildChain(); | ||
|
|
||
| function deps(overrides = {}) { | ||
| return { | ||
| executeSandboxCommand: vi.fn().mockReturnValue({ status: 0, stdout: "200" }), | ||
| captureForwardList: vi.fn().mockReturnValue("my-sandbox 127.0.0.1 18789 12345 running"), | ||
| downloadSandboxConfig: vi.fn().mockReturnValue({ gateway: { controlUi: { allowedOrigins: ["http://127.0.0.1:18789"] } } }), | ||
| restartGateway: vi.fn().mockReturnValue(true), | ||
| stopForward: vi.fn(), startForward: vi.fn(), | ||
| getSessionAgent: vi.fn().mockReturnValue(null), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe("recoverDashboardChain", () => { | ||
| it("no-ops when healthy", () => { | ||
| const d = deps(); | ||
| expect(recoverDashboardChain("my-sandbox", chain, d).attempted).toBe(false); | ||
| expect(d.restartGateway).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("restarts gateway and checks return value", () => { | ||
| let n = 0; | ||
| const d = deps({ executeSandboxCommand: vi.fn(() => ({ status: 0, stdout: ++n <= 1 ? "000" : "200" })) }); | ||
| const r = recoverDashboardChain("my-sandbox", chain, d); | ||
| expect(r.attempted).toBe(true); | ||
| expect(r.actions).toContain("restarted gateway"); | ||
| expect(d.restartGateway).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("reports gateway restart failure", () => { | ||
| const d = deps({ | ||
| executeSandboxCommand: vi.fn().mockReturnValue({ status: 0, stdout: "000" }), | ||
| restartGateway: vi.fn().mockReturnValue(false), | ||
| }); | ||
| const r = recoverDashboardChain("my-sandbox", chain, d); | ||
| expect(r.actions).toContain("gateway restart failed"); | ||
| }); | ||
|
|
||
| it("re-establishes missing forward", () => { | ||
| let n = 0; | ||
| const d = deps({ captureForwardList: vi.fn(() => ++n <= 1 ? null : "my-sandbox 127.0.0.1 18789 12345 running") }); | ||
| const r = recoverDashboardChain("my-sandbox", chain, d); | ||
| expect(r.actions).toContain("re-established forward"); | ||
| expect(d.stopForward).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("diagnoses CORS mismatch without auto-fixing", () => { | ||
| const d = deps({ downloadSandboxConfig: vi.fn().mockReturnValue({ gateway: { controlUi: { allowedOrigins: [] } } }) }); | ||
| expect(recoverDashboardChain("my-sandbox", chain, d).actions.some(a => a.includes("CORS"))).toBe(true); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.