From 73c58d2a6216387e4dbc921e8068d7f12e3c26b6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 15:34:00 -0700 Subject: [PATCH 01/12] refactor(cli): group remaining architecture modules --- .coderabbit.yaml | 2 +- bin/lib/tiers.js | 6 +- nemoclaw-blueprint/private-networks.yaml | 2 +- scripts/benchmark-sandbox-image-build.js | 2 +- scripts/check-legacy-migrated-paths.ts | 6 +- scripts/dev-tier-selector.js | 2 +- scripts/ts-migration-assist.ts | 8 +- scripts/ts-migration/move-map.json | 4 +- .../phases/05-runtime-leaves.json | 2 +- scripts/ts-migration/phases/07-policies.json | 2 +- src/commands/README.md | 21 ++++++ src/commands/sandbox/config/set.ts | 2 +- src/lib/README.md | 7 +- src/lib/actions/sandbox/connect.ts | 2 +- src/lib/actions/sandbox/destroy.ts | 4 +- src/lib/actions/sandbox/doctor.ts | 2 +- src/lib/actions/sandbox/policy-channel.ts | 6 +- src/lib/actions/sandbox/rebuild.ts | 4 +- src/lib/actions/sandbox/runtime.ts | 74 ------------------- src/lib/actions/sandbox/snapshot.ts | 4 +- src/lib/actions/sandbox/status.ts | 2 +- src/lib/actions/upgrade-sandboxes.ts | 2 +- src/lib/adapters/http/README.md | 8 ++ .../http/probe.test.ts} | 2 +- .../{http-probe.ts => adapters/http/probe.ts} | 8 +- src/lib/commands/deprecated/start.ts | 4 +- src/lib/commands/deprecated/stop.ts | 4 +- .../global-oclif-command-adapters.test.ts | 2 +- src/lib/commands/list.ts | 2 +- src/lib/commands/sandbox/config/get.ts | 2 +- .../sandbox/config/set.ts} | 2 +- src/lib/commands/sandbox/connect.ts | 2 +- src/lib/commands/sandbox/destroy.ts | 2 +- src/lib/commands/sandbox/logs.ts | 11 ++- .../sandbox/oclif-command-adapters.test.ts | 13 +++- src/lib/commands/sandbox/rebuild.ts | 2 +- src/lib/commands/sandbox/skill/common.ts | 11 ++- src/lib/commands/sandbox/snapshot/common.ts | 11 ++- src/lib/commands/sandbox/status.ts | 2 +- .../simple-global-oclif-adapters.test.ts | 4 +- src/lib/commands/status.ts | 2 +- src/lib/commands/tunnel/start.ts | 4 +- src/lib/commands/tunnel/stop.ts | 4 +- src/lib/coverage-hotspots.test.ts | 2 +- src/lib/deploy/README.md | 8 ++ .../{deploy.test.ts => deploy/index.test.ts} | 4 +- src/lib/{deploy.ts => deploy/index.ts} | 4 +- src/lib/inference/health.ts | 4 +- src/lib/inference/local.ts | 4 +- src/lib/inference/onboard-probes.ts | 2 +- src/lib/inference/provider-models.ts | 4 +- src/lib/inventory/README.md | 9 +++ .../index.test.ts} | 2 +- .../index.ts} | 6 +- src/lib/list-command-deps.ts | 2 +- src/lib/messaging-channel-config.ts | 2 +- src/lib/messaging-conflict.ts | 2 +- src/lib/onboard.ts | 64 +++++----------- src/lib/onboard/branding.ts | 26 +++++++ src/lib/onboard/env.ts | 20 +++++ src/lib/policy/README.md | 9 +++ src/lib/{policies.ts => policy/index.ts} | 8 +- src/lib/{ => policy}/tiers.ts | 2 +- src/lib/recover-cli-command.ts | 2 +- src/lib/registry-recovery-action.ts | 2 +- .../build-context.ts} | 0 .../channels.test.ts} | 2 +- .../channels.ts} | 2 +- .../{sandbox-config.ts => sandbox/config.ts} | 26 +++---- .../create-stream.test.ts} | 2 +- .../create-stream.ts} | 2 +- .../version.test.ts} | 14 ++-- .../version.ts} | 12 +-- src/lib/shields/index.test.ts | 4 +- src/lib/shields/index.ts | 4 +- src/lib/shields/timer.ts | 4 +- src/lib/status-command-deps.ts | 4 +- src/lib/tunnel/README.md | 9 +++ .../service-command.test.ts} | 2 +- .../service-command.ts} | 0 src/lib/{ => tunnel}/services-sandbox.test.ts | 4 +- src/lib/{ => tunnel}/services.test.ts | 3 +- src/lib/{ => tunnel}/services.ts | 12 +-- src/nemoclaw.ts | 2 +- test/cli.test.ts | 2 +- test/config-set-cli-dispatch.test.ts | 2 +- test/config-set-nested-ssrf.test.ts | 24 +++--- test/config-set.test.ts | 2 +- test/credential-exposure.test.ts | 2 +- test/e2e/test-rebuild-openclaw.sh | 6 +- test/onboard-preset-diff.test.ts | 2 +- test/onboard-readiness.test.ts | 2 +- test/onboard.test.ts | 20 +++-- test/policies.test.ts | 4 +- test/policy-tiers-onboard.test.ts | 20 ++--- test/policy-tiers.test.ts | 4 +- test/repro-2010.test.ts | 2 +- test/sandbox-build-context.test.ts | 2 +- test/shellquote-sandbox.test.ts | 2 +- test/wsl2-probe-timeout.test.ts | 4 +- 100 files changed, 353 insertions(+), 311 deletions(-) create mode 100644 src/commands/README.md delete mode 100644 src/lib/actions/sandbox/runtime.ts create mode 100644 src/lib/adapters/http/README.md rename src/lib/{http-probe.test.ts => adapters/http/probe.test.ts} (99%) rename src/lib/{http-probe.ts => adapters/http/probe.ts} (98%) rename src/lib/{sandbox-config-set-cli-command.ts => commands/sandbox/config/set.ts} (96%) create mode 100644 src/lib/deploy/README.md rename src/lib/{deploy.test.ts => deploy/index.test.ts} (99%) rename src/lib/{deploy.ts => deploy/index.ts} (99%) create mode 100644 src/lib/inventory/README.md rename src/lib/{inventory-commands.test.ts => inventory/index.test.ts} (99%) rename src/lib/{inventory-commands.ts => inventory/index.ts} (98%) create mode 100644 src/lib/onboard/branding.ts create mode 100644 src/lib/onboard/env.ts create mode 100644 src/lib/policy/README.md rename src/lib/{policies.ts => policy/index.ts} (99%) rename src/lib/{ => policy}/tiers.ts (99%) rename src/lib/{sandbox-build-context.ts => sandbox/build-context.ts} (100%) rename src/lib/{sandbox-channels.test.ts => sandbox/channels.test.ts} (98%) rename src/lib/{sandbox-channels.ts => sandbox/channels.ts} (98%) rename src/lib/{sandbox-config.ts => sandbox/config.ts} (97%) rename src/lib/{sandbox-create-stream.test.ts => sandbox/create-stream.test.ts} (99%) rename src/lib/{sandbox-create-stream.ts => sandbox/create-stream.ts} (99%) rename src/lib/{sandbox-version.test.ts => sandbox/version.test.ts} (94%) rename src/lib/{sandbox-version.ts => sandbox/version.ts} (91%) create mode 100644 src/lib/tunnel/README.md rename src/lib/{services-command.test.ts => tunnel/service-command.test.ts} (98%) rename src/lib/{services-command.ts => tunnel/service-command.ts} (100%) rename src/lib/{ => tunnel}/services-sandbox.test.ts (99%) rename src/lib/{ => tunnel}/services.test.ts (98%) rename src/lib/{ => tunnel}/services.ts (97%) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 5c4fa3d7569..5f6acaf63f8 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -224,7 +224,7 @@ reviews: - path: "src/lib/onboard/preflight.ts" instructions: *e2e-overlayfs - - path: "src/lib/deploy.ts" + - path: "src/lib/deploy/**" instructions: | This file contains deployment lifecycle logic (start/stop, cloudflared tunnel, uninstall). diff --git a/bin/lib/tiers.js b/bin/lib/tiers.js index 1f7a58e485a..e0ccac7dc0a 100644 --- a/bin/lib/tiers.js +++ b/bin/lib/tiers.js @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Thin re-export shim — the implementation lives in src/lib/tiers.ts, -// compiled to dist/lib/tiers.js. +// Thin re-export shim — the implementation lives in src/lib/policy/tiers.ts, +// compiled to dist/lib/policy/tiers.js. -const mod = require("../../dist/lib/tiers"); +const mod = require("../../dist/lib/policy/tiers"); module.exports = { TIERS_FILE: mod.TIERS_FILE, listTiers: mod.listTiers, diff --git a/nemoclaw-blueprint/private-networks.yaml b/nemoclaw-blueprint/private-networks.yaml index 35179e07d2d..3081d0eab7f 100644 --- a/nemoclaw-blueprint/private-networks.yaml +++ b/nemoclaw-blueprint/private-networks.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # # IPv4 and IPv6 networks that SSRF validation must reject. Consumed by: -# - src/lib/sandbox-config.ts (CLI `config set` literal-IP gate) +# - src/lib/sandbox/config.ts (CLI `config set` literal-IP gate) # - nemoclaw/src/blueprint/ssrf.ts (plugin endpoint URL validator) # Both consumers build a node:net BlockList from this data at module load. # diff --git a/scripts/benchmark-sandbox-image-build.js b/scripts/benchmark-sandbox-image-build.js index 332af919bf7..20f5cfff8af 100755 --- a/scripts/benchmark-sandbox-image-build.js +++ b/scripts/benchmark-sandbox-image-build.js @@ -10,7 +10,7 @@ const { collectBuildContextStats, stageLegacySandboxBuildContext, stageOptimizedSandboxBuildContext, -} = require("../dist/lib/sandbox-build-context"); +} = require("../dist/lib/sandbox/build-context"); function parseArgs(argv) { const args = { diff --git a/scripts/check-legacy-migrated-paths.ts b/scripts/check-legacy-migrated-paths.ts index 8a12edcfd94..64eac01bf7e 100644 --- a/scripts/check-legacy-migrated-paths.ts +++ b/scripts/check-legacy-migrated-paths.ts @@ -36,11 +36,11 @@ const REMOVED_SHIM_MOVES: Record = { "bin/lib/registry.js": "src/lib/state/registry.ts", "bin/lib/resolve-openshell.js": "src/lib/adapters/openshell/resolve.ts", "bin/lib/runtime-recovery.js": "src/lib/runtime-recovery.ts", - "bin/lib/sandbox-build-context.js": "src/lib/sandbox-build-context.ts", - "bin/lib/services.js": "src/lib/services.ts", + "bin/lib/sandbox-build-context.js": "src/lib/sandbox/build-context.ts", + "bin/lib/services.js": "src/lib/tunnel/services.ts", "bin/lib/version.js": "src/lib/core/version.ts", "bin/lib/onboard.js": "src/lib/onboard.ts", - "bin/lib/policies.js": "src/lib/policies.ts", + "bin/lib/policies.js": "src/lib/policy/index.ts", "bin/lib/runner.js": "src/lib/runner.ts", }; diff --git a/scripts/dev-tier-selector.js b/scripts/dev-tier-selector.js index 49aa6884292..543d821749b 100644 --- a/scripts/dev-tier-selector.js +++ b/scripts/dev-tier-selector.js @@ -55,7 +55,7 @@ const onboard = /** @type {{ * selectTierPresetsAndAccess: (tierName: string, allPresets: unknown[]) => Promise; * }} */ (require("../dist/lib/onboard.js")); const { selectPolicyTier, selectTierPresetsAndAccess } = onboard; -const policies = require("../dist/lib/policies.js"); +const policies = require("../dist/lib/policy/index.js"); (async () => { const tier = await selectPolicyTier(); diff --git a/scripts/ts-migration-assist.ts b/scripts/ts-migration-assist.ts index 847913ad4c9..ab223c64534 100644 --- a/scripts/ts-migration-assist.ts +++ b/scripts/ts-migration-assist.ts @@ -41,20 +41,20 @@ const SPECIAL_REWRITES: Record> = { ['require("./lib/credentials")', 'require("../bin/lib/credentials")'], ['require("./lib/registry")', 'require("../bin/lib/registry")'], ['require("./lib/nim")', 'require("../bin/lib/nim")'], - ['require("./lib/policies")', 'require("../bin/lib/policies")'], + ['require("./lib/policy")', 'require("../bin/lib/policies")'], ['require("./lib/inference/config")', 'require("../bin/lib/inference-config")'], ['require("./lib/version")', 'require("../bin/lib/version")'], ['require("./lib/state/onboard-session")', 'require("../bin/lib/onboard-session")'], ['require("./lib/runtime-recovery")', 'require("../bin/lib/runtime-recovery")'], ['require("./lib/onboard/usage-notice")', 'require("../bin/lib/usage-notice")'], - ['require("./lib/services")', 'require("../bin/lib/services")'], + ['require("./lib/tunnel/services")', 'require("../bin/lib/services")'], ['require("./lib/debug")', 'require("./lib/diagnostics/debug")'], ['require("./lib/debug-command")', 'require("./lib/diagnostics/debug-command")'], ['require("../dist/lib/debug-command")', 'require("./lib/diagnostics/debug-command")'], ['require("../dist/lib/openshell")', 'require("./lib/openshell")'], - ['require("../dist/lib/inventory-commands")', 'require("./lib/inventory-commands")'], + ['require("./lib/inventory")', 'require("./lib/inventory-commands")'], ['require("../dist/lib/deploy")', 'require("./lib/deploy")'], - ['require("../dist/lib/services-command")', 'require("./lib/services-command")'], + ['require("./lib/tunnel/service-command")', 'require("./lib/services-command")'], ['require("../dist/lib/uninstall-command")', 'require("./lib/uninstall-command")'], ], }; diff --git a/scripts/ts-migration/move-map.json b/scripts/ts-migration/move-map.json index 15f71916199..fd33cff84a6 100644 --- a/scripts/ts-migration/move-map.json +++ b/scripts/ts-migration/move-map.json @@ -1,9 +1,9 @@ { "runtimeMoves": { "bin/lib/platform.js": "src/lib/platform.ts", - "bin/lib/sandbox-build-context.js": "src/lib/sandbox-build-context.ts", + "bin/lib/sandbox-build-context.js": "src/lib/sandbox/build-context.ts", "bin/lib/runner.js": "src/lib/runner.ts", - "bin/lib/policies.js": "src/lib/policies.ts", + "bin/lib/policies.js": "src/lib/policy/index.ts", "bin/lib/onboard.js": "src/lib/onboard.ts", "bin/nemoclaw.js": "src/nemoclaw.ts" } diff --git a/scripts/ts-migration/phases/05-runtime-leaves.json b/scripts/ts-migration/phases/05-runtime-leaves.json index 183470c1c7b..103efb2698c 100644 --- a/scripts/ts-migration/phases/05-runtime-leaves.json +++ b/scripts/ts-migration/phases/05-runtime-leaves.json @@ -1,7 +1,7 @@ { "moveRuntime": { "bin/lib/platform.js": "src/lib/platform.ts", - "bin/lib/sandbox-build-context.js": "src/lib/sandbox-build-context.ts" + "bin/lib/sandbox-build-context.js": "src/lib/sandbox/build-context.ts" }, "shimStrategy": { "bin/lib/platform.js": "simple", diff --git a/scripts/ts-migration/phases/07-policies.json b/scripts/ts-migration/phases/07-policies.json index ad81776e819..3b0cc6b579c 100644 --- a/scripts/ts-migration/phases/07-policies.json +++ b/scripts/ts-migration/phases/07-policies.json @@ -3,7 +3,7 @@ "test/policies.test.js" ], "moveRuntime": { - "bin/lib/policies.js": "src/lib/policies.ts" + "bin/lib/policies.js": "src/lib/policy/index.ts" }, "shimStrategy": { "bin/lib/policies.js": "simple" diff --git a/src/commands/README.md b/src/commands/README.md new file mode 100644 index 00000000000..8cfa8289616 --- /dev/null +++ b/src/commands/README.md @@ -0,0 +1,21 @@ + + + +# `src/commands` + +This tree is the oclif discovery surface for the packaged `nemoclaw` CLI. +Each file is intentionally thin: it exports a command class from `src/lib/commands/**` +and attaches NemoClaw's public display metadata. + +```text +src/commands/.ts + -> import command implementation from src/lib/commands/** + -> wrap with src/lib/cli/command-display.ts metadata +``` + +Keep behavior out of this tree. Product behavior belongs in `src/lib/actions/**`, pure +planning and classification belongs in `src/lib/domain/**`, and host/runtime boundaries +belong in `src/lib/adapters/**`. + +Hidden `nemoclaw internal ...` entrypoints live under `src/commands/internal/**`; see +`src/commands/internal/README.md` for their narrower compatibility contract. diff --git a/src/commands/sandbox/config/set.ts b/src/commands/sandbox/config/set.ts index c53fe4b1516..01ec6d03304 100644 --- a/src/commands/sandbox/config/set.ts +++ b/src/commands/sandbox/config/set.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/sandbox-config-set-cli-command"; +import Command from "../../../lib/commands/sandbox/config/set"; import { withCommandDisplay } from "../../../lib/cli/command-display"; export default withCommandDisplay(Command, [ diff --git a/src/lib/README.md b/src/lib/README.md index b7e9a4ece13..d08e8ce4b19 100644 --- a/src/lib/README.md +++ b/src/lib/README.md @@ -36,17 +36,18 @@ src/lib/dashboard/** dashboard contract, health, and recovery helpers src/lib/deploy/** deploy/build-image support that is not yet action-shaped src/lib/diagnostics/** debug collection and diagnostic report helpers src/lib/inference/** inference config, health probes, local runtime helpers +src/lib/inventory/** list/status inventory shaping and presentation models src/lib/messaging/** channel/messaging policy and message filtering helpers src/lib/onboard/** onboarding support modules around the large legacy flow -src/lib/policy/** policy preset loading and application support +src/lib/policy/** policy preset loading, tier selection, and application support src/lib/runtime/** sandbox/runtime recovery helpers -src/lib/sandbox/** sandbox config, build, stream, and version support +src/lib/sandbox/** sandbox config, build, stream, channel, and version support src/lib/security/** redaction, secret patterns, and credential filtering src/lib/shields/** shields orchestration, timers, and audit helpers src/lib/tunnel/** local service/tunnel command support ``` -Prefer small mechanical PRs that move one cluster at a time. High-import legacy files such as `onboard.ts`, `runner.ts`, `policies.ts`, `nim.ts`, and `services.ts` should either move late or keep temporary compatibility re-export files at their old paths. +Prefer small mechanical PRs that move one cluster at a time. High-import legacy files such as `onboard.ts`, `runner.ts`, and any remaining large top-level modules should either move late or keep temporary compatibility re-export files at their old paths. ## Suggested migration sequence diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index ea151444504..626acd1ec91 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -22,7 +22,7 @@ import { getActiveSandboxSessions, } from "../../state/sandbox-session"; import { checkAndRecoverSandboxProcesses } from "./process-recovery"; -import * as sandboxVersion from "../../sandbox-version"; +import * as sandboxVersion from "../../sandbox/version"; import { D, G, R, YW } from "../../cli/terminal-style"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 30d7afa56ca..8e50fe6bc9a 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -85,8 +85,8 @@ function cleanupSandboxServices( ): void { if (stopHostServices) { // `stopAll()` already runs `unloadOllamaModels()` unconditionally — - // see src/lib/services.ts. Don't double-call here. - const { stopAll } = require("../../services"); + // see src/lib/tunnel/services.ts. Don't double-call here. + const { stopAll } = require("../../tunnel/services"); stopAll({ sandboxName }); } else { // No global stop, so `stopAll()` did not run; explicitly free Ollama diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 1d15f583b15..6e0c90b8308 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -20,7 +20,7 @@ import type { SandboxEntry } from "../../state/registry"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { ROOT } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; -import * as sandboxVersion from "../../sandbox-version"; +import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import { buildStatusCommandDeps } from "../../status-command-deps"; import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d19b2b09331..c85bd645492 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -11,11 +11,11 @@ import { getCredential, prompt as askPrompt } from "../../credentials/store"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; const onboardProviders = require("../../onboard/providers"); -import * as policies from "../../policies"; +import * as policies from "../../policy"; import { parsePolicyAddArgs } from "../../domain/policy-channel"; import * as registry from "../../state/registry"; import { runOpenshell } from "../../adapters/openshell/runtime"; -import { rebuildSandbox } from "./runtime"; +import { rebuildSandbox } from "./rebuild"; import { KNOWN_CHANNELS, clearChannelTokens, @@ -23,7 +23,7 @@ import { getChannelTokenKeys, knownChannelNames, persistChannelTokens, -} from "../../sandbox-channels"; +} from "../../sandbox/channels"; const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 5c5ac25de3d..ccec4cffb48 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -24,7 +24,7 @@ import * as nim from "../../inference/nim"; import type { Session } from "../../state/onboard-session"; import * as onboardSession from "../../state/onboard-session"; import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime"; -import * as policies from "../../policies"; +import * as policies from "../../policy"; import * as registry from "../../state/registry"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { parseLiveSandboxNames } from "../../runtime-recovery"; @@ -35,7 +35,7 @@ import { getActiveSandboxSessions, } from "../../state/sandbox-session"; import * as sandboxState from "../../state/sandbox"; -import * as sandboxVersion from "../../sandbox-version"; +import * as sandboxVersion from "../../sandbox/version"; import { B, D, G, R, RD as _RD, YW } from "../../cli/terminal-style"; const agentRuntime = require("../../../../bin/lib/agent-runtime"); diff --git a/src/lib/actions/sandbox/runtime.ts b/src/lib/actions/sandbox/runtime.ts deleted file mode 100644 index 18502d718fd..00000000000 --- a/src/lib/actions/sandbox/runtime.ts +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - - -import type { DestroySandboxOptions, RebuildSandboxOptions } from "../../domain/lifecycle/options"; -import type { SandboxConnectOptions } from "./connect"; -import type { SandboxLogsOptions } from "../../domain/sandbox/log-options"; - -export async function connectSandbox( - sandboxName: string, - options?: SandboxConnectOptions, -): Promise { - const { connectSandbox: connectExtractedSandbox } = require("./connect") as { - connectSandbox: (sandboxName: string, options?: SandboxConnectOptions) => Promise; - }; - await connectExtractedSandbox(sandboxName, options); -} - -export async function showSandboxStatus(sandboxName: string): Promise { - const { showSandboxStatus: showExtractedSandboxStatus } = require("./status") as { - showSandboxStatus: (sandboxName: string) => Promise; - }; - await showExtractedSandboxStatus(sandboxName); -} - -export function showSandboxLogs(sandboxName: string, options: SandboxLogsOptions): void { - const { showSandboxLogs: showSandboxLogsAction } = require("./logs") as { - showSandboxLogs: (sandboxName: string, options: SandboxLogsOptions) => void; - }; - showSandboxLogsAction(sandboxName, options); -} - -export async function destroySandbox( - sandboxName: string, - options: string[] | DestroySandboxOptions = {}, -): Promise { - const { destroySandbox: destroyExtractedSandbox } = require("./destroy") as { - destroySandbox: ( - sandboxName: string, - options?: string[] | DestroySandboxOptions, - ) => Promise; - }; - await destroyExtractedSandbox(sandboxName, options); -} - -export async function rebuildSandbox( - sandboxName: string, - options: string[] | RebuildSandboxOptions = {}, -): Promise { - const { rebuildSandbox: rebuildExtractedSandbox } = require("./rebuild") as { - rebuildSandbox: ( - sandboxName: string, - options?: string[] | RebuildSandboxOptions, - ) => Promise; - }; - await rebuildExtractedSandbox(sandboxName, options); -} - -export async function installSandboxSkill( - sandboxName: string, - args: string[] = [], -): Promise { - const { installSandboxSkill: installExtractedSandboxSkill } = require("./skill-install") as { - installSandboxSkill: (sandboxName: string, args?: string[]) => Promise; - }; - await installExtractedSandboxSkill(sandboxName, args); -} - -export async function runSandboxSnapshot(sandboxName: string, args: string[]): Promise { - const { runSandboxSnapshot: runExtractedSandboxSnapshot } = require("./snapshot") as { - runSandboxSnapshot: (sandboxName: string, args: string[]) => Promise; - }; - await runExtractedSandboxSnapshot(sandboxName, args); -} diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 05bd897270c..730a229f142 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -10,7 +10,7 @@ import { dockerCapture, dockerInspect } from "../../adapters/docker"; import { parseLiveSandboxNames } from "../../runtime-recovery"; import { ROOT, run, shellQuote, validateName } from "../../runner"; import { captureOpenshell, getOpenshellBinary } from "../../adapters/openshell/runtime"; -import * as policies from "../../policies"; +import * as policies from "../../policy"; import * as registry from "../../state/registry"; import type { SandboxEntry } from "../../state/registry"; import * as sandboxState from "../../state/sandbox"; @@ -117,7 +117,7 @@ async function autoCreateSandboxFromSource( dstName: string, srcEntry: SandboxEntry | { name: string }, ): Promise { - const sandboxCreateStream = require("../../sandbox-create-stream"); + const sandboxCreateStream = require("../../sandbox/create-stream"); const { isSandboxReady } = require("../../state/gateway"); const basePolicy = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const openshellBin = getOpenshellBinary(); diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 153183d4660..f85580e1a11 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -27,7 +27,7 @@ import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, } from "../../state/sandbox-session"; -import * as sandboxVersion from "../../sandbox-version"; +import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import { D, G, R, RD, YW } from "../../cli/terminal-style"; diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index 9cbbfe2f0b2..183803aaeb5 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -12,7 +12,7 @@ import { captureOpenshell } from "../adapters/openshell/runtime"; import * as registry from "../state/registry"; import { parseLiveSandboxNames } from "../runtime-recovery"; import { rebuildSandbox } from "./sandbox/rebuild"; -import * as sandboxVersion from "../sandbox-version"; +import * as sandboxVersion from "../sandbox/version"; import { B, D, G, R, YW } from "../cli/terminal-style"; import { classifyUpgradeableSandboxes, diff --git a/src/lib/adapters/http/README.md b/src/lib/adapters/http/README.md new file mode 100644 index 00000000000..551d6643bff --- /dev/null +++ b/src/lib/adapters/http/README.md @@ -0,0 +1,8 @@ + + + +# HTTP adapters + +HTTP adapter modules isolate host-side network probes and subprocess-backed HTTP +checks from action/domain logic. Keep pure response classification in domain or +feature modules; keep `curl`, temporary files, and network-boundary behavior here. diff --git a/src/lib/http-probe.test.ts b/src/lib/adapters/http/probe.test.ts similarity index 99% rename from src/lib/http-probe.test.ts rename to src/lib/adapters/http/probe.test.ts index 91f0a125806..4852fc364ce 100644 --- a/src/lib/http-probe.test.ts +++ b/src/lib/adapters/http/probe.test.ts @@ -13,7 +13,7 @@ import { summarizeCurlFailure, summarizeProbeError, summarizeProbeFailure, -} from "./http-probe"; +} from "./probe"; describe("http-probe helpers", () => { it("returns explicit curl timeouts", () => { diff --git a/src/lib/http-probe.ts b/src/lib/adapters/http/probe.ts similarity index 98% rename from src/lib/http-probe.ts rename to src/lib/adapters/http/probe.ts index 156ea37e4a9..5c6046bc669 100644 --- a/src/lib/http-probe.ts +++ b/src/lib/adapters/http/probe.ts @@ -10,11 +10,11 @@ import { type SpawnSyncReturns, } from "node:child_process"; -import type { ProbeResult } from "./onboard/types"; -import { ROOT } from "./state/paths"; -import { compactText } from "./core/url-utils"; +import type { ProbeResult } from "../../onboard/types"; +import { ROOT } from "../../state/paths"; +import { compactText } from "../../core/url-utils"; -import { isErrnoException } from "./core/errno"; +import { isErrnoException } from "../../core/errno"; export type CurlProbeResult = ProbeResult; diff --git a/src/lib/commands/deprecated/start.ts b/src/lib/commands/deprecated/start.ts index b680d4d8809..f3bafd262ae 100644 --- a/src/lib/commands/deprecated/start.ts +++ b/src/lib/commands/deprecated/start.ts @@ -4,8 +4,8 @@ import { Command, Flags } from "@oclif/core"; import { CLI_NAME } from "../../cli/branding"; -import { startAll } from "../../services"; -import { runStartCommand } from "../../services-command"; +import { startAll } from "../../tunnel/services"; +import { runStartCommand } from "../../tunnel/service-command"; import { serviceDeps } from "../tunnel/common"; export default class DeprecatedStartCommand extends Command { diff --git a/src/lib/commands/deprecated/stop.ts b/src/lib/commands/deprecated/stop.ts index 95ac2ecf980..46fbf833dc4 100644 --- a/src/lib/commands/deprecated/stop.ts +++ b/src/lib/commands/deprecated/stop.ts @@ -4,8 +4,8 @@ import { Command, Flags } from "@oclif/core"; import { CLI_NAME } from "../../cli/branding"; -import { stopAll } from "../../services"; -import { runStopCommand } from "../../services-command"; +import { stopAll } from "../../tunnel/services"; +import { runStopCommand } from "../../tunnel/service-command"; import { serviceDeps } from "../tunnel/common"; export default class DeprecatedStopCommand extends Command { diff --git a/src/lib/commands/global-oclif-command-adapters.test.ts b/src/lib/commands/global-oclif-command-adapters.test.ts index ee9395883d7..6736f6203de 100644 --- a/src/lib/commands/global-oclif-command-adapters.test.ts +++ b/src/lib/commands/global-oclif-command-adapters.test.ts @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ showStatusCommand: vi.fn(), })); -vi.mock("../inventory-commands", () => ({ +vi.mock("../inventory", () => ({ getSandboxInventory: mocks.getSandboxInventory, getStatusReport: mocks.getStatusReport, renderSandboxInventoryText: mocks.renderSandboxInventoryText, diff --git a/src/lib/commands/list.ts b/src/lib/commands/list.ts index bd136beb1f0..52a68055934 100644 --- a/src/lib/commands/list.ts +++ b/src/lib/commands/list.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSandboxInventory, renderSandboxInventoryText } from "../inventory-commands"; +import { getSandboxInventory, renderSandboxInventoryText } from "../inventory"; import { NemoClawCommand } from "../cli/nemoclaw-oclif-command"; import { buildListCommandDeps } from "../list-command-deps"; diff --git a/src/lib/commands/sandbox/config/get.ts b/src/lib/commands/sandbox/config/get.ts index 2509d44bf31..400127dd163 100644 --- a/src/lib/commands/sandbox/config/get.ts +++ b/src/lib/commands/sandbox/config/get.ts @@ -4,7 +4,7 @@ import { Command, Flags } from "@oclif/core"; import { CLI_NAME } from "../../../cli/branding"; -import * as sandboxConfig from "../../../sandbox-config"; +import * as sandboxConfig from "../../../sandbox/config"; import { sandboxNameArg } from "../common"; export default class SandboxConfigGetCommand extends Command { diff --git a/src/lib/sandbox-config-set-cli-command.ts b/src/lib/commands/sandbox/config/set.ts similarity index 96% rename from src/lib/sandbox-config-set-cli-command.ts rename to src/lib/commands/sandbox/config/set.ts index 40ae493bdcf..90eaa12ee9f 100644 --- a/src/lib/sandbox-config-set-cli-command.ts +++ b/src/lib/commands/sandbox/config/set.ts @@ -4,7 +4,7 @@ import { Args, Command, Flags } from "@oclif/core"; -import * as sandboxConfig from "./sandbox-config"; +import * as sandboxConfig from "../../../sandbox/config"; const sandboxNameArg = Args.string({ name: "sandbox", diff --git a/src/lib/commands/sandbox/connect.ts b/src/lib/commands/sandbox/connect.ts index 13825f85f82..36a61c5ea62 100644 --- a/src/lib/commands/sandbox/connect.ts +++ b/src/lib/commands/sandbox/connect.ts @@ -4,7 +4,7 @@ import { Args, Command, Flags } from "@oclif/core"; import { CLI_NAME } from "../../cli/branding"; -import { connectSandbox } from "../../actions/sandbox/runtime"; +import { connectSandbox } from "../../actions/sandbox/connect"; export default class ConnectCliCommand extends Command { static id = "sandbox:connect"; diff --git a/src/lib/commands/sandbox/destroy.ts b/src/lib/commands/sandbox/destroy.ts index 5741c851193..9a710a6dcb2 100644 --- a/src/lib/commands/sandbox/destroy.ts +++ b/src/lib/commands/sandbox/destroy.ts @@ -4,7 +4,7 @@ import { Args, Flags } from "@oclif/core"; import { NemoClawCommand } from "../../cli/nemoclaw-oclif-command"; -import { destroySandbox } from "../../actions/sandbox/runtime"; +import { destroySandbox } from "../../actions/sandbox/destroy"; export default class DestroyCliCommand extends NemoClawCommand { static id = "sandbox:destroy"; diff --git a/src/lib/commands/sandbox/logs.ts b/src/lib/commands/sandbox/logs.ts index 97b45a5a955..09a8f818e8c 100644 --- a/src/lib/commands/sandbox/logs.ts +++ b/src/lib/commands/sandbox/logs.ts @@ -6,15 +6,20 @@ import { Args, Command, Flags } from "@oclif/core"; import { logsSinceDurationFlag } from "../../cli/duration-flags"; import type { SandboxLogsOptions } from "../../domain/sandbox/log-options"; import { DEFAULT_SANDBOX_LOG_LINES } from "../../domain/sandbox/log-options"; -import { showSandboxLogs } from "../../actions/sandbox/runtime"; - type SandboxLogsRuntimeBridge = { sandboxLogs: (sandboxName: string, options: SandboxLogsOptions) => void; }; const DEFAULT_SANDBOX_LOG_LINE_COUNT = Number(DEFAULT_SANDBOX_LOG_LINES); -let runtimeBridgeFactory = (): SandboxLogsRuntimeBridge => ({ sandboxLogs: showSandboxLogs }); +let runtimeBridgeFactory = (): SandboxLogsRuntimeBridge => ({ + sandboxLogs: (sandboxName, options) => { + const { showSandboxLogs } = require("../../actions/sandbox/logs") as { + showSandboxLogs: (sandboxName: string, options: SandboxLogsOptions) => void; + }; + showSandboxLogs(sandboxName, options); + }, +}); export function setSandboxLogsRuntimeBridgeFactoryForTest( factory: () => SandboxLogsRuntimeBridge, diff --git a/src/lib/commands/sandbox/oclif-command-adapters.test.ts b/src/lib/commands/sandbox/oclif-command-adapters.test.ts index 7f76a1f561a..2a70c881eb2 100644 --- a/src/lib/commands/sandbox/oclif-command-adapters.test.ts +++ b/src/lib/commands/sandbox/oclif-command-adapters.test.ts @@ -17,10 +17,19 @@ const mocks = vi.hoisted(() => ({ showSandboxStatus: vi.fn().mockResolvedValue(undefined), })); -vi.mock("../../actions/sandbox/runtime", () => ({ +vi.mock("../../actions/sandbox/connect", () => ({ connectSandbox: mocks.connectSandbox, +})); + +vi.mock("../../actions/sandbox/destroy", () => ({ destroySandbox: mocks.destroySandbox, +})); + +vi.mock("../../actions/sandbox/rebuild", () => ({ rebuildSandbox: mocks.rebuildSandbox, +})); + +vi.mock("../../actions/sandbox/status", () => ({ showSandboxStatus: mocks.showSandboxStatus, })); @@ -29,7 +38,7 @@ vi.mock("../../actions/sandbox/policy-channel", () => ({ listSandboxPolicies: mocks.listSandboxPolicies, })); -vi.mock("../../sandbox-config", () => ({ +vi.mock("../../sandbox/config", () => ({ configGet: mocks.configGet, })); diff --git a/src/lib/commands/sandbox/rebuild.ts b/src/lib/commands/sandbox/rebuild.ts index 74ba5cad2ac..9a65f38811a 100644 --- a/src/lib/commands/sandbox/rebuild.ts +++ b/src/lib/commands/sandbox/rebuild.ts @@ -4,7 +4,7 @@ import { Args, Flags } from "@oclif/core"; import { NemoClawCommand } from "../../cli/nemoclaw-oclif-command"; -import { rebuildSandbox } from "../../actions/sandbox/runtime"; +import { rebuildSandbox } from "../../actions/sandbox/rebuild"; export default class RebuildCliCommand extends NemoClawCommand { static id = "sandbox:rebuild"; diff --git a/src/lib/commands/sandbox/skill/common.ts b/src/lib/commands/sandbox/skill/common.ts index d4a2e70a946..9c76c4e9a3e 100644 --- a/src/lib/commands/sandbox/skill/common.ts +++ b/src/lib/commands/sandbox/skill/common.ts @@ -1,9 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { installSandboxSkill } from "../../../actions/sandbox/runtime"; - -let runtimeBridgeFactory = () => ({ sandboxSkillInstall: installSandboxSkill }); +let runtimeBridgeFactory = () => ({ + sandboxSkillInstall: async (sandboxName: string, args?: string[]) => { + const { installSandboxSkill } = require("../../../actions/sandbox/skill-install") as { + installSandboxSkill: (sandboxName: string, args?: string[]) => Promise; + }; + await installSandboxSkill(sandboxName, args); + }, +}); export function setSkillInstallRuntimeBridgeFactoryForTest( factory: () => { sandboxSkillInstall: (sandboxName: string, args?: string[]) => Promise }, diff --git a/src/lib/commands/sandbox/snapshot/common.ts b/src/lib/commands/sandbox/snapshot/common.ts index b46c42e608b..8de5869bd9a 100644 --- a/src/lib/commands/sandbox/snapshot/common.ts +++ b/src/lib/commands/sandbox/snapshot/common.ts @@ -3,9 +3,14 @@ import { Args } from "@oclif/core"; -import { runSandboxSnapshot } from "../../../actions/sandbox/runtime"; - -let runtimeBridgeFactory = () => ({ sandboxSnapshot: runSandboxSnapshot }); +let runtimeBridgeFactory = () => ({ + sandboxSnapshot: async (sandboxName: string, args: string[]) => { + const { runSandboxSnapshot } = require("../../../actions/sandbox/snapshot") as { + runSandboxSnapshot: (sandboxName: string, args: string[]) => Promise; + }; + await runSandboxSnapshot(sandboxName, args); + }, +}); export function setSnapshotRuntimeBridgeFactoryForTest( factory: () => { sandboxSnapshot: (sandboxName: string, args: string[]) => Promise }, diff --git a/src/lib/commands/sandbox/status.ts b/src/lib/commands/sandbox/status.ts index 18f3d174cd8..78a9284058d 100644 --- a/src/lib/commands/sandbox/status.ts +++ b/src/lib/commands/sandbox/status.ts @@ -3,7 +3,7 @@ import { Command, Flags } from "@oclif/core"; -import { showSandboxStatus } from "../../actions/sandbox/runtime"; +import { showSandboxStatus } from "../../actions/sandbox/status"; import { sandboxNameArg } from "./common"; export default class SandboxStatusCommand extends Command { diff --git a/src/lib/commands/simple-global-oclif-adapters.test.ts b/src/lib/commands/simple-global-oclif-adapters.test.ts index 578e03e785b..8d2c2f78360 100644 --- a/src/lib/commands/simple-global-oclif-adapters.test.ts +++ b/src/lib/commands/simple-global-oclif-adapters.test.ts @@ -39,8 +39,8 @@ vi.mock("../actions/global", () => ({ vi.mock("../adapters/openshell/client", () => ({ captureOpenshellCommand: mocks.captureOpenshellCommand })); vi.mock("../state/registry", () => ({ listSandboxes: mocks.listSandboxes })); vi.mock("../adapters/openshell/resolve", () => ({ resolveOpenshell: mocks.resolveOpenshell })); -vi.mock("../services", () => ({ startAll: mocks.startAll, stopAll: mocks.stopAll })); -vi.mock("../services-command", () => ({ +vi.mock("../tunnel/services", () => ({ startAll: mocks.startAll, stopAll: mocks.stopAll })); +vi.mock("../tunnel/service-command", () => ({ runStartCommand: mocks.runStartCommand, runStopCommand: mocks.runStopCommand, })); diff --git a/src/lib/commands/status.ts b/src/lib/commands/status.ts index 5ce043f27f6..4c962da07ef 100644 --- a/src/lib/commands/status.ts +++ b/src/lib/commands/status.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getStatusReport, showStatusCommand } from "../inventory-commands"; +import { getStatusReport, showStatusCommand } from "../inventory"; import { NemoClawCommand } from "../cli/nemoclaw-oclif-command"; import { buildStatusCommandDeps } from "../status-command-deps"; diff --git a/src/lib/commands/tunnel/start.ts b/src/lib/commands/tunnel/start.ts index 81ffc6c13cc..e3288dc25c1 100644 --- a/src/lib/commands/tunnel/start.ts +++ b/src/lib/commands/tunnel/start.ts @@ -3,8 +3,8 @@ import { Command, Flags } from "@oclif/core"; -import { startAll } from "../../services"; -import { runStartCommand } from "../../services-command"; +import { startAll } from "../../tunnel/services"; +import { runStartCommand } from "../../tunnel/service-command"; import { serviceDeps } from "./common"; export default class TunnelStartCommand extends Command { diff --git a/src/lib/commands/tunnel/stop.ts b/src/lib/commands/tunnel/stop.ts index 6b30dafb266..0d25b7f1523 100644 --- a/src/lib/commands/tunnel/stop.ts +++ b/src/lib/commands/tunnel/stop.ts @@ -3,8 +3,8 @@ import { Command, Flags } from "@oclif/core"; -import { stopAll } from "../../services"; -import { runStopCommand } from "../../services-command"; +import { stopAll } from "../../tunnel/services"; +import { runStopCommand } from "../../tunnel/service-command"; import { serviceDeps } from "./common"; export default class TunnelStopCommand extends Command { diff --git a/src/lib/coverage-hotspots.test.ts b/src/lib/coverage-hotspots.test.ts index f6241bf003d..9264c820558 100644 --- a/src/lib/coverage-hotspots.test.ts +++ b/src/lib/coverage-hotspots.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest"; import { parseDuration } from "./domain/duration"; import { parseGatewayTokenArgs, runGatewayTokenCommand } from "./gateway-token-command"; -import { resolveDefaultSandboxName, runStartCommand, runStopCommand } from "./services-command"; +import { resolveDefaultSandboxName, runStartCommand, runStopCommand } from "./tunnel/service-command"; import { getVersion } from "./core/version"; // Narrow coverage guard for small helper modules that are otherwise only diff --git a/src/lib/deploy/README.md b/src/lib/deploy/README.md new file mode 100644 index 00000000000..147658fd9f7 --- /dev/null +++ b/src/lib/deploy/README.md @@ -0,0 +1,8 @@ + + + +# Deploy + +Deploy modules support remote/Brev compatibility flows and build-image setup that +has not yet been split into action/domain/adapter layers. Prefer new orchestration +in `src/lib/actions/**` and pure deploy planning helpers in `src/lib/domain/**`. diff --git a/src/lib/deploy.test.ts b/src/lib/deploy/index.test.ts similarity index 99% rename from src/lib/deploy.test.ts rename to src/lib/deploy/index.test.ts index c21d97ce75f..c34045f0000 100644 --- a/src/lib/deploy.test.ts +++ b/src/lib/deploy/index.test.ts @@ -9,8 +9,8 @@ import { inferDeployProvider, isBrevInstanceFailed, isBrevInstanceReady, -} from "../../dist/lib/deploy"; -import { validateName } from "../../dist/lib/runner"; +} from "../../../dist/lib/deploy"; +import { validateName } from "../../../dist/lib/runner"; describe("inferDeployProvider", () => { it("prefers an explicit provider override", () => { diff --git a/src/lib/deploy.ts b/src/lib/deploy/index.ts similarity index 99% rename from src/lib/deploy.ts rename to src/lib/deploy/index.ts index aa3c6459289..48ba9bb3d1e 100644 --- a/src/lib/deploy.ts +++ b/src/lib/deploy/index.ts @@ -5,8 +5,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { NAME_ALLOWED_FORMAT, getNameValidationGuidance } from "./name-validation"; -import { sleepSeconds } from "./core/wait"; +import { NAME_ALLOWED_FORMAT, getNameValidationGuidance } from "../name-validation"; +import { sleepSeconds } from "../core/wait"; type ExecLikeValue = | string diff --git a/src/lib/inference/health.ts b/src/lib/inference/health.ts index 781f132eb54..dd2ce8242e9 100644 --- a/src/lib/inference/health.ts +++ b/src/lib/inference/health.ts @@ -7,8 +7,8 @@ * and performs lightweight reachability checks for remote cloud providers. */ -import type { CurlProbeResult } from "../http-probe"; -import { runCurlProbe } from "../http-probe"; +import type { CurlProbeResult } from "../adapters/http/probe"; +import { runCurlProbe } from "../adapters/http/probe"; import { getProviderSelectionConfig } from "./config"; import type { LocalProviderHealthProbeOptions } from "./local"; import { probeLocalProviderHealth } from "./local"; diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index cadfa8b98ab..015df92dc8d 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -6,8 +6,8 @@ * health checks, and command generators for vLLM and Ollama. */ -import type { CurlProbeResult } from "../http-probe"; -import { runCurlProbe } from "../http-probe"; +import type { CurlProbeResult } from "../adapters/http/probe"; +import { runCurlProbe } from "../adapters/http/probe"; const { shellQuote, runCapture } = require("../runner"); diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index 98f5470f362..8406c7abe68 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -7,7 +7,7 @@ const { normalizeCredentialValue } = require("../credentials/store"); const { isWsl } = require("../platform"); -const httpProbe = require("../http-probe"); +const httpProbe = require("../adapters/http/probe"); const { isNvcfFunctionNotFoundForAccount, nvcfFunctionNotFoundMessage, diff --git a/src/lib/inference/provider-models.ts b/src/lib/inference/provider-models.ts index 2cf62065681..2d219599fa6 100644 --- a/src/lib/inference/provider-models.ts +++ b/src/lib/inference/provider-models.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { CurlProbeResult } from "../http-probe"; -import { getCurlTimingArgs, runCurlProbe } from "../http-probe"; +import type { CurlProbeResult } from "../adapters/http/probe"; +import { getCurlTimingArgs, runCurlProbe } from "../adapters/http/probe"; import type { ModelCatalogFetchResult, ModelValidationResult } from "../onboard/types"; // credentials.ts still uses CommonJS-style exports. diff --git a/src/lib/inventory/README.md b/src/lib/inventory/README.md new file mode 100644 index 00000000000..fda5d3a657b --- /dev/null +++ b/src/lib/inventory/README.md @@ -0,0 +1,9 @@ + + + +# Inventory + +Inventory modules shape sandbox registry, live inference, service, and messaging +health data into the rows printed by `nemoclaw list` and `nemoclaw status`. +Command parser glue should stay in `src/lib/commands/**`; registry I/O should stay +in `src/lib/state/**`. diff --git a/src/lib/inventory-commands.test.ts b/src/lib/inventory/index.test.ts similarity index 99% rename from src/lib/inventory-commands.test.ts rename to src/lib/inventory/index.test.ts index 71683eb86ef..7a6f59cff6d 100644 --- a/src/lib/inventory-commands.test.ts +++ b/src/lib/inventory/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { getSandboxInventory, listSandboxesCommand, showStatusCommand } from "./inventory-commands"; +import { getSandboxInventory, listSandboxesCommand, showStatusCommand } from "./index"; describe("inventory commands", () => { it("returns structured empty inventory for JSON consumers", async () => { diff --git a/src/lib/inventory-commands.ts b/src/lib/inventory/index.ts similarity index 98% rename from src/lib/inventory-commands.ts rename to src/lib/inventory/index.ts index 77eac8f7b31..5096421b8da 100644 --- a/src/lib/inventory-commands.ts +++ b/src/lib/inventory/index.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { CLI_NAME } from "./cli/branding"; -import type { GatewayInference } from "./inference/config"; -import { redactFull } from "./security/redact"; +import { CLI_NAME } from "../cli/branding"; +import type { GatewayInference } from "../inference/config"; +import { redactFull } from "../security/redact"; export interface SandboxEntry { name: string; diff --git a/src/lib/list-command-deps.ts b/src/lib/list-command-deps.ts index 14882f5a559..cac45f04709 100644 --- a/src/lib/list-command-deps.ts +++ b/src/lib/list-command-deps.ts @@ -3,7 +3,7 @@ import * as onboardSession from "./state/onboard-session"; -import type { ListSandboxesCommandDeps } from "./inventory-commands"; +import type { ListSandboxesCommandDeps } from "./inventory"; import { parseGatewayInference } from "./inference/config"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import { parseSshProcesses, createSystemDeps } from "./state/sandbox-session"; diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index a39811e4312..814810a42f9 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { listChannels } from "./sandbox-channels"; +import { listChannels } from "./sandbox/channels"; export type MessagingChannelConfig = Record; diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index 8486a8e3697..70ea33d2fea 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -14,7 +14,7 @@ // the live OpenShell gateway for known provider names. import type { SandboxEntry } from "./state/registry"; -import { getChannelDef, getChannelTokenKeys } from "./sandbox-channels"; +import { getChannelDef, getChannelTokenKeys } from "./sandbox/channels"; type ProbeResult = "present" | "absent" | "error"; type ConflictReason = "matching-token" | "unknown-token"; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c36d8767654..2136a206c6e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -5,7 +5,17 @@ // Supports non-interactive mode via --non-interactive flag or // NEMOCLAW_NON_INTERACTIVE=1 env var for CI/CD pipelines. -const { getAgentBranding } = require("./cli/branding"); +const { + envInt, + LOCAL_INFERENCE_TIMEOUT_SECS, + SANDBOX_READY_TIMEOUT_SECS, +}: typeof import("./onboard/env") = require("./onboard/env"); +const { + agentProductName, + cliDisplayName, + cliName, + setOnboardBrandingAgent, +}: typeof import("./onboard/branding") = require("./onboard/branding"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -13,40 +23,6 @@ const path = require("path"); const { spawn, spawnSync } = require("child_process"); const pRetry = require("p-retry"); -/** Parse a numeric env var, returning `fallback` when unset or non-finite. */ -function envInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (raw === undefined || raw === "") return fallback; - const n = Number(raw); - return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback; -} -/** Inference timeout (seconds) for local providers (Ollama, vLLM, NIM). */ -const LOCAL_INFERENCE_TIMEOUT_SECS = envInt("NEMOCLAW_LOCAL_INFERENCE_TIMEOUT", 180); -/** Sandbox Ready wait after OpenShell create returns but k3s is still converging. */ -const SANDBOX_READY_TIMEOUT_SECS = envInt("NEMOCLAW_SANDBOX_READY_TIMEOUT", 180); - -let onboardBrandingAgent: string | null = null; - -function setOnboardBrandingAgent(agentName: string | null | undefined): void { - onboardBrandingAgent = agentName || null; -} - -function onboardBranding(): import("./cli/branding").AgentBranding { - return getAgentBranding(onboardBrandingAgent || process.env.NEMOCLAW_AGENT || null); -} - -function cliName(): string { - return onboardBranding().cli; -} - -function cliDisplayName(): string { - return onboardBranding().display; -} - -function agentProductName(): string { - return onboardBranding().product; -} - /** Strip ANSI escape sequences before printing process output to the terminal. * Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; @@ -95,7 +71,7 @@ function requireValue(value: T | null | undefined, message: string): T { const { collectBuildContextStats, stageOptimizedSandboxBuildContext, -} = require("./sandbox-build-context"); +} = require("./sandbox/build-context"); const { buildSubprocessEnv } = require("./subprocess-env"); const { DASHBOARD_PORT, @@ -278,9 +254,9 @@ const { const registry: typeof import("./state/registry") = require("./state/registry"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); -const policies: typeof import("./policies") = require("./policies"); +const policies: typeof import("./policy") = require("./policy"); const shields = require("./shields"); -const tiers: typeof import("./tiers") = require("./tiers"); +const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); const preflightUtils: typeof import("./onboard/preflight") = require("./onboard/preflight"); const clusterImagePatch: typeof import("./cluster-image-patch") = require("./cluster-image-patch"); @@ -302,15 +278,15 @@ const validation: typeof import("./validation") = require("./validation"); const urlUtils: typeof import("./core/url-utils") = require("./core/url-utils"); const buildContext = require("./build-context"); const dashboardContract: typeof import("./dashboard/contract") = require("./dashboard/contract"); -const httpProbe: typeof import("./http-probe") = require("./http-probe"); +const httpProbe: typeof import("./adapters/http/probe") = require("./adapters/http/probe"); const modelPrompts: typeof import("./inference/model-prompts") = require("./inference/model-prompts"); const providerModels: typeof import("./inference/provider-models") = require("./inference/provider-models"); -const sandboxCreateStream: typeof import("./sandbox-create-stream") = require("./sandbox-create-stream"); +const sandboxCreateStream: typeof import("./sandbox/create-stream") = require("./sandbox/create-stream"); const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); const webSearch: typeof import("./inference/web-search") = require("./inference/web-search"); import type { AgentDefinition } from "./agent/defs"; -import type { CurlProbeResult } from "./http-probe"; +import type { CurlProbeResult } from "./adapters/http/probe"; import type { GatewayInference, ProviderSelectionConfig } from "./inference/config"; import type { GpuInfo, ValidationResult } from "./inference/local"; import { @@ -329,11 +305,11 @@ import type { ProbeResult, ValidationFailureLike, } from "./onboard/types"; -import { listChannels } from "./sandbox-channels"; -import type { StreamSandboxCreateResult } from "./sandbox-create-stream"; +import { listChannels } from "./sandbox/channels"; +import type { StreamSandboxCreateResult } from "./sandbox/create-stream"; import type { SandboxEntry } from "./state/registry"; import type { BackupResult } from "./state/sandbox"; -import type { TierDefinition, TierPreset } from "./tiers"; +import type { TierDefinition, TierPreset } from "./policy/tiers"; import type { SandboxCreateFailure, ValidationClassification } from "./validation"; import type { ProbeRecovery } from "./validation-recovery"; import type { WebSearchConfig } from "./inference/web-search"; diff --git a/src/lib/onboard/branding.ts b/src/lib/onboard/branding.ts new file mode 100644 index 00000000000..1f191bf69e0 --- /dev/null +++ b/src/lib/onboard/branding.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAgentBranding, type AgentBranding } from "../cli/branding"; + +let onboardBrandingAgent: string | null = null; + +export function setOnboardBrandingAgent(agentName: string | null | undefined): void { + onboardBrandingAgent = agentName || null; +} + +export function onboardBranding(): AgentBranding { + return getAgentBranding(onboardBrandingAgent || process.env.NEMOCLAW_AGENT || null); +} + +export function cliName(): string { + return onboardBranding().cli; +} + +export function cliDisplayName(): string { + return onboardBranding().display; +} + +export function agentProductName(): string { + return onboardBranding().product; +} diff --git a/src/lib/onboard/env.ts b/src/lib/onboard/env.ts new file mode 100644 index 00000000000..d59bbb3fda3 --- /dev/null +++ b/src/lib/onboard/env.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Parse a numeric env var, returning `fallback` when unset or non-finite. */ +export function envInt( + name: string, + fallback: number, + env: Record = process.env, +): number { + const raw = env[name]; + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback; +} + +/** Inference timeout (seconds) for local providers (Ollama, vLLM, NIM). */ +export const LOCAL_INFERENCE_TIMEOUT_SECS = envInt("NEMOCLAW_LOCAL_INFERENCE_TIMEOUT", 180); + +/** Sandbox Ready wait after OpenShell create returns but k3s is still converging. */ +export const SANDBOX_READY_TIMEOUT_SECS = envInt("NEMOCLAW_SANDBOX_READY_TIMEOUT", 180); diff --git a/src/lib/policy/README.md b/src/lib/policy/README.md new file mode 100644 index 00000000000..ab44d0ef086 --- /dev/null +++ b/src/lib/policy/README.md @@ -0,0 +1,9 @@ + + + +# Policy + +Policy modules own sandbox network-policy preset loading, tier resolution, and +policy application helpers. They may orchestrate OpenShell policy commands while +legacy flows are being migrated, but pure selection/planning helpers should move +under `src/lib/domain/**` when they can be isolated. diff --git a/src/lib/policies.ts b/src/lib/policy/index.ts similarity index 99% rename from src/lib/policies.ts rename to src/lib/policy/index.ts index c2dad0850ea..1eeb76bbc17 100644 --- a/src/lib/policies.ts +++ b/src/lib/policy/index.ts @@ -3,16 +3,16 @@ // // Policy preset management — list, load, merge, and apply presets. -import type { JsonValue, JsonObject } from "./core/json-types"; +import type { JsonValue, JsonObject } from "../core/json-types"; const fs = require("fs"); const path = require("path"); const os = require("os"); const readline = require("readline"); const YAML = require("yaml"); -const { ROOT, run, runCapture } = require("./runner"); -const registry = require("./state/registry"); -const { loadAgent } = require("./agent/defs"); +const { ROOT, run, runCapture } = require("../runner"); +const registry = require("../state/registry"); +const { loadAgent } = require("../agent/defs"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); diff --git a/src/lib/tiers.ts b/src/lib/policy/tiers.ts similarity index 99% rename from src/lib/tiers.ts rename to src/lib/policy/tiers.ts index 5e0fefc0cdc..be4b77bb52e 100644 --- a/src/lib/tiers.ts +++ b/src/lib/policy/tiers.ts @@ -14,7 +14,7 @@ import fs from "node:fs"; import path from "node:path"; import YAML from "yaml"; -import { ROOT } from "./runner"; +import { ROOT } from "../runner"; const TIERS_FILE = path.join(ROOT, "nemoclaw-blueprint", "policies", "tiers.yaml"); const ALLOWED_ACCESS: ReadonlySet = new Set(["read", "read-write"]); diff --git a/src/lib/recover-cli-command.ts b/src/lib/recover-cli-command.ts index fd5eb77473d..576b5a89c89 100644 --- a/src/lib/recover-cli-command.ts +++ b/src/lib/recover-cli-command.ts @@ -4,7 +4,7 @@ import { Args, Command, Flags } from "@oclif/core"; -import { connectSandbox } from "./actions/sandbox/runtime"; +import { connectSandbox } from "./actions/sandbox/connect"; export default class RecoverCliCommand extends Command { static id = "sandbox:recover"; diff --git a/src/lib/registry-recovery-action.ts b/src/lib/registry-recovery-action.ts index 3df6c244b67..6783ded4bc8 100644 --- a/src/lib/registry-recovery-action.ts +++ b/src/lib/registry-recovery-action.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { recoverNamedGatewayRuntime } from "./gateway-runtime-action"; -import type { RecoveryResult } from "./inventory-commands"; +import type { RecoveryResult } from "./inventory"; import * as onboardSession from "./state/onboard-session"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import { captureOpenshell } from "./adapters/openshell/runtime"; diff --git a/src/lib/sandbox-build-context.ts b/src/lib/sandbox/build-context.ts similarity index 100% rename from src/lib/sandbox-build-context.ts rename to src/lib/sandbox/build-context.ts diff --git a/src/lib/sandbox-channels.test.ts b/src/lib/sandbox/channels.test.ts similarity index 98% rename from src/lib/sandbox-channels.test.ts rename to src/lib/sandbox/channels.test.ts index 9b62c31ef73..a3049fba83d 100644 --- a/src/lib/sandbox-channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -9,7 +9,7 @@ import { getChannelTokenKeys, knownChannelNames, listChannels, -} from "../../dist/lib/sandbox-channels"; +} from "./channels"; describe("sandbox-channels KNOWN_CHANNELS", () => { it("covers telegram, discord, and slack", () => { diff --git a/src/lib/sandbox-channels.ts b/src/lib/sandbox/channels.ts similarity index 98% rename from src/lib/sandbox-channels.ts rename to src/lib/sandbox/channels.ts index 15f65a4e207..bf54ed96625 100644 --- a/src/lib/sandbox-channels.ts +++ b/src/lib/sandbox/channels.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { deleteCredential, saveCredential } from "./credentials/store"; +import { deleteCredential, saveCredential } from "../credentials/store"; export interface ChannelDef { envKey: string; diff --git a/src/lib/sandbox-config.ts b/src/lib/sandbox/config.ts similarity index 97% rename from src/lib/sandbox-config.ts rename to src/lib/sandbox/config.ts index eeb3c1cfbca..36098a63245 100644 --- a/src/lib/sandbox-config.ts +++ b/src/lib/sandbox/config.ts @@ -18,16 +18,16 @@ const os = require("os"); const path = require("path"); const { promises: dnsPromises } = require("node:dns"); const { isIP } = require("node:net"); -const { validateName } = require("./runner"); -const { dockerExecFileSync } = require("./adapters/docker/exec"); -const credentialFilter: typeof import("./security/credential-filter") = require("./security/credential-filter"); +const { validateName } = require("../runner"); +const { dockerExecFileSync } = require("../adapters/docker/exec"); +const credentialFilter: typeof import("../security/credential-filter") = require("../security/credential-filter"); const { stripCredentials, isConfigObject, isConfigValue, isCredentialField } = credentialFilter; -const { appendAuditEntry } = require("./shields/audit"); -const { isPrivateHostname, isPrivateIp } = require("./private-networks"); +const { appendAuditEntry } = require("../shields/audit"); +const { isPrivateHostname, isPrivateIp } = require("../private-networks"); -type ConfigObject = import("./security/credential-filter").ConfigObject; -type ConfigValue = import("./security/credential-filter").ConfigValue; -const { runOpenshellCommand, captureOpenshellCommand } = require("./adapters/openshell/client"); +type ConfigObject = import("../security/credential-filter").ConfigObject; +type ConfigValue = import("../security/credential-filter").ConfigValue; +const { runOpenshellCommand, captureOpenshellCommand } = require("../adapters/openshell/client"); function parseJson(text: string): T { return JSON.parse(text); @@ -94,11 +94,11 @@ const DEFAULT_AGENT_CONFIG: AgentConfigTarget = { function resolveAgentConfig(sandboxName: string): AgentConfigTarget { try { - const registry = require("./state/registry"); + const registry = require("../state/registry"); const entry = registry.getSandbox(sandboxName); if (!entry || !entry.agent) return DEFAULT_AGENT_CONFIG; - const agentDefs = require("./agent/defs"); + const agentDefs = require("../agent/defs"); const agent = agentDefs.loadAgent(entry.agent); const cfg = agent.configPaths; @@ -829,7 +829,7 @@ async function configRotateToken(sandboxName: string, opts: RotateTokenOpts = {} // 1. Determine which provider and credentialEnv the sandbox uses. // Load the onboard session and verify it matches this sandbox. - const { loadSession } = require("./state/onboard-session"); + const { loadSession } = require("../state/onboard-session"); const session = loadSession(); if (!session || !session.credentialEnv) { @@ -866,7 +866,7 @@ async function configRotateToken(sandboxName: string, opts: RotateTokenOpts = {} } else if (opts.fromStdin) { newToken = await readStdin(); } else { - const { promptSecret } = require("./credentials/store"); + const { promptSecret } = require("../credentials/store"); newToken = await promptSecret(` New ${credentialEnv} value: `); } @@ -886,7 +886,7 @@ async function configRotateToken(sandboxName: string, opts: RotateTokenOpts = {} // 4. Stage the new value in the current process so the openshell update // that follows can read it via --credential . The OpenShell // gateway becomes the system of record once the update succeeds. - const { saveCredential } = require("./credentials/store"); + const { saveCredential } = require("../credentials/store"); saveCredential(credentialEnv, newToken); // 5. Update the openshell provider diff --git a/src/lib/sandbox-create-stream.test.ts b/src/lib/sandbox/create-stream.test.ts similarity index 99% rename from src/lib/sandbox-create-stream.test.ts rename to src/lib/sandbox/create-stream.test.ts index 9547610d19f..9902f949c0f 100644 --- a/src/lib/sandbox-create-stream.test.ts +++ b/src/lib/sandbox/create-stream.test.ts @@ -9,7 +9,7 @@ import { type StreamableChildProcess, type StreamableReadable, streamSandboxCreate, -} from "./sandbox-create-stream"; +} from "./create-stream"; class FakeReadable extends EventEmitter implements StreamableReadable { destroy(): void {} diff --git a/src/lib/sandbox-create-stream.ts b/src/lib/sandbox/create-stream.ts similarity index 99% rename from src/lib/sandbox-create-stream.ts rename to src/lib/sandbox/create-stream.ts index 02c684f3dff..d357430847d 100644 --- a/src/lib/sandbox-create-stream.ts +++ b/src/lib/sandbox/create-stream.ts @@ -3,7 +3,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { ROOT } from "./state/paths"; +import { ROOT } from "../state/paths"; export interface StreamSandboxCreateResult { status: number; diff --git a/src/lib/sandbox-version.test.ts b/src/lib/sandbox/version.test.ts similarity index 94% rename from src/lib/sandbox-version.test.ts rename to src/lib/sandbox/version.test.ts index b181c7fdcc8..8597ae35d8c 100644 --- a/src/lib/sandbox-version.test.ts +++ b/src/lib/sandbox/version.test.ts @@ -7,11 +7,11 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; // Mock heavy dependencies that pull in the full module graph -vi.mock("./adapters/openshell/resolve.js", () => ({ +vi.mock("../adapters/openshell/resolve.js", () => ({ resolveOpenshell: vi.fn(() => "/usr/local/bin/openshell"), })); -vi.mock("./adapters/openshell/client.js", () => ({ +vi.mock("../adapters/openshell/client.js", () => ({ parseVersionFromText: (value = "") => { const match = String(value).match(/([0-9]+\.[0-9]+\.[0-9]+)/); return match ? match[1] : null; @@ -31,7 +31,7 @@ vi.mock("./adapters/openshell/client.js", () => ({ captureOpenshellCommand: vi.fn(), })); -vi.mock("./agent/defs.js", () => ({ +vi.mock("../agent/defs.js", () => ({ loadAgent: vi.fn((name: string) => ({ name, displayName: name === "openclaw" ? "OpenClaw" : "Hermes Agent", @@ -47,10 +47,10 @@ vi.mock("child_process", async (importOriginal) => { return { ...actual, spawnSync: vi.fn() }; }); -import { checkAgentVersion, formatStalenessWarning } from "./sandbox-version.js"; -import * as registry from "./state/registry.js"; -import { captureOpenshellCommand } from "./adapters/openshell/client.js"; -import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts.js"; +import { checkAgentVersion, formatStalenessWarning } from "./version.js"; +import * as registry from "../state/registry.js"; +import { captureOpenshellCommand } from "../adapters/openshell/client.js"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import { spawnSync } from "child_process"; describe("checkAgentVersion", () => { diff --git a/src/lib/sandbox-version.ts b/src/lib/sandbox/version.ts similarity index 91% rename from src/lib/sandbox-version.ts rename to src/lib/sandbox/version.ts index 958adc10057..b9b19596906 100644 --- a/src/lib/sandbox-version.ts +++ b/src/lib/sandbox/version.ts @@ -13,12 +13,12 @@ import fs from "fs"; import os from "os"; import path from "path"; -import { parseVersionFromText, versionGte } from "./adapters/openshell/client.js"; -import * as registry from "./state/registry.js"; -import { loadAgent } from "./agent/defs.js"; -import { resolveOpenshell } from "./adapters/openshell/resolve.js"; -import { captureOpenshellCommand } from "./adapters/openshell/client.js"; -import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts.js"; +import { parseVersionFromText, versionGte } from "../adapters/openshell/client.js"; +import * as registry from "../state/registry.js"; +import { loadAgent } from "../agent/defs.js"; +import { resolveOpenshell } from "../adapters/openshell/resolve.js"; +import { captureOpenshellCommand } from "../adapters/openshell/client.js"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; export interface VersionCheckResult { sandboxVersion: string | null; diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 83c509ca317..a7f72990a40 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -18,7 +18,7 @@ vi.mock("../runner", () => ({ ROOT: "/mock/root", })); -vi.mock("../policies", () => ({ +vi.mock("../policy", () => ({ buildPolicyGetCommand: vi.fn((name) => ["openshell", "policy", "get", "--full", name]), buildPolicySetCommand: vi.fn((file, name) => [ "openshell", @@ -33,7 +33,7 @@ vi.mock("../policies", () => ({ PERMISSIVE_POLICY_PATH: "/mock/permissive.yaml", })); -vi.mock("../sandbox-config", () => ({ +vi.mock("../sandbox/config", () => ({ resolveAgentConfig: vi.fn(() => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index cc95b5d18e1..307212b9b5d 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -20,10 +20,10 @@ const { buildPolicySetCommand, parseCurrentPolicy, PERMISSIVE_POLICY_PATH, -} = require("../policies"); +} = require("../policy"); const { parseDuration, MAX_SECONDS, DEFAULT_SECONDS } = require("../domain/duration"); const { appendAuditEntry } = require("./audit"); -const { resolveAgentConfig } = require("../sandbox-config"); +const { resolveAgentConfig } = require("../sandbox/config"); const STATE_DIR = path.join(process.env.HOME ?? "/tmp", ".nemoclaw", "state"); diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index a0b8917114e..c7c79f095ed 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -11,9 +11,9 @@ import fs from "node:fs"; import path from "node:path"; -import { buildPolicySetCommand } from "../policies"; +import { buildPolicySetCommand } from "../policy"; import { run } from "../runner"; -import { DEFAULT_AGENT_CONFIG, resolveAgentConfig } from "../sandbox-config"; +import { DEFAULT_AGENT_CONFIG, resolveAgentConfig } from "../sandbox/config"; import { lockAgentConfig } from "./index"; type UnknownRecord = { [key: string]: unknown }; diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index 854509e8234..cb90578c5ef 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -5,14 +5,14 @@ import { spawnSync } from "node:child_process"; import { parseGatewayInference } from "./inference/config"; -import type { MessagingBridgeHealth, ShowStatusCommandDeps } from "./inventory-commands"; +import type { MessagingBridgeHealth, ShowStatusCommandDeps } from "./inventory"; import { backfillMessagingChannels, findAllOverlaps } from "./messaging-conflict"; import type { CaptureOpenshellResult } from "./adapters/openshell/client"; import { captureOpenshellCommand, stripAnsi } from "./adapters/openshell/client"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import * as registry from "./state/registry"; import { resolveOpenshell } from "./adapters/openshell/resolve"; -import { getServiceStatuses, showStatus as showServiceStatus } from "./services"; +import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; function captureOpenshell( rootDir: string, diff --git a/src/lib/tunnel/README.md b/src/lib/tunnel/README.md new file mode 100644 index 00000000000..e75a878511c --- /dev/null +++ b/src/lib/tunnel/README.md @@ -0,0 +1,9 @@ + + + +# Tunnel and host services + +This folder contains host-side service lifecycle helpers used by `nemoclaw start`, +`nemoclaw stop`, and tunnel/port-forward related commands. Keep oclif parser glue +in `src/lib/commands/**`; service orchestration that starts or stops host +processes belongs here or in `src/lib/actions/**` when it is command-specific. diff --git a/src/lib/services-command.test.ts b/src/lib/tunnel/service-command.test.ts similarity index 98% rename from src/lib/services-command.test.ts rename to src/lib/tunnel/service-command.test.ts index 2e7fba9921c..9cdb600448a 100644 --- a/src/lib/services-command.test.ts +++ b/src/lib/tunnel/service-command.test.ts @@ -7,7 +7,7 @@ import { resolveDefaultSandboxName, runStartCommand, runStopCommand, -} from "../../dist/lib/services-command"; +} from "../../../dist/lib/tunnel/service-command"; describe("services command", () => { let savedEnv: Record; diff --git a/src/lib/services-command.ts b/src/lib/tunnel/service-command.ts similarity index 100% rename from src/lib/services-command.ts rename to src/lib/tunnel/service-command.ts diff --git a/src/lib/services-sandbox.test.ts b/src/lib/tunnel/services-sandbox.test.ts similarity index 99% rename from src/lib/services-sandbox.test.ts rename to src/lib/tunnel/services-sandbox.test.ts index 202025012c4..f4530acbd40 100644 --- a/src/lib/services-sandbox.test.ts +++ b/src/lib/tunnel/services-sandbox.test.ts @@ -14,12 +14,12 @@ import { spawnSync as realSpawnSync } from "node:child_process"; // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-require-imports -const resolveOpenshellModule = require("../../dist/lib/adapters/openshell/resolve"); +const resolveOpenshellModule = require("../../../dist/lib/adapters/openshell/resolve"); import { stopSandboxChannels, stopAll, -} from "../../dist/lib/services"; +} from "../../../dist/lib/tunnel/services"; // --------------------------------------------------------------------------- // stopSandboxChannels diff --git a/src/lib/services.test.ts b/src/lib/tunnel/services.test.ts similarity index 98% rename from src/lib/services.test.ts rename to src/lib/tunnel/services.test.ts index 99b84a70eb4..bb0ec2d5ae2 100644 --- a/src/lib/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -8,12 +8,13 @@ import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; // Import from compiled dist/ so coverage is attributed correctly. -import { getServiceStatuses, showStatus, stopAll } from "../../dist/lib/services"; +import { getServiceStatuses, showStatus, stopAll } from "../../../dist/lib/tunnel/services"; const ollamaProxyDistPath = resolve( import.meta.dirname, "..", "..", + "..", "dist", "lib", "inference", diff --git a/src/lib/services.ts b/src/lib/tunnel/services.ts similarity index 97% rename from src/lib/services.ts rename to src/lib/tunnel/services.ts index cc2bfe4167f..c4639ebd07d 100644 --- a/src/lib/services.ts +++ b/src/lib/tunnel/services.ts @@ -15,11 +15,11 @@ import { } from "node:fs"; import { join } from "node:path"; -import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME } from "./cli/branding"; -import { dockerSpawnSync } from "./adapters/docker"; -import { DASHBOARD_PORT } from "./core/ports"; -import { resolveOpenshell } from "./adapters/openshell/resolve"; -import { buildSubprocessEnv } from "./subprocess-env"; +import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME } from "../cli/branding"; +import { dockerSpawnSync } from "../adapters/docker"; +import { DASHBOARD_PORT } from "../core/ports"; +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { buildSubprocessEnv } from "../subprocess-env"; // --------------------------------------------------------------------------- // Types @@ -429,7 +429,7 @@ export function stopAll(opts: ServiceOptions = {}): void { } try { - const { unloadOllamaModels } = require("./inference/ollama/proxy"); + const { unloadOllamaModels } = require("../inference/ollama/proxy"); unloadOllamaModels(); } catch { /* best-effort */ diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 03035ec25a1..9848a2fc9a5 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -41,7 +41,7 @@ import type { SandboxEntry } from "./lib/state/registry"; const nim = require("./lib/inference/nim"); const shields = require("./lib/shields"); const { parseGatewayInference } = require("./lib/inference/config"); -const policies = require("./lib/policies"); +const policies = require("./lib/policy"); const { probeProviderHealth } = require("./lib/inference/health"); const { buildStatusCommandDeps } = require("./lib/status-command-deps"); const { help, version } = require("./lib/actions/root-help"); diff --git a/test/cli.test.ts b/test/cli.test.ts index 48bad09419b..b59b8a32828 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -307,7 +307,7 @@ function createDebugCommandTestEnv(prefix: string): Record { describe("CLI dispatch", () => { it("config get validates flags and values before dispatch", async () => { - const sandboxConfigModule = await import("../dist/lib/sandbox-config.js"); + const sandboxConfigModule = await import("../dist/lib/sandbox/config.js"); const { parseConfigGetArgs } = (sandboxConfigModule.default ?? sandboxConfigModule) as { parseConfigGetArgs: ( args: string[], diff --git a/test/config-set-cli-dispatch.test.ts b/test/config-set-cli-dispatch.test.ts index 4baba1f0b0c..4e3f056af21 100644 --- a/test/config-set-cli-dispatch.test.ts +++ b/test/config-set-cli-dispatch.test.ts @@ -19,7 +19,7 @@ describe("config set CLI dispatch", () => { it("awaits configSet before completing the dispatcher", async () => { const cliPath = require.resolve("../dist/nemoclaw.js"); const registryPath = require.resolve("../dist/lib/state/registry.js"); - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config.js"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config.js"); const runnerPath = require.resolve("../dist/lib/runner.js"); const priorCli = require.cache[cliPath]; diff --git a/test/config-set-nested-ssrf.test.ts b/test/config-set-nested-ssrf.test.ts index b935a754094..014d11f4878 100644 --- a/test/config-set-nested-ssrf.test.ts +++ b/test/config-set-nested-ssrf.test.ts @@ -9,7 +9,7 @@ const requireCache: Record = require.cache as any; describe("config set nested URL SSRF enforcement", () => { it("rejects nested object/array URL values that target private hosts", async () => { - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config"); const openshellPath = require.resolve("../dist/lib/adapters/openshell/client"); const shieldsAuditPath = require.resolve("../dist/lib/shields/audit"); @@ -51,7 +51,7 @@ describe("config set nested URL SSRF enforcement", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const { configSet } = require("../dist/lib/sandbox-config"); + const { configSet } = require("../dist/lib/sandbox/config"); const nestedValue = JSON.stringify({ primary: "https://api.nvidia.com/v1", fallback: ["https://example.com/v1", { internal: "http://localhost:8080/internal" }], @@ -84,7 +84,7 @@ describe("config set nested URL SSRF enforcement", () => { }); it("validates the key before doing URL or DNS validation", async () => { - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config"); const openshellPath = require.resolve("../dist/lib/adapters/openshell/client"); const shieldsAuditPath = require.resolve("../dist/lib/shields/audit"); @@ -130,7 +130,7 @@ describe("config set nested URL SSRF enforcement", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const { configSet } = require("../dist/lib/sandbox-config"); + const { configSet } = require("../dist/lib/sandbox/config"); await expect( configSet("sandbox-ssrf-test", { @@ -163,7 +163,7 @@ describe("config set nested URL SSRF enforcement", () => { }); it("accepts nested object/array URL values when all are public", async () => { - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config"); const openshellPath = require.resolve("../dist/lib/adapters/openshell/client"); const shieldsAuditPath = require.resolve("../dist/lib/shields/audit"); @@ -205,7 +205,7 @@ describe("config set nested URL SSRF enforcement", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const { configSet } = require("../dist/lib/sandbox-config"); + const { configSet } = require("../dist/lib/sandbox/config"); const nestedValue = JSON.stringify({ primary: "https://93.184.216.34/v1", fallback: ["http://93.184.216.35/v1", { backup: "https://93.184.216.36/v2" }], @@ -238,7 +238,7 @@ describe("config set nested URL SSRF enforcement", () => { }); it("ignores nested non-http URL-like strings and does not crash", async () => { - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config"); const openshellPath = require.resolve("../dist/lib/adapters/openshell/client"); const shieldsAuditPath = require.resolve("../dist/lib/shields/audit"); @@ -280,7 +280,7 @@ describe("config set nested URL SSRF enforcement", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const { configSet } = require("../dist/lib/sandbox-config"); + const { configSet } = require("../dist/lib/sandbox/config"); const nestedValue = JSON.stringify({ ftpUrl: "ftp://files.example.com", plainText: "not-a-url", @@ -314,7 +314,7 @@ describe("config set nested URL SSRF enforcement", () => { }); it("recognizes mixed-case http and https schemes in nested values", async () => { - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config"); const openshellPath = require.resolve("../dist/lib/adapters/openshell/client"); const shieldsAuditPath = require.resolve("../dist/lib/shields/audit"); @@ -356,7 +356,7 @@ describe("config set nested URL SSRF enforcement", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const { configSet } = require("../dist/lib/sandbox-config"); + const { configSet } = require("../dist/lib/sandbox/config"); const nestedValue = JSON.stringify({ primary: "HTTP://93.184.216.34/v1", fallback: ["HtTpS://93.184.216.35/v2", { backup: "hTtP://93.184.216.36/v3" }], @@ -389,7 +389,7 @@ describe("config set nested URL SSRF enforcement", () => { }); it("redacts credentials, query strings, and fragments in validation errors", async () => { - const sandboxConfigPath = require.resolve("../dist/lib/sandbox-config"); + const sandboxConfigPath = require.resolve("../dist/lib/sandbox/config"); const openshellPath = require.resolve("../dist/lib/adapters/openshell/client"); const shieldsAuditPath = require.resolve("../dist/lib/shields/audit"); @@ -431,7 +431,7 @@ describe("config set nested URL SSRF enforcement", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const { configSet } = require("../dist/lib/sandbox-config"); + const { configSet } = require("../dist/lib/sandbox/config"); const nestedValue = JSON.stringify({ primary: "http://user:pass@127.0.0.1:8080/private/path?token=secret#frag", }); diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 3da32a487f2..2f6a5437082 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -17,7 +17,7 @@ const { rewriteConfigUrlsWithDnsPinning, formatConfigValueForLogs, resolveAgentConfig, -} = require("../dist/lib/sandbox-config"); +} = require("../dist/lib/sandbox/config"); type MutableScalar = string | number | boolean | null | undefined; type MutableValue = MutableScalar | MutableMap | MutableValue[]; diff --git a/test/credential-exposure.test.ts b/test/credential-exposure.test.ts index 211acd7f6f1..fc975b29566 100644 --- a/test/credential-exposure.test.ts +++ b/test/credential-exposure.test.ts @@ -13,7 +13,7 @@ import { createRequire } from "node:module"; import { describe, it, expect } from "vitest"; import { buildSubprocessEnv as buildCliSubprocessEnv } from "../src/lib/subprocess-env"; import { buildSubprocessEnv as buildPluginSubprocessEnv } from "../nemoclaw/src/lib/subprocess-env"; -import { getCurlTimingArgs } from "../src/lib/http-probe"; +import { getCurlTimingArgs } from "../src/lib/adapters/http/probe"; const require = createRequire(import.meta.url); const { buildProviderArgs } = require("../dist/lib/onboard/providers.js") as { diff --git a/test/e2e/test-rebuild-openclaw.sh b/test/e2e/test-rebuild-openclaw.sh index 66c564aadce..7ce79484d16 100755 --- a/test/e2e/test-rebuild-openclaw.sh +++ b/test/e2e/test-rebuild-openclaw.sh @@ -246,14 +246,14 @@ NEMOCLAW_MODULE_DIR="$(node -e " if (m) { const nodeDir = path.dirname(path.dirname(m[1])); const candidate = path.join(nodeDir, 'lib/node_modules/nemoclaw'); - if (fs.existsSync(path.join(candidate, 'dist/lib/policies.js'))) { + if (fs.existsSync(path.join(candidate, 'dist/lib/policy/index.js'))) { console.log(candidate); process.exit(0); } } // Last resort: relative to the repo root const repoCandidate = '${REPO_ROOT}'; - if (fs.existsSync(path.join(repoCandidate, 'dist/lib/policies.js'))) { + if (fs.existsSync(path.join(repoCandidate, 'dist/lib/policy/index.js'))) { console.log(repoCandidate); process.exit(0); } @@ -266,7 +266,7 @@ diag "NemoClaw module dir: ${NEMOCLAW_MODULE_DIR}" for preset in npm pypi; do info " Applying preset: ${preset}" node -e " - const policies = require('${NEMOCLAW_MODULE_DIR}/dist/lib/policies.js'); + const policies = require('${NEMOCLAW_MODULE_DIR}/dist/lib/policy/index.js'); const ok = policies.applyPreset('${SANDBOX_NAME}', '${preset}'); if (!ok) { console.error('applyPreset returned false for ${preset}'); process.exit(1); } " || fail "Failed to apply preset: ${preset}" diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index d46c112060d..c2135f6ba93 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -50,7 +50,7 @@ function buildPreamble({ const credPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const resolveOpenshellPath = JSON.stringify( path.join(repoRoot, "dist", "lib", "adapters", "openshell", "resolve.js"), ); diff --git a/test/onboard-readiness.test.ts b/test/onboard-readiness.test.ts index 85635f316c3..de5ad3d8113 100644 --- a/test/onboard-readiness.test.ts +++ b/test/onboard-readiness.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { applyPreset, buildPolicySetCommand, buildPolicyGetCommand } from "../dist/lib/policies"; +import { applyPreset, buildPolicySetCommand, buildPolicyGetCommand } from "../dist/lib/policy"; type OnboardReadinessInternals = { hasStaleGateway: (output: string | null | undefined) => boolean; diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 609638fea27..30be6b8fd3a 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -13,7 +13,7 @@ import type { AgentDefinition } from "../dist/lib/agent/defs.js"; import { loadAgent } from "../dist/lib/agent/defs.js"; import { buildChain, buildControlUiUrls } from "../dist/lib/dashboard/contract.js"; import { NAME_ALLOWED_FORMAT } from "../dist/lib/name-validation.js"; -import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context.js"; +import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox/build-context.js"; type ShimScalar = string | number | boolean | null | undefined; type ShimCallable = (...args: readonly string[]) => ShimValue; @@ -2014,12 +2014,16 @@ const { loadAgent } = require(${agentDefsPath}); }); it("allows slow sandbox create recovery to wait beyond 60 seconds", () => { + const envSource = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard", "env.ts"), + "utf-8", + ); const source = fs.readFileSync( path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), "utf-8", ); - assert.match(source, /NEMOCLAW_SANDBOX_READY_TIMEOUT", 180/); + assert.match(envSource, /NEMOCLAW_SANDBOX_READY_TIMEOUT", 180/); assert.match(source, /Math\.ceil\(SANDBOX_READY_TIMEOUT_SECS \/ 2\)/); assert.match(source, /within \$\{SANDBOX_READY_TIMEOUT_SECS\}s/); }); @@ -4092,7 +4096,7 @@ const { setupInference } = require(${onboardPath}); "utf-8", ); const probeSource = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "http-probe.ts"), + path.join(import.meta.dirname, "..", "src", "lib", "adapters", "http", "probe.ts"), "utf-8", ); const recoverySource = fs.readFileSync( @@ -4100,7 +4104,7 @@ const { setupInference } = require(${onboardPath}); "utf-8", ); - assert.match(onboardSource, /http-probe/); + assert.match(onboardSource, /adapters\/http\/probe/); assert.match(probeSource, /return \["--connect-timeout", "10", "--max-time", "60"\];/); assert.match(recoverySource, /failure\.curlStatus === 2/); assert.match(recoverySource, /local curl invocation error/); @@ -4286,9 +4290,9 @@ const { setupInference } = require(${onboardPath}); path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), "utf-8", ); - const { streamSandboxCreate } = require("../dist/lib/sandbox-create-stream"); + const { streamSandboxCreate } = require("../dist/lib/sandbox/create-stream"); - assert.match(onboardSource, /sandbox-create-stream/); + assert.match(onboardSource, /sandbox\/create-stream/); assert.equal(typeof streamSandboxCreate, "function"); }); @@ -6244,7 +6248,7 @@ childProcess.spawn = fakeSpawn; // childProcess object above does not reach it. Patch the cached module // directly so streamSandboxCreate (called by createSandbox) doesn't spawn // a real bash process that tries to hit a live gateway. -const sandboxCreateStreamMod = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "sandbox-create-stream.js"))}); +const sandboxCreateStreamMod = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "sandbox", "create-stream.js"))}); const _origStreamCreate = sandboxCreateStreamMod.streamSandboxCreate; sandboxCreateStreamMod.streamSandboxCreate = (command, env, options = {}) => { return _origStreamCreate(command, env, { ...options, spawnImpl: fakeSpawn }); @@ -7323,7 +7327,7 @@ const { createSandbox } = require(${onboardPath}); const scriptPath = path.join(tmpDir, "messaging-noninteractive.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); - const httpProbePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "http-probe.js")); + const httpProbePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "adapters", "http", "probe.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { diff --git a/test/policies.test.ts b/test/policies.test.ts index 684981e0574..3cc59b525f3 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -9,7 +9,7 @@ import { createRequire } from "node:module"; import type { Interface as ReadlineInterface } from "node:readline"; import { afterEach, describe, it, expect, vi } from "vitest"; import { spawnSync } from "node:child_process"; -import policies from "../dist/lib/policies"; +import policies from "../dist/lib/policy"; import { execTimeout } from "./helpers/timeouts"; const requireForTest = createRequire(import.meta.url); @@ -18,7 +18,7 @@ const YAML = requireForTest("yaml"); const REPO_ROOT = path.join(import.meta.dirname, ".."); const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "nemoclaw.js")); const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "credentials", "store.js")); -const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "policies.js")); +const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "policy", "index.js")); const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "state", "registry.js")); const SELECT_FROM_LIST_ITEMS = [ { name: "npm", description: "npm and Yarn registry access" }, diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 83c2cdddb6b..0cabcca3770 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -110,7 +110,7 @@ console.log = () => {}; }); it("restricted tier produces an empty preset list", () => { - const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "tiers.js")); + const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "tiers.js")); const script = buildPreamble({ tierEnv: "restricted" }) + String.raw` @@ -130,7 +130,7 @@ console.log = () => {}; }); it("balanced tier resolves presets all with read-write access", () => { - const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "tiers.js")); + const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "tiers.js")); const script = buildPreamble({ tierEnv: "balanced" }) + String.raw` @@ -153,7 +153,7 @@ console.log = () => {}; }); it("open tier resolves presets including at least one social/messaging preset", () => { - const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "tiers.js")); + const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "tiers.js")); const script = buildPreamble({ tierEnv: "open" }) + String.raw` @@ -179,7 +179,7 @@ console.log = () => {}; }); it("a preset can be deselected via selected option in resolveTierPresets", () => { - const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "tiers.js")); + const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "tiers.js")); const script = buildPreamble({ tierEnv: "balanced" }) + String.raw` @@ -199,7 +199,7 @@ console.log = () => {}; }); it("access level can be restricted from read-write to read via override", () => { - const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "tiers.js")); + const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "tiers.js")); const script = buildPreamble({ tierEnv: "balanced" }) + String.raw` @@ -250,7 +250,7 @@ console.log = (...args) => lines.push(args.join(" ")); }); it("selected tier is persisted to the registry via updateSandbox({ policyTier })", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "open", policyMode: "skip" }) + String.raw` @@ -294,7 +294,7 @@ console.log = (...args) => lines.push(args.join(" ")); // that the user may have meant NEMOCLAW_POLICY_TIER when the value looks like // a tier name. it("falls back to tier suggestions when NEMOCLAW_POLICY_MODE is unknown (#2429)", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -337,7 +337,7 @@ console.log = () => {}; }); it("omits the tier-name hint for a non-tier invalid value (#2429)", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -374,8 +374,8 @@ console.log = () => {}; }); describe("selectTierPresetsAndAccess", () => { - const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "tiers.js")); - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const tiersPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "tiers.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); function buildPresetsScript(body: string): string { const credPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index 7c9f968e918..dd9ce82eb6b 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -11,8 +11,8 @@ // - Integration with the existing policies module import { describe, expect, it } from "vitest"; -import policies from "../dist/lib/policies"; -import tiers from "../dist/lib/tiers"; +import policies from "../dist/lib/policy"; +import tiers from "../dist/lib/policy/tiers"; interface TierPreset { name: string; diff --git a/test/repro-2010.test.ts b/test/repro-2010.test.ts index 4f4d976f945..8524a6361f5 100644 --- a/test/repro-2010.test.ts +++ b/test/repro-2010.test.ts @@ -17,7 +17,7 @@ import os from "node:os"; import { spawnSync } from "node:child_process"; const REPO_ROOT = path.join(import.meta.dirname, ".."); -const POLICIES_PATH = path.join(REPO_ROOT, "dist", "lib", "policies.js"); +const POLICIES_PATH = path.join(REPO_ROOT, "dist", "lib", "policy", "index.js"); const RUNNER_PATH = path.join(REPO_ROOT, "dist", "lib", "runner.js"); const CLI_PATH = path.join(REPO_ROOT, "bin", "nemoclaw.js"); const REGISTRY_PATH = path.join(REPO_ROOT, "dist", "lib", "state", "registry.js"); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 1ed65d4090d..03bf0b61321 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -10,7 +10,7 @@ import { collectBuildContextStats, stageLegacySandboxBuildContext, stageOptimizedSandboxBuildContext, -} from "../dist/lib/sandbox-build-context"; +} from "../dist/lib/sandbox/build-context"; describe("sandbox build context staging", () => { it("optimized staging excludes blueprint .venv and extra scripts while preserving required files", () => { diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index 8605d8f1865..e1eb263e5b8 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -48,7 +48,7 @@ describe("sandboxName command hardening in onboard.js", () => { pathToFileURL(path.join(repoRoot, "dist", "lib", "credentials", "store.js")).href, ); const streamUrl = JSON.stringify( - pathToFileURL(path.join(repoRoot, "dist", "lib", "sandbox-create-stream.js")).href, + pathToFileURL(path.join(repoRoot, "dist", "lib", "sandbox", "create-stream.js")).href, ); fs.mkdirSync(fakeBin, { recursive: true }); diff --git a/test/wsl2-probe-timeout.test.ts b/test/wsl2-probe-timeout.test.ts index 7967de66e6c..b28c3fa786a 100644 --- a/test/wsl2-probe-timeout.test.ts +++ b/test/wsl2-probe-timeout.test.ts @@ -64,7 +64,7 @@ describe("WSL2 inference verification timeouts (issue #987)", () => { describe("retry logic in probeOpenAiLikeEndpoint", () => { function runProbeWithCurlStatuses(statuses: number[]) { - const httpProbePath = require.resolve("../dist/lib/http-probe.js"); + const httpProbePath = require.resolve("../dist/lib/adapters/http/probe.js"); const platformPath = require.resolve("../dist/lib/platform.js"); const probesPath = require.resolve("../dist/lib/inference/onboard-probes.js"); const httpProbe = require(httpProbePath); @@ -151,7 +151,7 @@ describe("WSL2 inference verification timeouts (issue #987)", () => { }; function runProbeWithResults(results: ProbeResultFixture[], opts: { isWsl?: boolean } = {}) { - const httpProbePath = require.resolve("../dist/lib/http-probe.js"); + const httpProbePath = require.resolve("../dist/lib/adapters/http/probe.js"); const platformPath = require.resolve("../dist/lib/platform.js"); const probesPath = require.resolve("../dist/lib/inference/onboard-probes.js"); const httpProbe = require(httpProbePath); From 8f3ebc1a0955c559d445a139b7bf27b28578518a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 15:46:00 -0700 Subject: [PATCH 02/12] refactor(onboard): extract initial policy helpers --- src/lib/onboard.ts | 113 ++----------------------- src/lib/onboard/initial-policy.test.ts | 76 +++++++++++++++++ src/lib/onboard/initial-policy.ts | 90 ++++++++++++++++++++ src/lib/onboard/temp-files.test.ts | 50 +++++++++++ src/lib/onboard/temp-files.ts | 28 ++++++ 5 files changed, 251 insertions(+), 106 deletions(-) create mode 100644 src/lib/onboard/initial-policy.test.ts create mode 100644 src/lib/onboard/initial-policy.ts create mode 100644 src/lib/onboard/temp-files.test.ts create mode 100644 src/lib/onboard/temp-files.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2136a206c6e..f77f4f81112 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -16,6 +16,13 @@ const { cliName, setOnboardBrandingAgent, }: typeof import("./onboard/branding") = require("./onboard/branding"); +const { + cleanupTempDir, + secureTempFile, +}: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); +const { + prepareInitialSandboxCreatePolicy, +}: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -314,28 +321,6 @@ import type { SandboxCreateFailure, ValidationClassification } from "./validatio import type { ProbeRecovery } from "./validation-recovery"; import type { WebSearchConfig } from "./inference/web-search"; -/** - * Create a temp file inside a directory with a cryptographically random name. - * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that - * could be exploited via symlink attacks on shared /tmp. - * Ref: https://github.com/NVIDIA/NemoClaw/issues/1093 - */ -function secureTempFile(prefix: string, ext = ""): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - return path.join(dir, `${prefix}${ext}`); -} - -/** - * Safely remove a mkdtemp-created directory. Guards against accidentally - * deleting the system temp root if a caller passes os.tmpdir() itself. - */ -function cleanupTempDir(filePath: string, expectedPrefix: string): void { - const parentDir = path.dirname(filePath); - if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { - fs.rmSync(parentDir, { recursive: true, force: true }); - } -} - const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; @@ -1731,90 +1716,6 @@ type SelectionDrift = { unknown: boolean; }; -type InitialSandboxPolicy = { - policyPath: string; - appliedPresets: string[]; - cleanup?: () => boolean; -}; - -const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record = { - slack: ["slack"], -}; - -function getNetworkPolicyNames(policyContent: string): Set | null { - try { - // Lazy require: yaml is already a dependency via the policy helpers. - const YAML = require("yaml"); - const parsed = YAML.parse(policyContent); - const networkPolicies = parsed?.network_policies; - if ( - !networkPolicies || - typeof networkPolicies !== "object" || - Array.isArray(networkPolicies) - ) { - return new Set(); - } - return new Set(Object.keys(networkPolicies)); - } catch { - return null; - } -} - -function prepareInitialSandboxCreatePolicy( - basePolicyPath: string, - activeMessagingChannels: string[], -): InitialSandboxPolicy { - const requestedCreateTimePresets = [ - ...new Set( - activeMessagingChannels.flatMap( - (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], - ), - ), - ]; - - if (requestedCreateTimePresets.length === 0) { - return { policyPath: basePolicyPath, appliedPresets: [] }; - } - - const basePolicy = fs.readFileSync(basePolicyPath, "utf-8"); - const basePolicyNames = getNetworkPolicyNames(basePolicy); - if (basePolicyNames === null) { - return { policyPath: basePolicyPath, appliedPresets: [] }; - } - const existingCreateTimePresets = requestedCreateTimePresets.filter((preset) => - basePolicyNames.has(preset), - ); - const createTimePresets = requestedCreateTimePresets.filter( - (preset) => !basePolicyNames.has(preset), - ); - if (createTimePresets.length === 0) { - return { policyPath: basePolicyPath, appliedPresets: existingCreateTimePresets }; - } - - const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets); - if (mergedPolicy.missingPresets.length > 0) { - throw new Error( - `Cannot prepare sandbox create policy; missing policy preset(s): ${mergedPolicy.missingPresets.join(", ")}`, - ); - } - - const policyPath = secureTempFile("nemoclaw-initial-policy", ".yaml"); - fs.writeFileSync(policyPath, mergedPolicy.policy, { encoding: "utf-8", mode: 0o600 }); - - return { - policyPath, - appliedPresets: [...existingCreateTimePresets, ...mergedPolicy.appliedPresets], - cleanup: () => { - try { - cleanupTempDir(policyPath, "nemoclaw-initial-policy"); - return true; - } catch { - return false; - } - }, - }; -} - function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]) { const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); // upsertMessagingProviders process.exits on failure, so reaching this diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts new file mode 100644 index 00000000000..5ea0c476b46 --- /dev/null +++ b/src/lib/onboard/initial-policy.test.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../policy", () => ({ + mergePresetNamesIntoPolicy: (policy: string, presetNames: string[]) => ({ + policy: `${policy.trimEnd()}\n slack: {}\n`, + appliedPresets: presetNames, + missingPresets: [], + }), +})); + +import { getNetworkPolicyNames, prepareInitialSandboxCreatePolicy } from "./initial-policy"; + +const tmpRoots: string[] = []; + +function tmpPolicy(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-initial-policy-test-")); + tmpRoots.push(dir); + const file = path.join(dir, "base.yaml"); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("initial sandbox policy helpers", () => { + it("returns network policy names from a policy document", () => { + expect(getNetworkPolicyNames("version: 1\nnetwork_policies:\n slack: {}\n npm: {}\n")).toEqual( + new Set(["slack", "npm"]), + ); + }); + + it("returns null when policy YAML cannot be parsed", () => { + expect(getNetworkPolicyNames("network_policies: [unterminated")).toBeNull(); + }); + + it("keeps the base policy when no channel needs a create-time preset", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + + expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["telegram"])).toEqual({ + policyPath: basePolicyPath, + appliedPresets: [], + }); + }); + + it("records an existing create-time preset without writing a temp policy", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n slack: {}\n"); + + expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"])).toEqual({ + policyPath: basePolicyPath, + appliedPresets: ["slack"], + }); + }); + + it("merges missing create-time presets into a temporary policy", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"]); + + expect(prepared.policyPath).not.toBe(basePolicyPath); + expect(prepared.appliedPresets).toEqual(["slack"]); + expect(fs.readFileSync(prepared.policyPath, "utf-8")).toContain("slack"); + expect(prepared.cleanup?.()).toBe(true); + expect(fs.existsSync(prepared.policyPath)).toBe(false); + }); +}); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts new file mode 100644 index 00000000000..ed3b5cd21bb --- /dev/null +++ b/src/lib/onboard/initial-policy.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import YAML from "yaml"; + +import * as policies from "../policy"; +import { cleanupTempDir, secureTempFile } from "./temp-files"; + +export type InitialSandboxPolicy = { + policyPath: string; + appliedPresets: string[]; + cleanup?: () => boolean; +}; + +const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record = { + slack: ["slack"], +}; + +export function getNetworkPolicyNames(policyContent: string): Set | null { + try { + const parsed = YAML.parse(policyContent); + const networkPolicies = parsed?.network_policies; + if ( + !networkPolicies || + typeof networkPolicies !== "object" || + Array.isArray(networkPolicies) + ) { + return new Set(); + } + return new Set(Object.keys(networkPolicies)); + } catch { + return null; + } +} + +export function prepareInitialSandboxCreatePolicy( + basePolicyPath: string, + activeMessagingChannels: string[], +): InitialSandboxPolicy { + const requestedCreateTimePresets = [ + ...new Set( + activeMessagingChannels.flatMap( + (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], + ), + ), + ]; + + if (requestedCreateTimePresets.length === 0) { + return { policyPath: basePolicyPath, appliedPresets: [] }; + } + + const basePolicy = fs.readFileSync(basePolicyPath, "utf-8"); + const basePolicyNames = getNetworkPolicyNames(basePolicy); + if (basePolicyNames === null) { + return { policyPath: basePolicyPath, appliedPresets: [] }; + } + const existingCreateTimePresets = requestedCreateTimePresets.filter((preset) => + basePolicyNames.has(preset), + ); + const createTimePresets = requestedCreateTimePresets.filter( + (preset) => !basePolicyNames.has(preset), + ); + if (createTimePresets.length === 0) { + return { policyPath: basePolicyPath, appliedPresets: existingCreateTimePresets }; + } + + const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets); + if (mergedPolicy.missingPresets.length > 0) { + throw new Error( + `Cannot prepare sandbox create policy; missing policy preset(s): ${mergedPolicy.missingPresets.join(", ")}`, + ); + } + + const policyPath = secureTempFile("nemoclaw-initial-policy", ".yaml"); + fs.writeFileSync(policyPath, mergedPolicy.policy, { encoding: "utf-8", mode: 0o600 }); + + return { + policyPath, + appliedPresets: [...existingCreateTimePresets, ...mergedPolicy.appliedPresets], + cleanup: () => { + try { + cleanupTempDir(policyPath, "nemoclaw-initial-policy"); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/lib/onboard/temp-files.test.ts b/src/lib/onboard/temp-files.test.ts new file mode 100644 index 00000000000..e7bcc9e17ed --- /dev/null +++ b/src/lib/onboard/temp-files.test.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { cleanupTempDir, secureTempFile } from "./temp-files"; + +const createdParents: string[] = []; + +afterEach(() => { + for (const parent of createdParents.splice(0)) { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +describe("onboard temp file helpers", () => { + it("creates a file path under a unique prefixed temp directory", () => { + const filePath = secureTempFile("nemoclaw-test", ".txt"); + const parent = path.dirname(filePath); + createdParents.push(parent); + + expect(path.basename(parent).startsWith("nemoclaw-test-")).toBe(true); + expect(path.basename(filePath)).toBe("nemoclaw-test.txt"); + }); + + it("removes only the matching mkdtemp-created parent directory", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + fs.writeFileSync(filePath, "payload"); + + cleanupTempDir(filePath, "nemoclaw-cleanup"); + + expect(fs.existsSync(parent)).toBe(false); + }); + + it("does not remove unrelated temp directories", () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "other-prefix-")); + createdParents.push(parent); + const filePath = path.join(parent, "nemoclaw-cleanup.txt"); + fs.writeFileSync(filePath, "payload"); + + cleanupTempDir(filePath, "nemoclaw-cleanup"); + + expect(fs.existsSync(parent)).toBe(true); + }); +}); diff --git a/src/lib/onboard/temp-files.ts b/src/lib/onboard/temp-files.ts new file mode 100644 index 00000000000..4e086d9c270 --- /dev/null +++ b/src/lib/onboard/temp-files.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Create a temp file inside a directory with a cryptographically random name. + * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that + * could be exploited via symlink attacks on shared /tmp. + * Ref: https://github.com/NVIDIA/NemoClaw/issues/1093 + */ +export function secureTempFile(prefix: string, ext = ""): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + return path.join(dir, `${prefix}${ext}`); +} + +/** + * Safely remove a mkdtemp-created directory. Guards against accidentally + * deleting the system temp root if a caller passes os.tmpdir() itself. + */ +export function cleanupTempDir(filePath: string, expectedPrefix: string): void { + const parentDir = path.dirname(filePath); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } +} From 3d312996711d743886a7f1341c1bfc121c22c9c5 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 16:41:03 -0700 Subject: [PATCH 03/12] refactor(onboard): extract compatible endpoint smoke helpers --- src/lib/onboard.ts | 144 +---------------- .../onboard/compatible-endpoint-smoke.test.ts | 61 ++++++++ src/lib/onboard/compatible-endpoint-smoke.ts | 147 ++++++++++++++++++ 3 files changed, 214 insertions(+), 138 deletions(-) create mode 100644 src/lib/onboard/compatible-endpoint-smoke.test.ts create mode 100644 src/lib/onboard/compatible-endpoint-smoke.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f77f4f81112..8f9c50cc021 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -20,6 +20,12 @@ const { cleanupTempDir, secureTempFile, }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); +const { + buildCompatibleEndpointSandboxSmokeCommand, + buildCompatibleEndpointSandboxSmokeScript, + shouldRunCompatibleEndpointSandboxSmoke, + spawnOutputToString, +}: typeof import("./onboard/compatible-endpoint-smoke") = require("./onboard/compatible-endpoint-smoke"); const { prepareInitialSandboxCreatePolicy, }: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); @@ -1839,144 +1845,6 @@ function isInferenceRouteReady(provider: string, model: string): boolean { return Boolean(live && live.provider === provider && live.model === model); } -function shouldRunCompatibleEndpointSandboxSmoke( - provider: string | null | undefined, - messagingChannels: string[] | null | undefined, - agent: AgentDefinition | null | undefined = null, -): boolean { - const agentName = agent?.name || "openclaw"; - return ( - agentName === "openclaw" && - provider === "compatible-endpoint" && - Array.isArray(messagingChannels) && - messagingChannels.length > 0 - ); -} - -function spawnOutputToString(value: unknown): string { - if (typeof value === "string") return value; - if (Buffer.isBuffer(value)) return value.toString("utf-8"); - if (value == null) return ""; - return String(value); -} - -function buildCompatibleEndpointSandboxSmokeScript(model: string): string { - return ` -set -eu -MODEL=${shellQuote(model)} -CONFIG=/sandbox/.openclaw/openclaw.json - -python3 - "$CONFIG" "$MODEL" <<'PYCFG' -import json -import sys - -path = sys.argv[1] -model = sys.argv[2] - -def die(message): - print(message, file=sys.stderr) - sys.exit(1) - -try: - with open(path, "r", encoding="utf-8") as f: - cfg = json.load(f) -except Exception as exc: - die("could not read openclaw.json: %s" % exc) - -providers = cfg.get("models", {}).get("providers", {}) -if not isinstance(providers, dict): - die("openclaw.json models.providers is not an object") -if "deepinfra" in providers: - die("openclaw.json contains a direct deepinfra provider; expected managed inference provider") - -provider = providers.get("${MANAGED_PROVIDER_ID}") -if not isinstance(provider, dict): - die("openclaw.json missing models.providers.${MANAGED_PROVIDER_ID}") -if provider.get("baseUrl") != "${INFERENCE_ROUTE_URL}": - die("models.providers.${MANAGED_PROVIDER_ID}.baseUrl is %r; expected ${INFERENCE_ROUTE_URL}" % provider.get("baseUrl")) -if provider.get("apiKey") != "unused": - die("models.providers.${MANAGED_PROVIDER_ID}.apiKey must remain the non-secret placeholder 'unused'") - -primary = cfg.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") -expected_primary = "${MANAGED_PROVIDER_ID}/" + model -if primary != expected_primary: - die("agents.defaults.model.primary is %r; expected %r" % (primary, expected_primary)) - -print("OPENCLAW_CONFIG_OK") -PYCFG - -payload_file="$(mktemp)" -response_file="$(mktemp)" -error_file="$(mktemp)" -trap 'rm -f "$payload_file" "$response_file" "$error_file"' EXIT - -python3 - "$MODEL" >"$payload_file" <<'PYPAYLOAD' -import json -import sys - -model = sys.argv[1] -print(json.dumps({ - "model": model, - "messages": [ - {"role": "user", "content": "Reply with exactly: PONG"} - ], - "max_tokens": 32, -})) -PYPAYLOAD - -curl -sS --connect-timeout 10 --max-time 60 \ - "${INFERENCE_ROUTE_URL}/chat/completions" \ - -H "Content-Type: application/json" \ - -d "@$payload_file" >"$response_file" 2>"$error_file" || { - rc=$? - printf 'curl exit %s: ' "$rc" >&2 - cat "$error_file" >&2 - exit "$rc" -} - -python3 - "$response_file" <<'PYRESP' -import json -import sys - -path = sys.argv[1] -try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) -except Exception as exc: - body = "" - try: - with open(path, "r", encoding="utf-8", errors="replace") as f: - body = f.read(1000) - except Exception: - pass - print("inference.local returned non-JSON response: %s; body=%s" % (exc, body), file=sys.stderr) - sys.exit(1) - -content = ( - data.get("choices", [{}])[0] - .get("message", {}) - .get("content") -) -if not isinstance(content, str) or not content.strip(): - print("inference.local response did not contain choices[0].message.content: %s" % json.dumps(data)[:1000], file=sys.stderr) - sys.exit(1) - -print("INFERENCE_SMOKE_OK " + content.strip()[:200]) -PYRESP -`.trim(); -} - -function buildCompatibleEndpointSandboxSmokeCommand(model: string): string { - const script = buildCompatibleEndpointSandboxSmokeScript(model); - const encoded = Buffer.from(script, "utf8").toString("base64"); - return [ - 'tmp="$(mktemp)"', - 'trap \'rm -f "$tmp"\' EXIT', - `python3 -c 'import base64, pathlib, sys; pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.argv[2]))' "$tmp" ${shellQuote(encoded)}`, - 'sh "$tmp"', - ].join("; "); -} - function verifyCompatibleEndpointSandboxSmoke(options: { sandboxName: string; provider: string; diff --git a/src/lib/onboard/compatible-endpoint-smoke.test.ts b/src/lib/onboard/compatible-endpoint-smoke.test.ts new file mode 100644 index 00000000000..f1d052033de --- /dev/null +++ b/src/lib/onboard/compatible-endpoint-smoke.test.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../inference/config", () => ({ + INFERENCE_ROUTE_URL: "https://inference.local/v1", + MANAGED_PROVIDER_ID: "inference", +})); + +import { + buildCompatibleEndpointSandboxSmokeCommand, + buildCompatibleEndpointSandboxSmokeScript, + shouldRunCompatibleEndpointSandboxSmoke, + spawnOutputToString, +} from "./compatible-endpoint-smoke"; + +describe("compatible endpoint sandbox smoke helpers", () => { + it("runs only for OpenClaw compatible-endpoint sandboxes with messaging", () => { + expect(shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", ["telegram"])).toBe( + true, + ); + expect( + shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", ["telegram"], { + name: "openclaw", + }), + ).toBe(true); + expect( + shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", ["telegram"], { + name: "hermes", + }), + ).toBe(false); + expect(shouldRunCompatibleEndpointSandboxSmoke("nvidia-prod", ["telegram"])).toBe(false); + expect(shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", [])).toBe(false); + }); + + it("normalizes spawn output values to strings", () => { + expect(spawnOutputToString("already string")).toBe("already string"); + expect(spawnOutputToString(Buffer.from("buffered"))).toBe("buffered"); + expect(spawnOutputToString(null)).toBe(""); + expect(spawnOutputToString(42)).toBe("42"); + }); + + it("builds a sandbox script that checks managed provider routing", () => { + const script = buildCompatibleEndpointSandboxSmokeScript("provider/model'"); + + expect(script).toContain("OPENCLAW_CONFIG_OK"); + expect(script).toContain("INFERENCE_SMOKE_OK"); + expect(script).toContain("models.providers.inference"); + expect(script).toContain("https://inference.local/v1/chat/completions"); + expect(script).toContain("MODEL='provider/model'\\'''"); + }); + + it("wraps the script as a base64 decoded temporary shell command", () => { + const command = buildCompatibleEndpointSandboxSmokeCommand("nvidia/model"); + + expect(command).toContain("base64.b64decode"); + expect(command).toContain('sh "$tmp"'); + expect(command).toContain("trap"); + }); +}); diff --git a/src/lib/onboard/compatible-endpoint-smoke.ts b/src/lib/onboard/compatible-endpoint-smoke.ts new file mode 100644 index 00000000000..ad4eacb0b39 --- /dev/null +++ b/src/lib/onboard/compatible-endpoint-smoke.ts @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { shellQuote } from "../core/shell-quote"; +import { INFERENCE_ROUTE_URL, MANAGED_PROVIDER_ID } from "../inference/config"; + +type CompatibleEndpointSmokeAgent = { + name?: string | null; +} | null | undefined; + +export function shouldRunCompatibleEndpointSandboxSmoke( + provider: string | null | undefined, + messagingChannels: string[] | null | undefined, + agent: CompatibleEndpointSmokeAgent = null, +): boolean { + const agentName = agent?.name || "openclaw"; + return ( + agentName === "openclaw" && + provider === "compatible-endpoint" && + Array.isArray(messagingChannels) && + messagingChannels.length > 0 + ); +} + +export function spawnOutputToString(value: unknown): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("utf-8"); + if (value == null) return ""; + return String(value); +} + +export function buildCompatibleEndpointSandboxSmokeScript(model: string): string { + return ` +set -eu +MODEL=${shellQuote(model)} +CONFIG=/sandbox/.openclaw/openclaw.json + +python3 - "$CONFIG" "$MODEL" <<'PYCFG' +import json +import sys + +path = sys.argv[1] +model = sys.argv[2] + +def die(message): + print(message, file=sys.stderr) + sys.exit(1) + +try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) +except Exception as exc: + die("could not read openclaw.json: %s" % exc) + +providers = cfg.get("models", {}).get("providers", {}) +if not isinstance(providers, dict): + die("openclaw.json models.providers is not an object") +if "deepinfra" in providers: + die("openclaw.json contains a direct deepinfra provider; expected managed inference provider") + +provider = providers.get("${MANAGED_PROVIDER_ID}") +if not isinstance(provider, dict): + die("openclaw.json missing models.providers.${MANAGED_PROVIDER_ID}") +if provider.get("baseUrl") != "${INFERENCE_ROUTE_URL}": + die("models.providers.${MANAGED_PROVIDER_ID}.baseUrl is %r; expected ${INFERENCE_ROUTE_URL}" % provider.get("baseUrl")) +if provider.get("apiKey") != "unused": + die("models.providers.${MANAGED_PROVIDER_ID}.apiKey must remain the non-secret placeholder 'unused'") + +primary = cfg.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") +expected_primary = "${MANAGED_PROVIDER_ID}/" + model +if primary != expected_primary: + die("agents.defaults.model.primary is %r; expected %r" % (primary, expected_primary)) + +print("OPENCLAW_CONFIG_OK") +PYCFG + +payload_file="$(mktemp)" +response_file="$(mktemp)" +error_file="$(mktemp)" +trap 'rm -f "$payload_file" "$response_file" "$error_file"' EXIT + +python3 - "$MODEL" >"$payload_file" <<'PYPAYLOAD' +import json +import sys + +model = sys.argv[1] +print(json.dumps({ + "model": model, + "messages": [ + {"role": "user", "content": "Reply with exactly: PONG"} + ], + "max_tokens": 32, +})) +PYPAYLOAD + +curl -sS --connect-timeout 10 --max-time 60 \ + "${INFERENCE_ROUTE_URL}/chat/completions" \ + -H "Content-Type: application/json" \ + -d "@$payload_file" >"$response_file" 2>"$error_file" || { + rc=$? + printf 'curl exit %s: ' "$rc" >&2 + cat "$error_file" >&2 + exit "$rc" +} + +python3 - "$response_file" <<'PYRESP' +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +except Exception as exc: + body = "" + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + body = f.read(1000) + except Exception: + pass + print("inference.local returned non-JSON response: %s; body=%s" % (exc, body), file=sys.stderr) + sys.exit(1) + +content = ( + data.get("choices", [{}])[0] + .get("message", {}) + .get("content") +) +if not isinstance(content, str) or not content.strip(): + print("inference.local response did not contain choices[0].message.content: %s" % json.dumps(data)[:1000], file=sys.stderr) + sys.exit(1) + +print("INFERENCE_SMOKE_OK " + content.strip()[:200]) +PYRESP +`.trim(); +} + +export function buildCompatibleEndpointSandboxSmokeCommand(model: string): string { + const script = buildCompatibleEndpointSandboxSmokeScript(model); + const encoded = Buffer.from(script, "utf8").toString("base64"); + return [ + 'tmp="$(mktemp)"', + 'trap \'rm -f "$tmp"\' EXIT', + `python3 -c 'import base64, pathlib, sys; pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.argv[2]))' "$tmp" ${shellQuote(encoded)}`, + 'sh "$tmp"', + ].join("; "); +} From 05d10417ac3d9f1afc98f33e7c6d6130f7d1fd18 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 16:58:51 -0700 Subject: [PATCH 04/12] refactor(onboard): extract selection drift helpers --- src/lib/onboard.ts | 111 +-------------------- src/lib/onboard/selection-drift.test.ts | 115 +++++++++++++++++++++ src/lib/onboard/selection-drift.ts | 127 ++++++++++++++++++++++++ test/onboard.test.ts | 8 +- 4 files changed, 253 insertions(+), 108 deletions(-) create mode 100644 src/lib/onboard/selection-drift.test.ts create mode 100644 src/lib/onboard/selection-drift.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8f9c50cc021..e384f78fcff 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -29,6 +29,9 @@ const { const { prepareInitialSandboxCreatePolicy, }: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); +const { + getSelectionDrift, +}: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -326,6 +329,7 @@ import type { TierDefinition, TierPreset } from "./policy/tiers"; import type { SandboxCreateFailure, ValidationClassification } from "./validation"; import type { ProbeRecovery } from "./validation-recovery"; import type { WebSearchConfig } from "./inference/web-search"; +import type { SelectionDrift } from "./onboard/selection-drift"; const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; @@ -1713,15 +1717,6 @@ type EndpointValidationResult = | { ok: true; api: string | null; retry?: undefined } | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; -type SelectionDrift = { - changed: boolean; - providerChanged: boolean; - modelChanged: boolean; - existingProvider: string | null; - existingModel: string | null; - unknown: boolean; -}; - function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]) { const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); // upsertMessagingProviders process.exits on failure, so reaching this @@ -1952,102 +1947,6 @@ function pruneStaleSandboxEntry(sandboxName: string): boolean { return liveExists; } -function findSelectionConfigPath(dir: string): string | null { - if (!dir || !fs.existsSync(dir)) return null; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const found = findSelectionConfigPath(fullPath); - if (found) return found; - continue; - } - if (entry.name === "config.json") { - return fullPath; - } - } - return null; -} - -function readSandboxSelectionConfig(sandboxName: string): ProviderSelectionConfig | null { - if (!sandboxName) return null; - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-")); - try { - const result = runOpenshell( - [ - "sandbox", - "download", - sandboxName, - "/sandbox/.nemoclaw/config.json", - `${tmpDir}${path.sep}`, - ], - { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, - ); - if (result.status !== 0) return null; - const configPath = findSelectionConfigPath(tmpDir); - if (!configPath) return null; - try { - const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")); - return parsed && typeof parsed === "object" ? parsed : null; - } catch { - return null; - } - } catch { - return null; - } finally { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - } -} - -function getSelectionDrift( - sandboxName: string, - requestedProvider: string | null, - requestedModel: string | null, -): SelectionDrift { - const existing = readSandboxSelectionConfig(sandboxName); - if (!existing) { - return { - changed: true, - providerChanged: false, - modelChanged: false, - existingProvider: null, - existingModel: null, - unknown: true, - }; - } - - const existingProvider = typeof existing.provider === "string" ? existing.provider : null; - const existingModel = typeof existing.model === "string" ? existing.model : null; - if (!existingProvider || !existingModel) { - return { - changed: true, - providerChanged: false, - modelChanged: false, - existingProvider, - existingModel, - unknown: true, - }; - } - - const providerChanged = Boolean( - existingProvider && requestedProvider && existingProvider !== requestedProvider, - ); - const modelChanged = Boolean(existingModel && requestedModel && existingModel !== requestedModel); - - return { - changed: providerChanged || modelChanged, - providerChanged, - modelChanged, - existingProvider, - existingModel, - unknown: false, - }; -} - async function confirmRecreateForSelectionDrift( sandboxName: string, drift: SelectionDrift, @@ -4760,7 +4659,7 @@ async function createSandbox( const needsProviderMigration = hasMessagingTokens && messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); - const selectionDrift = getSelectionDrift(sandboxName, provider, model); + const selectionDrift = getSelectionDrift(sandboxName, provider, model, { runOpenshell }); const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown; // Detect whether any messaging credential has been rotated since the diff --git a/src/lib/onboard/selection-drift.test.ts b/src/lib/onboard/selection-drift.test.ts new file mode 100644 index 00000000000..e0e23c5b097 --- /dev/null +++ b/src/lib/onboard/selection-drift.test.ts @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + findSelectionConfigPath, + getSelectionDrift, + readSandboxSelectionConfig, +} from "./selection-drift"; + +const tmpRoots: string[] = []; + +function tmpRoot(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-test-")); + tmpRoots.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("selection drift helpers", () => { + it("finds nested config.json files", () => { + const root = tmpRoot(); + const nested = path.join(root, "sandbox", ".nemoclaw"); + fs.mkdirSync(nested, { recursive: true }); + const configPath = path.join(nested, "config.json"); + fs.writeFileSync(configPath, "{}", "utf-8"); + + expect(findSelectionConfigPath(root)).toBe(configPath); + }); + + it("returns null when the sandbox download fails", () => { + const runOpenshell = vi.fn(() => ({ status: 1 })); + + expect(readSandboxSelectionConfig("alpha", { runOpenshell })).toBeNull(); + expect(runOpenshell).toHaveBeenCalledWith( + [ + "sandbox", + "download", + "alpha", + "/sandbox/.nemoclaw/config.json", + expect.stringMatching(/nemoclaw-selection-.*\/$/), + ], + { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, + ); + }); + + it("reads a downloaded selection config and cleans up the temp directory", () => { + let downloadedParent: string | null = null; + const runOpenshell = vi.fn((args: string[]) => { + downloadedParent = args[4]; + const targetDir = path.join(String(downloadedParent), "nested"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync( + path.join(targetDir, "config.json"), + JSON.stringify({ provider: "compatible-endpoint", model: "model-a" }), + "utf-8", + ); + return { status: 0 }; + }); + + expect(readSandboxSelectionConfig("alpha", { runOpenshell })).toEqual({ + provider: "compatible-endpoint", + model: "model-a", + }); + expect(downloadedParent).not.toBeNull(); + expect(fs.existsSync(String(downloadedParent))).toBe(false); + }); + + it("reports unknown drift when no readable selection config exists", () => { + expect( + getSelectionDrift("alpha", "compatible-endpoint", "model-a", { + runOpenshell: () => ({ status: 1 }), + }), + ).toEqual({ + changed: true, + providerChanged: false, + modelChanged: false, + existingProvider: null, + existingModel: null, + unknown: true, + }); + }); + + it("reports provider and model drift from the downloaded selection config", () => { + const runOpenshell = vi.fn((args: string[]) => { + const targetDir = String(args[4]); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync( + path.join(targetDir, "config.json"), + JSON.stringify({ provider: "old-provider", model: "old-model" }), + "utf-8", + ); + return { status: 0 }; + }); + + expect(getSelectionDrift("alpha", "new-provider", "new-model", { runOpenshell })).toEqual({ + changed: true, + providerChanged: true, + modelChanged: true, + existingProvider: "old-provider", + existingModel: "old-model", + unknown: false, + }); + }); +}); diff --git a/src/lib/onboard/selection-drift.ts b/src/lib/onboard/selection-drift.ts new file mode 100644 index 00000000000..1809595c6f0 --- /dev/null +++ b/src/lib/onboard/selection-drift.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { ProviderSelectionConfig } from "../inference/config"; + +export type SelectionDrift = { + changed: boolean; + providerChanged: boolean; + modelChanged: boolean; + existingProvider: string | null; + existingModel: string | null; + unknown: boolean; +}; + +type RunOpenshellForSelection = ( + args: string[], + opts: { ignoreError: true; stdio: ["ignore", "ignore", "ignore"] }, +) => { status: number | null }; + +export type SelectionConfigReadDeps = { + runOpenshell: RunOpenshellForSelection; + tmpDir?: string; +}; + +export function findSelectionConfigPath(dir: string): string | null { + if (!dir || !fs.existsSync(dir)) return null; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = findSelectionConfigPath(fullPath); + if (found) return found; + continue; + } + if (entry.name === "config.json") { + return fullPath; + } + } + return null; +} + +export function readSandboxSelectionConfig( + sandboxName: string, + deps: SelectionConfigReadDeps, +): ProviderSelectionConfig | null { + if (!sandboxName) return null; + const tmpDir = fs.mkdtempSync(path.join(deps.tmpDir ?? os.tmpdir(), "nemoclaw-selection-")); + try { + const result = deps.runOpenshell( + [ + "sandbox", + "download", + sandboxName, + "/sandbox/.nemoclaw/config.json", + `${tmpDir}${path.sep}`, + ], + { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, + ); + if (result.status !== 0) return null; + const configPath = findSelectionConfigPath(tmpDir); + if (!configPath) return null; + try { + const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } + } catch { + return null; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} + +export function getSelectionDrift( + sandboxName: string, + requestedProvider: string | null, + requestedModel: string | null, + deps: SelectionConfigReadDeps, +): SelectionDrift { + const existing = readSandboxSelectionConfig(sandboxName, deps); + if (!existing) { + return { + changed: true, + providerChanged: false, + modelChanged: false, + existingProvider: null, + existingModel: null, + unknown: true, + }; + } + + const existingProvider = typeof existing.provider === "string" ? existing.provider : null; + const existingModel = typeof existing.model === "string" ? existing.model : null; + if (!existingProvider || !existingModel) { + return { + changed: true, + providerChanged: false, + modelChanged: false, + existingProvider, + existingModel, + unknown: true, + }; + } + + const providerChanged = Boolean( + existingProvider && requestedProvider && existingProvider !== requestedProvider, + ); + const modelChanged = Boolean(existingModel && requestedModel && existingModel !== requestedModel); + + return { + changed: providerChanged || modelChanged, + providerChanged, + modelChanged, + existingProvider, + existingModel, + unknown: false, + }; +} diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 30be6b8fd3a..ebb8f80f886 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -6307,15 +6307,19 @@ const { createSandbox } = require(${onboardPath}); path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), "utf-8", ); + const selectionSource = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard", "selection-drift.ts"), + "utf-8", + ); assert.match( source, - /const selectionDrift = getSelectionDrift\(sandboxName, provider, model\);/, + /const selectionDrift = getSelectionDrift\(sandboxName, provider, model, \{ runOpenshell \}\);/, ); assert.match( source, /const confirmedSelectionDrift = selectionDrift\.changed && !selectionDrift\.unknown;/, ); - assert.match(source, /unknown:\s*true/); + assert.match(selectionSource, /unknown:\s*true/); assert.match(source, /if \(confirmedSelectionDrift\)/); assert.match(source, /Recreating sandbox due to provider\/model drift/); assert.match( From 0055de026fe99d4e0d079a010b0fe65860ed4c5a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 17:13:27 -0700 Subject: [PATCH 05/12] refactor(onboard): extract config sync helpers --- src/lib/onboard.ts | 36 ++--------------- src/lib/onboard/config-sync.test.ts | 60 +++++++++++++++++++++++++++++ src/lib/onboard/config-sync.ts | 39 +++++++++++++++++++ 3 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 src/lib/onboard/config-sync.test.ts create mode 100644 src/lib/onboard/config-sync.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e384f78fcff..6fd40428503 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -26,6 +26,10 @@ const { shouldRunCompatibleEndpointSandboxSmoke, spawnOutputToString, }: typeof import("./onboard/compatible-endpoint-smoke") = require("./onboard/compatible-endpoint-smoke"); +const { + buildSandboxConfigSyncScript, + writeSandboxConfigSyncFile, +}: typeof import("./onboard/config-sync") = require("./onboard/config-sync"); const { prepareInitialSandboxCreatePolicy, }: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); @@ -1974,42 +1978,10 @@ async function confirmRecreateForSelectionDrift( return isAffirmativeAnswer(answer); } -function buildSandboxConfigSyncScript(selectionConfig: ProviderSelectionConfig): string { - // Do not rewrite openclaw.json at runtime. Model routing is handled by the - // host-side gateway (`openshell inference set` in Step 5), not from inside - // the sandbox. We write the NemoClaw selection config and normalize the - // mutable-default OpenClaw config permissions after the gateway has had a - // chance to perform its own startup initialization. - return ` -set -euo pipefail -mkdir -p ~/.nemoclaw -cat > ~/.nemoclaw/config.json <<'EOF_NEMOCLAW_CFG' -${JSON.stringify(selectionConfig, null, 2)} -EOF_NEMOCLAW_CFG -config_dir=/sandbox/.openclaw -if [ -d "$config_dir" ]; then - config_dir_owner="$(stat -c '%U' "$config_dir" 2>/dev/null || echo unknown)" - if [ "$config_dir_owner" != "root" ]; then - chmod -R g+rwX,o-rwx "$config_dir" 2>/dev/null || true - find "$config_dir" -type d -exec chmod g+s {} + 2>/dev/null || true - chmod 2770 "$config_dir" 2>/dev/null || true - chmod 660 "$config_dir/openclaw.json" "$config_dir/.config-hash" 2>/dev/null || true - fi -fi -exit -`.trim(); -} - function isOpenclawReady(sandboxName: string): boolean { return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName)); } -function writeSandboxConfigSyncFile(script: string): string { - const scriptFile = secureTempFile("nemoclaw-sync", ".sh"); - fs.writeFileSync(scriptFile, `${script}\n`, { mode: 0o600 }); - return scriptFile; -} - function encodeDockerJsonArg(value: LooseValue): string { return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); } diff --git a/src/lib/onboard/config-sync.test.ts b/src/lib/onboard/config-sync.test.ts new file mode 100644 index 00000000000..fdcd7e22b2a --- /dev/null +++ b/src/lib/onboard/config-sync.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { buildSandboxConfigSyncScript, writeSandboxConfigSyncFile } from "./config-sync"; + +describe("sandbox config sync helpers", () => { + it("builds a sandbox sync script that records provider selection without rewriting OpenClaw config", () => { + const script = buildSandboxConfigSyncScript({ + endpointType: "custom", + endpointUrl: "https://inference.local/v1", + ncpPartner: null, + model: "nemotron-3-nano:30b", + profile: "inference-local", + credentialEnv: "OPENAI_API_KEY", + provider: "compatible-endpoint", + providerLabel: "Other OpenAI-compatible endpoint", + }); + + expect(script).toMatch(/cat > ~\/\.nemoclaw\/config\.json/); + expect(script).toContain('"model": "nemotron-3-nano:30b"'); + expect(script).toContain('"credentialEnv": "OPENAI_API_KEY"'); + expect(script).not.toMatch(/cat > ~\/\.openclaw\/openclaw\.json/); + expect(script).not.toMatch(/openclaw models set/); + expect(script).toMatch(/config_dir=\/sandbox\/\.openclaw/); + expect(script).toMatch(/chmod -R g\+rwX,o-rwx "\$config_dir"/); + expect(script).toMatch(/find "\$config_dir" -type d -exec chmod g\+s \{\} \+/); + expect(script).toMatch(/chmod 2770 "\$config_dir"/); + expect(script).toMatch( + /chmod 660 "\$config_dir\/openclaw\.json" "\$config_dir\/\.config-hash"/, + ); + expect(script).toMatch(/\[ "\$config_dir_owner" != "root" \]/); + expect(script).toMatch(/^\s*exit$/m); + }); + + it("writes sandbox sync scripts to a mkdtemp-backed temp file", () => { + const scriptFile = writeSandboxConfigSyncFile("echo test"); + try { + expect(scriptFile).toMatch(/nemoclaw-sync.*\.sh$/); + expect(fs.readFileSync(scriptFile, "utf8")).toBe("echo test\n"); + const parentDir = path.dirname(scriptFile); + expect(parentDir).not.toBe(os.tmpdir()); + expect(parentDir).toContain("nemoclaw-sync"); + if (process.platform !== "win32") { + const stat = fs.statSync(scriptFile); + expect(stat.mode & 0o777).toBe(0o600); + } + } finally { + const parentDir = path.dirname(scriptFile); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith("nemoclaw-sync-")) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } + } + }); +}); diff --git a/src/lib/onboard/config-sync.ts b/src/lib/onboard/config-sync.ts new file mode 100644 index 00000000000..2ec8cfcde28 --- /dev/null +++ b/src/lib/onboard/config-sync.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import type { ProviderSelectionConfig } from "../inference/config"; +import { secureTempFile } from "./temp-files"; + +export function buildSandboxConfigSyncScript(selectionConfig: ProviderSelectionConfig): string { + // Do not rewrite openclaw.json at runtime. Model routing is handled by the + // host-side gateway (`openshell inference set` in Step 5), not from inside + // the sandbox. We write the NemoClaw selection config and normalize the + // mutable-default OpenClaw config permissions after the gateway has had a + // chance to perform its own startup initialization. + return ` +set -euo pipefail +mkdir -p ~/.nemoclaw +cat > ~/.nemoclaw/config.json <<'EOF_NEMOCLAW_CFG' +${JSON.stringify(selectionConfig, null, 2)} +EOF_NEMOCLAW_CFG +config_dir=/sandbox/.openclaw +if [ -d "$config_dir" ]; then + config_dir_owner="$(stat -c '%U' "$config_dir" 2>/dev/null || echo unknown)" + if [ "$config_dir_owner" != "root" ]; then + chmod -R g+rwX,o-rwx "$config_dir" 2>/dev/null || true + find "$config_dir" -type d -exec chmod g+s {} + 2>/dev/null || true + chmod 2770 "$config_dir" 2>/dev/null || true + chmod 660 "$config_dir/openclaw.json" "$config_dir/.config-hash" 2>/dev/null || true + fi +fi +exit +`.trim(); +} + +export function writeSandboxConfigSyncFile(script: string): string { + const scriptFile = secureTempFile("nemoclaw-sync", ".sh"); + fs.writeFileSync(scriptFile, `${script}\n`, { mode: 0o600 }); + return scriptFile; +} From 0b5c164277d016f61a3900a963a250e6f0cb7261 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 17:44:00 -0700 Subject: [PATCH 06/12] refactor(onboard): extract dockerfile patch helpers --- src/lib/onboard.ts | 202 +----------------- src/lib/onboard/dockerfile-patch.test.ts | 108 ++++++++++ src/lib/onboard/dockerfile-patch.ts | 249 +++++++++++++++++++++++ 3 files changed, 362 insertions(+), 197 deletions(-) create mode 100644 src/lib/onboard/dockerfile-patch.test.ts create mode 100644 src/lib/onboard/dockerfile-patch.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6fd40428503..23570c452b5 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -30,6 +30,11 @@ const { buildSandboxConfigSyncScript, writeSandboxConfigSyncFile, }: typeof import("./onboard/config-sync") = require("./onboard/config-sync"); +const { + isValidProxyHost, + isValidProxyPort, + patchStagedDockerfile, +}: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { prepareInitialSandboxCreatePolicy, }: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); @@ -1982,10 +1987,6 @@ function isOpenclawReady(sandboxName: string): boolean { return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName)); } -function encodeDockerJsonArg(value: LooseValue): string { - return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); -} - function isAffirmativeAnswer(value: string | null | undefined): boolean { return ["y", "yes"].includes( String(value || "") @@ -2269,199 +2270,6 @@ function verifyWebSearchInsideSandbox( // getSandboxInferenceConfig — moved to onboard-providers.ts -// Shared validators for NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT. -// Both `patchStagedDockerfile()` (build-time Dockerfile ARG override) and -// `createSandbox()` (runtime sandbox env whitelist) must reject the same -// inputs, otherwise the build and runtime paths can diverge — e.g. a -// build-time-accepted value silently no-ops at runtime, leaving the -// container running with the default proxy. Hostname regex deliberately -// excludes `:` so raw IPv6 literals are rejected: the runtime -// `http://${HOST}:${PORT}` template does not bracket them and would -// produce a malformed URL. Port is range-checked because a 5-digit -// length filter alone would accept out-of-range values like 70000. -const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; -function isValidProxyHost(value: string): boolean { - return PROXY_HOST_RE.test(value); -} -function isValidProxyPort(value: string): boolean { - if (!/^[0-9]{1,5}$/.test(value)) return false; - const port = Number(value); - return port >= 1 && port <= 65535; -} - -function patchStagedDockerfile( - dockerfilePath: string, - model: string, - chatUiUrl: string, - buildId = String(Date.now()), - provider: string | null = null, - preferredInferenceApi: string | null = null, - webSearchConfig: WebSearchConfig | null = null, - messagingChannels: string[] = [], - messagingAllowedIds: LooseObject = {}, - discordGuilds: LooseObject = {}, - baseImageRef: string | null = null, - telegramConfig: LooseObject = {}, -) { - const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = - getSandboxInferenceConfig(model, provider, preferredInferenceApi); - let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); - // Pin the base image to a specific digest when available (#1904). - // The ref must come from pullAndResolveBaseImageDigest() — never from - // blueprint.yaml, whose digest belongs to a different registry. - // Only rewrite when the current value already points at our sandbox-base - // image — custom --from Dockerfiles may use a different base. - if (baseImageRef) { - dockerfile = dockerfile.replace( - /^ARG BASE_IMAGE=(.*)$/m, - (line: string, currentValue: string) => { - const trimmed = String(currentValue).trim(); - if ( - trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || - trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) - ) { - return `ARG BASE_IMAGE=${baseImageRef}`; - } - return line; - }, - ); - } - dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, - ); - dockerfile = dockerfile.replace(/^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}`); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}`, - ); - // Honor NEMOCLAW_CONTEXT_WINDOW / NEMOCLAW_MAX_TOKENS / NEMOCLAW_REASONING - // so the user can tune model metadata without editing the Dockerfile. - const POSITIVE_INT_RE = /^[1-9][0-9]*$/; - const contextWindow = process.env.NEMOCLAW_CONTEXT_WINDOW; - if (contextWindow && POSITIVE_INT_RE.test(contextWindow)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_CONTEXT_WINDOW=.*$/m, - `ARG NEMOCLAW_CONTEXT_WINDOW=${contextWindow}`, - ); - } - const maxTokens = process.env.NEMOCLAW_MAX_TOKENS; - if (maxTokens && POSITIVE_INT_RE.test(maxTokens)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MAX_TOKENS=.*$/m, - `ARG NEMOCLAW_MAX_TOKENS=${maxTokens}`, - ); - } - const reasoning = process.env.NEMOCLAW_REASONING; - if (reasoning === "true" || reasoning === "false") { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_REASONING=.*$/m, - `ARG NEMOCLAW_REASONING=${reasoning}`, - ); - } - // Honor NEMOCLAW_INFERENCE_INPUTS for vision-capable models. OpenClaw's - // model schema currently accepts "text" and "image" only, so validate - // strictly against that vocabulary. Adding modalities to OpenClaw later - // only requires widening this regex. See #2421. - const inferenceInputs = process.env.NEMOCLAW_INFERENCE_INPUTS; - if (inferenceInputs && /^(text|image)(,(text|image))*$/.test(inferenceInputs)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_INPUTS=.*$/m, - `ARG NEMOCLAW_INFERENCE_INPUTS=${inferenceInputs}`, - ); - } - // NEMOCLAW_AGENT_TIMEOUT — override agents.defaults.timeoutSeconds at build - // time. Lets users increase the per-request inference timeout without - // editing the Dockerfile. Ref: issue #2281 - const agentTimeout = process.env.NEMOCLAW_AGENT_TIMEOUT; - if (agentTimeout && POSITIVE_INT_RE.test(agentTimeout)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_AGENT_TIMEOUT=.*$/m, - `ARG NEMOCLAW_AGENT_TIMEOUT=${agentTimeout}`, - ); - } - // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every - // at build time. Accepts Go-style durations with a required s/m/h suffix - // ("30m", "1h"); "0m" disables heartbeat. Ref: issue #2880 - const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; - if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, - `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, - ); - } - // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host - // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. - // Without this, the host export is silently dropped at image build time and - // the sandbox falls back to the default 10.200.0.1:3128 proxy. See #1409. - const proxyHostEnv = process.env.NEMOCLAW_PROXY_HOST; - if (proxyHostEnv && isValidProxyHost(proxyHostEnv)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PROXY_HOST=.*$/m, - `ARG NEMOCLAW_PROXY_HOST=${proxyHostEnv}`, - ); - } - const proxyPortEnv = process.env.NEMOCLAW_PROXY_PORT; - if (proxyPortEnv && isValidProxyPort(proxyPortEnv)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PROXY_PORT=.*$/m, - `ARG NEMOCLAW_PROXY_PORT=${proxyPortEnv}`, - ); - } - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, - `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, - ); - // Onboard flow expects immediate dashboard access without device pairing, - // so disable device auth for images built during onboard (see #1217). - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, - `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`, - ); - if (messagingChannels.length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeDockerJsonArg(messagingChannels)}`, - ); - } - if (Object.keys(messagingAllowedIds).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeDockerJsonArg(messagingAllowedIds)}`, - ); - } - if (Object.keys(discordGuilds).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, - `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, - ); - } - if (telegramConfig && Object.keys(telegramConfig).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`, - ); - } - fs.writeFileSync(dockerfilePath, dockerfile); -} - // Inference probes — moved to inference/onboard-probes.ts const { hasResponsesToolCall, diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts new file mode 100644 index 00000000000..bab97d9aebb --- /dev/null +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + encodeDockerJsonArg, + isValidProxyHost, + isValidProxyPort, + patchStagedDockerfile, +} from "./dockerfile-patch"; + +const tmpRoots: string[] = []; + +function dockerfileWith(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-patch-test-")); + tmpRoots.push(dir); + const file = path.join(dir, "Dockerfile"); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + delete process.env.NEMOCLAW_PROXY_HOST; + delete process.env.NEMOCLAW_PROXY_PORT; +}); + +describe("dockerfile patch helpers", () => { + it("encodes Docker JSON ARG values as base64 JSON", () => { + expect(Buffer.from(encodeDockerJsonArg({ supportsStore: false }), "base64").toString("utf-8")).toBe( + JSON.stringify({ supportsStore: false }), + ); + expect(Buffer.from(encodeDockerJsonArg(null), "base64").toString("utf-8")).toBe("{}"); + }); + + it("validates proxy host and port values", () => { + expect(isValidProxyHost("host.docker.internal")).toBe(true); + expect(isValidProxyHost("10.200.0.1")).toBe(true); + expect(isValidProxyHost("bad:ipv6::host")).toBe(false); + expect(isValidProxyPort("1")).toBe(true); + expect(isValidProxyPort("65535")).toBe(true); + expect(isValidProxyPort("0")).toBe(false); + expect(isValidProxyPort("70000")).toBe(false); + }); + + it("patches base image, inference, proxy, and messaging args", () => { + process.env.NEMOCLAW_PROXY_HOST = "host.docker.internal"; + process.env.NEMOCLAW_PROXY_PORT = "3128"; + const dockerfilePath = dockerfileWith( + [ + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_PROXY_HOST=old", + "ARG NEMOCLAW_PROXY_PORT=old", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=old", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=old", + "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + { fetchEnabled: true }, + ["telegram"], + { telegram: ["123"] }, + { discord: ["456"] }, + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc", + { requireMention: true }, + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).toContain("ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc"); + expect(patched).toContain("ARG NEMOCLAW_MODEL=custom-model"); + expect(patched).toContain("ARG NEMOCLAW_PROVIDER_KEY=inference"); + expect(patched).toContain("ARG NEMOCLAW_PRIMARY_MODEL_REF=inference/custom-model"); + expect(patched).toContain("ARG CHAT_UI_URL=https://chat.example"); + expect(patched).toContain("ARG NEMOCLAW_INFERENCE_COMPAT_B64="); + expect(patched).toContain("ARG NEMOCLAW_BUILD_ID=build-1"); + expect(patched).toContain("ARG NEMOCLAW_PROXY_HOST=host.docker.internal"); + expect(patched).toContain("ARG NEMOCLAW_PROXY_PORT=3128"); + expect(patched).toContain("ARG NEMOCLAW_WEB_SEARCH_ENABLED=1"); + expect(patched).toContain("ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1"); + expect(patched).not.toContain("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old"); + expect(patched).not.toContain("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old"); + }); +}); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts new file mode 100644 index 00000000000..c44b09bbd68 --- /dev/null +++ b/src/lib/onboard/dockerfile-patch.ts @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import type { WebSearchConfig } from "../inference/web-search"; + +type SandboxInferenceConfig = { + providerKey: string; + primaryModelRef: string; + inferenceBaseUrl: string; + inferenceApi: string; + inferenceCompat: unknown; +}; + +function getDockerfileSandboxInferenceConfig( + model: string, + provider: string | null = null, + preferredInferenceApi: string | null = null, +): SandboxInferenceConfig { + let providerKey; + let primaryModelRef; + let inferenceBaseUrl = "https://inference.local/v1"; + let inferenceApi = preferredInferenceApi || "openai-completions"; + let inferenceCompat = null; + + switch (provider) { + case "openai-api": + providerKey = "openai"; + primaryModelRef = `openai/${model}`; + break; + case "anthropic-prod": + case "compatible-anthropic-endpoint": + providerKey = "anthropic"; + primaryModelRef = `anthropic/${model}`; + inferenceBaseUrl = "https://inference.local"; + inferenceApi = "anthropic-messages"; + break; + case "gemini-api": + case "compatible-endpoint": + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "nvidia-router": + case "nvidia-prod": + case "nvidia-nim": + default: + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + break; + } + + return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; +} + +const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; +const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; +const POSITIVE_INT_RE = /^[1-9][0-9]*$/; + +type LooseObject = Record; + +export function encodeDockerJsonArg(value: unknown): string { + return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); +} + +export function isValidProxyHost(value: string): boolean { + return PROXY_HOST_RE.test(value); +} + +export function isValidProxyPort(value: string): boolean { + if (!/^[0-9]{1,5}$/.test(value)) return false; + const port = Number(value); + return port >= 1 && port <= 65535; +} + +export function patchStagedDockerfile( + dockerfilePath: string, + model: string, + chatUiUrl: string, + buildId = String(Date.now()), + provider: string | null = null, + preferredInferenceApi: string | null = null, + webSearchConfig: WebSearchConfig | null = null, + messagingChannels: string[] = [], + messagingAllowedIds: LooseObject = {}, + discordGuilds: LooseObject = {}, + baseImageRef: string | null = null, + telegramConfig: LooseObject = {}, +): void { + const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = + getDockerfileSandboxInferenceConfig(model, provider, preferredInferenceApi); + let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); + // Pin the base image to a specific digest when available (#1904). + // The ref must come from pullAndResolveBaseImageDigest() — never from + // blueprint.yaml, whose digest belongs to a different registry. + // Only rewrite when the current value already points at our sandbox-base + // image — custom --from Dockerfiles may use a different base. + if (baseImageRef) { + dockerfile = dockerfile.replace( + /^ARG BASE_IMAGE=(.*)$/m, + (line: string, currentValue: string) => { + const trimmed = String(currentValue).trim(); + if ( + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) + ) { + return `ARG BASE_IMAGE=${baseImageRef}`; + } + return line; + }, + ); + } + dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, + `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, + ); + dockerfile = dockerfile.replace(/^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}`); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, + `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_API=.*$/m, + `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_BUILD_ID=.*$/m, + `ARG NEMOCLAW_BUILD_ID=${buildId}`, + ); + // Honor NEMOCLAW_CONTEXT_WINDOW / NEMOCLAW_MAX_TOKENS / NEMOCLAW_REASONING + // so the user can tune model metadata without editing the Dockerfile. + const contextWindow = process.env.NEMOCLAW_CONTEXT_WINDOW; + if (contextWindow && POSITIVE_INT_RE.test(contextWindow)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_CONTEXT_WINDOW=.*$/m, + `ARG NEMOCLAW_CONTEXT_WINDOW=${contextWindow}`, + ); + } + const maxTokens = process.env.NEMOCLAW_MAX_TOKENS; + if (maxTokens && POSITIVE_INT_RE.test(maxTokens)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MAX_TOKENS=.*$/m, + `ARG NEMOCLAW_MAX_TOKENS=${maxTokens}`, + ); + } + const reasoning = process.env.NEMOCLAW_REASONING; + if (reasoning === "true" || reasoning === "false") { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_REASONING=.*$/m, + `ARG NEMOCLAW_REASONING=${reasoning}`, + ); + } + // Honor NEMOCLAW_INFERENCE_INPUTS for vision-capable models. OpenClaw's + // model schema currently accepts "text" and "image" only, so validate + // strictly against that vocabulary. Adding modalities to OpenClaw later + // only requires widening this regex. See #2421. + const inferenceInputs = process.env.NEMOCLAW_INFERENCE_INPUTS; + if (inferenceInputs && /^(text|image)(,(text|image))*$/.test(inferenceInputs)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_INPUTS=.*$/m, + `ARG NEMOCLAW_INFERENCE_INPUTS=${inferenceInputs}`, + ); + } + // NEMOCLAW_AGENT_TIMEOUT — override agents.defaults.timeoutSeconds at build + // time. Lets users increase the per-request inference timeout without + // editing the Dockerfile. Ref: issue #2281 + const agentTimeout = process.env.NEMOCLAW_AGENT_TIMEOUT; + if (agentTimeout && POSITIVE_INT_RE.test(agentTimeout)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_AGENT_TIMEOUT=.*$/m, + `ARG NEMOCLAW_AGENT_TIMEOUT=${agentTimeout}`, + ); + } + // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every + // at build time. Accepts Go-style durations with a required s/m/h suffix + // ("30m", "1h"); "0m" disables heartbeat. Ref: issue #2880 + const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, + `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, + ); + } + // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host + // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. + // Without this, the host export is silently dropped at image build time and + // the sandbox falls back to the default 10.200.0.1:3128 proxy. See #1409. + const proxyHostEnv = process.env.NEMOCLAW_PROXY_HOST; + if (proxyHostEnv && isValidProxyHost(proxyHostEnv)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROXY_HOST=.*$/m, + `ARG NEMOCLAW_PROXY_HOST=${proxyHostEnv}`, + ); + } + const proxyPortEnv = process.env.NEMOCLAW_PROXY_PORT; + if (proxyPortEnv && isValidProxyPort(proxyPortEnv)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROXY_PORT=.*$/m, + `ARG NEMOCLAW_PROXY_PORT=${proxyPortEnv}`, + ); + } + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, + `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, + ); + // Onboard flow expects immediate dashboard access without device pairing, + // so disable device auth for images built during onboard (see #1217). + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, + `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`, + ); + if (messagingChannels.length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, + `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeDockerJsonArg(messagingChannels)}`, + ); + } + if (Object.keys(messagingAllowedIds).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, + `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeDockerJsonArg(messagingAllowedIds)}`, + ); + } + if (Object.keys(discordGuilds).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, + `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, + ); + } + if (telegramConfig && Object.keys(telegramConfig).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, + `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`, + ); + } + fs.writeFileSync(dockerfilePath, dockerfile); +} From bd0ffe567830dd7b367cd43d7f12e904235cf0c3 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 8 May 2026 20:51:54 -0700 Subject: [PATCH 07/12] test(inference): expect Kimi status thinking flag --- src/lib/inference/health.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/inference/health.test.ts b/src/lib/inference/health.test.ts index fe6504094fb..7627b082a2f 100644 --- a/src/lib/inference/health.test.ts +++ b/src/lib/inference/health.test.ts @@ -242,6 +242,7 @@ describe("inference health", () => { model: "moonshotai/kimi-k2.6", messages: [{ role: "user", content: "Reply with exactly: OK" }], max_tokens: 8, + chat_template_kwargs: { thinking: false }, }); }); From 2cbb30213d498cf8d3c895b2d3e9ae6bbf0f87a7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 9 May 2026 02:31:55 -0700 Subject: [PATCH 08/12] test(policy): update tier onboarding policy import --- test/policy-tiers-onboard.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 3c128671245..4e7558cec4e 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -289,7 +289,7 @@ console.log = (...args) => lines.push(args.join(" ")); }); it("omits Brave from policy preset selection when web search is unsupported", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -330,7 +330,7 @@ console.log = () => {}; }); it("removes a previously-applied Brave preset when web search is unsupported", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -376,7 +376,7 @@ console.log = () => {}; }); it("clamps resumed policy presets to web-search-supported presets", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -416,7 +416,7 @@ console.log = () => {}; }); it("clamps an unsupported-only resumed policy preset list to empty", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -456,7 +456,7 @@ console.log = () => {}; }); it("preserves a resumed custom preset whose name matches an unsupported built-in", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", @@ -497,7 +497,7 @@ console.log = () => {}; }); it("preserves a non-interactive custom preset whose name matches an unsupported built-in", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script = buildPreamble({ tierEnv: "balanced", From 517721313b73c062c8c1b6686dc62978d6fb7b2d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 11 May 2026 15:29:07 -0700 Subject: [PATCH 09/12] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/nemoclaw.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 9848a2fc9a5..b21c208b813 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -41,7 +41,6 @@ import type { SandboxEntry } from "./lib/state/registry"; const nim = require("./lib/inference/nim"); const shields = require("./lib/shields"); const { parseGatewayInference } = require("./lib/inference/config"); -const policies = require("./lib/policy"); const { probeProviderHealth } = require("./lib/inference/health"); const { buildStatusCommandDeps } = require("./lib/status-command-deps"); const { help, version } = require("./lib/actions/root-help"); From 1fc9fc5be60cba76e59c91c046ffa8e60bf35f0a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 10:11:56 -0700 Subject: [PATCH 10/12] fix(onboard): fail fast in compatible endpoint smoke wrapper --- src/lib/onboard/compatible-endpoint-smoke.test.ts | 1 + src/lib/onboard/compatible-endpoint-smoke.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib/onboard/compatible-endpoint-smoke.test.ts b/src/lib/onboard/compatible-endpoint-smoke.test.ts index f1d052033de..dd57b579717 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.test.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.test.ts @@ -54,6 +54,7 @@ describe("compatible endpoint sandbox smoke helpers", () => { it("wraps the script as a base64 decoded temporary shell command", () => { const command = buildCompatibleEndpointSandboxSmokeCommand("nvidia/model"); + expect(command).toContain("set -eu"); expect(command).toContain("base64.b64decode"); expect(command).toContain('sh "$tmp"'); expect(command).toContain("trap"); diff --git a/src/lib/onboard/compatible-endpoint-smoke.ts b/src/lib/onboard/compatible-endpoint-smoke.ts index ad4eacb0b39..2740cc04586 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.ts @@ -139,6 +139,7 @@ export function buildCompatibleEndpointSandboxSmokeCommand(model: string): strin const script = buildCompatibleEndpointSandboxSmokeScript(model); const encoded = Buffer.from(script, "utf8").toString("base64"); return [ + "set -eu", 'tmp="$(mktemp)"', 'trap \'rm -f "$tmp"\' EXIT', `python3 -c 'import base64, pathlib, sys; pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.argv[2]))' "$tmp" ${shellQuote(encoded)}`, From b0347b10ddeeb42d21215d4a7f0485e36072de68 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 12 May 2026 17:35:52 -0700 Subject: [PATCH 11/12] fix(onboard): sanitize dockerfile arg replacements --- src/lib/onboard/dockerfile-patch.test.ts | 41 ++++++++++++++ src/lib/onboard/dockerfile-patch.ts | 68 +++++++++++++++--------- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 34bdc7706dd..55e5ffc588d 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -108,4 +108,45 @@ describe("dockerfile patch helpers", () => { expect(patched).not.toContain("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old"); expect(patched).not.toContain("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old"); }); + + it("strips CR/LF from Dockerfile ARG interpolations", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "model\nRUN touch /tmp/model-pwn", + "https://chat.example\r\nRUN touch /tmp/chat-pwn", + "build-1\nRUN touch /tmp/build-pwn", + "compatible-endpoint", + "openai-responses\nRUN touch /tmp/api-pwn", + null, + [], + {}, + {}, + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc\nRUN touch /tmp/base-pwn", + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).not.toMatch(/\r|\nRUN touch/); + expect(patched).toContain("ARG NEMOCLAW_MODEL=modelRUN touch /tmp/model-pwn"); + expect(patched).toContain("ARG CHAT_UI_URL=https://chat.exampleRUN touch /tmp/chat-pwn"); + expect(patched).toContain("ARG NEMOCLAW_BUILD_ID=build-1RUN touch /tmp/build-pwn"); + expect(patched).toContain("ARG NEMOCLAW_INFERENCE_API=openai-responsesRUN touch /tmp/api-pwn"); + expect(patched).toContain( + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abcRUN touch /tmp/base-pwn", + ); + }); }); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 5f460c32d7f..035b29eb4c8 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -66,6 +66,14 @@ export function encodeDockerJsonArg(value: unknown): string { return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); } +function sanitizeDockerArg(value: unknown): string { + return String(value ?? "").replace(/[\r\n]/g, ""); +} + +function encodeSanitizedDockerJsonArg(value: unknown): string { + return sanitizeDockerArg(encodeDockerJsonArg(value)); +} + export function isValidProxyHost(value: string): boolean { return PROXY_HOST_RE.test(value); } @@ -91,15 +99,17 @@ export function patchStagedDockerfile( telegramConfig: LooseObject = {}, darwinVmCompat = false, ): void { + const sanitizedModel = sanitizeDockerArg(model); const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = - getDockerfileSandboxInferenceConfig(model, provider, preferredInferenceApi); + getDockerfileSandboxInferenceConfig(sanitizedModel, provider, preferredInferenceApi); let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); // Pin the base image to a specific digest when available (#1904). // The ref must come from pullAndResolveBaseImageDigest() — never from // blueprint.yaml, whose digest belongs to a different registry. // Only rewrite when the current value already points at our sandbox-base // image — custom --from Dockerfiles may use a different base. - if (baseImageRef) { + const sanitizedBaseImageRef = baseImageRef ? sanitizeDockerArg(baseImageRef) : null; + if (sanitizedBaseImageRef) { dockerfile = dockerfile.replace( /^ARG BASE_IMAGE=(.*)$/m, (line: string, currentValue: string) => { @@ -108,41 +118,47 @@ export function patchStagedDockerfile( trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) ) { - return `ARG BASE_IMAGE=${baseImageRef}`; + return `ARG BASE_IMAGE=${sanitizedBaseImageRef}`; } return line; }, ); } - dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MODEL=.*$/m, + `ARG NEMOCLAW_MODEL=${sanitizedModel}`, + ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, + `ARG NEMOCLAW_PROVIDER_KEY=${sanitizeDockerArg(providerKey)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${sanitizeDockerArg(primaryModelRef)}`, + ); + dockerfile = dockerfile.replace( + /^ARG CHAT_UI_URL=.*$/m, + `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); - dockerfile = dockerfile.replace(/^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}`); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, + `ARG NEMOCLAW_INFERENCE_BASE_URL=${sanitizeDockerArg(inferenceBaseUrl)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, + `ARG NEMOCLAW_INFERENCE_API=${sanitizeDockerArg(inferenceApi)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeSanitizedDockerJsonArg(inferenceCompat)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}`, + `ARG NEMOCLAW_BUILD_ID=${sanitizeDockerArg(buildId)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_DARWIN_VM_COMPAT=.*$/m, - `ARG NEMOCLAW_DARWIN_VM_COMPAT=${darwinVmCompat ? "1" : "0"}`, + `ARG NEMOCLAW_DARWIN_VM_COMPAT=${sanitizeDockerArg(darwinVmCompat ? "1" : "0")}`, ); // Honor NEMOCLAW_CONTEXT_WINDOW / NEMOCLAW_MAX_TOKENS / NEMOCLAW_REASONING // so the user can tune model metadata without editing the Dockerfile. @@ -150,21 +166,21 @@ export function patchStagedDockerfile( if (contextWindow && POSITIVE_INT_RE.test(contextWindow)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_CONTEXT_WINDOW=.*$/m, - `ARG NEMOCLAW_CONTEXT_WINDOW=${contextWindow}`, + `ARG NEMOCLAW_CONTEXT_WINDOW=${sanitizeDockerArg(contextWindow)}`, ); } const maxTokens = process.env.NEMOCLAW_MAX_TOKENS; if (maxTokens && POSITIVE_INT_RE.test(maxTokens)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MAX_TOKENS=.*$/m, - `ARG NEMOCLAW_MAX_TOKENS=${maxTokens}`, + `ARG NEMOCLAW_MAX_TOKENS=${sanitizeDockerArg(maxTokens)}`, ); } const reasoning = process.env.NEMOCLAW_REASONING; if (reasoning === "true" || reasoning === "false") { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_REASONING=.*$/m, - `ARG NEMOCLAW_REASONING=${reasoning}`, + `ARG NEMOCLAW_REASONING=${sanitizeDockerArg(reasoning)}`, ); } // Honor NEMOCLAW_INFERENCE_INPUTS for vision-capable models. OpenClaw's @@ -175,7 +191,7 @@ export function patchStagedDockerfile( if (inferenceInputs && /^(text|image)(,(text|image))*$/.test(inferenceInputs)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_INPUTS=.*$/m, - `ARG NEMOCLAW_INFERENCE_INPUTS=${inferenceInputs}`, + `ARG NEMOCLAW_INFERENCE_INPUTS=${sanitizeDockerArg(inferenceInputs)}`, ); } // NEMOCLAW_AGENT_TIMEOUT — override agents.defaults.timeoutSeconds at build @@ -185,7 +201,7 @@ export function patchStagedDockerfile( if (agentTimeout && POSITIVE_INT_RE.test(agentTimeout)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_AGENT_TIMEOUT=.*$/m, - `ARG NEMOCLAW_AGENT_TIMEOUT=${agentTimeout}`, + `ARG NEMOCLAW_AGENT_TIMEOUT=${sanitizeDockerArg(agentTimeout)}`, ); } // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every @@ -195,7 +211,7 @@ export function patchStagedDockerfile( if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, - `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, + `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${sanitizeDockerArg(agentHeartbeat)}`, ); } // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host @@ -206,48 +222,48 @@ export function patchStagedDockerfile( if (proxyHostEnv && isValidProxyHost(proxyHostEnv)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROXY_HOST=.*$/m, - `ARG NEMOCLAW_PROXY_HOST=${proxyHostEnv}`, + `ARG NEMOCLAW_PROXY_HOST=${sanitizeDockerArg(proxyHostEnv)}`, ); } const proxyPortEnv = process.env.NEMOCLAW_PROXY_PORT; if (proxyPortEnv && isValidProxyPort(proxyPortEnv)) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROXY_PORT=.*$/m, - `ARG NEMOCLAW_PROXY_PORT=${proxyPortEnv}`, + `ARG NEMOCLAW_PROXY_PORT=${sanitizeDockerArg(proxyPortEnv)}`, ); } dockerfile = dockerfile.replace( /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, - `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, + `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${sanitizeDockerArg(webSearchConfig ? "1" : "0")}`, ); // Onboard flow expects immediate dashboard access without device pairing, // so disable device auth for images built during onboard (see #1217). dockerfile = dockerfile.replace( /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, - `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`, + `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=${sanitizeDockerArg("1")}`, ); if (messagingChannels.length > 0) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeDockerJsonArg(messagingChannels)}`, + `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeSanitizedDockerJsonArg(messagingChannels)}`, ); } if (Object.keys(messagingAllowedIds).length > 0) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeDockerJsonArg(messagingAllowedIds)}`, + `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeSanitizedDockerJsonArg(messagingAllowedIds)}`, ); } if (Object.keys(discordGuilds).length > 0) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, - `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, + `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeSanitizedDockerJsonArg(discordGuilds)}`, ); } if (telegramConfig && Object.keys(telegramConfig).length > 0) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`, + `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeSanitizedDockerJsonArg(telegramConfig)}`, ); } fs.writeFileSync(dockerfilePath, dockerfile); From cd70819ce6c5adc701953dd55036d9d52f340e9b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 17:53:55 -0700 Subject: [PATCH 12/12] fix(onboard): preserve falsy docker json args --- src/lib/onboard/dockerfile-patch.test.ts | 1 + src/lib/onboard/dockerfile-patch.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 535450d6cdf..100b996f853 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -38,6 +38,7 @@ describe("dockerfile patch helpers", () => { JSON.stringify({ supportsStore: false }), ); expect(Buffer.from(encodeDockerJsonArg(null), "base64").toString("utf-8")).toBe("{}"); + expect(Buffer.from(encodeDockerJsonArg(false), "base64").toString("utf-8")).toBe("false"); }); it("validates proxy host and port values", () => { diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 9f31687e099..508b37eecee 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -13,7 +13,7 @@ const POSITIVE_INT_RE = /^[1-9][0-9]*$/; type LooseObject = Record; export function encodeDockerJsonArg(value: unknown): string { - return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); + return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); } function sanitizeDockerArg(value: unknown): string {