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
18 changes: 18 additions & 0 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ config:
writable_dir: /sandbox/.openclaw-data
config_file: openclaw.json # relative to immutable_dir
format: json
# Known top-level sections in openclaw.json. Used by `nemoclaw config set`
# to reject obvious typos (e.g. `inferenc.endpoint`) without coupling
# NemoClaw to OpenClaw's evolving config schema. Agents that omit this
# field skip validation entirely (see #2400).
known_sections:
- llm
- tts
- stt
- mcp
- sandbox
- extensions
- personas
- customization
- inference
- plugins
- skills
- ui
- logging

# ── State directories ──────────────────────────────────────────
state_dirs:
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface AgentDefinition {
readonly healthProbe: AgentHealthProbe;
readonly forwardPort: number;
readonly configPaths: AgentConfigPaths;
readonly knownConfigSections: string[] | null;
readonly stateDirs: string[];
readonly versionCommand: string;
readonly expectedVersion: string | null;
Expand Down Expand Up @@ -148,6 +149,22 @@ export function loadAgent(name: string): AgentDefinition {
};
},

/**
* Optional list of known top-level config sections, declared by the
* agent in `manifest.yaml > config.known_sections`. Used by
* `nemoclaw config set` to give an early error on obvious typos
* (e.g. `inferenc.endpoint`) without coupling NemoClaw to any one
* agent's evolving config schema. When an agent does NOT declare
* this list, validation is skipped — agents stay free to add new
* top-level keys without a NemoClaw release.
*/
get knownConfigSections(): string[] | null {
const cfg = (raw.config as Record<string, unknown>) || {};
const sections = cfg.known_sections;
if (!Array.isArray(sections) || sections.length === 0) return null;
return sections.filter((s): s is string => typeof s === "string");
},
Comment on lines +161 to +166
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 | 🟡 Minor

Return null when the filtered allowlist is empty.

If config.known_sections contains only non-string entries, this getter currently returns [], which makes configSet silently skip validation for a malformed manifest. Normalizing an empty filtered result back to null keeps the documented contract intact.

🛠️ Suggested fix
     get knownConfigSections(): string[] | null {
       const cfg = (raw.config as Record<string, unknown>) || {};
       const sections = cfg.known_sections;
       if (!Array.isArray(sections) || sections.length === 0) return null;
-      return sections.filter((s): s is string => typeof s === "string");
+      const knownSections = sections.filter((s): s is string => typeof s === "string");
+      return knownSections.length > 0 ? knownSections : null;
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/agent-defs.ts` around lines 161 - 166, The getter knownConfigSections
currently returns an empty array when cfg.known_sections contains no string
entries, violating the documented contract and causing configSet to skip
validation; update the knownConfigSections getter (in src/lib/agent-defs.ts) to
normalize the filtered result so that if the filtered array of strings is empty
it returns null instead of [], i.e. after filtering (s): s is string ensure you
check filtered.length and return null when zero, otherwise return the filtered
string array.


get stateDirs(): string[] {
return (raw.state_dirs as string[]) || [];
},
Expand Down
47 changes: 39 additions & 8 deletions src/lib/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ interface AgentConfigTarget {
format: string;
/** Config file basename */
configFile: string;
/**
* Optional list of known top-level config sections from the agent's
* manifest. When provided, `nemoclaw config set` rejects writes whose
* top-level key isn't in this set. When null, validation is skipped
* (agents are free to evolve their config schema without a NemoClaw
* release).
*/
knownSections: string[] | null;
}

const DEFAULT_AGENT_CONFIG: AgentConfigTarget = {
Expand All @@ -57,6 +65,7 @@ const DEFAULT_AGENT_CONFIG: AgentConfigTarget = {
configDir: "/sandbox/.openclaw",
format: "json",
configFile: "openclaw.json",
knownSections: null,
};

function resolveAgentConfig(sandboxName: string): AgentConfigTarget {
Expand All @@ -75,6 +84,7 @@ function resolveAgentConfig(sandboxName: string): AgentConfigTarget {
configDir: cfg.immutableDir,
format: cfg.format || "json",
configFile: cfg.configFile,
knownSections: agent.knownConfigSections,
};
} catch {
// Registry or agent-defs unavailable (e.g., during tests) — fall back
Expand Down Expand Up @@ -308,22 +318,43 @@ function configSet(sandboxName: string, opts: ConfigSetOpts = {}): void {
process.exit(1);
}

// 5. Show what will change
// 5. Validate the top-level config section against the agent's manifest.
// The agent's manifest.yaml may declare `config.known_sections`. When it
// does, we reject obvious typos here for a fast, friendly error. When it
// doesn't (e.g. for the Hermes agent, whose schema is intentionally open),
// validation is skipped — NemoClaw stays decoupled from each agent's
// evolving config schema (per #2400).
const topLevelKey = opts.key.split(".")[0];
if (target.knownSections && target.knownSections.length > 0) {
if (!target.knownSections.includes(topLevelKey)) {
console.error(` Unknown config section: '${topLevelKey}'`);
console.error(
` Valid top-level keys for agent '${target.agentName}': ${[...target.knownSections].sort().join(", ")}`,
);
console.error(
` (Allowlist is declared in agents/${target.agentName}/manifest.yaml → config.known_sections.\n` +
` If '${topLevelKey}' is a real new section, add it there in a follow-up PR.)`,
);
process.exit(1);
}
}

// 6. Show what will change
const oldValue = extractDotpath(config, opts.key);
console.log(` Agent: ${target.agentName}`);
console.log(` Key: ${opts.key}`);
console.log(` Old value: ${oldValue !== undefined ? JSON.stringify(oldValue) : "(not set)"}`);
console.log(` New value: ${JSON.stringify(parsedValue)}`);

// 6. Apply change
// 7. Apply change
setDotpath(config, opts.key, parsedValue);

// 7. Write to temp file in the agent's native format
// 8. Write to temp file in the agent's native format
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-"));
const tmpFile = path.join(tmpDir, target.configFile);
fs.writeFileSync(tmpFile, serializeConfig(config, target.format), { mode: 0o600 });

// 8. Write config to sandbox via kubectl exec (bypasses Landlock)
// 9. Write config to sandbox via kubectl exec (bypasses Landlock)
console.log(` Writing config to sandbox (${target.configPath})...`);
const content = fs.readFileSync(tmpFile, "utf-8");
execFileSync("docker", [
Expand All @@ -332,7 +363,7 @@ function configSet(sandboxName: string, opts: ConfigSetOpts = {}): void {
"sh", "-c", `cat > ${target.configPath}`,
], { input: content, stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });

// 9. Fix ownership via kubectl exec (bypasses Landlock)
// 10. Fix ownership via kubectl exec (bypasses Landlock)
try {
execFileSync("docker", [
"exec", K3S_CONTAINER,
Expand All @@ -343,15 +374,15 @@ function configSet(sandboxName: string, opts: ConfigSetOpts = {}): void {
// Best effort — chown failure is non-fatal
}

// 10. Cleanup temp
// 11. Cleanup temp
try {
fs.unlinkSync(tmpFile);
fs.rmdirSync(tmpDir);
} catch {
// Best effort
}

// 11. Audit log
// 12. Audit log
appendAuditEntry({
action: "shields_down",
sandbox: sandboxName,
Expand All @@ -361,7 +392,7 @@ function configSet(sandboxName: string, opts: ConfigSetOpts = {}): void {

console.log(` ${target.agentName} config updated.`);

// 12. Restart if requested
// 13. Restart if requested
if (opts.restart) {
console.log(" Restarting sandbox agent process...");
const restartBinary = getOpenshellBinary();
Expand Down
60 changes: 60 additions & 0 deletions test/config-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect } from "vitest";
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const yaml = require("js-yaml") as { load: (s: string) => unknown };
Comment on lines +5 to +9
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check if the test file exists and read the relevant lines
cat -n test/config-set.test.ts | head -20

Repository: NVIDIA/NemoClaw

Length of output: 1115


🏁 Script executed:

# Check the entire js-yaml import section and surrounding context
cat -n test/config-set.test.ts

Repository: NVIDIA/NemoClaw

Length of output: 8313


🏁 Script executed:

# Look for eslint configuration files
find . -maxdepth 2 -type f \( -name ".eslintrc*" -o -name "eslint.config.*" \) | head -20

Repository: NVIDIA/NemoClaw

Length of output: 108


🏁 Script executed:

# Check if js-yaml is in package.json and what version
cat package.json | grep -A 5 -B 5 "js-yaml" || echo "Not found in grep, checking full file"

Repository: NVIDIA/NemoClaw

Length of output: 814


🏁 Script executed:

# Check js-yaml module to understand if it supports ESM
ls -la node_modules/js-yaml 2>/dev/null | head -20 || echo "node_modules not available, checking package.json exports"

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Look for how js-yaml is used elsewhere in the codebase
rg "js-yaml" --type ts --type js -A 2 -B 2

Repository: NVIDIA/NemoClaw

Length of output: 674


🏁 Script executed:

# Check the main eslint configuration
cat eslint.config.mjs | head -100

Repository: NVIDIA/NemoClaw

Length of output: 2966


🏁 Script executed:

# Check if `@typescript-eslint/no-require-imports` rule is mentioned
grep -r "no-require-imports" eslint.config.mjs || echo "Rule not found in eslint.config.mjs"

Repository: NVIDIA/NemoClaw

Length of output: 95


🏁 Script executed:

# Look up js-yaml package to see if it has ESM exports
cat package.json | jq '.dependencies' || cat package.json | grep -A 10 '"dependencies"'

Repository: NVIDIA/NemoClaw

Length of output: 128


🏁 Script executed:

# Check if js-yaml has ESM support by looking at common patterns
web_search
js-yaml npm package ESM import support version 4.1

Repository: NVIDIA/NemoClaw

Length of output: 156


🌐 Web query:

js-yaml npm version 4.1.1 ESM import support

💡 Result:

Answer: [email protected] does not provide native ESM import support for Node.js as a package-level feature; it’s primarily CommonJS, and the project historically declined to rewrite for ESM/ES6 import syntax. In practice, you can still use it from ESM via default/CommonJS interop, or by importing a dedicated ESM build from a CDN path (not the npm package entrypoint).

Citations:


🏁 Script executed:

# Verify: check if there are any other uses of this suppression in the codebase
rg "@typescript-eslint/no-require-imports" --type ts --type js

Repository: NVIDIA/NemoClaw

Length of output: 1033


🏁 Script executed:

# Double-check the test section of eslint.config.mjs for any missed rules
grep -A 30 'files:.*test' eslint.config.mjs

Repository: NVIDIA/NemoClaw

Length of output: 1640


Remove the stale eslint suppression comment.

The @typescript-eslint/no-require-imports rule is not configured in eslint.config.mjs, so the suppression has no effect and should be removed as dead code. The require() here is intentional; eslint's test configuration explicitly allows require in test files to support CommonJS interop (e.g., js-yaml v4.1.1, which lacks native ESM support). No import pattern change is needed.

🧰 Tools
🪛 ESLint

[error] 8-8: Definition for rule '@typescript-eslint/no-require-imports' was not found.

(@typescript-eslint/no-require-imports)

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

In `@test/config-set.test.ts` around lines 5 - 9, Remove the stale eslint
suppression comment before the require call in test/config-set.test.ts: delete
the line "// eslint-disable-next-line `@typescript-eslint/no-require-imports`" so
the file uses the intentional CommonJS require for the yaml constant (const yaml
= require("js-yaml") as { load: (s: string) => unknown };) without dead
suppression comments.


// Build must run before these tests (imports from dist/)
const { extractDotpath, setDotpath, validateUrlValue, resolveAgentConfig } = require("../dist/lib/sandbox-config");
Expand All @@ -23,6 +28,61 @@ describe("resolveAgentConfig", () => {
const target = resolveAgentConfig("any-sandbox");
expect(target.configPath.endsWith(target.configFile)).toBe(true);
});

it("defaults knownSections to null when manifest is absent (e.g. fallback)", () => {
const target = resolveAgentConfig("nonexistent-sandbox");
expect(target.knownSections).toBe(null);
});
});

describe("config set top-level section validation (manifest-aware)", () => {
// Tracks the design discussion on #2071 / #2400: the validator must NOT
// hardcode an OpenClaw-specific allowlist. It must read `config.known_sections`
// from the agent's manifest, and skip validation entirely when the agent
// doesn't declare one (so agents like Hermes with their own schema aren't
// blocked).

const sandboxConfigSource = fs.readFileSync(
path.join(import.meta.dirname, "..", "src", "lib", "sandbox-config.ts"),
"utf-8",
);

it("does not contain a hardcoded KNOWN_TOP_LEVEL_KEYS allowlist", () => {
assert.doesNotMatch(sandboxConfigSource, /KNOWN_TOP_LEVEL_KEYS\s*=\s*new Set/);
});

it("reads the allowlist from the agent's manifest via target.knownSections", () => {
assert.match(sandboxConfigSource, /target\.knownSections/);
assert.match(
sandboxConfigSource,
/if \(target\.knownSections && target\.knownSections\.length > 0\)/,
);
});

it("declares known_sections in the openclaw agent manifest", () => {
const manifest = yaml.load(
fs.readFileSync(
path.join(import.meta.dirname, "..", "agents", "openclaw", "manifest.yaml"),
"utf-8",
),
) as { config?: { known_sections?: string[] } };
expect(Array.isArray(manifest.config?.known_sections)).toBe(true);
expect(manifest.config!.known_sections!.length).toBeGreaterThan(0);
// Spot-check a few essentials.
for (const key of ["llm", "sandbox", "extensions"]) {
expect(manifest.config!.known_sections).toContain(key);
}
});

it("does NOT declare known_sections in the hermes agent manifest (intentionally permissive)", () => {
const manifest = yaml.load(
fs.readFileSync(
path.join(import.meta.dirname, "..", "agents", "hermes", "manifest.yaml"),
"utf-8",
),
) as { config?: { known_sections?: string[] } };
expect(manifest.config?.known_sections).toBeUndefined();
});
});

describe("config set helpers", () => {
Expand Down