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
13 changes: 13 additions & 0 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ state_dirs:
- whatsapp
- credentials

# Machine-local gateway auth state: the Ed25519 device identity
# (identity/device.json) and paired-device token store (devices/). Backup
# sanitization scrubs their key/token fields, so a restored copy can never
# authenticate — restoring it replaces working pairing state with corrupt
# files and the CLI fails with GatewayCredentialsRequiredError (issue #6852).
# These dirs stay in state_dirs so destroy still wipes them from the durable
# volume, but they are never captured into or restored from snapshots;
# OpenClaw regenerates the identity on demand and NemoClaw auto-pair
# re-pairs on connect.
runtime_auth_state_dirs:
- identity
- devices

# ── Top-level durable state files ───────────────────────────────
# openclaw.json holds the core OpenClaw settings the state dirs above do not
# cover: model/provider config, MCP servers, custom agents, and channel
Expand Down
4 changes: 4 additions & 0 deletions docs/manage-sandboxes/backup-restore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ Snapshots also preserve user-owned `openclaw.json` settings.

During rebuild or restore, NemoClaw merges those settings with the freshly generated runtime config so current provider placeholders, messaging enablement, and gateway state win over stale snapshot values.
If the restored config cannot be parsed or applied safely, NemoClaw stops the restore instead of replacing the generated config with an unsafe fallback.

OpenClaw's device identity keys and paired-device tokens are intentionally excluded from snapshots because backup sanitization scrubs them beyond use.
Restore never touches the sandbox's current gateway pairing state, even when an older snapshot still contains those files.
OpenClaw regenerates its device identity on demand, and NemoClaw auto-pair re-pairs CLI clients on connect.
</AgentOnly>
<AgentOnly variant="hermes">
Credential-bearing Hermes files such as `auth.json` are intentionally excluded from snapshots.
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/definition-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export interface AgentDefinition {
inference?: AgentInference;
mcp?: AgentMcpCapability;
state_dirs?: string[];
runtime_auth_state_dirs?: string[];
state_files?: AgentStateFile[];
user_managed_files?: string[];
_legacy_paths?: StringMap;
Expand All @@ -135,6 +136,7 @@ export interface AgentDefinition {
readonly inferenceProviderOptions: string[];
readonly mcpCapability: AgentMcpCapability;
readonly stateDirs: string[];
readonly runtimeAuthStateDirs: string[];
readonly stateFiles: AgentStateFile[];
readonly userManagedFiles: string[];
readonly versionCommand: string;
Expand Down
13 changes: 13 additions & 0 deletions src/lib/agent/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ export function loadAgent(name: string): AgentDefinition {
const inference = readInference(raw);
const mcp = readMcpCapability(raw);
const stateDirs = readStringArray(raw, "state_dirs");
const runtimeAuthStateDirs = readStringArray(raw, "runtime_auth_state_dirs");
for (const dir of runtimeAuthStateDirs ?? []) {
if (!stateDirs?.includes(dir)) {
throw new Error(
`Agent manifest field 'runtime_auth_state_dirs' entry '${dir}' must also be listed in 'state_dirs'`,
);
}
}
const stateFiles = readStateFiles(raw);
const userManagedFiles = readUserManagedFiles(raw);
const phoneHomeHosts = readStringArray(raw, "phone_home_hosts");
Expand All @@ -165,6 +173,7 @@ export function loadAgent(name: string): AgentDefinition {
inference,
mcp,
state_dirs: stateDirs,
runtime_auth_state_dirs: runtimeAuthStateDirs,
state_files: stateFiles,
user_managed_files: userManagedFiles,
_legacy_paths: legacyPathConfig,
Expand Down Expand Up @@ -228,6 +237,10 @@ export function loadAgent(name: string): AgentDefinition {
return stateDirs ?? [];
},

get runtimeAuthStateDirs(): string[] {
return runtimeAuthStateDirs ?? [];
},

get stateFiles(): AgentStateFile[] {
return stateFiles ?? [];
},
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/hermes-recovery-boundary-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefini
reason: "test fixture",
},
stateDirs: [],
runtimeAuthStateDirs: [],
stateFiles: [],
userManagedFiles: [],
versionCommand: "test-agent --version",
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
reason: "test fixture",
},
stateDirs: [],
runtimeAuthStateDirs: [],
stateFiles: [],
userManagedFiles: [],
versionCommand: "agent --version",
Expand Down
84 changes: 84 additions & 0 deletions src/lib/agent/runtime-auth-state-dirs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";

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

import { AGENTS_DIR, loadAgent } from "./defs";

const tempAgentDirs: string[] = [];

function writeTempAgentManifest(name: string, contents: string): void {
const agentDir = path.join(AGENTS_DIR, name);
tempAgentDirs.push(agentDir);
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, "manifest.yaml"), contents);
}

afterEach(() => {
for (const agentDir of tempAgentDirs.splice(0)) {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});

describe("runtime_auth_state_dirs manifest field (#6852)", () => {
it("parses runtime_auth_state_dirs as a subset of state_dirs", () => {
const agentName = `runtime-auth-parse-${String(Date.now())}`;
writeTempAgentManifest(
agentName,
[
`name: ${agentName}`,
"display_name: RuntimeAuth",
"state_dirs:",
" - agents",
" - identity",
" - devices",
"runtime_auth_state_dirs:",
" - identity",
" - devices",
].join("\n"),
);

const agent = loadAgent(agentName);
expect(agent.stateDirs).toEqual(["agents", "identity", "devices"]);
expect(agent.runtimeAuthStateDirs).toEqual(["identity", "devices"]);
});

it("defaults to no runtime auth dirs when the field is absent", () => {
const agentName = `runtime-auth-absent-${String(Date.now())}`;
writeTempAgentManifest(
agentName,
[`name: ${agentName}`, "display_name: RuntimeAuth", "state_dirs:", " - agents"].join("\n"),
);

expect(loadAgent(agentName).runtimeAuthStateDirs).toEqual([]);
});

it("rejects a runtime auth dir that is not also a state dir", () => {
const agentName = `runtime-auth-orphan-${String(Date.now())}`;
writeTempAgentManifest(
agentName,
[
`name: ${agentName}`,
"display_name: RuntimeAuth",
"state_dirs:",
" - agents",
"runtime_auth_state_dirs:",
" - identity",
].join("\n"),
);

expect(() => loadAgent(agentName)).toThrow(
/runtime_auth_state_dirs.*'identity'.*must also be listed in 'state_dirs'/,
);
});

it("declares OpenClaw device identity and paired-device state as runtime auth dirs", () => {
const agent = loadAgent("openclaw");
expect(agent.runtimeAuthStateDirs).toEqual(["identity", "devices"]);
// Still wiped on destroy: the dirs must remain declared durable state.
expect(agent.stateDirs).toEqual(expect.arrayContaining(["identity", "devices"]));
});
});
1 change: 1 addition & 0 deletions src/lib/agent/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
inferenceProviderOptions: [],
mcpCapability: { support: "disabled", reason: "test fixture" },
stateDirs: [],
runtimeAuthStateDirs: [],
stateFiles: [],
userManagedFiles: [],
versionCommand: "test-agent --version",
Expand Down
19 changes: 18 additions & 1 deletion src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,11 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions =
const agentName = sb?.agent || "openclaw";
const agent = loadAgent(agentName);
const dir = agent.configPaths.dir;
const stateDirs = agent.stateDirs;
// Runtime auth state (device identity keypairs, paired-device tokens) is
// never captured: sanitizeBackupDirectory scrubs its key/token fields, so a
// backup copy could only ever restore as corrupt auth state (#6852).
const runtimeAuthStateDirs = new Set(agent.runtimeAuthStateDirs);
const stateDirs = agent.stateDirs.filter((d) => !runtimeAuthStateDirs.has(d));
const stateFiles = normalizeStateFileSpecs(agent.stateFiles);
_log(
`backupSandboxState: agent=${agentName}, dir=${dir}, stateDirs=[${stateDirs.join(",")}], stateFiles=[${stateFiles.map((f) => f.path).join(",")}]`,
Expand Down Expand Up @@ -1390,6 +1394,19 @@ function restoreSandboxStateInternal(
`Backup state directory '${normalizedBackupDir}' does not match target directory '${normalizedTargetDir}'`,
);
}
// Runtime auth state is never restored: its backup copies are
// credential-scrubbed and would replace the sandbox's working device
// identity and pairing tokens with corrupt files (#6852). The current
// target manifest is authoritative here so legacy backups whose embedded
// manifests still list these dirs are also skipped.
const targetRuntimeAuthDirs = new Set(targetAgent.runtimeAuthStateDirs);
const skippedRuntimeAuthDirs = localDirs.filter((d) => targetRuntimeAuthDirs.has(d));
if (skippedRuntimeAuthDirs.length > 0) {
_log(`Skipping runtime auth state dirs from restore: [${skippedRuntimeAuthDirs.join(",")}]`);
for (const d of skippedRuntimeAuthDirs) {
localDirs.splice(localDirs.indexOf(d), 1);
}
}
const targetStateFiles = new Map<string, AgentStateFile>();
for (const targetFile of targetAgent.stateFiles) {
const normalized = normalizeStateFilePath(targetFile.path);
Expand Down
1 change: 1 addition & 0 deletions test/helpers/base-image-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefini
reason: "test fixture",
},
stateDirs: [],
runtimeAuthStateDirs: [],
stateFiles: [],
userManagedFiles: [],
versionCommand: "hermes --version",
Expand Down
Loading
Loading