From 2f9cc68deeedbddf4d395799e5edea178877d27b Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Sat, 23 May 2026 20:52:07 +0800 Subject: [PATCH] fix(sandbox): auto-unlock shields during rebuild --- src/lib/actions/sandbox/rebuild.ts | 73 +++++ src/lib/shields/index.ts | 236 +++++++++------- test/rebuild-shields-auto-unlock.test.ts | 327 +++++++++++++++++++++++ 3 files changed, 540 insertions(+), 96 deletions(-) create mode 100644 test/rebuild-shields-auto-unlock.test.ts diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index d2ef0ffc762..33e6ad8f4d4 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -58,6 +58,7 @@ import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, } from "../../state/sandbox-session"; +import * as shields from "../../shields"; import { removeSandboxRegistryEntry } from "./destroy"; import { executeSandboxCommand } from "./process-recovery"; @@ -438,6 +439,65 @@ export async function rebuildSandbox( } } + // Step 1b: Auto-unlock shields if locked. Without this, the sandbox-state + // backup tar fails because high-risk state dirs are owned by root:root. + // We re-apply the lockdown after a successful rebuild (#3113). + const shieldsWereLocked = !shields.isShieldsDown(sandboxName); + if (shieldsWereLocked) { + console.log(""); + console.log(` ${YW}Shields are UP${R} — temporarily unlocking for rebuild backup...`); + try { + shields.shieldsDown(sandboxName, { + reason: "auto-unlock for rebuild", + skipTimer: true, + throwOnError: true, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(""); + console.error(` ${_RD}Failed to auto-unlock shields:${R} ${message}`); + console.error(" Sandbox is untouched — no data was lost."); + console.error( + ` Run \`${CLI_NAME} ${sandboxName} shields down\` manually, then retry rebuild.`, + ); + bail(`Failed to auto-unlock shields: ${message}`); + return; + } + } + + // Re-lock shields if we auto-unlocked. Idempotent across multiple call sites + // (success path + bail-before-destroy path). After destroy, the sandbox is + // gone — we surface manual recovery instructions instead. + let shieldsRelocked = false; + const relockShieldsIfNeeded = (sandboxStillExists: boolean): void => { + if (!shieldsWereLocked || shieldsRelocked) return; + if (!sandboxStillExists) { + console.warn(""); + console.warn( + ` ${YW}⚠${R} Cannot re-apply shields lockdown — sandbox no longer exists.`, + ); + console.warn( + ` After recovery, run \`${CLI_NAME} ${sandboxName} shields up\` to restore lockdown.`, + ); + return; + } + console.log(""); + console.log(" Re-applying shields lockdown..."); + try { + shields.shieldsUp(sandboxName, { throwOnError: true }); + console.log(` ${G}✓${R} Shields restored to UP`); + shieldsRelocked = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error( + ` ${YW}⚠${R} Failed to re-apply shields lockdown: ${message}`, + ); + console.error( + ` Run \`${CLI_NAME} ${sandboxName} shields up\` manually to restore lockdown.`, + ); + } + }; + // Step 2: Backup console.log(" Backing up sandbox state..."); log(`Agent type: ${sb.agent || "openclaw"}, stateDirs from manifest`); @@ -456,6 +516,7 @@ export async function rebuildSandbox( console.error(` Failed files: ${backup.failedFiles.join(", ")}`); } console.error(" Aborting rebuild to prevent data loss."); + relockShieldsIfNeeded(true); bail("Failed to back up sandbox state."); return; } @@ -463,6 +524,7 @@ export async function rebuildSandbox( if (!backupManifest) { console.error(" Failed to record backup metadata."); console.error(" Aborting rebuild to prevent data loss."); + relockShieldsIfNeeded(true); bail("Failed to record backup metadata."); return; } @@ -515,6 +577,7 @@ export async function rebuildSandbox( if (deleteResult.status !== 0 && !alreadyGone) { console.error(" Failed to delete sandbox. Aborting rebuild."); console.error(" State backup is preserved at: " + backupManifest.backupPath); + relockShieldsIfNeeded(true); bail("Failed to delete sandbox.", deleteResult.status || 1); return; } @@ -715,7 +778,12 @@ export async function rebuildSandbox( console.error( ` ${CLI_NAME} ${sandboxName} snapshot restore "${backupManifest.timestamp}"`, ); + if (shieldsWereLocked) { + console.error(` 4. Restore shields lockdown:`); + console.error(` ${CLI_NAME} ${sandboxName} shields up`); + } console.error(""); + relockShieldsIfNeeded(false); bail( `Recreate failed (sandbox destroyed). Backup: ${backupManifest.backupPath}`, onboardExitCode, @@ -855,6 +923,11 @@ export async function rebuildSandbox( }); log(`Registry updated: agentVersion=${agentDef.expectedVersion}`); + // Step 8: Re-apply shields lockdown if it was active before rebuild (#3113). + // Sandbox now exists and policy presets have been re-applied, so this is + // the correct point to restore the restrictive policy + lock config files. + relockShieldsIfNeeded(true); + console.log(""); if (restore.success) { console.log(` ${G}\u2713${R} Sandbox '${sandboxName}' rebuilt successfully`); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 051b27a430f..bae208d1fe2 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -910,10 +910,22 @@ interface ShieldsDownOpts { timeout?: string | null; reason?: string | null; policy?: string; + // Internal: skip the auto-restore timer fork. Used by `rebuild` which + // re-locks immediately after backup/recreate, so a detached timer racing + // with a destroyed-then-recreated sandbox is undesirable (#3113). + skipTimer?: boolean; + // Internal: throw on error instead of process.exit(1). Lets system-level + // operations (e.g., rebuild) recover instead of dying mid-flow. + throwOnError?: boolean; } function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { validateName(sandboxName, "sandbox name"); + const fail = opts.throwOnError + ? (msg: string) => { + throw new Error(msg); + } + : (_msg: string) => process.exit(1); const state = loadShieldsState(sandboxName); if (state.shieldsDown) { @@ -923,7 +935,8 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { console.error( " Run `nemoclaw shields up` first, or use --extend (not yet implemented).", ); - process.exit(1); + fail(`Config is already unlocked for ${sandboxName}`); + return; } // Kill stale auto-restore markers only when this command will actually @@ -951,7 +964,8 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { const policyYaml = parseCurrentPolicy(rawPolicy); if (!policyYaml) { console.error(" Cannot capture current policy. Is the sandbox running?"); - process.exit(1); + fail("Cannot capture current policy"); + return; } const ts = Date.now(); @@ -983,7 +997,8 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { console.error( ` Unknown policy "${policyName}". Use "permissive" or a path to a YAML file.`, ); - process.exit(1); + fail(`Unknown policy "${policyName}"`); + return; } console.log(` Applying ${policyName} policy...`); @@ -1013,7 +1028,8 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { console.error( ` Re-run \`nemoclaw ${sandboxName} shields down\` after correcting file ownership.`, ); - process.exit(1); + fail(message); + return; } // 3. Update state @@ -1027,92 +1043,100 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { shieldsPolicySnapshotPath: snapshotPath, }); - // 4. Start auto-restore timer (detached child process) + // 4. Start auto-restore timer (detached child process), unless skipped. // Pass the absolute restore time, not a relative timeout. Steps 1-2b // can take minutes (policy apply + kubectl chmod), so a relative timeout // passed at fork time would fire too early. - const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); - const processToken = randomBytes(16).toString("hex"); - const timerScript = path.join(__dirname, "timer.ts"); - const timerScriptJs = timerScript.replace(/\.ts$/, ".js"); - const actualScript = fs.existsSync(timerScriptJs) - ? timerScriptJs - : timerScript; + // + // skipTimer is used by `rebuild` (#3113), which re-locks via shieldsUp + // immediately after backup/recreate. Forking a detached timer in that + // flow risks racing the auto-restore against a destroyed-then-recreated + // sandbox. + if (!opts.skipTimer) { + const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); + const processToken = randomBytes(16).toString("hex"); + const timerScript = path.join(__dirname, "timer.ts"); + const timerScriptJs = timerScript.replace(/\.ts$/, ".js"); + const actualScript = fs.existsSync(timerScriptJs) + ? timerScriptJs + : timerScript; - try { - const child = fork( - actualScript, - [ - sandboxName, - snapshotPath, - restoreAt.toISOString(), - target.configPath, - target.configDir, - processToken, - ], - { - detached: true, - stdio: ["ignore", "ignore", "ignore", "ipc"], - }, - ); - child.disconnect(); - child.unref(); - - // Write timer marker - const markerPath = timerMarkerPath(sandboxName); - fs.writeFileSync( - markerPath, - JSON.stringify({ - pid: child.pid, - sandboxName, - snapshotPath, - restoreAt: restoreAt.toISOString(), - processToken, - }), - { mode: 0o600 }, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(` Cannot start auto-restore timer: ${message}`); - console.error(" Rolling back — restoring policy from snapshot..."); - const rollbackResult = run( - buildPolicySetCommand(snapshotPath, sandboxName), - { - ignoreError: true, - }, - ); - let rollbackLocked = false; - if (rollbackResult.status === 0) { - try { - lockAgentConfig(sandboxName, target); - rollbackLocked = true; - } catch { + try { + const child = fork( + actualScript, + [ + sandboxName, + snapshotPath, + restoreAt.toISOString(), + target.configPath, + target.configDir, + processToken, + ], + { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }, + ); + child.disconnect(); + child.unref(); + + // Write timer marker + const markerPath = timerMarkerPath(sandboxName); + fs.writeFileSync( + markerPath, + JSON.stringify({ + pid: child.pid, + sandboxName, + snapshotPath, + restoreAt: restoreAt.toISOString(), + processToken, + }), + { mode: 0o600 }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(` Cannot start auto-restore timer: ${message}`); + console.error(" Rolling back — restoring policy from snapshot..."); + const rollbackResult = run( + buildPolicySetCommand(snapshotPath, sandboxName), + { + ignoreError: true, + }, + ); + let rollbackLocked = false; + if (rollbackResult.status === 0) { + try { + lockAgentConfig(sandboxName, target); + rollbackLocked = true; + } catch { + console.error( + " Warning: Rollback re-lock could not be verified. Check config manually.", + ); + } + } else { + console.error(" Warning: Policy restore failed during rollback."); + } + if (rollbackLocked) { + saveShieldsState(sandboxName, { + shieldsDown: false, + shieldsDownAt: null, + shieldsDownTimeout: null, + shieldsDownReason: null, + shieldsDownPolicy: null, + }); + console.error(" Lockdown restored. Config was never left unguarded."); + } else { + // Leave state as shieldsDown: true — don't lie about protection level console.error( - " Warning: Rollback re-lock could not be verified. Check config manually.", + " Config remains unlocked — manual intervention required.", + ); + console.error( + ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, ); } - } else { - console.error(" Warning: Policy restore failed during rollback."); - } - if (rollbackLocked) { - saveShieldsState(sandboxName, { - shieldsDown: false, - shieldsDownAt: null, - shieldsDownTimeout: null, - shieldsDownReason: null, - shieldsDownPolicy: null, - }); - console.error(" Lockdown restored. Config was never left unguarded."); - } else { - // Leave state as shieldsDown: true — don't lie about protection level - console.error( - " Config remains unlocked — manual intervention required.", - ); - console.error( - ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, - ); + fail(`Cannot start auto-restore timer: ${message}`); + return; } - process.exit(1); } // 5. Audit log @@ -1127,16 +1151,22 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { }); // 6. Output - const mins = Math.floor(timeoutSeconds / 60); - const secs = timeoutSeconds % 60; - console.log( - ` Config unlocked for ${sandboxName} (auto-lockdown in: ${mins}m${secs ? ` ${secs}s` : ""})`, - ); - console.log(""); - console.log(" Sandbox is in default (mutable) state."); - console.log( - ` Run \`nemoclaw ${sandboxName} shields up\` to opt into lockdown.`, - ); + if (opts.skipTimer) { + console.log( + ` Config unlocked for ${sandboxName} (no auto-lockdown timer; caller will re-lock).`, + ); + } else { + const mins = Math.floor(timeoutSeconds / 60); + const secs = timeoutSeconds % 60; + console.log( + ` Config unlocked for ${sandboxName} (auto-lockdown in: ${mins}m${secs ? ` ${secs}s` : ""})`, + ); + console.log(""); + console.log(" Sandbox is in default (mutable) state."); + console.log( + ` Run \`nemoclaw ${sandboxName} shields up\` to opt into lockdown.`, + ); + } } // --------------------------------------------------------------------------- @@ -1146,8 +1176,19 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { // hardening step that restricts the sandbox beyond its default state. // --------------------------------------------------------------------------- -function shieldsUp(sandboxName: string): void { +interface ShieldsUpOpts { + // Internal: throw on error instead of process.exit(1). Lets system-level + // operations (e.g., rebuild) recover instead of dying mid-flow (#3113). + throwOnError?: boolean; +} + +function shieldsUp(sandboxName: string, opts: ShieldsUpOpts = {}): void { validateName(sandboxName, "sandbox name"); + const fail = opts.throwOnError + ? (msg: string) => { + throw new Error(msg); + } + : (_msg: string) => process.exit(1); const state = loadShieldsState(sandboxName); // shieldsDown === false means explicitly locked by a previous shields-up. @@ -1174,7 +1215,8 @@ function shieldsUp(sandboxName: string): void { console.error( " Sandbox remains unlocked; recapture shields-down state before running shields up.", ); - process.exit(1); + fail("Saved policy snapshot is missing"); + return; } if (snapshotPath) { console.log(" Restoring restrictive policy from snapshot..."); @@ -1187,7 +1229,8 @@ function shieldsUp(sandboxName: string): void { console.error( ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, ); - process.exit(1); + fail(activation.error ?? "unknown restore error"); + return; } } else { // 2b. Lock config file to read-only. @@ -1209,7 +1252,8 @@ function shieldsUp(sandboxName: string): void { console.error( ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, ); - process.exit(1); + fail(message); + return; } } diff --git a/test/rebuild-shields-auto-unlock.test.ts b/test/rebuild-shields-auto-unlock.test.ts new file mode 100644 index 00000000000..36a41d80d55 --- /dev/null +++ b/test/rebuild-shields-auto-unlock.test.ts @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tests for issue #3113: rebuild should auto-unlock when shields are UP. + * + * When the user has opted into shields-up (lockdown), rebuild used to abort + * at the backup step because state dirs are root-owned. Rebuild must + * temporarily unlock for backup, complete the rebuild, then re-lock. + */ + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const NODE_BIN = path.dirname(process.execPath); +const tmpFixtures: string[] = []; + +afterEach(() => { + for (const dir of tmpFixtures.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* */ + } + } +}); + +function createFixture(opts: { shieldsLocked: boolean }) { + const sandboxName = "my-assistant"; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-3113-")); + tmpFixtures.push(tmpDir); + const nemoclawDir = path.join(tmpDir, ".nemoclaw"); + fs.mkdirSync(nemoclawDir, { recursive: true, mode: 0o700 }); + const stateDir = path.join(nemoclawDir, "state"); + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const usageNotice = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "bin", "lib", "usage-notice.json"), "utf-8"), + ); + fs.writeFileSync( + path.join(nemoclawDir, "usage-notice.json"), + JSON.stringify( + { + acceptedVersion: usageNotice.version, + acceptedAt: "2026-01-01T00:00:00.000Z", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + // Pre-write a saved policy snapshot so the relock path can find it. + const snapshotPath = path.join(stateDir, "policy-snapshot-prior.yaml"); + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n", { mode: 0o600 }); + + if (opts.shieldsLocked) { + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify( + { + shieldsDown: false, + shieldsDownAt: null, + shieldsDownTimeout: null, + shieldsDownReason: null, + shieldsDownPolicy: null, + shieldsPolicySnapshotPath: snapshotPath, + updatedAt: new Date().toISOString(), + }, + null, + 2, + ), + { mode: 0o600 }, + ); + } + + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "meta/llama-3.3-70b-instruct", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + agent: null, + messagingChannels: null, + }, + }, + }), + { mode: 0o600 }, + ); + + fs.writeFileSync( + path.join(nemoclawDir, "credentials.json"), + JSON.stringify({ NVIDIA_API_KEY: "nvapi-test" }), + { mode: 0o600 }, + ); + + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify({ + version: 1, + sessionId: "s", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-01-01", + updatedAt: "2026-01-01", + lastStepStarted: null, + lastCompletedStep: "policies", + failure: null, + agent: null, + sandboxName, + provider: "nvidia-prod", + model: "meta/llama-3.3-70b-instruct", + endpointUrl: null, + credentialEnv: "NVIDIA_API_KEY", + preferredInferenceApi: null, + nimContainer: null, + webSearchConfig: null, + policyPresets: [], + messagingChannels: null, + metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + agent_setup: { status: "pending", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }), + { mode: 0o600 }, + ); + + // Workspace dir for the backup tar + const fakeRoot = path.join(tmpDir, "fake-sandbox-root"); + fs.mkdirSync(path.join(fakeRoot, "workspace"), { recursive: true }); + fs.writeFileSync(path.join(fakeRoot, "workspace", "marker.txt"), "test"); + const lockStatePath = path.join(tmpDir, "config-lock-state.txt"); + fs.writeFileSync(lockStatePath, opts.shieldsLocked ? "locked" : "unlocked"); + + // Fake openshell — also returns a parseable YAML policy for the + // shields-down policy snapshot capture path. + const sshConfig = [ + `Host openshell-${sandboxName}`, + " HostName 127.0.0.1", + " Port 2222", + " User sandbox", + " StrictHostKeyChecking no", + " UserKnownHostsFile /dev/null", + ].join("\\n"); + fs.writeFileSync( + path.join(tmpDir, "openshell"), + `#!/usr/bin/env node +const a = process.argv.slice(2); +if (a[0]==="sandbox" && a[1]==="list") { process.stdout.write("${sandboxName}\\n"); process.exit(0); } +if (a[0]==="sandbox" && a[1]==="ssh-config") { process.stdout.write("${sshConfig}\\n"); process.exit(0); } +if (a[0]==="sandbox" && a[1]==="delete") { process.exit(0); } +if (a[0]==="policy" && a[1]==="get") { process.stdout.write("version: 1\\nnetwork_policies:\\n test: {}\\n"); process.exit(0); } +if (a[0]==="policy" && a[1]==="set") { process.exit(0); } +if (a[0]==="status") { process.stdout.write("running\\n"); process.exit(0); } +if (a[0]==="gateway" && a[1]==="info") { process.stdout.write("nemoclaw\\n"); process.exit(0); } +if (a[0]==="gateway" && a[1]==="select") { process.exit(0); } +if (a[0]==="inference" && a[1]==="get") { process.stdout.write('{"provider":"nvidia-prod","model":"meta/llama-3.3-70b-instruct"}\\n'); process.exit(0); } +if (a[0]==="inference" && a[1]==="set") { process.exit(0); } +if (a[0]==="provider") { process.exit(0); } +if (a[0]==="forward") { process.exit(0); } +process.exit(0); +`, + { mode: 0o755 }, + ); + + // Fake docker — covers both the basic cases and kubectl exec proxying. + // For shields lock/unlock, we return zero exit with the data shields.ts + // verification expects (stat for unlock returns sandbox:sandbox 660/2770). + fs.writeFileSync( + path.join(tmpDir, "docker"), + `#!/usr/bin/env node +const fs = require("fs"); +const a = process.argv.slice(2); +const lockStatePath = ${JSON.stringify(lockStatePath)}; +function readLockState() { + try { return fs.readFileSync(lockStatePath, "utf8").trim(); } catch { return "unlocked"; } +} +function writeLockState(state) { + fs.writeFileSync(lockStatePath, state); +} +if (a[0]==="build") { process.exit(0); } +if (a[0]==="image" && a[1]==="inspect") { process.exit(0); } +if (a[0]==="inspect") { process.stdout.write("true\\n"); process.exit(0); } +if (a[0]==="ps") { process.exit(0); } +// kubectl exec proxy via "docker exec kubectl exec -n openshell -c agent -- " +if (a[0]==="exec") { + // Find the kubectl '--' delimiter; everything after is the actual command. + const dashDash = a.indexOf("--"); + const cmd = dashDash >= 0 ? a.slice(dashDash + 1) : []; + // Verification reads: + // stat -c '%a %U:%G' → expect "660 sandbox:sandbox" or "2770 sandbox:sandbox" + // lsattr -d → "----i------" (locked) or no immutable bit (unlocked) + // shields-up lock verification expects: + // stat → "444 root:root" / "755 root:root" + // lsattr → "----i------" + // We are testing the auto-unlock path: shields-down is called on a locked sandbox, + // verification should look like 660 sandbox:sandbox / 2770 sandbox:sandbox. + if (cmd[0]==="chattr" && cmd[1]==="-i") { writeLockState("unlocked"); process.exit(0); } + if (cmd[0]==="chattr" && cmd[1]==="+i") { writeLockState("locked"); process.exit(0); } + if (cmd[0]==="chown" && cmd[1]==="sandbox:sandbox") { writeLockState("unlocked"); process.exit(0); } + if (cmd[0]==="chown" && cmd[1]==="root:root") { writeLockState("locked"); process.exit(0); } + if (cmd[0]==="chmod" && (cmd[1]==="660" || cmd[1]==="2770")) { writeLockState("unlocked"); process.exit(0); } + if (cmd[0]==="chmod" && cmd[1]==="444") { writeLockState("locked"); process.exit(0); } + if (cmd[0]==="stat") { + const target = cmd[cmd.length-1]; + const locked = readLockState() === "locked"; + // Heuristic: directories tend to end with .openclaw or have no extension + if (target.endsWith(".openclaw") || target.endsWith(".hermes") || /\\/(workspace|skills|hooks|cron|agents|extensions|plugins|memory|credentials|identity|devices|canvas|telegram)$/.test(target)) { + process.stdout.write(locked ? "755 root:root\\n" : "2770 sandbox:sandbox\\n"); + } else { + process.stdout.write(locked ? "444 root:root\\n" : "660 sandbox:sandbox\\n"); + } + process.exit(0); + } + if (cmd[0]==="lsattr") { + const flags = readLockState() === "locked" ? "----i----------" : "---------------"; + process.stdout.write(flags + " " + cmd[cmd.length-1] + "\\n"); + process.exit(0); + } + if (cmd[0]==="chattr" || cmd[0]==="chown" || cmd[0]==="chmod" || cmd[0]==="sh" || cmd[0]==="find") { process.exit(0); } + process.exit(0); +} +process.exit(0); +`, + { mode: 0o755 }, + ); + + // Fake ssh — backup tars from the real fakeRoot + fs.writeFileSync( + path.join(tmpDir, "ssh"), + `#!/usr/bin/env node +const cmd = process.argv[process.argv.length - 1] || ""; +if (cmd.includes("[ -d")) { + process.stdout.write("workspace\\n"); + process.exit(0); +} +if (cmd.includes("tar")) { + const { spawnSync } = require("child_process"); + const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(fakeRoot)}, "workspace"], { + stdio: ["ignore", "pipe", "pipe"], + }); + if (r.stdout) process.stdout.write(r.stdout); + process.exit(r.status || 0); +} +if (cmd.includes("rm -rf")) { process.exit(0); } +if (cmd.includes("chown")) { process.exit(0); } +process.exit(0); +`, + { mode: 0o755 }, + ); + + return { tmpDir, nemoclawDir, sandboxName, snapshotPath }; +} + +function runRebuild(fixture: ReturnType) { + return spawnSync( + process.execPath, + [path.join(REPO_ROOT, "bin", "nemoclaw.js"), fixture.sandboxName, "rebuild", "--yes"], + { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + HOME: fixture.tmpDir, + PATH: fixture.tmpDir + ":" + NODE_BIN + ":/usr/bin:/bin", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_NO_CONNECT_HINT: "1", + NO_COLOR: "1", + }, + timeout: 30_000, + }, + ); +} + +describe("Issue #3113: rebuild auto-unlocks when shields are UP", () => { + it( + "detects locked shields and prints auto-unlock notice", + { timeout: 60_000 }, + () => { + const f = createFixture({ shieldsLocked: true }); + const r = runRebuild(f); + const output = (r.stdout || "") + (r.stderr || ""); + + // Without the fix this would be: + // "Failed to back up sandbox state. Aborting rebuild to prevent data loss." + expect(output).not.toContain("Aborting rebuild to prevent data loss"); + // With the fix, rebuild detects shields-up and unlocks before backup. + expect(output).toContain("Shields are UP"); + expect(output).toContain("temporarily unlocking for rebuild backup"); + // Shields-down was invoked programmatically (no permissive policy printout + // is required to assert; we just verify the snapshot capture step ran). + expect(output).toContain("Capturing current policy snapshot"); + // Backup proceeds. + expect(output).toContain("Backing up sandbox state"); + }, + ); + + it( + "skips auto-unlock when shields are not configured", + { timeout: 60_000 }, + () => { + const f = createFixture({ shieldsLocked: false }); + const r = runRebuild(f); + const output = (r.stdout || "") + (r.stderr || ""); + + expect(output).not.toContain("Shields are UP"); + expect(output).not.toContain("temporarily unlocking for rebuild backup"); + expect(output).toContain("Backing up sandbox state"); + }, + ); +});