Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
22 changes: 11 additions & 11 deletions .github/actions/stage-native-podman-e2e-toolchains/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ runs:
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
SOURCE_RUN_ID: "33211526093"
SOURCE_RUN_ID: "36534476155"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
run: |
set -euo pipefail
verify_artifact() {
Expand All @@ -39,22 +39,22 @@ runs:
END { exit found ? 0 : 1 }'
}
verify_artifact \
10385514729 \
native-runtime-podman-toolchain-amd64 \
sha256:1f73b66ef2a70862e860b0897e191ed05b8976d3b10b0f84b41e76d8f1cb58ba
11018865557 \
native-podman-e2e-toolchain-amd64 \
sha256:673dbb608ba6595e76ba8479d0e0680b41340546101c7938f28160470846fa8b
verify_artifact \
10386378988 \
native-runtime-podman-toolchain-arm64 \
sha256:470487563f801c77b95f1665510fe1a11894c913e2787ebfb9994f56bac8cf56
11019020387 \
native-podman-e2e-toolchain-arm64 \
sha256:c6a23c1132b8c3011252035dc2dae73e2ed47ff97151ba41380e3b07acfe6cdc

- name: Download immutable native Podman amd64 toolchain
if: ${{ inputs.enabled == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ inputs.github-token }}
repository: NVIDIA/NemoClaw
run-id: "33211526093"
artifact-ids: "10385514729"
run-id: "36534476155"
artifact-ids: "11018865557"
path: ${{ runner.temp }}/native-podman-e2e-toolchain-amd64

- name: Download immutable native Podman arm64 toolchain
Expand All @@ -63,8 +63,8 @@ runs:
with:
github-token: ${{ inputs.github-token }}
repository: NVIDIA/NemoClaw
run-id: "33211526093"
artifact-ids: "10386378988"
run-id: "36534476155"
artifact-ids: "11019020387"
path: ${{ runner.temp }}/native-podman-e2e-toolchain-arm64

- name: Publish native Podman amd64 toolchain for this run
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ jobs:
# Publish immutable source-run toolchains before candidate checkout or
# candidate-controlled workspace preparation can execute on this runner.
- name: Stage immutable native Podman E2E toolchains
uses: NVIDIA/NemoClaw/.github/actions/stage-native-podman-e2e-toolchains@8d7409d66a0e664829f9ddab177aa8460974291f
uses: NVIDIA/NemoClaw/.github/actions/stage-native-podman-e2e-toolchains@6b0acb521f2644fb48f8a735fde875ff798d9047
with:
enabled: ${{ contains(format(',{0},', inputs.gateway_runtimes || inputs.gateway_runtime || 'docker'), ',podman,') && 'true' || 'false' }}
github-token: ${{ github.token }}
Expand Down
4 changes: 3 additions & 1 deletion src/commands/sandbox/dashboard-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import type { SandboxEntry } from "../../lib/state/registry";

type DashboardUrlRuntimeBridge = {
fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => Promise<string | null>;
getSandbox: (sandboxName: string) => Pick<SandboxEntry, "agent" | "dashboardPort"> | null;
getSandbox: (
sandboxName: string,
) => Pick<SandboxEntry, "agent" | "dashboardPort" | "dashboardExternalUrl"> | null;
getAccessUrl?: (port: number) => string | null;
};

Expand Down
46 changes: 46 additions & 0 deletions src/lib/dashboard-url-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,52 @@ describe("dashboard-url command helpers", () => {
expect(sinks.err).toEqual([]);
});

it("prints the persisted external dashboard URL for a session-auth agent (#11439)", async () => {
const sinks = makeSinks();

await runDashboardUrlCommand(
"hermes",
{ quiet: true },
{
fetchToken: () => null,
getSandbox: () => ({
agent: "hermes",
dashboardPort: 18789,
dashboardExternalUrl: "https://dash.example.com:18789",
}),
getAgentDashboardAuth: () => "session",
// A host access URL must not override the persisted external origin.
getAccessUrl: () => "http://172.22.1.1:18789",
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toEqual(["https://dash.example.com:18789/"]);
});

it("embeds the token in the persisted external dashboard URL for token-auth agents (#11439)", async () => {
const sinks = makeSinks();

await runDashboardUrlCommand(
"agent-ui",
{ quiet: true },
{
fetchToken: () => "agent-token",
getSandbox: () => ({
agent: "agent-ui",
dashboardPort: 19001,
dashboardExternalUrl: "https://dash.example.com:19001",
}),
getAgentDashboardAuth: () => "url_token",
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toEqual(["https://dash.example.com:19001/#token=agent-token"]);
});

it("fetches a token for non-OpenClaw agents with token-auth dashboards", async () => {
const sinks = makeSinks();
const fetchToken = vi.fn(() => "agent-token");
Expand Down
25 changes: 21 additions & 4 deletions src/lib/dashboard-url-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export interface DashboardUrlCommandDeps {
/** Pull gateway.auth.token from the sandbox config (host-side helper). */
fetchToken: (sandboxName: string) => Promise<string | null> | string | null;
/** Read sandbox metadata such as agent name and recorded dashboard port. */
getSandbox?: (sandboxName: string) => Pick<SandboxEntry, "agent" | "dashboardPort"> | null;
getSandbox?: (
sandboxName: string,
) => Pick<SandboxEntry, "agent" | "dashboardPort" | "dashboardExternalUrl"> | null;
/** Resolve the browser-facing dashboard base URL for this host, when known. */
getAccessUrl?: (port: number) => string | null;
/** Resolve a registered agent's dashboard auth contract. */
Expand Down Expand Up @@ -65,6 +67,21 @@ function resolveDashboardPort(sandbox: Pick<SandboxEntry, "dashboardPort"> | nul
: DASHBOARD_PORT;
}

/**
* Prefer the persisted external dashboard URL (the browser-facing HTTPS reverse
* proxy origin resolved from `CHAT_UI_URL` at onboard time) over any
* host-derived access URL, falling back to the loopback form only when no
* external origin was configured (#11439).
*/
function resolveDashboardBaseUrl(
sandbox: Pick<SandboxEntry, "dashboardExternalUrl"> | null,
accessUrl: string | null,
): string | null {
const external = sandbox?.dashboardExternalUrl;
if (typeof external === "string" && external.length > 0) return external;
return accessUrl;
}

export function buildDashboardUrl(
token: string,
port = DASHBOARD_PORT,
Expand Down Expand Up @@ -141,7 +158,7 @@ export async function runDashboardUrlCommand(
for (const line of hint) log(line);
};

let sandbox: Pick<SandboxEntry, "agent" | "dashboardPort"> | null = null;
let sandbox: Pick<SandboxEntry, "agent" | "dashboardPort" | "dashboardExternalUrl"> | null = null;
if (deps.getSandbox) {
try {
sandbox = deps.getSandbox(sandboxName);
Expand Down Expand Up @@ -170,7 +187,7 @@ export async function runDashboardUrlCommand(
}
if (dashboardAuth === "session" || dashboardAuth === "none") {
const port = resolveDashboardPort(sandbox);
const accessUrl = deps.getAccessUrl?.(port) ?? null;
const accessUrl = resolveDashboardBaseUrl(sandbox, deps.getAccessUrl?.(port) ?? null);
const url = buildPlainDashboardUrl(port, accessUrl ?? undefined);
if (options.quiet) {
log(url);
Expand All @@ -197,7 +214,7 @@ export async function runDashboardUrlCommand(
}

const port = resolveDashboardPort(sandbox);
const accessUrl = deps.getAccessUrl?.(port) ?? null;
const accessUrl = resolveDashboardBaseUrl(sandbox, deps.getAccessUrl?.(port) ?? null);
const url = buildDashboardUrl(token, port, accessUrl ?? undefined);
if (options.quiet) {
log(url);
Expand Down
74 changes: 73 additions & 1 deletion src/lib/dashboard/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@

import { describe, expect, it } from "vitest";

import { rebindLoopbackDashboardUrlPort } from "./url";
import {
isValidDashboardExternalUrl,
rebindLoopbackDashboardUrlPort,
resolveExternalDashboardUrl,
resolveExternalDashboardUrlForPort,
} from "./url";

describe("rebindLoopbackDashboardUrlPort", () => {
it.each([
Expand All @@ -14,3 +19,70 @@ describe("rebindLoopbackDashboardUrlPort", () => {
expect(rebindLoopbackDashboardUrlPort(input, 29_443)).toBe(expected);
});
});

describe("resolveExternalDashboardUrl (#11439)", () => {
it("returns a genuine external origin, trimming a trailing slash", () => {
expect(resolveExternalDashboardUrl("https://dash.example.com:18789/")).toBe(
"https://dash.example.com:18789",
);
expect(resolveExternalDashboardUrl("https://dash.example.com:18789")).toBe(
"https://dash.example.com:18789",
);
});

it("returns null for loopback dashboard URLs (loopback reported from dashboardPort)", () => {
expect(resolveExternalDashboardUrl("http://127.0.0.1:18789")).toBeNull();
expect(resolveExternalDashboardUrl("http://localhost:18789/")).toBeNull();
});

it("returns null for empty or malformed input", () => {
expect(resolveExternalDashboardUrl(null)).toBeNull();
expect(resolveExternalDashboardUrl(undefined)).toBeNull();
expect(resolveExternalDashboardUrl("")).toBeNull();
expect(resolveExternalDashboardUrl("not a url")).toBeNull();
});

it("returns null for non-http(s), credentialed, or over-long origins so a persisted value can be read back", () => {
// These would otherwise be rejected at registry read time and brick list/status.
expect(resolveExternalDashboardUrl("ws://proxy.example.com:18789")).toBeNull();
expect(resolveExternalDashboardUrl("ftp://proxy.example.com:18789")).toBeNull();
expect(resolveExternalDashboardUrl("https://user:pass@dash.example.com:18789")).toBeNull();
expect(resolveExternalDashboardUrl(`https://dash.example.com/${"a".repeat(3000)}`)).toBeNull();
});
});

describe("isValidDashboardExternalUrl shared write/read predicate (#11439)", () => {
it("accepts absolute http(s) origins without credentials", () => {
expect(isValidDashboardExternalUrl("https://dash.example.com:18789")).toBe(true);
expect(isValidDashboardExternalUrl("http://dash.example.com/path")).toBe(true);
});

it("rejects non-http(s), credentialed, control-char, and over-long values", () => {
expect(isValidDashboardExternalUrl("ws://dash.example.com:18789")).toBe(false);
expect(isValidDashboardExternalUrl("https://user:pass@dash.example.com")).toBe(false);
expect(isValidDashboardExternalUrl("https://dash.example.com/\u0000")).toBe(false);
expect(isValidDashboardExternalUrl(`https://dash.example.com/${"a".repeat(3000)}`)).toBe(false);
expect(isValidDashboardExternalUrl("")).toBe(false);
expect(isValidDashboardExternalUrl("dash.example.com:18789")).toBe(false);
});
});

describe("resolveExternalDashboardUrlForPort (#11439)", () => {
it("rebinds the CHAT_UI_URL origin to the effective dashboard port", () => {
expect(resolveExternalDashboardUrlForPort("https://dash.example.com:18789", 18790)).toBe(
"https://dash.example.com:18790",
);
});

it("adds a scheme to a bare host:port origin before rebinding", () => {
expect(resolveExternalDashboardUrlForPort("dash.example.com:18789", 18790)).toBe(
"http://dash.example.com:18790",
);
});

it("returns null for a loopback or missing origin", () => {
expect(resolveExternalDashboardUrlForPort("http://127.0.0.1:18789", 18790)).toBeNull();
expect(resolveExternalDashboardUrlForPort(null, 18790)).toBeNull();
expect(resolveExternalDashboardUrlForPort("", 18790)).toBeNull();
});
});
75 changes: 75 additions & 0 deletions src/lib/dashboard/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,78 @@ export function rebindLoopbackDashboardUrlPort(value: string, port: number): str
parsed.port = String(port);
return parsed.toString();
}

const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u;

/**
* A persistable external dashboard URL must be an absolute http(s) URL with a
* host and no embedded credentials, matching what onboarding derives from
* `CHAT_UI_URL`. This is the single source of truth shared by the writer
* (`resolveExternalDashboardUrl`) and the registry read-side validator so a
* value that persists can always be read back (#11439). Userinfo is rejected so
* an operator-facing address is never a secret; control characters and
* unbounded length are rejected as registry-hardening.
*/
export function isValidDashboardExternalUrl(value: string): boolean {
if (value.length === 0 || value.length > 2048 || CONTROL_CHARACTER.test(value)) return false;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
parsed.hostname.length > 0 &&
parsed.username === "" &&
parsed.password === ""
);
}

/**
* Resolve the external dashboard URL to persist from the operator's
* `CHAT_UI_URL`, rebinding its port to the effective dashboard port. Returns
* null when no external origin is configured, the origin is loopback, or the
* value is not a valid persistable external origin. Shared by the fresh-create,
* reuse/resume, and OpenClaw-forward persistence paths so all record the same
* value (#11439).
*/
export function resolveExternalDashboardUrlForPort(
chatUiUrlEnv: string | null | undefined,
effectivePort: number,
): string | null {
if (!chatUiUrlEnv) return null;
const normalized = chatUiUrlEnv.includes("://") ? chatUiUrlEnv : `http://${chatUiUrlEnv}`;
let rebound: string;
try {
const parsed = new URL(normalized);
parsed.port = String(effectivePort);
rebound = parsed.toString();
} catch {
return null;
}
return resolveExternalDashboardUrl(rebound);
}

/**
* Return the browser-facing external dashboard URL to persist for a sandbox, or
* null when the resolved dashboard URL is a plain loopback address or is not a
* valid persistable external origin. A loopback URL adds nothing over the
* persisted `dashboardPort`, so only a genuine external origin (e.g. the HTTPS
* reverse proxy behind `CHAT_UI_URL`) is worth recording so `status`,
* `dashboard-url`, and `list` can report it later (#11439). The value is
* validated with the same predicate the registry read-side enforces, so a
* persisted value can always be read back. Malformed or non-http(s) input
* yields null.
*/
export function resolveExternalDashboardUrl(chatUiUrl: string | null | undefined): string | null {
if (!chatUiUrl) return null;
const normalized = chatUiUrl.replace(/\/$/, "");
if (!isValidDashboardExternalUrl(normalized)) return null;
try {
if (isLoopbackDashboardUrl(normalized)) return null;
} catch {
return null;
}
return normalized;
}
Loading
Loading