Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
004846e
feat(state): add key-level restore ownership to agent manifests
laitingsheng Jul 8, 2026
f5db189
fix(state): make key-allowlist restore ownership authoritative on reb…
laitingsheng Jul 8, 2026
27ba5d7
refactor(agent): extract state-file restore ownership parsing into it…
laitingsheng Jul 8, 2026
a25f9ed
fix(state): reject non-finite numbers and unsafe paths in key-allowli…
laitingsheng Jul 8, 2026
91b39e1
test(agent): keep restore-reader cleanup linear for growth guardrail
laitingsheng Jul 9, 2026
44a248c
merge: bring in latest main
laitingsheng Jul 9, 2026
3d0fd9b
merge: bring in latest main
laitingsheng Jul 9, 2026
2ea27e5
fix(agent): fail closed on unknown fresh-header fields and non-string…
laitingsheng Jul 9, 2026
ae2d89d
fix(state): sort TOML keys deterministically before serializing merge…
laitingsheng Jul 9, 2026
fcf2e0a
test(sandbox): cover positive applyManagedStateFileRestore dispatch o…
laitingsheng Jul 9, 2026
cfab14a
fix(state): warn when agent manifest load fails during restore owners…
laitingsheng Jul 9, 2026
af2238f
fix(state): re-verify config parent directory is not a symlink before…
laitingsheng Jul 9, 2026
9857603
merge: bring in latest main
laitingsheng Jul 9, 2026
e9e4e14
fix(state): open config parent via O_NOFOLLOW dir_fd for staged write…
laitingsheng Jul 9, 2026
53407b7
merge: bring in latest main
laitingsheng Jul 9, 2026
6641d7f
merge: sync current main
cjagwani Jul 9, 2026
dd65850
fix(state): harden key-level restore ownership
cjagwani Jul 9, 2026
f9e4537
chore(onboard): keep entrypoint net-neutral
cjagwani Jul 9, 2026
78b59a2
Merge remote-tracking branch 'origin/main' into feat/manifest-key-lev…
laitingsheng Jul 10, 2026
68415a3
test(state): add file-safety and real TOML coverage for key-allowlist…
laitingsheng Jul 10, 2026
43cfaeb
fix(test): repair sandbox snapshot test assertions broken by main merge
laitingsheng Jul 10, 2026
06ed085
fix(test): type streamSandboxCreateMock to the current 4-arg signature
laitingsheng Jul 10, 2026
4916799
Merge remote-tracking branch 'origin/feat/manifest-key-level-state-ow…
laitingsheng Jul 10, 2026
88f53cb
fix(test): restructure conditional test setup to satisfy growth guard…
laitingsheng Jul 10, 2026
5b5a494
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 10, 2026
0713daa
refactor(state): decompose restore ownership helpers
cv Jul 10, 2026
97e44b2
test(state): split restore ownership hotspots
cv Jul 10, 2026
e2daeb0
test(state): cover custom-image whole-file restore
cv Jul 10, 2026
38b8a6a
test(state): clarify snapshot restore delegation
cv Jul 10, 2026
506ceed
test(state): require custom restore capability
cv Jul 10, 2026
306d9a3
fix(state): reject duplicate backup state files
cv Jul 10, 2026
95ab6c8
merge(main): sync current release target
cjagwani Jul 10, 2026
b2725d5
Merge remote-tracking branch 'origin/main' into feat/manifest-key-lev…
laitingsheng Jul 11, 2026
6458da7
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 11, 2026
144f8e7
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 11, 2026
b19dda9
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 11, 2026
775e65f
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 11, 2026
883aa56
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 11, 2026
7e3ffe9
Merge branch 'main' into feat/manifest-key-level-state-ownership
cv Jul 11, 2026
8c6e699
fix(state): exclude ChatGPT auth from snapshots
cv Jul 12, 2026
10b2361
merge: sync current main
cv Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions agents/langchain-deepagents-code/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,11 @@ state_dirs:

# ── Top-level durable state files ───────────────────────────────
# config.toml mixes DCode preferences with NemoClaw-managed model routing.
# Managed re-onboard restore carries forward only boolean ui.show_scrollbar,
# ui.show_url_open_toast, and threads.relative_time preferences, plus
# threads.sort_order when it is updated_at or created_at. Fresh models/update
# tables and provider metadata remain authoritative. All other backup keys,
# including ui.theme and behavior-bearing, unknown, or security-sensitive keys,
# are dropped.
# Ownership is split into three explicit buckets. Users own only the allowlisted
# ui.show_scrollbar, ui.show_url_open_toast, threads.relative_time, and
# threads.sort_order preferences below. NemoClaw owns the fresh models/update
# tables and generated provider headers. Agent-runtime, unknown, executable, and
# security-sensitive backup keys are not restorable and are dropped.
# .env and user-authored .deepagents/.mcp.json content are intentionally omitted
# because they may contain service credentials. NemoClaw writes only direct-HTTP
# bridge endpoint config and OpenShell placeholders to its separate
Expand All @@ -62,6 +61,29 @@ state_dirs:
# not user-authored durable state.
state_files:
- path: config.toml
restore:
merge: key-allowlist
require_fresh_tables:
- models
- update
# Checked positionally: the first N leading '#' lines (N = entries below)
# must match. Further leading comments are safety-checked and preserved.
require_fresh_headers:
- "# Generated by NemoClaw. This file contains no provider secrets."
- match: prefix
value: "# NemoClaw provider route: "
user_keys:
- key: ui.show_scrollbar
type: boolean
- key: ui.show_url_open_toast
type: boolean
- key: threads.relative_time
type: boolean
- key: threads.sort_order
type: enum
values:
- updated_at
- created_at
user_managed_files:
- .deepagents/.env
- .deepagents/.mcp.json
Expand Down
2 changes: 2 additions & 0 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ state_dirs:
# credentials.
state_files:
- path: openclaw.json
restore:
merge: openclaw-config
user_managed_files:
- .env
- .mcp.json
Expand Down
10 changes: 9 additions & 1 deletion docs/manage-sandboxes/backup-restore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ The Hermes database can contain session metadata and message history needed for
</AgentOnly>
Snapshots also preserve sandbox registry metadata that affects rebuild behavior, including custom policy presets applied with `policy-add --from-file` or `policy-add --from-dir`.
When you restore a snapshot, NemoClaw replays those recorded custom presets with their stored YAML content, so you do not need the original preset files on disk for the restored sandbox to keep the same policy state.
The target sandbox's current agent manifest remains authoritative for state-file restore behavior.
NemoClaw rejects the restore when the snapshot's agent, config directory, state-file path, or state-file strategy conflicts with that manifest.
For managed images, NemoClaw applies the current manifest's managed config merge rules by default and does not fall back to whole-file replacement.
For Deep Agents targets, whole-file config replacement is limited to sandboxes created from a custom Dockerfile.

```bash
$$nemoclaw my-assistant snapshot create
Expand Down Expand Up @@ -126,7 +130,11 @@ NemoClaw rejects unsafe symlinks and hard links inside sandbox state during back
Credential-bearing Deep Agents files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are intentionally excluded from snapshots.
Deep Agents auth state files such as `.deepagents/.state/auth.json` and `.deepagents/.state/chatgpt-auth.json` are also excluded because the managed launcher refuses to start when upstream credential state is present.
The managed `.deepagents/.nemoclaw-mcp.json` projection and `hooks.json` are also excluded because NemoClaw reconstructs managed MCP state and disables executable Deep Agents Code hooks in the managed harness.
NemoClaw recreates generated `config.toml`, managed MCP projection state, and provider credentials from host-side onboarding and OpenShell provider state during rebuild.
NemoClaw recreates the current inference route headers, `models` and `update` tables, managed MCP projection state, and provider credentials from host-side onboarding and OpenShell provider state during rebuild.
For a NemoClaw-managed Deep Agents image, it restores only the allowlisted `ui.show_scrollbar`, `ui.show_url_open_toast`, `threads.relative_time`, and `threads.sort_order` preferences from the previous `config.toml` when their values pass validation.
Unknown, runtime-controlled, executable, and security-sensitive backup keys are dropped instead of replacing freshly generated settings on that managed path.
A Deep Agents target created from a custom Dockerfile restores `config.toml` as a whole file because the custom image owns its config schema, so review that backup before restoring it.
On the managed key-level restore path, if either config is malformed, required managed data is missing, or the restore detects an unsafe link or file replacement, NemoClaw marks the restore as failed and does not fall back to copying the backed-up file wholesale.
Before a Deep Agents rebuild changes the sandbox, NemoClaw verifies the recorded inference route, provider, model, reasoning settings, web search selection, base image, and policy inputs.
If a late check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact.

Expand Down
10 changes: 8 additions & 2 deletions docs/manage-sandboxes/workspace-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ The Deep Agents manifest declares these durable directories:
/sandbox/.deepagents/agent/skills/
```

It also declares `/sandbox/.deepagents/config.toml` as a durable top-level state file because the file contains non-secret NemoClaw-generated provider and model configuration.
It also declares `/sandbox/.deepagents/config.toml` as a durable top-level state file with key-level ownership.
The target sandbox's current Deep Agents manifest defines this ownership policy, so a snapshot cannot weaken it.
NemoClaw keeps the newly generated inference route headers and the `models` and `update` tables authoritative during rebuild.
On a NemoClaw-managed image, only the allowlisted `ui.show_scrollbar`, `ui.show_url_open_toast`, `threads.relative_time`, and `threads.sort_order` preferences can be restored from the previous file.
Runtime-controlled, unknown, executable, and security-sensitive backup keys are dropped on that managed path.
A Deep Agents target created from a custom Dockerfile restores `config.toml` as a whole file because the custom image owns its config schema.
On the managed key-level restore path, if config validation or safe atomic replacement fails, NemoClaw marks the restore as failed instead of falling back to a whole-file copy.
Credential-bearing files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are intentionally omitted from snapshots.
Managed MCP state is rebuilt from the host-side NemoClaw registry and OpenShell provider state instead of treated as user-authored durable state.
Memory files such as `/sandbox/.deepagents/agent/AGENTS.md` and `/sandbox/.deepagents/agent/memories/` are upstream Deep Agents Code files.
Expand All @@ -204,7 +210,7 @@ Back up important Deep Agents state before destroying the sandbox.
## Editing State

Prefer NemoClaw host commands for generated configuration such as model, provider, managed MCP, and policy settings.
Direct edits to `/sandbox/.deepagents/config.toml` can be overwritten by rebuilds because NemoClaw owns the managed inference route.
Direct edits to NemoClaw-owned or non-allowlisted keys in `/sandbox/.deepagents/config.toml` can be overwritten by rebuilds.
Use `$$nemoclaw <name> connect` when you need to inspect runtime files interactively, or use `openshell sandbox download` and `openshell sandbox upload` for manual file transfer.

Use Deep Agents Code commands for upstream-managed memories and skills.
Expand Down
2 changes: 2 additions & 0 deletions src/lib/actions/sandbox/rebuild-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ async function rebuildSandboxUnlocked(

const restored = runRebuildRestorePhase({
sandboxName,
targetAgentType: rebuildAgent || "openclaw",
targetImageIsCustom: Boolean(fromDockerfile),
backupManifest: backup.backupManifest,
policyPresets: targetPolicyPresets,
customPolicies:
Expand Down
42 changes: 42 additions & 0 deletions src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it, vi } from "vitest";

import * as sandboxState from "../../state/sandbox";
import { runRebuildRestorePhase } from "./rebuild-restore-phase";

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

describe("rebuild restore target forwarding", () => {
it("forwards the recreated target identity and explicit custom-image capability", () => {
vi.spyOn(console, "log").mockImplementation(() => undefined);
const restoreRecreatedSandboxState = vi
.spyOn(sandboxState, "restoreRecreatedSandboxState")
.mockReturnValue({
success: true,
restoredDirs: [],
restoredFiles: [],
failedDirs: [],
failedFiles: [],
});

runRebuildRestorePhase({
sandboxName: "alpha",
targetAgentType: "langchain-deepagents-code",
targetImageIsCustom: true,
backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never,
policyPresets: [],
customPolicies: [],
reconcileManagedDcodeObservability: false,
log: vi.fn(),
});

expect(restoreRecreatedSandboxState).toHaveBeenCalledWith("alpha", "/tmp/rebuild-backup", {
targetAgentType: "langchain-deepagents-code",
allowCustomImageWholeStateFileRestore: true,
});
});
});
45 changes: 29 additions & 16 deletions src/lib/actions/sandbox/rebuild-restore-phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ import { runRebuildRestorePhase } from "./rebuild-restore-phase";
const BUILTIN_OBSERVABILITY_CONTENT =
"network_policies:\n observability-otlp-local:\n name: observability-otlp-local\n";

type StandardRestoreOptions = Omit<
Parameters<typeof runRebuildRestorePhase>[0],
"targetAgentType" | "targetImageIsCustom"
>;

function runStandardRebuildRestorePhase(options: StandardRestoreOptions) {
return runRebuildRestorePhase({
...options,
targetAgentType: "openclaw",
targetImageIsCustom: false,
});
}

describe("rebuild policy restore fidelity", () => {
beforeEach(() => {
vi.spyOn(policies, "loadPresetForSandbox").mockImplementation((_sandboxName, presetName) =>
Expand All @@ -40,7 +53,7 @@ describe("rebuild policy restore fidelity", () => {
error: "could not read fresh OpenClaw plugin install registry",
});

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never,
policyPresets: [],
Expand Down Expand Up @@ -78,7 +91,7 @@ describe("rebuild policy restore fidelity", () => {
content: `network_policies:\n ${name}-custom:\n name: ${name}-custom\n`,
sourcePath: `/tmp/${name}.yaml`,
}));
const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: {
agentType: "openclaw",
Expand Down Expand Up @@ -126,7 +139,7 @@ describe("rebuild policy restore fidelity", () => {
sourcePath: "/tmp/custom-egress.yaml",
},
];
const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand Down Expand Up @@ -159,7 +172,7 @@ describe("rebuild policy restore fidelity", () => {
"network_policies:\n mcp-bridge-search:\n endpoints:\n - host: mcp.example.com\n allowed_ips: [203.0.113.10]\n",
sourcePath: MCP_BRIDGE_POLICY_SOURCE,
};
const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand Down Expand Up @@ -188,7 +201,7 @@ describe("rebuild policy restore fidelity", () => {
.mockReturnValueOnce("absent");
const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["npm"],
Expand All @@ -213,7 +226,7 @@ describe("rebuild policy restore fidelity", () => {
.mockReturnValueOnce(null);
vi.spyOn(policies, "removePreset").mockReturnValue(true);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["npm"],
Expand Down Expand Up @@ -243,7 +256,7 @@ describe("rebuild policy restore fidelity", () => {
throw new Error("apply failed");
});

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["npm", "bad", "throw"],
Expand All @@ -264,7 +277,7 @@ describe("rebuild policy restore fidelity", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(policies, "applyPreset").mockReturnValue(true);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["observability-otlp-local"],
Expand All @@ -285,7 +298,7 @@ describe("rebuild policy restore fidelity", () => {
vi.spyOn(policies, "applyPreset").mockReturnValue(true);
vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue(null);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["observability-otlp-local"],
Expand All @@ -301,7 +314,7 @@ describe("rebuild policy restore fidelity", () => {
it("does not remove or persist DCode base-policy keys detected as broad presets", () => {
const removePreset = vi.spyOn(policies, "removePreset");

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand All @@ -326,7 +339,7 @@ describe("rebuild policy restore fidelity", () => {
sourcePath: "/tmp/operator-collector.yaml",
};

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand Down Expand Up @@ -358,7 +371,7 @@ describe("rebuild policy restore fidelity", () => {
sourcePath: "/tmp/corp-otel.yaml",
};

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand Down Expand Up @@ -392,7 +405,7 @@ describe("rebuild policy restore fidelity", () => {
.mockReturnValueOnce("absent");
const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["observability-otlp-local"],
Expand Down Expand Up @@ -421,7 +434,7 @@ describe("rebuild policy restore fidelity", () => {
.mockReturnValueOnce("absent");
const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand Down Expand Up @@ -449,7 +462,7 @@ describe("rebuild policy restore fidelity", () => {
vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("drift");
const removePreset = vi.spyOn(policies, "removePreset");

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: [],
Expand All @@ -476,7 +489,7 @@ describe("rebuild policy restore fidelity", () => {
.mockReturnValueOnce(null);
vi.spyOn(policies, "removePreset").mockReturnValue(true);

const result = runRebuildRestorePhase({
const result = runStandardRebuildRestorePhase({
sandboxName: "alpha",
backupManifest: null,
policyPresets: ["observability-otlp-local"],
Expand Down
9 changes: 8 additions & 1 deletion src/lib/actions/sandbox/rebuild-restore-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type { RebuildSandboxEntry } from "./rebuild-flow-helpers";

export interface RebuildRestorePhaseInput {
sandboxName: string;
targetAgentType: string;
targetImageIsCustom: boolean;
backupManifest: RebuildBackupManifest;
policyPresets: string[];
customPolicies: NonNullable<RebuildSandboxEntry["customPolicies"]>;
Expand Down Expand Up @@ -176,6 +178,8 @@ function reconcileFinalManagedObservability(
export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): RebuildRestorePhaseResult {
const {
sandboxName,
targetAgentType,
targetImageIsCustom,
backupManifest,
policyPresets,
customPolicies,
Expand All @@ -190,7 +194,10 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild
const restore = sandboxState.restoreRecreatedSandboxState(
sandboxName,
backupManifest.backupPath,
{ targetAgentType: backupManifest.agentType },
{
targetAgentType,
...(targetImageIsCustom ? { allowCustomImageWholeStateFileRestore: true } : {}),
},
);
log(
`Restore result: success=${restore.success}, restored=${restore.restoredDirs.join(",")}; files=${restore.restoredFiles.join(",")}, failed=${restore.failedDirs.join(",")}; failedFiles=${restore.failedFiles.join(",")}${restore.error ? `; error=${restore.error}` : ""}`,
Expand Down
5 changes: 4 additions & 1 deletion src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ vi.mock("../../inference/nim", () => ({
stopNimContainer: vi.fn(),
stopNimContainerByName: vi.fn(),
}));
vi.mock("../../messaging/channels", () => ({ listMessagingProviderSuffixes: vi.fn(() => []) }));
vi.mock("../../messaging/channels", () => ({
listMessagingProviderSuffixes: vi.fn(() => []),
listMessagingCredentialMetadata: vi.fn(() => []),
}));
vi.mock("../../policy", () => ({
applyPreset: vi.fn(() => true),
applyPresetContent: vi.fn(() => true),
Expand Down
Loading
Loading