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
218 changes: 218 additions & 0 deletions .github/workflows/codebase-growth-guardrails.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
types: [opened, reopened, synchronize, ready_for_review]

permissions:
contents: read
pull-requests: read

jobs:
Expand Down Expand Up @@ -105,3 +106,220 @@ jobs:
in the top-level onboard entrypoint.
EOF
exit 1

- name: Require changed test files to stay within size budget
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail

changed_files_file="$(mktemp)"
rows_file="$(mktemp)"
base_budget_file="$(mktemp)"
head_budget_file="$(mktemp)"
base_budget_mode_file="$(mktemp)"
budget_changed=false

gh api --paginate "/repos/${REPO}/pulls/${PR_NUMBER}/files" \
--jq '.[] | [.status, .filename, (.previous_filename // "")] | @tsv' \
> "$changed_files_file"

gh api --paginate "/repos/${REPO}/pulls/${PR_NUMBER}/files" \
--jq '.[] | select((.status != "removed") and (.filename | test("^(test|src|nemoclaw/src)/.*\\.(test|spec)\\.(ts|js|mts|mjs|cts|cjs)$"))) | [.filename] | @tsv' \
> "$rows_file"

if gh api "/repos/${REPO}/contents/ci/test-file-size-budget.json?ref=${BASE_SHA}" --jq .content 2>/dev/null \
| base64 --decode > "$base_budget_file" && [ -s "$base_budget_file" ]; then
echo "base" > "$base_budget_mode_file"
else
echo "WARN: budget file not found at base SHA; using conservative default fallback."
printf '%s\n' '{"defaultMaxLines":1500,"legacyMaxLines":{}}' > "$base_budget_file"
echo "fallback" > "$base_budget_mode_file"
fi

while IFS=$'\t' read -r _file_status file_path previous_path; do
if [ "$file_path" = "ci/test-file-size-budget.json" ] || [ "$previous_path" = "ci/test-file-size-budget.json" ]; then
budget_changed=true
break
fi
done < "$changed_files_file"

if [ "$budget_changed" = true ]; then
if ! gh api "/repos/${HEAD_REPO}/contents/ci/test-file-size-budget.json?ref=${HEAD_SHA}" --jq .content \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| base64 --decode > "$head_budget_file" || [ ! -s "$head_budget_file" ]; then
echo "FAIL: ci/test-file-size-budget.json must remain present and parseable at the PR head."
exit 1
fi
else
cp "$base_budget_file" "$head_budget_file"
fi

node - "$base_budget_file" "$head_budget_file" "$base_budget_mode_file" "$rows_file" <<'NODE'
const fs = require("node:fs");

function countLines(text) {
if (text.length === 0) return 0;
const newlineCount = text.match(/\r\n|\r|\n/g)?.length ?? 0;
return newlineCount + (/(?:\r\n|\r|\n)$/.test(text) ? 0 : 1);
}

function parseBudget(sourceText, label) {
const parsed = JSON.parse(sourceText);
if (!Number.isInteger(parsed.defaultMaxLines) || parsed.defaultMaxLines <= 0) {
throw new Error(`${label} must define positive integer defaultMaxLines`);
}

const legacyMaxLines = parsed.legacyMaxLines ?? {};
if (
typeof legacyMaxLines !== "object" ||
legacyMaxLines === null ||
Array.isArray(legacyMaxLines)
) {
throw new Error(`${label} legacyMaxLines must be an object`);
}

for (const [file, maxLines] of Object.entries(legacyMaxLines)) {
if (!Number.isInteger(maxLines) || maxLines <= 0) {
throw new Error(`${label} has invalid legacy budget for ${file}: ${maxLines}`);
}
}

return { defaultMaxLines: parsed.defaultMaxLines, legacyMaxLines };
}

function changedTestFiles(rowsPath) {
const rowsText = fs.readFileSync(rowsPath, "utf8").trim();
return rowsText.length === 0 ? [] : rowsText.split(/\n/).filter(Boolean);
}

function githubContentsUrl(file) {
const repo = process.env.HEAD_REPO;
const headSha = process.env.HEAD_SHA;
if (!repo || !headSha) {
throw new Error("HEAD_REPO and HEAD_SHA must be set");
}
const encodedPath = file.split("/").map(encodeURIComponent).join("/");
return `https://api.github.com/repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(headSha)}`;
}

async function fetchHeadTextFile(file) {
const response = await fetch(githubContentsUrl(file), {
headers: {
Authorization: `Bearer ${process.env.GH_TOKEN}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Could not fetch ${file}: HTTP ${response.status}`);
}
const body = await response.json();
if (body.type !== "file" || body.encoding !== "base64" || typeof body.content !== "string") {
throw new Error(`Could not decode file contents for ${file}`);
}
return Buffer.from(body.content.replace(/\s/g, ""), "base64").toString("utf8");
}

async function validateBudgetChange(baseBudget, headBudget, baseBudgetMode) {
if (baseBudgetMode === "fallback") return [];

const violations = [];
if (headBudget.defaultMaxLines > baseBudget.defaultMaxLines) {
violations.push(
`defaultMaxLines increased from ${baseBudget.defaultMaxLines} to ${headBudget.defaultMaxLines}`,
);
}

for (const [file, baseMax] of Object.entries(baseBudget.legacyMaxLines)) {
const headMax = headBudget.legacyMaxLines[file];
if (headMax === undefined) {
const text = await fetchHeadTextFile(file);
if (text !== null && countLines(text) > headBudget.defaultMaxLines) {
violations.push(
`${file} removed its legacy budget while still exceeding defaultMaxLines`,
);
}
} else if (headMax > baseMax) {
violations.push(`${file} legacy budget increased from ${baseMax} to ${headMax}`);
}
}

for (const [file, headMax] of Object.entries(headBudget.legacyMaxLines)) {
if (baseBudget.legacyMaxLines[file] === undefined && headMax > headBudget.defaultMaxLines) {
violations.push(
`${file} adds a new legacy budget (${headMax}) above defaultMaxLines (${headBudget.defaultMaxLines})`,
);
}
const text = await fetchHeadTextFile(file);
if (text === null) {
violations.push(`${file} has a legacy budget but no matching test file at the PR head`);
} else if (countLines(text) > headMax) {
violations.push(`${file} has ${countLines(text)} line(s), above its legacy budget ${headMax}`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return violations;
}

async function main() {
const [baseBudgetPath, headBudgetPath, baseBudgetModePath, rowsPath] = process.argv.slice(2);
const baseBudget = parseBudget(fs.readFileSync(baseBudgetPath, "utf8"), "base budget");
const headBudget = parseBudget(fs.readFileSync(headBudgetPath, "utf8"), "head budget");
const baseBudgetMode = fs.readFileSync(baseBudgetModePath, "utf8").trim();
const rows = changedTestFiles(rowsPath);

function maxLinesFor(file) {
return headBudget.legacyMaxLines[file] ?? headBudget.defaultMaxLines;
}

const budgetViolations = await validateBudgetChange(baseBudget, headBudget, baseBudgetMode);
const violations = [];

for (const file of rows) {
const text = await fetchHeadTextFile(file);
if (text === null) {
throw new Error(`Changed test file ${file} was not found at the PR head`);
}
const lines = countLines(text);
const maxLines = maxLinesFor(file);
if (lines > maxLines) {
violations.push(`${file}: ${lines} line(s) > ${maxLines}`);
}
const legacyMax = headBudget.legacyMaxLines[file];
if (legacyMax !== undefined && lines < legacyMax) {
violations.push(`${file}: ${lines} line(s) < ${legacyMax} legacy budget; lower the budget entry`);
}
}

if (budgetViolations.length > 0 || violations.length > 0) {
if (budgetViolations.length > 0) {
console.error("FAIL: ci/test-file-size-budget.json weakens the base budget.");
for (const violation of budgetViolations) {
console.error(`- ${violation}`);
}
}
if (violations.length > 0) {
console.error("FAIL: one or more changed test files exceed or underrun the size budget.");
console.error("Split large tests into focused files, or shrink a legacy oversized test before adding more coverage there.");
for (const violation of violations) {
console.error(`- ${violation}`);
}
}
process.exit(1);
}

console.log(
`PASS: test size budget policy is monotonic and ${rows.length} changed test file(s) are within budget.`,
);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
4 changes: 4 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,15 @@ jobs:
--skip test-cli \
--skip test-plugin \
--skip source-shape-test-budget \
--skip test-file-size-budget \
--skip test-skills-yaml

- name: Run source-shape budget
run: npm run source-shape:check

- name: Run test file size budget
run: npm run test-size:check

- name: Run skills YAML tests
run: npx vitest run test/skills-frontmatter.test.ts

Expand Down
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# NemoClaw — prek hook configuration
# prek: https://github.com/j178/prek — single binary, no Python required for the runner
# Installed as an npm devDependency (@j178/prek) — available after `npm install`.
Expand Down Expand Up @@ -304,6 +307,14 @@ repos:
files: ^(test/|scripts/find-source-shape-tests\.ts$|ci/source-shape-test-budget\.json$)
priority: 20

- id: test-file-size-budget
name: Test file size budget
entry: npm run test-size:check
language: system
pass_filenames: false
files: ^(test/|src/.*\.(test|spec)\.(ts|js|mts|mjs|cts|cjs)$|nemoclaw/src/.*\.(test|spec)\.(ts|js|mts|mjs|cts|cjs)$|scripts/check-test-file-size-budget\.ts$|ci/test-file-size-budget\.json$)
priority: 20

- id: test-skills-yaml
name: Test (skills YAML)
entry: npx vitest run test/skills-frontmatter.test.ts
Expand Down
18 changes: 18 additions & 0 deletions ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0",
"defaultMaxLines": 1500,
"legacyMaxLines": {
"nemoclaw/src/commands/migration-state.test.ts": 1566,
"src/lib/inference/nim.test.ts": 2079,
"src/lib/onboard/preflight.test.ts": 1905,
"test/channels-add-preset.test.ts": 1915,
"test/generate-openclaw-config.test.ts": 2106,
"test/install-preflight.test.ts": 4397,
"test/nemoclaw-start.test.ts": 5319,
"test/onboard-messaging.test.ts": 2122,
"test/onboard-selection.test.ts": 7757,
"test/onboard.test.ts": 4887,
"test/policies.test.ts": 3147,
"test/sandbox-connect-inference.test.ts": 1577
}
}
5 changes: 3 additions & 2 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1028,8 +1028,9 @@ $$nemoclaw onboard
These are build-time settings baked into the sandbox image.
Changing them after onboarding requires re-running `$$nemoclaw onboard` to rebuild the image.

When `HTTP_PROXY` or `HTTPS_PROXY` is set on the host, NemoClaw adds `localhost` and `127.0.0.1` to `NO_PROXY` for managed subprocesses.
This keeps local Ollama health checks and model pulls from being routed through a corporate or desktop proxy while preserving the proxy for external hosts.
When `HTTP_PROXY` or `HTTPS_PROXY` is set on the host, NemoClaw adds `localhost`, `127.0.0.1`, `::1`, `0.0.0.0`, the container-host aliases `host.docker.internal` and `host.containers.internal`, and the managed inference hostname `inference.local` to `NO_PROXY` for host-side subprocesses and for the env forwarded into `openshell sandbox create`.
This keeps local Ollama health checks, model pulls, and managed inference traffic from being chained through a corporate or desktop proxy at the sandbox-create boundary, while preserving the proxy for external hosts.
Inside the running sandbox, processes continue to use the OpenShell L7 proxy for `inference.local` so OpenShell's internal routing, DNS, and audit boundaries stay intact.

### Agent cannot reach a host-side HTTP service

Expand Down
35 changes: 29 additions & 6 deletions nemoclaw/src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,26 @@ const ALLOWED_ENV_PREFIXES = ["LC_", "XDG_", "OPENSHELL_", "GRPC_"];
// ── Public API ─────────────────────────────────────────────────

/**
* When any HTTP proxy is forwarded, ensure local host-bound traffic is not
* routed through it. Without this, tools that respect HTTP_PROXY (curl, Node.js
* http, Python requests) will tunnel loopback or WSL Windows-host requests to
* the user's proxy (e.g. Privoxy), which fails with HTTP 500.
* See: #2616
* When any HTTP proxy is forwarded, augment NO_PROXY so the host proxy is
* never asked to forward traffic destined for the host loopback, the
* container-host aliases, or the OpenShell-managed inference hostname.
*
* Boundary: the helper covers host-side subprocesses (curl, Node.js http,
* Python requests) and the env forwarded into `openshell sandbox create
* -- env ...`. The latter is what determines whether OpenShell's L7 proxy
* chains a hostname through the host HTTP_PROXY when the host has one set
* (for example Privoxy at 127.0.0.1:8118 on macOS + Colima). Adding
* `inference.local` here is the seed that keeps OpenShell-internal
* inference traffic off the host proxy chain.
*
* The sandbox runtime's own NO_PROXY is set later by
* `scripts/nemoclaw-start.sh` against the OpenShell L7 proxy address and
* intentionally does not include `inference.local`, which is orthogonal
* to this seed and unaffected by the augmentation.
*
* Removal condition: when OpenShell's host-side proxy chaining no longer
* consults the caller's NO_PROXY for sandbox-create env decisions, this
* augmentation can be dropped.
*/
export function withLocalNoProxy(env: Record<string, string>): void {
const hasProxy = env.HTTP_PROXY || env.HTTPS_PROXY || env.http_proxy || env.https_proxy;
Expand All @@ -65,7 +80,15 @@ export function withLocalNoProxy(env: Record<string, string>): void {
.map((s) => s.trim())
.filter(Boolean);
let changed = false;
for (const host of ["localhost", "127.0.0.1", "host.docker.internal", "::1", "0.0.0.0"]) {
for (const host of [
"localhost",
"127.0.0.1",
"host.docker.internal",
"host.containers.internal",
"::1",
"0.0.0.0",
"inference.local",
]) {
if (!parts.includes(host)) {
parts.push(host);
changed = true;
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0",
"name": "nemoclaw",
"version": "0.1.0",
"description": "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference",
Expand Down Expand Up @@ -35,6 +36,7 @@
"type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts",
"source-shape:scan": "tsx scripts/find-source-shape-tests.ts --metrics",
"source-shape:check": "tsx scripts/find-source-shape-tests.ts --check",
"test-size:check": "tsx scripts/check-test-file-size-budget.ts",
"bump:version": "tsx scripts/bump-version.ts",
"release:plan": "tsx scripts/release-plan.ts",
"release:cut": "bash scripts/release-cut-tag.sh",
Expand Down
Loading
Loading