Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ RUN node --experimental-strip-types \
ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e
ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1
ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=a0c87387c0a00e7aad5892375303115577d72293ea6af7d15d1861e6a02c62c6
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=5cdac58c052f1df745d70f0126bc658eff1daef745688dc7570ca7795c068228
ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819
ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0
# hadolint ignore=DL4006
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/validate-env-secret-boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"API_SERVER_HOST",
"API_SERVER_PORT",
"GPG_KEY",
"NEMOCLAW_HERMES_API_PORT",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"NEMOCLAW_INFERENCE_API",
"NEMOCLAW_INFERENCE_PROVIDER_ID",
"NEMOCLAW_PROVIDER_KEY",
Expand Down
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"allowedCycles": [],
"maxRootFiles": {
"src/lib/onboard": 309,
"src/lib/onboard": 308,
"src/lib/actions": 19,
"src/lib/actions/sandbox": 183,
"src/lib/state": 38,
Expand Down
15 changes: 13 additions & 2 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3468,6 +3468,7 @@ repair_installer_nvidia_cdi_spec() {
run_installer_host_preflight() {
local preflight_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/onboard/preflight.js"
local gateway_management_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/onboard/gateway-management.js"
local portable_profile_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/onboard/experimental/portable-profile.js"
local host_readiness_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/readiness/host.js"
local onboard_admission_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/readiness/onboard-admission.js"
if ! command_exists node \
Expand All @@ -3488,6 +3489,16 @@ run_installer_host_preflight() {
const hostReadinessPath = process.argv[2];
const onboardAdmissionPath = process.argv[3];
const gatewayManagementPath = process.argv[4];
const portableProfilePath = process.argv[5];
let explicitlySelectedPortableProfile = false;
try {
const portableProfile = require(portableProfilePath);
if (typeof portableProfile.isPortableExperimentalProfile === "function") {
explicitlySelectedPortableProfile = Boolean(
portableProfile.isPortableExperimentalProfile()
);
}
} catch {}
try {
const { assessHost, planHostAdvisories } = require(preflightPath);
const { createHostReadinessReport } = require(hostReadinessPath);
Expand All @@ -3512,7 +3523,7 @@ run_installer_host_preflight() {
);
const admission = evaluateOnboardReadinessAdmission(readiness, {
explicitlyOptedOutGpuPassthrough: false,
allowUnsupportedRuntime: false,
allowUnsupportedRuntime: explicitlySelectedPortableProfile,
// The installer starts a NemoClaw-managed onboarding flow. Let the
// authoritative onboarding gate apply supported storage remediation,
// but only when the gateway declaration confirms NemoClaw ownership.
Expand Down Expand Up @@ -3584,7 +3595,7 @@ run_installer_host_preflight() {
} catch {
process.exit(0);
}
' "$preflight_module" "$host_readiness_module" "$onboard_admission_module" "$gateway_management_module"
' "$preflight_module" "$host_readiness_module" "$onboard_admission_module" "$gateway_management_module" "$portable_profile_module"
)"; then
status=0
else
Expand Down
10 changes: 9 additions & 1 deletion test/helpers/installer-readiness-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ export function writeFailedOnboardSession(home: string): void {

export function writeInstallerReadinessModuleStubs(readinessDir: string): void {
const onboardDir = path.join(path.dirname(readinessDir), "onboard");
const experimentalDir = path.join(onboardDir, "experimental");
fs.mkdirSync(readinessDir, { recursive: true });
fs.mkdirSync(onboardDir, { recursive: true });
fs.mkdirSync(experimentalDir, { recursive: true });
fs.writeFileSync(
`${readinessDir}/host.js`,
`exports.createHostReadinessReport = (_options, collection) => ({ host: collection.assess() });\n`,
Expand All @@ -51,7 +53,9 @@ export function writeInstallerReadinessModuleStubs(readinessDir: string): void {
`${readinessDir}/onboard-admission.js`,
`exports.evaluateOnboardReadinessAdmission = (report, options) => {
const host = report.host;
const unsupportedRuntime = host.runtime === "podman" || host.isUnsupportedRuntime === true;
const unsupportedRuntime =
(host.runtime === "podman" || host.isUnsupportedRuntime === true) &&
!options.allowUnsupportedRuntime;
const cdiBlocks = host.cdiNvidiaGpuSpecNeedsRepair && !(host.isWsl && host.runtime === "docker-desktop");
const storageRemediationAvailable =
host.platform === "linux" &&
Expand Down Expand Up @@ -91,6 +95,10 @@ export function writeInstallerReadinessModuleStubs(readinessDir: string): void {
};
`,
);
fs.writeFileSync(
`${experimentalDir}/portable-profile.js`,
`exports.isPortableExperimentalProfile = (env = process.env) => env.NEMOCLAW_EXPERIMENTAL_PROFILE === "portable";\n`,
);
}

export function runStorageRemediationInstallerPreflight({
Expand Down
7 changes: 7 additions & 0 deletions test/hermes-env-secret-boundary-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,13 @@ wait "$child"
});

describe("Hermes durable lazy-install target", () => {
it("accepts the provider-assigned Hermes API port in the runtime environment", () => {
const result = runRuntimeEnvValidation({ NEMOCLAW_HERMES_API_PORT: "8645" });

expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toBe("");
});

it("accepts the image-owned lazy target in the runtime environment (#8613)", () => {
const result = runRuntimeEnvValidation({
HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages",
Expand Down
32 changes: 32 additions & 0 deletions test/install-portable-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -46,4 +48,34 @@ describe("installer portable profile runtime override", () => {
expect(result.stdout).toBe("DOCKER_HOST=unix:///preexisting.sock\n");
expect(result.stderr).toBe("");
});

it("rejects an unknown experimental profile before install effects (#9007)", () => {
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-invalid-profile-"));
const marker = path.join(fixture, "existing-state");
fs.writeFileSync(marker, "unchanged\n");
const stateBefore = fs.readdirSync(fixture);

const result = spawnSync(
"bash",
[INSTALLER_PAYLOAD, "--experimental-profile", "not-portable"],
{
cwd: fixture,
encoding: "utf-8",
env: {
...process.env,
HOME: fixture,
NEMOCLAW_EXPERIMENTAL_PROFILE: "",
TMPDIR: fixture,
XDG_CONFIG_HOME: path.join(fixture, "config"),
},
},
);

expect(result.status).toBe(1);
expect(`${result.stdout}${result.stderr}`).toContain(
"Unknown experimental profile: not-portable (expected: portable).",
);
expect(fs.readdirSync(fixture)).toEqual(stateBefore);
expect(fs.readFileSync(marker, "utf-8")).toBe("unchanged\n");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
161 changes: 141 additions & 20 deletions test/package-contract/installer-host-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,25 @@ function runInstallerHostAdmissionTest(
runtime: string;
hasNestedOverlayConflict?: boolean;
isUnsupportedRuntime?: boolean;
additionalFindingIds?: string[];
unknownCapabilityIds?: string[];
},
forcedRejection?: { findingIds: string[]; capabilityIds: string[] },
options: {
experimentalProfile?: string;
gatewayManagementMode?: string;
portableProfileArtifact?: "present" | "missing";
} = {},
) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-host-admission-"));
const fakeBin = path.join(tmp, "bin");
const sourceRoot = path.join(tmp, "source");
const onboardDir = path.join(sourceRoot, "dist", "lib", "onboard");
const experimentalDir = path.join(onboardDir, "experimental");
const readinessDir = path.join(sourceRoot, "dist", "lib", "readiness");
fs.mkdirSync(fakeBin);
fs.mkdirSync(onboardDir, { recursive: true });
fs.mkdirSync(experimentalDir, { recursive: true });
fs.mkdirSync(readinessDir, { recursive: true });

fs.writeFileSync(
Expand All @@ -59,8 +68,25 @@ exports.planHostAdvisories = () => [];
);
fs.writeFileSync(
path.join(onboardDir, "gateway-management.js"),
`exports.loadGatewayManagementDeclaration = () => ({ ok: true, declaration: null });\n`,
`const mode = process.env.TEST_GATEWAY_MANAGEMENT_MODE;
exports.loadGatewayManagementDeclaration = () => ({
ok: true,
declaration: mode ? { mode } : null,
});
`,
);
const portableProfileArtifacts =
options.portableProfileArtifact === "missing"
? []
: [
[
path.join(experimentalDir, "portable-profile.js"),
`exports.isPortableExperimentalProfile = (env = process.env) => env.NEMOCLAW_EXPERIMENTAL_PROFILE === "portable";\n`,
] as const,
];
for (const [artifactPath, contents] of portableProfileArtifacts) {
fs.writeFileSync(artifactPath, contents);
}
fs.writeFileSync(
path.join(readinessDir, "host.js"),
`exports.createHostReadinessReport = (_options, collection) => {
Expand All @@ -80,7 +106,10 @@ exports.planHostAdvisories = () => [];
summary: "The detected container runtime is unsupported.",
});
}
return { findings, host };
for (const id of host.additionalFindingIds || []) {
findings.push({ id, severity: "blocking", summary: "Blocking finding: " + id });
}
return { findings, capabilityIds: host.unknownCapabilityIds || [], host };
};
`,
);
Expand All @@ -92,22 +121,55 @@ exports.evaluateOnboardReadinessAdmission = (report, options) => {
return { admitted: false, reasonIds: [], ...forcedRejection, waivedFindingIds: [] };
}
const findingIds = report.findings
.filter((finding) =>
finding.id !== "host.docker.storage_incompatible" || !options.allowStorageRemediation
)
.filter((finding) => {
if (
finding.id === "host.docker.runtime_unsupported" &&
options.allowUnsupportedRuntime
) return false;
if (
finding.id === "host.docker.storage_incompatible" &&
options.allowStorageRemediation
) return false;
if (
options.allowPortableHostPreparation &&
(finding.id === "host.docker.daemon_unreachable" ||
finding.id === "host.docker.storage_incompatible")
) return false;
return true;
})
.map((finding) => finding.id);
return findingIds.length === 0
? { admitted: true, waivedFindingIds: ["host.docker.storage_incompatible"] }
: { admitted: false, reasonIds: [], findingIds, capabilityIds: [], waivedFindingIds: [] };
const capabilityIds = report.capabilityIds.filter(
(id) =>
!options.allowPortableHostPreparation ||
(id !== "host.docker.daemon_reachable" &&
id !== "host.docker.runtime_supported" &&
id !== "host.docker.storage_compatible")
);
return findingIds.length === 0 && capabilityIds.length === 0
? { admitted: true, waivedFindingIds: [] }
: { admitted: false, reasonIds: [], findingIds, capabilityIds, waivedFindingIds: [] };
};
`,
);
fs.writeFileSync(
path.join(onboardDir, "gateway-management.js"),
`exports.loadGatewayManagementDeclaration = () => ({ ok: true, declaration: null });\n`,
);
writeNodeStub(fakeBin);

const {
NEMOCLAW_EXPERIMENTAL_PROFILE: _experimentalProfile,
TEST_GATEWAY_MANAGEMENT_MODE: _gatewayManagementMode,
...inheritedEnv
} = process.env;
const childEnv: NodeJS.ProcessEnv = {
...inheritedEnv,
HOME: tmp,
INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD,
PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`,
SOURCE_ROOT: sourceRoot,
...(options.experimentalProfile
? { NEMOCLAW_EXPERIMENTAL_PROFILE: options.experimentalProfile }
: {}),
TEST_GATEWAY_MANAGEMENT_MODE: options.gatewayManagementMode ?? "",
};

const result = spawnSync(
"bash",
[
Expand All @@ -121,13 +183,7 @@ run_installer_host_preflight
{
cwd: tmp,
encoding: "utf-8",
env: {
...process.env,
HOME: tmp,
INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD,
PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`,
SOURCE_ROOT: sourceRoot,
},
env: childEnv,
},
);

Expand All @@ -145,7 +201,21 @@ describe("installer host preflight package contract", () => {
expect(output).not.toMatch(/Host preflight found issues/);
});

it("prints the blocking readiness finding when no advisory action exists", () => {
it("admits an unsupported runtime for the explicit portable profile (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
},
undefined,
{ experimentalProfile: "portable" },
);

expect(result.status, output).toBe(0);
expect(output).not.toMatch(/Host preflight found issues/);
});

it("rejects the same unsupported runtime without the portable profile (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest({
runtime: "podman",
isUnsupportedRuntime: true,
Expand All @@ -156,6 +226,57 @@ describe("installer host preflight package contract", () => {
expect(output).toMatch(/The detected container runtime is unsupported\./);
});

it("keeps an unsupported runtime blocked without the portable classifier artifact (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
},
undefined,
{ experimentalProfile: "portable", portableProfileArtifact: "missing" },
);

expect(result.status).toBe(1);
expect(output).toMatch(/Host preflight found issues/);
expect(output).toMatch(/The detected container runtime is unsupported\./);
});

it.each([
["daemon reachability", "host.docker.daemon_unreachable", undefined],
["storage compatibility", "host.docker.storage_incompatible", "externally-supervised"],
["GPU prerequisites", "host.gpu.container_toolkit_missing", undefined],
["platform qualification", "host.platform.unsupported", undefined],
["an injected finding", "host.test.blocked", undefined],
])("rejects %s blockers for the explicit portable profile (#9007)", (_, findingId, mode) => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
additionalFindingIds: [findingId],
},
undefined,
{ experimentalProfile: "portable", gatewayManagementMode: mode },
);

expect(result.status, output).toBe(1);
expect(output).toContain(findingId);
});

it("rejects an unknown required capability for the explicit portable profile (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
unknownCapabilityIds: ["host.docker.runtime_supported"],
},
undefined,
{ experimentalProfile: "portable" },
);

expect(result.status, output).toBe(1);
expect(output).toContain("host.docker.runtime_supported");
});

it("prints only stable unknown finding and required-capability diagnostics", () => {
const oversizedFindingId = `host.${"f".repeat(124)}`;
const oversizedCapabilityId = `host.${"c".repeat(124)}`;
Expand Down
Loading
Loading