Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c9c12f7
feat(cli): add Hermes portable lifecycle authority
senthilr-nv Aug 18, 2026
d62edfd
fix(cli): harden Hermes portable lifecycle admission
senthilr-nv Aug 18, 2026
de66410
merge: update Hermes portable branch from main
senthilr-nv Aug 18, 2026
a7417be
feat(cli): bind Hermes portable lifecycle authority
senthilr-nv Aug 18, 2026
ffd482c
merge(main): rebind Hermes portable lifecycle
senthilr-nv Aug 18, 2026
33790d4
fix(cli): accept private installer source modes
senthilr-nv Aug 18, 2026
7db9997
fix(cli): resolve Hermes lifecycle analyzer findings
senthilr-nv Aug 18, 2026
fa80cae
fix(cli): address portable lifecycle review findings
senthilr-nv Aug 18, 2026
186e0cd
merge: resolve conflicts with main
github-actions[bot] Aug 18, 2026
8ea4291
fix(cli): consolidate portable lifecycle authority
senthilr-nv Aug 18, 2026
33d3b1c
fix(onboard): preserve agent-aware GPU plan
senthilr-nv Aug 18, 2026
5827986
fix(cli): complete Hermes lifecycle review fixes
rsliter Aug 18, 2026
cb31b1a
merge: reconcile Hermes lifecycle authority
rsliter Aug 18, 2026
a512bc4
fix(onboard): admit hosted policy source modes
senthilr-nv Aug 18, 2026
fd51829
refactor(sessions): centralize deferred lifecycle exit
senthilr-nv Aug 18, 2026
0afdaa8
fix(installer): constrain source checkout permissions
senthilr-nv Aug 18, 2026
bfe4b79
fix(installer): isolate Hermes Podman selector
senthilr-nv Aug 18, 2026
dcd2b86
fix(onboard): isolate Hermes Podman selectors
senthilr-nv Aug 18, 2026
46564a4
fix(onboard): bind Hermes route reservation
senthilr-nv Aug 18, 2026
548b9d1
merge(main): resolve #9424 against current main
senthilr-nv Aug 18, 2026
4581137
fix(onboard): preserve route reservation session authority
senthilr-nv Aug 18, 2026
779fd6c
fix(onboard): centralize route reservation authority
senthilr-nv Aug 19, 2026
c1e0c55
fix(onboard): preserve route reservation provenance
senthilr-nv Aug 19, 2026
8bdba5a
merge: resolve conflicts with main
github-actions[bot] Aug 19, 2026
45f6d85
fix(onboard): preserve portable route provenance
senthilr-nv Aug 19, 2026
e674156
merge(main): sync PR #9424 with main
senthilr-nv Aug 19, 2026
3ab4823
chore(onboard): clarify portable lifecycle handoff
senthilr-nv Aug 19, 2026
dbb9d2e
fix(onboard): admit reviewed WeChat build inputs
senthilr-nv Aug 19, 2026
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
4 changes: 0 additions & 4 deletions ci/onboard-entry-composition-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,17 @@
"runOnboard": 2
},
"messaging": {
"createSandboxWithBaseImageResolution": 9,
"createSandboxWithBaseImageResolution.plan.rebindMessagingTokenDefs": 1,
"getOpenShellInstallDeps.hasRequiredOpenshellMessagingFeatures": 4,
"runOnboard": 1,
"runOnboard.finalizationDeps.verifyDeployment.getMessagingChannels": 1
},
"policy": {
"createOnboardPolicyApplication.getRecordedPolicyTier": 1,
"createSandboxWithBaseImageResolution": 7,
"preflightAuthoritativeRebuildTarget": 1,
"runOnboard": 6,
"sandboxCreateIntentResolver.getAgentPolicyPath": 1
},
"provider": {
"createSandboxWithBaseImageResolution": 20,
"handleNimLocalSelection": 36,
"handleRemoteProviderSelection": 84,
"handleRemoteProviderSelection.providerExistsInGateway": 1,
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 @@ -49,7 +49,7 @@
"src/lib/inference/local.ts": 21,
"src/lib/inference/onboard-probes.ts": 21,
"src/lib/inference/vllm.ts": 21,
"src/lib/onboard.ts": 203,
"src/lib/onboard.ts": 201,
"src/lib/onboard/machine/handlers/sandbox.ts": 21,
"src/lib/sandbox/config.ts": 22,
"src/lib/shields/index.ts": 23
Expand Down
20 changes: 12 additions & 8 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,18 @@ has_payload_marker() {
clone_nemoclaw_ref() {
local ref="$1" dest="$2"

git init --quiet "$dest"
git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git
if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then
printf "[ERROR] Requested install ref '%s' is not available from https://github.com/NVIDIA/NemoClaw.git.\n" "$ref" >&2
printf " Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again.\n" >&2
exit 1
fi
git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target
(
# Git applies the process umask when it creates the authoritative source checkout.
umask 022
git init --quiet "$dest"
git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git
if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then
printf "[ERROR] Requested install ref '%s' is not available from https://github.com/NVIDIA/NemoClaw.git.\n" "$ref" >&2
printf " Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again.\n" >&2
exit 1
fi
git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target
)
}

exec_installer_from_ref() {
Expand Down
2 changes: 1 addition & 1 deletion scripts/dev-tier-selector.mts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ runner.run = () => successfulRunResult;
runner.runCapture = () => "";

registry.getSandbox = () => ({ name: "test-sb", model: null, provider: null });
registry.registerSandbox = () => true;
registry.registerSandbox = (entry) => entry;
registry.updateSandbox = () => true;

// ── Run ────────────────────────────────────────────────────────────────────
Expand Down
40 changes: 31 additions & 9 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ set -euo pipefail
# are removed on any exit path (set -e, unhandled signal, unexpected error).
_cleanup_pids=()
_cleanup_files=()
# Bind the portable installer's temporary Docker CLI selector so only that
# exact value can be removed from the Hermes onboarding child.
unset _PORTABLE_INSTALLER_DOCKER_HOST
_PORTABLE_INSTALLER_DOCKER_HOST=""
# #4414: When re-launched as a staged copy via `curl | bash`, queue the
# staged tmpfile for removal on EXIT. NEMOCLAW_INSTALLER_STAGED carries
# the staged path forward so both the loop guard and cleanup use one var.
Expand Down Expand Up @@ -173,12 +177,16 @@ resolve_stamped_version() {
clone_nemoclaw_ref() {
local ref="$1" dest="$2"

git init --quiet "$dest"
git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git
if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then
error "Requested install ref '$ref' is not available from https://github.com/NVIDIA/NemoClaw.git. Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again."
fi
git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target
(
# Git applies the process umask when it creates the authoritative source checkout.
umask 022
git init --quiet "$dest"
git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git
if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then
error "Requested install ref '$ref' is not available from https://github.com/NVIDIA/NemoClaw.git. Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again."
fi
git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target
)
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -3937,15 +3945,28 @@ run_onboard() {
# forward --yes so the Ollama size-confirmation gate does not abort
# the unattended download (the size is still printed to logs).
onboard_cmd+=(--yes)
fi

local invoke_bin="$cli_invoke"
local -a invoke_args=("${onboard_cmd[@]}")
if [[ "${NEMOCLAW_EXPERIMENTAL_PROFILE:-}" == "portable" &&
"${NEMOCLAW_AGENT:-openclaw}" == "hermes" &&
-n "$_PORTABLE_INSTALLER_DOCKER_HOST" &&
"${DOCKER_HOST:-}" == "$_PORTABLE_INSTALLER_DOCKER_HOST" ]]; then
invoke_bin="/usr/bin/env"
invoke_args=(-u DOCKER_HOST "$cli_invoke" "${onboard_cmd[@]}")
fi

if [ "${NON_INTERACTIVE:-}" = "1" ]; then
NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION="$installer_auto_fresh_receipt_generation" \
"$cli_invoke" "${onboard_cmd[@]}" || status=$?
"$invoke_bin" "${invoke_args[@]}" || status=$?
elif [ -t 0 ]; then
NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION="$installer_auto_fresh_receipt_generation" \
"$cli_invoke" "${onboard_cmd[@]}" || status=$?
"$invoke_bin" "${invoke_args[@]}" || status=$?
elif { exec 3</dev/tty; } 2>/dev/null; then
info "Installer stdin is piped; attaching onboarding to /dev/tty…"
NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION="$installer_auto_fresh_receipt_generation" \
"$cli_invoke" "${onboard_cmd[@]}" <&3 || status=$?
"$invoke_bin" "${invoke_args[@]}" <&3 || status=$?
exec 3<&-
else
error "Interactive onboarding requires a TTY. Re-run in a terminal or set NEMOCLAW_NON_INTERACTIVE=1 with --yes-i-accept-third-party-software."
Expand Down Expand Up @@ -4164,6 +4185,7 @@ prepare_portable_experimental_runtime_override() {
/*) export DOCKER_HOST="unix://${podman_socket}" ;;
*) error "Podman reported an invalid rootless API socket path: ${podman_socket:-empty}" ;;
esac
_PORTABLE_INSTALLER_DOCKER_HOST="$DOCKER_HOST"

info "Portable profile selected rootless Podman through DOCKER_HOST=${DOCKER_HOST}."
}
Expand Down
2 changes: 2 additions & 0 deletions src/commands/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => ({

vi.mock("../lib/credentials/store", () => ({
KNOWN_CREDENTIAL_ENV_KEYS: ["NVIDIA_INFERENCE_API_KEY"],
getCredential: vi.fn(),
prompt: mocks.prompt,
saveCredential: vi.fn(),
}));
vi.mock("../lib/actions/global", () => ({
recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime,
Expand Down
59 changes: 59 additions & 0 deletions src/commands/global-oclif-command-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({
runInferenceSet: vi.fn(),
runOnboardAction: vi.fn(),
runUpgradeSandboxesAction: vi.fn(),
assertNoHermesPortableHostAuthority: vi.fn(),
withPortableHostFence: vi.fn(
async (homeOrOperation: string | (() => unknown), operation?: () => unknown) =>
(typeof homeOrOperation === "function" ? homeOrOperation : operation)?.(),
),
showStatusCommand: vi.fn(),
onboardRuntimeDeps: { googlechatTunnelRuntime: {} },
}));
Expand All @@ -42,6 +47,13 @@ vi.mock("../lib/actions/global", () => ({
runUpgradeSandboxesAction: mocks.runUpgradeSandboxesAction,
}));

vi.mock("../lib/state/portable-uninstall-retirement", async (importOriginal) => ({
...(await importOriginal()),
assertNoHermesPortableHostAuthority: mocks.assertNoHermesPortableHostAuthority,
withCurrentPortableHostFence: mocks.withPortableHostFence,
withPortableHostFence: mocks.withPortableHostFence,
}));

vi.mock("../lib/cli/onboard-runtime-deps", () => ({
createOnboardActionRuntimeDeps: mocks.createOnboardActionRuntimeDeps,
}));
Expand Down Expand Up @@ -88,6 +100,7 @@ const rootDir = process.cwd();
describe("global oclif command adapters", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.assertNoHermesPortableHostAuthority.mockReset();
mocks.buildListCommandDeps.mockReturnValue({ getLiveInference: vi.fn() });
mocks.buildStatusCommandDeps.mockReturnValue({ statusDeps: true });
mocks.getSandboxInventory.mockResolvedValue({ sandboxes: [] });
Expand All @@ -111,6 +124,11 @@ describe("global oclif command adapters", () => {
it("runs list through inventory helpers", async () => {
await ListCommand.run([], rootDir);

expect(mocks.withPortableHostFence).toHaveBeenCalledOnce();
expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith(
expect.any(String),
"list",
);
expect(mocks.buildListCommandDeps).toHaveBeenCalledWith();
expect(mocks.getSandboxInventory).toHaveBeenCalledWith({
getLiveInference: expect.any(Function),
Expand Down Expand Up @@ -170,6 +188,8 @@ describe("global oclif command adapters", () => {
it("runs status through status helpers", async () => {
await StatusCommand.run([], rootDir);

expect(mocks.withPortableHostFence).toHaveBeenCalledOnce();
expect(mocks.assertNoHermesPortableHostAuthority).not.toHaveBeenCalled();
expect(mocks.buildStatusCommandDeps).toHaveBeenCalledWith(rootDir);
expect(mocks.showStatusCommand).toHaveBeenCalledWith({ statusDeps: true });
});
Expand Down Expand Up @@ -214,6 +234,40 @@ describe("global oclif command adapters", () => {
});
});

it.each([
[
"list",
() => ListCommand.run([], rootDir),
[mocks.buildListCommandDeps, mocks.getSandboxInventory],
],
["inference:get", () => InferenceGetCommand.run([], rootDir), [mocks.runInferenceGet]],
[
"upgrade-sandboxes",
() => UpgradeSandboxesCommand.run(["--check"], rootDir),
[mocks.runUpgradeSandboxesAction],
],
] as const)(
"rejects %s under the host fence before any action (#9203)",
async (commandId, run, effects) => {
mocks.assertNoHermesPortableHostAuthority.mockImplementation(() => {
throw new Error(
`Command '${commandId}' is not supported while an experimental Hermes portable lifecycle receipt exists. No legacy Docker or OpenShell action was attempted.`,
);
});

await expect(run()).rejects.toThrow(
`Command '${commandId}' is not supported while an experimental Hermes portable lifecycle receipt exists`,
);

expect(mocks.withPortableHostFence).toHaveBeenCalledOnce();
expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith(
expect.any(String),
commandId,
);
expect(effects.every((effect) => effect.mock.calls.length === 0)).toBe(true);
},
);

it("maps onboard-family flags directly into the shared typed action", async () => {
await OnboardCliCommand.run(["--name", "alpha", "--resume"], rootDir);
await SetupCliCommand.run(["--name", "alpha", "--resume"], rootDir);
Expand Down Expand Up @@ -272,6 +326,11 @@ describe("global oclif command adapters", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await InferenceGetCommand.run(["--json"], rootDir);
expect(mocks.withPortableHostFence).toHaveBeenCalledOnce();
expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith(
expect.any(String),
"inference:get",
);
expect(mocks.runInferenceGet).toHaveBeenCalledWith({ quiet: true });
expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toEqual({
provider: "nvidia-prod",
Expand Down
15 changes: 11 additions & 4 deletions src/commands/sandbox/config/rotate-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import { Args, Flags } from "@oclif/core";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";
import {
assertHermesPortableCommandUnavailable,
NemoClawCommand,
withSandboxCommandLifecycleLock,
} from "../../../lib/cli/nemoclaw-oclif-command";

import * as sandboxConfig from "../../../lib/sandbox/config";

Expand Down Expand Up @@ -37,9 +41,12 @@ export default class SandboxConfigRotateTokenCommand extends NemoClawCommand {
public async run(): Promise<void> {
const { args, flags } = await this.parse(SandboxConfigRotateTokenCommand);
try {
await sandboxConfig.configRotateToken(args.sandboxName, {
fromEnv: flags["from-env"] ?? null,
fromStdin: flags.stdin ?? false,
await withSandboxCommandLifecycleLock(args.sandboxName, () => {
assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:config:rotate-token");
return sandboxConfig.configRotateToken(args.sandboxName, {
fromEnv: flags["from-env"] ?? null,
fromStdin: flags.stdin ?? false,
});
});
} catch (error) {
if (error instanceof sandboxConfig.SandboxConfigError) {
Expand Down
19 changes: 13 additions & 6 deletions src/commands/sandbox/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import { Args, Flags } from "@oclif/core";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";
import {
assertHermesPortableCommandUnavailable,
NemoClawCommand,
withSandboxCommandLifecycleLock,
} from "../../../lib/cli/nemoclaw-oclif-command";

import * as sandboxConfig from "../../../lib/sandbox/config";

Expand Down Expand Up @@ -42,11 +46,14 @@ export default class SandboxConfigSetCommand extends NemoClawCommand {
public async run(): Promise<void> {
const { args, flags } = await this.parse(SandboxConfigSetCommand);
try {
await sandboxConfig.configSet(args.sandboxName, {
key: flags.key ?? null,
value: flags.value ?? null,
restart: flags.restart ?? false,
acceptNewPath: flags["config-accept-new-path"] ?? false,
await withSandboxCommandLifecycleLock(args.sandboxName, () => {
assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:config:set");
return sandboxConfig.configSet(args.sandboxName, {
key: flags.key ?? null,
value: flags.value ?? null,
restart: flags.restart ?? false,
acceptNewPath: flags["config-accept-new-path"] ?? false,
});
});
} catch (error) {
if (error instanceof sandboxConfig.SandboxConfigError) {
Expand Down
15 changes: 15 additions & 0 deletions src/commands/sandbox/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock("../../lib/actions/sandbox/exec", () => ({
}));

import { log } from "../../lib/cli/logger";
import * as portableAgentLifecycle from "../../lib/onboard/experimental/portable-agent-lifecycle";
import SandboxExecCommand from "./exec";

const rootDir = process.cwd();
Expand All @@ -34,6 +35,20 @@ describe("SandboxExecCommand oclif parse path", () => {
);
});

it("rejects schema-5 inside the user-facing exec lifecycle fence (#9203)", async () => {
vi.spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable").mockImplementation(
() => {
throw new Error("schema-5 rejected");
},
);

await expect(SandboxExecCommand.run(["alpha", "--", "true"], rootDir)).rejects.toThrow(
"schema-5 rejected",
);

expect(execSandboxMock).not.toHaveBeenCalled();
});

it("does not assign host meaning to logging flags after --", async () => {
const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined);

Expand Down
19 changes: 13 additions & 6 deletions src/commands/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

import { Args, Flags } from "@oclif/core";
import { execSandbox } from "../../lib/actions/sandbox/exec";
import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";
import {
assertHermesPortableCommandUnavailable,
NemoClawCommand,
withSandboxCommandLifecycleLock,
} from "../../lib/cli/nemoclaw-oclif-command";

export default class SandboxExecCommand extends NemoClawCommand {
static id = "sandbox:exec";
Expand Down Expand Up @@ -51,11 +55,14 @@ export default class SandboxExecCommand extends NemoClawCommand {
const cmd = (
separatorIndex === -1 ? argv.slice(1) : originalArgv.slice(separatorIndex + 1)
) as string[];
await execSandbox(args.sandboxName, cmd, {
workdir: flags.workdir,
tty: typeof flags.tty === "boolean" ? flags.tty : null,
timeoutSeconds: flags.timeout,
stdin: flags.stdin,
await withSandboxCommandLifecycleLock(args.sandboxName, () => {
assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:exec");
return execSandbox(args.sandboxName, cmd, {
workdir: flags.workdir,
tty: typeof flags.tty === "boolean" ? flags.tty : null,
timeoutSeconds: flags.timeout,
stdin: flags.stdin,
});
});
}
}
Loading
Loading