Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/lib/dashboard-contract.test.ts
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");
});
});
95 changes: 95 additions & 0 deletions src/lib/dashboard-contract.ts
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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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}`;
Comment thread
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)];
}
51 changes: 51 additions & 0 deletions src/lib/dashboard-health.test.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Import the source modules instead of dist in these tests.

These assertions are tied to whatever was last built into dist, so they can miss regressions in the source under review or fail on a clean checkout where dist/ has not been generated. The same fix should be applied to src/lib/dashboard-recover.test.ts as well.

🔧 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { verifyDashboardChain } from "../../dist/lib/dashboard-health";
import { buildChain } from "../../dist/lib/dashboard-contract";
import { verifyDashboardChain } from "./dashboard-health";
import { buildChain } from "./dashboard-contract";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dashboard-health.test.ts` around lines 5 - 6, Tests import compiled
artifacts from dist; change imports to point at the source modules so tests run
against current source. Replace imports of "../../dist/lib/dashboard-health" and
"../../dist/lib/dashboard-contract" with their source equivalents (importing
verifyDashboardChain and buildChain from the src modules), and apply the same
change in the companion test file that references dashboard-recover to ensure
both tests import source modules rather than dist artifacts.


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");
});
});
71 changes: 71 additions & 0 deletions src/lib/dashboard-health.ts
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] ?? "?"}` };
}
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the full CORS contract, not just accessUrl.

buildChain() publishes chain.corsOrigins, but this check only looks for new URL(chain.accessUrl).origin. If allowedOrigins is missing http://127.0.0.1:${chain.port}, the verifier can still report healthy even though the local control UI URL generated by buildControlUiUrls() will fail CORS. ``

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
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dashboard-health.ts` around lines 50 - 58, verifyCors currently only
checks new URL(chain.accessUrl).origin but buildChain publishes
chain.corsOrigins and the verifier must ensure all control UI origins are
allowed; update verifyCors to read config.gateway.controlUi.allowedOrigins and
verify that every origin in chain.corsOrigins (falling back to [new
URL(chain.accessUrl).origin] if corsOrigins is absent) is present in
allowedOrigins, returning ok: true only when all are included and ok: false with
a detail listing the missing origins when any are absent; reference verifyCors,
chain.corsOrigins, buildChain and buildControlUiUrls to locate the relevant
logic.

}

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 };
}
59 changes: 59 additions & 0 deletions src/lib/dashboard-recover.test.ts
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);
});
});
Loading
Loading