Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
13 changes: 8 additions & 5 deletions agents/hermes/seed-dashboard-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,14 +531,17 @@ def _normalized_routing(gateway: dict, routing_keys: list[str], policy: dict) ->

def _managed_policy_sections(gateway: dict, policy: dict) -> dict:
config = policy["config"]
section_names = {path.split(".", 1)[0] for path in policy["managed_paths"]}
for section_name in section_names:
if not _same_json_value(gateway.get(section_name), config.get(section_name)):
raise InvalidDashboardSeedDocumentError("gateway policy does not match managed policy")

sections: dict = {}
for dotted_path in policy["managed_paths"]:
expected = policy_value(config, dotted_path)
try:
actual = policy_value(gateway, dotted_path)
except ManagedPolicyError as exc:
raise InvalidDashboardSeedDocumentError(
"gateway policy does not match managed policy"
) from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if not _same_json_value(actual, expected):
raise InvalidDashboardSeedDocumentError("gateway policy does not match managed policy")
_set_policy_value(sections, dotted_path, deepcopy(expected))
expected_web = policy["config"].get("web")
gateway_web = gateway.get("web")
Expand Down
7 changes: 6 additions & 1 deletion src/lib/actions/sandbox/rebuild-backup-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight";
import { backupSandboxStateForRebuild, type RebuildSandboxEntry } from "./rebuild-flow-helpers";
import * as policyGet from "./policy-get";

export { clearRebuildPolicyHandoff, writeRebuildPolicyHandoff } from "../../state/sandbox";
export {
clearHermesOperatorConfigHandoff,
clearRebuildPolicyHandoff,
writeHermesOperatorConfigHandoff,
writeRebuildPolicyHandoff,
} from "../../state/sandbox";

export type RebuildBackupManifest = Exclude<
ReturnType<typeof backupSandboxStateForRebuild>,
Expand Down
192 changes: 191 additions & 1 deletion src/lib/actions/sandbox/rebuild-durable-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";

import type { ConfigObject } from "../../security/credential-filter";
import { createSession, normalizeSession } from "../../state/onboard-session";
import { resolveRebuildDurableConfig } from "./rebuild-durable-config";
import {
applyHermesOperatorConfigSnapshot,
captureHermesOperatorConfigSnapshot,
captureHermesOperatorConfigSnapshotFromConfig,
parseHermesOperatorConfigSnapshot,
resolveRebuildDurableConfig,
serializeHermesOperatorConfigSnapshot,
verifyHermesOperatorConfigSnapshot,
} from "./rebuild-durable-config";

describe("resolveRebuildDurableConfig", () => {
it("keeps the registry tool-disclosure selection authoritative", () => {
Expand Down Expand Up @@ -336,3 +348,181 @@ describe("resolveRebuildDurableConfig", () => {
expect(config.hermesAuthMethodError).toContain("cannot determine");
});
});

describe("Hermes operator config rebuild handoff", () => {
const liveConfig: ConfigObject = {
_config_version: 33,
_nemoclaw_upstream: {
provider: "compatible-endpoint",
provider_key: "compatible-endpoint",
model: "llama3.2:1b",
},
model: {
default: "llama3.2:1b",
provider: "custom",
base_url: "https://inference.local/v1",
api_key: "sentinel",
max_tokens: 24576,
},
providers: {
"compatible-endpoint": {
name: "compatible-endpoint",
api: "https://inference.local/v1",
api_key: "sentinel",
default_model: "llama3.2:1b",
discover_models: true,
stale_timeout_seconds: 181,
request_timeout_seconds: 182,
},
},
memory: { provider: "hindsight" },
delegation: {
model: "compatible-endpoint/llama3.2:1b",
child_timeout_seconds: 183,
},
approvals: { mode: "manual", timeout: 184 },
security: { allow_private_urls: true },
custom_providers: [
{
name: "compatible-endpoint",
base_url: "https://inference.local/v1",
api_key: "sentinel",
discover_models: true,
},
{ name: "operator-extra", base_url: "http://192.0.2.10/v1", discover_models: false },
],
operator_issue_10495: { enabled: true, label: "preserve-rebuild" },
};

const contractKeys = [
"memory.provider",
"model.max_tokens",
"providers.compatible-endpoint.stale_timeout_seconds",
"providers.compatible-endpoint.request_timeout_seconds",
"delegation.model",
"delegation.child_timeout_seconds",
"approvals.timeout",
"security.allow_private_urls",
"custom_providers",
"operator_issue_10495",
"model.default",
];

it("captures every audited non-route value and marks a managed route key dropped", () => {
const snapshot = captureHermesOperatorConfigSnapshotFromConfig(
"hermes",
liveConfig,
contractKeys,
);

expect(snapshot.entries.map((entry) => entry.key)).toEqual(contractKeys.slice(0, -1).sort());
expect(snapshot.droppedKeys).toEqual(["model.default"]);
expect(snapshot.entries.find((entry) => entry.key === "custom_providers")?.value).toEqual([
{ name: "operator-extra", base_url: "http://192.0.2.10/v1", discover_models: false },
]);
});

it("merges operator values over a fresh route and verifies restored and dropped keys", () => {
const snapshot = captureHermesOperatorConfigSnapshotFromConfig(
"hermes",
liveConfig,
contractKeys,
);
const fresh: ConfigObject = {
_nemoclaw_upstream: {
provider: "compatible-endpoint",
provider_key: "compatible-endpoint",
model: "new-model",
},
model: {
default: "new-model",
provider: "custom",
base_url: "https://inference.local/v1",
api_key: "fresh-sentinel",
},
providers: {
"compatible-endpoint": {
name: "compatible-endpoint",
api: "https://inference.local/v1",
api_key: "fresh-sentinel",
default_model: "new-model",
discover_models: true,
},
},
custom_providers: [
{
name: "compatible-endpoint",
base_url: "https://inference.local/v1",
api_key: "fresh-sentinel",
discover_models: true,
},
],
};

const merged = applyHermesOperatorConfigSnapshot(fresh, snapshot);
expect((merged.model as ConfigObject).default).toBe("new-model");
expect((merged.model as ConfigObject).max_tokens).toBe(24576);
expect(merged.custom_providers).toHaveLength(2);
expect(verifyHermesOperatorConfigSnapshot(merged, snapshot)).toEqual({
restoredKeys: contractKeys.slice(0, -1).sort(),
droppedKeys: ["model.default"],
});
});

it("rejects unsupported paths from a tampered handoff before merge", () => {
const document = JSON.stringify({
version: 1,
sandboxName: "hermes",
entries: [{ key: "gateway.authToken", value: "unsafe" }],
droppedKeys: [],
});
expect(parseHermesOperatorConfigSnapshot(document, "hermes")).toBeNull();
expect(
parseHermesOperatorConfigSnapshot(
JSON.stringify({
version: 1,
sandboxName: "hermes",
entries: [],
droppedKeys: ["gateway.authToken"],
}),
"hermes",
),
).toBeNull();
expect(() =>
captureHermesOperatorConfigSnapshotFromConfig("hermes", liveConfig, ["custom_providers.0"]),
).toThrow("unsupported Hermes operator config key");
});

it("reads audited config-set keys and round-trips a bounded snapshot", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-10495-audit-"));
try {
const auditFile = path.join(dir, "shields-audit.jsonl");
fs.writeFileSync(
auditFile,
[
JSON.stringify({
action: "config_set",
sandbox: "hermes",
reason: "config set hermes:model.max_tokens",
}),
JSON.stringify({
action: "config_set",
sandbox: "other",
reason: "config set hermes:memory.provider",
}),
].join("\n"),
);
const snapshot = captureHermesOperatorConfigSnapshot("hermes", {
auditFile,
resolveConfig: () => ({ agentName: "hermes" }) as never,
readConfig: () => liveConfig,
});
const document = serializeHermesOperatorConfigSnapshot(snapshot);
expect(parseHermesOperatorConfigSnapshot(document, "hermes")).toEqual(snapshot);
expect(parseHermesOperatorConfigSnapshot(document, "other")).toBeNull();
expect(snapshot.entries).toEqual([{ key: "model.max_tokens", value: 24576 }]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
Loading
Loading