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: 4 additions & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ state_files:
- path: .hermes_history
- path: runtime/state.db
strategy: sqlite_backup
user_managed_files:
# Relative to /sandbox, not config.dir. Hermes stores user-edited API-key
# values in /sandbox/.hermes/.env, and rebuild should warn before dropping it.
- .hermes/.env

# ── Authentication ──────────────────────────────────────────────
# Hermes does NOT have OpenClaw-style browser device pairing.
Expand Down
3 changes: 3 additions & 0 deletions agents/langchain-deepagents-code/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ state_dirs:
state_files:
- path: config.toml
- path: hooks.json
user_managed_files:
- .env
- .mcp.json

device_pairing: false

Expand Down
3 changes: 3 additions & 0 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ state_dirs:
# credentials.
state_files:
- path: openclaw.json
user_managed_files:
- .env
- .mcp.json

# ── Authentication ──────────────────────────────────────────────
device_pairing: true
Expand Down
214 changes: 214 additions & 0 deletions src/lib/actions/sandbox/rebuild-flow-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// 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";

type RebuildFlowHelpersModule =
typeof import("../../../../dist/lib/actions/sandbox/rebuild-flow-helpers");
type SandboxStateModule = typeof import("../../../../dist/lib/state/sandbox");
type UserManagedFilesProbeModule =
typeof import("../../../../dist/lib/state/user-managed-files-probe");

const requireDist = createRequire(import.meta.url);
const rebuildFlowHelpersPath = "../../../../dist/lib/actions/sandbox/rebuild-flow-helpers.js";
const sandboxStatePath = "../../../../dist/lib/state/sandbox.js";
const userManagedFilesProbePath = "../../../../dist/lib/state/user-managed-files-probe.js";

function loadRebuildFlowHelpers(): RebuildFlowHelpersModule {
delete require.cache[requireDist.resolve(rebuildFlowHelpersPath)];
return requireDist(rebuildFlowHelpersPath);
}

function loadSandboxState(): SandboxStateModule {
return requireDist(sandboxStatePath);
}

function loadUserManagedFilesProbe(): UserManagedFilesProbeModule {
return requireDist(userManagedFilesProbePath);
}

function makeBackupResult(): ReturnType<SandboxStateModule["backupSandboxState"]> {
return {
success: true,
backedUpDirs: [".state"],
backedUpFiles: ["config.toml"],
failedDirs: [],
failedFiles: [],
manifest: {
version: 1,
sandboxName: "alpha",
timestamp: "2026-06-01T00-00-00-000Z",
agentType: "langchain-deepagents-code",
agentVersion: null,
expectedVersion: "0.1.12",
stateDirs: [".state"],
backedUpDirs: [".state"],
stateFiles: [{ path: "config.toml", strategy: "copy" }],
dir: "/sandbox/.deepagents",
backupPath: "/tmp/nemoclaw-rebuild-backup",
blueprintDigest: null,
policyPresets: [],
customPolicies: [],
} as ReturnType<SandboxStateModule["backupSandboxState"]>["manifest"],
};
}

function makeSandboxEntry(): Parameters<
RebuildFlowHelpersModule["backupSandboxStateForRebuild"]
>[1] {
return {
name: "alpha",
agent: "langchain-deepagents-code",
provider: null,
model: null,
policies: [],
customPolicies: [],
nimContainer: null,
} as unknown as Parameters<RebuildFlowHelpersModule["backupSandboxStateForRebuild"]>[1];
}

function makeBail(): (msg: string, code?: number) => never {
return (msg: string) => {
throw new Error(`bail: ${msg}`);
};
}

describe("backupSandboxStateForRebuild — user-managed file warning", () => {
let warnSpy: MockInstance;
let logSpy: MockInstance;
let errorSpy: MockInstance;
let backupSpy: MockInstance;
let probeSpy: MockInstance;

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
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({
declared: [],
existing: [],
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("emits warning when user-managed files exist in the sandbox", () => {
probeSpy.mockReturnValue({
declared: [".env", ".mcp.json"],
existing: [".env", ".mcp.json"],
});

const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
const result = backupSandboxStateForRebuild(
"alpha",
makeSandboxEntry(),
false,
() => undefined,
() => true,
makeBail(),
);

expect(result).toBeTruthy();
expect(backupSpy).toHaveBeenCalledOnce();
expect(probeSpy).toHaveBeenCalledOnce();
expect(probeSpy).toHaveBeenCalledWith("alpha");

const warnLines = warnSpy.mock.calls.map((args: unknown[]) => String(args[0]));
expect(warnLines.some((line: string) => line.includes("not preserved by rebuild"))).toBe(true);
expect(warnLines.some((line: string) => line.includes(".env, .mcp.json"))).toBe(true);
expect(warnLines.some((line: string) => line.includes("Re-add them after rebuild"))).toBe(true);
});

it("emits no warning when probe returns no existing user-managed files", () => {
probeSpy.mockReturnValue({
declared: [".env", ".mcp.json"],
existing: [],
});

const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
const result = backupSandboxStateForRebuild(
"alpha",
makeSandboxEntry(),
false,
() => undefined,
() => true,
makeBail(),
);

expect(result).toBeTruthy();
expect(probeSpy).toHaveBeenCalledOnce();
const warnLines = warnSpy.mock.calls.map((args: unknown[]) => String(args[0]));
expect(warnLines.some((line: string) => line.includes("not preserved by rebuild"))).toBe(false);
});

it("emits no warning when agent declares no user-managed files", () => {
probeSpy.mockReturnValue({ declared: [], existing: [] });

const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
const result = backupSandboxStateForRebuild(
"alpha",
makeSandboxEntry(),
false,
() => undefined,
() => true,
makeBail(),
);

expect(result).toBeTruthy();
expect(probeSpy).toHaveBeenCalledOnce();
const warnLines = warnSpy.mock.calls.map((args: unknown[]) => String(args[0]));
expect(warnLines.some((line: string) => line.includes("not preserved by rebuild"))).toBe(false);
});

it("skips probe when staleRecovery short-circuits the backup", () => {
const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
const result = backupSandboxStateForRebuild(
"alpha",
makeSandboxEntry(),
true,
() => undefined,
() => true,
makeBail(),
);

expect(result).toBeNull();
expect(backupSpy).not.toHaveBeenCalled();
expect(probeSpy).not.toHaveBeenCalled();
});

it("surfaces a user-visible warning when the probe errors but does not fail the backup", () => {
probeSpy.mockImplementation(() => {
throw new Error("ssh boom");
});

const { backupSandboxStateForRebuild } = loadRebuildFlowHelpers();
const result = backupSandboxStateForRebuild(
"alpha",
makeSandboxEntry(),
false,
() => undefined,
() => true,
makeBail(),
);

expect(result).toBeTruthy();
const warnLines = warnSpy.mock.calls.map((args: unknown[]) => String(args[0]));
expect(
warnLines.some((line: string) =>
line.includes("Could not check declared user-managed files"),
),
).toBe(true);
expect(warnLines.some((line: string) => line.includes("Re-add any user-managed files"))).toBe(
true,
);
expect(errorSpy).not.toHaveBeenCalled();
});
});
29 changes: 29 additions & 0 deletions src/lib/actions/sandbox/rebuild-flow-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { parseLiveSandboxNames } from "../../runtime-recovery";
import * as shields from "../../shields";
import * as registry from "../../state/registry";
import * as sandboxState from "../../state/sandbox";
import * as userManagedFilesProbe from "../../state/user-managed-files-probe";
import { loadAgent } from "../../agent/defs";
import { CLI_NAME } from "../../cli/branding";
import { resolveSandboxGatewayName } from "../../onboard/gateway-binding";
Expand Down Expand Up @@ -220,5 +221,33 @@ export function backupSandboxStateForRebuild(
);
}
console.log(` Backup: ${backupManifest.backupPath}`);
warnUnpreservedUserManagedFiles(sandboxName, log);
return backupManifest;
}

function warnUnpreservedUserManagedFiles(sandboxName: string, log: (msg: string) => void): void {
let probe: userManagedFilesProbe.UserManagedFilesProbe;
try {
probe = userManagedFilesProbe.probeUserManagedFiles(sandboxName);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`User-managed file probe errored: ${message}`);
console.warn(
` ${YW}⚠${R} Could not check declared user-managed files before rebuild (probe failed).`,
);
console.warn(
" Re-add any user-managed files you keep in the sandbox after rebuild, or manage them from the host.",
);
return;
}
if (probe.existing.length === 0) {
if (probe.declared.length > 0) {
log(`User-managed files declared but none present in sandbox: [${probe.declared.join(",")}]`);
}
return;
}
console.warn(
` ${YW}⚠${R} User-managed files in sandbox not preserved by rebuild: ${probe.existing.join(", ")}`,
);
console.warn(" Re-add them after rebuild, or manage them from the host.");
}
1 change: 1 addition & 0 deletions src/lib/agent/base-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
inferenceProviderOptions: [],
stateDirs: [],
stateFiles: [],
userManagedFiles: [],
versionCommand: "hermes --version",
expectedVersion: "2026.4.30",
hasDevicePairing: false,
Expand Down
Loading
Loading