diff --git a/docs/inference/switch-models.mdx b/docs/inference/switch-models.mdx
index ef0b0095d47..4e4eb01bf15 100644
--- a/docs/inference/switch-models.mdx
+++ b/docs/inference/switch-models.mdx
@@ -43,6 +43,9 @@ For a compatible endpoint, omit `--endpoint-url` when the durable registry entry
NemoClaw reuses the recorded route and does not repoint the gateway.
If the route metadata is incomplete, NemoClaw stops and tells you to re-run onboarding.
+For Hermes, the command also mirrors the selected model into the dashboard profile.
+If it reports that the Dashboard config did not converge, the route and main Hermes config remain committed; follow [Hermes dashboard config did not converge](../../reference/troubleshooting#hermes-dashboard-config-did-not-converge) before using Dashboard Chat.
+
diff --git a/docs/inference/switch-providers.mdx b/docs/inference/switch-providers.mdx
index 574dbb030f7..579b4e61082 100644
--- a/docs/inference/switch-providers.mdx
+++ b/docs/inference/switch-providers.mdx
@@ -50,7 +50,10 @@ Changes within the current API family hot-reload without replacing the gateway p
When the API family changes, NemoClaw commits the route and configuration, then restarts only the OpenClaw gateway and verifies its health.
For Hermes, NemoClaw updates `/sandbox/.hermes/config.yaml`, including the model, base URL, API-family mode, and OpenShell proxy API-key placeholder.
-Hermes does not rebuild or restart for this runtime route change.
+When the dashboard profile exists, NemoClaw also mirrors the route into `/sandbox/.hermes/dashboard-home/config.yaml`; a normal runtime route change does not rebuild or restart Hermes.
+If that dashboard mirror cannot be confirmed, the route and main config remain committed but the command exits nonzero.
+Follow [Hermes dashboard config did not converge](../../reference/troubleshooting#hermes-dashboard-config-did-not-converge) before using Dashboard Chat.
+A missing dashboard profile is treated as disabled and does not fail the switch.
If the in-sandbox configuration sync fails after the gateway route changes, NemoClaw keeps the gateway and host registry aligned and prints a rebuild hint.
Run the rebuild before relying on the running agent.
diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index e8b73955927..0ddb35457c1 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -2844,7 +2844,11 @@ Switch the active inference provider or model for a NemoClaw-managed Hermes sand
The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry.
It is also available in sandbox-first form as `$$nemoclaw inference set --provider --model `.
For Hermes, the patch updates `/sandbox/.hermes/config.yaml` (`model.default`, `model.base_url`, `model.provider: custom`, API-family mode when needed, and the OpenShell proxy API-key placeholder) and does not rebuild or restart the gateway.
+When the Hermes dashboard profile exists, the command also mirrors the model route into `/sandbox/.hermes/dashboard-home/config.yaml` for Dashboard Chat.
Keeping the placeholder preserves dashboard and API authentication after provider switches.
+If NemoClaw cannot confirm that the dashboard config was updated, the route, registry, and main Hermes config remain committed, but the command exits nonzero without printing `Inference route synced`.
+Restart the sandbox with `nemohermes stop` followed by `nemohermes start`, then verify Dashboard Chat before relying on it.
+A missing dashboard profile is treated as disabled and does not fail the switch.
Under the `nemohermes` alias, it uses the registered Hermes sandbox when exactly one exists; otherwise pass `--sandbox ` to target one explicitly.
diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx
index 9c7387d47ca..90c2cac9675 100644
--- a/docs/reference/troubleshooting.mdx
+++ b/docs/reference/troubleshooting.mdx
@@ -2574,6 +2574,22 @@ After the rebuild completes, return to the Skills page to confirm the skill is r
The issues below are common problems you may encounter when running Hermes through `nemohermes`.
For setup, refer to [Quickstart with Hermes](../../hermes/get-started/quickstart).
+### Hermes dashboard config did not converge
+
+`nemohermes inference set` updates the OpenShell route, registry, and `/sandbox/.hermes/config.yaml` before it refreshes the separate dashboard profile.
+If the dashboard profile exists but NemoClaw cannot confirm that `/sandbox/.hermes/dashboard-home/config.yaml` was updated, the command exits nonzero without printing `Inference route synced`.
+The committed route and main Hermes config are not rolled back.
+
+Restart the sandbox so startup mirrors the committed model route into the dashboard profile:
+
+```bash
+nemohermes stop
+nemohermes start
+```
+
+Then run `nemohermes inference get` and verify Dashboard Chat uses the selected model.
+If the command succeeds because the dashboard profile is missing, the dashboard is disabled and no dashboard recovery is required.
+
### Hermes restart reports `config hash mismatch`
A Hermes restart reports `config hash mismatch` when a strict root-owned hash is available and `/sandbox/.hermes/config.yaml` or `/sandbox/.hermes/.env` does not match it.
diff --git a/src/lib/actions/inference-set-gateway-restart.ts b/src/lib/actions/inference-set-gateway-restart.ts
index a7e56632172..216e253a55e 100644
--- a/src/lib/actions/inference-set-gateway-restart.ts
+++ b/src/lib/actions/inference-set-gateway-restart.ts
@@ -20,6 +20,14 @@ interface InferenceResultForGateway {
model: string;
primaryModelRef: string;
inSandboxConfigSynced: boolean;
+ /**
+ * Hermes only: whether the isolated Web Dashboard profile converged onto the
+ * switched model (#6893). `undefined` for agents/switches with no Dashboard to
+ * converge (treated as converged). When explicitly `false` the "Inference route
+ * synced" line is withheld and the caller raises a post-commit failure so the
+ * command cannot claim a route it did not fully apply.
+ */
+ dashboardConverged?: boolean;
}
export interface InferenceMutation {
@@ -101,7 +109,10 @@ export function finalizeInferenceMutation(
deps.appendAuditEntry(auditEntry);
}
- if (result.inSandboxConfigSynced && !openClawGatewayRestartRequired) {
+ // A Hermes switch whose Web Dashboard profile did not converge is not fully
+ // applied, so withhold the success line (the caller already warned) (#6893).
+ const hermesDashboardStale = agentName === "hermes" && result.dashboardConverged === false;
+ if (result.inSandboxConfigSynced && !openClawGatewayRestartRequired && !hermesDashboardStale) {
deps.log(
agentName === "hermes"
? ` Inference route synced for '${result.sandboxName}': ${result.model}`
diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts
index 4d19276c136..56b7121c142 100644
--- a/src/lib/actions/inference-set-hermes-run.test.ts
+++ b/src/lib/actions/inference-set-hermes-run.test.ts
@@ -105,6 +105,205 @@ describe("runInferenceSet Hermes routing", () => {
expect(deps.calls.restartSandboxGateway).not.toHaveBeenCalled();
});
+ it("re-seeds the isolated Hermes dashboard config after an in-place switch (#6893)", async () => {
+ const config: ConfigObject = {
+ model: {
+ default: "moonshotai/kimi-k2.6",
+ provider: "custom",
+ base_url: "https://inference.local/v1",
+ },
+ };
+ const deps = createDeps({
+ config,
+ entry: {
+ name: "hermes",
+ agent: "hermes",
+ provider: "hermes-provider",
+ model: "moonshotai/kimi-k2.6",
+ },
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
+ });
+
+ await runInferenceSet(
+ {
+ provider: "hermes-provider",
+ model: "openai/gpt-5.4-mini",
+ sandboxName: "hermes",
+ noVerify: true,
+ },
+ deps,
+ );
+
+ // The dashboard-home config only re-mirrors the gateway model routing at
+ // startup, so the in-place switch must re-seed it or Dashboard Chat stays on
+ // the previous model. It must run after the gateway config was written.
+ expect(deps.calls.seedHermesDashboardConfig).toHaveBeenCalledWith("hermes", HERMES_TARGET);
+ const writeOrder = deps.calls.writeSandboxConfig.mock.invocationCallOrder[0];
+ const seedOrder = deps.calls.seedHermesDashboardConfig.mock.invocationCallOrder[0];
+ expect(seedOrder).toBeGreaterThan(writeOrder);
+ });
+
+ it("does not re-seed the dashboard when the in-sandbox config write fails (#6893)", async () => {
+ const config: ConfigObject = {
+ model: { default: "moonshotai/kimi-k2.6", provider: "custom" },
+ };
+ const deps = createDeps({
+ config,
+ entry: {
+ name: "hermes",
+ agent: "hermes",
+ provider: "hermes-provider",
+ model: "moonshotai/kimi-k2.6",
+ },
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
+ });
+ deps.calls.writeSandboxConfig.mockImplementation(() => {
+ throw new Error("write failed");
+ });
+
+ await runInferenceSet(
+ {
+ provider: "hermes-provider",
+ model: "openai/gpt-5.4-mini",
+ sandboxName: "hermes",
+ noVerify: true,
+ },
+ deps,
+ );
+
+ // A failed gateway-config write leaves the old config in place; re-seeding the
+ // dashboard from it would be pointless (and the guidance is to rebuild).
+ expect(deps.calls.seedHermesDashboardConfig).not.toHaveBeenCalled();
+ });
+
+ it("does not re-seed or report synced when the config hash refresh fails (#6893)", async () => {
+ const config: ConfigObject = {
+ model: { default: "moonshotai/kimi-k2.6", provider: "custom" },
+ };
+ const deps = createDeps({
+ config,
+ entry: {
+ name: "hermes",
+ agent: "hermes",
+ provider: "hermes-provider",
+ model: "moonshotai/kimi-k2.6",
+ },
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
+ });
+ deps.calls.recomputeSandboxConfigHash.mockImplementation(() => {
+ throw new Error("hash refresh failed");
+ });
+
+ await runInferenceSet(
+ {
+ provider: "hermes-provider",
+ model: "openai/gpt-5.4-mini",
+ sandboxName: "hermes",
+ noVerify: true,
+ },
+ deps,
+ );
+
+ expect(deps.calls.writeSandboxConfig).toHaveBeenCalledOnce();
+ expect(deps.calls.seedHermesDashboardConfig).not.toHaveBeenCalled();
+ const logs = deps.calls.log.mock.calls.map((call) => String(call[0]));
+ expect(logs.some((line) => line.includes("failed to refresh its integrity hash"))).toBe(true);
+ expect(logs.some((line) => line.includes("rebuild"))).toBe(true);
+ expect(logs.some((line) => line.includes("Inference route synced"))).toBe(false);
+ });
+
+ it("fails after commit when the dashboard does not converge (#6893)", async () => {
+ const config: ConfigObject = {
+ model: {
+ default: "moonshotai/kimi-k2.6",
+ provider: "custom",
+ base_url: "https://inference.local/v1",
+ },
+ };
+ const deps = createDeps({
+ config,
+ entry: {
+ name: "hermes",
+ agent: "hermes",
+ provider: "hermes-provider",
+ model: "moonshotai/kimi-k2.6",
+ },
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
+ seedHermesDashboardConfigResult: "failed",
+ });
+
+ await expect(
+ runInferenceSet(
+ {
+ provider: "hermes-provider",
+ model: "openai/gpt-5.4-mini",
+ sandboxName: "hermes",
+ noVerify: true,
+ },
+ deps,
+ ),
+ ).rejects.toMatchObject({
+ name: "InferenceSetError",
+ exitCode: 1,
+ message: expect.stringMatching(/committed route was not rolled back.*Restart the sandbox/u),
+ });
+
+ // The route, main config, durable session, and audit are already committed,
+ // but the command must fail instead of claiming complete convergence.
+ expect(deps.getSession()?.model).toBe("openai/gpt-5.4-mini");
+ expect(deps.calls.appendAuditEntry).toHaveBeenCalledOnce();
+ const logs = deps.calls.log.mock.calls.map((c) => String(c[0]));
+ expect(logs.some((l) => l.includes("Inference route synced"))).toBe(false);
+ expect(logs.some((l) => l.includes("could not refresh the dashboard"))).toBe(true);
+ });
+
+ it("still reports synced when the dashboard profile is absent (Dashboard disabled) (#6893)", async () => {
+ const config: ConfigObject = {
+ model: {
+ default: "moonshotai/kimi-k2.6",
+ provider: "custom",
+ base_url: "https://inference.local/v1",
+ },
+ };
+ const deps = createDeps({
+ config,
+ entry: {
+ name: "hermes",
+ agent: "hermes",
+ provider: "hermes-provider",
+ model: "moonshotai/kimi-k2.6",
+ },
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
+ seedHermesDashboardConfigResult: "absent",
+ });
+
+ await runInferenceSet(
+ {
+ provider: "hermes-provider",
+ model: "openai/gpt-5.4-mini",
+ sandboxName: "hermes",
+ noVerify: true,
+ },
+ deps,
+ );
+
+ // Nothing to converge — the switch is fully applied, so it still reports synced
+ // and does not warn.
+ const logs = deps.calls.log.mock.calls.map((c) => String(c[0]));
+ expect(logs.some((l) => l.includes("Inference route synced"))).toBe(true);
+ expect(logs.some((l) => l.includes("could not refresh the dashboard"))).toBe(false);
+ });
+
it("keeps Hermes custom Anthropic switches off the managed Anthropic SSE frontend (#6289)", async () => {
const config: ConfigObject = {
model: {
diff --git a/src/lib/actions/inference-set-openclaw-run.test.ts b/src/lib/actions/inference-set-openclaw-run.test.ts
index 5a8fa243d67..b63c7d54d3c 100644
--- a/src/lib/actions/inference-set-openclaw-run.test.ts
+++ b/src/lib/actions/inference-set-openclaw-run.test.ts
@@ -49,6 +49,8 @@ describe("runInferenceSet OpenClaw routing", () => {
});
expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET, config);
expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET);
+ // The dashboard re-seed is Hermes-only; OpenClaw has no isolated dashboard config. (#6893)
+ expect(deps.calls.seedHermesDashboardConfig).not.toHaveBeenCalled();
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
diff --git a/src/lib/actions/inference-set.test-support.ts b/src/lib/actions/inference-set.test-support.ts
index 6e19b36d265..00de8f2a36c 100644
--- a/src/lib/actions/inference-set.test-support.ts
+++ b/src/lib/actions/inference-set.test-support.ts
@@ -86,12 +86,14 @@ export function createDeps(options: {
prepareRunOpenshell?: () => void;
rewriteConfigUrlsWithDnsPinning?: (value: ConfigValue) => Promise;
restartSandboxGateway?: InferenceSetDeps["restartSandboxGateway"];
+ seedHermesDashboardConfigResult?: "converged" | "absent" | "failed";
withGatewayRouteMutationLock?: InferenceSetDeps["withGatewayRouteMutationLock"];
}): InferenceSetDeps & {
calls: {
captureOpenshell: ReturnType;
writeSandboxConfig: ReturnType;
recomputeSandboxConfigHash: ReturnType;
+ seedHermesDashboardConfig: ReturnType;
updateSandbox: ReturnType;
readSandboxConfig: ReturnType;
updateSession: ReturnType;
@@ -124,6 +126,7 @@ export function createDeps(options: {
})),
writeSandboxConfig: vi.fn(),
recomputeSandboxConfigHash: vi.fn(),
+ seedHermesDashboardConfig: vi.fn(() => options.seedHermesDashboardConfigResult ?? "converged"),
updateSandbox: vi.fn(() => true),
readSandboxConfig: vi.fn(() => options.config),
updateSession: vi.fn((mutator: (value: Session) => Session | void) => {
@@ -169,6 +172,7 @@ export function createDeps(options: {
readSandboxConfig: calls.readSandboxConfig,
writeSandboxConfig: calls.writeSandboxConfig,
recomputeSandboxConfigHash: calls.recomputeSandboxConfigHash,
+ seedHermesDashboardConfig: calls.seedHermesDashboardConfig,
prepareRunOpenshell: calls.prepareRunOpenshell,
captureOpenshell: calls.captureOpenshell,
appendAuditEntry: calls.appendAuditEntry,
diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts
index c0eb2886569..b6c5dccf259 100644
--- a/src/lib/actions/inference-set.ts
+++ b/src/lib/actions/inference-set.ts
@@ -29,10 +29,12 @@ import {
} from "../openshell-gateway-endpoint-guard";
import {
type AgentConfigTarget,
+ type HermesDashboardReseedResult,
readSandboxConfig,
recomputeSandboxConfigHash,
resolveAgentConfig,
rewriteConfigUrlsWithDnsPinning,
+ seedHermesDashboardConfig,
writeSandboxConfig,
} from "../sandbox/config";
import type { ConfigObject, ConfigValue } from "../security/credential-filter";
@@ -92,6 +94,11 @@ export interface InferenceSetResult {
inSandboxConfigSynced: boolean;
}
+interface InferenceSetMutationResult extends InferenceSetResult {
+ /** Internal post-commit convergence state used before returning to the CLI caller. */
+ dashboardConverged?: boolean;
+}
+
export interface InferenceSetDeps extends InferenceGatewayRestartDeps {
getDefaultSandbox: () => string | null;
getSandbox: (name: string) => SandboxEntry | null;
@@ -110,6 +117,10 @@ export interface InferenceSetDeps extends InferenceGatewayRestartDeps {
config: ConfigObject,
) => void;
recomputeSandboxConfigHash: (sandboxName: string, target: AgentConfigTarget) => void;
+ seedHermesDashboardConfig: (
+ sandboxName: string,
+ target: AgentConfigTarget,
+ ) => HermesDashboardReseedResult;
prepareRunOpenshell: () => void;
captureOpenshell: (
args: string[],
@@ -212,6 +223,7 @@ function defaultDeps(): InferenceSetDeps {
readSandboxConfig,
writeSandboxConfig,
recomputeSandboxConfigHash,
+ seedHermesDashboardConfig,
prepareRunOpenshell: () => {
getOpenshellBinary();
},
@@ -565,7 +577,7 @@ async function runInferenceSetWithoutHostLock(
options: InferenceSetOptions,
deps: InferenceSetDeps,
expectedGatewayName: string,
-): Promise> {
+): Promise> {
// #6321: accept the installer-style provider name onboard uses (e.g.
// `anthropicCompatible`) as well as the OpenShell provider name, by
// normalizing to the OpenShell name before validation and all downstream use.
@@ -866,6 +878,25 @@ async function runInferenceSetWithoutHostLock(
` Run '${CLI_NAME} ${sandboxName} rebuild' to finish applying the model inside the sandbox.`,
);
}
+ // Hermes keeps an isolated dashboard-home config that only mirrors the gateway
+ // config's model routing at sandbox startup. Re-seed it after an in-place
+ // switch so Dashboard Chat (and /api/model/info) converge on the new model
+ // instead of silently staying on the previous one (#6893).
+ // - "converged": dashboard now matches the switch.
+ // - "absent": Dashboard disabled — nothing to converge, still a success.
+ // - "failed": warn and fail after the committed mutation is finalized so
+ // callers cannot accept a partially converged switch.
+ let dashboardConverged: boolean | undefined;
+ if (agentName === "hermes" && inSandboxConfigSynced) {
+ const reseed = deps.seedHermesDashboardConfig(sandboxName, target);
+ dashboardConverged = reseed !== "failed";
+ if (reseed === "failed") {
+ deps.log(
+ ` Warning: updated the Hermes model route but could not refresh the dashboard ` +
+ `config for '${sandboxName}'. Restart the sandbox to converge Dashboard Chat.`,
+ );
+ }
+ }
const sessionUpdated = updateMatchingOnboardSession(
sandboxName,
provider,
@@ -890,6 +921,7 @@ async function runInferenceSetWithoutHostLock(
configChanged: patched.changed,
sessionUpdated,
inSandboxConfigSynced,
+ dashboardConverged,
},
},
deps,
@@ -931,6 +963,13 @@ export async function runInferenceSet(
// it, but retain the outer sandbox lifecycle lock so another process cannot
// destroy/recreate this name between the committed write and restart.
completeInferenceGatewayRestart(mutation, deps);
+ if (mutation.result.dashboardConverged === false) {
+ throw new InferenceSetError(
+ `Inference route and main Hermes config were updated for '${mutation.result.sandboxName}', ` +
+ `but the Dashboard config did not converge. The committed route was not rolled back. ` +
+ `Restart the sandbox to converge Dashboard Chat.`,
+ );
+ }
return mutation.result;
});
}
diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts
index 0f96b1cdebd..69929515788 100644
--- a/src/lib/sandbox/config.ts
+++ b/src/lib/sandbox/config.ts
@@ -45,6 +45,10 @@ const {
parseConfig,
serializeConfig,
}: typeof import("./config-format") = require("./config-format");
+const {
+ OPENSHELL_OPERATION_TIMEOUT_MS,
+}: typeof import("../adapters/openshell/timeouts") = require("../adapters/openshell/timeouts");
+const { redactFull }: typeof import("../security/redact") = require("../security/redact");
type ConfigObject = import("../security/credential-filter").ConfigObject;
type ConfigValue = import("../security/credential-filter").ConfigValue;
@@ -594,6 +598,167 @@ function recomputeSandboxConfigHash(sandboxName: string, target: AgentConfigTarg
privilegedSandboxExec(sandboxName, ["sh", "-c", script]);
}
+// Absolute path to the Hermes dashboard config seeder inside the sandbox image
+// (installed by the agents/hermes image build). The python resolution order
+// mirrors start.sh's trusted `_HERMES_PYTHON` list.
+const HERMES_DASHBOARD_SEEDER_PATH = "/usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py";
+const HERMES_TRUSTED_PYTHON3 = [
+ "/opt/hermes/.venv/bin/python3",
+ "/usr/local/bin/python3",
+ "/usr/bin/python3",
+] as const;
+const HERMES_DASHBOARD_PATH_ABSENT_STATUS = 3;
+// OpenShell rejects CR/LF in argv, so encode the multiline program inside a
+// single-line Python expression.
+const HERMES_DASHBOARD_PATH_INSPECTION = `exec(${JSON.stringify(
+ [
+ "import os",
+ "import stat",
+ "import sys",
+ "try:",
+ " mode = os.lstat(sys.argv[1]).st_mode",
+ "except FileNotFoundError:",
+ ` raise SystemExit(${HERMES_DASHBOARD_PATH_ABSENT_STATUS})`,
+ "except OSError as exc:",
+ ' print(f"unable to inspect Hermes dashboard path: {exc}", file=sys.stderr)',
+ " raise SystemExit(2)",
+ "raise SystemExit(0 if stat.S_ISDIR(mode) else 2)",
+ ].join("\n"),
+)})`;
+
+export type HermesDashboardReseedResult = "converged" | "absent" | "failed";
+
+export interface HermesDashboardReseedDeps {
+ getOpenshellBinary: () => string;
+ captureOpenshellCommand: (
+ binary: string,
+ args: string[],
+ options: import("../adapters/openshell/client").CaptureOpenshellOptions,
+ ) => import("../adapters/openshell/client").CaptureOpenshellResult;
+ reportFailure?: (stage: "python" | "inspection" | "seed", detail: string) => void;
+}
+
+const HERMES_DASHBOARD_RESEED_DIAGNOSTIC_MAX_CHARS = 800;
+
+function hermesDashboardReseedFailureDetail(
+ result: import("../adapters/openshell/client").CaptureOpenshellResult,
+): string {
+ const raw =
+ result.error?.message || result.stderr?.trim() || result.output.trim() || result.stdout?.trim();
+ const detail = redactFull(raw || "no command output")
+ .replace(/\s+/gu, " ")
+ .trim();
+ const bounded = detail.slice(0, HERMES_DASHBOARD_RESEED_DIAGNOSTIC_MAX_CHARS);
+ return [
+ `status=${result.status === null ? "null" : result.status}`,
+ result.signal ? `signal=${result.signal}` : "",
+ bounded ? `detail=${bounded}` : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
+
+/**
+ * Re-run the Hermes dashboard config seeder inside the sandbox so the isolated
+ * dashboard-home config (`/dashboard-home/config.yaml`) re-mirrors the
+ * gateway config's model routing after an in-place `inference set`. Sandbox
+ * startup runs the same seeder; without re-running it, Dashboard Chat and its
+ * `/api/model/info` endpoint stay on the previous model even though the gateway
+ * config, registry, and CLI status all report the new one (#6893).
+ *
+ * Runs as the sandbox user (non-privileged `sandbox exec`, matching start.sh's
+ * step-down before touching sandbox-owned dashboard-home state); the seeder does
+ * no-follow atomic writes and refuses symlinked paths. Best-effort: returns
+ * `failed` on failure so the caller can warn without aborting the route switch.
+ */
+function seedHermesDashboardConfig(
+ sandboxName: string,
+ target: AgentConfigTarget,
+ deps: HermesDashboardReseedDeps = { getOpenshellBinary, captureOpenshellCommand },
+): HermesDashboardReseedResult {
+ const dashboardHome = `${target.configDir}/dashboard-home`;
+ const binary = deps.getOpenshellBinary();
+ const capture = (command: string[]) =>
+ deps.captureOpenshellCommand(
+ binary,
+ ["sandbox", "exec", "--name", sandboxName, "--", ...command],
+ {
+ ignoreError: true,
+ includeStreams: true,
+ maxBuffer: CONFIG_CAPTURE_MAX_BUFFER,
+ timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
+ },
+ );
+ const failed = (result: import("../adapters/openshell/client").CaptureOpenshellResult) =>
+ Boolean(result.error || result.signal || result.status !== 0);
+ const reportFailure = (
+ stage: "python" | "inspection" | "seed",
+ result: import("../adapters/openshell/client").CaptureOpenshellResult,
+ ) => {
+ const detail = hermesDashboardReseedFailureDetail(result);
+ if (deps.reportFailure) {
+ deps.reportFailure(stage, detail);
+ return;
+ }
+ console.error(` Hermes dashboard reseed ${stage} failed: ${detail}`);
+ };
+
+ let python: (typeof HERMES_TRUSTED_PYTHON3)[number] | null = null;
+ let lastPythonFailure: import("../adapters/openshell/client").CaptureOpenshellResult | undefined;
+ for (const candidate of HERMES_TRUSTED_PYTHON3) {
+ const probe = capture([candidate, "-c", ""]);
+ if (!failed(probe)) {
+ python = candidate;
+ break;
+ }
+ lastPythonFailure = probe;
+ }
+ if (!python) {
+ if (lastPythonFailure) reportFailure("python", lastPythonFailure);
+ return "failed";
+ }
+
+ // lstat distinguishes a genuinely absent profile from a file, a symlink
+ // (including a broken one), or an inspection error. Only the first case is a
+ // clean no-op; everything else fails closed so callers cannot report sync.
+ const inspection = capture([python, "-c", HERMES_DASHBOARD_PATH_INSPECTION, dashboardHome]);
+ if (
+ !inspection.error &&
+ !inspection.signal &&
+ inspection.status === HERMES_DASHBOARD_PATH_ABSENT_STATUS
+ ) {
+ return "absent";
+ }
+ if (failed(inspection)) {
+ reportFailure("inspection", inspection);
+ return "failed";
+ }
+
+ const dashboardConfigPath = `${dashboardHome}/config.yaml`;
+ const seed = capture([
+ python,
+ HERMES_DASHBOARD_SEEDER_PATH,
+ target.configPath,
+ dashboardConfigPath,
+ `${target.configDir}/.env`,
+ `${dashboardHome}/.env`,
+ ]);
+ if (failed(seed)) {
+ reportFailure("seed", seed);
+ return "failed";
+ }
+ const seededMarker = `[dashboard] seeded model routing into ${dashboardConfigPath}`;
+ if (
+ !String(seed.stderr ?? "")
+ .split(/\r?\n/u)
+ .includes(seededMarker)
+ ) {
+ reportFailure("seed", seed);
+ return "failed";
+ }
+ return "converged";
+}
+
// ---------------------------------------------------------------------------
// URL validation (strict SSRF checks for config set)
// ---------------------------------------------------------------------------
@@ -1225,6 +1390,7 @@ export {
resolveAgentConfig,
restartSandboxAgentAfterConfigSet,
rewriteConfigUrlsWithDnsPinning,
+ seedHermesDashboardConfig,
setDotpath,
validateConfigDotpath,
validateUrlValue,
diff --git a/src/lib/sandbox/hermes-dashboard-reseed.test.ts b/src/lib/sandbox/hermes-dashboard-reseed.test.ts
new file mode 100644
index 00000000000..2ab7355c985
--- /dev/null
+++ b/src/lib/sandbox/hermes-dashboard-reseed.test.ts
@@ -0,0 +1,214 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AgentConfigTarget } from "./config";
+import { seedHermesDashboardConfig } from "./config";
+
+interface CaptureResult {
+ status: number | null;
+ output: string;
+ stdout?: string;
+ stderr?: string;
+ error?: Error;
+ signal?: NodeJS.Signals | null;
+}
+
+const TARGET: AgentConfigTarget = {
+ agentName: "hermes",
+ configPath: "/sandbox/.hermes/config.yaml",
+ configDir: "/sandbox/.hermes",
+ format: "yaml",
+ configFile: "config.yaml",
+};
+const PYTHON = "/opt/hermes/.venv/bin/python3";
+const SEEDER = "/usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py";
+const DASHBOARD_CONFIG = "/sandbox/.hermes/dashboard-home/config.yaml";
+const capture = vi.fn<(binary: string, args: string[], options: unknown) => CaptureResult>();
+const reportFailure = vi.fn<(stage: "python" | "inspection" | "seed", detail: string) => void>();
+
+function result(overrides: Partial = {}): CaptureResult {
+ return { status: 0, output: "", stdout: "", stderr: "", signal: null, ...overrides };
+}
+
+function sandboxCommand(args: string[]): string[] {
+ const separator = args.indexOf("--");
+ expect(separator).toBe(4);
+ return args.slice(separator + 1);
+}
+
+function successfulSeed(configPath = DASHBOARD_CONFIG): CaptureResult {
+ return result({ stderr: `[dashboard] seeded model routing into ${configPath}\n` });
+}
+
+function mockReseedFlow(options: { inspection?: CaptureResult; seed?: CaptureResult } = {}): void {
+ capture
+ .mockReturnValueOnce(result())
+ .mockReturnValueOnce(options.inspection ?? result())
+ .mockReturnValueOnce(options.seed ?? successfulSeed());
+}
+
+describe("seedHermesDashboardConfig", () => {
+ beforeEach(() => {
+ capture.mockReset();
+ reportFailure.mockReset();
+ });
+
+ const deps = {
+ getOpenshellBinary: () => "/host/OpenShell binary;still-one-argv",
+ captureOpenshellCommand: capture,
+ reportFailure,
+ };
+
+ it("passes adversarial paths as discrete argv without invoking a shell (#6893)", () => {
+ mockReseedFlow({
+ seed: successfulSeed("/sandbox/Hermes home;$(touch dir-pwned)/dashboard-home/config.yaml"),
+ });
+ const target: AgentConfigTarget = {
+ ...TARGET,
+ configPath: "/sandbox/Hermes config;$(touch source-pwned)/config'quote.yaml",
+ configDir: "/sandbox/Hermes home;$(touch dir-pwned)",
+ };
+
+ expect(seedHermesDashboardConfig("hermes name;$(touch sandbox-pwned)", target, deps)).toBe(
+ "converged",
+ );
+
+ expect(capture).toHaveBeenCalledTimes(3);
+ for (const [binary, args, options] of capture.mock.calls) {
+ expect(binary).toBe("/host/OpenShell binary;still-one-argv");
+ expect(args.slice(0, 5)).toEqual([
+ "sandbox",
+ "exec",
+ "--name",
+ "hermes name;$(touch sandbox-pwned)",
+ "--",
+ ]);
+ expect(sandboxCommand(args)[0]).not.toMatch(/^(?:ba)?sh$/);
+ for (const arg of args) expect(arg).not.toMatch(/[\r\n]/u);
+ expect(options).toEqual({
+ ignoreError: true,
+ includeStreams: true,
+ maxBuffer: 17 * 1024 * 1024,
+ timeout: 30_000,
+ });
+ }
+ expect(sandboxCommand(capture.mock.calls[0][1])).toEqual([PYTHON, "-c", ""]);
+ const inspectionCommand = sandboxCommand(capture.mock.calls[1][1]);
+ expect(inspectionCommand[0]).toBe(PYTHON);
+ expect(inspectionCommand[1]).toBe("-c");
+ expect(inspectionCommand[2]).toContain("os.lstat(sys.argv[1])");
+ expect(inspectionCommand[2]).toContain("except FileNotFoundError:");
+ expect(inspectionCommand[2]).toContain("stat.S_ISDIR(mode)");
+ expect(inspectionCommand.at(-1)).toBe("/sandbox/Hermes home;$(touch dir-pwned)/dashboard-home");
+ expect(sandboxCommand(capture.mock.calls[2][1])).toEqual([
+ PYTHON,
+ SEEDER,
+ "/sandbox/Hermes config;$(touch source-pwned)/config'quote.yaml",
+ "/sandbox/Hermes home;$(touch dir-pwned)/dashboard-home/config.yaml",
+ "/sandbox/Hermes home;$(touch dir-pwned)/.env",
+ "/sandbox/Hermes home;$(touch dir-pwned)/dashboard-home/.env",
+ ]);
+ });
+
+ it("returns absent only when the path inspection reports it missing (#6893)", () => {
+ mockReseedFlow({ inspection: result({ status: 3, stderr: "missing" }) });
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("absent");
+ expect(capture).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([
+ ["a regular file", result({ status: 2, stderr: "not a directory" })],
+ ["a broken symlink", result({ status: 2, stderr: "symlink" })],
+ ["an inspection error", result({ status: 2, stderr: "permission denied" })],
+ ])("fails closed when dashboard-home is %s (#6893)", (_case, inspection) => {
+ mockReseedFlow({ inspection });
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("failed");
+ expect(capture).toHaveBeenCalledTimes(2);
+ expect(reportFailure).toHaveBeenCalledWith(
+ "inspection",
+ expect.stringMatching(/^status=2 detail=/u),
+ );
+ });
+
+ it.each([
+ ["a nonzero exit", result({ status: 1, output: "seed failed", stderr: "write denied" })],
+ [
+ "a captured execution error",
+ result({ status: null, output: "", error: new Error("spawn failed") }),
+ ],
+ ["a signal", result({ status: null, output: "", signal: "SIGTERM" })],
+ ])("maps %s from the seeder to failed while capturing both streams (#6893)", (_case, seed) => {
+ mockReseedFlow({ seed });
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("failed");
+ expect(capture).toHaveBeenCalledTimes(3);
+ expect(capture.mock.calls[2][2]).toMatchObject({ includeStreams: true });
+ expect(reportFailure).toHaveBeenCalledWith("seed", expect.stringMatching(/^status=/u));
+ });
+
+ it.each([
+ [
+ "PyYAML is unavailable",
+ "[dashboard] PyYAML unavailable (No module named yaml); skipping model seed",
+ ],
+ [
+ "the gateway config is missing",
+ `[dashboard] gateway config ${TARGET.configPath} missing; skipping model seed`,
+ ],
+ [
+ "the gateway config is unreadable",
+ `[dashboard] gateway config ${TARGET.configPath} unreadable (permission denied); skipping model seed`,
+ ],
+ [
+ "the gateway config has no model routing",
+ "[dashboard] gateway config has no model routing; nothing to seed",
+ ],
+ ])("fails closed when %s despite a zero exit (#6893)", (_case, stderr) => {
+ mockReseedFlow({ seed: result({ stderr: `${stderr}\n` }) });
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("failed");
+ expect(capture).toHaveBeenCalledTimes(3);
+ expect(reportFailure).toHaveBeenCalledWith(
+ "seed",
+ expect.stringContaining(`status=0 detail=${stderr}`),
+ );
+ });
+
+ it("requires the success marker for the requested dashboard config path (#6893)", () => {
+ mockReseedFlow({ seed: successfulSeed("/sandbox/.hermes/other/config.yaml") });
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("failed");
+ expect(reportFailure).toHaveBeenCalledWith("seed", expect.stringMatching(/^status=0 detail=/u));
+ });
+
+ it("fails when none of the fixed trusted Python candidates can run (#6893)", () => {
+ capture.mockReturnValue(result({ status: 127, stderr: "not found" }));
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("failed");
+ expect(capture).toHaveBeenCalledTimes(3);
+ expect(capture.mock.calls.map((call) => sandboxCommand(call[1])[0])).toEqual([
+ "/opt/hermes/.venv/bin/python3",
+ "/usr/local/bin/python3",
+ "/usr/bin/python3",
+ ]);
+ expect(reportFailure).toHaveBeenCalledWith("python", "status=127 detail=not found");
+ });
+
+ it("fully redacts and bounds captured seeder diagnostics (#6893)", () => {
+ mockReseedFlow({
+ seed: result({
+ status: 1,
+ stderr: `Authorization: Bearer nvapi-secret-value ${"x".repeat(1_000)}`,
+ }),
+ });
+
+ expect(seedHermesDashboardConfig("hermes", TARGET, deps)).toBe("failed");
+ const [, detail] = reportFailure.mock.calls[0];
+ expect(detail).toContain("Bearer ");
+ expect(detail).not.toContain("nvapi-secret-value");
+ expect(detail.length).toBeLessThanOrEqual(820);
+ });
+});
diff --git a/test/e2e/live/hermes-inference-switch.test.ts b/test/e2e/live/hermes-inference-switch.test.ts
index 2636c5ba551..f0175d72869 100644
--- a/test/e2e/live/hermes-inference-switch.test.ts
+++ b/test/e2e/live/hermes-inference-switch.test.ts
@@ -52,6 +52,8 @@ import {
const TIMEOUT_MS = 45 * 60_000;
const MOCK_BASELINE_API_KEY = "hermes-inference-switch-baseline-credential";
const MOCK_BASELINE_MODEL = "hermes-inference-switch-baseline-model";
+const HERMES_DASHBOARD_INTERNAL_PORT =
+ process.env.NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT ?? "19119";
function canonicalEndpoint(value: unknown): string | null {
return typeof value === "string" ? new URL(value).toString() : null;
@@ -142,16 +144,20 @@ test("Hermes inference set updates route/config and preserves live runtime", {
const redactionValues = [apiKey, publicApiKey].filter(
(value): value is string => typeof value === "string",
);
- const installEnv: NodeJS.ProcessEnv = mockBaseline
- ? {
- COMPATIBLE_API_KEY: apiKey,
- NEMOCLAW_COMPAT_MODEL: MOCK_BASELINE_MODEL,
- NEMOCLAW_ENDPOINT_URL: mockBaseline.baseUrl,
- NEMOCLAW_MODEL: MOCK_BASELINE_MODEL,
- NEMOCLAW_PREFERRED_API: "openai-completions",
- NEMOCLAW_PROVIDER: "custom",
- }
- : {};
+ const installEnv: NodeJS.ProcessEnv = {
+ NEMOCLAW_HERMES_DASHBOARD: "1",
+ NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT: HERMES_DASHBOARD_INTERNAL_PORT,
+ ...(mockBaseline
+ ? {
+ COMPATIBLE_API_KEY: apiKey,
+ NEMOCLAW_COMPAT_MODEL: MOCK_BASELINE_MODEL,
+ NEMOCLAW_ENDPOINT_URL: mockBaseline.baseUrl,
+ NEMOCLAW_MODEL: MOCK_BASELINE_MODEL,
+ NEMOCLAW_PREFERRED_API: "openai-completions",
+ NEMOCLAW_PROVIDER: "custom",
+ }
+ : {}),
+ };
const install = await installHermes(host, apiKey, installEnv);
expect(install.exitCode, resultText(install)).toBe(0);
@@ -223,6 +229,41 @@ test("Hermes inference set updates route/config and preserves live runtime", {
expect((await apiKeyShape(sandbox)).exitCode).toBe(0);
expect(config.stdout).not.toMatch(/^models:\s*$/mu);
+ const dashboardConfig = await sandbox.exec(
+ SANDBOX_NAME,
+ ["cat", "/sandbox/.hermes/dashboard-home/config.yaml"],
+ {
+ artifactName: "hermes-dashboard-config-yaml-after-switch",
+ env: env(),
+ redactionValues,
+ timeoutMs: 30_000,
+ },
+ );
+ expect(dashboardConfig.exitCode, resultText(dashboardConfig)).toBe(0);
+ const dashboardModel = parseHermesModelBlock(dashboardConfig.stdout);
+ expect(dashboardModel.default).toBe(SWITCH_MODEL);
+ expect(dashboardModel.provider).toBe(SWITCH_PROVIDER);
+ expect(dashboardModel.base_url).toBe(expectedBaseUrl());
+ expect(dashboardModel.api_mode).toBe(expectedApiMode());
+
+ const dashboardModelInfo = await sandbox.exec(
+ SANDBOX_NAME,
+ [
+ "curl",
+ "-sf",
+ "--max-time",
+ "10",
+ `http://127.0.0.1:${HERMES_DASHBOARD_INTERNAL_PORT}/api/model/info`,
+ ],
+ {
+ artifactName: "hermes-dashboard-model-info-after-switch",
+ env: env(),
+ timeoutMs: 30_000,
+ },
+ );
+ expect(dashboardModelInfo.exitCode, resultText(dashboardModelInfo)).toBe(0);
+ expect(JSON.parse(dashboardModelInfo.stdout)).toMatchObject({ model: SWITCH_MODEL });
+
const strictHash = await hashCheck(sandbox, "/etc/nemoclaw/hermes.config-hash", "strict");
expect(strictHash.exitCode, resultText(strictHash)).toBe(0);
expect(strictHash.stdout).toContain("OK");