diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index ae9fc8960d9..0987273f11e 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -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. diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 2501656537d..13409df4a31 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -50,6 +50,9 @@ state_dirs: state_files: - path: config.toml - path: hooks.json +user_managed_files: + - .env + - .mcp.json device_pairing: false diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 3ff1099d6c1..ba2f95a2f69 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -67,6 +67,9 @@ state_dirs: # credentials. state_files: - path: openclaw.json +user_managed_files: + - .env + - .mcp.json # ── Authentication ────────────────────────────────────────────── device_pairing: true diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts new file mode 100644 index 00000000000..32d9c23d78d --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts @@ -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 { + 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["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[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(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index 0b2f92b2795..3676c35f8f9 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -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"; @@ -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."); +} diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index b3481f2aab7..7861c428594 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -34,6 +34,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { inferenceProviderOptions: [], stateDirs: [], stateFiles: [], + userManagedFiles: [], versionCommand: "hermes --version", expectedVersion: "2026.4.30", hasDevicePairing: false, diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 770d610abc6..276b1424d5a 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -60,6 +60,7 @@ describe("agent definitions", () => { // #5027: openclaw.json must be declared as a durable state file so // backup-all/rebuild preserve core settings (model/provider, MCP, agents). expect(openclaw.stateFiles).toEqual([{ path: "openclaw.json", strategy: "copy" }]); + expect(openclaw.userManagedFiles).toEqual([".env", ".mcp.json"]); expect(openclaw.legacyPaths?.startScript).toContain("scripts/nemoclaw-start.sh"); }); @@ -96,6 +97,7 @@ describe("agent definitions", () => { "whatsapp", "teams", ]); + expect(hermes.userManagedFiles).toEqual([".hermes/.env"]); }); it("loads the LangChain Deep Agents Code terminal acceptance contract", () => { @@ -130,6 +132,7 @@ describe("agent definitions", () => { { path: "hooks.json", strategy: "copy" }, ]); expect(deepAgentsCode.stateFiles.map((entry) => entry.path)).not.toContain(".env"); + expect(deepAgentsCode.userManagedFiles).toEqual([".env", ".mcp.json"]); }); it("orders OpenClaw first in interactive choices", () => { @@ -343,4 +346,89 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/runtime\.smoke_commands/); }); + + it("rejects non-string user_managed_files entries", () => { + const agentName = `invalid-umf-nonstring-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Broken UMF", + "user_managed_files:", + " - .env", + " - 42", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[1\].*string/); + }); + + it("rejects non-array user_managed_files values", () => { + const agentName = `invalid-umf-nonarray-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "display_name: Broken UMF", "user_managed_files: not-an-array"].join( + "\n", + ), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_managed_files.*must be an array/); + }); + + it("rejects empty-string user_managed_files entries", () => { + const agentName = `invalid-umf-empty-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "display_name: Broken UMF", "user_managed_files:", ' - ""'].join( + "\n", + ), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*empty/); + }); + + it("rejects absolute paths in user_managed_files entries", () => { + const agentName = `invalid-umf-absolute-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Broken UMF", + "user_managed_files:", + " - /sandbox/.env", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*absolute/); + }); + + it("rejects '..' traversal in user_managed_files entries", () => { + const agentName = `invalid-umf-traversal-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Broken UMF", + "user_managed_files:", + ' - "../secret"', + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*'\.\.'/); + }); + + it("rejects control characters in user_managed_files entries", () => { + const agentName = `invalid-umf-control-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Broken UMF", + "user_managed_files:", + ' - ".env\\n.malicious"', + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*control characters/); + }); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index 982e0c16ae9..71edbcf6988 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -82,6 +82,7 @@ export interface AgentDefinition { inference?: AgentInference; state_dirs?: string[]; state_files?: AgentStateFile[]; + user_managed_files?: string[]; messaging_platforms?: { supported?: string[] }; _legacy_paths?: StringMap; agentDir: string; @@ -95,6 +96,7 @@ export interface AgentDefinition { readonly inferenceProviderOptions: string[]; readonly stateDirs: string[]; readonly stateFiles: AgentStateFile[]; + readonly userManagedFiles: string[]; readonly versionCommand: string; readonly expectedVersion: string | null; readonly hasDevicePairing: boolean; @@ -162,6 +164,46 @@ function readStringArray(record: ManifestRecord, key: string): string[] | undefi return value.filter((entry): entry is string => typeof entry === "string"); } +const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; + +function readUserManagedFiles(record: ManifestRecord): string[] | undefined { + const value = record.user_managed_files; + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new Error("Agent manifest field 'user_managed_files' must be an array"); + } + + return value.map((entry, index) => { + if (typeof entry !== "string") { + throw new Error( + `Agent manifest field 'user_managed_files[${String(index)}]' must be a string`, + ); + } + if (entry.length === 0) { + throw new Error( + `Agent manifest field 'user_managed_files[${String(index)}]' must not be empty`, + ); + } + if (CONTROL_CHAR_RE.test(entry)) { + throw new Error( + `Agent manifest field 'user_managed_files[${String(index)}]' must not contain control characters`, + ); + } + if (entry.startsWith("/")) { + throw new Error( + `Agent manifest field 'user_managed_files[${String(index)}]' must be a relative path, not absolute`, + ); + } + const segments = entry.split("/"); + if (segments.some((segment) => segment === "..")) { + throw new Error( + `Agent manifest field 'user_managed_files[${String(index)}]' must not contain '..' path components`, + ); + } + return entry; + }); +} + function readStateFiles(record: ManifestRecord): AgentStateFile[] | undefined { const value = record.state_files; if (value === undefined) return undefined; @@ -384,6 +426,7 @@ export function loadAgent(name: string): AgentDefinition { const inference = readInference(raw); const stateDirs = readStringArray(raw, "state_dirs"); const stateFiles = readStateFiles(raw); + const userManagedFiles = readUserManagedFiles(raw); const phoneHomeHosts = readStringArray(raw, "phone_home_hosts"); const messagingPlatforms = readMessagingPlatforms(raw); const legacyPathConfig = readStringMap(raw, "_legacy_paths"); @@ -407,6 +450,7 @@ export function loadAgent(name: string): AgentDefinition { inference, state_dirs: stateDirs, state_files: stateFiles, + user_managed_files: userManagedFiles, messaging_platforms: messagingPlatforms, _legacy_paths: legacyPathConfig, agentDir, @@ -465,6 +509,10 @@ export function loadAgent(name: string): AgentDefinition { return stateFiles ?? []; }, + get userManagedFiles(): string[] { + return userManagedFiles ?? []; + }, + get versionCommand(): string { return versionCommand ?? `${binaryPath ?? "unknown"} --version`; }, diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index a3e6d56cb9a..56c877d8e8c 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -26,6 +26,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini inferenceProviderOptions: [], stateDirs: [], stateFiles: [], + userManagedFiles: [], versionCommand: "test-agent --version", expectedVersion: null, hasDevicePairing: false, diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 27d1ba8d739..f78f6ee2ef1 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -27,6 +27,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { inferenceProviderOptions: [], stateDirs: [], stateFiles: [], + userManagedFiles: [], versionCommand: "agent --version", expectedVersion: null, hasDevicePairing: false, diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index b0ac296fa42..d442326d65a 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -29,6 +29,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { inferenceProviderOptions: [], stateDirs: [], stateFiles: [], + userManagedFiles: [], versionCommand: "test-agent --version", expectedVersion: null, hasDevicePairing: false, diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index c64767b8841..cffe18df1f1 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -466,7 +466,7 @@ export function safeTarExtract(tarBuffer: Buffer, targetDir: string): SafeExtrac // ── Helpers ──────────────────────────────────────────────────────── -function getSshConfig(sandboxName: string): string | null { +export function getSshConfig(sandboxName: string): string | null { const openshellBinary = resolveOpenshell(); if (!openshellBinary) return null; @@ -478,7 +478,7 @@ function getSshConfig(sandboxName: string): string | null { return result.output; } -function sshArgs(configFile: string, sandboxName: string): string[] { +export function sshArgs(configFile: string, sandboxName: string): string[] { return [ "-F", configFile, diff --git a/src/lib/state/user-managed-files-probe.test.ts b/src/lib/state/user-managed-files-probe.test.ts new file mode 100644 index 00000000000..19961259d9f --- /dev/null +++ b/src/lib/state/user-managed-files-probe.test.ts @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import child_process from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type SandboxStateModule = typeof import("../../../dist/lib/state/sandbox"); +type RegistryModule = typeof import("../../../dist/lib/state/registry"); +type DefsModule = typeof import("../../../dist/lib/agent/defs"); +type ProbeModule = typeof import("../../../dist/lib/state/user-managed-files-probe"); + +const requireDist = createRequire(import.meta.url); +const sandboxStatePath = "../../../dist/lib/state/sandbox.js"; +const registryPath = "../../../dist/lib/state/registry.js"; +const defsPath = "../../../dist/lib/agent/defs.js"; +const probePath = "../../../dist/lib/state/user-managed-files-probe.js"; + +function loadProbe(): ProbeModule { + delete require.cache[requireDist.resolve(probePath)]; + return requireDist(probePath); +} + +function loadSandboxState(): SandboxStateModule { + return requireDist(sandboxStatePath); +} + +function loadRegistry(): RegistryModule { + return requireDist(registryPath); +} + +function loadDefs(): DefsModule { + return requireDist(defsPath); +} + +function makeFakeAgent(declared: string[]): ReturnType { + return { + name: "fake-agent", + displayName: "Fake Agent", + description: null, + binaryPath: null, + versionCommand: "fake --version", + expectedVersion: null, + hasDevicePairing: false, + phoneHomeHosts: [], + runtime: { kind: "terminal" }, + healthProbe: null, + forwardPort: 0, + dashboard: { kind: "headless" }, + dashboardUi: null, + configPaths: { + dir: "/sandbox/.fake", + configFile: "config.toml", + envFile: null, + format: "toml", + }, + inferenceProviderOptions: [], + stateDirs: [], + stateFiles: [], + userManagedFiles: declared, + messagingPlatforms: [], + agentDir: "/tmp/fake-agent", + manifestPath: "/tmp/fake-agent/manifest.yaml", + } as unknown as ReturnType; +} + +describe("probeUserManagedFiles", () => { + let recordedArgs: string[][]; + let spawnSpy: ReturnType; + let tempSshFiles: Set; + let originalMkdtempSync: typeof fs.mkdtempSync; + + beforeEach(() => { + recordedArgs = []; + tempSshFiles = new Set(); + const sandboxState = loadSandboxState(); + const registry = loadRegistry(); + const defs = loadDefs(); + + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "alpha", + agent: "fake-agent", + } as unknown as ReturnType); + vi.spyOn(defs, "loadAgent").mockImplementation(() => makeFakeAgent([".env", ".mcp.json"])); + vi.spyOn(sandboxState, "getSshConfig").mockReturnValue( + "Host openshell-alpha\n HostName 127.0.0.1\n", + ); + + originalMkdtempSync = fs.mkdtempSync; + vi.spyOn(fs, "mkdtempSync").mockImplementation(((prefix: string): string => { + const dir = originalMkdtempSync.call(fs, prefix) as string; + tempSshFiles.add(dir); + return dir; + }) as unknown as typeof fs.mkdtempSync); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempSshFiles) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + }); + + function stubSpawnSync(stdout: string, status: number, stderr = ""): void { + spawnSpy = vi.spyOn(child_process, "spawnSync").mockImplementation((( + command: string, + args?: readonly string[], + ) => { + const argList = Array.isArray(args) ? [...args] : []; + const sshCall = command === "ssh" && argList.length > 0; + sshCall && recordedArgs.push(argList); + return { + status, + signal: null, + output: [], + pid: 0, + stdout, + stderr, + } as ReturnType; + }) as typeof child_process.spawnSync); + } + + it("probes declared user-managed files at the sandbox root, not the agent config dir", () => { + stubSpawnSync(".env\n.mcp.json\n", 0); + const { probeUserManagedFiles, USER_MANAGED_FILES_BASE } = loadProbe(); + + const result = probeUserManagedFiles("alpha"); + + expect(USER_MANAGED_FILES_BASE).toBe("/sandbox"); + expect(result.declared).toEqual([".env", ".mcp.json"]); + expect(result.existing).toEqual([".env", ".mcp.json"]); + expect(spawnSpy).toHaveBeenCalledOnce(); + const lastArgs = recordedArgs[0] ?? []; + const probeCmd = lastArgs[lastArgs.length - 1] ?? ""; + expect(probeCmd).toContain("if [ -f '/sandbox/.env' ]"); + expect(probeCmd).toContain("if [ -f '/sandbox/.mcp.json' ]"); + expect(probeCmd).not.toContain("/sandbox/.fake/"); + }); + + it("supports nested declared files relative to the sandbox root", () => { + const defs = loadDefs(); + vi.spyOn(defs, "loadAgent").mockImplementation(() => makeFakeAgent([".hermes/.env"])); + stubSpawnSync(".hermes/.env\n", 0); + const { probeUserManagedFiles } = loadProbe(); + + const result = probeUserManagedFiles("alpha"); + + expect(result.declared).toEqual([".hermes/.env"]); + expect(result.existing).toEqual([".hermes/.env"]); + const lastArgs = recordedArgs[0] ?? []; + const probeCmd = lastArgs[lastArgs.length - 1] ?? ""; + expect(probeCmd).toContain("if [ -f '/sandbox/.hermes/.env' ]"); + }); + + it("returns empty existing when ssh exits 0 with no stdout (no declared files present)", () => { + stubSpawnSync("", 0); + const { probeUserManagedFiles } = loadProbe(); + + const result = probeUserManagedFiles("alpha"); + expect(result.declared).toEqual([".env", ".mcp.json"]); + expect(result.existing).toEqual([]); + }); + + it("throws when ssh exits non-zero with no stdout", () => { + stubSpawnSync("", 255, "connection refused"); + const { probeUserManagedFiles } = loadProbe(); + + expect(() => probeUserManagedFiles("alpha")).toThrow(/user-managed file probe failed/); + }); + + it("throws when sandbox has no SSH config and the agent declares user-managed files", () => { + stubSpawnSync("", 0); + const sandboxState = loadSandboxState(); + vi.spyOn(sandboxState, "getSshConfig").mockReturnValue(null); + const { probeUserManagedFiles } = loadProbe(); + + expect(() => probeUserManagedFiles("alpha")).toThrow( + /user-managed file probe failed: no SSH config/, + ); + expect(spawnSpy).not.toHaveBeenCalled(); + }); + + it("returns empty existing when sandbox has no SSH config but agent declares no user-managed files", () => { + stubSpawnSync("", 0); + const defs = loadDefs(); + vi.spyOn(defs, "loadAgent").mockImplementation(() => makeFakeAgent([])); + const sandboxState = loadSandboxState(); + vi.spyOn(sandboxState, "getSshConfig").mockReturnValue(null); + const { probeUserManagedFiles } = loadProbe(); + + const result = probeUserManagedFiles("alpha"); + expect(result.declared).toEqual([]); + expect(result.existing).toEqual([]); + expect(spawnSpy).not.toHaveBeenCalled(); + }); + + it("creates its temp SSH config inside the OS tmpdir", () => { + stubSpawnSync("", 0); + const { probeUserManagedFiles } = loadProbe(); + + probeUserManagedFiles("alpha"); + expect(tempSshFiles.size).toBeGreaterThan(0); + const tmpdir = path.resolve(os.tmpdir()); + for (const dir of tempSshFiles) { + const resolved = path.resolve(dir); + expect(resolved.startsWith(tmpdir + path.sep) || resolved === tmpdir).toBe(true); + } + }); + + it("shell-quotes unusual but permitted filenames safely", () => { + const defs = loadDefs(); + vi.spyOn(defs, "loadAgent").mockImplementation(() => makeFakeAgent(["user's.env", ".env"])); + stubSpawnSync("", 0); + const { probeUserManagedFiles } = loadProbe(); + + probeUserManagedFiles("alpha"); + const lastArgs = recordedArgs[0] ?? []; + const probeCmd = lastArgs[lastArgs.length - 1] ?? ""; + expect(probeCmd).toContain("'/sandbox/user'\\''s.env'"); + expect(probeCmd).toContain("'/sandbox/.env'"); + }); + + it("cleans up the temp SSH config on success", () => { + stubSpawnSync(".env\n", 0); + const { probeUserManagedFiles } = loadProbe(); + + probeUserManagedFiles("alpha"); + for (const dir of tempSshFiles) { + expect(fs.existsSync(dir)).toBe(false); + } + }); + + it("cleans up the temp SSH config on SSH failure", () => { + stubSpawnSync("", 255, "boom"); + const { probeUserManagedFiles } = loadProbe(); + + expect(() => probeUserManagedFiles("alpha")).toThrow(); + for (const dir of tempSshFiles) { + expect(fs.existsSync(dir)).toBe(false); + } + }); + + it("returns empty declared when the agent has no user_managed_files", () => { + const defs = loadDefs(); + vi.spyOn(defs, "loadAgent").mockImplementation(() => makeFakeAgent([])); + stubSpawnSync("", 0); + const { probeUserManagedFiles } = loadProbe(); + + const result = probeUserManagedFiles("alpha"); + expect(result.declared).toEqual([]); + expect(result.existing).toEqual([]); + }); +}); diff --git a/src/lib/state/user-managed-files-probe.ts b/src/lib/state/user-managed-files-probe.ts new file mode 100644 index 00000000000..f89ab8658e3 --- /dev/null +++ b/src/lib/state/user-managed-files-probe.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "child_process"; + +import { loadAgent } from "../agent/defs.js"; +import { shellQuote } from "../runner.js"; +import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; + +import * as registry from "./registry.js"; +import { getSshConfig, sshArgs } from "./sandbox.js"; + +export const USER_MANAGED_FILES_BASE = "/sandbox"; + +export interface UserManagedFilesProbe { + declared: string[]; + existing: string[]; +} + +const _verbose = (): boolean => process.env.NEMOCLAW_REBUILD_VERBOSE === "1"; + +function _log(msg: string): void { + if (_verbose()) console.error(` [user-managed-files-probe ${new Date().toISOString()}] ${msg}`); +} + +export function probeUserManagedFiles(sandboxName: string): UserManagedFilesProbe { + const sb = registry.getSandbox(sandboxName); + const agentName = sb?.agent || "openclaw"; + const agent = loadAgent(agentName); + const declared = Array.isArray(agent.userManagedFiles) ? [...agent.userManagedFiles] : []; + if (declared.length === 0) return { declared, existing: [] }; + + _log( + `sandbox=${sandboxName}, agent=${agentName}, declared=[${declared.join(",")}], base=${USER_MANAGED_FILES_BASE}`, + ); + + const sshConfig = getSshConfig(sandboxName); + if (!sshConfig) { + _log("no SSH config — cannot probe declared user-managed files"); + throw new Error( + "user-managed file probe failed: no SSH config available for sandbox " + sandboxName, + ); + } + + const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-umf-"); + const configFile = tempSshConfig.file; + try { + const probeCmd = + declared + .map( + (relPath) => + `if [ -f ${shellQuote(`${USER_MANAGED_FILES_BASE}/${relPath}`)} ]; then printf '%s\\n' ${shellQuote(relPath)}; fi`, + ) + .join("; ") + " 2>/dev/null"; + const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), probeCmd], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + }); + const stdout = (result.stdout || "").trim(); + if (result.status !== 0 && stdout.length === 0) { + _log( + `SSH probe failed: exit=${result.status}, stderr=${(result.stderr || "").trim().substring(0, 200)}`, + ); + throw new Error( + `user-managed file probe failed: ssh exit=${result.status}, stderr=${(result.stderr || "").trim().substring(0, 200)}`, + ); + } + const existing = stdout.split("\n").filter((line) => line.length > 0); + _log(`${existing.length}/${declared.length} present in sandbox`); + return { declared, existing }; + } finally { + tempSshConfig.cleanup(); + } +}