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
1 change: 1 addition & 0 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ Use `nemohermes my-assistant status` to see both the dashboard and API endpoints

`gateway-token` is not applicable to Hermes sandboxes.
Hermes API access uses bearer-token authentication configured through the Hermes runtime, not the OpenClaw gateway token.
For browser access to the dashboard, use `nemohermes my-assistant dashboard-url`; Hermes dashboard auth is read from the in-sandbox config (`/sandbox/.hermes/config.yaml`), not a gateway token.
If you need the endpoint for an OpenAI-compatible client, use `nemohermes my-assistant status` and the API URL it reports.

### `nemohermes <name> destroy`
Expand Down
1 change: 1 addition & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,7 @@ Do not log it, share it, or commit it to version control.

`gateway-token` is not applicable to Hermes sandboxes.
Hermes API access uses bearer-token authentication configured through the Hermes runtime, not the OpenClaw gateway token.
For browser access to the dashboard, use `nemohermes my-assistant dashboard-url`; Hermes dashboard auth is read from the in-sandbox config (`/sandbox/.hermes/config.yaml`), not a gateway token.
If you need the endpoint for an OpenAI-compatible client, use `nemohermes my-assistant status` and the API URL it reports.

</AgentOnly>
Expand Down
35 changes: 35 additions & 0 deletions src/commands/simple-global-oclif-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,41 @@ describe("simple global oclif adapters", () => {
}
});

it("renders every line of a multi-line Hermes diagnostic to stderr without leaking an oclif stack trace", async () => {
// PRA-T3 on #5252: when the helper throws a multi-line
// GatewayTokenCommandError for a Hermes sandbox, the wrapper must
// (a) write every line via console.error, (b) signal failure via
// process.exitCode, and (c) leak no @oclif/core ExitError stack trace.
const hermesLines = [
" gateway-token is not applicable for sandbox 'hermes': it uses the 'hermes' agent, which does not expose a gateway auth token. This command only supports the OpenClaw agent.",
" For Hermes dashboard access, run: nemohermes hermes dashboard-url",
" Hermes dashboard auth is read from the in-sandbox config (~/.hermes/config.yaml), not a gateway token.",
];
mocks.runGatewayTokenCommand.mockImplementationOnce(() => {
throw new mocks.GatewayTokenCommandError(hermesLines, 1);
});
setGatewayTokenRuntimeBridgeFactoryForTest(() => ({
fetchGatewayAuthTokenFromSandbox: mocks.fetchGatewayAuthTokenFromSandbox,
getSandboxAgent: () => "hermes",
}));

const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
await expect(GatewayTokenCliCommand.run(["hermes"], rootDir)).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
for (const line of hermesLines) {
expect(errorSpy).toHaveBeenCalledWith(line);
}
const combined = errorSpy.mock.calls.map((args) => args.join(" ")).join("\n");
expect(combined).not.toMatch(/ExitError|@oclif\/core|at Object\.exit/);
} finally {
process.exitCode = previousExitCode;
errorSpy.mockRestore();
}
});

it("clears a stale non-zero process.exitCode on a successful gateway-token run", async () => {
// CodeRabbit #3182: if a prior run() left process.exitCode = 1, a later
// successful invocation must still report success. Always overwrite.
Expand Down
94 changes: 90 additions & 4 deletions src/lib/gateway-token-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import {
GatewayTokenCommandError,
Expand Down Expand Up @@ -156,15 +156,101 @@ describe("runGatewayTokenCommand", () => {
expect(getSandboxAgent).toHaveBeenCalledWith("hermes");
expect(fetchToken).not.toHaveBeenCalled();
expect(sinks.out).toEqual([]);
// Issue #3180 contract: a single agent-aware "not applicable" line.
// Nothing is written to the live stderr sink; diagnostics travel on the
// thrown error's `lines` so the caller renders them.
expect(sinks.err).toEqual([]);
expect(thrown?.lines).toHaveLength(1);
const stderr = thrown?.lines[0] ?? "";
const stderr = thrown?.lines.join("\n") ?? "";
// Issue #3180 contract: an agent-aware "not applicable" lead line.
expect(stderr).toMatch(/hermes/);
expect(stderr).toMatch(/OpenClaw/);
expect(stderr).toMatch(/not applicable/i);
expect(stderr).not.toMatch(/sandbox is running/i);
expect(stderr).not.toMatch(/ExitError|@oclif\/core|at Object\.exit/);
// Issue #5249: the Hermes message must direct users to the supported
// dashboard auth path instead of dead-ending on the OpenClaw-only note.
expect(stderr).toMatch(/dashboard-url/);
expect(stderr).toMatch(/\.hermes\/config\.yaml/);
});

// PRA-2 on #5252: the Hermes diagnostic must reflect the invoked CLI alias
// so users who type `nemohermes` see `nemohermes` in the next-step hint,
// not the hardcoded `nemoclaw`. The launcher binaries set
// `NEMOCLAW_INVOKED_AS` so `getAgentBranding().cli` resolves to the right
// command at runtime. `vi.stubEnv` + `vi.unstubAllEnvs` keep the test
// deterministic without conditional state restoration in a `finally`.
afterEach(() => {
vi.unstubAllEnvs();
});

it("Hermes diagnostic uses nemohermes when invoked through the NemoHermes alias", () => {
vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemohermes");
const sinks = makeSinks();
let thrown: GatewayTokenCommandError | null = null;
try {
runGatewayTokenCommand(
"hermes",
{ quiet: false },
{
fetchToken: () => "should-not-be-called",
getSandboxAgent: () => "hermes",
log: sinks.log,
error: sinks.error,
},
);
} catch (error) {
thrown = error as GatewayTokenCommandError;
}
expect(thrown).toBeInstanceOf(GatewayTokenCommandError);
const stderr = thrown?.lines.join("\n") ?? "";
expect(stderr).toContain("For Hermes dashboard access, run: nemohermes hermes dashboard-url");
expect(stderr).not.toContain("nemoclaw hermes dashboard-url");
});

it("Hermes diagnostic uses nemoclaw when Hermes is selected through the nemoclaw binary", () => {
vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemoclaw");
const sinks = makeSinks();
let thrown: GatewayTokenCommandError | null = null;
try {
runGatewayTokenCommand(
"hermes",
{ quiet: false },
{
fetchToken: () => "should-not-be-called",
getSandboxAgent: () => "hermes",
log: sinks.log,
error: sinks.error,
},
);
} catch (error) {
thrown = error as GatewayTokenCommandError;
}
expect(thrown).toBeInstanceOf(GatewayTokenCommandError);
const stderr = thrown?.lines.join("\n") ?? "";
expect(stderr).toContain("For Hermes dashboard access, run: nemoclaw hermes dashboard-url");
expect(stderr).not.toContain("nemohermes hermes dashboard-url");
});

it("keeps a single explanatory line for non-Hermes, non-OpenClaw agents", () => {
const sinks = makeSinks();
let thrown: GatewayTokenCommandError | null = null;
try {
runGatewayTokenCommand(
"beta",
{ quiet: false },
{
fetchToken: () => "unused",
getSandboxAgent: () => "someother",
log: sinks.log,
error: sinks.error,
},
);
} catch (error) {
thrown = error as GatewayTokenCommandError;
}
expect(thrown).toBeInstanceOf(GatewayTokenCommandError);
expect(thrown?.lines).toHaveLength(1);
expect(thrown?.lines[0]).toMatch(/not applicable/i);
expect(thrown?.lines[0]).not.toMatch(/dashboard-url/);
});

it("falls back to fetchToken when the agent lookup throws", () => {
Expand Down
33 changes: 30 additions & 3 deletions src/lib/gateway-token-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* exit 1: token unavailable; diagnostics written to stderr.
*/

import { getAgentBranding } from "./cli/branding";

export interface GatewayTokenCommandDeps {
/** Pull gateway.auth.token from the sandbox config (host-side helper). */
fetchToken: (sandboxName: string) => string | null;
Expand Down Expand Up @@ -54,6 +56,33 @@ function gatewayTokenFail(lines: string | readonly string[], exitCode = 1): neve

const SECURITY_WARNING = "Treat this token like a password -- do not log, share, or commit it.";

/**
* Build the agent-aware "not applicable" diagnostic for a non-OpenClaw agent.
*
* NCQ #5249: the bare "this command only supports OpenClaw" line (NCQ #3180)
* leaves Hermes users following generic dashboard-token quickstart patterns
* without a next step. For Hermes specifically, point them at the supported
* dashboard auth path so Day0 verification is not a dead end. Other
* non-OpenClaw agents keep the single explanatory line.
*/
function notApplicableLines(sandboxName: string, agent: string): readonly string[] {
const lead = ` gateway-token is not applicable for sandbox '${sandboxName}': it uses the '${agent}' agent, which does not expose a gateway auth token. This command only supports the OpenClaw agent.`;
if (agent === "hermes") {
// Pull the invoked CLI name from branding so the hint matches whatever the
// user actually typed: `nemohermes` when launched through the alias,
// `nemoclaw` when Hermes is selected through the default binary. Resolving
// at call time (not import time) keeps the hint in sync with
// NEMOCLAW_INVOKED_AS even when the env var is set after module load.
const cliName = getAgentBranding().cli;
return [
lead,
` For Hermes dashboard access, run: ${cliName} ${sandboxName} dashboard-url`,
" Hermes dashboard auth is read from the in-sandbox config (/sandbox/.hermes/config.yaml), not a gateway token.",
];
}
return [lead];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Run the gateway-token command. Throws {@link GatewayTokenCommandError} on
* failure. The caller is responsible for rendering failures and for having
Expand All @@ -80,9 +109,7 @@ export function runGatewayTokenCommand(
}
}
if (resolvedAgent && resolvedAgent !== "openclaw") {
gatewayTokenFail(
` gateway-token is not applicable for sandbox '${sandboxName}': it uses the '${resolvedAgent}' agent, which does not expose a gateway auth token. This command only supports the OpenClaw agent.`,
);
gatewayTokenFail(notApplicableLines(sandboxName, resolvedAgent));
}

let token: string | null;
Expand Down
Loading