diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 6eed68a8ab9..1eefe655b34 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -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 diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index cc7d3c4c44a..02530ba1fb3 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -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. Credential-bearing Hermes files such as `auth.json` are intentionally excluded from snapshots. diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index 4664876db1b..715b6120cf8 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -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; @@ -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; diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index f7bf87ecc13..c7d93db37cf 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -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"); @@ -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, @@ -228,6 +237,10 @@ export function loadAgent(name: string): AgentDefinition { return stateDirs ?? []; }, + get runtimeAuthStateDirs(): string[] { + return runtimeAuthStateDirs ?? []; + }, + get stateFiles(): AgentStateFile[] { return stateFiles ?? []; }, diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index d844558e53c..469e96446cd 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -30,6 +30,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini reason: "test fixture", }, stateDirs: [], + runtimeAuthStateDirs: [], stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index e74837db26a..78eaa117e63 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -31,6 +31,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { reason: "test fixture", }, stateDirs: [], + runtimeAuthStateDirs: [], stateFiles: [], userManagedFiles: [], versionCommand: "agent --version", diff --git a/src/lib/agent/runtime-auth-state-dirs.test.ts b/src/lib/agent/runtime-auth-state-dirs.test.ts new file mode 100644 index 00000000000..9f4b3413ec1 --- /dev/null +++ b/src/lib/agent/runtime-auth-state-dirs.test.ts @@ -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"])); + }); +}); diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index bbe020107c7..4b4da8dcaf3 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -25,6 +25,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { inferenceProviderOptions: [], mcpCapability: { support: "disabled", reason: "test fixture" }, stateDirs: [], + runtimeAuthStateDirs: [], stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 09aa0c7d5e7..0efdf74ffd1 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -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(",")}]`, @@ -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(); for (const targetFile of targetAgent.stateFiles) { const normalized = normalizeStateFilePath(targetFile.path); diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index 993b921baa4..999f878f078 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -45,6 +45,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini reason: "test fixture", }, stateDirs: [], + runtimeAuthStateDirs: [], stateFiles: [], userManagedFiles: [], versionCommand: "hermes --version", diff --git a/test/snapshot-runtime-auth-state.test.ts b/test/snapshot-runtime-auth-state.test.ts new file mode 100644 index 00000000000..2d14911ad19 --- /dev/null +++ b/test/snapshot-runtime-auth-state.test.ts @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Regression test for issue #6852: snapshot restore must never replace the +// sandbox's working gateway auth state (OpenClaw device identity keypair and +// paired-device token store) with backup copies. Backup sanitization scrubs +// key/token fields, so any backed-up copy of identity/ or devices/ is corrupt +// by construction; restoring it breaks gateway auth for every CLI client +// (GatewayCredentialsRequiredError) until the device is re-paired. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, describe, expect, it, vi } from "vitest"; + +// sandbox-state computes its backup root from HOME at module load time. +// vi.stubEnv records and restores the prior value (including unset) on teardown. +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-auth-home-")); +vi.stubEnv("HOME", TMP_HOME); + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "src", "lib", "state", "sandbox.ts")).href +)) as typeof import("../src/lib/state/sandbox.js"); + +afterAll(() => { + vi.unstubAllEnvs(); + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +/** + * Fake `openshell` and `ssh` executables mirroring the backup/restore SSH + * contract against a local sandbox-root directory. Unlike the config-only + * harness in openclaw-config-snapshot.test.ts, this one also implements the + * state-DIRECTORY contract: exist checks, the pre-backup audit, tar + * download/extract, pre-restore cleanup, and usability probes. + */ +function writeFakeSandboxBins(binDir: string, fakeRoot: string): void { + writeExecutable( + path.join(binDir, "openshell"), + `#!/bin/sh +if [ "$1" = "sandbox" ] && [ "$2" = "get" ]; then + printf '{"name":"%s"}\n' "\${3:-alpha}" + exit 0 +fi +if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ]; then + printf 'Host openshell-alpha\n HostName 127.0.0.1\n User sandbox\n' + exit 0 +fi +exit 0 +`, + ); + + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const fakeRoot = ${JSON.stringify(fakeRoot)}; +const dir = path.join(fakeRoot, ".openclaw"); +const cmd = process.argv[process.argv.length - 1] || ""; +function mapPath(p) { + return p.replace(/^\\/sandbox\\/\\.openclaw/, dir); +} +function readStdin() { + const chunks = []; + for (;;) { + const buf = Buffer.alloc(65536); + let n = 0; + try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { break; } + if (n === 0) break; + chunks.push(buf.subarray(0, n)); + } + return Buffer.concat(chunks); +} +// Backup: state-dir existence probe (piped through awk '!seen[$0]++'). +if (cmd.includes("!seen[$0]++")) { + const probes = [...cmd.matchAll(/\\[ -d '([^']+)' \\] && printf '%s\\\\n' '([^']+)'/g)]; + for (const m of probes) { + if (fs.existsSync(mapPath(m[1]))) process.stdout.write(m[2] + "\\n"); + } + process.exit(0); +} +// Backup: pre-backup symlink/hardlink audit — fixture has none. +if (cmd.includes("-printf")) { process.exit(0); } +// Backup: tar download of state dirs. +if (cmd.startsWith("tar -cf - -C ")) { + const names = [...cmd.matchAll(/'([^']+)'/g)].map((m) => m[1]); + const result = spawnSync("tar", ["-cf", "-", "-C", mapPath(names[0]), "--", ...names.slice(1)], { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + process.stdout.write(result.stdout || Buffer.alloc(0)); + process.exit(result.status || 0); +} +// Restore: pre-restore cleanup of target state dirs. +if (cmd.startsWith("rm -rf -- ")) { + for (const m of cmd.matchAll(/rm -rf -- '([^']+)'/g)) { + fs.rmSync(mapPath(m[1]), { recursive: true, force: true }); + } + process.exit(0); +} +// Restore: tar extract of the backup archive into the state dir. +if (cmd.includes("-xf - -C ")) { + const target = mapPath([...cmd.matchAll(/'([^']+)'/g)].map((m) => m[1])[0]); + const result = spawnSync("tar", ["--no-same-owner", "-xf", "-", "-C", target], { + input: readStdin(), + stdio: ["pipe", "pipe", "pipe"], + }); + process.exit(result.status || 0); +} +// Restore: best-effort chown; usability probe over restored dirs. +if (cmd.startsWith("chown ")) { process.exit(0); } +if (cmd.includes("[ -d ")) { process.exit(0); } +// Backup + config merge: read the live openclaw.json. +if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + process.stdout.write(fs.readFileSync(path.join(dir, "openclaw.json"))); + process.exit(0); +} +// Restore: staged openclaw.json write-back. +if (cmd.includes(".nemoclaw-restore") && cmd.includes("openclaw.json")) { + const configPath = path.join(dir, "openclaw.json"); + const restored = readStdin(); + if (cmd.includes("last-good")) { + fs.writeFileSync(path.join(dir, "openclaw.json.last-good"), restored); + } + fs.writeFileSync(configPath, restored); + if (cmd.includes("sha256sum") && cmd.includes(".config-hash")) { + const digest = require("crypto").createHash("sha256").update(fs.readFileSync(configPath)).digest("hex"); + fs.writeFileSync(path.join(dir, ".config-hash"), digest + " openclaw.json\\n"); + } + process.exit(0); +} +process.exit(0); +`, + ); +} + +function writeOpenClawRegistry(sandboxName: string): void { + fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent: null, + }, + }, + }), + ); +} + +const LIVE_DEVICE_IDENTITY = JSON.stringify({ + deviceId: "live-device", + publicKey: "live-public-key", + privateKey: "live-private-key", +}); +const LIVE_PAIRED_DEVICE = JSON.stringify({ + deviceId: "live-device", + token: "live-operator-token", +}); + +describe("runtime auth state across snapshot backup/restore (#6852)", () => { + it("never captures or restores device identity and pairing state", async () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-auth-")); + try { + const binDir = path.join(fixture, "bin"); + const fakeRoot = path.join(fixture, "sandbox-root"); + const openclawDir = path.join(fakeRoot, ".openclaw"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.join(openclawDir, "agents", "main"), { recursive: true }); + fs.mkdirSync(path.join(openclawDir, "identity"), { recursive: true }); + fs.mkdirSync(path.join(openclawDir, "devices"), { recursive: true }); + + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify({ gateway: { auth: { token: "live-gateway-token" } } }, null, 2), + ); + fs.writeFileSync(path.join(openclawDir, "agents", "main", "state.txt"), "old-agent-state"); + fs.writeFileSync(path.join(openclawDir, "identity", "device.json"), LIVE_DEVICE_IDENTITY); + fs.writeFileSync(path.join(openclawDir, "devices", "paired.json"), LIVE_PAIRED_DEVICE); + + writeFakeSandboxBins(binDir, fakeRoot); + writeOpenClawRegistry("alpha"); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", path.join(binDir, "openshell")); + vi.stubEnv("PATH", `${binDir}:${process.env.PATH || ""}`); + + // ── Backup: runtime auth dirs are not captured at all ────────── + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(true); + expect(backup.backedUpDirs).toContain("agents"); + expect(backup.backedUpDirs).not.toContain("identity"); + expect(backup.backedUpDirs).not.toContain("devices"); + expect(backup.manifest?.stateDirs).not.toContain("identity"); + expect(backup.manifest?.stateDirs).not.toContain("devices"); + const backupPath = backup.manifest!.backupPath!; + expect(fs.existsSync(path.join(backupPath, "agents", "main", "state.txt"))).toBe(true); + expect(fs.existsSync(path.join(backupPath, "identity"))).toBe(false); + expect(fs.existsSync(path.join(backupPath, "devices"))).toBe(false); + + // ── Legacy backup: simulate a pre-fix snapshot that captured the + // runtime auth dirs (credential-sanitized into corrupt placeholders) + // and listed them in its embedded manifest. ───────────────────── + fs.mkdirSync(path.join(backupPath, "identity"), { recursive: true }); + fs.mkdirSync(path.join(backupPath, "devices"), { recursive: true }); + fs.writeFileSync( + path.join(backupPath, "identity", "device.json"), + JSON.stringify({ + deviceId: "live-device", + publicKey: "[STRIPPED_BY_MIGRATION]", + privateKey: "[STRIPPED_BY_MIGRATION]", + }), + ); + fs.writeFileSync( + path.join(backupPath, "devices", "paired.json"), + JSON.stringify({ deviceId: "live-device", token: "[STRIPPED_BY_MIGRATION]" }), + ); + const manifestPath = path.join(backupPath, "rebuild-manifest.json"); + const legacyManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + legacyManifest.stateDirs = [...legacyManifest.stateDirs, "identity", "devices"]; + legacyManifest.backedUpDirs = [...legacyManifest.backedUpDirs, "identity", "devices"]; + fs.writeFileSync(manifestPath, JSON.stringify(legacyManifest, null, 2)); + + // Mutate live state so a clobbering restore is detectable. + fs.writeFileSync(path.join(openclawDir, "agents", "main", "state.txt"), "new-agent-state"); + + // ── Restore: durable dirs restored, runtime auth dirs untouched ─ + const restore = sandboxState.restoreSandboxState("alpha", backupPath); + expect(restore.success).toBe(true); + expect(restore.restoredDirs).toContain("agents"); + expect(restore.restoredDirs).not.toContain("identity"); + expect(restore.restoredDirs).not.toContain("devices"); + + // agents/ came back from the backup... + expect(fs.readFileSync(path.join(openclawDir, "agents", "main", "state.txt"), "utf-8")).toBe( + "old-agent-state", + ); + // ...while the live device identity and pairing tokens survived intact. + expect(fs.readFileSync(path.join(openclawDir, "identity", "device.json"), "utf-8")).toBe( + LIVE_DEVICE_IDENTITY, + ); + expect(fs.readFileSync(path.join(openclawDir, "devices", "paired.json"), "utf-8")).toBe( + LIVE_PAIRED_DEVICE, + ); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); +});