Skip to content
4 changes: 4 additions & 0 deletions .github/actions/ci-static-checks/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ runs:
[ "$HADOLINT_SHA256" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; }
chmod +x /usr/local/bin/hadolint

- name: Validate sandbox payload lockfile
shell: bash
run: npm --prefix nemoclaw ci --ignore-scripts --dry-run

- name: Install dependencies
shell: bash
run: npm install --ignore-scripts
Expand Down
4 changes: 2 additions & 2 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1537,7 +1537,7 @@ install_nemoclaw() {
fi
spin "Installing ${_CLI_DISPLAY} dependencies" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm install --ignore-scripts"
spin "Building ${_CLI_DISPLAY} CLI modules" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm run --if-present build:cli"
spin "Building ${_CLI_DISPLAY} plugin" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\"/nemoclaw && npm install --ignore-scripts && npm run build"
spin "Building ${_CLI_DISPLAY} plugin" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\"/nemoclaw && npm ci --ignore-scripts && npm run build"
spin "Linking ${_CLI_DISPLAY} CLI" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm link"

# Bootstrap OpenShell when the source checkout is being used as a fresh
Expand Down Expand Up @@ -1580,7 +1580,7 @@ install_nemoclaw() {
fi
spin "Installing ${_CLI_DISPLAY} dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts"
spin "Building ${_CLI_DISPLAY} CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run --if-present build:cli"
spin "Building ${_CLI_DISPLAY} plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build"
spin "Building ${_CLI_DISPLAY} plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm ci --ignore-scripts && npm run build"
spin "Linking ${_CLI_DISPLAY} CLI" bash -c "cd \"$nemoclaw_src\" && npm link"

# Install/upgrade the OpenShell CLI on the GitHub-clone path (curl|bash).
Expand Down
32 changes: 16 additions & 16 deletions test/install-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ function writeNpmStub(fakeBin: string, installSnippet: string = "exit 0") {
set -euo pipefail
if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi
if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then echo "$NPM_PREFIX"; exit 0; fi
if [ "$1" = "install" ] || [ "$1" = "link" ] || [ "$1" = "uninstall" ] || [ "$1" = "pack" ] || [ "$1" = "run" ]; then
if [ "$1" = "ci" ] || [ "$1" = "install" ] || [ "$1" = "link" ] || [ "$1" = "uninstall" ] || [ "$1" = "pack" ] || [ "$1" = "run" ]; then
${installSnippet}
if [ "$1" = "ci" ]; then exit 0; fi
fi
echo "unexpected npm invocation: $*" >&2; exit 98`,
);
Expand Down Expand Up @@ -242,7 +243,7 @@ fi
if [ "$1" = "pack" ]; then
exit 1
fi
if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then
if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then
exit 0
fi
if [ "$1" = "run" ]; then
Expand Down Expand Up @@ -346,7 +347,7 @@ fi
if [ "$1" = "pack" ]; then
exit 1
fi
if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then
if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then
exit 0
fi
if [ "$1" = "run" ]; then
Expand Down Expand Up @@ -505,8 +506,7 @@ exit 98
expect(output.trim()).toBe("nemoclaw-installer");
expect(output).not.toMatch(/0\.1\.0/);
});

it("uses npm install + npm link for a source checkout (no -g)", { timeout: 20000 }, () => {
it("preserves the sandbox payload lockfile with npm ci (#3798)", { timeout: 20000 }, () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-source-"));
const fakeBin = path.join(tmp, "bin");
const prefix = path.join(tmp, "prefix");
Expand Down Expand Up @@ -550,7 +550,7 @@ if [ "$1" = "pack" ]; then
tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package
exit 0
fi
if [ "$1" = "install" ]; then exit 0; fi
if [ "$1" = "install" ]; then printf '{"rewritten":true}\n' > package-lock.json; exit 0; fi
if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi
if [ "$1" = "link" ]; then
cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS'
Expand All @@ -564,8 +564,6 @@ EOS
fi`,
);

// Write a package.json that triggers the source-checkout path.
// Must use spaces after colons to match the grep in install.sh.
fs.writeFileSync(
path.join(tmp, "package.json"),
JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2),
Expand All @@ -575,6 +573,8 @@ fi`,
path.join(tmp, "nemoclaw", "package.json"),
JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2),
);
const payloadLockPath = path.join(tmp, "nemoclaw", "package-lock.json");
fs.writeFileSync(payloadLockPath, "payload lock sentinel\n");
fs.mkdirSync(path.join(tmp, "nemoclaw-blueprint", "router", "llm-router"), {
recursive: true,
});
Expand All @@ -593,6 +593,7 @@ fi`,
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1",
NEMOCLAW_DEFER_OPENSHELL_INSTALL: "1",
NEMOCLAW_REPO_ROOT: tmp,
NPM_PREFIX: prefix,
NPM_LOG_PATH: npmLog,
PYTHON_LOG_PATH: pythonLog,
Expand All @@ -602,12 +603,11 @@ fi`,

expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
const log = fs.readFileSync(npmLog, "utf-8");
// install (no -g) and link must both have been called
expect(log).toMatch(/^install(?!\s+-g)/m);
expect(log.match(/^install --ignore-scripts$/gm)).toHaveLength(1);
expect(log.match(/^ci --ignore-scripts$/gm)).toHaveLength(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(fs.readFileSync(payloadLockPath)).toEqual(Buffer.from("payload lock sentinel\n"));
expect(log).toMatch(/^link/m);
// the GitHub URL must NOT appear — this is a local install
expect(log).not.toMatch(new RegExp(GITHUB_INSTALL_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
// Model Router must not run provider-specific dependency setup from the generic installer.
expect(fs.existsSync(pythonLog)).toBe(false);
const gitCalls = fs.existsSync(gitLog) ? fs.readFileSync(gitLog, "utf-8") : "";
expect(gitCalls).not.toMatch(/submodule/);
Expand Down Expand Up @@ -1711,7 +1711,7 @@ exit 0
echo "ENOTFOUND simulated network error" >&2
exit 1
fi
if [ "$1" = "install" ] || [ "$1" = "run" ] || [ "$1" = "link" ]; then
if [ "$1" = "ci" ] || [ "$1" = "install" ] || [ "$1" = "run" ] || [ "$1" = "link" ]; then
echo "ENOTFOUND simulated network error" >&2
exit 1
fi`,
Expand Down Expand Up @@ -1802,7 +1802,7 @@ fi
if [ "$1" = "pack" ]; then
exit 1
fi
if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then
if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then
exit 0
fi
if [ "$1" = "run" ]; then
Expand Down Expand Up @@ -1934,7 +1934,7 @@ fi
if [ "$1" = "pack" ]; then
exit 1
fi
if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then
if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then
exit 0
fi
if [ "$1" = "run" ]; then
Expand Down Expand Up @@ -3218,7 +3218,7 @@ set -euo pipefail
if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi
if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then echo "$NPM_PREFIX"; exit 0; fi
if [ "$1" = "pack" ]; then exit 1; fi
if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then exit 0; fi
if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then exit 0; fi
if [ "$1" = "run" ]; then exit 0; fi
if [ "$1" = "uninstall" ]; then exit 0; fi
if [ "$1" = "link" ]; then
Expand Down
4 changes: 4 additions & 0 deletions test/pr-workflow-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,10 @@ describe("pull request and main workflow contracts", () => {
const installerRuns = stepRuns(sharedActions.installerIntegration).join("\n");

expect(staticRuns).toContain("npm install --ignore-scripts");
expect(staticRuns).toContain("npm --prefix nemoclaw ci --ignore-scripts --dry-run");
expect(
requiredStepIndex(sharedActions.staticChecks, "Validate sandbox payload lockfile"),
).toBeLessThan(requiredStepIndex(sharedActions.staticChecks, "Install dependencies"));
expect(staticRuns).toContain("npm run validate:configs");
expect(staticRuns).toContain("npm run typecheck:scorecard");
expect(staticPrekRun).toContain("npx prek run --all-files --stage pre-commit");
Expand Down
Loading