diff --git a/scripts/checks/test-create-require-budget.ts b/scripts/checks/test-create-require-budget.ts index 7662efcfb41..05fe8cd232a 100644 --- a/scripts/checks/test-create-require-budget.ts +++ b/scripts/checks/test-create-require-budget.ts @@ -23,15 +23,11 @@ export const CLI_CREATE_REQUIRE_FILES = [ "src/lib/actions/sandbox/gateway-state-hints.test.ts", "src/lib/actions/sandbox/process-recovery-lock.test.ts", "src/lib/actions/sandbox/rebuild-agent-base-image-preflight.test.ts", - "src/lib/actions/sandbox/rebuild-config-hash.test.ts", - "src/lib/actions/sandbox/rebuild-flow-helpers.test.ts", "src/lib/actions/sandbox/rebuild-gateway-drift.test.ts", "src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts", - "src/lib/actions/sandbox/rebuild-messaging-stage.test.ts", "src/lib/actions/sandbox/rebuild-resume-config.test.ts", "src/lib/actions/sandbox/rebuild-resume-reasoning.test.ts", "src/lib/actions/sandbox/sandbox-gateway-routing.test.ts", - "src/lib/actions/upgrade-sandboxes-recovery.test.ts", "src/lib/adapters/openshell/gateway-drift.test.ts", "src/lib/hermes-provider-auth.test.ts", "src/lib/inference/nim-igpu-compute-constrained.test.ts", diff --git a/src/lib/actions/sandbox/rebuild-config-hash-command.ts b/src/lib/actions/sandbox/rebuild-config-hash-command.ts new file mode 100644 index 00000000000..1490fb18a09 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-config-hash-command.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { shellQuote } from "../../core/shell-quote"; + +export function buildRefreshMutableOpenClawConfigHashCommand( + configDir = "/sandbox/.openclaw", +): string { + return [ + `config_dir=${shellQuote(configDir)}`, + 'config_file="${config_dir}/openclaw.json"', + 'hash_file="${config_dir}/.config-hash"', + '[ -d "$config_dir" ] || exit 0', + '[ ! -L "$config_dir" ] || { echo "refusing symlinked OpenClaw config dir: $config_dir" >&2; exit 10; }', + '[ ! -L "$config_file" ] || { echo "refusing symlinked OpenClaw config file: $config_file" >&2; exit 11; }', + '[ ! -L "$hash_file" ] || { echo "refusing symlinked OpenClaw config hash: $hash_file" >&2; exit 12; }', + 'owner="$(stat -c "%U" "$config_dir" 2>/dev/null || echo unknown)"', + '[ "$owner" != "root" ] || exit 0', + '[ -f "$config_file" ] || exit 0', + 'cd "$config_dir" || exit 13', + "sha256sum openclaw.json > .config-hash || exit 14", + "chmod 660 .config-hash 2>/dev/null || true", + ].join("; "); +} diff --git a/src/lib/actions/sandbox/rebuild-config-hash.test.ts b/src/lib/actions/sandbox/rebuild-config-hash.test.ts index 53c54b70131..6b9978c3b2c 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash.test.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash.test.ts @@ -4,26 +4,24 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -type RebuildModule = typeof import("./rebuild"); - -const requireDist = createRequire(import.meta.url); -const { buildRefreshMutableOpenClawConfigHashCommand } = requireDist( - "./rebuild.js", -) as RebuildModule; +import { buildRefreshMutableOpenClawConfigHashCommand } from "./rebuild-config-hash-command"; function sha256Hex(filePath: string): string { return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } -function runRefresh(configDir: string): ReturnType { +function runRefresh( + configDir: string, + env: NodeJS.ProcessEnv = process.env, +): ReturnType { return spawnSync("bash", ["-c", buildRefreshMutableOpenClawConfigHashCommand(configDir)], { encoding: "utf-8", + env, timeout: 5000, }); } @@ -70,4 +68,30 @@ describe.skipIf(process.platform !== "linux")("OpenClaw rebuild config hash refr fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it.skipIf(process.getuid?.() === 0)( + "reports hash command failures instead of masking them (#6245)", + () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-hash-failure-")); + const configDir = path.join(tmpDir, ".openclaw"); + const binDir = path.join(tmpDir, "bin"); + const hashCommand = path.join(binDir, "sha256sum"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "openclaw.json"), '{"gateway":{}}\n'); + fs.writeFileSync(hashCommand, "#!/bin/sh\nexit 42\n"); + fs.chmodSync(hashCommand, 0o755); + + const result = runRefresh(configDir, { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + }); + + expect(result.status).toBe(14); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/src/lib/actions/sandbox/rebuild-config-hash.ts b/src/lib/actions/sandbox/rebuild-config-hash.ts index e55c9cfdb9c..1e60e12b97b 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash.ts @@ -2,29 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { R, YW } from "../../cli/terminal-style"; -import { shellQuote } from "../../runner"; import { redact } from "../../security/redact"; import { executeSandboxCommand } from "./process-recovery"; +import { buildRefreshMutableOpenClawConfigHashCommand } from "./rebuild-config-hash-command"; -export function buildRefreshMutableOpenClawConfigHashCommand( - configDir = "/sandbox/.openclaw", -): string { - return [ - `config_dir=${shellQuote(configDir)}`, - 'config_file="${config_dir}/openclaw.json"', - 'hash_file="${config_dir}/.config-hash"', - '[ -d "$config_dir" ] || exit 0', - '[ ! -L "$config_dir" ] || { echo "refusing symlinked OpenClaw config dir: $config_dir" >&2; exit 10; }', - '[ ! -L "$config_file" ] || { echo "refusing symlinked OpenClaw config file: $config_file" >&2; exit 11; }', - '[ ! -L "$hash_file" ] || { echo "refusing symlinked OpenClaw config hash: $hash_file" >&2; exit 12; }', - 'owner="$(stat -c "%U" "$config_dir" 2>/dev/null || echo unknown)"', - '[ "$owner" != "root" ] || exit 0', - '[ -f "$config_file" ] || exit 0', - 'cd "$config_dir" || exit 13', - "sha256sum openclaw.json > .config-hash", - "chmod 660 .config-hash 2>/dev/null || true", - ].join("; "); -} +export { buildRefreshMutableOpenClawConfigHashCommand }; export function refreshMutableOpenClawConfigHashAfterPostRestoreWrites( sandboxName: string, diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts index 80a6261f3d0..0494ac69103 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts @@ -1,39 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createRequire } from "node:module"; - import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; -import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; - -type RebuildFlowHelpersModule = typeof import("./rebuild-flow-helpers"); -type SandboxStateModule = typeof import("../../state/sandbox"); -type UserManagedFilesProbeModule = typeof import("../../state/user-managed-files-probe"); - -const requireDist = createRequire(import.meta.url); -const rebuildFlowHelpersPath = "./rebuild-flow-helpers.js"; -const sandboxStatePath = "../../state/sandbox.js"; -const userManagedFilesProbePath = "../../state/user-managed-files-probe.js"; - -function loadRebuildFlowHelpers(): RebuildFlowHelpersModule { - delete require.cache[requireDist.resolve(rebuildFlowHelpersPath)]; - return requireDist(rebuildFlowHelpersPath); -} - -// Warm the CommonJS dependency graph outside the first test's timeout. Tests -// still reload this entry module after installing dependency spies. -loadRebuildFlowHelpers(); -delete require.cache[requireDist.resolve(rebuildFlowHelpersPath)]; - -function loadSandboxState(): SandboxStateModule { - return requireDist(sandboxStatePath); -} - -function loadUserManagedFilesProbe(): UserManagedFilesProbeModule { - return requireDist(userManagedFilesProbePath); -} -function makeBackupResult(): ReturnType { +import * as agentDefs from "../../agent/defs"; +import * as agentOnboard from "../../agent/onboard"; +import * as gatewayRuntime from "../../gateway-runtime-action"; +import * as sandboxState from "../../state/sandbox"; +import * as userManagedFilesProbe from "../../state/user-managed-files-probe"; +import { + backupSandboxStateForRebuild, + ensureRebuildAgentBaseImage, + ensureRebuildTargetGatewaySelected, + pinRebuildAgentBaseImageForRecreate, + warnUnpreservedUserManagedFiles, +} from "./rebuild-flow-helpers"; + +function makeBackupResult(): ReturnType { return { success: true, backedUpDirs: [".state"], @@ -55,13 +38,11 @@ function makeBackupResult(): ReturnType["manifest"], + } as ReturnType["manifest"], }; } -function makeSandboxEntry(): Parameters< - RebuildFlowHelpersModule["backupSandboxStateForRebuild"] ->[1] { +function makeSandboxEntry(): Parameters[1] { return { name: "alpha", agent: "langchain-deepagents-code", @@ -70,7 +51,7 @@ function makeSandboxEntry(): Parameters< policies: [], customPolicies: [], nimContainer: null, - } as unknown as Parameters[1]; + } satisfies Parameters[1]; } function makeBail(): (msg: string, code?: number) => never { @@ -94,14 +75,12 @@ describe("rebuild target gateway preflight", () => { }); it("health-checks and pins the sandbox's persisted gateway", async () => { - const gatewayRuntime = requireDist("../../gateway-runtime-action.js"); const recover = vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockResolvedValue({ recovered: true, - before: { state: "connected_other" }, - after: { state: "healthy_named" }, + before: { state: "connected_other", status: "", gatewayInfo: "", activeGateway: null }, + after: { state: "healthy_named", status: "", gatewayInfo: "", activeGateway: null }, attempted: true, }); - const { ensureRebuildTargetGatewaySelected } = loadRebuildFlowHelpers(); await expect( ensureRebuildTargetGatewaySelected( @@ -117,14 +96,12 @@ describe("rebuild target gateway preflight", () => { }); it("fails closed when the target gateway cannot become healthy", async () => { - const gatewayRuntime = requireDist("../../gateway-runtime-action.js"); vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockResolvedValue({ recovered: false, - before: { state: "connected_other" }, - after: { state: "missing_named" }, + before: { state: "connected_other", status: "", gatewayInfo: "", activeGateway: null }, + after: { state: "missing_named", status: "", gatewayInfo: "", activeGateway: null }, attempted: true, }); - const { ensureRebuildTargetGatewaySelected } = loadRebuildFlowHelpers(); await expect( ensureRebuildTargetGatewaySelected( @@ -157,9 +134,7 @@ describe("rebuild agent base image preflight", () => { }); function mockBaseImagePreflight(imageRef: string) { - const agentDefs = requireDist("../../agent/defs.js"); - const agentOnboard = requireDist("../../agent/onboard.js"); - vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ name: "hermes" }); + vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ name: "hermes" } as never); const ensureAgentBaseImage = vi .spyOn(agentOnboard, "ensureAgentBaseImage") .mockReturnValue({ imageTag: imageRef, built: true }); @@ -172,7 +147,6 @@ describe("rebuild agent base image preflight", () => { it("forces a repository-local build and returns its exact ref when no override exists", () => { const imageRef = "nemoclaw-hermes-sandbox-base-local:12345678"; const { ensureAgentBaseImage } = mockBaseImagePreflight(imageRef); - const { ensureRebuildAgentBaseImage } = loadRebuildFlowHelpers(); const result = ensureRebuildAgentBaseImage("hermes", makeBail()); @@ -189,7 +163,6 @@ describe("rebuild agent base image preflight", () => { const { ensureAgentBaseImage, pinAgentSandboxBaseImageRef } = mockBaseImagePreflight(mutableRef); pinAgentSandboxBaseImageRef.mockReturnValue(immutableRef); - const { ensureRebuildAgentBaseImage } = loadRebuildFlowHelpers(); const result = ensureRebuildAgentBaseImage("hermes", makeBail()); @@ -201,7 +174,6 @@ describe("rebuild agent base image preflight", () => { }); it("pins the preflighted ref only for recreation and restores caller state", () => { - const { pinRebuildAgentBaseImageForRecreate } = loadRebuildFlowHelpers(); const env: NodeJS.ProcessEnv = { [overrideEnvVar]: "nemoclaw-hermes-sandbox-base-local:image-caller", }; @@ -222,7 +194,6 @@ describe("rebuild agent base image preflight", () => { }); it("removes a scoped recreation pin when the caller had no override", () => { - const { pinRebuildAgentBaseImageForRecreate } = loadRebuildFlowHelpers(); const env: NodeJS.ProcessEnv = {}; const restore = pinRebuildAgentBaseImageForRecreate( { @@ -251,10 +222,8 @@ describe("warnUnpreservedUserManagedFiles", () => { logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - const sandboxState = loadSandboxState(); backupSpy = vi.spyOn(sandboxState, "backupSandboxState").mockReturnValue(makeBackupResult()); - const probeModule = loadUserManagedFilesProbe(); - probeSpy = vi.spyOn(probeModule, "probeUserManagedFiles").mockReturnValue({ + probeSpy = vi.spyOn(userManagedFilesProbe, "probeUserManagedFiles").mockReturnValue({ declared: [], existing: [], }); @@ -270,7 +239,6 @@ describe("warnUnpreservedUserManagedFiles", () => { existing: [".env", ".mcp.json"], }); - const { warnUnpreservedUserManagedFiles } = loadRebuildFlowHelpers(); warnUnpreservedUserManagedFiles("alpha", () => undefined); expect(probeSpy).toHaveBeenCalledOnce(); @@ -292,7 +260,6 @@ describe("warnUnpreservedUserManagedFiles", () => { existing: [], }); - const { warnUnpreservedUserManagedFiles } = loadRebuildFlowHelpers(); warnUnpreservedUserManagedFiles("alpha", () => undefined); expect(probeSpy).toHaveBeenCalledOnce(); @@ -303,7 +270,6 @@ describe("warnUnpreservedUserManagedFiles", () => { it("emits no warning when agent declares no user-managed files", () => { probeSpy.mockReturnValue({ declared: [], existing: [] }); - const { warnUnpreservedUserManagedFiles } = loadRebuildFlowHelpers(); warnUnpreservedUserManagedFiles("alpha", () => undefined); expect(probeSpy).toHaveBeenCalledOnce(); @@ -312,7 +278,6 @@ describe("warnUnpreservedUserManagedFiles", () => { }); it("skips probe when staleRecovery short-circuits the backup", () => { - const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers(); const result = backupSandboxStateForRebuild( "alpha", makeSandboxEntry(), @@ -328,7 +293,6 @@ describe("warnUnpreservedUserManagedFiles", () => { }); it("does not probe during backup before managed MCP adapter entries are scrubbed", () => { - const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers(); const result = backupSandboxStateForRebuild( "alpha", makeSandboxEntry(), @@ -348,7 +312,6 @@ describe("warnUnpreservedUserManagedFiles", () => { throw new Error("ssh boom"); }); - const { warnUnpreservedUserManagedFiles } = loadRebuildFlowHelpers(); expect(() => warnUnpreservedUserManagedFiles("alpha", () => undefined)).not.toThrow(); const warnLines = warnSpy.mock.calls.map((args: unknown[]) => String(args[0])); @@ -374,7 +337,6 @@ describe("warnUnpreservedUserManagedFiles", () => { error: "Pre-backup audit rejected an unsafe symlink", }); - const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers(); expect(() => backupSandboxStateForRebuild( "alpha", diff --git a/src/lib/actions/sandbox/rebuild-messaging-phase.ts b/src/lib/actions/sandbox/rebuild-messaging-phase.ts index e9219704452..d62300abb39 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-phase.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-phase.ts @@ -2,80 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import { runOpenshell } from "../../adapters/openshell/runtime"; -import { loadAgent } from "../../agent/defs"; import { RD as _RD, D, G, R } from "../../cli/terminal-style"; +import { MessagingSetupApplier } from "../../messaging/applier/setup-applier"; import type { MessagingHookApplyRequest, - MessagingHookOutputMap, MessagingOpenShellRunner, - SandboxMessagingPlan, -} from "../../messaging"; -import { - createBuiltInChannelManifestRegistry, - createBuiltInRenderTemplateResolver, - isMessagingSupportedAgent, - listSupportedMessagingChannelIdsForAgent, - MessagingSetupApplier, - MessagingWorkflowPlanner, - tryGetMessagingAgentId, -} from "../../messaging"; +} from "../../messaging/applier/types"; +import type { MessagingHookOutputMap } from "../../messaging/hooks"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; import type { RebuildBail } from "./rebuild-credential-preflight"; +import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-stage"; -/** Build and stage the manifest-derived messaging recreate contract. */ -export async function stageMessagingManifestPlanForRebuild( - sandboxName: string, - sandboxEntry: SandboxEntry, - rebuildAgent: string | null, - log: (message: string) => void, -): Promise { - const agent = loadAgent(rebuildAgent || "openclaw"); - const manifestRegistry = createBuiltInChannelManifestRegistry(); - const manifests = manifestRegistry.list(); - const agentId = tryGetMessagingAgentId(agent, manifests); - if (agentId === null) { - MessagingSetupApplier.clearPlanEnv(); - log( - `Messaging manifest rebuild plan skipped: agent '${agent.name}' is not supported by any channel manifest`, - ); - return null; - } - if (!isMessagingSupportedAgent(agent, manifests)) { - MessagingSetupApplier.clearPlanEnv(); - log( - `Messaging manifest rebuild plan skipped: agent '${agent.name}' has no supported messaging channels`, - ); - return null; - } - const supportedChannelIds = listSupportedMessagingChannelIdsForAgent(manifests, agentId); - const planner = new MessagingWorkflowPlanner( - manifestRegistry, - undefined, - createBuiltInRenderTemplateResolver(), - ); - const plan = await planner.buildRebuildPlanFromSandboxEntry({ - sandboxName, - agent: agentId, - sandboxEntry, - supportedChannelIds, - }); - if (!plan) { - MessagingSetupApplier.clearPlanEnv(); - log("Messaging manifest rebuild plan: no configured channels"); - return null; - } - MessagingSetupApplier.writePlanToEnv(plan); - if (plan.channels.length === 0) { - log("Messaging manifest rebuild plan staged: no configured channels"); - return plan; - } - log( - `Messaging manifest rebuild plan staged: ${plan.channels - .map((channel) => channel.channelId) - .join(",")}`, - ); - return plan; -} +export { stageMessagingManifestPlanForRebuild }; /** Stage the manifest plan while preserving rebuild's fail-before-delete boundary. */ export async function stageRebuildMessagingPlanOrBail( diff --git a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts index bf678a1dcf7..44d0ae04f6d 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts @@ -6,28 +6,13 @@ // channel manifests, so a non-messaging sandbox rebuild cannot // carry messaging-plan state into the Dockerfile patch step. // -// Loaded through the shared source require hook because the rebuild graph uses -// runtime CommonJS dependencies that must share one cache for test spies. - -import { createRequire } from "node:module"; - import { afterEach, describe, expect, it, vi } from "vitest"; -const requireSource = createRequire(import.meta.url); -const D = (p: string) => requireSource(`../../${p}`); - -const defs = D("agent/defs.js"); -const messaging = D("messaging/index.js") as { - MessagingSetupApplier: { clearPlanEnv: () => void; writePlanToEnv: (plan: unknown) => void }; -}; -const { stageMessagingManifestPlanForRebuild } = D("actions/sandbox/rebuild.js") as { - stageMessagingManifestPlanForRebuild: ( - sandboxName: string, - sandboxEntry: unknown, - rebuildAgent: string | null, - log: (msg: string) => void, - ) => Promise; -}; +import * as defs from "../../agent/defs"; +import { MessagingSetupApplier } from "../../messaging/applier/setup-applier"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; +import type { SandboxEntry } from "../../state/registry"; +import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-stage"; const emptyStoredMessagingPlan = { schemaVersion: 1, @@ -42,7 +27,7 @@ const emptyStoredMessagingPlan = { buildSteps: [], stateUpdates: [], healthChecks: [], -}; +} satisfies SandboxMessagingPlan; describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => { afterEach(() => { @@ -50,10 +35,10 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => }); it("emits the skip message for any agent whose name is not supported by channel manifests", async () => { - const loadAgentSpy = vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "future-non-messaging-agent", - }); - const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); + const loadAgentSpy = vi + .spyOn(defs, "loadAgent") + .mockReturnValue({ name: "future-non-messaging-agent" } as never); + const clearPlanEnvSpy = vi.spyOn(MessagingSetupApplier, "clearPlanEnv"); const messages: string[] = []; const result = await stageMessagingManifestPlanForRebuild( @@ -73,12 +58,10 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => }); it("stages an explicit empty rebuild plan so token-backed channels are not rediscovered", async () => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "openclaw", - }); - const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw" } as never); + const clearPlanEnvSpy = vi.spyOn(MessagingSetupApplier, "clearPlanEnv"); const writePlanEnvSpy = vi - .spyOn(messaging.MessagingSetupApplier, "writePlanToEnv") + .spyOn(MessagingSetupApplier, "writePlanToEnv") .mockImplementation(() => undefined); const messages: string[] = []; @@ -104,11 +87,9 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => }); it("stages a plan for a known agent using channel-manifest supported channels", async () => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "openclaw", - }); - const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); - const writePlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "writePlanToEnv"); + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw" } as never); + const clearPlanEnvSpy = vi.spyOn(MessagingSetupApplier, "clearPlanEnv"); + const writePlanEnvSpy = vi.spyOn(MessagingSetupApplier, "writePlanToEnv"); const sandboxEntryWithStoredPlan = { name: "openclaw-sandbox", @@ -141,7 +122,7 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => healthChecks: [], }, }, - }; + } satisfies SandboxEntry; const messages: string[] = []; const result = await stageMessagingManifestPlanForRebuild( diff --git a/src/lib/actions/sandbox/rebuild-messaging-stage.ts b/src/lib/actions/sandbox/rebuild-messaging-stage.ts new file mode 100644 index 00000000000..62deb4fd265 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { loadAgent } from "../../agent/defs"; +import { MessagingSetupApplier } from "../../messaging/applier/setup-applier"; +import { createBuiltInChannelManifestRegistry } from "../../messaging/channels/built-ins"; +import { createBuiltInRenderTemplateResolver } from "../../messaging/channels/template-resolver"; +import { MessagingWorkflowPlanner } from "../../messaging/compiler/workflow-planner"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; +import { + isMessagingSupportedAgent, + listSupportedMessagingChannelIdsForAgent, + tryGetMessagingAgentId, +} from "../../messaging/utils"; +import type { SandboxEntry } from "../../state/registry"; + +/** Build and stage the manifest-derived messaging recreate contract. */ +export async function stageMessagingManifestPlanForRebuild( + sandboxName: string, + sandboxEntry: SandboxEntry, + rebuildAgent: string | null, + log: (message: string) => void, +): Promise { + const agent = loadAgent(rebuildAgent || "openclaw"); + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const manifests = manifestRegistry.list(); + const agentId = tryGetMessagingAgentId(agent, manifests); + if (agentId === null) { + MessagingSetupApplier.clearPlanEnv(); + log( + `Messaging manifest rebuild plan skipped: agent '${agent.name}' is not supported by any channel manifest`, + ); + return null; + } + if (!isMessagingSupportedAgent(agent, manifests)) { + MessagingSetupApplier.clearPlanEnv(); + log( + `Messaging manifest rebuild plan skipped: agent '${agent.name}' has no supported messaging channels`, + ); + return null; + } + const supportedChannelIds = listSupportedMessagingChannelIdsForAgent(manifests, agentId); + const planner = new MessagingWorkflowPlanner( + manifestRegistry, + undefined, + createBuiltInRenderTemplateResolver(), + ); + const plan = await planner.buildRebuildPlanFromSandboxEntry({ + sandboxName, + agent: agentId, + sandboxEntry, + supportedChannelIds, + }); + if (!plan) { + MessagingSetupApplier.clearPlanEnv(); + log("Messaging manifest rebuild plan: no configured channels"); + return null; + } + MessagingSetupApplier.writePlanToEnv(plan); + if (plan.channels.length === 0) { + log("Messaging manifest rebuild plan staged: no configured channels"); + return plan; + } + log( + `Messaging manifest rebuild plan staged: ${plan.channels + .map((channel) => channel.channelId) + .join(",")}`, + ); + return plan; +} diff --git a/src/lib/actions/upgrade-sandboxes-preflight.test.ts b/src/lib/actions/upgrade-sandboxes-preflight.test.ts index ad5662ae19c..56dbb8dfb5e 100644 --- a/src/lib/actions/upgrade-sandboxes-preflight.test.ts +++ b/src/lib/actions/upgrade-sandboxes-preflight.test.ts @@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({ parseLiveSandboxEntries: vi.fn(), parseReadySandboxNames: vi.fn(), prompt: vi.fn(), - rebuildSandbox: vi.fn(), shouldSkipUpgradeConfirmation: vi.fn(), splitRebuildableSandboxes: vi.fn(), })); @@ -40,14 +39,15 @@ vi.mock("../runtime-recovery", () => ({ vi.mock("../sandbox/version", () => ({ checkAgentVersion: mocks.checkAgentVersion })); vi.mock("../state/registry", () => ({ listSandboxes: mocks.listSandboxes })); vi.mock("../state/sandbox", () => ({ getLatestBackup: mocks.getLatestBackup })); -vi.mock("./sandbox/rebuild", () => ({ rebuildSandbox: mocks.rebuildSandbox })); -import { upgradeSandboxes } from "./upgrade-sandboxes"; +import { upgradeSandboxes, upgradeSandboxesDependencies } from "./upgrade-sandboxes"; describe("upgrade-sandboxes gateway preflight adapter (#6237)", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE", ""); + vi.spyOn(upgradeSandboxesDependencies, "getGatewayPort").mockReturnValue(8080); + vi.spyOn(upgradeSandboxesDependencies, "rebuildSandbox").mockResolvedValue(undefined); mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ status: 0, output: "alpha Ready", @@ -110,6 +110,6 @@ describe("upgrade-sandboxes gateway preflight adapter (#6237)", () => { expect(mocks.classifyUpgradeableSandboxes).not.toHaveBeenCalled(); expect(mocks.getLatestBackup).not.toHaveBeenCalled(); - expect(mocks.rebuildSandbox).not.toHaveBeenCalled(); + expect(upgradeSandboxesDependencies.rebuildSandbox).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/upgrade-sandboxes-recovery.test.ts b/src/lib/actions/upgrade-sandboxes-recovery.test.ts index 4eb06e4d282..d2cb0cd0782 100644 --- a/src/lib/actions/upgrade-sandboxes-recovery.test.ts +++ b/src/lib/actions/upgrade-sandboxes-recovery.test.ts @@ -1,19 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createRequire } from "node:module"; - import { afterEach, describe, expect, it, vi } from "vitest"; -type UpgradeSandboxes = typeof import("./upgrade-sandboxes")["upgradeSandboxes"]; - -const requireDist = createRequire(import.meta.url); -const upgradeModulePath = "./upgrade-sandboxes.js"; +import * as coreVersion from "../core/version"; +import * as sandboxList from "../openshell-sandbox-list"; +import * as sandboxVersion from "../sandbox/version"; +import * as registry from "../state/registry"; +import * as sandboxState from "../state/sandbox"; +import { upgradeSandboxes, upgradeSandboxesDependencies } from "./upgrade-sandboxes"; -// Warm the CommonJS source graph outside the first test's timeout. Each harness -// still reloads the entry module after installing its dependency spies. -requireDist(upgradeModulePath); -delete require.cache[requireDist.resolve(upgradeModulePath)]; +type UpgradeSandboxes = typeof upgradeSandboxes; function makeManifest(sandboxName: string) { const timestamp = `2026-07-01T06-50-4${sandboxName.length}-044Z`; @@ -63,7 +60,6 @@ function createRecoveryHarness( managedEvidenceSpy: ReturnType; liveListSpy: ReturnType; } { - delete require.cache[requireDist.resolve(upgradeModulePath)]; vi.stubEnv("NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE", "1"); vi.stubEnv( "NEMOCLAW_CONFIRMED_LEGACY_MANAGED_SANDBOXES", @@ -71,19 +67,13 @@ function createRecoveryHarness( ? options.confirmedLegacyManagedNames : JSON.stringify(options.confirmedLegacyManagedNames ?? []), ); - vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(options.gatewayPort ?? 8080)); - delete require.cache[requireDist.resolve("../core/ports.js")]; - - const coreVersion = requireDist("../core/version.js"); - const sandboxList = requireDist("../openshell-sandbox-list.js"); - const sandboxVersion = requireDist("../sandbox/version.js"); - const registry = requireDist("../state/registry.js"); - const sandboxState = requireDist("../state/sandbox.js"); - const rebuild = requireDist("./sandbox/rebuild.js"); vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(upgradeSandboxesDependencies, "getGatewayPort").mockReturnValue( + options.gatewayPort ?? 8080, + ); vi.spyOn(coreVersion, "getVersion").mockReturnValue("0.0.71"); const liveListSpy = vi .spyOn(sandboxList, "captureSandboxListWithGatewayPreflightOrExit") @@ -92,6 +82,7 @@ function createRecoveryHarness( output: options.liveOutput ?? names.map((name) => `${name} Error`).join("\n"), }); vi.spyOn(registry, "listSandboxes").mockReturnValue({ + defaultSandbox: null, sandboxes: names.map((name) => ({ name, agent: null, @@ -108,6 +99,7 @@ function createRecoveryHarness( sandboxVersion: options.staleNames?.includes(name) === true ? "2026.5.26" : "2026.5.27", expectedVersion: "2026.5.27", isStale: options.staleNames?.includes(name) === true, + verificationFailed: false, detectionMethod: "registry", }; }); @@ -125,10 +117,12 @@ function createRecoveryHarness( const managedEvidenceSpy = options.useRealManagedEvidence ? vi.spyOn(sandboxState, "hasPositiveManagedImageEvidence") : vi.spyOn(sandboxState, "hasPositiveManagedImageEvidence").mockReturnValue(true); - const rebuildSpy = vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined); + const rebuildSpy = vi + .spyOn(upgradeSandboxesDependencies, "rebuildSandbox") + .mockResolvedValue(undefined); return { - upgradeSandboxes: requireDist(upgradeModulePath).upgradeSandboxes, + upgradeSandboxes, rebuildSpy, latestBackupSpy, managedEvidenceSpy, @@ -139,7 +133,6 @@ function createRecoveryHarness( afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - delete require.cache[requireDist.resolve(upgradeModulePath)]; }); describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index 50fe197362e..38b7a080d1c 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -22,7 +22,23 @@ import { parseLiveSandboxEntries, parseReadySandboxNames } from "../runtime-reco import * as sandboxVersion from "../sandbox/version"; import * as registry from "../state/registry"; import * as sandboxState from "../state/sandbox"; -import { rebuildSandbox } from "./sandbox/rebuild"; + +type RebuildModule = typeof import("./sandbox/rebuild"); + +export const upgradeSandboxesDependencies = { + getGatewayPort(): number { + return GATEWAY_PORT; + }, + async loadRebuildModule(): Promise { + return import("./sandbox/rebuild"); + }, + async rebuildSandbox( + ...args: Parameters + ): ReturnType { + const { rebuildSandbox } = await upgradeSandboxesDependencies.loadRebuildModule(); + return rebuildSandbox(...args); + }, +}; // ── Upgrade sandboxes (#1904) ──────────────────────────────────── // Detect sandboxes running stale agent versions and offer to rebuild them. @@ -212,7 +228,7 @@ export async function upgradeSandboxes( // initial list, the confirmation list, and persisted-binding eligibility must // share this source; OpenShell's mutable current selection may be a sibling // gateway where the same sandbox name has different state. - const selectedGatewayName = resolveGatewayName(GATEWAY_PORT); + const selectedGatewayName = resolveGatewayName(upgradeSandboxesDependencies.getGatewayPort()); const liveResult = await captureSandboxListWithGatewayPreflightOrExit( { action: "checking sandbox upgrade state", @@ -400,7 +416,7 @@ export async function upgradeSandboxes( } } try { - await rebuildSandbox(sandbox.name, ["--yes"], { + await upgradeSandboxesDependencies.rebuildSandbox(sandbox.name, ["--yes"], { throwOnError: true, recoveryManifest: manifest ?? undefined, ...("allowLegacyManagedImageRecovery" in item diff --git a/test/package-contract/rebuild-loader-boundary.test.ts b/test/package-contract/rebuild-loader-boundary.test.ts new file mode 100644 index 00000000000..c94de6fca45 --- /dev/null +++ b/test/package-contract/rebuild-loader-boundary.test.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, "..", ".."); +const require = createRequire(import.meta.url); +const upgradePath = path.join(repoRoot, "dist", "lib", "actions", "upgrade-sandboxes.js"); +const rebuildPath = path.join(repoRoot, "dist", "lib", "actions", "sandbox", "rebuild.js"); + +type UpgradeModule = typeof import("../../src/lib/actions/upgrade-sandboxes"); + +function snapshotRequireCache(): typeof require.cache { + return { ...require.cache }; +} + +function restoreRequireCache(snapshot: typeof require.cache): void { + for (const modulePath of Object.keys(require.cache)) delete require.cache[modulePath]; + Object.assign(require.cache, snapshot); +} + +describe("compiled rebuild loader boundary", () => { + it("keeps the rebuild graph lazy until upgrade forwarding (#6245)", async () => { + const priorCache = snapshotRequireCache(); + try { + delete require.cache[upgradePath]; + delete require.cache[rebuildPath]; + const upgrade = require(upgradePath) as UpgradeModule; + + expect(require.cache[rebuildPath]).toBeUndefined(); + const rebuild = await upgrade.upgradeSandboxesDependencies.loadRebuildModule(); + expect(require.cache[rebuildPath]).toBeDefined(); + expect(rebuild.rebuildSandbox).toBeTypeOf("function"); + + const forwardedRebuild = vi.fn().mockResolvedValue(undefined); + vi.spyOn(upgrade.upgradeSandboxesDependencies, "loadRebuildModule").mockResolvedValue({ + rebuildSandbox: forwardedRebuild, + } as never); + await upgrade.upgradeSandboxesDependencies.rebuildSandbox("alpha", ["--yes"], { + throwOnError: true, + }); + expect(forwardedRebuild).toHaveBeenCalledWith("alpha", ["--yes"], { + throwOnError: true, + }); + } finally { + vi.restoreAllMocks(); + restoreRequireCache(priorCache); + } + }); + + it("preserves the public rebuild facade exports (#6245)", () => { + const priorCache = snapshotRequireCache(); + try { + const rebuild = require(rebuildPath) as { + buildRefreshMutableOpenClawConfigHashCommand?: (configDir?: string) => string; + stageMessagingManifestPlanForRebuild?: (...args: unknown[]) => Promise; + }; + + expect(rebuild.buildRefreshMutableOpenClawConfigHashCommand).toBeTypeOf("function"); + expect(rebuild.stageMessagingManifestPlanForRebuild).toBeTypeOf("function"); + expect( + rebuild.buildRefreshMutableOpenClawConfigHashCommand?.("/tmp/openclaw config"), + ).toContain("config_dir='/tmp/openclaw config'"); + } finally { + restoreRequireCache(priorCache); + } + }); +});