chore(tooling): switch linting to biome (#2862)

## Summary
Switches JavaScript and TypeScript linting/formatting from ESLint and
Prettier to Biome. Keeps the credential environment-variable security
check by replacing the custom ESLint rule with a standalone TypeScript
guard wired into npm scripts and prek.

## Changes
- Add `biome.json` and update root/plugin npm scripts plus `Makefile`
targets to use Biome.
- Replace prek Prettier/ESLint hooks with Biome format/lint hooks.
- Remove ESLint/Prettier configs and dependencies from root and plugin
projects.
- Add `scripts/check-direct-credential-env.ts` and update tests to
preserve the direct credential env guard.
- Remove stale ESLint suppression comments from source and tests.

## Type of Change
- [x] Code change (feature, bug fix, or refactor)
- [ ] Code change with doc updates
- [ ] Doc only (prose changes, no code sample modifications)
- [ ] Doc only (includes code sample changes)

## Verification
- [x] `npx prek run --all-files` passes
- [x] `npm test` passes
- [x] Tests added or updated for new or changed behavior
- [x] No secrets, API keys, or credentials committed
- [ ] Docs updated for user-facing behavior changes
- [ ] `make docs` builds without warnings (doc changes only)
- [ ] Doc pages follow the [style
guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md)
(doc changes only)
- [ ] New doc pages include SPDX header and frontmatter (new pages only)

## AI Disclosure
- [x] AI-assisted — tool: pi coding agent

---
Signed-off-by: Carlos Villela <cvillela@nvidia.com>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Migrated formatting/linting from ESLint/Prettier to Biome and removed
legacy formatter configs and many inline lint suppressions.
* **New Features**
* Added an automated CLI check that scans source files for direct reads
of credential environment variables.
* **Documentation**
* Updated linting guidance to reflect Biome-based configuration and
project linting behavior.
* **Bug Fixes**
* Interactive consent now prints " Installation cancelled" on prompt
errors and returns false.
* **Tests**
* Added/updated tests for credential-guarding logic and the interactive
consent error path.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Carlos Villela <cvillela@nvidia.com>
This commit is contained in:
Carlos Villela 2026-05-01 16:16:37 -07:00 committed by GitHub
parent fdcc8c18f8
commit defeffc3e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 941 additions and 2860 deletions

View file

@ -16,8 +16,8 @@
# Priority groups (prek runs same-priority hooks in parallel):
# 0 — General file fixers (whitespace, EOF, line endings)
# 4 — SPDX header insertion (--fix)
# 5 — Shell / TS formatters (shfmt, prettier)
# 6 — Fixes that should follow formatters (ruff check --fix, eslint --fix)
# 5 — Shell / JS / TS formatters (shfmt, Biome)
# 6 — Fixes that should follow formatters (ruff check --fix, Biome lint --write)
# 10 — Linters and read-only checks
# 20 — Project-level checks (vitest, coverage, ratchet)
@ -96,40 +96,22 @@ repos:
- repo: local
hooks:
- id: prettier-plugin
name: Prettier (plugin)
entry: bash -c 'root="$(git rev-parse --show-toplevel)" && cd "$root/nemoclaw" && files=() && for f in "$@"; do files+=("${f#nemoclaw/}"); done && npx prettier --write "${files[@]}"' --
- id: biome-format
name: Biome format
entry: npx biome format --write
language: system
files: ^nemoclaw/.*\.ts$
pass_filenames: true
priority: 5
- id: prettier-js
name: Prettier (JavaScript)
entry: npx prettier --write
language: system
files: ^(bin|test)/.*\.js$
files: ^(biome\.json|package(-lock)?\.json|nemoclaw/package(-lock)?\.json|commitlint\.config\.js|bin/.*\.js|src/.*\.ts|scripts/.*\.(js|ts)|test/.*\.js|nemoclaw/src/.*\.ts)$
pass_filenames: true
priority: 5
# ── Priority 6: auto-fix after formatting ─────────────────────────────────
- repo: local
hooks:
- id: eslint-plugin
name: ESLint (plugin)
entry: bash -c 'root="$(git rev-parse --show-toplevel)" && cd "$root/nemoclaw" && files=() && for f in "$@"; do files+=("${f#nemoclaw/}"); done && npx eslint --fix "${files[@]}"' --
- id: biome-lint-fix
name: Biome lint fixes
entry: npx biome lint --write
language: system
files: ^nemoclaw/.*\.ts$
pass_filenames: true
priority: 6
- repo: local
hooks:
- id: eslint-cli
name: ESLint (CLI)
entry: npx eslint --fix
language: system
files: ^(bin|test|scripts|docs/_ext)/.*\.js$
files: ^(commitlint\.config\.js|bin/.*\.js|src/.*\.ts|scripts/.*\.(js|ts)|test/.*\.js|docs/_ext/.*\.js|nemoclaw/src/.*\.ts)$
pass_filenames: true
priority: 6
@ -167,6 +149,14 @@ repos:
files: ^(nemoclaw-blueprint/.*\.yaml$|nemoclaw/openclaw\.plugin\.json$|schemas/.*\.json$)
priority: 10
- id: direct-credential-env
name: Direct credential env guard
entry: npx tsx scripts/check-direct-credential-env.ts
language: system
files: ^src/lib/onboard\.ts$
pass_filenames: true
priority: 10
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:

View file

@ -1,7 +0,0 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -1,3 +1,6 @@
<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
# Agent Instructions
## Project Overview
@ -106,13 +109,13 @@ For shell scripts use `#` comments. For Markdown use HTML comments.
- `bin/` launcher and remaining `scripts/*.js`: **CommonJS** (`require`/`module.exports`), Node.js 22.16+
- `test/`: **ESM** (`import`/`export`)
- ESLint config in `eslint.config.mjs`
- Cyclomatic complexity limit: 20 (ratcheting down to 15)
- Biome config in `biome.json`
- Keep function complexity low; existing complexity hotspots are tracked separately
- Unused vars pattern: prefix with `_`
### TypeScript
- Plugin code in `nemoclaw/src/` with its own ESLint config
- Plugin code in `nemoclaw/src/` is linted and formatted by the root Biome config
- CLI type-checking via `tsconfig.cli.json`
- Plugin type-checking via `nemoclaw/tsconfig.json`
@ -166,7 +169,7 @@ All hooks managed by [prek](https://prek.j178.dev/) (installed via `npm install`
### Gotchas
- `npm install` at root triggers `prek install` which sets up git hooks. If hooks fail, check that `core.hooksPath` is unset: `git config --unset core.hooksPath`
- The `nemoclaw/` subdirectory has its own `package.json`, `node_modules/`, and ESLint config — it's a separate npm project
- The `nemoclaw/` subdirectory has its own `package.json` and `node_modules`, while sharing the root Biome config — it's a separate npm project
- SPDX headers are auto-inserted by pre-commit hooks; don't worry about adding them manually
- Coverage thresholds are ratcheted in `ci/coverage-threshold-*.json` — new code should not decrease CLI or plugin coverage
- The `.claude/skills` symlink points to `.agents/skills` — both paths resolve to the same content

View file

@ -1,4 +1,4 @@
.PHONY: check lint format lint-ts format-ts check-installer-hash docs docs-strict docs-live docs-clean
.PHONY: check lint format format-biome lint-ts format-ts check-installer-hash docs docs-strict docs-live docs-clean
check:
npx prek run --all-files
@ -10,10 +10,10 @@ lint: check
lint-ts:
cd nemoclaw && npm run check
format: format-ts format-cli
format: format-biome
format-cli:
npx prettier --write 'bin/**/*.js' 'test/**/*.js'
format-biome:
npx biome format --write .
format-ts:
cd nemoclaw && npm run lint:fix && npm run format

152
biome.json Normal file
View file

@ -0,0 +1,152 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": [
"biome.json",
"commitlint.config.js",
"package.json",
"package-lock.json",
"nemoclaw/package.json",
"nemoclaw/package-lock.json",
"src/**/*.ts",
"bin/**/*.js",
"scripts/**/*.js",
"scripts/**/*.ts",
"test/**/*.js",
"docs/_ext/**/*.js",
"nemoclaw/src/**/*.ts",
"!dist",
"!nemoclaw/dist",
"!node_modules",
"!nemoclaw/node_modules",
"!docs/_build"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf"
},
"javascript": {
"globals": [
"Buffer",
"CustomEvent",
"DOMParser",
"DocumentLoader",
"Element",
"Event",
"HTMLElement",
"MutationObserver",
"SearchEngine",
"SearchPageManager",
"URL",
"URLSearchParams",
"Utils",
"__dirname",
"__filename",
"clearInterval",
"clearTimeout",
"console",
"document",
"exports",
"fetch",
"lunr",
"module",
"process",
"require",
"setInterval",
"setTimeout",
"window"
],
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"correctness": {
"noUndeclaredVariables": "error"
}
}
},
"overrides": [
{
"includes": [
"bin/**/*.js",
"commitlint.config.js",
"scripts/**/*.js",
"test/**/*.js",
"docs/_ext/**/*.js"
],
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "error"
}
}
}
},
{
"includes": ["src/**/*.ts"],
"formatter": {
"enabled": false
}
},
{
"includes": ["docs/_ext/**/*.js"],
"formatter": {
"enabled": false
},
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "off"
}
}
}
},
{
"includes": ["nemoclaw/src/**/*.ts"],
"linter": {
"rules": {
"complexity": {
"useOptionalChain": "error"
},
"correctness": {
"noUnusedVariables": "error"
},
"nursery": {
"noFloatingPromises": "error",
"useExhaustiveSwitchCases": "error",
"useNullishCoalescing": "error"
},
"style": {
"noCommonJs": "error",
"useExportType": "error",
"useImportType": "error"
},
"suspicious": {
"noExplicitAny": "error"
}
}
}
}
]
}

View file

@ -7,17 +7,7 @@ module.exports = {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"chore",
"refactor",
"test",
"ci",
"perf",
"merge",
],
["feat", "fix", "docs", "chore", "refactor", "test", "ci", "perf", "merge"],
],
},
};

View file

@ -1,105 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
/**
* ESLint rule: no-direct-credential-env
*
* Flags direct `process.env` access for known provider credential keys
* in read context. Use `resolveProviderCredential()` or `getCredential()`
* instead, which resolve from both env and ~/.nemoclaw/credentials.json.
*
* See #2306.
*/
"use strict";
const CREDENTIAL_ENV_KEYS = new Set([
"NVIDIA_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"COMPATIBLE_API_KEY",
"COMPATIBLE_ANTHROPIC_API_KEY",
]);
const MESSAGE =
"Direct process.env access for provider credentials bypasses credentials.json. " +
"Use resolveProviderCredential() or getCredential() instead. See #2306.";
module.exports = {
meta: {
type: "problem",
docs: {
description:
"Disallow direct process.env access for known provider credential keys in read context",
},
schema: [],
messages: {
noDirectCredentialEnv: MESSAGE,
},
},
create(context) {
return {
MemberExpression(node) {
// Only flag reads, not assignments (process.env.KEY = value is OK)
if (isAssignmentTarget(node)) return;
// Must be process.env.SOMETHING or process.env[something]
if (!isProcessEnvAccess(node)) return;
// Static access: process.env.NVIDIA_API_KEY
if (!node.computed && node.property.type === "Identifier") {
if (CREDENTIAL_ENV_KEYS.has(node.property.name)) {
context.report({ node, messageId: "noDirectCredentialEnv" });
}
return;
}
// Static computed access: process.env["NVIDIA_API_KEY"]
if (node.computed && node.property.type === "Literal") {
if (
typeof node.property.value === "string" &&
CREDENTIAL_ENV_KEYS.has(node.property.value)
) {
context.report({ node, messageId: "noDirectCredentialEnv" });
}
return;
}
// Dynamic access: process.env[credentialEnv]
if (node.computed && node.property.type === "Identifier") {
if (/credential/i.test(node.property.name)) {
context.report({ node, messageId: "noDirectCredentialEnv" });
}
}
},
};
},
};
/**
* Check if this node is in a write context (assignment target or delete operand).
* e.g. process.env.KEY = value true (assignment)
* delete process.env.KEY true (deletion)
* process.env.KEY false (read context)
*/
function isAssignmentTarget(node) {
const parent = node.parent;
if (!parent) return false;
if (parent.type === "AssignmentExpression" && parent.left === node) return true;
if (parent.type === "UnaryExpression" && parent.operator === "delete") return true;
return false;
}
/**
* Check if this MemberExpression is accessing process.env
* (i.e., the object is process.env and we're accessing a property of it).
*/
function isProcessEnvAccess(node) {
const obj = node.object;
if (obj.type !== "MemberExpression") return false;
if (obj.object.type !== "Identifier" || obj.object.name !== "process") return false;
if (obj.property.type !== "Identifier" || obj.property.name !== "env") return false;
return true;
}

View file

@ -1,156 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import js from "@eslint/js";
import tsParser from "@typescript-eslint/parser";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const noDirectCredentialEnv = require("./eslint-rules/no-direct-credential-env.js");
export default [
// Ignore build artifacts, vendored code, and the nemoclaw sub-project (has its own config)
{
ignores: ["nemoclaw/**", "node_modules/**", "dist/**", "docs/_build/**"],
},
// ── bin/ and scripts/ — CommonJS, Node.js ──────────────────────────────
{
...js.configs.recommended,
files: ["bin/**/*.js", "scripts/**/*.js", "commitlint.config.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "commonjs",
globals: {
require: "readonly",
module: "readonly",
exports: "readonly",
__dirname: "readonly",
__filename: "readonly",
process: "readonly",
console: "readonly",
Buffer: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
URL: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
// Cyclomatic complexity — ratchet down to 15 as we refactor suppressed functions
complexity: ["error", { max: 20 }],
},
},
// ── test/ — ESM, Node.js (vitest globals come from imports) ────────────
{
...js.configs.recommended,
files: ["test/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
// Tests are ESM but some test CJS modules via require()/require.cache
require: "readonly",
process: "readonly",
console: "readonly",
Buffer: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
URL: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
},
},
// ── test/ — TypeScript syntax, Node.js runtime ─────────────────────────
{
...js.configs.recommended,
files: ["test/**/*.ts"],
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: "module",
globals: {
require: "readonly",
process: "readonly",
console: "readonly",
Buffer: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
URL: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": "off",
},
},
// ── src/lib/onboard.ts — credential env guard (#2306) ───────────────────
{
files: ["src/lib/onboard.ts"],
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: "module",
},
plugins: {
nemoclaw: {
rules: { "no-direct-credential-env": noDirectCredentialEnv },
},
},
rules: {
"nemoclaw/no-direct-credential-env": "error",
},
},
// ── docs/ — browser JS ─────────────────────────────────────────────────
{
...js.configs.recommended,
files: ["docs/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "script",
globals: {
window: "readonly",
document: "readonly",
console: "readonly",
fetch: "readonly",
URL: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
CustomEvent: "readonly",
HTMLElement: "readonly",
Element: "readonly",
Event: "readonly",
MutationObserver: "readonly",
DOMParser: "readonly",
URLSearchParams: "readonly",
lunr: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
},
},
];

View file

@ -1,7 +0,0 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -1,49 +0,0 @@
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettier from "eslint-config-prettier";
export default [
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
projectService: {
allowDefaultProject: ["src/*.test.ts", "src/*/*.test.ts"],
},
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
...tseslint.configs["strict-type-checked"]?.rules,
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/consistent-type-exports": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/prefer-nullish-coalescing": [
"error",
{ ignorePrimitives: { string: true } },
],
"@typescript-eslint/prefer-optional-chain": "error",
},
},
{
files: ["src/**/*.test.ts"],
rules: {
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/unbound-method": "off",
},
},
prettier,
];

File diff suppressed because it is too large Load diff

View file

@ -14,10 +14,10 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
"format:check": "biome format src",
"check": "npm run lint && npm run format:check && tsc --noEmit",
"clean": "rm -rf dist/"
},
@ -29,12 +29,8 @@
"yaml": "^2.4.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^8.57.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"typescript": "^5.4.0",
"vitest": "^4.1.0"
},

View file

@ -24,8 +24,9 @@ vi.mock("node:fs", async (importOriginal) => {
};
});
const { getNetworkEntries, getPrivateNetworks, isPrivateHostname, resetCache } =
await import("./private-networks.js");
const { getNetworkEntries, getPrivateNetworks, isPrivateHostname, resetCache } = await import(
"./private-networks.js"
);
const VALID_YAML = `
ipv4:

View file

@ -72,7 +72,6 @@ vi.mock("execa", () => ({
}));
vi.mock("./ssrf.js", () => ({
// eslint-disable-next-line @typescript-eslint/require-await
validateEndpointUrl: vi.fn(async (url: string) => ({ url, pinnedUrl: url })),
}));
@ -729,14 +728,17 @@ describe("runner", () => {
// ── Path traversal rejection ──────────────────────────────────
it.each(["../../etc", "../tmp", "valid.with.dots", "foo\x00bar", "/absolute/path"])(
"rejects malicious run ID: %j",
(rid) => {
expect(() => {
actionStatus(rid);
}).toThrow(/Invalid run ID/);
},
);
it.each([
"../../etc",
"../tmp",
"valid.with.dots",
"foo\x00bar",
"/absolute/path",
])("rejects malicious run ID: %j", (rid) => {
expect(() => {
actionStatus(rid);
}).toThrow(/Invalid run ID/);
});
it("accepts a legitimate hyphenated run ID", () => {
const rid = "nc-20260406-abc12345";
@ -801,12 +803,16 @@ describe("runner", () => {
// ── Path traversal rejection ──────────────────────────────────
it.each(["../../etc", "../tmp", "valid.with.dots", "foo\x00bar", "/absolute/path", ""])(
"rejects malicious run ID: %j",
async (rid) => {
await expect(actionRollback(rid)).rejects.toThrow(/Invalid run ID/);
},
);
it.each([
"../../etc",
"../tmp",
"valid.with.dots",
"foo\x00bar",
"/absolute/path",
"",
])("rejects malicious run ID: %j", async (rid) => {
await expect(actionRollback(rid)).rejects.toThrow(/Invalid run ID/);
});
it("defaults sandbox_name to 'openclaw' when not in plan", async () => {
const runDir = `${RUNS_DIR}/nc-run-1`;

View file

@ -1490,16 +1490,17 @@ describe("commands/migration-state", () => {
}
};
it.each(["__proto__", "constructor", "prototype"])(
"rejects unsafe path segment: %s",
(segment) => {
const doc: Record<string, unknown> = {};
expect(() => {
setConfigValue(doc, `${segment}.polluted`, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
},
);
it.each([
"__proto__",
"constructor",
"prototype",
])("rejects unsafe path segment: %s", (segment) => {
const doc: Record<string, unknown> = {};
expect(() => {
setConfigValue(doc, `${segment}.polluted`, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
});
it("rejects __proto__ in nested position", () => {
const doc: Record<string, unknown> = {};
@ -1509,16 +1510,16 @@ describe("commands/migration-state", () => {
expectPrototypeClean();
});
it.each(["foo.prototype.bar", "foo.constructor.bar"])(
"rejects unsafe segment in nested path: %s",
(configPath) => {
const doc: Record<string, unknown> = {};
expect(() => {
setConfigValue(doc, configPath, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
},
);
it.each([
"foo.prototype.bar",
"foo.constructor.bar",
])("rejects unsafe segment in nested path: %s", (configPath) => {
const doc: Record<string, unknown> = {};
expect(() => {
setConfigValue(doc, configPath, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
});
it("allows legitimate dotted paths", () => {
const doc: Record<string, unknown> = {};

View file

@ -7,7 +7,6 @@ import { parsePort } from "./ports.js";
const ENV_KEY = "TEST_PLUGIN_PORT";
function clearEnv(): void {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete process.env[ENV_KEY];
}

View file

@ -190,7 +190,6 @@ describe("before_tool_call secret scanner hook (#1233)", () => {
const onCalls = vi.mocked(api.on).mock.calls;
const hookCall = onCalls.find(([name]) => name === "before_tool_call");
expect(hookCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by expect above
return hookCall![1];
}

1059
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,10 +16,11 @@
},
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write 'bin/**/*.js' 'scripts/**/*.ts' 'test/**/*.{js,ts}'",
"format:check": "prettier --check 'bin/**/*.js' 'scripts/**/*.ts' 'test/**/*.{js,ts}'",
"lint": "biome lint . && npm run check:credential-env",
"lint:fix": "biome lint --write . && npm run check:credential-env",
"format": "biome format --write .",
"format:check": "biome format .",
"check:credential-env": "tsx scripts/check-direct-credential-env.ts src/lib/onboard.ts",
"typecheck": "tsc -p jsconfig.json",
"build:cli": "tsc -p tsconfig.src.json && tsc -p nemoclaw-blueprint/tsconfig.json",
"typecheck:cli": "tsc -p tsconfig.cli.json",
@ -64,17 +65,14 @@
"url": "https://github.com/NVIDIA/NemoClaw.git"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^10.0.1",
"@j178/prek": "^0.3.6",
"@types/node": "^25.5.2",
"@typescript-eslint/parser": "^8.58.1",
"@vitest/coverage-v8": "^4.1.0",
"ajv": "^8.17.0",
"eslint": "^10.1.0",
"execa": "^9.6.1",
"prettier": "^3.8.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.0"

View file

@ -0,0 +1,244 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
/**
* Guards src/lib/onboard.ts against direct reads of provider credential env vars.
*
* Direct `process.env.NVIDIA_API_KEY`-style reads bypass credentials.json. Use
* resolveProviderCredential() or getCredential() for credential resolution unless
* a narrowly-scoped raw env check is intentional and explicitly suppressed.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as ts from "typescript";
const CREDENTIAL_ENV_KEYS = new Set([
"NVIDIA_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"COMPATIBLE_API_KEY",
"COMPATIBLE_ANTHROPIC_API_KEY",
]);
const MESSAGE =
"Direct process.env access for provider credentials bypasses credentials.json. " +
"Use resolveProviderCredential() or getCredential() instead. See #2306.";
const SUPPRESSION_TOKEN_PATTERN =
/\b(?:check-direct-credential-env-ignore|no-direct-credential-env)\b/;
const COMMENT_LINE_PREFIX_PATTERN = /^\s*(?:\/\/|\/\*|\*)/;
export interface DirectCredentialEnvViolation {
filePath: string;
line: number;
column: number;
key: string;
text: string;
}
export function findDirectCredentialEnvReads(
sourceText: string,
filePath = "source.ts",
): DirectCredentialEnvViolation[] {
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
scriptKindForPath(filePath),
);
const violations: DirectCredentialEnvViolation[] = [];
function visit(node: ts.Node): void {
const credentialKey = credentialKeyForProcessEnvAccess(node);
if (
credentialKey &&
!isAssignmentOrDeleteTarget(node) &&
!hasSuppressionComment(sourceFile, node)
) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
violations.push({
filePath,
line: position.line + 1,
column: position.character + 1,
key: credentialKey,
text: node.getText(sourceFile),
});
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return violations;
}
export function checkFiles(filePaths: readonly string[]): DirectCredentialEnvViolation[] {
return filePaths.flatMap((filePath) =>
findDirectCredentialEnvReads(readFileSync(filePath, "utf-8"), filePath),
);
}
export function formatViolations(violations: readonly DirectCredentialEnvViolation[]): string {
return violations
.map(
(violation) =>
`${violation.filePath}:${violation.line}:${violation.column} ${MESSAGE} (${violation.text})`,
)
.join("\n");
}
function credentialKeyForProcessEnvAccess(node: ts.Node): string | null {
if (ts.isPropertyAccessExpression(node) && isProcessEnvExpression(node.expression)) {
return CREDENTIAL_ENV_KEYS.has(node.name.text) ? node.name.text : null;
}
if (!ts.isElementAccessExpression(node) || !isProcessEnvExpression(node.expression)) {
return null;
}
const argument = stripParentheses(node.argumentExpression);
if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) {
return CREDENTIAL_ENV_KEYS.has(argument.text) ? argument.text : null;
}
if (ts.isIdentifier(argument) && /credential/i.test(argument.text)) {
return `[${argument.text}]`;
}
return null;
}
function isProcessEnvExpression(expression: ts.Expression): boolean {
return (
ts.isPropertyAccessExpression(expression) &&
ts.isIdentifier(expression.expression) &&
expression.expression.text === "process" &&
expression.name.text === "env"
);
}
function isAssignmentOrDeleteTarget(node: ts.Node): boolean {
let current = node;
let parent = node.parent;
while (
parent &&
isTransparentExpressionWrapper(parent) &&
getWrappedExpression(parent) === current
) {
current = parent;
parent = parent.parent;
}
return (
(parent !== undefined &&
ts.isBinaryExpression(parent) &&
parent.left === current &&
isAssignmentOperator(parent.operatorToken.kind)) ||
(parent !== undefined && ts.isDeleteExpression(parent) && parent.expression === current)
);
}
function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
return (
kind === ts.SyntaxKind.EqualsToken ||
kind === ts.SyntaxKind.PlusEqualsToken ||
kind === ts.SyntaxKind.MinusEqualsToken ||
kind === ts.SyntaxKind.AsteriskEqualsToken ||
kind === ts.SyntaxKind.AsteriskAsteriskEqualsToken ||
kind === ts.SyntaxKind.SlashEqualsToken ||
kind === ts.SyntaxKind.PercentEqualsToken ||
kind === ts.SyntaxKind.LessThanLessThanEqualsToken ||
kind === ts.SyntaxKind.GreaterThanGreaterThanEqualsToken ||
kind === ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken ||
kind === ts.SyntaxKind.AmpersandEqualsToken ||
kind === ts.SyntaxKind.BarEqualsToken ||
kind === ts.SyntaxKind.CaretEqualsToken ||
kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken ||
kind === ts.SyntaxKind.BarBarEqualsToken ||
kind === ts.SyntaxKind.QuestionQuestionEqualsToken
);
}
function isTransparentExpressionWrapper(node: ts.Node): boolean {
return (
ts.isParenthesizedExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node) ||
ts.isTypeAssertionExpression(node)
);
}
function getWrappedExpression(node: ts.Node): ts.Node | undefined {
if (
ts.isParenthesizedExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node) ||
ts.isTypeAssertionExpression(node)
) {
return node.expression;
}
return undefined;
}
function stripParentheses(expression: ts.Expression): ts.Expression {
let current = expression;
while (ts.isParenthesizedExpression(current)) {
current = current.expression;
}
return current;
}
function hasSuppressionComment(sourceFile: ts.SourceFile, node: ts.Node): boolean {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
const lines = sourceFile.getFullText().split(/\r?\n/);
return [line - 1, line].some((candidate) => {
const text = lines[candidate];
return (
text !== undefined &&
COMMENT_LINE_PREFIX_PATTERN.test(text) &&
SUPPRESSION_TOKEN_PATTERN.test(text)
);
});
}
function scriptKindForPath(filePath: string): ts.ScriptKind {
switch (path.extname(filePath)) {
case ".cjs":
case ".js":
case ".mjs":
return ts.ScriptKind.JS;
case ".jsx":
return ts.ScriptKind.JSX;
case ".tsx":
return ts.ScriptKind.TSX;
default:
return ts.ScriptKind.TS;
}
}
function main(): void {
const filePaths = process.argv.slice(2).filter((arg) => arg !== "--");
if (filePaths.length === 0) {
console.error("Usage: tsx scripts/check-direct-credential-env.ts FILE...");
process.exitCode = 2;
return;
}
const violations = checkFiles(filePaths);
if (violations.length > 0) {
console.error(formatViolations(violations));
process.exitCode = 1;
}
}
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) {
main();
}

View file

@ -17,7 +17,6 @@ type ManifestValue = ManifestScalar | ManifestRecord | ManifestValue[];
type ManifestRecord = { [key: string]: ManifestValue };
type StringMap = { [key: string]: string };
// eslint-disable-next-line @typescript-eslint/no-require-imports
const yaml: { load(input: string): unknown } = require("js-yaml");
export interface AgentHealthProbe {

View file

@ -460,7 +460,6 @@ export function promptSecret(question: string): Promise<string> {
if (ch === "\u001b") {
const rest = text.slice(i);
// eslint-disable-next-line no-control-regex
const match = rest.match(/^\u001b(?:\[[0-9;?]*[~A-Za-z]|\][^\u0007]*\u0007|.)/);
if (match) {
i += match[0].length - 1;

View file

@ -10,7 +10,6 @@
const GATEWAY_NAME = "nemoclaw";
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(value: string): string {

View file

@ -111,7 +111,6 @@ export function summarizeProbeFailure(body = "", status = 0, curlStatus = 0, std
return summarizeProbeError(body, status);
}
// eslint-disable-next-line complexity
export function runCurlProbe(argv: string[], opts: CurlProbeOptions = {}): CurlProbeResult {
const bodyFile = secureTempFile("nemoclaw-curl-probe", ".json");
try {

View file

@ -125,7 +125,6 @@ export function getOpenClawPrimaryModel(provider: string, model?: string): strin
export function parseGatewayInference(output: string | null | undefined): GatewayInference | null {
if (!output) return null;
// eslint-disable-next-line no-control-regex
const stripped = output.replace(/\u001b\[[0-9;]*m/g, "");
const lines = stripped.split("\n");
let inGateway = false;

View file

@ -9,13 +9,11 @@
import type { CurlProbeResult } from "./http-probe";
import { runCurlProbe } from "./http-probe";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { shellQuote, runCapture } = require("./runner");
import { VLLM_PORT, OLLAMA_PORT, OLLAMA_PROXY_PORT } from "./ports";
import { sleepSeconds } from "./wait";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { isWsl } = require("./platform");
/** Port containers use to reach Ollama — proxy on non-WSL, direct on WSL2. */

View file

@ -6,7 +6,6 @@ import { isSafeModelId } from "./validation";
import { validateNvidiaEndpointModel } from "./provider-models";
// credentials.ts still uses CommonJS-style exports.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getCredential, prompt } = require("./credentials");
export const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__";

View file

@ -3,9 +3,7 @@
//
// NIM container management — pull, start, stop, health-check NIM images.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runCapture } = require("./runner");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const {
dockerContainerInspectFormat,
dockerForceRm,
@ -16,9 +14,7 @@ const {
dockerRunDetached,
dockerStop,
} = require("./docker");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { sleepSeconds } = require("./wait");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nimImages = require("../../bin/lib/nim-images.json");
import { VLLM_PORT } from "./ports";

View file

@ -162,7 +162,6 @@ function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) {
}
// ── OpenAI-like probe ────────────────────────────────────────────
// eslint-disable-next-line complexity
function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) {
const useQueryParam = options.authMode === "query-param";
const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : "";

View file

@ -313,7 +313,6 @@ export function createSession(overrides: Partial<Session> = {}): Session {
};
}
// eslint-disable-next-line complexity
export function normalizeSession(data: Session | SessionJsonValue | undefined): Session | null {
if (!isObject(data) || data.version !== SESSION_VERSION) return null;

View file

@ -1105,7 +1105,7 @@ function upsertProvider(
// openshell receives `--credential <ENV>` and reads the value from the
// `env` block passed here, falling back to the inherited process.env.
// Use getCredential() for the env-fallback branch (per the
// no-direct-credential-env eslint rule from PR #2306) — it mirrors
// direct credential env guard from PR #2306) — it mirrors
// openshell's resolution order while the staging contract has
// already populated the same value into process.env.
const upsertedValue = env[credentialEnv] ?? getCredential(credentialEnv);
@ -2762,7 +2762,6 @@ function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds =
// ── Step 1: Preflight ────────────────────────────────────────────
// eslint-disable-next-line complexity
async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
step(1, 8, "Preflight checks");
@ -3871,7 +3870,6 @@ function formatOnboardConfigSummary({
].join("\n");
}
// eslint-disable-next-line complexity
async function createSandbox(
gpu: ReturnType<typeof nim.detectGpu>,
model: string,
@ -4864,7 +4862,6 @@ async function createSandbox(
// ── Step 3: Inference selection ──────────────────────────────────
// eslint-disable-next-line complexity
type ProviderChoice = { key: string; label: string };
function providerNameToOptionKey(
@ -5251,8 +5248,9 @@ async function setupNim(
// Check raw process.env first — NEMOCLAW_PROVIDER_KEY is a user-facing
// override that should take precedence before resolving from credentials.json.
const _nvProviderKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim();
// eslint-disable-next-line nemoclaw/no-direct-credential-env -- intentional: checking if env is already set before applying NEMOCLAW_PROVIDER_KEY override
if (_nvProviderKey && !process.env.NVIDIA_API_KEY) {
// check-direct-credential-env-ignore -- intentional: checking if env is already set before applying NEMOCLAW_PROVIDER_KEY override
const existingNvidiaKey = normalizeCredentialValue(process.env.NVIDIA_API_KEY ?? "");
if (_nvProviderKey && !existingNvidiaKey) {
process.env.NVIDIA_API_KEY = _nvProviderKey;
}
if (isNonInteractive()) {
@ -5290,9 +5288,12 @@ async function setupNim(
// isn't already set, use NEMOCLAW_PROVIDER_KEY as the API key for this provider.
// Check raw process.env — the override must apply before resolving from credentials.json.
const _providerKeyHint = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim();
// eslint-disable-next-line nemoclaw/no-direct-credential-env -- intentional: checking if env is already set before applying NEMOCLAW_PROVIDER_KEY override
if (_providerKeyHint && credentialEnv && !process.env[credentialEnv]) {
process.env[credentialEnv] = _providerKeyHint;
if (_providerKeyHint && credentialEnv) {
// check-direct-credential-env-ignore -- intentional: checking if env is already set before applying NEMOCLAW_PROVIDER_KEY override
const existingCredentialKey = normalizeCredentialValue(process.env[credentialEnv] ?? "");
if (!existingCredentialKey) {
process.env[credentialEnv] = _providerKeyHint;
}
}
if (isNonInteractive()) {
@ -5869,7 +5870,6 @@ async function setupNim(
// ── Step 4: Inference provider ───────────────────────────────────
// eslint-disable-next-line complexity
async function setupInference(
sandboxName: string | null,
model: string,
@ -6516,7 +6516,6 @@ async function setupOpenclaw(sandboxName: string, model: string, provider: strin
// ── Step 7: Policy presets ───────────────────────────────────────
// eslint-disable-next-line complexity
async function _setupPolicies(
sandboxName: string,
options: {
@ -7140,7 +7139,6 @@ function computeSetupPresetSuggestions(
return suggestions;
}
// eslint-disable-next-line complexity
async function setupPoliciesWithSelection(
sandboxName: string,
options: {
@ -7986,7 +7984,6 @@ function skippedStepMessage(
// ── Main ─────────────────────────────────────────────────────────
// eslint-disable-next-line complexity
async function onboard(opts: OnboardOptions = {}): Promise<void> {
setOnboardBrandingAgent(opts.agent || process.env.NEMOCLAW_AGENT || null);
NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1";

View file

@ -37,7 +37,6 @@ export interface CaptureOpenshellResult {
signal?: NodeJS.Signals | null;
}
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
export function stripAnsi(value = ""): string {

View file

@ -17,7 +17,6 @@ import path from "node:path";
import { DASHBOARD_PORT } from "./ports";
// runner.ts still uses CommonJS-style exports — use require here.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runCapture } = require("./runner");
type RunCaptureFn = typeof import("./runner").runCapture;

View file

@ -6,7 +6,6 @@ import { getCurlTimingArgs, runCurlProbe } from "./http-probe";
import type { ModelCatalogFetchResult, ModelValidationResult } from "./onboard-types";
// credentials.ts still uses CommonJS-style exports.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { normalizeCredentialValue } = require("./credentials");
export const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1";

View file

@ -8,7 +8,6 @@
import { loadSession } from "./onboard-session";
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(text: string | null | undefined): string {

View file

@ -116,7 +116,6 @@ export function resolveSkillPaths(
// Re-export shellQuote from runner.ts — a repo-wide test enforces
// a single definition lives in runner.ts.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { shellQuote } = require("./runner");
export { shellQuote };

View file

@ -61,8 +61,8 @@ function parseJson<T>(text: string): T {
// Reflect.get is used throughout the codebase as a type-safe alternative to
// direct property access on loosely-typed objects. Unlike an `as Record<…>`
// cast it never widens the target type and avoids eslint no-unsafe-member-access
// warnings. See also: deploy.ts, onboard.ts, ws-proxy-fix.ts.
// cast it never widens the target type and keeps loosely-typed member access
// explicit. See also: deploy.ts, onboard.ts, ws-proxy-fix.ts.
function readStringProperty(value: object | null, key: string): string | undefined {
if (!value) {
return undefined;
@ -212,11 +212,16 @@ export async function ensureUsageNoticeConsent({
}
// credentials is still CJS
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ask = promptFn || require("./credentials").prompt;
const answer = String(await ask(` ${config.interactivePrompt}`))
.trim()
.toLowerCase();
const ask: PromptFn = promptFn ?? (require("./credentials") as { prompt: PromptFn }).prompt;
let answer: string;
try {
answer = String(await ask(` ${config.interactivePrompt}`))
.trim()
.toLowerCase();
} catch {
writeLine(" Installation cancelled");
return false;
}
if (answer !== "yes") {
writeLine(" Installation cancelled");
return false;

View file

@ -855,7 +855,6 @@ function printGatewayLifecycleHint(output = "", sandboxName = "", writer = conso
}
}
// eslint-disable-next-line complexity
async function getReconciledSandboxGatewayState(sandboxName: string) {
let lookup = getSandboxGatewayState(sandboxName);
if (lookup.state === "present") {
@ -1553,7 +1552,6 @@ async function sandboxConnect(sandboxName: string) {
exitWithSpawnResult(result);
}
// eslint-disable-next-line complexity
async function sandboxStatus(sandboxName: string) {
const sb = registry.getSandbox(sandboxName);
const live = parseGatewayInference(
@ -4158,7 +4156,6 @@ function printConnectOrderHint(candidate: string | null): void {
const [cmd, ...args] = process.argv.slice(2);
// eslint-disable-next-line complexity
(async () => {
// No command → help
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {

View file

@ -75,7 +75,6 @@ if (!certSetup.ok) {
`This test must not silently skip in CI — install openssl on the runner.`,
);
}
// eslint-disable-next-line no-console
console.warn(`[http-proxy-fix-e2e] skipping locally: ${certSetup.reason}`);
}
const key = certSetup.ok ? certSetup.key : Buffer.alloc(0);
@ -97,7 +96,6 @@ type CapturedRequest = {
function loadWrapper() {
delete require.cache[FIX_PATH];
// eslint-disable-next-line @typescript-eslint/no-require-imports
require(FIX_PATH);
}

View file

@ -47,7 +47,6 @@ type RewrittenOptions = http.RequestOptions & {
function loadWrapper() {
// Clear cached copies so the IIFE re-runs and reads our test env.
delete require.cache[FIX_PATH];
// eslint-disable-next-line @typescript-eslint/no-require-imports
require(FIX_PATH);
}

View file

@ -2,136 +2,101 @@
// SPDX-License-Identifier: Apache-2.0
/**
* Tests for the no-direct-credential-env ESLint rule.
* Tests for the direct credential env guard.
*
* Verifies that the rule flags direct process.env reads for known
* credential keys while allowing assignments, deletions, and
* non-credential keys.
* Verifies that the guard flags direct process.env reads for known credential
* keys while allowing assignments, deletions, suppressions, and non-credential
* keys.
*
* See #2306.
*/
import { describe, expect, it } from "vitest";
import { RuleTester } from "eslint";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { findDirectCredentialEnvReads } from "../scripts/check-direct-credential-env";
// Import the CJS rule via dynamic import
const rulePath = path.join(import.meta.dirname, "..", "eslint-rules", "no-direct-credential-env.js");
const rule = (await import(pathToFileURL(rulePath).href)).default;
describe("direct credential env guard", () => {
it.each([
// Assignments (write context) — allowed
'process.env.NVIDIA_API_KEY = "test";',
"process.env.OPENAI_API_KEY = value;",
"process.env[credentialEnv] = providerKey;",
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
});
// Deletions (write context) — allowed
"delete process.env.NVIDIA_API_KEY;",
"delete process.env.ANTHROPIC_API_KEY;",
describe("ESLint rule: nemoclaw/no-direct-credential-env", () => {
it("flags and allows the expected patterns", () => {
ruleTester.run("no-direct-credential-env", rule, {
valid: [
// Assignments (write context) — allowed
{ code: 'process.env.NVIDIA_API_KEY = "test";' },
{ code: 'process.env.OPENAI_API_KEY = value;' },
{ code: "process.env[credentialEnv] = providerKey;" },
// Non-credential env vars — allowed
"const x = process.env.NEMOCLAW_MODEL;",
"const x = process.env.HOME;",
"const x = process.env.PATH;",
// Deletions (write context) — allowed
{ code: "delete process.env.NVIDIA_API_KEY;" },
{ code: "delete process.env.ANTHROPIC_API_KEY;" },
// NEMOCLAW_PROVIDER_KEY is a user-facing override, not credential resolution.
"const x = process.env.NEMOCLAW_PROVIDER_KEY;",
// Non-credential env vars — allowed
{ code: "const x = process.env.NEMOCLAW_MODEL;" },
{ code: "const x = process.env.HOME;" },
{ code: "const x = process.env.PATH;" },
// Correct patterns — allowed
'const key = getCredential("NVIDIA_API_KEY");',
'const key = resolveProviderCredential("NVIDIA_API_KEY");',
// NEMOCLAW_PROVIDER_KEY is a user-facing override, not credential resolution
{ code: "const x = process.env.NEMOCLAW_PROVIDER_KEY;" },
// Bracketed string-literal assignments — allowed
'process.env["NVIDIA_API_KEY"] = "test";',
// Correct patterns — allowed
{ code: 'const key = getCredential("NVIDIA_API_KEY");' },
{ code: 'const key = resolveProviderCredential("NVIDIA_API_KEY");' },
// Dynamic access with non-credential variable name — allowed
"const x = process.env[someKey];",
"const x = process.env[envName];",
// Bracketed string-literal assignments — allowed
{ code: 'process.env["NVIDIA_API_KEY"] = "test";' },
// Dynamic access with non-credential variable name — allowed
{ code: "const x = process.env[someKey];" },
{ code: "const x = process.env[envName];" },
],
invalid: [
// Static reads of known credential keys
{
code: "const key = process.env.NVIDIA_API_KEY;",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: "const key = process.env.OPENAI_API_KEY;",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: "const key = process.env.ANTHROPIC_API_KEY;",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: "const key = process.env.GEMINI_API_KEY;",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: "const key = process.env.COMPATIBLE_API_KEY;",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: "const key = process.env.COMPATIBLE_ANTHROPIC_API_KEY;",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
// Conditional check (read context)
{
code: "if (!process.env.NVIDIA_API_KEY) {}",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
// Bracketed string-literal reads
{
code: 'const key = process.env["NVIDIA_API_KEY"];',
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: 'if (!process.env["OPENAI_API_KEY"]) {}',
errors: [{ messageId: "noDirectCredentialEnv" }],
},
// Dynamic read with credential-containing variable name
{
code: "if (!process.env[credentialEnv]) {}",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
{
code: "const x = process.env[resolvedCredentialEnv];",
errors: [{ messageId: "noDirectCredentialEnv" }],
},
],
});
// Explicitly suppressed raw-env reads — allowed
"// check-direct-credential-env-ignore -- raw env check required\nconst key = process.env.NVIDIA_API_KEY;",
"// no-direct-credential-env -- backward-compatible suppression\nconst key = process.env.NVIDIA_API_KEY;",
])("allows %s", (code) => {
expect(findDirectCredentialEnvReads(code)).toEqual([]);
});
it("onboard.ts has zero violations (Phase 1 already fixed all patterns)", async () => {
const { spawnSync } = await import("node:child_process");
it.each([
// Static reads of known credential keys
["const key = process.env.NVIDIA_API_KEY;", "NVIDIA_API_KEY"],
["const key = process.env.OPENAI_API_KEY;", "OPENAI_API_KEY"],
["const key = process.env.ANTHROPIC_API_KEY;", "ANTHROPIC_API_KEY"],
["const key = process.env.GEMINI_API_KEY;", "GEMINI_API_KEY"],
["const key = process.env.COMPATIBLE_API_KEY;", "COMPATIBLE_API_KEY"],
[
"const key = process.env.COMPATIBLE_ANTHROPIC_API_KEY;",
"COMPATIBLE_ANTHROPIC_API_KEY",
],
// Conditional check (read context)
["if (!process.env.NVIDIA_API_KEY) {}", "NVIDIA_API_KEY"],
// Bracketed string-literal reads
['const key = process.env["NVIDIA_API_KEY"];', "NVIDIA_API_KEY"],
['if (!process.env["OPENAI_API_KEY"]) {}', "OPENAI_API_KEY"],
// Dynamic read with credential-containing variable name
["if (!process.env[credentialEnv]) {}", "[credentialEnv]"],
["const x = process.env[resolvedCredentialEnv];", "[resolvedCredentialEnv]"],
// Suppression token inside non-comment text must not suppress.
[
"const marker = 'no-direct-credential-env';\nconst key = process.env.NVIDIA_API_KEY;",
"NVIDIA_API_KEY",
],
])("flags %s", (code, key) => {
expect(findDirectCredentialEnvReads(code)).toMatchObject([{ key }]);
});
it("onboard.ts has zero violations", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const result = spawnSync(
"npx",
["eslint", "src/lib/onboard.ts", "--format", "json"],
["tsx", "scripts/check-direct-credential-env.ts", "src/lib/onboard.ts"],
{
cwd: repoRoot,
encoding: "utf-8",
timeout: 60_000,
},
);
const output = JSON.parse(result.stdout);
const violations = output[0].messages.filter(
(m: any) => m.ruleId === "nemoclaw/no-direct-credential-env",
);
expect(violations).toHaveLength(0);
expect(result.status, result.stderr).toBe(0);
});
});

View file

@ -3515,7 +3515,6 @@ const { createSandbox } = require(${onboardPath});
// Without this, a CHAT_UI_URL set in the developer's shell or CI would be
// inherited, causing chatUiUrl to use the wrong port and making the forward
// command assertion below fail spuriously.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CHAT_UI_URL: _stripped, ...inheritedEnv } = process.env;
const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,

View file

@ -88,6 +88,20 @@ describe("usage notice", () => {
expect(lines.join("\n")).toContain("Installation cancelled");
});
it("cancels interactive onboarding when the prompt fails", async () => {
const lines: string[] = [];
const ok = await ensureUsageNoticeConsent({
nonInteractive: false,
promptFn: async () => {
throw new Error("prompt failed");
},
writeLine: (line: string) => lines.push(line),
});
expect(ok).toBe(false);
expect(lines.join("\n")).toContain("Installation cancelled");
});
it("records interactive acceptance when the user types yes", async () => {
const config = loadUsageNoticeConfig();
const ok = await ensureUsageNoticeConsent({

View file

@ -44,7 +44,6 @@ describe("isDangerousHost", () => {
});
it.each([undefined, null, 42, {}, []])("returns false for non-string %s", (v) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(isDangerousHost(v as any)).toBe(false);
});