Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions scripts/checks/test-create-require-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions src/lib/actions/sandbox/rebuild-config-hash-command.ts
Original file line number Diff line number Diff line change
@@ -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("; ");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
40 changes: 32 additions & 8 deletions src/lib/actions/sandbox/rebuild-config-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof spawnSync> {
function runRefresh(
configDir: string,
env: NodeJS.ProcessEnv = process.env,
): ReturnType<typeof spawnSync> {
return spawnSync("bash", ["-c", buildRefreshMutableOpenClawConfigHashCommand(configDir)], {
encoding: "utf-8",
env,
timeout: 5000,
});
}
Expand Down Expand Up @@ -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 });
}
},
);
});
22 changes: 2 additions & 20 deletions src/lib/actions/sandbox/rebuild-config-hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
84 changes: 23 additions & 61 deletions src/lib/actions/sandbox/rebuild-flow-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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<SandboxStateModule["backupSandboxState"]> {
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<typeof sandboxState.backupSandboxState> {
return {
success: true,
backedUpDirs: [".state"],
Expand All @@ -55,13 +38,11 @@ function makeBackupResult(): ReturnType<SandboxStateModule["backupSandboxState"]
blueprintDigest: null,
policyPresets: [],
customPolicies: [],
} as ReturnType<SandboxStateModule["backupSandboxState"]>["manifest"],
} as ReturnType<typeof sandboxState.backupSandboxState>["manifest"],
};
}

function makeSandboxEntry(): Parameters<
RebuildFlowHelpersModule["backupSandboxStateForRebuild"]
>[1] {
function makeSandboxEntry(): Parameters<typeof backupSandboxStateForRebuild>[1] {
return {
name: "alpha",
agent: "langchain-deepagents-code",
Expand All @@ -70,7 +51,7 @@ function makeSandboxEntry(): Parameters<
policies: [],
customPolicies: [],
nimContainer: null,
} as unknown as Parameters<RebuildFlowHelpersModule["backupSandboxStateForRebuild"]>[1];
} satisfies Parameters<typeof backupSandboxStateForRebuild>[1];
}

function makeBail(): (msg: string, code?: number) => never {
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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 });
Expand All @@ -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());

Expand All @@ -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());

Expand All @@ -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",
};
Expand All @@ -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(
{
Expand Down Expand Up @@ -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: [],
});
Expand All @@ -270,7 +239,6 @@ describe("warnUnpreservedUserManagedFiles", () => {
existing: [".env", ".mcp.json"],
});

const { warnUnpreservedUserManagedFiles } = loadRebuildFlowHelpers();
warnUnpreservedUserManagedFiles("alpha", () => undefined);

expect(probeSpy).toHaveBeenCalledOnce();
Expand All @@ -292,7 +260,6 @@ describe("warnUnpreservedUserManagedFiles", () => {
existing: [],
});

const { warnUnpreservedUserManagedFiles } = loadRebuildFlowHelpers();
warnUnpreservedUserManagedFiles("alpha", () => undefined);

expect(probeSpy).toHaveBeenCalledOnce();
Expand All @@ -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();
Expand All @@ -312,7 +278,6 @@ describe("warnUnpreservedUserManagedFiles", () => {
});

it("skips probe when staleRecovery short-circuits the backup", () => {
const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
const result = backupSandboxStateForRebuild(
"alpha",
makeSandboxEntry(),
Expand All @@ -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(),
Expand All @@ -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]));
Expand All @@ -374,7 +337,6 @@ describe("warnUnpreservedUserManagedFiles", () => {
error: "Pre-backup audit rejected an unsafe symlink",
});

const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
expect(() =>
backupSandboxStateForRebuild(
"alpha",
Expand Down
Loading
Loading