Skip to content
Closed
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
17 changes: 10 additions & 7 deletions src/lib/agent-onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const uiAgent = {
// Regression fixture for issue #2078 — matches the text a user sees when
// no token is available and prevents the wording from regressing to
// something that implies port 8642 is a browser UI.
const buildUrlsLoopback = (token: string | null, port: number): string[] => {
const hash = token ? `#token=${token}` : "";
const buildUrlsLoopback = (token: string | null, port: number, forDisplay?: boolean): string[] => {
const hash = token ? (forDisplay ? "#token=REDACTED" : `#token=${token}`) : "";
return [`http://127.0.0.1:${port}/${hash}`];
};

Expand Down Expand Up @@ -76,17 +76,20 @@ describe("printDashboardUi — regression for #2078 (port 8642 is not a chat UI)
expect(noteSpy).not.toHaveBeenCalled();
});

it("prints tokenized URL with save-now warning for UI-kind agents", () => {
it("prints redacted token URL with retrieval hint for UI-kind agents", () => {
printDashboardUi("sandbox-y", "tok", uiAgent, {
note: noteSpy,
buildControlUiUrls: buildUrlsLoopback,
});

const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
expect(output).toContain(
"Ficticious UI (tokenized URL; treat it like a password; save it now - it will not be printed again)",
);
expect(output).toContain("Ficticious UI");
expect(output).not.toContain("tokenized URL; treat it like a password");
expect(output).toContain("Port 19000 must be forwarded before opening this URL.");
expect(output).toContain("http://127.0.0.1:19000/#token=tok");
// Token should be redacted in display output
expect(output).toContain("http://127.0.0.1:19000/#token=REDACTED");
expect(output).not.toContain("#token=tok");
// Should print instructions for retrieving the full token
expect(output).toContain("To get the full token: nemoclaw sandbox-y connect");
});
});
15 changes: 9 additions & 6 deletions src/lib/agent-onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,12 @@ export function getAgentDashboardInfo(agent: AgentDefinition): {
* back to the original UI-style output used by browser dashboards.
*/
export function printDashboardUi(
_sandboxName: string,
sandboxName: string,
token: string | null,
agent: AgentDefinition,
deps: {
note: (msg: string) => void;
buildControlUiUrls: (token: string | null, port: number) => string[];
buildControlUiUrls: (token: string | null, port: number, forDisplay?: boolean) => string[];
},
): void {
const info = getAgentDashboardInfo(agent);
Expand All @@ -252,13 +252,16 @@ export function printDashboardUi(
}

if (token) {
console.log(
` ${info.displayName} ${label} (tokenized URL; treat it like a password; save it now - it will not be printed again)`,
);
console.log(` ${info.displayName} ${label}`);
console.log(` Port ${info.port} must be forwarded before opening this URL.`);
for (const url of deps.buildControlUiUrls(token, info.port)) {
// Print URLs with the token redacted (forDisplay=true) so the
// gateway auth token does not appear in terminal scrollback or
// CI/CD build logs. The full token can be retrieved via:
// nemoclaw <name> connect → jq '.gateway.auth.token' /sandbox/.openclaw/openclaw.json
for (const url of deps.buildControlUiUrls(token, info.port, true)) {
console.log(` ${url}`);
}
console.log(` To get the full token: nemoclaw ${sandboxName} connect → jq '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`);
} else {
deps.note(" Could not read gateway token from the sandbox (download failed).");
console.log(` ${info.displayName} ${label}`);
Expand Down
17 changes: 17 additions & 0 deletions src/lib/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,21 @@ describe("buildControlUiUrls", () => {
const urls = buildControlUiUrls("my-token", 19000);
expect(urls).toEqual(["http://127.0.0.1:19000/#token=my-token"]);
});

it("redacts token in URL when forDisplay is true", () => {
const urls = buildControlUiUrls("my-secret-token", 18789, true);
expect(urls[0]).toContain("#token=my-s***********");
expect(urls[0]).not.toContain("my-secret-token");
});

it("fully masks short tokens (<=4 chars) when forDisplay is true", () => {
const urls = buildControlUiUrls("abcd", 18789, true);
expect(urls[0]).toContain("#token=****");
expect(urls[0]).not.toContain("abcd");
});

it("returns full token in URL when forDisplay is false", () => {
const urls = buildControlUiUrls("my-secret-token");
expect(urls[0]).toContain("#token=my-secret-token");
});
});
26 changes: 25 additions & 1 deletion src/lib/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,35 @@ export function resolveDashboardForwardTarget(
}
}

/**
* Redact a token for safe display — show only the first 4 characters
* and replace the rest with asterisks. Returns the full token when
* embedding in a URL fragment (forDisplay=false).
*/
function redactToken(token: string, forDisplay: boolean): string {
if (!forDisplay) return token;
if (token.length <= 4) return "*".repeat(token.length);
return token.slice(0, 4) + "*".repeat(token.length - 4);
}

/**
* Build Control UI URLs.
*
* @param token Gateway auth token (null if unavailable)
* @param port Dashboard port
* @param forDisplay When true, the token is redacted in the URL so it
* is safe to print to stdout/CI logs. Callers that
* need a clickable URL for programmatic use should
* pass false (or omit — default is false for
* backward compatibility).
*/
export function buildControlUiUrls(
token: string | null = null,
port: number = CONTROL_UI_PORT,
forDisplay: boolean = false,
): string[] {
const hash = token ? `#token=${token}` : "";
const displayToken = token ? redactToken(token, forDisplay) : "";
const hash = token ? `#token=${displayToken}` : "";
Comment on lines +65 to +68

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find call sites still using the 2-arg form (defaults to forDisplay=false)
ast-grep --lang ts --pattern 'buildControlUiUrls($TOKEN, $PORT)'

# Inspect display/logging flows around dashboard guidance and URL printing
rg -n -C4 'getDashboardAccessInfo|getDashboardGuidanceLines|console\.log|buildControlUiUrls\(' src/lib/onboard.ts src/lib/agent-onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 50371


🏁 Script executed:

# Get context around line 6193 in onboard.ts
head -n 6210 src/lib/onboard.ts | tail -n 30

Repository: NVIDIA/NemoClaw

Length of output: 1096


🏁 Script executed:

# Get context around line 6263 in onboard.ts
head -n 6280 src/lib/onboard.ts | tail -n 30

Repository: NVIDIA/NemoClaw

Length of output: 1208


🏁 Script executed:

# Check the buildControlUiUrls function signature
rg -A 10 'function buildControlUiUrls|export.*buildControlUiUrls' src/lib/dashboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 574


🏁 Script executed:

# Find all calls to buildControlUiUrls and check if results are used in console.log/print contexts
rg -B 3 -A 3 'buildControlUiUrls' src/lib/onboard.ts | head -100

Repository: NVIDIA/NemoClaw

Length of output: 1298


🏁 Script executed:

# Find where getDashboardAccessInfo is called and used
rg -B 5 -A 5 'getDashboardAccessInfo' src/lib/onboard.ts src/lib/agent-onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 1957


🏁 Script executed:

# Check what buildAuthenticatedDashboardUrl does - is it redacting tokens?
rg -A 15 'function buildAuthenticatedDashboardUrl|export.*buildAuthenticatedDashboardUrl' src/lib/dashboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Search for any other 2-arg calls to buildControlUiUrls in display contexts
rg -B 5 'buildControlUiUrls\([^,]+,[^,)]+\)' src/lib/onboard.ts | grep -A 5 -B 5 'console\|log\|print'

Repository: NVIDIA/NemoClaw

Length of output: 548


🏁 Script executed:

# Check buildAuthenticatedDashboardUrl implementation
rg -A 20 'buildAuthenticatedDashboardUrl' src/lib/dashboard.ts | head -50

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Check getDashboardGuidanceLines - does it print the URLs?
rg -A 15 'getDashboardGuidanceLines' src/lib/dashboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# See where getDashboardGuidanceLines output is used
rg -B 3 -A 3 'getDashboardGuidanceLines' src/lib/onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 760


🏁 Script executed:

# Search for buildAuthenticatedDashboardUrl in dashboard.ts
cat src/lib/dashboard.ts | grep -A 20 'buildAuthenticatedDashboardUrl'

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Get the full getDashboardGuidanceLines implementation
sed -n '/^function getDashboardGuidanceLines/,/^}/p' src/lib/dashboard.ts | head -100

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Trace: show what's in guidanceLines that gets printed
rg -A 10 'const guidanceLines = getDashboardGuidanceLines' src/lib/onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 606


🏁 Script executed:

# Find where guidanceLines is printed
rg -A 20 'guidanceLines' src/lib/onboard.ts | grep -A 20 'console.log'

Repository: NVIDIA/NemoClaw

Length of output: 2112


🏁 Script executed:

# More thorough search for guidanceLines usage
rg 'guidanceLines' src/lib/onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 206


🏁 Script executed:

# Get the full buildAuthenticatedDashboardUrl function
rg -B 2 'export.*buildAuthenticatedDashboardUrl|function buildAuthenticatedDashboardUrl' src/lib/dashboard.ts -A 15

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Also check if it's in a different file
find src -name "*.ts" | xargs grep -l "buildAuthenticatedDashboardUrl"

Repository: NVIDIA/NemoClaw

Length of output: 77


🏁 Script executed:

# Let's look at what buildAuthenticatedDashboardUrl actually does with the token
rg 'buildAuthenticatedDashboardUrl' -B 5 -A 20 src/lib/dashboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Search all TypeScript files for buildAuthenticatedDashboardUrl definition
rg 'buildAuthenticatedDashboardUrl' src/lib/ -B 3 -A 20

Repository: NVIDIA/NemoClaw

Length of output: 5054


🏁 Script executed:

# Check dashboard.ts file for all exports
grep -E 'export (function|const)' src/lib/dashboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 141


🏁 Script executed:

# Look for the actual implementation by searching patterns
ast-grep --pattern 'function buildAuthenticatedDashboardUrl' src/lib/

Repository: NVIDIA/NemoClaw

Length of output: 309


🏁 Script executed:

# Verify: check what redactToken does
rg -A 10 'function redactToken|export.*redactToken' src/lib/dashboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 411


🏁 Script executed:

# Double-check the flow: getDashboardAccessInfo returns what?
sed -n '6189,6215p' src/lib/onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 1088


🏁 Script executed:

# Confirm that dashboardAccess entries are indeed printed with full tokens
sed -n '6270,6280p' src/lib/onboard.ts

Repository: NVIDIA/NemoClaw

Length of output: 344


Pass forDisplay=true to buildControlUiUrls() in getDashboardAccessInfo to prevent token leakage.

At line 6193, getDashboardAccessInfo calls buildControlUiUrls(token, dashboardPort) without the forDisplay argument, which defaults to false. This causes unredacted tokens to be embedded in URLs that are subsequently printed to console at lines 6278–6280. The full token then appears in terminal scrollback and CI/CD logs.

Change line 6193 to:

const dashboardAccess = buildControlUiUrls(token, dashboardPort, true).map((url, index) => ({

This aligns with the pattern already correctly implemented in agent-onboard.ts:261 where dashboard URLs are printed with forDisplay=true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dashboard.ts` around lines 65 - 68, getDashboardAccessInfo is calling
buildControlUiUrls(token, dashboardPort) which leaves forDisplay=false and
embeds the raw token in URLs; update the call in getDashboardAccessInfo to pass
forDisplay=true (i.e., call buildControlUiUrls(token, dashboardPort, true)) so
the token is redacted before those URLs are mapped/printed, ensuring you
reference the same token and dashboardPort variables used in the existing call.

const baseUrl = `http://127.0.0.1:${port}`;
const urls = [`${baseUrl}${CONTROL_UI_PATH}${hash}`];
const chatUi = (process.env.CHAT_UI_URL || "").trim().replace(/\/$/, "");
Expand Down